Skip to content

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

@JoisFe

Description

@JoisFe

Discussed in https://github.com/orgs/Study-2-Effective-Java/discussions/116

Originally posted by JoisFe February 12, 2023

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

스트림 API

  • 다량의 데이터 처리 작업 (순차적이든 병렬적이든)을 돕고자 자바 8에서 추가 된 API

스트림 API가 제공하는 추상 개념

  1. 스트림 (Stream) 은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻함
  2. 스트림 파이프라인 (Stream Pipeline) 은 이 원소들로 수행하는 연산 단계를 표현하는 시스템
  • 스트림의 원소들은 어디로부터든 올 수 있음
    • 대표적으로 컬렉션, 배열, 파일, 정규표현식 패턴 매처(matcher), 난수 생성기, 혹은 다른 스트림이 있음
  • 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값
    • 기본 타입 값으로는 int, long, double 이렇게 3가지를 지원

스트림 파이프라인 (Stream Pipeline)

  • 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산 (terminal operation)으로 끝남
  • 그 사이에 하나 이상의 중간 연산 (intermediate operation)이 있을 수 있음

중간 연산 (intermediate operation)

  • 각 중간 연산은 스트림을 어떠한 방식으로 변환 (transform) 함
    • 각 원소에 함수를 적용하거나 특정 조건을 만족 못하는 원소를 걸러낼 수 있음
  • 중간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고 다를 수도 있음

종단 연산 (terminal operation)

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

지연 평가 (lazy evaluation)

  • 스트림 파이프라인은 지연 평가된다.
  • 평가는 종단 연산이 호출될 때 이뤄지며 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다
  • 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다.
  • 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니 종단 연산을 빼먹는 일이 절대 없도록 주의해야 한다!

플루언트 API (Fluent API)

  • 스트림 API는 메서드 연쇄를 지원하는 플루언트 API 이다.
  • 즉 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있음
  • 기본적으로 스트림 파이프라인은 순차적으로 수행됨
  • 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 되나 효과를 볼 수 있는 상황은 적다 (아이템 48 참고)

다재다능한 스트림 API 하지만 주의해서 사용해야

  • 스트림 API는 다재다능하여 사실상 어떠한 계산이라도 해낼 수 있음
  • 하지만 할 수 있다는 뜻이지 해야하는 것은 아님
  • 스트림을 제대로 사용하면 프로그램이 짧고 깔끔해짐
  • 잘 못 사용하면 읽이 어렵고 유지보수도 힘들어 짐

스트림 API 사용 노하우

  • 스트림을 언제 써야하는지를 규정하는 명확한 규칙은 존재하지 않지만 참고할 만한 노하우는 존재함
public class Anagrams {

    public static void main(String[] args) throws FileNotFoundException {
        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);
    }
}

위 코드의 설명

  • 위 코드는 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 (anagram) 그룹을 출력함
  • 아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말함
  • 위 코드는 사용자가 명시한 사전 파일에서 각 단어를 읽어 맵에 저장
  • 맵의 키는 그 단어를 구성하는 철자들을 알파벳순으로 정렬한 값
    • 즉 "staple"의 키는 "aelpst"가 되고 "petals"의 키도 "aelpst"가 됨
    • 따라서 두 단어는 아나그램이고 아나그램끼리는 같은 키를 공유함
  • 맵의 값은 같은 키를 공유한 단어들을 담은 집합
  • 사전 하나를 모두 처리하고 나면 각 집합은 사전에 등재된 아나그램들을 모두 담은 상태가 됨
  • 마지막으로 이 프로그램은 맵의 values() 메서드로 아나그램 집합들을 얻어 원소 수가 문턱값보다 많은 집합들을 출력

위 코드의 스트림 사용

  • 맵에 각 단어를 삽입할 때 자바 8에서 추가된 computeIfAbsent 메서드를 사용
  • 이 메서드는 맵 안에 키가 있는지 찾은 다음 있으면 단순히 그 키에 매핑된 값을 반환
  • 키가 없으면 건네진 함수 객체를 키에 적용하여 값을 계산해낸 다음 그 키와 값을 매핑해놓고 계산된 값을 반환
  • computeIfAbsent를 사용하면 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있음
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-with-resources 문을 사용해 사전 파일을 제대로 닫기 위해서임

스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워짐

절충 지점

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 -> alphabetize(word)))
                 .values().stream()
                 .filter(group -> group.size() >= minGroupSize)
                 .forEach(group -> System.out.println(group.size() + ": " + group));
        }
    }

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

        return new String(a);
    }
}
  • 위 코드 또한 이전의 코드들과 같은 기능을 함
  • 스트림을 적당히 사용하여 원래 코드보다 짧을 뿐만 아니라 명확하기 까지 한 코드가 되었음
  • 스트림을 전에 본 적이 없더라도 위 코드는 이해하기 쉬울 것임
  • 파일의 모든 라인으로 구성된 스트림을 얻고 스트림 변수의 이름을 words로 지어서 스트림 안의 각 원소가 단어(word) 임을 명확히 명시하였음
  • 이 스트림의 파이프라인에는 중간 연산은 없으며 종단 연산에서는 모든 단어를 수집해 맵으로 모음
  • 이 맵은 단어들을 아나그램끼리 묶어놓은 것으로 앞선 두 코드가 생성한 맵과 실질적으로 같음
  • 그 다음 이 맵의 values()가 반환한 값으로부터 새로운 Stream<List> 스트림을 열게됨
  • 이 스트림의 원소는 물론 아나그램 리스트
  • 그 리스트들 중 원소가 minGroupSize 보다 적은 것은 필터링돼 무시됨
  • 마지막으로 종단 연산인 forEach는 살아남은 리스트를 출력

alphabetize 메서드를 스트림을 사용해 구현하기

  • 해당 메서드를 스트림을 사용하여 다르게 구현시 명확성이 떨어지고 잘 못 구현할 가능성이 커짐
  • 심지어 느려질 수도 있음

why?

  • 자바가 기본 타입인 char용 스트림을 지원하지 않기 때문 (그렇다고 자바가 char 스트림을 지원했어야 한다는 뜻 아님!!, 그렇게 하는게 불가능함)
"Hello world!".chars().forEach(System.out::print);
image
  • 위 코드는 Hello world!를 출력하리라 기대했지만 72~~~~ 이상한 값을 출력함
  • "Hello world!".chars()가 반환하는 스트림의 원소는 char가 아닌 int 값이기 때문
  • 따라서 정숫값을 출력하는 print 메서드가 호출된 것

이름이 chars 인데 int 스트림을 반환하면 헷갈리는 문제

  • 올바른 print 메서드를 호출하게 하려면 형변환을 명시적으로 해줘야 함
"Hello world!".chars().forEach(x -> System.out.println((char) x));
image

하지만 char 값들을 처리할 때는 스트림을 삼가하는 편이 나음

1. 스트림으로 바꾸는게 가능할지라도 코드 가독성과 유지보수 측면에는 손해를 볼 수 있기에 무조건 스트림으로 바꾸는 것을 서두르지 말자

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

  • 스트림과 반복문을 적절히 조합하는게 최선

함수 객체(람다 or 메서드 참조 등) 보단 코드 블록의 경우가 나은 경우

  • 스트림 파이프라인은 되풀이되는 계산을 함수 객체로 표현
  • 반복 코드에서는 코드 블록을 주로 사용

함수 객체(람다 or 메서드 참조 등) 으로는 할 수 없지만 코드 블록(반복) 으로 할 수 있는 일

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

코드 블록 (반복) 보단 함수 객체(람다 or 메서드 참조 등)의 경우가 나은 경우

  1. 원소들의 시퀀스를 일관되게 변환함
  2. 원소들의 시퀀스를 필터링함
  3. 원소들의 시퀀스를 하나의 연산을 사용해 결합 (더하기, 연결하기, 최솟값 구하기 등)
  4. 원소들의 시퀀스를 컬렉션에 모음 (아마도 공통된 속성을 기준으로 묶어가면서)
  5. 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾음

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

EX)

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

코드 예시

  • 처음 20개의 메르센 소수(Mersenne prime)를 출력하는 코드
  • 메르센 수 : 2^p - 1 형태의 수
  • 메르센 소수 : 여기서 p가 소수이면 해당 메르센 수도 소수일 수 있는데 이때의 수를 메르센 소수
  • 파이프라인의 첫 스트림으로는 모든 소수를 사용할 것
static Stream<BigInteger> primes() {
  return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
  • 위 코드는 (무한) 스트림을 반환하는 메서드
  • BigInteger의 정적 멤버들은 정적 임포트하여 사용한다고 가정
  • Stream.iterate라는 정적 팩터리는 매개변수 2개를 받음
    • 첫 번째 매개변수는 스트림의 첫 번째 원소
    • 두 번째 매개변수는 스트림에서 다음 원소를 생성해주는 함수
public static void main(String[] args) {
        primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
                .filter(mersenne -> mersenne.isProbablePrime(50))
                .limit(20)
                .forEach(System.out::println);
    }
image
  • 위 코드는 앞서의 설명에 대해 정직하게 구현한 코드
  • 소수들을 사용해 메르센 수를 계산하고 결과값이 소수인 경우만 남긴 다음 (매직 넘버 50은 소수성 검사가 true를 반환할 확률을 제어) 결과 스트림의 원소 수를 20개로 제한해놓고 작업이 끝나면 결과를 출력

여기서 각 메르센 소수의 앞에 지수(p)를 출력하길 원한다고 가정

  • 이 값은 초기 스트림에만 나타나므로 결과를 출력하는 종단 연산에서는 접근할 수 없음
  • 하지만 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해 메르센 수의 지수를 쉽게 계산해낼 수 있음
  • 지수는 단순히 숫자를 이진수로 표현한 다음 몇 비트인지를 세면 나옴 따라서 종단 연산을 이 아이디어를 이용해 작성하면 원하는 결과를 얻음
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));
    }
image

스트림과 반복 중 어느 것을 써야 할지 알기 어려운 작업

EX) 카드 덱을 초기화 하는 작업

  • 카드는 숫자 (rank)와 무늬(suit)를 묶은 불변 값 클래스, 숫자와 무늬는 모두 열거 타입 이라 가정
  • 이 작업은 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 문제 (수학자들은 이를 두 집합의 데카르트 곱이라 함)

for-each 반복문을 중첩해 구현한 코드

private static List<Card> newDeck() {
        List<Card> result = new ArrayList<>();

        for (Suit suit : Suit.values()) {
            for (Rankd 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(toList());
    }
  • 중간 연산으로 사용한 flatMap은 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음 그 스트림들을 다시 하나의 스트림으로 합침 -> 평탄화 (flattening)
  • 위 코드는 중첩된 람다를 사용

위 두 코드에서 누가 좋은가...

  • 위의 두 코드는 결국 개인 취향과 프로그래밍 환경의 문제
  • 처음 방법이 더 단순하고 자연스러워 보임
  • 하지만 스트림과 함수형 프로그래밍에 익숙한 프로그래머라면 두번째 방식이 조금 더 명확하고 어렵지 않을 것임

정리

  1. 스트림이 나을수도 반복 방식이 더 나을수도 (상황마다 달라)
  2. 어찌 됬든 스트림에 대해서는 알아야 하지 않겠는가..
  3. 스트림과 반복 중 어느 쪽이 나은지 모르겠다면 둘 다 해보고 더 나은 쪽을 택하라

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions