Java

[모던 자바] Collector 인터페이스 소개 및 구현 예제

Beekei 2022. 3. 8. 12:53
반응형

Collector 인터페이스란?

Collector 인터페이스는 리듀싱 연산(즉, 컬렉터)을 어떻게 구현하지 제공하는 메서드 집합으로 구성된다.

물론 우리가 Collector 인터페이스를 구현하는 리듀싱 연산을 만들 수도 있다.

Collector 인터페이스를 집접 구현해서 더 효율적으로 문제를 해결하는 컬렉터를 만드는 방법을 살펴보자.

 

Collector 인터페이스를 보면 시그니처와 다섯 개의 메서드 정의를 확인할 수 있다.

public interface Collector<T, A, R> {
    Supplier<A> supplier();
    BiConsumer<A, T> accumulator();
    BinaryOperator<A> combiner();
    Function<A, R> finisher();
    Set<Characteristics> characteristics();
}

먼저 시그니처를 보면 T, A, R이 있다.

T는 수집될 스트림 항목의 제네릭 형식이다.

A는 누적자. 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식이다.

R은 수집 연산 결과 객체의 형식(항상 그런 것은 아니지만 대개 컬렉션 형식)이다.


Collector 인터페이스 구현하기

실제로 List를 수집하는 Collector 클래스를 예를 들어 어떻게 구현할지 살펴보자.

public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {

    @Override
    public Supplier<List<T>> supplier() {
        return null;
    }

    @Override
    public BiConsumer<List<T>, T> accumulator() {
        return null;
    }

    @Override
    public BinaryOperator<List<T>> combiner() {
        return null;
    }

    @Override
    public Function<List<T>, List<T>> finisher() {
        return null;
    }

    @Override
    public Set<Characteristics> characteristics() {
        return null;
    }
    
}

 

supplier : 새로운 결과 컨테이너 만들기

supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다.

간단히 말해 reducing 연산을 진행하며 누적할 저장소 공간(A)을 생성한다고 생각하면 될 것 같다. 

ToListCollector에서는 ArrayList형 인스턴스를 만들어 반환한다.

@Override
public Supplier<List<T>> supplier() {
    return ArrayList::new;
}

 

accumulator : 결과 컨테이너에 요소 추가하기

accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다. 

요소를 탐색하면서 적용하는 함수의 의해 누적자 내부상태가 바뀌므로 함수의 반환 타입은 void이다.

ToListCollector에서는 supplier에서 받은 저장소 공간 List(A)에 데이터(T)를 누적하는 로직이 들어가야 한다.

@Override
public BiConsumer<List<T>, T> accumulator() {
    return List::add;
}

 

finisher : 최종 변환값을 결과 컨테이너로 적용하기

finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다.

ToListCollector에서는 스트림 탐색을 진행하며 누적한 데이터(A)와 최종 결과(R)가 같은 형태이므로 변환 과정이 필요하지 않다. Function의 identity 메서드는 데이터를 그대로 반환한다.

@Override
public Function<List<T>, List<T>> finisher() {
    return Function.identity();
}

 

이렇게 supplier, accumulator, finisher세 메서드 만으로도 순차적 스트림 리듀싱 기능을 수행할 수 있다.

하지만 실제로는 collect가 동작하기 전에 다른 중간 연산과 파이프라인을 구성할 수 있게 해주는 게으른 특성, 그리고 병렬 실행 등도 고려해야 하므로 스트림 리듀싱 기능 구현은 생각보다 복잡하다.

 

combiner : 두 결과 컨테이너 병합

combiner 메서드는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다.

ToListCollector에서는 List와 List를 병합하는 코드를 작성하면 된다. 

@Override
public BinaryOperator<List<T>> combiner() {
    return (list1, list2) -> {
        list1.addAll(list2);
        return list1;
    };
}

combiner 메서드까지 이용하면 스트림의 리듀싱을 병렬로 수행할 수 있다.

스트림의 리듀싱을 병렬로 수행할 때 포크/조인 프레임워크와 Spliterator를 사용한다.

스트림을 분항해야 하는지 정의하는 조건이 거짓으로 바뀌기 전까지 원래 스트림을 재귀적으로 분할한다.

보통 분산된 작업의 크기가 너무 작아지면 병렬 수행의 속도는 순차 수행의 속도보다 느려진다. 즉, 병렬 수행의 효과가 상쇄된다. 일반적으로 프로세싱 코어의 개수를 초과하는 병렬 작업은 효율적이지 않다.

 

characteristics 

characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다.

Characteristics는 스트림을 병렬로 리듀스할 것인지 그리고 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다.

Characteristics는 다음 세 항목을 포함하는 열거형이다

  • CONCURRENT
    다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컬렉터 플러그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
  • UNORDERED
    리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
  • IDENTITY_FINISH
    finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다.

    즉, 리듀싱 과정에서 최종 결과 형태로 변환하지 않고 누적자 객체를 바로 사용할 수 있다. 

ToListCollector에서는 누적자 객체와 최종 결과의 형태가 같아서 추가 변환이 필요없으므로 IDENTITY_FINISH다.

그리고 병렬 처리를 실행할것이기 때문에 CONCURRENT로 설정한다.

@Override
public Set<Characteristics> characteristics() {
    return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH, Characteristics.CONCURRENT));
}

 

그럼 구현한 ToListCollector를 가지고 테스트를 해보자.

@ToString
@Getter
@AllArgsConstructor
public class Order {
    private String orderNumber;
    private String productName;
    private Integer payPrice;
    private Currency currency;
}

List<Order> orders = new ArrayList<>() {{
    add(new Order("00001", "아반테", 20000000, Currency.getInstance(Locale.KOREA)));
    add(new Order("00002", "소나타", 30000000, Currency.getInstance(Locale.KOREA)));
    add(new Order("00003", "그랜저", 40000000, Currency.getInstance(Locale.KOREA)));
    add(new Order("00004", "그랜저", 30000, Currency.getInstance(Locale.US)));
}};

List<Order> usOrders = orders.stream()
    .filter(order -> order.getCurrency().equals(Currency.getInstance(Locale.US)))
    .collect(new ToListCollector<>());
// [Order{orderNumber='00004', productName='그랜저', payPrice=30000, currency=USD}]

Collector 구현을 만들지 않고도 커스텀 수집 수행하기

IDENTITY_FINISH 수집 연산에서는 Collector 인터페이스를 완전히 새로 구현하지 않도고 같은 결과를 얻을 수 있다.

Stream은 세 함수(발행, 누적, 합침)을 인수로 받는 collect 메서드를 overload하며 각각의 메서드는 Collector 인터페이스의 메서드가 반환하는 함수와 같은 기능을 수행한다.

List<Order> usOrders = orders.stream()
    .filter(order -> order.getCurrency().equals(Currency.getInstance(Locale.US)))
    .collect(ArrayList::new, List::add, List::addAll);
// [Order{orderNumber='00004', productName='그랜저', payPrice=30000, currency=USD}]

ArrayList::new(발행), List::add(누적), List::addAll(합침)을 인자로 받아 Collector를 구현하지 않고 위 예제를 처럼 구현하였다.

위 예제에 비해 코드가 좀 더 간결하고 축약되어 있지만 가독성은 떨어진다. 적절한 클래스로 커스텀 컬렉터를 구현하는 편이 중복을 피하고 재사용성을 높이는데 도움이 된다.

또한 Characteristics를 전달할 수 없다.

 

반응형