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(511"남두현")
                , new OrganizationMember(622"윤석진")
                , new OrganizationMember(633"성지윤")
                , new OrganizationMember(744"백선기")
                , new OrganizationMember(754"황후순")
                , new OrganizationMember(561"이현우")
                , new OrganizationMember(572"태재영"));
        
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 에서 진행되는 중간연산과 최종연산을 수행하는 방법을 간략하게는 훑어보게 된 것 같습니다. 이러한 결과로 컨테이너 객체를 이용한 비지니스 로직이 한 결 더 유연하고 간략하며, 최적화까지 할 수 있는 여지가 생겼습니다. 


하지만 잘 사용해야 가능한 이야기겠죠? :-)



자바 8 인 액션
국내도서
저자 : 라울-게이브리얼 우르마(RAOUL-GABRIEL URMA),마리오 푸스코(MARIO FUSCO),앨런 마이크로프트(ALAN MYCROFT) / 우정은역
출판 : 한빛미디어 2015.04.01
상세보기






반응형
Posted by N'