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

2023. 5. 17. 08:46Java/Effective Java

스트림 API

  • 스트림 API는 다량의 데이터 처리 작업(순차적이든 병렬적이든)을 돕고자 자바8에 추가되었다.
  • 이 API가 제공하는 추상 개념 중 핵심은 두 가지다.
    • 스트림(Stream)은 데이터 원소의 유한 혹은 무한 시퀀스(sequence)를 의미
    • 스트림 파이프라인(Stream Pipeline)은 이 원소들로 수행하는 연산단계를 표현하는 개념
  • 스트림 안의 데이터 원소들은 객체 참조(reference)나 기본 타입(int, long, double)을 지원
  • 메서드 연쇄를 지원하는 플루언트 API다.
    • 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다.
    • 파이프라인 여러 개를 연결해 표현식 하나로 만들 수 있다.

 

 

스트림 파이프라인

  • 소스 트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있다.
  • 각 중간 연산은 스트림을 어떠한 방식으로 변환한다.
  • 스트림 파이프라인은 지연 평가 된다.
    • 평가는 종단 연산이 호출될 때 이루어지며, 종단 연산에 쓰이지 않은 데이터 원소는 계산에 쓰이지 않는다
    • 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠

 

 

Stream 예제 - 아나그램(anagram)

1) 일반 loop를 이용한 코드

public static void main(String[] args) {
    File dectionary = new File(args[0]);
    int minGroupSize = Integer.parseInt(args[1]);

    Map<String, Set<String>> groups = new HashMap<>();
    try (Scanner s = new Scanner(dectionary)) {
        while(s.hasNext()) {
            String word = s.next();
            groups.computeIfAbsent(alphabetize(word), 
                                   (unused) -> new TreeSet<>()).add(word);
        }
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }
    
    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);
}

 

2) Stream을 과도하게 쓴 코드

  • 스트림을 과용하면 프로그램이 읽거나 유지보수 하기 어려워진다.
public static void main(String[] args) throws IOException {
    File dectionary = new File(args[0]);
    int minGroupSize = Integer.parseInt(args[1]);

    try(Stream<String> words = Files.lines(dectionary.toPath())) {
        words.collect(
            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);
    }
}

 

3) Stream을 적절히 활용한 코드

  • 스트림을 적절하게 사용하면 명료해진다.
  • 람다에서는 타입 추론 기능을 사용하기 때문에 주로 타입 이름을 생략한다. 따라서 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
  • 연산에 적절한 이름을 지어 주고 도우미 메서드를 적정히 활용하는 것은 일반 반복코드보다 스트림 파이프라인에서 훨씬 크다
public static void main(String[] args) throws IOException {
    File dectionary = new File(args[0]);
    int minGroupSize = Integer.parseInt(args[1]);

    try(Stream<String> words = Files.lines(dectionary.toPath())) {
        words.collect(groupingBy(word -> alphabetize(word)))
            .values().stream()
            .filter(group -> group.size() >= minGroupSize)
            .forEach(group -> System.out.println(group.size() + ": " + group));
    }
}

 

 

코드 블럭 vs 람다

  • 코드블럭에서는 범위 안의 지역변수를 읽고 수정할 수 있다.
  • 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있다. 지역 변수를 수정하는 것은 불가능하다.
  • 코드 블럭에서는 return을 이용해 메서드를 빠져나가 거나 break, continue문을 통해 블럭 바깥의 반복문을 종료 할 수 있다.
  • 메서드 선언에 명시된 예외(Exception)을 던질 수 있다.
  • 람다로는 모든 것이 불가능하다.

 

 

스트림이 적합한 경우

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

 

 

요약

  • 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하는 것이 좋다.