/////
Search
Duplicate
3️⃣

그룹화

데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터베이스에서 많이 수행되는 작업이다.
앞서 예제에서 확인했듯이 명령형으로 그룹화를 구현하려면 까다롭고 귀찮다. 자바 8의 함수형을 사용하면 가독성 있는 한 줄의 코드로 이를 구현할 수 있다.
이번에는 메뉴를 그룹화한다고 가정하자, 예를 들어 고기를 포함하는 그룹, 생선을 포함하는 그룹, 나머지 그룹으로 메뉴를 그룹화할 수 있다.
다음처럼 팩토리 메서드 Collectors.groupingBy를 이용해서 쉽게 메뉴를 그룹화할 수 있다.
Map<Dish.Type, List<Dish>> dishesByType = menu.stream().collect(groupingBy(Dish::getType));
Java
복사
스트림의 각 요리 객체에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy 메서드로 전달했다.
이 함수를 기준으로 스트림이 그룹화되므로 이를 분류 함수라고 부른다.
그룹화 연산의 결과로 그룹화 함수가 반환하는 키 그리고 각 키에 대응하는 스트림의 모든 항목 리스트를 값으로 갖는 맵이 반환된다.
단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다.
예를 들어 400 칼로리 이하diet400 ~ 700 칼로리normal700 칼로리 이상fat으로 분류한다고 가정하자.
Dish 클래스에는 이러한 연산 메서드가 없으므로 메서드 참조를 분류 함수로 사용할 수 없다.
따라서 다음 예제에서 보여주는 것처럼 메서드 참조대신 람다 표현식으로 필요한 로직을 구현할 수 있다.
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream() .collect(groupingBy(dish -> { if (dish.getCalories() <= 400) return CaloricLevel.DIET; else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL; else return CaloricLevel.FAT; }));
Java
복사
지금까지 메뉴의 요리를 종류 또는 칼로리로 그룹화하는 방법을 살펴봤다. 그러면 요리 종류와 칼로리 두 가지 기준으로 동시에 그룹화할 수 있을까?

1. 그룹화된 요소 조작

요소를 그룹화한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다.
예를 들어 500 칼로리가 넘는 요리만 필터링한다고 할 때
다음 코드처럼 그룹화를 하기 전에 프레디케이트로 필터를 적용해 문제를 해결하려고 할 것이다.
Map<Dish.Type, List<Dish>> caloricDishesByType = menu.stream().filter(dish -> dish.getCalories() > 500) .collect(groupingBy(Dish::getType));
Java
복사
위 코드로 문제를 해결할 수 있지만 단점도 존재한다. 메뉴 요리는 다음처럼 맵 형태로 되어 있으므로 코드에 위 기능을 사용하려면 맵에 코드를 적용해야 한다.
{OTHER=[french fries, pizza], MEAT=[pork, beef]}
Java
복사
작성한 필터 프레디케이트를 만족하는 FISH 종류 요리는 없으므로 결과 맵에서 해당 키 자체가 사라진다.
Collectors 클래스는 일반적인 분류 함수에 Collector 형식의 두 번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해서 이 문제를 해결한다.
Map<Dish.Type, List<Dish>> caloricDishesByType = menu.stream() .collect(groupingBy(Dish::getType, filtering(dish -> dish.getCalories() > 500, toList())));
Java
복사
filtering 메소드는 Collectors 클래스의 또 다른 정적 팩토리 메서드로 프레디케이트를 인수로 받는다.
이 프레디케이트로 각 그룹의 요소와 필터링 된 요소를 재그룹화한다. 이렇게 해서 아래 결과 맵에서 볼 수 있는 것처럼 목록이 빈 FISH 항목도 추가된다.
{OTHER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}
Java
복사
그룹화된 항목을 조작하는 다른 유용한 기능 중 또 다른 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다.
filtering 컬렉터와 같은 이유로 Collecotrs 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다.
예를 들어 이 함수를 이용해 그룹의 각 요리를 관련 이름 목록으로 변환할 수 있다.
Map<Dish.Type, List<String>> dishNamesByType = menu.stream() .collect(groupingBy(Dish::getType, mapping(Dish::getName, toList())));
Java
복사
이전 예제와 달리 결과 맵의 각 그룹은 요리가 아니라 문자열 리스트다. groupingBy와 연계해 세 번째 컬렉터를 사용해서 일반 맵이 아닌 flatMap 변환을 수행할 수 있다.
아래 연산은 flatMapping 연산 결과를 수집해서 리스트가 아니라 집합으로 그룹화해 중복 태그를 제거한다.
Map<Dish.Type, Set<String>> dishNamesByType = menu.stream() .collect(groupingBy(Dish::getType, flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())));
Java
복사

2. 다수준 그룹화

두 인수를 받는 팩토리 메서드 Collectors.groupingBy를 이용해서 항목을 다수준으로 그룹화할 수 있다. Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다.
즉 바깥쪽 groupingBy 메서드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있다.
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> disheByTypeCaloricLevel = menu.stream().collect( groupingBy(Dish::getType, groupingBy(dish -> { if (dish.getCalories() <= 400) return CaloricLevel.DIET; else if (dish.getCaloriese() <= 700) return CaloricLevel.NORMAL; else return CaloricLevel.FAT; }) ) );
Java
복사
그룹화의 결과로 다음과 같은 두 수준의 맵이 만들어진다.
{MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]}, FISH={DIET=[prawns], NORMAL=[salmon]}, OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}
Java
복사
외부 맵은 첫 번째 수준의 분류 함수에서 분류한 키값 ‘fish, meat, other’를 갖는다. 그리고 외부 맵의 값은 두 번째 수준의 분류 함수의 기준 ‘normal, diet, fat’을 키 값으로 갖는다.
최종적으로 두 수준의 맵은 첫 번째 키와 두 번째 키의 기준에 부합하는 요소 리스트를 값으로 갖는다. 다수준 그룹화 연산은 다양한 수준으로 확장할 수 있다.
즉 n수준 그룹화의 결과는 n수준 트리 구조로 표현되는 n수준 맵이 된다.
groupingBy의 연산을 버킷 개념으로 생각하면 쉽다. 첫 번째 groupingBy는 각 키의 버킷을 만든다. 그리고 준비된 각각의 버킷을 서브스트림 컬렉터로 채워가기를 반복하며 n수준 그룹화를 달성한다.

3. 서브그룹으로 데이터 수집

두 번째 groupingBy 컬렉터를 외부 컬렉터로 전달해서 다수준 그룹화 연산을 구현했다.
첫 번째 groupingBy로 넘겨주는 컬렉터의 형식은 제한이 없다. 다음 코드처럼 groupingBy 컬렉터에 두 번째 인수로 counting 컬렉터를 전달해서 메뉴에서 요리의 수를 종류별로 계산할 수 있다.
Map<Dish.Type, Long> typesCount = menu.stream().collect( groupingBy(Dish::getType, counting())); // : 다음은 결과 맵이다. {MEAT=3, FISH=2, OTHER=4}
Java
복사
분류 함수 한 개의 인수를 갖는 groupingBy(f)는 사실 groupingBy(f, toList())의 축약형이다.
요리의 종류를 분류하는 컬렉터로 메뉴에서 가장 높은 칼로리를 가진 요리를 찾는 프로그램도 다시 구현할 수 있다.
Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream() .collect(groupingBy(Dish::getType, maxBy(comparingInt(Dish::getCalories))));
Java
복사
그룹화의 결과로 요리의 종류를 키로, Optional<Dish>를 값으로 갖는 맵이 반환된다. Optional<Dish>는 해당 종류의 음식 중 가장 높은 칼로리를 래핑한다.
팩토리 메서드 maxBy가 생성하는 컬렉터의 결과 형식에 따라 맵의 값이 Optional 형식이 되었다. 실제로 메뉴의 요리 중 Optional.empty()를 값으로 갖는 요리는 존재하지 않는데 말이다.
처음부터 존재하지 않는 요리의 키는 맵에 추가되지 않기 때문이다. groupingBy 컬렉터는 스트림의 첫 번째 요소를 찾은 이후에야 그룹화 맵에 새로운 키를 추가한다.
리듀싱 컬렉터가 반환하는 형식을 사용하는 상황이므로 굳이 Optional 래퍼를 사용할 필요가 없다
컬렉터 결과를 다른 형식에 적용하기
마지막 그룹화 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다. 즉팩토리 메서드 Collecotrs.collectingAndThen으로 컬렉터가 반환한 결과를 응용할 수 있다.
Map<Dish.Type, Dish> mostCaloricByType = menu.stream() .collect(groupingBy(Dish::getType, // 분류 함수 collectingAndThen(maxBy(comparingInt(Dish::getCalories)), // 감싸인 컬렉터 Optional::get))); // 변환 함수
Java
복사
groupingBy는 가장 바깥쪽에 위치하면서 요리의 종류에 따라 메뉴 스트림을 각 세 개의 서브 스트림으로 그룹화한다.
groupingBy 컬렉터는 collectingAndThen 컬렉터를 감싼다. 따라서 두 번째 컬렉터는 그룹화된 세 개의 서브스트림에 적용된다.
collectingAndThen 컬렉터는 maxBy 컬렉터를 감싼다.
리듀싱 컬렉터가 서브스트림에 연산을 수행한 결과에 collectingAndThenOptional::get 변환 함수가 적용된다.
groupingBy 컬렉터가 반환하는 맵의 분류 키에 대응하는 세 값이 각각의 요리 형식에서 가장 높은 칼로리다.
groupingBy와 함께 사용하는 다른 컬렉터 예제
일반적으로 스트림에서 같은 그룹으로 분류된 요소들에 리듀싱 작업을 수행할 때, 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 사용한다.
예를 들어 메뉴에 있는 모든 요리의 칼로리 합계를 구하려고 만들어 두었던 컬렉터를 재사용할 수 있다. 물론 여기서는 각 그룹으로 분류된 요리 그룹의 총 칼로리 합을 반환한다.
Map<Dish.Type, Integer> totalCaloriesByType = menu.stream().collect(groupingBy(Dish::getType, summingInt(Dish::getCalories)));
Java
복사
이 외에도 mapping 메서드로 만들어진 컬렉터도 groupingBy와 자주 사용된다. mapping 메서드는 스트림의 인수를 변환하는 함수와 변환 함수의 결과 객체를 누적하는 컬렉터를 인수로 받아 동작한다.
mapping은 입력 요소를 누적하기 전에 매핑 함수를 적용해서 다양한 형식의 객체를 주어진 형식의 컬렉터에 맞게 변환하는 역할을 수행한다.
예를 들어 각 요리 형식에 존재하는 모든 칼로리의 레벨을 구하고 싶다고 하자. 다음 코드처럼 작성하여 이 기능을 구현할 수 있다.
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType = menu.stream().collect( groupingBy(Dish::getType, mapping(dish -> { if (dish.getCalories() <= 400) return CaloricLevel.DIET; else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL; else if return CaloricLevel.FAT; }, toSet() ))); // 다른 형태의 자료구조를 원한다면 toCollection(HashSet::new) 등을 대신 사용하면 된다.
Java
복사
mapping 메서드에 전달한 변환 함수는 Dish를 CalroicLevel로 매핑한다. 그리고 해당 결과 스트림은 toSet 컬렉터로 전달되어 집합으로 요소가 누적된다.