아이템 45. 스트림은 주의해서 사용하라

wisdom·2022년 9월 15일
0

Effetctive Java

목록 보기
45/80
post-thumbnail

1. 스트림 API 소개

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

1) 핵심 개념 : 스트림 & 파이프라인

이 API가 제공하는 추상 개념의 핵심은 다음의 2가지다.

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

2) 스트림의 원소

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

스트림 안의 데이터 원소들은 객체 참조 나 기본 타입 중 int, long, double 의 값이다.

3) 스트림 파이프라인

스트림 파이프라인은 다음과 같이 구성된다.

소스 스트림 → 0개 이상의 중간 연산(intermediate operation) → 종단 연산(terminal operation) 

중간 연산 (intermediate operation)

  • 스트림을 어떠한 방식으로 변환(transform)한다.
  • 각 원소에 함수를 적용하거나, 특정 조건을 만족 못하는 원소를 걸러낸다.
  • 한 스트림을 다른 스트림으로 변환하면, 변환된 스트림의 원소 타입은 변환 전과 같을 수도 있고 다를 수도 있다.

종단 연산 (terminal operation)

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

지연 평가 (lazy evaluation)

  • 스트림 파이프라인은 지연 평가된다.
  • 평가는 종단 연산이 호출될 때 이루어지고, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
  • 지연 평가는 무한 스트림을 다룰 수 있게 해준느 열쇠다.
  • 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같다.
    따라서 종단 연산을 빼먹는 일이 절대 없도록 하자.

메서드 연쇄

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

병렬 처리

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

2. 스트림 vs 반복

반복 코드를 스트림으로 바꾸는 게 가능하더라도 모든 반복문을 스트림으로 바꾸지는 않는 것이 좋다.
스트림을 사용했을 때 코드 가독성과 유지보수 측면에서 손해를 볼 수 있기 때문이다.

중간 정도 복잡한 작업에도 스트림과 반복문을 적절히 조합하는 게 최선이다.
따라서 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하자.

코드 블록(반복 코드)에서만 가능한 경우

스트림 파이프라인은 되풀이되는 계산을 함수 객체(주로 람다나 메서드 참조) 로 표현한다.
반면 반복 코드에서는 코드 블록 을 사용해 표현한다.

함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일들은 다음과 같다.

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

  2. 종료 혹은 건너뛰기 (return, break, continue), 예외
    코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나, break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다. 또한 메서드 선언에 명시된 검사 예외를 던질 수 있다.
    반면, 람다로는 이 중 어떤 것도 할 수 없다.

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

  1. 원소들의 시퀀스를 일관성 있게 변환하는 경우
  2. 원소들의 시퀀스를 필터링하는 경우
  3. 원소들의 시퀀스를 연산 후 결합하는 경우 (더하기, 연결하기, 최솟값 구하기 등)
  4. 원소들의 시퀀스를 모으는 경우 (공통된 속성을 기준으로 묶어서)
  5. 원소들의 시퀀스 중 특정 조건을 만족하는 원소를 찾는 경우

3. 주의사항

1) 스트림을 과용하면 가독성이 좋지 않고 유지보수하기 어렵다.

스트림을 적절히 활용하면 깔끔하고 명료해지지만, 너무 과하게 사용하는 경우 코드를 읽기 어렵고 유지보수하기 어려워진다.

// 스트림을 과하게 사용한 경우
public class StreamAnagrams {
    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 class HybridAnagrams {
    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);
    }
}

2) 람다 매개변수의 이름은 주의해서 정해야 한다.

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


3) 적절한 도우미 메서드를 활용하자.

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

파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 때문에, 람다에서 세부 구현을 주 프로그램 로직 밖으로 빼내는 것은 전체적인 가독성을 높여준다.


4) char 값을 처리할 때는 스트림을 사용하지 말자.

자바에서는 char용 스트림을 지원하지 않는다.

예를 들어, 다음과 같은 코드를 실행하면 721011081081113211911111410810033 라는 값이 출력된다.

이것은 "Hello world!".chars()가 반환하는 스트림의 원소가 int 값이기 때문이다.
명시적 형변환을 하면 제대로 출력이 되기는 하지만 char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.

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

5) 스트림에서 한 데이터를 파이프라인의 여러 단계에서 동시에 접근하기는 어렵다.

스트림으로 처리하기 어려운 일 중 대표적인 것이 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기 어려운 경우다.

스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값을 읽는 구조이기 때문이다.

가능한 경우라면, 앞 단계의 값이 필요할 때 매핑을 거꾸로 수행하는 방법을 사용하자.

다음은 처음 20개의 메르센 소수(Mersenne prime)을 출력하는 예제다.
메르센 소수를 계산하기 전 지수(p)를 종단 연산에서 출력하기 위해, 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해주었다.

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



📌 핵심 정리

스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞는 일이 있다.
그리고 수많은 작업이 이 둘을 조합했을 때 가장 멋지게 해결된다.
어느 쪽을 선택하는 확고부동한 규칙은 없지만 참고할 만한 지침 정도는 있다.
어느 쪽이 나은지가 확연히 드러나는 경우가 많겠지만, 아니더라도 방법은 있다.
스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.

profile
백엔드 개발자

0개의 댓글