Home [Effective Java] Item45. 스트림은 주의해서 사용하라!
Post
Cancel

[Effective Java] Item45. 스트림은 주의해서 사용하라!

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

Intro

  • 스트림 API는 다량의 데이터 처리 작업(순차적, 병렬적)을 돕고자 자바 8에 추가되었다.
  • 스트림 API가 제공하는 추상적 개념은 두 가지다.
    1. 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
    2. 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.
  • 대표적으로는 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수생성기 등이 있다.
  • 스트림 안의 데이터 원소들은 객체 참조나 기본 타입(int, long, double) 값이다.

스트림 파이프라인

소스 스트림 -> 중간 연산(intermediate operation) -> 종단 연산(terminal operation)

  • 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산에서 끝난다.
    • 그 사이에 하나 이상의 중간 연산이 있을 수 있다.
    • 각 중간 연산은 스트림을 변환(transform) 한다.

예시

1
Stream.of(br.readLine().split(" ")).mapToInt(Integer::parseInt).toArray();
  • BufferedReader(br)로 부터 공백을 기준으로 분리한 문자열 스트림의 각 원소를 int 타입으로 변환하는 중간 연산을 거친 후, toArray를 통해 모든 원소를 배열에 담아 반환하는 종단 연산을 수행하는 예이다.

지연 평가(lazy evaluation)

  • 스트림 파이프라인은 지연 평가된다.
  • 평가는 종단 연산이 호출될 때 이뤄진다. 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
  • 이러한 지연 평가는 무한 스트림을 다룰 수 있게 해주는 열쇠다.
  • 종단 연산이 없으면 스트림 파이프라인은 아무 일도 하지 않으니, 종단 연산을 빼먹지 말자.

fluent API

  • 스트림 API는 메서드 연쇄를 지원하는 플루언트 API다.
  • 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성하는 것이 가능하다.
  • 기본적으로 스트림 파이프라인은 순차적으로 호출되니, 병렬적 사용을 원하면 parallel 메서드를 호출해주기만 하면 된다.
    • 실제로 병렬적 사용의 효과를 볼 수 있는 상황이 많지는 않다.

예제

  • 다전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력하는 예제다.

    아나그램 : 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    
      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);
          }
      }
    
    • 맵에 각 단어를 삽입할 때 자바 8에서 추가된 computeIfAbsent 메서드를 사용했다.
      • 이 메서드는 맵 안에 키가 있는지 찾은 후, 있으면 단순히 그 키에 매핑된 값을 반환한다. 없으면 건네진 함수 객체를 키에 적용 후 매핑 및 계산값을 반환한다.
      • computeIfAbsent 메서드 사용을 통해 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현 가능하다.
  • 과도한 스트림 사용은 프로그램을 읽거나 유지보수하기 어려워진다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    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 -> 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);
            }
        }
    }
    
    • 코드를 한눈에 이해하는 것이 어렵다. 확실히 짧지만, 그럼에도 가독성이 좋지는 못하다.
  • 스트림을 적절히 사용해 깔끔하고 명료하게 구성해보자.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    public class Anagrams {
        private static String alphabetize(String s) {
              char[] a = s.toCharArray();
              Arrays.sort(a);
              return new String(a);
        }
        
        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(group -> System.out.println(group.size() + ": " + group));
            }
        }
    }
    
    • 가독성이 훨씬 좋아졌다.
    • 스트림을 모르더라도, 단어를 받아 alphabetize 메서드를 적용해 그루핑하고 있음을 이해하는 것이 가능하다.
    • alphabetize 메서드도 스트림을 사용해 구현할 수 있지만, 그렇게 하면 명확성이 떨어지고 잘못 구현할 가능성이 커진다. 심지어 느려질 수도 있다.
      • 그 이유는 자바가 기본 타입인 char 용의 스트림을 지원하지 않기 때문이다.
      • 그러니 char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.

스트림을 사용하도록 리펙터링

  • 스트림을 처음 쓰기 시작하면 모든 반복문을 스트림으로 바꾸고 싶은 유혹이 일겠지만, 서두르지 말자.
    • 코드 가독성과 유지보수 측면을 고려하자.
  • 중간 정도의 복잡도를 보이는 작업에도 스트림과 반복문을 조합한 사용이 최선이다.
  • 그러니 기존 코드는 스트림을 사용하도록 리펙터링하되, 새 코드가 더 나아 보일 때만 반영하자.

코드 블록과 함수 객체의 비교(스트림의 한계점)

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

    왜 final 혹은 effectively final 제한자를 사용해야 하는가?

    간략히 말하자면 쓰레드 한정 기법을 위배하지 않기 위해서다. (멀티 쓰레드의 안전성을 유지하기 위함)

    자유 변수(외부의 지역변수)의 경우 람다표현식 리턴 후 해당 쓰레드의 스택 영역에서 사라질 수도 있어 람다 캡쳐링(자유변수의 복사본을 만들어 접근을 허용하는 기법)에 의해서 복사해 사용한다. 이 덕분에 다른 스레드에서 해당 자유 변수를 참조할 수도 있다. 그런데 이 참조값이 람다 실행 시점에 따라 바뀐다고 생각해보자. 참조되는 값의 예측이 불가능할 것이다. 그렇기 때문에 Sync를 맞춰줄 필요가 있게 되어 final 혹은 effectively final을 사용하는 것이다.

  • 코드 블록에서는 return 문을 사용해 메서드 탈출이 가능하고, break, continue 문으로 블록 바깥의 반복문을 종료, 건너뜀이 가능하다. 또 메서드 선언에 명시된 검사 예외를 던질 수 있다.
    • 람다로는 이 중 어떠한 것도 할 수 없다.

스트림이 안성맞춤인 경우

  • 원소들의 시퀀스를 일관되게 변환하는 경우
  • 원소들의 시퀀스를 필터링하는 경우
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합하는 경우(더하기, 연결하기, 최솟값 구하기 등)
  • 원소들의 시퀀스를 컬렉션에 모으는 경우(공통 속성을 기준으로 그룹핑)
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 탐색하는 경우

스트림으로 처리하기 어려운 경우

  • 한 데이터가 파이프라인의 여러 단계를 통과할 때, 이 데이터의 각 단계에서의 값들에 동시 접근하기 어려운 경우
    • 스트림 파이프라인은 일단 한 값을 다른 값게 매핑하면 원본 값을 잃는 구조이기 때문이다.

스트림과 반복문 비교

  • 본인의 취향에 따라 선택하면 된다.
  • 반복문을 사용한 코드가 익숙하면 그것을 이용하고, 스트림과 함수형 프로그래밍에 익숙하다면 스트림 방식을 사용하자.

핵심 정리

  • 스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞은 일도 있다.
    • 대체로 이 둘을 조합했을 때 가장 멋지게 해결된다.
  • 스트림과 반복 중 어느 것을 선택해야 하는지에 대한 확고부동한 규칙은 없다. 하지만 참고할만한 지침 정도는 있다.
    • 앞서 적은 내용을 확인해보자.
  • 어느 것을 사용해야 하는지 확고하지 않다면, 둘 다 사용해보고 더 나은 쪽을 택하자.
This post is licensed under younghwani by the author.

[Effective Java] Item44. 표준 함수형 인터페이스를 사용하라!

[Effective Java] Item46. 스트림에서는 부작용 없는 함수를 사용하라!

Comments powered by Disqus.