본문 바로가기

Java/JAVA

Java Stream Collector 반쪽짜리 스트림을 쓰던 그대에게. Advanced Stream!

반응형

Java Stream "Collector"

filter, map, reduce, ... 뭐 이 정도?

이번에 "모던 자바 인 액션"이라는 책을 다시 보면서 반쪽짜리 스트림을 쓰고 있었구나... 하는 생각이 들었습니다.

이전에는 filter, map, reduce, flatmap, ..등 대충 이 정도는 어떻게 동작하는지에 대해서 알고 있고, 실제 업무에서도 사용해봤기 때문에 속칭 "스트림 좀 쓴다!" 하고 있었습니다.(

자부심

)

그러나 이번에 "Collector"쪽을 보면서 배울 게 아직 많구나 하는 느낌을 받았습니다.

Collector를 보고 난 후, 제 코드에서 확인한 것은 스트림에서 대부분의 마무리를 .collect(Collectors.toList()) 로 끝내고 있었던 것입니다.

🐲화룡점정이라 했던가요? 마지막 스트림 연산에 생명을 불어넣어 고급스러운(?) 스트림을 만들어야 했는데 항상 대충 마무리 지은 것이지요.

이번에 Collectors의 유틸 메소드를 본 이후로는 위와 같이 마무리하지 않으려고 마음먹었습니다.

그래서 해당 내용을 정리하려고 합니다. (물론 Collectors.toList() 역시도 Collectors의 좋은 유틸 메소드고 적절하게 활용되면 좋습니다. 오해없기😄)

Collector

Collector는 스트림에서 종단연산에 해당하는 .collect() 함수의 파라미터에 해당하는 인터페이스입니다.

이 인터페이스의 구현을 통해 어떻게 reduce할지를 결정합니다.

일일이 구현이 가능한 것도 장점이지만, 더 눈에 띄는 부분은 Collectors 라는 클래스에 정적메소드로 미리 유틸함수를 제공하는 것입니다.

여기에 있는 것을 익히면서 사용해도 좋고, 나중에 커스텀하게 구현할 때도 참고할 수 있어서 좋습니다.

아, 살짝 다른 얘긴데, 자바8 이전에는 인터페이스가 정적 메소드를 가질 수 없었기 때문에 뒤에 s를 붙여 정적메소드를 제공하는 클래스를 만드는 관습이 있었습니다. (ex. Collection<E>Collections, ComparatorComparators, ...)

그래서 아마 Collectors이지 않을까 합니다. (물론 요새는 List.of와 같이 기존 인터페이스에 정적 메소드를 만듭니다.)

다시 돌아와서는 아까 언급한대로 미리 구현된 Collector의 구현들을 살펴보고 익히고 내것으로 만들겠습니다. 🚙

Collectors

스트림 데이터를 어떻게 그룹화 하냐에 따라 여러 방식이 나뉩니다.

  • 요약
    • counting
    • maxBy, minBy
    • summingInt, summingLong, summingDouble)
    • averagingInt, averagingLong, averagingDoubl
    • summarizingInt, summarizingLong, summarizingDouble
    • joining
    • toList, toSet, toCollection
  • 다수준 그룹화
    • groupingBy, collectingAndThen
  • 분할
    • partitioningBy

요약

사실 아래 나오는 요약 Collector는 많이 흥미롭지는 않습니다. 😵

특별한 상황에 대입하면 더 좋을법하고, 일반적인 경우에는 Collectors.toList()나, Collectors.toSet()같은 게 더 사용할 일이 많다고 생각합니다. (간단한 소개 스타일로 작성했으니 이런게 있구나 하고 넘어가면 좋을 것 같습니다.)

개수 모으기 - counting

long menuCount = menu.stream().collect(Collectors.counting());
//long menuCount = menu.stream().count();

위 코드의 주석처럼 스트림의 .count()로 가볍게 처리할 수도 있지만, 다른 Collector와 counting을 섞어서 쓸 때 조금 더 유연하게 쓸 수 있어서 좋을 때가 있다고 하니 그렇게 알아뒀습니다.

결과적으로 Collectors.counting()의 경우 스트림의 개수로 요약합니다.


최댓값과 최솟값 - maxBy, minBy

Comparator<User> userCareerComparator = Comparator.comparingInt(User::getCareer);
//직원(User)의 경력(Career)을 기준으로 비교/정렬하는 Comparator 생성
Optional<User> longestCareerUser = users.stream().collect(Collectors.maxBy(userCareerComparator)); 

maxBy(Comparator comparator) 는 파라미터로 비교/정렬을 위한 기준을 제공하는 Comparator를 받고, Comparator를 기준으로 최댓값을 갖는 객체로 요약해서 리턴합니다.

마찬가지로 minBy(Comparator comparator) 는 Comparator를 기준으로 최솟값을 갖는 객체로 요약해서 리턴합니다.


숫자 타입 요약(합계, 평균, 통계) - summingInt, averagingInt, summarizingInt

  • 숫자 합계
int totalSalary = users.stream().collect(Collectors.summingInt(User::getSalary));

summingInt() 는 스트림에 있는 객체를 int 타입으로 매핑하는 함수를 파라미터로 받습니다.

위 코드에서는 각 User 객체의 급여(int로 가정)의 총합계로 요약합니다.

Collectors.summingLong(), Collectors.summingDouble()은 스트림에 있는 객체가 매핑되는 타입만 int에서 long, double로 바뀔 뿐 "합계"로 요약하는 것은 같습니다.

  • 숫자 평균
double avgSalary = users.stream().collect(Collectors.averagingInt(User::getSalary));

averagingInt() 는 스트림에 있는 객체를 int 타입으로 패밍하는 함수를 파라미터로 받습니다.

위 코드에서는 각 User 객체의 급여(int 로 가정)의 평균으로 요약합니다.

Collectors.averagingLong(), Collectors.avaragingDouble()은 마찬가지로 매핑되는 타입만 다를 뿐 "평균"으로 요약합니다.

  • 숫자 통계
IntSummaryStatistics salaryStatistics = users.stream().collect(Collectors.summarizingInt(User::getSalary));

여러 통계를 종합해놨습니다. 그래서 리턴 타입도 IntSummaryStatistics 인 것을 확인할 수 있습니다. (객체를 콘솔로 찍어보면 아래와 같이 나옵니다.)

IntSummaryStatistics{count=10, sum=40000000, min 2980000, average=4000000, max=5840000}

문자열 연결 - joining

String employeeNames = users.stream().map(User::getName).collect(joining());
//MeganAddisonAmeliaElla
String employeeNames = users.stream().map(User::getName).collect(joining(", "));
//Megan, Addison, Amelia, Ella

joining은 내부적으로 StringBuilder를 이용해서 객체들의 toString() 메서드를 호출한 결과(String)를 연결하여 요약된 문자열을 만듭니다.

오버로딩된 joining메소드도 있어서 중간에 연결할 때 사이에 들어갈 문자열을 지정할 수도 있습니다.

기본에 충실한 요약 - reducing

오버로드된 reducing()메소드는 잘 참고해서 사용해야한다.

기본적으로 파라미터를 받는데, (초기값[또는 스트림이 비었을 때 값], 변환 함수, 같은 종류의 두 항목을 하나로 만드는 함수) 이렇게 3개다.

int totalSalary = users.stream().collect(reducing(0, User::getSalary, (i,j) -> i+j));

위 코드처럼 작성하면 0을 초기값으로하고 스트림으로 들어오는 객체(User)를 급여로 변환하고, 그 변환된 급여 두 항목을 더하여 하나로 만드는 함수를 통해 급여의 총합을 구하는 것을 확인할 수 있다.


그룹화

사실 요약보다 그룹화, 이게 좀 흥미로웠습니다.

스트림 사용 이후에 처리하기 조금 더 편리한 형태 즉, 내가 원하는 자료 구조로 그룹화 시킬 수 있다는 게 상당히 매력적입니다. 🌈

groupingBy

Map<User.Position, List<User>> usersByPosition = users.stream().collect(groupingBy(User::getPosition));

코드부터 보면 이해가 더 빠릅니다.

전체 직원(Users)에서 직책(Position)별로 나눠서 직원을 그룹화했습니다.

결과는 아래와 같이 나온다고 생각하면 됩니다. 단, 간단하게 표현한 것이라 tony, alex, ... 이렇게 이름만 표시했을 뿐 원래대로라면 User객체에 대한 toString이 나와야 더 정확합니다.

{MANAGER=[tony, alex, oliveia], STAFF=[elsa, mayya, lily, jacob, jace], CEO=[eric]

groupingBy의 파라미터로 온 분류함수를 기준에 의해 Map의 key로가고 객체들은 value로 그룹화되었습니다.

위에서는 단순히 getPosition으로 직책만 가져왔기 때문에 분류 함수 느낌이 안 납니다.

그리고 여러 조건에 의해서 분류하고 싶을 수 있습니다.

그런 요구사항을 아래의 코드처럼하면 만족시킬 수 있습니다.

Map<User.Position, List<User>> excellentUsersByPosition =
users.stream()
     .collect(groupingBy(User::getPosition,
                                                 filtering(user -> user.getScore() > 100, toList())));

직책별로 우수사원을 뽑기위해 groupingBy의 오버라이드 메소드를 이용했습니다.

User::getPosition으로 직책으로 분리를 우선하고, filtering 즉 프레디케이트(predicate) 조건을 갖는 Collector를 이용하여 필터링된 객체를 다시 그룹화합니다. (결과는 아래를 참조하면 됩니다.)

{MANAGER=[tony], STAFF=[lily, jacob], CEO=[]}

filtering 말고 mapping 도 있습니다. (둘 다 Collector)

이름만 봐도 객체로 그룹화하는게 아니라 뭔가 다른 타입으로 매핑하는 것을 알 수 있습니다.

Map<User.Position, List<User>> excellentUserAgeByPosition = 
users.stream()
         .collect(groupingBy(User::getPosition,
                                                 mapping(User::getAge, toList())));

결과는 아래죠.

{MANAGER=[39,37,37], STAFF=[29, 33, 33, 31, 37], CEO=[55]}

flatMapping 도 있지만 다루진 않겠습니다.


다수준 그룹화

위에서 사용한 것은 그룹화가 한 단계만 되어있습니다만, 다 단계로 그룹화할 수도 있습니다.

Map<User.Position, Map<Department, List<User>>> usersByPositionDepartment = 
users.stream().collect(
    groupingBy(User::getPosition,
        groupingBy(User::getDepartment))
);

결과는 아래와 같습니다.

{
 MANAGER={RND=[tony], QA=[alex], HR=[oliveia]},
 STAFF={RND=[elsa, mayya], QA=[lily, jacob], HR=[jace],
 CEO={MANAGEMENT=[eric]}
}

바깥에 있는 groupingBy에 의해서 직책(position)별로 그룹화를 한 후에, 안에 있는 groupingBy에 의해서 다시 부서(Department)별로 그룹화한 결과로 맵 안에 맵을 갖는 자료형태를 갖게 된 것을 확인할 수 있습니다. 😀

어떻게 이렇게 할 수 있었는지는 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);
}

위와 같이 groupingBy 의 첫 번째 파라미터는 분류 함수를 받고, 두 번째 파라미터는 Collector를 받습니다. 그래서 이 두 번째 파라미터에 있는 Collector로 연계해서 만든 것이지요.

이것을 이용하면 2개 이상 수준의 다단계 그룹화도 가능합니다.

앞에서 1단계 그룹화를 했던 것은 Collector 부분에 toList()를 사용한 오버로딩된 정적 메소드를 사용한 것을 확인할 수 있습니다.

public static <T, K> Collector<T, ?, Map<K, List<T>>>
    groupingBy(Function<? super T, ? extends K> classifier) {
    return groupingBy(classifier, toList());
}

분할

분할은 그룹화랑 다른 게 하나 밖에 없습니다.

그룹화는 N개의 그룹으로 그룹화할 수 있는 반면, 분할은 무조건 2개의 그룹으로 그룹화할 수 있는 것입니다. (2분할!)

이걸 뭐하러 쓰지? 할 수도 있는데 더 명확하게 표현할 수 있는 장점이 있습니다.

정규직이냐 비정규직이냐 나눌 때를 예로 든다면, 이와 같은 분할이 훨씬 명확합니다.

그룹화로 시도한다면 코드를 읽다가 오해를 살 수도 있습니다. (정규직, 무기계약직, 계약직, ...)

partitioningBy

Map<Boolean, List<User>> contractedEmployee = users.stream().collect(partioningBy(User::isFulltimeJob));
//결과
//{false=[jacob, eric], true=[tony, alex, oliveia, elsa, mayya, lily, jace]}

다시 한 번 의문이 들 수 있습니다.

이럴꺼면 그냥 filter로 하면 되는거 아닌가? 하고요. 그런데 그렇지 않습니다.

filter로 스트림에서 나눠버리면 필터링되지 않은 그룹은 사용할 수가 없는 문제가 있습니다. (아래 코드 참조)

users.stream().filter(User::isFulltimeJob).collect(Collectors.toList());
//정규직이 아닌 그룹은 사용할 수 없는 단점...

이렇게 까지해서 자바에서 제공하는 Collector 에 대해서 알아봤습니다.

Collector 는 인터페이스이기 때문에 입맛에 맞게 구현해서 사용할 수도 있습니다만, 거기까지는 다루지 않겠습니다.

이런 코드는 특히나 개발자간의 합의에 의해서 관리되어야 한다는 생각을 갖고 있기 때문에, 자바에서 제공해주는 위의 메소드들을 이해하고 사용해보면서 실력을 늘리는 것 만으로도 충분하는 생각입니다.

참고 도서 : 모던 자바 인 액션

반응형