아이템 45. 스트림은 주의해서 사용하라. - ksw6169/effective-java GitHub Wiki

스트림이란?

  • 스트림 API는 다량의 데이터 처리 작업을 돕고자 자바 8에 추가되었다.
  • 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
  • 스트림 파이프라인은 스트림의 원소들로 수행하는 연산 단계를 표현하는 개념이다.
  • 스트림의 원소들은 어디로부터든 올 수 있다. (컬렉션, 배열, 파일, 정규표현식 패턴 매처 등)
  • 스트림 안의 데이터 원소들은 객체 참조나 기본 타입(int, long, double) 값이다.

스트림 파이프라인

  • 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있다.
  • 스트림 파이프라인은 지연 평가(lazy evaluation) 된다. 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
  • 스트림 API는 메소드 연쇄를 지원하는 플루언트 API(fluent API)다. 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다.
  • 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.
  • 기본적으로 스트림 파이프라인은 순차적으로 수행된다.
  • 파이프라인을 병렬로 수행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메소드를 호출해주기만 하면 되나, 효과를 볼 수 있는 상황은 많지 않다.
  • 스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.

중간 연산(intermediate operation)

  • 각 중간 연산은 스트림을 다른 스트림으로 변환(transform) 한다.
  • 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같거나 다를 수 있다.

종단 연산(terminal operation)

  • 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가한다.
  • 원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력하는 식이다.

스트림 예제 - 아나그램

다음 예제는 사전 파일에서 단어를 읽어 사용자가 지정한 값보다 원소 수가 많은 아나그램(anagram) 그룹을 출력하는 예제다. 여기서 아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말한다.

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();

                /**
                 * computeIfAbsent()
                 * Map 안에 키가 있으면 키에 매핑된 값을 반환한다. (여기서는 TreeSet을 반환)
                 * Map 안에 키가 없으면 새로운 key와 value(mappingFunction 람다 함수를 실행한 결과)를 값으로 저장한다.
                 */
                groups.computeIfAbsent(alphabetize(word),
                        (unused) -> new TreeSet<>()).add(word);
            }
        }

        // 사용자가 지정한 minGroupSize 이상일 경우만 출력
        for (Set<String> group : groups.values()) {
            if (groups.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);
    }
}

다음 코드는 앞의 코드와 같은 일을 하지만 스트림을 과하게 활용하는 코드다. 스트림을 과용하면 프로그램의 가독성이 안좋아지고 유지보수가 어려워진다.

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(
                    Collectors.groupingBy(word -> word.chars().sorted().collect(
                            StringBuilder::new, (sb, c) -> sb.append((char) c), StringBuilder::append).toString())
            ).values().stream()
                    .filter(group -> group.size() >= minGroupSize)
                    .map(group -> group.size() + ": " + group)
                    .forEach(System.out::println);
        }
    }
}

다음 코드는 스트림을 적절히 사용하도록 수정한 코드다. 그 결과 원래 코드보다 짧을 뿐 아니라 명확하기까지 하다.

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(Collectors.groupingBy(word -> alphabetize(word)))
                    .values().stream().filter(group -> group.size() >= minGroupSize)
                    .forEach(g -> System.out.println(g.size() + ": " + g));
        }
    }

    // 알파벳 순으로 정렬
    private static String alphabetize(String s) {
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}

람다 매개변수의 이름은 주의해서 정해야 한다.

  • 위 코드에서 g라고 쓴 부분은 지면 관계상 줄인 것으로 group 이라고 명시하는 게 좋다. 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
  • 한편, 단어의 철자를 알파벳순으로 정렬하는 일은 별도 메소드인 alphabetize에서 수행했다. 연산에 적절한 이름을 지어주고 세부 구현을 주 프로그램 로직 밖으로 빼내 전체적인 가독성을 높인 것이다. 도우미 메소드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다는 스트림 파이프라인에서 훨씬 크다. 파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 때문이다.

모든 반복문을 스트림으로 바꾸지 말라.

  • 스트림으로 바꾸는 게 가능할지라도 코드 가독성과 유지보수 측면에서는 손해를 볼 수 있다.
  • 중간 정도 복잡한 작업에도 스트림과 반복문을 적절히 조합하는 게 최선이다.
  • 그러니 기존 코드는 스트림을 사용하도록 리팩토링하되, 새 코드가 더 나아보일 때만 반영하자.

스트림 대신 반복문을 사용해야 하는 상황

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

스트림을 사용하기 좋은 상황

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

스트림으로 처리하기 어려운 상황

  • 한 데이터가 파이프라인의 여러 단계(stage)를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근해야 하는 상황이다. 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문에 이 경우에는 스트림으로 처리하기가 어렵다.

평탄화(flattening)

아래 코드 중 스트림을 사용한 코드에서 중간 연산으로 사용한 flatMap은 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음 그 스트림들을 다시 하나의 스트림으로 합친다. 이를 평탄화(flattening) 라고도 한다.

private static List<Card> newDeck() {
    List<Card> result = new ArrayList<>();
    for (Suit suit : Suit.values())
        for (Rank rank : Rank.values())
            result.add(new Card(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(Collectors.toList());
}

핵심 정리

  • 스트림으로 처리할 수 있는 일이 있고, 반복 방식이 더 알맞은 일도 있다.
  • 어느 쪽으로 구현할 것인지에 대해서는 확고부동한 규칙이 없지만 참고할 만한 지침은 있다.
  • 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.

참고 자료

  • Effective Java 3/E
⚠️ **GitHub.com Fallback** ⚠️