ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Java] 스트림은 주의해서 사용하라
    Java 2023. 2. 2. 11:08

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

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

    스트림 원소들은 어디로부터든 올 수 있다. 대표적으로는 아래와 같은 것들이 있다.

    • 컬렉션
    • 배열
    • 파일
    • 정규표현식
    • 난수 생성기
    • 다른 스트림

    스트림 안의 데이터 원소들은 객체 참조나 기본 타입(int, long, double)을 지원한다.

    • Stream : 객체 참조타입에 대한 Stream
    • IntStream: int 타입에 대한 Stream
    • LongStream: long 타입에 대한 Stream
    • DoubleStrema: double 타입에 대한 Stream

    스트림 파이프라인

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

    종단 연산

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

    • forEach(Consumer<? super T> consumer) : Stream의 요소를 순회
    • count() : 스트림 내의 요소 수 반환
    • max(Comparator<? super T> comparator) : 스트림 내의 최대 값 반환
    • min(Comparator<? super T> comparator) : 스트림 내의 최소 값 반환
    • allMatch(Predicate<? super T> predicate) : 스트림 내에 모든 요소가 predicate 함수에 만족할 경우 true
    • anyMatch(Predicate<? super T> predicate) : 스트림 내에 하나의 요소라도 predicate 함수에 만족할 경우 true
    • noneMatch(Predicate<? super T> predicate) : 스트림 내에 모든 요소가 predicate 함수에 만족하지않는 경우 true
    • sum() : 스트림 내의 요소의 합 (IntStream, LongStream, DoubleStream)
    • average() : 스트림 내의 요소의 평균 (IntStream, LongStream, DoubleStream)

    중간 연산

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

    • filter(Predicate<? super T> predicate) : predicate 함수에 맞는 요소만 사용하도록 필터
    • map(Function<? Super T, ? extends R> function) : 요소 각각의 function 적용
    • flatMap(Function<? Super T, ? extends R> function) : 스트림의 스트림을 하나의 스트림으로 변환
    • distinct() : 중복 요소 제거
    • sort() : 기본 정렬
    • sort(Comparator<? super T> comparator) : comparator 함수를 이용하여 정렬
    • skip(long n) : n개 만큼의 스트림 요소 건너뜀
    • limit(long maxSize) : maxSize 갯수만큼만 출력

    스트림의 지연 평가 (lazy evaluation)

    • 스트림에 대한 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
    • 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다.
    • 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는다.

    스트림 예제 - 아나그램(anagram)

    이 프로그램은 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력한다.

    • 아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말한다.
    • 사용자가 명시한 사전 파일에서 각 단어를 읽어 맵에 저장한다.
    • 맵의 키는 그 단어를 구성하는 철자들을 알파벳순으로 정렬한 값이다.
      • "staple"의 키는 "aelpst", "petals"의 키도 "aelpst"
    • 아나그램끼리는 같은 키를 공유하고, 맵의 값은 같은 키를 공유한 단어들을 담은 집합이다.

    반복을 이용한 코드

    public static void main(String[] args) {
        File dectionary = new File(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
    
        Map<String, Set<String>> groups = new HashMap<>();
        try (Scanner s = new Scanner(dectionary)) {
            while(s.hasNext()) {
                String word = s.next();
                groups.computeIfAbsent(alphabetize(word), 
                                       (unused) -> new TreeSet<>()).add(word);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        
        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);
    }
    • 맵에 각 단어를 삽입할 때 자바 8에서 추가된 computeIfAbsent 메서드를 사용했다.
    • 이 메서드는 맵 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환한다.

    Stream을 과도하게 사용한 코드

    public static void main(String[] args) throws IOException {
        File dectionary = new File(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
    
        try(Stream<String> words = Files.lines(dectionary.toPath())) {
            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);
        }
    }
    • 앞의 코드와 같은 일을 하지만 스트림을 과하게 활용한다.
    • 사전 파일을 여는 부분만 제외하면 프로그램 전체가 단 하나의 표현식으로 처리된다.
    • 이러한 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

    Stream을 적절히 활용한 코드

    public static void main(String[] args) throws IOException {
        File dectionary = new File(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
    
        try(Stream<String> words = Files.lines(dectionary.toPath())) {
            words.collect(groupingBy(word -> alphabetize(word)))
                .values().stream()
                .filter(group -> group.size() >= minGroupSize)
                .forEach(group -> System.out.println(group.size() + ": " + group));
        }
    }
    • 앞의 두 프로그램과 기능은 같지만 스트림을 적당히 사용했다.
    • 코드가 짧을 뿐 아니라 명확해졌다.
    • 이 스트림의 파이프라인에는 중간 연산은 없으며, 종단 연산에서는 모든 단어를 수집해 맵으로 모은다.
    • 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.

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

    모든 반복문을 스트림으로 바꾸고 싶은 유횩이 생기지만, 스트림으로 바꾸는 게 가능할지라도 코드 가독성과 유지보수 측면에서는 손해를 볼 수 있기 때문에 무작정 바꾸지 않는 게 좋다.

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

    코드 블록 vs 람다

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

    스트림이 적합한 경우

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

    이러한 일 중 하나를 수행하는 로직이라면 스트림을 적용하기에 좋은 후보다.

    스트림이 적합하지 않은 경우

    • 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우
    • 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문이다.

    예제 - 데카르트 곱

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

    데카르트 곱 계산을 반복 방식으로 구현

    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;
    }
    • for-each 반복문을 중첩해서 구현한 코드로, 스트림에 익숙하지 않은 사람에게 친숙한 방식이다.
    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은 스트림의 원소를 각각의 하나의 스트림으로 매핑한 다음 그 스트림들을 다시 하나의 스트림으로 합친다. 이를 평탄화라고도 한다.
    • 중첩된 람다를 사용했다.

    어느 방법이 좋아 보이는가?

    결국은 개인 취향과 프로그래밍 환경의 문제다. 처음 방식은 더 단순하고 더 자연스러워 보일 것이다. 이해하고 유지보수 하기에 처음 코드가 더 편한 프로그래머가 많겠지만, 두 번째인 스트림 방식을 편하게 생각하는 프로그래머도 있다.

    • 확신이 서지 않는 독자는 첫 번째 방식을 쓰는 게 더 안전할 것이다.
    • 스트림 방식이 나아보이고 동료들도 스트림 코드를 이해할 수 있고 선호한다면 스트림 방식을 사용하자.

    핵심 정리

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