Search

[JAVA, Stream] 스트림 결과물을 Map으로 만들어보자! Collectors.toMap(), Collectors.GroupingBy()

작성일
2021/02/28 00:00
수정일
카테고리
자바
태그
Stream

목표

goal : collect() 최종연산에서 스트림을 Map으로 변환하는 두 가지 방법에 대해 알아본다.

예제에서 작성한 코드

javaApiTest
sihyung92

collect 최종연산에서 Map을 만들어주는 두 가지 방법

1.
toMap() 은 단순히 스트림을 Map으로 변환해주고 싶을 때 활용합니다.
2.
groupingBy()는 스트림 요소 들을 특정 조건으로 그룹핑하여 맵으로 반환하고 싶을 때 활용합니다.

활용법

grouping by

SQL의 GROUP BY 같은 작업을 수행하고 싶을 때 활용합니다. 각 반 학생들 중 가장 큰 키를 구한다던지, 학급별 점수 총 합을 구한다던지 하는 연산을 특정한 그룹별로 구하고 싶을 때 유효합니다.
예제로 사용할 클래스는 다음과 같습니다.
class BlogPost { String title; String author; BlogPostType type; int likes; //constructor, getter, setter, equals... } enum BlogPostType { NEWS, REVIEW, GUIDE } class Tuple { BlogPostType type; String author; } List<BlogPost> posts = Arrays.asList( new BlogPost("제목1", "작가1", GUIDE, 1), new BlogPost("제목2", "작가1", REVIEW, 2), new BlogPost("제목3", "작가1", REVIEW, 3), new BlogPost("제목4", "작가2", NEWS, 4), new BlogPost("제목5", "작가3", GUIDE, 5), new BlogPost("제목6", "작가4", REVIEW, 6), //동일한 2건 new BlogPost("제목7", "작가4", GUIDE, 7), new BlogPost("제목7", "작가4", GUIDE, 7) );
Java
복사

하나의 칼럼으로 그룹핑하기

가장 간단하게, key로 삼을 속성을 인자로 줄 수 있습니다. 결과는 스트림에서 key값이 일치하는 요소들을 모아 ArrayList<스트림 요소>로 만들어 value로 삼고, 동일했던 key값을 key로 삼습니다. 예제에서 작가명을 key로 주면, 작가1,작가2,작가3,작가4의 keySet을 가진 HashMap이 반환됩니다.
@DisplayName("keyMapper만 주어서 맵 생성 테스트") @Test void groupingByAuthor() { Map<String, List<BlogPost>> groupByAuthor = posts.stream() .collect(Collectors.groupingBy(BlogPost::getAuthor)); int expectedAuthorsSize = 4; assertEquals(groupByAuthor.size(), expectedAuthorsSize); }
Java
복사

칼럼을 커스텀하여 그룹핑하기

key로 삼을 칼럼을 커스텀하고 싶다면 집적 Function을 전달하는 방법도 있습니다.
@DisplayName("복잡한 keyMapper 활용하여 맵 생성") @Test void gropingByTuple(){ Map<Tuple, List<BlogPost>> postsPerTypeAndAuthor = posts.stream() .collect(Collectors.groupingBy(post -> new Tuple(post.getType(), post.getAuthor()))); assertEquals(postsPerTypeAndAuthor.get(new Tuple(GUIDE,"작가1")).size(), 1); }
Java
복사

value를 원하는 타입으로 그룹핑하기

두번 째 인자는 그룹핑 된 요소들을 어떤 형태로 구조화 할지 결정합니다.
@DisplayName("value를 List가 아니라 원하는 방식으로 mapping") @Test void groupingByModifyingValueType(){ Map<BlogPostType, Set<BlogPost>> postsPerType = posts.stream() .collect(Collectors.groupingBy(BlogPost::getType, Collectors.toSet())); int expectedAuthorsSize = 3; //4개 중 중복건 2개 assertEquals(postsPerType.get(GUIDE).size(), expectedAuthorsSize); }
Java
복사

여러 필드로 그룹핑하기

두번 째 인자를 활용하여 여러 필드로 그룹핑 할 수 도 있습니다. 작가로 그룹핑 한 후, 내부에서 Type으로 그룹핑하는 예제입니다.
@DisplayName("여러 칼럼을 기준으로 그룹핑") @Test void groupingByMultipleColumn() { Map<String, Map<BlogPostType, List<BlogPost>>> groupingAuthorThanType = posts.stream() .collect(Collectors.groupingBy(BlogPost::getAuthor, Collectors.groupingBy(BlogPost::getType))); int expectedSize = 2; assertEquals(groupingAuthorThanType.get("작가1").get(REVIEW).size(), expectedSize); }
Java
복사

그룹 결과의 개수, 평균값, 합계값 구하기

Collectors.counting()을 이용하여 개수를 구할 수 있습니다
Collectors.avergeInt()를 이용하여 평균값을 구할 수 있습니다.(소수점이 나올 수 있으므로 Double로 반환됩니다.)
Collectors.summingInt()를 이용하면 합계값을 구할 수 있습니다.
@DisplayName("그룹한 결과값의 개수 구하기") @Test void gettingGroupingResultsCount() { Map<String, Long> averageLikesPerAuthor = posts.stream() .collect(Collectors.groupingBy(BlogPost::getAuthor, Collectors.counting())); int expectedLikesCount = 3; //[3] assertEquals(averageLikesPerAuthor.get("작가1"), expectedLikesCount); } @DisplayName("그룹한 결과값의 평균 구하기") @Test void gettingGroupingResultsAverage() { Map<String, Double> averageLikesPerAuthor = posts.stream() .collect(Collectors.groupingBy(BlogPost::getAuthor, Collectors.averagingInt(BlogPost::getLikes))); int expectedLikesCount = 2; //[1,2,3] assertEquals(averageLikesPerAuthor.get("작가1"), expectedLikesCount); } @DisplayName("그룹한 결과값의 합계 구하기") @Test void gettingGroupingResultsSum() { Map<String, Integer> averageLikesPerAuthor = posts.stream() .collect(Collectors.groupingBy(BlogPost::getAuthor, Collectors.summingInt(BlogPost::getLikes))); int expectedLikesCount = 6; //[1,2,3] assertEquals(averageLikesPerAuthor.get("작가1"), expectedLikesCount); }
Java
복사

그룹 결과 중 최대, 최소인 요소 구하기

Collectors.maxBy(), minBy()를 이용하면 최대 최소인 요소를 구할 수 있습니다. maxBy 및 minBy는 Comparator를 인자로 받습니다. Comparator API의 comparingInt()를 활용할 수 있습니다.
@DisplayName("그룹 결과의 최대,최소 요소 구하기") @Test void gettingGroupingResultsMaxAndMin() { Map<BlogPostType, Optional<BlogPost>> maxLikesPerPostType = posts.stream() .collect(Collectors.groupingBy(BlogPost::getType, Collectors.maxBy(Comparator.comparingInt(BlogPost::getLikes)))); Map<BlogPostType, Optional<BlogPost>> minLikesPerPostType = posts.stream() .collect(Collectors.groupingBy(BlogPost::getType, Collectors.minBy(Comparator.comparingInt(BlogPost::getLikes)))); int max = 7; int min = 1; assertEquals(maxLikesPerPostType.get(GUIDE).get().getLikes(), max); assertEquals(minLikesPerPostType.get(GUIDE).get().getLikes(), min); }
Java
복사

그룹 결과를 내맘대로 커스텀하기

Collectors.mapping()을 통해 결과값을 내가 원하는 방식으로 저장할 수 있습니다.
@DisplayName("value를 커스텀하여 저장하기") @Test void mappingGroupedResultsToDifferentType() { Map<BlogPostType, String> postsPerType = posts.stream() .collect(Collectors.groupingBy(BlogPost::getType, Collectors.mapping(BlogPost::getTitle, Collectors.joining(", ", "Post titles: [", "]")))); String expectedResult = "Post titles: [제목2, 제목3, 제목6]"; assertEquals(postsPerType.get(REVIEW), expectedResult); }
Java
복사

다른 Map 구현체로 저장하기

두번 째 인자로 Map 생성자를 주면, 다른 Type의 맵을 반환할 수 있습니다. 이럴 경우 downstream(결과 모음)의 조작은 세번째 인자를 통해 하게 됩니다.
@DisplayName("다른 map으로 저장하기") @Test void groupingByAnotherMap() { EnumMap<BlogPostType, List<BlogPost>> postsPerType = posts.stream() .collect(Collectors.groupingBy(BlogPost::getType, () -> new EnumMap<>(BlogPostType.class), Collectors.toList())); //[제목1, 제목5, 제목7, 제목7] int expectedGuideSize = 4; assertEquals(postsPerType.get(GUIDE).size(), expectedGuideSize); }
Java
복사

ThreadSafe하게 만들기

ParallelStream인 경우, Collectors.groupingBy 대신 GroupingByConcurrent()를 활용하여 ConcurrentHashMap을 반환할 수 있습니다.
ConcurrentMap<BlogPostType, List<BlogPost>> postsPerType = posts.parallelStream() .collect(groupingByConcurrent(BlogPost::getType));
Java
복사

toMap

위의 예제를 활용하여 테스트 하였습니다.
class Book { private String name; private int releaseYear; private String isbn; // constructor, getters and setters }
Java
복사
예제용 클래스입니다! 책 이름, isbn번호(국제 표준 도서 번호), 출간년도를 가지고 있습니다.
3권의 책으로 리스트를 만들어보겠습니다.
List<Book> bookList = new ArrayList<>(); bookList.add(new Book("The Fellowship of the Ring", 1954, "0395489318")); bookList.add(new Book("The Two Towers", 1954, "0345339711")); bookList.add(new Book("The Return of the King", 1955, "0618129111"));
Java
복사
활용할 메서드 입니다.

기본 활용

Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper)
제네릭 때문에 다소 복잡해보이지만, keyMapper에 key로 할당할 내용, valueMapper에는 value로 할당할 내용을 전달하면 됩니다.
@DisplayName("Isbn가 key, 책 이름이 value인 맵 생성") @Test void toMapTest() { Map<String, String> IsbnAndName = books.stream() .collect(Collectors.toMap(Book::getIsbn, Book::getName)); int expectedSize = 3; assertEquals(IsbnAndName.size(), expectedSize); }
Java
복사
만약 동일한 key에 value가 들어가는 상황이라면, 기존 map처럼 덮어씌우는게 아닌 IllegalStateException을 throw하게 됩니다.
@DisplayName("동일한 key가 있을 때 오류가 나는지 확인") @Test void whenMapHasDuplicatedKeyTest() { assertThrows(IllegalStateException.class, () -> books.stream() .collect(Collectors.toMap(Book::getReleaseYear, Function.identity()))); }
Java
복사
key가 중복될 수 있는 상황이라면 어찌해야 할까요?

Key가 중복될 수 있는 경우

세번째 인자로 key가 겹쳤을 때 수행할 BinaryOperator를 3번째 인자로 전달하면 됩니다.
Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator mergeFunction)
@DisplayName("키 중복시 병합 로직도 추가한 toMap테스트") @Test void listToMapWithDupKeyTest() { Map<Integer, Book> releaseYearAndBook = books.stream() .collect(Collectors.toMap(Book::getReleaseYear, Function.identity(), (existing, replacement) -> existing)); // (기존 value, 새롭게 들어온 value) → 수행할 행위, 여기선 map.put()과 달리 기존값이 남도록 해보겠습니다. assertEquals(releaseYearAndBook.size(), 2); assertEquals(releaseYearAndBook.get(1954).getName(), "The Fellowship of the Ring"); }
Java
복사
마지막으로 toMap은 기본 구현체로 HashMap()을 채택하고 있는데, 다른 Map을 구현체로 삼는 방법을 알아보겠습니다.

다른 Map 구현체를 이용하고 싶은 경우

Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator mergeFunction, Supplier mapSupplier)
4번째 인자로 Map 생성자를 전달하면 해당 map을 구현체로 삼게 됩니다.
@DisplayName("Hash Map이 아닌 다른 Map으로 생성") @Test void listToConcurrentMapTest() { ConcurrentHashMap<Integer, Book> concurrentHashMap = books.stream() .collect(Collectors.toMap(Book::getReleaseYear, Function.identity(), (o1, o2) -> o1, ConcurrentHashMap::new)); assertTrue(concurrentHashMap instanceof ConcurrentHashMap); }
Java
복사

마지막 한마디

인덴트를 한칸 줄여주는 고마운 스트림~ 선형 자료구조를 Map으로 뚝딱 만들어버리는 다재다능한 스트림~ 애정합니다~~