Collectors! 데이터를 수집해보자.
JAVA8 의 등장하면서 생긴 가장 큰 변화 중 하나는 Stream API 를 통한 함수형 프로그래밍 패러다임을 사용할 수 있게 된 것입니다. 비지니스 로직 중 대부분을 SQL 과 같은 질의로 처리할 수 있게 되었기 때문에 활용을 잘한다면 성능과 간결함이라는 토끼를 모두 잡을 수 있게 된 것이죠.
지난 포스팅까지 살펴본 Stream API 는 파이프라인식으로 여러 메소드를 연결하여 원하는 결과를 질의하는 형태였는데요. 이러한 파이프라인에 해당하는 각 메소드는 데이터를 필터하거나 타입을 변경하는 중간연산(filter, skip, map 등)과 결과를 원하는 형태로 반환하는 최종연산(foreach, collect, reduce)으로 분류할 수 있을 것 같습니다.
오늘은 그 중 최종 연산을 원하는 형태로 질의할 수 있는 방법중 하나인 collect를 집중적으로 알아보려 합니다.
(중간 연산에 대한 내용은 아래에서 참고하실 수 있습니다.)
Stream API를 포스팅 했던 대부분 예제에서는 collect 를 다음과 같이 중간연산의 결과를 List 형태로 출력하는 방법을 많이 사용했었습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class Menu { private Integer price; private String name; public Menu (String name, int price) { this.name = name; this.price = price; } public String getName() { return name; } public Integer getPrice() { return price; } } final List<Menu> menuList = Arrays.asList( new Menu("고기", 1200) , new Menu("랍스타", 7600) , new Menu("피자", 3100)); List<Menu> meatMenuList = menuList.stream(). filter(menu -> menu.getName().equals("고기")). collect(Collectors.toList()); | cs |
collect 부분의 메소드를 조금 zoom-in 해서 보면, Collector 클래스를 파라미터로 받겠금 되어있는 것을 볼 수 있는데 오늘의 메인은 바로 이 Collector 입니다. :-)
1. Collector 란?
Collector 는 Stream API 의 최종연산을 어떻게 도출할 지를 추상화시킨 인터페이스입니다.
해당 인터페이스의 각 구현 방법에 따라 원하는 최종 결과를 수행할 수 있습니다. 원하는 최종 결과를 얻기 위해 구현해야할 항목은 다음과 같습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | public interface Collector<T, A, R> { /** * 새로운 결과 컨테이너를 만들기 위한 메소드 * <pre> * 처음에는 빈 값이 들어가며, 연산 과정을 통해 채워진 후 결과를 출력합니다. * </pre> * * @return */ Supplier<A> supplier(); /** * 컨테이너에 요소를 추가하는 메소드 * <pre> * 공급자 (Supplier) 를 통해 출력해야할 컨테이너에 데이터를 추가합니다. * </pre> * * @return */ BiConsumer<A, T> accumulator(); /** * 병렬 처리(parallelStream) 시, 두 컨테이너를 병합합니다. * * @return */ BinaryOperator<A> combiner(); /** * 최종 변환값을 결과 컨테이너 적용합니다. * * <pre> * 누적된 결과 컨테이너와 최종형태가 같다면, 항등함수( f(x) = x ) 를 내보냅니다. * </pre> * * @return */ Function<A, R> finisher(); /** * 컬렉터 연산 시, 방법에 대한 전략 목록을 정의합니다. * * <pre> * UNORDERED : 리듀싱 결과는 방문 순서나 누적 순서에 영향을 받지 않는다. * CONCURRENT : 누적 컨테이너에 요소를 추가하는 accumulator 메소드를 동시에 호출할 수 있다. * IDENTITY_FINISH : finisher 메소드가 항등함수를 내보내야하는 상황이라면, 해당 메소드를 생략하고 누적객체를 바로 사용한다. * </pre> * * @return */ Set<Characteristics> characteristics(); } | cs |
구현된 Collector 는 위의 메소드를 일련의 논리적 순서를 수행하며 원하는 결과를 도출할 수 있습니다.
즉 collect 를 사용하여 최종결과를 얻기 위해서는 위의 interface 를 사용자가 구현하거나, Collectors 클래스 내부의 toList() 와 같이 이미 구현된 클래스를 사용하는 방법이 있습니다.
Collectors 는 구현화된 전략 Collector 를 출력하는 팩토리로, Stream API 는 이 전략 Collector 를 바탕으로 최종연산을 할 수 있습니다.
Collectors 내부에 정의된 최종연산을 수행하는 방법이 크게 다음과 같은 세 가지 (Reducing, Grouping, Partitioning) 정도의 목적을 가지고 있다고 할 수 있습니다.
2. Reducing
Stream API 와 관련된 포스팅 중 map 과 reduce 연산을 통하여 요약된 결과를 출력하는 map-reduce 패턴에 대해 언급을 한 적이 있습니다. map 과 reduce 를 통해 Collection 의 원하는 부분만을 원하는 결과로 출력할 수 있다는 것은 매우 매력적인 일이라 할 수 있습니다. (해당 내용은 아래 포스트에서 확인할 수 있습니다.)
Collectors 클래스에서도 이러한 요약연산을 할 수 있는 reducing 메소드를 제공하며, 이 외에도 많이 사용될 법한 통계용 전략 Collector 역시 볼 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | final List<Menu> menuList = Arrays.asList( new Menu("고기", 1200) , new Menu("랍스타", 7600) , new Menu("피자", 3100)); // 총 합 menuList.stream().collect(Collectors.summingInt(Menu::getPrice)); // 사용자 정의 총 합 menuList.stream().collect(Collectors.reducing(0, Menu::getPrice, (a, b) -> a+b)); // 사용자 정의 총 합 Optional menuList.stream().map(Menu::getPrice).collect(Collectors.reducing((a, b) -> a+b)).ifPresent(System.out::println); // 연산 통계 IntSummaryStatistics statics = menuList .stream() .collect(Collectors.summarizingInt(Menu::getPrice)); System.out.println("Max : " + statics.getMax()); System.out.println("Min : " + statics.getMin()); System.out.println("sum : " + statics.getSum()); System.out.println("Average : " + statics.getAverage()); System.out.println("count : " + statics.getCount()); // 출력물 // 11900 // Max : 7600 // Min : 1200 // sum : 11900 // Average : 3966.6666666666665 // count : 3 | cs |
Reducing 을 하는 방법은 위와 같이 열려 있으며, 비지니스 로직 구현 시 알맞은 상황따라 구현하면 될 것 같네요.
3. Grouping
SQL 로 쿼리를 만들다보면 특정 필드따라 그룹을 만들고 싶은 경우가 있으며 GROUP BY 키워드를 사용하여 다음과 같이 grouping 된 결과를 얻을 수 있습니다.
1 2 3 4 5 6 7 | SELECT company_subject_sn , COUNT(member_subject_sn) FROM MEMBER GROUP BY company_subject_sn | cs |
Collectors 의 groupingBy 메소드를 사용하면, Collection 객체에 대하여 특정조건으로 분류시키는 작업을 간단하게 만들 수 있습니다.
예를들어 아래의 조직구성원 타입의 Collection 을 회사별, 조직별로 묶는 Map 을 제작은 아래와 같습니다.
비지니스 로직을 처리하기 위한 작업 중 아래와 같이 여러 key 값을 이용하여 Map 을 구성해야할 경우가 다수 존재합니다. 알고리즘 자체는 어려운 것은 아니지만 매번 저렇게 작성을 해야한다는 것은 안타까운 일입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 | /** * 조직 정보를 담은 객체 * * @author Doohyun * */ class OrganizationMember { private Integer companySubjectSn; // 회사 순번 private Integer memberSubjectSn; // 구성원 순번 private Integer organizationSn; // 조직 순번 private String name; // 이름 public OrganizationMember(Integer companySubjectSn, Integer memberSubjectSn, Integer organizationSn, String name) { this.companySubjectSn = companySubjectSn; this.memberSubjectSn = memberSubjectSn; this.organizationSn = organizationSn; this.name = name; } public Integer getCompanySubjectSn() { return companySubjectSn; } public Integer getMemberSubjectSn() { return memberSubjectSn; } public Integer getOrganizationSn() { return organizationSn; } public String getName() { return name; } } List<OrganizationMember> testList = Arrays.asList( new OrganizationMember(5, 1, 1, "남두현") , new OrganizationMember(6, 2, 2, "윤석진") , new OrganizationMember(6, 3, 3, "성지윤") , new OrganizationMember(7, 4, 4, "백선기") , new OrganizationMember(7, 5, 4, "황후순") , new OrganizationMember(5, 6, 1, "이현우") , new OrganizationMember(5, 7, 2, "태재영")); HashMap<Integer, Map<Integer, List<String>>> groupingMap = new HashMap<>(); for (OrganizationMember organizationMember : testList) { final Integer companySubjectSn = organizationMember.getCompanySubjectSn(); final Integer orgSn = organizationMember.getOrganizationSn(); if (!groupingMap.containsKey(companySubjectSn)) { // 회사순번으로 분류 groupingMap.put(companySubjectSn, new HashMap<>()); } final Map<Integer, List<String>> orgMap = groupingMap.get(companySubjectSn); if (!orgMap.containsKey(orgSn)) { // 조직순번으로 분류 orgMap.put(orgSn, new LinkedList<>()); } // 조직순번으로 분류 orgMap.get(orgSn).add(organizationMember.getName()); } System.out.println(groupingMap.toString()); // 출력 // {5={1=[남두현, 이현우], 2=[태재영]}, 6={2=[윤석진], 3=[성지윤]}, 7={4=[백선기, 황후순]}} | cs |
Collectors 팩토리에서 제공하는 groupingBy 를 사용하면 위의 문제를 보다 쉽고 가독성있게 구현할 수 있습니다. 아래 예제는 위의 기능을 Collectors.groupingBy() 를 사용하여 간결하게 작성한 내용입니다.
1 2 3 4 5 6 7 8 9 10 11 | Map<Integer, Map<Integer, List<String>>> groupinParallelMap = testList .parallelStream() .collect( Collectors.groupingBy(OrganizationMember::getCompanySubjectSn , Collectors.groupingBy(OrganizationMember::getOrganizationSn, Collectors.mapping(OrganizationMember::getName, Collectors.toList())))); System.out.println(groupinParallelMap.toString()); // 출력 // {5={1=[남두현, 이현우], 2=[태재영]}, 6={2=[윤석진], 3=[성지윤]}, 7={4=[백선기, 황후순]}} | cs |
파이프라인이 조금 복잡해보이지만, groupingBy 에서 회사, 조직 순번순으로 분류작업을 하였으며, Stream API 의 map 과 비슷한 역할을 하는 mapping 메소드를 통해 조직객체를 이름으로 변경을 했습니다.
4. Partitioning
앞서 설명한 Collectors.groupingBy() 과 비슷하게, Stream 내의 객체들을 특정 Predicate 로 분류하여 그룹화할 수 있는 기능 또한 존재합니다. Collectors.partitioningBy() 을 통해 사용할 수 있습니다.
Predicate 로 분류작업을 수행하기 때문에 반환되는 key 의 값은 Boolean(참, 거짓) 입니다. 그러므로, 결과 Map 은 최대 2 개의 그룹이 존재한다고 봐도 될 것 같습니다.
아래 예제는 회사순번이 5인 그룹과 아닌 그룹으로 분류, 또한 세부 그룹을 조직순번으로 분류한 작업입니다.
1 2 3 4 5 6 7 8 9 10 | Map<Boolean, Map<Integer, List<String>>> groupinParallelMap = testList .parallelStream() .collect( Collectors.partitioningBy((OrganizationMember v) -> v.getCompanySubjectSn() == 5 , Collectors.groupingBy(OrganizationMember::getOrganizationSn , Collectors.mapping(OrganizationMember::getName, Collectors.toList())))); // 출력 // {false={2=[윤석진], 3=[성지윤], 4=[백선기, 황후순]}, true={1=[남두현, 이현우], 2=[태재영]}} | cs |
앞의 예제와 크게 변하지 않고 단순히 파이프라인 한 개만 변할 것을 알 수 있습니다. (이게 함수형 프로그래밍의 장점이죠!!)
또한 이런 분류작업을 병렬로 하고 있음(parallelStream) 을 알 수 있습니다.
오늘 포스팅으로 Stream 에서 진행되는 중간연산과 최종연산을 수행하는 방법을 간략하게는 훑어보게 된 것 같습니다. 이러한 결과로 컨테이너 객체를 이용한 비지니스 로직이 한 결 더 유연하고 간략하며, 최적화까지 할 수 있는 여지가 생겼습니다.
하지만 잘 사용해야 가능한 이야기겠죠? :-)
|
'개발이야기 > 함수형 프로그래밍' 카테고리의 다른 글
병렬처리(2) - 병렬스트림 잘 알고 사용하기! (0) | 2017.02.15 |
---|---|
병렬처리(1) - Stream API 를 이용해 간단히 병렬화 하기. (0) | 2017.02.15 |
Stream API 활용편5 (숫자형 스트림, 스트림 생산) (0) | 2017.01.25 |
Stream API 활용편4 (리듀싱) (0) | 2017.01.23 |
Stream API 활용편3 (검색과 매칭) (0) | 2017.01.18 |