자바로 같은 상품 Grouping
아래 장바구니 페이지를 보면 손목 보호대는 같은 상품 2개가 묶여서 담겨있다. 하지만 팔꿈치 보호대는 같은 상품임에도 1개씩 개별적으로 담겨있다. 그래서 같은 상품끼리 Grouping 해주는 작업이 추가적으로 필요하다.
먼저 Product 와 Item 의 관계를 이해해야한다. 위에서 Product 는 '슈퍼비스트 손목보호대' '슈퍼비스트 팔꿈치, 무릎 관절보호대' 이고 Item 이 블랙, 팔꿈치(무릎은 선택안함) 이다. 쉽게 생각하면 Item 은 옵션정보다. 그래서 Product 는 여러개의 Item 들을 가질 수 있다.
import java.util.List;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
public class Product {
String productId;
List<Item> items;
@Value
@Builder
public static class Item {
String itemId;
long itemQuantity;
}
}
샘플로 만든 products 에는 총 3개의 Product 가 있고 그중 두개는 같은 productId 다. Item 레벨에서도 itemId 1 과 2 가 중복으로 같이 있다. 최종적으론 같은 productId, 같은 itemId 별로 Grouping 하는게 목적이다.
public class Main {
public static void main(String[] args) {
/**
* [
* Product(
* productId=1,
* items=[
* Product.Item(itemId=1, itemQuantity=1),
* Product.Item(itemId=1, itemQuantity=1),
* Product.Item(itemId=2, itemQuantity=2)
* ]
* ),
* Product(
* productId=1,
* items=[
* Product.Item(itemId=3, itemQuantity=3),
* Product.Item(itemId=2, itemQuantity=2)
* ]
* ),
* Product(
* productId=2,
* items=[
* Product.Item(itemId=5, itemQuantity=5)
* ]
* )
* ]
*/
List<Product> products = getProducts();
}
private static List<Product> getProducts() {
return List.of(
Product.builder()
.productId("1")
.items(List.of(
Item.builder()
.itemId("1")
.itemQuantity(1)
.build(),
Item.builder()
.itemId("1")
.itemQuantity(1)
.build(),
Item.builder()
.itemId("2")
.itemQuantity(2)
.build()
)
)
.build(),
Product.builder()
.productId("1")
.items(List.of(
Item.builder()
.itemId("3")
.itemQuantity(3)
.build(),
Item.builder()
.itemId("2")
.itemQuantity(2)
.build()
)
)
.build(),
Product.builder()
.productId("2")
.items(List.of(
Item.builder()
.itemId("5")
.itemQuantity(5)
.build()
)
)
.build()
);
}
}
같은 productId, 같은 itemId 별로 Grouping 시킨 결과는 ProductGroup 으로 만들었다. 중복없는 productId 별로 여러개의 ItemGroup 을 가질 수 있고, ItemGroup 안의 itemId 도 중복되지 않는다. 마지막으로 같은 itemId 의 itemQuantity 는 모두 합쳐진다.
@Value
@Builder(access = AccessLevel.PRIVATE)
public class ProductGroup {
String productId;
List<ItemGroup> itemGroups;
@Value
@Builder(access = AccessLevel.PRIVATE)
public static class ItemGroup {
String itemId;
long totalQuantity;
}
}
이제 Grouping 시키는 로직을 살펴보자. 제일 먼저 해야할것은 productId 별로 Product 들을 Grouping 해야한다.
public static List<ProductGroup> grouping(List<Product> products) {
return products.stream()
// 1차 작업 Map<String, List<Product>>
.collect(Collectors.groupingBy(Product::getProductId, toList()))
.entrySet() // Set<Map<K,V>.Entry<String, List<Product>>>
.stream() // Stream<Map<K,V>.Entry<String, List<Product>>>
// 2차 작업
.map(ProductGroup::makeEachProductGroups) // Stream<ProductGroup>
.toList();
}
1차 작업을 수행하면 아래 주석과 같이 Map으로 담겨진다. 이 Map 을 갖고 entrySet().stream() 을 활용해서 ProductGroup 을 만든다.
public static List<ProductGroup> grouping(List<Product> products) {
return products.stream()
/**
* 1 차 작업 : Map<String, List<Product>> productListsById
* {
* 1=[
* Product(
* productId=1,
* items=[
* Product.Item(itemId=1, itemQuantity=1),
* Product.Item(itemId=1, itemQuantity=1),
* Product.Item(itemId=2, itemQuantity=2)
* ]
* ),
* Product(
* productId=1,
* items=[
* Product.Item(itemId=3, itemQuantity=3),
* Product.Item(itemId=2, itemQuantity=2)
* ]
* )
* ],
* 2=[
* Product(
* productId=2,
* items=[Product.Item(itemId=5, itemQuantity=5)]
* )
* ]
* }
*/
.collect(Collectors.groupingBy(Product::getProductId, toList()))
.entrySet() // Set<Map<K,V>.Entry<String, List<Product>>>
.stream() // Stream<Map<K,V>.Entry<String, List<Product>>>
// 2차 작업
.map(ProductGroup::makeEachProductGroups) // Stream<ProductGroup>
.toList();
cf) 아래와 같이 toUnmodifiableMap 을 쓰면, 중복된 productId 가 있을경우 duplicateKeyException 예외가 발생한다.
Map<String, Product> productsById = products.stream()
.collect(Collectors.toUnmodifiableMap(Product::getProductId, Function.identity()));
이어지는 2차 작업작업에서 ProductGroup 을 만든다. 그리고 ItemGroup 단계(makeItemGroups)로 가서 먼저 flatMap(product -> product.getItems().stream()) 으로 Product 안 Item 들을 Stream으로 만든 후 itemId 별로 다시 Grouping 한다.
그 결과를 갖고 ItemGroup 을 만들고 totalQuantity 에는 Grouping 된 Item 들의 quantity 를 다 더한다.
private static ProductGroup makeEachProductGroups(Entry<String, List<Product>> productListById) {
return ProductGroup.builder()
.productId(productListById.getKey())
.itemGroups(makeItemGroups(productListById.getValue()))
.build();
}
private static List<ItemGroup> makeItemGroups(List<Product> products) {
return products.stream() // Stream<Product>
.flatMap(product -> product.getItems().stream()) // Stream<Product.Item>
.collect(groupingBy(Item::getItemId, toList())) // Map<String, List<Product.Item>>
.entrySet().stream() // Stream<Map<K,V>.Entry<String, List<Product.Item>>>
.map(it ->
ItemGroup.builder()
.itemId(it.getKey())
.totalQuantity(it.getValue().stream().mapToLong(Item::getItemQuantity).sum())
.build()
) // Stream<ProductGroup.ItemGroup>
.toList();
}
Grouping 해서 얻은 최종 결과는 아래와 같다.
/**
* 2 차 작업 : grouping
* [
* ProductGroup(
* productId=1,
* itemGroups=[
* ProductGroup.ItemGroup(itemId=1, totalQuantity=2),
* ProductGroup.ItemGroup(itemId=2, totalQuantity=4),
* ProductGroup.ItemGroup(itemId=3, totalQuantity=3)
* ]
* ),
* ProductGroup(
* productId=2,
* itemGroups=[
* ProductGroup.ItemGroup(itemId=5, totalQuantity=5)
* ]
* )
* ]
*/
makeItemGroups 로직에서 주의해야할 게 하나있다. flatMap(product -> product.getItems().stream()) 으로 Product 안 Item 들을 Stream으로 만든 후 itemId 별로 다시 Grouping 해야한다.
아래와 같이 각 product 별 item 을 이용해 Grouping 하면 안된다.
private static List<ItemGroup> makeItemGroupsWrongWay(List<Product> products) {
return products.stream()
.flatMap(product -> // proudct 마다 하면 안됌
product.getItems().stream()
.collect(groupingBy(Item::getItemId, toList()))
.entrySet()
.stream()
.map(it ->
ItemGroup.builder()
.itemId(it.getKey())
.totalQuantity(it.getValue().stream().mapToLong(Item::getItemQuantity).sum())
.build()
))
.toList();
}