본문 바로가기

Reading/Effective Java

[Effective-Java] Item 45. 스트림은 주의해서 사용하라

스트림 API


순차적이든 병렬적이든 다량의 데이터 처리 작업을 돕고자 자바 8에 추가된 스트림 API가 제공하는 추상 개념 중 핵심은 두 가지다.

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

스트림의 원소들은 어디로부터든 올 수 있다. ex) 컬렉션, 배열, 파일, 정규표현식 패턴 매처(matcher), 난수 생성기 등

스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이다. 기본 타입 값은 IntStream, LongStream, DoubleStream이 있다.

 

스트림 파이프라인은 소스 스트림에서 시작해 종단 연산(terminal operation)으로 끝나며,

그 사이 하나 이상의 중간 연산(intermedate operation)이 있을 수 있다. 

 

중간 연산(intermedate operation): 한 Stream을 다른 Stream으로 반환해 메소드 체이닝이 가능하게 하는 연산 

filter(Predicate<T>를 이용한 조건 필터링), map(Function<T, R>을 이용해 특정 형태로 변환) 등이 있다.

 

종단(단말) 연산(terminal operation): 마지막 중간 연산이 내놓은 스트림, 즉 가공된 스트림에 결과를 만들어내는 연산

collect(결과를 모으는 연산), reduce(결과를 특정 형태로 합치기), max, count, sum, average 등이 있다.

 

스트림 파이프라인은 지연 평가(lazy evaluation)된다. 

평가는 종단 연산이 호출될 때 이뤄지며 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.

지연 평가가 무한 스트림을 다룰 수 있게 해주고 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op와 같다. 

 

스트림 API는 메소드 연쇄를 지원하는 플루언트 API(fluent API)고, 파이프 라인 여러 개를 연결해 단 하나의 표현식으로 완성할 수 있다. 

기본적으로 스트림 파이프라인은 순차적으로 수행되며, 병렬로 실행하려면 파이프 라인을 구성하는 스트림 중 하나에서 parallel 메소드를 호출해주면 되긴 하나 효과를 볼 수 있는 상황이 많진 않다 --> 아이템 48 참조

 

 

스트림 주의사항


스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.

 

다음 코드가 바로 그 예시다.

맵의 value에 단어를 넣고 key 값으로 단어 구성 철자를 알파벳 순으로 정렬한 값을 넣는다.(sta, tsa는 key가 ast로 같다)

그리고 지정한 단어 개수보다 원소수가 많은 그룹을 출력한다. 

 

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

 

이 코드는 스트림이 과하게 사용되어 코드를 읽고 유지보수하기 힘들어 보인다.

다음은 스트림 절충 코드를 보자. 

 

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(g -> System.out.println(g.size() + ": " + g));
    }
}

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

 

 

단어의 철자를 알파벳순으로 정렬하는 일은 별도 메소드인 alphabetize에서 수행하게 하였다.

연산에 적절한 이름을 짓고 세부 구현을 주 프로그램 로직 밖으로 빼내 전체적으로 가독성도 올라갔다.

도우미 메소드를 적절히 활용하는 일은 일반 반복 코드보다 스트림 파이프라인에서 훨씬 크다.

파이프 라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 떄문이다.

 

 

추가적으로, 람다 매개변수의 이름은 주의해서 정해야한다. 위 코드에서 forEach의 매개변수도 g보다는 group이 맞다.

람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.

 

alplhabetize 메소드도 스트림을 사용해 다르게 구현할 수 있지만 그렇게 하면 명확성 하락과 잘못 구현될 가능성이 크고 느려질 수 있다.

자바가 기본 타입인 char용 Stream은 제공하지 않기 때문이다.

 

"Hello".chars().forEach(System.out::print);

 

String의 chars 메소드는 IntStream을 반환한다.

따라서 메소드 참조인 print를 하면 char의 아스키코드값이 반환된다. 물론(char)x 이런식으로 형변환해야한다.

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

 

기존 코드는 스트림을 사용하도록 리팩토링하되, 새 코드가 더 나아 보일때만 반영하자.

 

 

상황별 스트림 사용


이러한 일들을 수행해야 한다면 스트림과 맞지 않는다.

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

하지만 이런 경우들은 스트림에 적용하기 좋다.

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

 

 

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


한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시 접근하기 어렵다.

파이프라인은 한 값을 다른 값에 매핑하고 나면 원래의 값을 잃는 구조다. 원래 값, 새로운 값의 쌍을 저장하는 객체를 사용해 매핑하는 우회 방법도 있지만, 매핑 객체가 필요한 단계가 여러 곳이면 쉽지 않다. 가능한 경우라면 앞 단계 값이 필요할 때 매핑을 거꾸로 수행하는게 낫다.

 

메르센 소수는 (2^p)-1 형태의 수다. 여기서 p가 소수면 해당 메르센 수도 소수일 수 있는데 이때의 수를 메르센 소수라 한다.

다음 코드는 무한 스트림을 반환하는 메소드다.

Stream.iterate 정적 팩토리는 첫 번째 인자로 첫 번째 원소를 두고 두번째 인자로 다음 원소를 생성하는 UnaryOperator를 선언한다.

 

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

 

위 코드의 이름처럼 메소드 이름은 원소의 정체를 알려주는 복수 명사로 쓰기를 추천한다. 스트림 파이프라인 가독성이 좋아진다.

이를 활용해서 처음 20개의 메르센 소수를 출력하게할 수 있다.

 

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

 

소수들을 사용해, (2^p) -1로 만들고, filter로 소수만 거른 후에, 겨로가 스트림의 원소를 20개로 제한하고 출력한다.

하지만 주석된 부분으로 출력하면 그 소수의 값만 출력되는데 앞에 지수(p)를 출력하고싶을 수 있다.

이 값은 초기 스트림에만 나와 종단 연산에는 접근할 수 없지만 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행하면,

즉 지수는 단순 숫자를 이진수로 표현한 다음 몇 비트인지 세면 나오므로 종단 연산을 새롭게 처리하면 된다.

 

 

정리


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

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.