JAVA/Effective Java

[아이템 45] 스트림은 주의해서 사용하라

꾸준함. 2024. 3. 2. 23:32

스트림 API

 

1. 개념

 

  • 다량의 데이터 처리 작업을 돕고자 자바 8+ 버전부터 추가된 기능
  • 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻함
  • 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념
  • 스트림의 원소들은 어디로부터든 올 수 있으며 대표적으로 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기, 혹은 다른 스트림이 있음
  • 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값
    • 기본 타입 값은 int, long, double 지원

 

  • 스트림 API는 메서드 연쇄를 지원하는 fluent API
    • 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성 가능
    • 파아프라인 여러 개를 연결해 표현식 하나로 만들 수 있음

 

  • 기본적으로 스트림 파이프라인은 순차적으로 수행
    • 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출
    • parallel을 사용한다고 하더라도 효과를 볼 수 있는 상황은 많지 않음

 

2. 스트림 API를 적용해야 하는 케이스

 

스트림 API는 다재다능하기 때문에 사실상 어떠한 계산이라도 해낼 수 있습니다.

하지만 할 수 있다는 뜻이지, 반드시 해야 한다는 뜻이 아닙니다.

스트림을 제대로 사용하면 프로그램이 짧고 간결해지지만 잘 못 사용할 경우 가독성이 저하되기 때문에 유지보수가 힘들어집니다.

스트림을 사용해야 하는 확고한 규칙은 없지만 아래에 소개할 anagram 코드들을 통해 참고할 수 있는 노하우가 있습니다.

  • 스트림 API 없이 구현한 코드
  • 스트림 API를 과하게 사용한 코드
  • 스트림 API를 적절히 활용한 코드

 

해당 코드들은 모두 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 anagram 그룹을 출력하는 프로그램입니다.

 

2.1 스트림 API 없이 구현한 코드

 

 

 

 

2.1 코드 부연 설명

  • 자바 8에 추가된 computeIfAbsent 메서드 사용하여 키에 다수의 값을 매핑하는 맵을 쉽게 구현
    • key 값이 존재하는 경우 Map 안에 있는 value 반환
    • key 값이 존재하지 않는 경우 Map에 {새로운 key, 람다 함수 실행 결과인 value} 반환

 

2.2 스트림 API를 과하게 사용한 코드


 

2.2 코드 부연 설명

  • 2.1보다 짧아졌지만 가독성 측면에서는 좋지 않은 코드
  • 스트림에 익숙하지 않고 반복문에만 익숙한 개발자에게는 더욱 읽기 어려운 코드

 

2.3 스트림 API를 적절히 활용한 코드


 

2.3 코드 부연 설명

  • 스트림을 적절히 활용하여 가독성이 좋아짐
  • 람다의 매개변수명을 잘 지어야 파이프라인의 가독성이 유지됨
    • 컴파일러가 타입 추론을 못하지 않는 이상 람다에서 타입명을 생략하기 때문
    • ex) 9번째 줄에서 forEach() 내 group을 g로 바꿀 경우 가독성이 저하됨

 

  • 스트림 파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 때문에 도우미 메서드 적절히 활용 필요
    • ex) 7번째 줄의 alphabetize 메서드는 단어의 철자를 알파벳 순으로 정렬하는 메서드
    • alphabetize 메서드를 스트림으로 구현했다면 명확성이 떨어지고 잘 못 구현됐을 가능성이 큼
      • 앞서 언급했다시피 기본 타입은 int, long, double만 지원하고 char용 스트림을 지원하지 않기 때문
      • 따라서 char 값들을 처리할 때는 스트림을 삼가는 것을 권장

 

2.4 결론

스트림을 처음 쓰기 시작하면 모든 반복문을 스트림으로 바꾸고 싶은 유혹이 생기겠지만 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하는 것을 권장합니다.

 

스트림 vs 반복문

  • 스트림 파이프라인은 되풀이되는 계산을 람다나 메서드 참조와 같은 함수 객체로 표현하는 반면
  • 반복문에서는 코드 블록을 사용해 표현

 

1. 스트림으로 처리하면 좋은 경우

 

  • 원소들의 시퀀스를 일관되게 변환 (map)
  • 원소들의 시퀀스를 필터링 (filter)
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합 (reduce)
  • 원소들의 시퀀스를 컬렉션에 모으기 (collect(Collections.toList()))
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소 찾기 (filter)

 

2. 스트림으로 처리하는 것을 권장하지 않는 경우

 

  • 파이프라인의 여러 단계에서의 값들에 동시 접근하는 경우
    • 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조
    • 원래 값과 새로운 값의 쌍을 저장하는 Map 객체를 사용해 매핑하는 우회 방법도 있지만 코드가 지저분해지기 때문에 스트림을 쓰는 주목적에서 벗어남
    • 가능하다면 앞 단계의 값이 필요할 때 매핑을 거꾸로 수행하는 방법을 권장 (메르센 소수 출력하는 프로그램 참고)

 

2.1 메르센 소수 출력하는 프로그램


 

코드 부연 설명

  • 메르센 소수는 소수 중 2^n - 1 형태
  • 메서드명 primes는 스트림의 원소가 소수임을 말해줌
    • 해당 메서드가 이용하는 Stream.iterate라는 정적 팩토리는 매개변수 2개를 받음
    • 첫 번째 매개변수는 스트림의 첫 번째 원소
    • 두 번째 매개변수는 스트림에서 다음 원소를 생성해주는 함수

 

  • 소수들을 사용해 메르센 수를 계산하고, 결괏값이 소수인 경우만 남긴 다음 결과 스트림의 원소 수를 20개로 제한
  • 각 메르센 소수의 앞에 지수(n)을 출력하기 원한다고 가정
    • n은 초기 스트림에만 나타나므로 결과를 출력하는 종단 연산에서는 접근 불가
    • 하지만 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해 메르센 수의 지수 계산 가능
    • 지수는 단순히 숫자를 이진수로 표현한 다음 몇 비트인지를 세면 나옴

 

3. 코드 블록으로만 처리 가능한 경우

 

  • return 문을 사용해 메서드에서 빠져나가는 경우
  • break나 continue문을 통해 블록 바깥의 반복문을 종료하거나 건너뛰는 경우
  • 메서드 선언에 명시된 검사 예외를 던질 수 있음 (throw)
  • 범위 안의 지역변수를 읽고 수정
    • 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고 지역 변수를 수정하는 것은 불가능

 

정리

스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞은 일도 있습니다.

스트림과 반복문 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 작성해보고 더 낫다고 판단되는 코드를 작성하는 것을 권장합니다.

 

참고

이펙티브 자바

반응형