[ODOP:10][분석실패] Java Stream - Collector 의 groupingBy

Java 의 Stream 에서 Collector 의 내부 코드를 까보며 이해하는 내용 입니다 완전한 분석이 이루어지지 않았지만 이정도만 해도 이해가 되었기에 중략 하였습니다

아래의 코드는 제네릭을 많이 사용하며 함수형으로 만들어져있기 때문에 이해하기 어렵다
한 줄 한 줄 읽어 보면서 자세히 이해 해 보자
Map<String, List<UUID>> collect = users.stream()  
    .collect(  
       Collectors.groupingBy(  
          User::group,  
          Collectors.mapping(  
             User::id,  
             Collectors.toList()  
          )  
       )  
    );  
  
System.out.println("collect = " + collect);

코드 구조 분해

위 코드를 좀 더 보기 쉽게 구조를 분해 해 보자

// 1단계
Collector<User, ?, List<UUID>> mapping = Collectors.mapping(  
    User::id,  
    Collectors.toList()  
);  

// 2단계
Collector<User, ?, Map<String, List<UUID>>> userMapCollector = Collectors.groupingBy(  
    User::group,  
    mapping  
);  

// 3단계
Map<String, List<UUID>> collect = users.stream().collect(userMapCollector);  
  
System.out.println("collect = " + collect);

결과는 최초의 코드와 같다

다만, 코드의 구조만 분해 된 것이다

내부 코드 이해하기

먼저 Collectors.mapping 을 이해 해 보자

// 1단계
Collector<User, ?, List<UUID>> mapping = Collectors.mapping(  
    User::id,  
    Collectors.toList()  
);

이 부분 이다

Collectors.mapping

public static <T, U, A, R> Collector<T, ?, R> mapping(
			Function<? super T, ? extends U> mapper,  
            Collector<? super U, A, R> downstream
) {  
    BiConsumer<A, ? super U> downstreamAccumulator = 
									    downstream.accumulator();  
    return new CollectorImpl<>(
			downstream.supplier(),  
			(r, t) -> {
				return downstreamAccumulator.accept(r,mapper.apply(t))
			},  
            downstream.combiner(),
            downstream.finisher(),  
            downstream.characteristics()
	);  
}

위 코드는 실제 Collectors.mapping 함수의 본문 이며 Java 17 기준 이다

먼저 인자를 확인 해 보자

인자는 총 2 가지로 나뉜다

먼저 Function

public interface Function<T, R> {
	R apply(T t);
}

그리고 Collector 이다

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

여기서 자세한건 알 필요는 없고 이 인터페이스의 목적을 조금만 이해하고 넘어가야 한다.

이 인터페이스의 목적은 '변경 가능 한 컨테이너에 입력 요소를 누적하고, 최종 연산에서 변환하는 기능을 서술하기 위해' 만들어 졌다

말이 어렵다

일단 우리가 생각하는 Collection 으로서의 클래스가 아님을 인지하면 된다

타입의 이해

개요

제네릭을 이해하기 위해서는 먼저 해당 제네릭이 어떤 타입인지 이해하면 된다

하나 하나 우리가 작성 한 코드의 타입을 넣어 보며 이해 해 보자

먼저 원본 코드이다

// 1단계
Collector<User, ?, List<UUID>> mapping = Collectors.mapping(  
    User::id,  
    Collectors.toList()  
);

그리고 Collectors.mapping 의 코드이다

public static <T, U, A, R> Collector<T, ?, R> mapping(
			Function<? super T, ? extends U> mapper,  
            Collector<? super U, A, R> downstream
) {  
    BiConsumer<A, ? super U> downstreamAccumulator = 
									    downstream.accumulator();  
    return new CollectorImpl<>(
			downstream.supplier(),  
			(r, t) -> {
				return downstreamAccumulator.accept(r,mapper.apply(t))
			},  
            downstream.combiner(),
            downstream.finisher(),  
            downstream.characteristics()
	);  
}

여기서 우리가 입력 한 타입들로 이 값들을 변경 해 본다

User::id -> Function<? super T, ? extends U> mapper

먼저, User::id 는 record 인 User 의 UUID 인 id 를 리턴하는 getter 이다

우리가 작성 했던 코드를 보자.

// 1단계
Collector<User, ?, List<UUID>> mapping = Collectors.mapping(  
    User::id,  // 이 코드는
    Collectors.toList()  
);

Collector<User, ?, List<UUID>> mapping = Collectors.mapping(  
    user -> user.id(),  // 이렇게도 변경 할 수 있다
    Collectors.toList()  
);

간단히 말해 User 를 받아서 String 으로 반환되는 형태의 함수라는 것이다

Function 클래스를 확인 해 보자

public interface Function<T, R> {
	R apply(T t);
}
  • 앞에있는 T 는 인자
  • 뒤에있는 R 은 리턴 이다

그럼 이제 아래의 이 코드의 타입을 유추 할 수 있다

Function<? super T, ? extends U> mapper

T: User
U: String

변환 해 보자

public static <T, U, A, R> Collector<T, ?, R> mapping(
			Function<? super T, ? extends U> mapper,  
            Collector<? super U, A, R> downstream
) {  
    BiConsumer<A, ? super U> downstreamAccumulator = 
									    downstream.accumulator();  
    return new CollectorImpl<>(
			downstream.supplier(),  
			(r, t) -> {
				return downstreamAccumulator.accept(r,mapper.apply(t))
			},  
            downstream.combiner(),
            downstream.finisher(),  
            downstream.characteristics()
	);  
}

위 코드에서 변환 해야 할 부분은 아래와 같다

  • T: User
  • U: String
public static <User, String, A, R> Collector<User, ?, R> mapping(
			Function<User, String> mapper,  
            Collector<String, A, R> downstream
) {  
    BiConsumer<A, String> downstreamAccumulator = 
									    downstream.accumulator();  
    return new CollectorImpl<>(
			downstream.supplier(),  
			(r, user) -> {
				return downstreamAccumulator.accept(r,mapper.apply(user))
			},  
            downstream.combiner(),
            downstream.finisher(),  
            downstream.characteristics()
	);  
}

조금 더 눈에 들어오기 시작 한다

계속 변환 해 보자

Collectors.toList() -> Collector<? super U, A, R> downstream

// 1단계
Collector<User, ?, List<UUID>> mapping = Collectors.mapping(  
    User::id,
    Collectors.toList()  
);

이 코드의 타입을 알기 위해서는 우리가 위에서 변환 했던 결과를 봐야만 한다

public static <User, String, A, R> Collector<User, ?, R> mapping(
			Function<User, String> mapper,  
            Collector<String, A, R> downstream
) {  
    BiConsumer<A, String> downstreamAccumulator = 
									    downstream.accumulator();  
    return new CollectorImpl<>(
			downstream.supplier(),  
			(r, user) -> {
				return downstreamAccumulator.accept(r,mapper.apply(user))
			},  
            downstream.combiner(),
            downstream.finisher(),  
            downstream.characteristics()
	);  
}

여기서 Collector<String 이라는 부분을 확인 할 수 있을 것이다
(일단 Collector<User, ?, R> 은 무시하고 넘어가자. 저건 인자 타입이 아니라 리턴 타입 이므로 무시해도 된다)

다음은 Collectors.toList 의 코드이다

public static <T> Collector<T, ?, List<T>> toList() {  
    return new CollectorImpl<>(
			    ArrayList::new, 
			    List::add,
			    (left, right) -> { left.addAll(right); return left; },  
                CH_ID
	);  
}

잘보면 우리는 한가지 타입을 연계하여 유추 할 수 있다
바로 T 가 무엇인지 이다

위에서 Collector<String 을 유추했음을 이야기 했다

그렇다면 아래의 T 가 String 이라는 것을 알 수 있을 것이다

그럼 변환 해 보자

변환 할 대상은 아래와 같다

  • T: String
public static <String> Collector<String, ?, List<String>> toList() {  
    return new CollectorImpl<>(
			    ArrayList::new, 
			    List::add,
			    (left, right) -> { left.addAll(right); return left; },  
                CH_ID
	);  
}

그런데 이 과정에서 또 하나의 힌트를 더 얻었다

바로 Collector 의 3번째 인자 값이다

public static <User, String, A, R> Collector<User, ?, R> mapping(
			Function<User, String> mapper,  
            Collector<String, A, R> downstream
) {  
    BiConsumer<A, String> downstreamAccumulator = 
									    downstream.accumulator();  
    return new CollectorImpl<>(
			downstream.supplier(),  
			(r, user) -> {
				return downstreamAccumulator.accept(r,mapper.apply(user))
			},  
            downstream.combiner(),
            downstream.finisher(),  
            downstream.characteristics()
	);  
}

여기서 downstream 을 보면 3번째 인자가 R 이라는 것을 알 수 있다

그럼 변환 해 보자
변환 대상은 아래와 같다

  • R: List<String>
public static 
	<User, String, A, List<String>> // 제네릭
	Collector<User, ?, List<String>> // 리턴 타입
	mapping( // 함수 이름
			Function<User, String> mapper,  
            Collector<String, A, List<String>> downstream
) {  
    BiConsumer<A, String> downstreamAccumulator = 
									    downstream.accumulator();  
    return new CollectorImpl<>(
			downstream.supplier(),  
			(stringList, user) -> {
				return downstreamAccumulator
							.accept(stringList, mapper.apply(user))
			},  
            downstream.combiner(),
            downstream.finisher(),  
            downstream.characteristics()
	);  
}

Last A

마지막으로 변환 할 것은 A 이다

downstream 의 accumulator 를 보자

public interface Collector<T, A, R> {
	BiConsumer<A, T> accumulator();
}

여기서 타입 추론이 가능한데, 방법은 위의 코드에서 힌트가 나와있다

downstreamAccumulator.accept(stringList, mapper.apply(user))

바로 이 부분이다

mapper 는 아래의 형태를 가지고 있다

Function<User, String> mapper

따라서 인자값은 User 를 받고, 응답은 String 형태라는 것이다

BiConsumer 를 보면 아래와 같다

public interface BiConsumer<T, U> {
	void accept(T t, U u);
}

여기서 위의 값으로 유추하면 아래와 같이 변환 된다

  • T: List<String>
  • U: String

그럼 타입 추론에 따라 A == U 이므로 U 의 String 이 들어가게 된다

바꿔보자

public static 
	<User, String, String, List<String>> // 제네릭
	Collector<User, ?, List<String>> // 리턴 타입
	mapping( // 함수 이름
			Function<User, String> mapper,  
            Collector<String, String, List<String>> downstream
) {  
    BiConsumer<String, String> downstreamAccumulator = 
									    downstream.accumulator();  
    return new CollectorImpl<>(
			downstream.supplier(),  
			(stringList, user) -> {
				return downstreamAccumulator
							.accept(stringList, mapper.apply(user))
			},  
            downstream.combiner(),
            downstream.finisher(),  
            downstream.characteristics()
	);  
}

그런데 아직 와일드카드는 해결되지 않았다.

하지만 와일드 카드 역시 유추가 가능하다

방금 A == U 라고 했으며, 이 값은 CollectorImpl 을 통해 똑같이 타입추론이 들어갈 수 있게 된다

따라서 와일드카드 역시 String 이 된다

이제 와일드카드도 지우고, 이해하기 쉽게 제네릭도 지워보자

public static 
	Collector<User, String, List<String>> // 리턴 타입
	mapping( // 함수 이름
			Function<User, String> mapper,  
            Collector<String, String, List<String>> downstream
) {  
    BiConsumer<String, String> downstreamAccumulator = 
									    downstream.accumulator();  
    return new CollectorImpl<>(
			downstream.supplier(),  
			(stringList, user) -> {
				return downstreamAccumulator
							.accept(stringList, mapper.apply(user))
			},  
            downstream.combiner(),
            downstream.finisher(),  
            downstream.characteristics()
	);  
}

Collectors.groupingBy

아직 끝난게 아니다

이번에 타입 추론을 해 볼 대상은 위 코드에서 2단계인 코드 묶음이다

// 1단계
Collector<User, ?, List<UUID>> mapping = Collectors.mapping(  
    User::id,  
    Collectors.toList()  
);  

// 2단계
Collector<User, ?, Map<String, List<UUID>>> userMapCollector = Collectors.groupingBy(  
    User::group,  
    mapping  
);  

// 3단계
Map<String, List<UUID>> collect = users.stream().collect(userMapCollector);  
  
System.out.println("collect = " + collect);

1 단계에서 왠만큼 추론을 끝내 놓았으니 2단계는 날로먹을 수 있다

아래는 1 단계에서 추론 한 타입들 이며

public static 
	Collector<User, String, List<String>> // 리턴 타입
	mapping( // 함수 이름
			Function<User, String> mapper,  
            Collector<String, String, List<String>> downstream
) { ... }

아래는 Collectors.groupingBy 함수 이다

public static 
	<T, K, A, D> // 제네릭
	Collector<T, ?, Map<K, D>> // 리턴 타입
	groupingBy( // 함수 이름
		Function<? super T, ? extends K> classifier,
		Collector<? super T, A, D> downstream
) {  
    return groupingBy(classifier, HashMap::new, downstream);  
}

아아.. 실로 Easy 하지 않을 수 없다

downStream 의 추론을 기반으로 정리 해 보면 위 코드에서 알 수 있는 타입들은 아래와 같다

  • T: User
  • A: String
  • D: List<String>

변환 해 보자

public static 
	<User, K, String, List<String>> // 제네릭
	Collector<User, ?, Map<K, List<String>>> // 리턴 타입
	groupingBy( // 함수 이름
		Function<User, ? extends K> classifier,
		Collector<User, String, List<String>> downstream
) {  
    return groupingBy(classifier, HashMap::new, downstream);  
}

나머지는 알아서 잘 추론 할 수 있을 것이라 믿어 의심치 않는다.