DEV ℧ Developer Diary

[EffectiveJava] item45 - 스트림은 주의해서 사용하라

스트림 API는 다량의 데이터 처리 작업(순차 또는 병령)을 돕고자 자바8에 추가된 기능이다.

Stream API

스트림 API가 제공하는 추상 개념 중 핵심은 두가지이다.

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

스트림은 대표적으로 컬렉션, 배열 ,파일, 정규표현식 패턴 매처(matcher) 등등이 있고, 기본 타입값으로는 int, long, double 세가지를 지원한다.

Stream Pipeline

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

중간연산

중간 연산은 스트림을 각 원소에 함수를 적용하거나 특정 조건을 만족 못하는 원소를 걸러내는 등의 방식으로 변환한다. 중간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데, 변환도니 스트림의 원소 타입은 변환전 스트림의 원소타입과 같을 수도 있고 다를 수도 있다.

종단연산

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

스트림 파이프라인의 지연평가

평가는 종단 연산ㄴ이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠이다.

즉, 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어와 같다.

스트림 파이프라인의 순차성

기본적으로 스트림 파이프라인은 순차적으로 수행된다. 하지만 병렬로 실행하기 위해서는 parallel 메서드를 호출해주기만 하면 되나 효과를 볼 수 있는 상황은 많지 않다.

스트림의 활용

스트림 API는 다재다능 하여 사실상 어떠한 계산으로 해낼 수 있지만, 무조건 스트림만 사용해야 한다는 것은 아니다.
스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.

다음 예시를 통해 알아보도록 하자.

public class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Path.of(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 word) {
        char[] a = word.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}

특정 숫자보다 많은 단어들의 애너그램을 모아 출력하는 프로그램이다.

자바 8에 추가된 computeIfAbsent메서드를 사용했으며, 이 메소드는 맵안에 키가 있는지 찾은 다음 있으면 단순히 그 키에 매핑된 값을 반환한다.

만약 위의 코드를 스트림으로 변경한다면 어떻게 변경 될까?

잘못된 예시

public class Anagrams {
    public static void main(String[] args) throws IOException{
        Path dictionary = Path.of(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);
        }
    }
}

사전 파일을 여는 부분만 제외한다면 프로그램 전체가 하나의 파이프라인으로 묶여서 표현되고 있다.

위의 소스는 스트림을 과하게 활용해서 프로그램을 읽거나 유지보수하기가 어렵다. 실제로 저 코드를 수정해야 한다면 벌써 머리가 아파오기 시작한다..

이제 아래는 적당히 스트림을 사용한 예시이다.

올바른 예시

public class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Path.of(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));
        }
    }

    private static String alphabetize(String word) {
        char[] a = word.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}

이 코드는 위의 코드보다 훨씬 가독성이 좋아졌다.

또한 스트림 변수의 이름을 words로 지어 스트림 안의 각 원소의 단어(word)임을 명확히 했다.

참고 : 람다에서는 타입 이름을 자주 생략하므로 매개변수의 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다. 또한 도우미 메소드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다는 스트림 파이프라인에서 훨씬 크다.

char의 스트림

도우미 메서드인 alphabetize의 내부 로직도 스트림으로 구현할 수 있겠지만 좋은 방법은 아니다.

자바가 기본 타입인 char용 스트림을 지원 하지 않기 때문에, 명확성이 떨어지고 잘못 구현할 가능성이 커진다.

굳이 구현한다면 아래와 같이 구현 할 수 있다.

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

하지만 형변환을 명시적으로 해줘야할 뿐더러, 형변환을 하지않으면

72101108108111321191111141081033

와 같은 정수를 출력하기 때문에, 헷갈릴 수 있다.

언제 스트림을 사용해야 할까?

코드 가독성과 유지보수 측면에서 손해를 볼수 있는것이 스트림이다.

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

함수객체와 코드블럭의 차이

람다와 같은 함수객체 내부에서는 할 수 없는 일이 if문이나 for문의 코드블럭에서는 할수 있는 일이 있는데, 각각 정리해 보도록 하자.

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

스트림을 적용하기 좋은 경우

스트림을 적용하기 좋은 경우는 아래와 같다.

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

스트림을 적용하기 어려운 경우

반면 스트림으로 처리하기 어려운 일도 있다.

  • 한 데이터가 파이프라인의 여러 단계(stage)를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어렵다. 한 값을 다른 값에 매핑하면 원래의 값을 잃기 때문이다.
  • 매핑 객체가 필요한 단계가 여러 곳이라면 특히 더 적용하기 어려워 진다.

스트림 VS 반복문

스트림과 반복문 중 어느 쪽을 써야 할지 바로 알기 어려운 작업도 많다.

다음의 예시를 살펴보자.

/** 데카르트 곱 계산을 반복문 방식으로 구현 */
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());
}

두 방식중 결국은 개인 취향과 프로그래밍 환경의 문제다.

처음 방식은 더 단순하고 자연스럽지만, 두번째인 스트림 방식도 스트림과 함수형 프로그래밍에 익숙한 프로그래머라면, 스트림 방식이 조금더 명확하고 그리 어렵지도 않을 것이다.

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