CHAPTER6 - 스트림으로 데이터 수집(1)

2025. 2. 21. 01:00Book/모던 자바 인 액션

목차.

1. Collectors 클래스로 컬렉션을 만들고 사용

2. 하나의 값으로 데이터 스트림 리듀스하기

3. 특별한 리듀싱 요약 연산

 

 

컬렉터를 어떻게 활용할 수 있을까? 예제를 먼저 살펴보자. 어떤 트랜잭션 리스트가 있고 이들을 액면 통화로 그룹화한다고 가정하자.

Map<Currency, List<Transaction>> transactionsByCurrency = new HashMap<>();

for (Transaction transaction : TransactionExample.transactions) {
    Currency currency = transaction.getCurrency();
    List<Transaction> transactionsForCurrency = transactionsByCurrency.get(currency);

    if (transactionsForCurrency == null) {
        transactionsForCurrency = new ArrayList<>();
        transactionsByCurrency.put(currency, transactionsForCurrency);
    }

    transactionsForCurrency.add(transaction);
}

 

'통화별로 트랜잭션 리스트를 그룹화' 라고 간단히 표현할 수 있지만 코드가 무엇을 실행하는지 한눈에 파악하기 어렵다. Stream에 toList를 사용하는 대신 더 범용적인 컬렉터 파라미터를 collect 메서드에 전달함으로써 원하는 연산을 간결하게 구현할 수 있음을 배워보자

Map<Currency, List<Transaction>> transactionsByCurrencies =
        TransactionExample.transactions.stream()
                .collect(Collectors.groupingBy(Transaction::getCurrency));

 

 

1. 컬렉터란 무엇인가?

 

위 예제는 함수형 프로그맹의 편리함을 보여준다. 함수형 프로그래밍에서는 '무엇'을 원하는지 직접 명시할 수 있어서 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없다. 예제에서는 collect 메서드로 Collector 인터페이스 구현을 전달했다. Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.

 

5장의 toList를 Collector 인터페이스의 구현으로 사용했다. 위 예제에서는 groupingBy를 이용해서

'각 키 버킷 그리고 각 키 버킷에 대응하는 요소 리스트를 값으로 포함하는 맵을 만들라'

는 동작을 수행한다.

 

 

1.1 고급 리듀싱 기능을 수행하는 컬렉터

잘 설계된 함수형 API의 장점은 높은 수준의 조합성과 재사용성을 꼽을 수 있다. collect로 결과를 수집하는 과정을 간단하면서 유연한 방식으로 정의할 수 있다. 예를 들어 스트림에 collect를 호출하면 스트림의 요소에 리듀싱 연산이 수행된다. 직접 for 루프문을 만드는게 아닌 내부 반복을 통해 리듀싱 연산이 자동으로 수행된다. collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서도 컬렉터가 작업을 처리한다.

 

위 예제에서 컬렉터는 트랜잭션의 통화를 추출하고 통화/트랜잭션 쌍을 그룹화 맵으로 누적했다.

 

* 리듀싱 연산 : 스트림의 모든 요소를 반복적으로 처리해서 값으로 도출하는 질의

 

 

1.2 미리 정의된 컬렉터

groupingBy 같이 Collectors 클래스에서 제공하는 팩토리 메서드의 기능을 설명한다. Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.

 

- 스트림 요소를 하나의 값으로 리듀스하고 요약

- 요소 그룹화

- 요소 분할

 

 

 

 

2. 리듀싱과 요약

컬렉터로 스트림의 항목을 컬렉션으로 재구성할 수 있다. 즉, 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다.

 

첫 번째 예제로 counting() 이라는 팩토리 메서드가 반환하는 컬렉터로 메뉴에서 요리 수를 계산한다.

long howManyDishes = DishExample.menu.stream()
        .collect(Collectors.counting());

 

다음으로 불필요한 과정을 생략할 수 있다.

long howManyDishes = DishExample.menu.stream()
        .count()

 

 

 

2.1 스트림값에서 최댓값과 최솟값 검색

메뉴에서 칼로리가 가장 높은 요리를 찾는다고 가정하자.

Collectors.maxBy => 최댓값

Collectors.minBy => 최솟값

두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator를 인수로 받는다.

Comparator<Dish> dishCaloriesComparator =
        Comparator.comparingInt(Dish::getCalories);

Optional<Dish> mostCalorieDish =
        DishExample.menu.stream()
                .collect(Collectors.maxBy(dishCaloriesComparator));

 

또한 스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 사용된다. 이러한 연산을 요약 연산이라 부른다.

 

 

2.2 요약 연산

collectors 클래스는 Collectors.summingInt라는 요약 팩토리 메서드를 제공한다.

summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다.

summingInt의 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다.

summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다. 예제로 살펴보자

int totalCalories = DishExample.menu.stream()
        .collect(Collectors.summingInt(Dish::getCalories));

 

메뉴에서 칼로리를 추출하고 누적자에 초깃값 0부터 마지막 요소의 칼로리까지 더해 반환한다.

아래는 같은 코드이다.

int totalCalories = DishExample.menu.stream()
        .mapToInt(Dish::getCalories)
        .sum();

 

Collectors.summingLong, Colectors.summingDouble 메서드도 같은 방식으로 동작하며 long, double 데이터 형식으로 요약하는 것만 다르다.

 

이러한 합계 계산 외에 평균값 등의 연산 기능도 제공한다.

double averageCalories = DishExample.menu.stream()
        .collect(Collectors.averagingInt(Dish::getCalories));

 

만약 합계, 최소, 최대, 평균, 요소수 를 한번에 구하고 싶다면 팩토리 메서드 summarizingInt가 반환하는 컬렉터를 사용할 수 있다.

IntSummaryStatistics menuStatistics = DishExample.menu.stream()
        .collect(Collectors.summarizingInt(Dish::getCalories));

System.out.println(menuStatistics);


=>
IntSummaryStatistics{count=9, sum=4200, min=120, average=466.666667, max=800}

 

마찬가지로 long, double에 대응하는 summarizingDouble, summarizingLong 메서드와 관련된 LongSummaryStatistics, DoubleSummaryStatistics 클래스도 있다.

 

 

2.3 문자열 연결

컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 String 필드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.

String shortMenu = DishExample.menu.stream()
        .map(Dish::getName)
        .collect(Collectors.joining());

System.out.println(shortMenu);


=>
season fruitsprawnsricechickensalmonfrench friespizzabeefpork

 

하지만 결과 문자열을 해석할 수 없다. 연결된 두 요소 사이에 구분 문자열을 넣을 수 있도록 오버로드된 joining 팩토리 메서드를 호출하면 된다.

String shortMenu2 = DishExample.menu.stream()
        .map(Dish::getName)
        .collect(Collectors.joining(", "));

System.out.println(shortMenu2);


=>
season fruits, prawns, rice, chicken, salmon, french fries, pizza, beef, pork

 

 

2.4 범용 리듀싱 요약 연산

지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다. 즉, 범용 Collectors.reducing으로도 구현할 수 있다. 그럼에도 이전 예제에서 범용 팩토리 메서드 대신 특화된 컬렉터를 사용한 이유는 편의성 때문이다. 예를 들어 아래 코드처럼 reducing 메서드로 만들어진 컬렉터로도 메뉴의 모든 칼로리 합계를 계산할 수 있다.

int totalCalories = DishExample.menu.stream()
        .collect(Collectors.reducing(
                0, Dish::getCalories, (a, b) -> a + b));

 

reducing은 인수 세 개를 받는다.

 

1. 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값이다.

2. 요리를 칼로리 정수로 변환할 때 사용하는 변환함수이다.

3. 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator다.

 

아래 코드처럼 한 개의 인수를 가진 reducing 버전을 이용해서 가장 칼로리가 높은 요리를 찾는 방법도 있다.

Optional<Dish> maxCalories = DishExample.menu.stream()
        .collect(Collectors.reducing((d1, d2) ->
		d1.getCalories() > d2.getCalories() ? d1 : d2));

 

한 개의 인수를 갖는 reducing 팩토리 메서드는 세 개의 인수를 갖는 reducing 메서드에서 스트림의 첫 번째 요소를 시작 요소, 즉 첫 번째 인수로 받으며, 자신을 그대로 반환하는 항등 함수를 두 번째 인수로 받는 상황에 해당한다. 즉, 한 개의 인수를 갖는 reducing 컬렉터는 시작값이 없으므로 빈 스트림이 넘겨졌을 때 시작값이 설정되지 않는 상황이 벌어진다. 그래서 반환 객체가 Optional<T> 이다.

 

 

****collect 와 reduce*****

collect와 reduce 메서든느 무엇이 다를까? 이들 메서드로 같은 기능을 구현할 수 있으므로 역할이 같다고 생각할 수 있다. 예를 들어 다음 코드를 보자 toList 켈럭터를 사용하는 collect 대신 reduce 메서드를 사용해보자

List<Integer> list = stream.reduce(
        new ArrayList<>(),
        (List<Integer> l, Integer e) -> {
            l.add(e);
            return l;
        },
        (List<Integer> l1, List<Integer> l2) -> {
            l1.addAll(l2);
            return l1;
        }
);

List<Integer> list = stream.collect(Collectors.toList());

 

위 리듀싱 연산은 세 개의 인자를 받는다

 

1. 초깃값인 빈 리스트

2. 누적 연산을 하는 BiFunction<U, ? super T, U>

3. 병렬 처리 시 병합 함수(위 코드에선 동작하지 않는다.)

 

위 코드에는 의미론적인 문제와 실용성 문제가 발생한다.

 

1. 의미론적

collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드(Array -> List)

reduce는 두 값을 하나로 도출하는 불변형 연산 (요소의 합, 최대, 최소, 평균...)

 

위 예제에서는 reduce의 누적자로 사용된 리스트를 변환시키므로 reduce를 잘못 사용하면서 실용성 문제도 발생한다.

 

2. 실용적

여러 스레드가 동시에 같은 List<Integer> l 에 l.add(e) 연산을 하면 동시 수정 문제가 발생할 수 있다(같은 위치에 값을 넣을때 발생) 결국 리스트가 망가지고 병렬 연산이 제대로 수행될 수 없다. 이 문제를 해결하려면 매번 새로운 리스트를 할당해야 하고 결국 객체를 할당하느라 성능이 저하될 것이다.

**************************

 

지금까지 살펴본 예제는 함수형 프로그래밍에서는 하나의 연산을 다양한 방법으로 해결할 수 있었다.

스트림 인터페이스에서 직접 제공하는 메서드를 이용하면 가독성도 좋고 단순하다. 반면 컬렉터를 이용하면 코드가 복잡해진다.

하지만 컬렉터는 코드가 좀 더 복잡한 대신 재사용성과 커스터마이즈 가능성을 제공하는 높은 수준의 추상화와 일반화를 얻을 수 있다.