JAVA/Effective Java

[Item 45] 스트림은 주의해서 사용해라

IT-사과 2022. 7. 10. 11:55

스트림 API는 다량의 데이터 처리 작업(순차적, 병렬적)을 돕고자 자바8에 추가되었다.

 

스트림 API가 제공하는 추상 개념 핵심은 다음과 같다. 

  • 스트림(stream)은 데이터 원소의 유한 혹은 무한 시퀀스(sequence)의 개념
  • 스트림 파이프라인(stream pipeline)은 이 원소들로 수행하는 연산단계를 표현하는 개념

또한 스트림의 원소들은 어디로부터든 올 수 있다. 대표적으로는 컬렉션, 배열, 파일, 정규표현식 패턴 매처(matcher), 난수 생성기, 혹은 다른 스트림이 있다. 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이고 기본 타입으로는 int, long, double을 지원한다.

 

스프림 파이프라인

  • 소스 스트림에서 시작하여 종단 연산(terminal operation)으로 끝나며,그 사이에 하나 이상의 중간 연산(intermediate operaion)이 있을 수 있다.

  • 각 중간 연산은 스트림을 어떠한 방식으로 변환(transform) 한다.   
    • 각 원소에 함수를 적용하거나 특정 조건을 만족 못하는 원소를 걸러낼 수 있다.
    •  한 스트림을 다른 스트림으로 변환하는데, 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고 다를 수도 있다.
  • 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 추가한다.   
    • 원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력하는 식이다.
  • 스트림 파이프라인은 지연 평가(lazy evaluation)된다.
    • 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
    • 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니, 종단 연산을 뻬먹는 일이 절대 없어야한다.
  • 스트림 API는 메서드 연쇄를 지원하는 플루언트 API(fluent API)다.
    • 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다. 
    • 파이프라인 여러개를 연결해 표현식 하나로 만들 수도 있다.
  • 기본적으로 스트림 파이프라인은 순차적으로 수행된다.
    • 만약 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 된다. 
      • 다만, 효과를 볼 수 있는 상황은 많지 않음.
  • 스트림 API는 다재다능하며 사실상 어떠한 계산이라도 해낼 수 있다. 
    • 스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다. 

 

스트림의 올바른 쓰임

 확고부동한 규칙은 없지만, 참고할 만한 노하우는 있다. 

아나그램 예제

// 사전 하나를 훑어 원소 수가 많은 아나그램 그룹들을 출력한다.
// 아나그램(anagram): 철자를 구성하는 알파벳이 같고 순서만 다른 단어
//     - 즉, "staple"의 키는 "aelpst"가 되고 "petals"의 키도 "aelpst"가 되면서 두 단어는 아나그램이다.  
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);
  }
}

주목하라던 부분을 보면 computeIfAbsent 를 사용하여 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있다. 

 

스트림을 과도하게 사용한 예제 

//스트림을 과하게 사용했다. - 따라 하지 말 것!
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(StringBuffer::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);
    }
}

앞의 코드와 같은 일을 하지만 스트림을 사용하여 사전 파일을 여는 부분만 제외하면 프로그램 전체가 단 하나의 표현식으로 표현된다. 사전을 여는 작업을 분리한 이유는 그저 try-with-resources 문을 사용해 사전 파일을 닫기 위함이다.

 

이 코드의 특징은 소스 길이는 확실히 짧지만 읽기가 어렵다. 그래서 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다. 

 

스트림을 적절히 사용한 예제

//스트림을 적절히 활용하면 깔끔하고 명료해진다. 
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));
    }
  }

  //alphabetize 메서드는 첫번째 예제와 동일
}
  • 스트림을 전에 본 적 없더라도 이 코드를 이해하기 쉬울 것이다.
    • 스트림의 변수의 이름을 words로 지어 스트림 안의 각 원소가 단어(word)임을 명확히 밝히고 있다.
    • 스트림 파이프라인에는 중간 연산 없이 종단 연산에서 모든 단어를 수집하여 맵으로 모으고 있다.
    • 맵의 values()가 반환한 값으로부터 새로운 Stream<List<String>> 스트림을 열어서 필터링 후 출력하고 있다.
  • 람다 매개변수의 이름을 주의해서 정해야 한다.
    • 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
  • 도우미 메서드를 적절히 확용하는 일의 중요성은 일반 반복코드에서보다는 스트림 파이르라인에서 훨씬 크다.
    • 세부 구현을 도우미 메서드인 alphabetize()로 분리하여 가독성을 높혔다.
    • 만약, 스트림 내부에서 구현을 했다면 명확성이 떨어지고 잘못 구현할 가능성이 커진다.
    • 심지어는, 자바는 기본 타입인 char용 스트림을 지원하지 않기 때문에 성능이 느려질 수도 있다. (물론 그렇게 했어야 했다는 건 아님)

char값 스트림 처리

결과는 721011081081113211911111410810을 출력한다. 반환하는 스트림의 원소는 char가 아닌 int이기 때문이다.

 

올바른 print 메서드를 호출하게 되려면 아래처럼 형변환을 명시적으로 해줘야한다. 

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

 

모든 반복문을 스트림으로 바꾸고 싶은 유혹이 들때가 있지만, 중간 정도 복잡한 작업에도(앞선 프로그램 처럼) 스트림과 반복문을 적절히 조합하는 게 최선이다. 

 

그러니 기존 코드는 스트림을 사용하도록 리팩토링하되, 새 코드가 더 나아 보일때만 반영할 하는 것이 좋다. 

 

스트림과 반복문

코드 블록 (반복문)을 써야만 할 때

  • 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있지만 람다에서는 final 이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능하다.

 

  • 코드 블록에서는 return 문을 사용해 메서드를 빠져나가거나, break나 continue문으로 블록 바깥의 반복문을 종료하거나 반복을 건너뛸수 있다.
    • 또한 메서드 선언에 명시된 검사 예외를 던질 수 있음

스트림을 써야할 때

  • 계산 로직 이상의 일들을 수행해야 한다면 스트림과는 맞지 않는 것이다.
  • 스트림이 안성 맞춤인 일들
    • 원소들의 시퀀스를 일관되게 변환함
    • 원소들의 시퀀스를 필터링함
    • 원소들의 시퀀스를 하나의 연산을 사용해 결합함 (더하기, 연결하기, 최솟값 구하기 등)
    • 원소들의 시퀀스를 컬렉션에 모음 (공통된 속성을 기준으로)
    • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾음

 


참고자료

 

www.kyobobook.co.kr/product/detailViewKor.laf?ejkGb=KOR&mallGb=KOR&barcode=9788966262281&orderClick=LET&Kc=

 

이펙티브 자바 3/E - 교보문고

프로그래밍인사이트 | 자바 6 출시 직후 출간된 『이펙티브 자바 2판』 이후로 자바는 커다란 변화를 겪었다. 그래서 졸트상에 빛나는 이 책도 자바 언어와 라이브러리의 최신 기능을 십분 활용

www.kyobobook.co.kr