•
데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터베이스에서 많이 수행되는 작업이다.
•
앞서 예제에서 확인했듯이 명령형으로 그룹화를 구현하려면 까다롭고 귀찮다. 자바 8의 함수형을 사용하면 가독성 있는 한 줄의 코드로 이를 구현할 수 있다.
•
이번에는 메뉴를 그룹화한다고 가정하자, 예를 들어 고기를 포함하는 그룹, 생선을 포함하는 그룹, 나머지 그룹으로 메뉴를 그룹화할 수 있다.
◦
다음처럼 팩토리 메서드 Collectors.groupingBy를 이용해서 쉽게 메뉴를 그룹화할 수 있다.
Map<Dish.Type, List<Dish>> dishesByType = menu.stream().collect(groupingBy(Dish::getType));
Java
복사
◦
스트림의 각 요리 객체에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy 메서드로 전달했다.
◦
이 함수를 기준으로 스트림이 그룹화되므로 이를 분류 함수라고 부른다.
◦
그룹화 연산의 결과로 그룹화 함수가 반환하는 키 그리고 각 키에 대응하는 스트림의 모든 항목 리스트를 값으로 갖는 맵이 반환된다.
◦
단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다.
▪
예를 들어 400 칼로리 이하를 diet로 400 ~ 700 칼로리를 normal로 700 칼로리 이상을 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 컬렉터를 감싼다.
◦
리듀싱 컬렉터가 서브스트림에 연산을 수행한 결과에 collectingAndThen의 Optional::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 컬렉터로 전달되어 집합으로 요소가 누적된다.