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

스트림 API는 다량의 데이터 처리 작업을 돕고자 자바 8에 추가되었다.

스트림 API의 핵심 개념

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

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

스트림 파이프라인

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

중간 연산

각 중간 연산은 스트림을 어떠한 방식으로 변환한다. 중간 연산은 모두 한 스트림을 다른 스트림으로 변환하는데, 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고 다를 수도 있다.

종단 연산

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

지연 평가

스트림 파이프라인은 지연 평가(lazy evaluation)된다. 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다. 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어와 같으니, 종단 연산을 빼먹으면 안 된다.

순차, 병렬처리

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

플루언트 API

스트림 API는 메서드 연쇄를 지원하는 플루언트 API다. 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다. 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.

스트림 API 사용

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);
        // }

        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 s) {
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}
  • 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
  • 도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다 스트림 파이프라인에서 훨씬 크다. 파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 때문이다.

char 스트림

alphabetize 메서드도 스트림을 사용해 구현할 수 있다. 하지만 자바가 기본 타입인 char용 스트림을 지원하지 않는다. char값들을 스트림으로 처리할 경우 형변환을 명시적으로 해줘야 한다.

s.chars().forEach(x -> System.out.println((char)x));

동작은 하지만 명확성이 떨어지고 잘못 구현할 가능성이 커진다. 심지어 느려질 수 있다. char값들을 처리할 때는 스트림을 삼가는 편이 낫다.

스트림과 반복

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

함수 객체와 코드 블록

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

계산 로직이 위의 일들을 수행해야 한다면 스트림과는 맞지 않다.

스트림을 사용하면 좋은 경우

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

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

대표적인 예로, 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우다. 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값을 잃는 구조이기 때문이다. 원래 값과 새로운 값의 쌍을 저장하는 객체를 사용해 매핑하는 우회 방법도 있지만, 만족스러운 해법은 아니다. 매핑 객체가 필요한 단계가 여러 곳이라면 특히 더 그렇다. 이런 방식은 코드 양도 많고 지저분하여 스트림을 쓰는 주목적에서 완전히 벗어난다. 가능한 경우라면, 앞 단계의 값이 필요할 때 매핑을 거꾸로 수행하는 방법이 나을 것이다.

public class MersennePrimes {
    static Stream<BigInteger> primes() {
        return Stream.iterate(TWO, BigInteger::nextProbablePrime);
    }

    public static void main(String[] args) {
        primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
                .filter(mersenne -> mersenne.isProbablePrime(50))
                .limit(20)
                .forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
    }
}

위 메서드는 메르센 소수의 앞에 지수를 출력하려고 한다. 이 값은 초기 스트림에만 나타나므로 결과를 출력하는 종단 연산에서는 접근할 수 없다. 하지만 다행히 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해 메르센 수의 지수를 쉽게 계산해낼 수 있다. 지수는 단순히 숫자를 이진수로 표현한 다음 몇 비트인지를 세면 나오기 때문이다.

Comments