CodeGym /행동 /JAVA 25 SELF /사용자 정의 Collector와 Spliterator

사용자 정의 Collector와 Spliterator

JAVA 25 SELF
레벨 33 , 레슨 4
사용 가능

1. 사용자 정의 Collector: 언제, 어떻게 작성할까

Java Stream API에서는 스트림을 컬렉션이나 집계 결과로 변환할 때 Collector 인터페이스를 사용합니다. 보통은 Collectors 클래스의 준비된 컬렉터(toList(), toMap(), groupingBy() 등)를 사용하지만, 가끔은 특별한 요구가 있을 수 있고 — 그럴 때는 직접 Collector를 작성할 수 있습니다.

Collector는 스트림의 요소로부터 최종 결과를 어떻게 수집할지 정의하는 객체입니다. 네 가지(사실은 다섯 가지) 핵심 구성 요소를 규정합니다:

  • supplier — 요소를 수집할 새 컨테이너를 생성합니다(예: 새 리스트나 맵).
  • accumulator — 다음 요소를 컨테이너에 추가합니다.
  • combiner — 두 컨테이너를 병합합니다(병렬 스트림에서 중요!).
  • finisher — 컨테이너를 최종 결과로 변환합니다(예: 불변으로 만들거나 다른 타입으로 변환).
  • characteristics — 컬렉터의 속성을 나타내는 플래그 집합입니다(예: 병렬 처리 지원 여부, 결과 타입 변경 여부 등).

시그니처:

Collector<T, A, R>
  • T — 스트림 요소의 타입,
  • A — 중간 누산기 타입,
  • R — 결과 타입.

2. 예시: MultiMap을 위한 Collector (Map<K, List<V>>)

예를 들어, Pair<K, V>의 스트림을 Map<K, List<V>>(멀티맵)으로 수집하여 각 키에 값 리스트를 매핑하고 싶다고 합시다.

구현 예:

public static <K, V> Collector<Pair<K, V>, ?, Map<K, List<V>>> toMultiMap() {
    return Collector.of(
        HashMap::new, // supplier
        (map, pair) -> map.computeIfAbsent(pair.key(), k -> new ArrayList<>()).add(pair.value()), // accumulator
        (map1, map2) -> { // combiner
            map2.forEach((k, vList) -> map1.merge(k, vList, (l1, l2) -> { l1.addAll(l2); return l1; }));
            return map1;
        },
        Function.identity(), // finisher
        Collector.Characteristics.UNORDERED
    );
}

사용 예:

List<Pair<String, Integer>> pairs = List.of(
    new Pair<>("a", 1), new Pair<>("b", 2), new Pair<>("a", 3)
);

Map<String, List<Integer>> multiMap = pairs.stream().collect(toMultiMap());
// multiMap: {a=[1, 3], b=[2]}

3. 예시: 상위 N개 요소를 위한 Collector

예를 들어, 스트림에서 가장 큰 요소 N개를 리스트로 수집하고 싶습니다(예: 내림차순 상위 5개).

구현:

public static <T> Collector<T, ?, List<T>> topN(int n, Comparator<? super T> comparator) {
    return Collector.of(
        () -> new PriorityQueue<>(n, comparator), // supplier
        (pq, t) -> {
            pq.offer(t);
            if (pq.size() > n) pq.poll(); // 가장 작은 값을 제거
        },
        (pq1, pq2) -> {
            pq2.forEach(t -> {
                pq1.offer(t);
                if (pq1.size() > n) pq1.poll();
            });
            return pq1;
        },
        pq -> {
            List<T> result = new ArrayList<>(pq);
            result.sort(comparator.reversed()); // 내림차순
            return result;
        },
        Collector.Characteristics.UNORDERED
    );
}

사용 예:

List<Integer> top3 = Stream.of(5, 1, 9, 3, 7, 2).collect(topN(3, Comparator.naturalOrder()));
// top3: [9, 7, 5]

4. 언제 직접 Collector를 작성하지 말아야 하는가

  • 표준 컬렉터와 다운스트림 연산(groupingBy, mapping, flatMapping, collectingAndThen 등)의 조합으로 문제를 표현할 수 있다면, 그것들을 사용하는 것이 더 낫습니다.
  • 자체 Collector는 특수한 데이터 구조, 복잡한 집계, 상위 N, 멀티맵 등과 같은 정말 비정형적인 시나리오에만 필요합니다.
  • Collector를 위한 Collector를 만들지 마세요 — 유지보수와 테스트만 복잡해집니다.

예시:

// Map<K, Set<V>>를 위한 직접 Collector 대신:
.collect(Collectors.groupingBy(
    Pair::key,
    Collectors.mapping(Pair::value, Collectors.toSet())
))

5. 사용자 정의 Spliterator: 왜 그리고 어떻게

Spliterator는 컬렉션(또는 다른 데이터 소스)을 효율적으로 순회하고 분할하기 위한 특별한 인터페이스로, 특히 병렬 처리에 유용합니다. 일반적인 이터레이터와 달리, Spliterator는 병렬 처리를 위해 컬렉션을 서로 독립적인 조각으로 ‘분할’(split)할 수 있습니다.

핵심 메서드:

  • tryAdvance(Consumer<? super T> action) — 다음 요소를 처리합니다.
  • trySplit() — 컬렉션을 두 부분으로 나누려고 시도합니다(한 부분에 대한 새 Spliterator를 반환).
  • estimateSize() — 남은 요소 수의 추정값.
  • characteristics() — 특성 비트 마스크(ORDERED, SIZED, SUBSIZED 등).

trySplit: 분할 전략

균형 잡힌 분할 — 병렬 스트림에서 중요합니다: trySplit은 작업이 고르게 분배되도록 크기가 비슷한 부분을 반환해야 합니다.

분할할 수 없다면(예: 요소가 너무 적음) — null을 반환합니다.

예시: 파일을 청크 단위로 읽는 Spliterator
큰 파일을 메모리에 모두 올리지 않고 한 번에 1000줄씩(청크) 처리하고 싶다고 합시다.

public class ChunkedLineSpliterator implements Spliterator<List<String>> {
    private final BufferedReader reader;
    private final int chunkSize;

    public ChunkedLineSpliterator(BufferedReader reader, int chunkSize) {
        this.reader = reader;
        this.chunkSize = chunkSize;
    }

    @Override
    public boolean tryAdvance(Consumer<? super List<String>> action) {
        List<String> chunk = new ArrayList<>(chunkSize);
        try {
            String line;
            for (int i = 0; i < chunkSize && (line = reader.readLine()) != null; i++) {
                chunk.add(line);
            }
            if (chunk.isEmpty()) return false;
            action.accept(chunk);
            return true;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    @Override
    public Spliterator<List<String>> trySplit() {
        // 파일의 스트리밍 읽기에서는 분할이 의미 없으므로 null 반환
        return null;
    }

    @Override
    public long estimateSize() {
        return Long.MAX_VALUE; // 사전에 알 수 없음
    }

    @Override
    public int characteristics() {
        return ORDERED | NONNULL;
    }
}

사용 예:

try (BufferedReader reader = Files.newBufferedReader(Path.of("big.txt"))) {
    StreamSupport.stream(new ChunkedLineSpliterator(reader, 1000), false)
        .forEach(chunk -> processChunk(chunk));
}

Spliterator 특성

  • ORDERED — 요소가 특정 순서를 가집니다(예: 리스트).
  • SIZED — 요소의 정확한 개수를 알고 있습니다.
  • SUBSIZEDtrySplit으로 생성된 모든 Spliterator도 SIZED입니다.
  • IMMUTABLE — 순회 중 소스가 변경되지 않습니다.
  • CONCURRENT — 소스가 안전한 병렬 수정을 지원합니다.
  • DISTINCT, SORTED, NONNULL — 추가 속성.

중요: 특성을 올바르게 지정하는 것은 스트림 최적화에 영향을 줍니다.

6. 예시

  • 파일을 청크(덩어리)로 읽기 — 큰 파일을 전체 메모리에 올리지 않고 부분적으로 처리할 수 있습니다.
  • 불필요한 할당 없이 파싱 — 바이트/문자 스트림을 파싱하면서 임시 객체 생성을 최소화하려면, 원본 배열의 “윈도” 또는 “슬라이스”를 반환하는 Spliterator를 구현할 수 있습니다.

예시: CSV를 행 단위로 파싱하는 Spliterator

public class CsvLineSpliterator implements Spliterator<String[]> {
    private final BufferedReader reader;

    public CsvLineSpliterator(BufferedReader reader) {
        this.reader = reader;
    }

    @Override
    public boolean tryAdvance(Consumer<? super String[]> action) {
        try {
            String line = reader.readLine();
            if (line == null) return false;
            action.accept(line.split(","));
            return true;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    @Override
    public Spliterator<String[]> trySplit() {
        return null; // 순차 파싱
    }

    @Override
    public long estimateSize() {
        return Long.MAX_VALUE;
    }

    @Override
    public int characteristics() {
        return ORDERED | NONNULL;
    }
}

7. parallel()과의 통합 — 안전하게 구현하는 법

  • Spliterator가 병렬 분할을 지원하고(trySplitnull이 아님), 특성에 SIZED/SUBSIZED가 포함되어 있으면 Stream API가 효율적으로 병렬화할 수 있습니다.
  • 스트리밍 소스(파일, 소켓)에서는 보통 분할을 지원하지 않습니다 — 순차 스트림을 사용하세요.
  • 컬렉션과 배열의 경우 — 균형 잡힌 분할을 구현하세요(예: 배열을 반으로 나누기).

예시: 배열용 Spliterator

public class ArraySpliterator<T> implements Spliterator<T> {
    private final T[] array;
    private int start, end;

    public ArraySpliterator(T[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    public boolean tryAdvance(Consumer<? super T> action) {
        if (start < end) {
            action.accept(array[start++]);
            return true;
        }
        return false;
    }

    @Override
    public Spliterator<T> trySplit() {
        int mid = (start + end) >>> 1;
        if (mid == start) return null;
        ArraySpliterator<T> split = new ArraySpliterator<>(array, start, mid);
        start = mid;
        return split;
    }

    @Override
    public long estimateSize() {
        return end - start;
    }

    @Override
    public int characteristics() {
        return ORDERED | SIZED | SUBSIZED | IMMUTABLE;
    }
}

사용 예:

String[] arr = {"a", "b", "c", "d"};
StreamSupport.stream(new ArraySpliterator<>(arr, 0, arr.length), true)
    .forEach(System.out::println);
1
설문조사/퀴즈
컬렉션 최적화, 레벨 33, 레슨 4
사용 불가능
컬렉션 최적화
컬렉션 최적화
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION