본문 바로가기

개발/이펙티브 자바

Effective Java ( 이펙티브 자바 ) - 아이템 45

스트림은 주의해서 사용하라


스트림은 다량의 데이터 처리 작업을 돕고자 자바8에 추가되었다.  스트림의 추상 개념 중 핵심은 두 가지다. 

  • 스트림은 데이터 원소의 유한 혹은 무한 시퀸스를 뜻한다.
  • 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다. 

  스트림의 원소들은 어디로부터든 올 수 있다. 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이다. int, long, double 이렇게 세 가지를 지원한다. 

  스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있다.

  • 각 중간 연산은 스트림을 어떤 방식으로 변환한다.  중간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데, 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고 다를 수도 있다. 
  • 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가한다. 정렬해 컬렉션에 담거나, 원소 하나를 선택하거나, 모든 원소를 출력하는 식이다.

스트림 파이프라인은 지연 평가된다. 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 종단 연산이 없는 스트릠 파이프라인은 아무 일도 하지 않으니, 종단 연산을 빼먹는 일이 절대 없도록 한다.

 

  기본적으로 스트림 파이프라인은 순차적으로 수행된다. 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 되나, 효과를 볼 수 있는 상황은 많지 않다. (아이템 48)

 

  스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다. 

  다음 코드를 보자. 이 프로그램은 사전 파일에서 사용자가 지정한 값보다 원소 수가 많은 아나그램 그룹을 출력한다. 사전 파일에서 단어를 읽어 맵에 저장한다. 키는 그 단어를 구성하는 철자들을 알파벳순으로 정렬한 값이다. 

public class Anagrams {
   public static void main(String[] args) throws IOException {
       File dictionary = new File(args[0]);
       int minGroupSize = Integer.parseInt(args[1]);
       
       Map<String, Set<String>> groups = new HashMap<>();
       try (Scanner s = new Scanner(dictionary)) {
          while (s.hasNext()) {
             String word = s.next();
             
             groups.computeIfAbsent(alphabetize(word),
                ( unused) -> new TreeSet<>()).add(word);
          }
       }
          
       for (Set<String group : groups.values())
          if (group.size() >= minGroupSize)
             System.out.println(group.size() + ": " + group)
    }
       
    private static String alphabetize(String s) {
       char [] a = s.toCharArray();
       Arrays.sort(a);
       return new String(a);
    }
}

코드 45-2는 스트림을 과용해서 프로그램이 읽거나 유지보수 하기 어려운 케이스를 보여줬다. 적당히 사용해야 한다.

 

public class Anagrams {
   public static void main(String [] args) throws IOException {
      Path dictionary = Paths.get(args[0]);
      int minGroupSize = Integer.parseInt(args[1]);
      
      try (Stream<String> words = Files.lines(dictionary)) {
         words.collect(groupingBy(word -> alphabetize(word)))
            .values().stream()
            .filter(group -> group.size() >= minGroupSize)
            .forEach(g -> System.out.println(g.size() + ": " + g));
      }
   }
}

이 스트림의 파이프라인에는 중간 연산은 없으며, 종단 연산에서는 모든 단어를 수집해 맵으로 모은다. 

람다 매개변수의 이름은 주의해서 정해야 한다. 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다

 

alphabetize 메서드도 스트림을 사용해 다르게 구현할 수 있다. 하지만 그렇게하면 명확성이 떨어지고 잘못 구현할 가능성이 커진다. 심지어 느려질 수도 있다. 자바가 기본 타입인 char용 스트림을 지원하지 않기 때문이다. 

"Hello World!".chars().forEach(System.out.print);

는 721011081081113211911111410810033 을 출력한다. 스트림의 원소는 char가 아닌 int가 chars()에서 반환되기 때문이다. 올바른 print 메서드를 호출하게 하려면 다음처럼 형변환을 명시적으로 해줘야 한다.

"Hello world!".chars().forEach(x -> System.out.print((char) x));

하지만 char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.

 

스트림을 처음 쓰기 시작하면 모든 반복문을 스트림으로 바꾸고 싶은 유혹이 일겠지만, 서두르지 않는게 좋자. 코드 가독성과 유지보수 측면에서는 손해를 볼 수 있다. 그러니 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하자. 

 

스트림 파이프라인은 함수 객체로 계산을 표현한다. 반면 반복 코드에서는 코드 블록을 사용해 표현한다. 코드 블록으로만 할 수 있는 일들이 있다.

  • 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다. 하지만 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능하다.
  • 코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나, break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다. 하지만 람다로는 이 중 어떤 것도 할 수 없다.

반대로 다음 일들에는 스트림이 아주 안성맞춤이다.

  • 원소들의 시퀸스를 일관되게 변환한다.
  • 원소들의 시퀸스를 필터링한다.
  • 원소들의 시퀸스를 하나의 연산을 사용해 결합한다(더하기, 연결하기, 최솟값 구하기 등)
  • 원소들의 시퀸스를 컬렉션에 모은다
  • 원소들의 시퀸스에서 특정 조건을 만족하는 원소를 찾는다.

한편, 스트림으로 처리하기 어려운 일도 있다. 대표적인 예로, 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우다. 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문이다.

 

예를 들어 처음 20개의 메르센 소수를 출력하는 프로그램을 작성해보자. 다음 코드는 무한 스트림을 반환하는 메서드다.

 

static Stream<BigInteger> primes() {
   return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

primes() 처럼, 스트림을 반환하는 메서드 이름은 복수 명사로 쓰면 스트림 파이프라인의 가독성이 크게 좋아진다. iterate 정적 팩터리는 첫 번째 매개변수로 스트림의 첫 번째 원소를, 두 번째 매개변수로 스트림에서 다음 원소를 생성해주는 함수를 받는다. 

 

public static void main(String[] args) {
   primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
      .filter(mersenne -> mersenne.isProbablePrime(50))
      .limit(20)
      .forEach(System.out::println);
}

여기에 우리가 각 메르센 소수의 앞에 지수(p)를 출력하길 원한다고 해보자. 이 값은 초기 스트림에만 나타나므로 결과를 출력하는 종단 연산에서는 접근할 수 없다. 하지만 다행히 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해 메르센 수의 지수를 쉽게 계산해낼 수 있다.  지수는 단순히 숫자를 이진수로 표현한 다음 몇 비트인지를 세면 나오므로, 아래와 같이 종단 연산을 바꾼다.

.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));

 

 

스트림과 반복 중 어느 쪽을 써야 할지 바로 알기 어려운 작업도 많다. 카드 덱을 초기화하는 작업을 생각해보자. 이 작업은 숫자와 무늬의, 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 문제다. 데카르트 곱이라고 부른다.

private static List<Card> newDeck() {
   List<Card> result = new ArrayList<>();
   for (Suit suit : Suit.values())
      for (Rank rank : Rank.values())
         result.add(new Cord(suit, rank));
   return result;
}

 

private static List<Card> newDeck() {
   return Stream.of(Suit.values())
      .flatMap(suit ->
         Stream.of(Rank.values())
            .map(rank -> new Card(suit, rank)))
      .collect(toList());
}

결국은 개인 취향과 프로그래밍 환경의 문제다.  스트림 방식이 나아 보이고, 동료들도 스트림 코드를 이해할 수 있고 선호한다면 스트림 방식을 사용하자.

 

스트림을 사용해야 멋지게 처리할 수 있는 일이 있고 반복 방식이 더 알맞은 일도 있다. 이 둘을 조합했을 때 가장 멋지게 해결된다. 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.