본문 바로가기
Project/DelFood

[이슈 #9] Mybatis <collection> 태그 N+1 문제 없이 사용하기

by EricJeong 2019. 12. 18.

개요

DelFood 주문 기능에 있는 '가격 계산' 로직에서 N+1 쿼리 문제가 발생하는 것을 확인하였습니다.

주문 전 가격을 계산하는 기능이었는데 for문을 돌며 select query를 날리고, 또 mybatis의 collection 태그를 잘못 사용하여 꽤 느린 로직이었습니다.

 

변경 전 요청과 결과 값

[
    {
        "menuId": "1",
        "count": "1",
        "options": [
            {
                "optionId": "1"
            },
            {
                "optionId": "9"
            }
        ]
    },
    {
        "menuId": "4",
        "count": "2",
        "options": [
            {
                "optionId": "4"
            }
        ]
    }
]

 

{
    "menus": [
        {
            "id": 1,
            "name": "바사칸치킨",
            "price": 3000,
            "options": [
                {
                    "id": 1,
                    "name": "치즈추가",
                    "price": 2000
                },
                {
                    "id": 9,
                    "name": "밥추가",
                    "price": 2000
                }
            ]
        },
        {
            "id": 4,
            "name": "스페셜(닭발&똥집) 세트",
            "price": 24000,
            "options": [
                {
                    "id": 4,
                    "name": "똥집튀김+치킨세트",
                    "price": 0
                }
            ]
        }
    ],
    "totalPrice": 0,
    "memberId": "yyy9942",
    "addressInfo": {
        "townCode": "3023010100",
        "cityName": "대전광역시",

	... (이하 생략)
    },
    "shopInfo": {
        "id": 3,
        "name": "호식이 두마리 치킨",
        "addressCode": "3011011700100010008004638"
    },
    "deliveryInfo": {
        "deliveryPrice": 2000
    }
}

 

 

참고 - DB 스키마

 

문제가 되는 로직

 

2중 for문을 돌며 N개의 메뉴를 조회하고, M개의 옵션을 조회하여 N * M번의 DB 호출이 발생하는 상황입니다.

주문하는 메뉴와 옵션의 개수가 조금만 많아진다면 과도한 DB서버 콜로 인해 문제가 발생할 수 있습니다.

 

  /**
   * 고객이 선정한 메뉴의 총 계산서를 출력한다.
   * @author jun
   * @param memberId 고객 아이디
   * @param items 주문 상품들. 메뉴, 옵션 리스트가 존재한다.
   * @return
   */
  @Transactional(readOnly = true)
  public ItemsBillDTO getBill(String memberId, List<OrderItemDTO> items) {
    // 고객 주소 정보 추출
    AddressDTO addressInfo = memberService.getMemberInfo(memberId).getAddressInfo();
    // 매장 정보 추출
    ShopInfo shopInfo = shopService.getShopByMenuId(items.get(0).getMenuId());
    // 배달료 계산
    long deliveryPrice = addressService.deliveryPrice(memberId, shopInfo.getId());
    
    // 계산서 생성
    ItemsBillDTO bill = ItemsBillDTO.builder()
                                    .memberId(memberId)
                                    .addressInfo(addressInfo)
                                    .shopInfo(shopInfo)
                                    .deliveryPrice(deliveryPrice)
                                    .build();
    
    /// 계산서에 각 항목별로 가격과 이름 등을 추가
    for (OrderItemDTO item : items) {
      // 여기서 메뉴 N개의 개수 만큼 N번의 쿼리 발생
      MenuDTO menuInfo = menuService.getMenuInfo(item.getMenuId());
      MenuInfo billMenuInfo = MenuInfo.builder()
          .id(menuInfo.getId())
          .name(menuInfo.getName())
          .price(menuInfo.getPrice())
          .build();
      
      // 항목별 옵션 정보 추가
      for (OrderItemOptionDTO orderItemOption : item.getOptions()) {
      	// 여기서 M개의 옵션 개수 만큼 M개의 쿼리 발생
        OptionDTO optionInfo = optionService.getOptionInfo(orderItemOption.getOptionId());
        OptionInfo billOptionInfo = OptionInfo.builder()
                                              .id(optionInfo.getId())
                                              .name(optionInfo.getName())
                                              .price(optionInfo.getPrice())
                                              .build();
        billMenuInfo.getOptions().add(billOptionInfo);
      }
      bill.getMenus().add(billMenuInfo);
    }
    return bill;
  }

 

해결 요점

계산서에 필요한 것은 다음과 같습니다.

 

1. 주문한 회원 정보 (아이디, 주소)

2. 매장 정보 (아이디, 이름, 주소)

3. N개의 메뉴 

4. M개의 메뉴 옵션

 

가장 문제가 되는 부분은 바로 N개의 메뉴, M개의 메뉴 옵션을 조회하여 N+M개의 쿼리가 발생하는 것입니다.

각각 하나하나 메뉴, 옵션을 조회하는 것이 아닌, 한 번의 쿼리로 메뉴와 옵션을 조회하여 DTO에 매핑하도록 변경하기로 하였습니다.

 

 

조회한 결과를 매핑할 DTO

 

다음은 영수증 내부 inner static class로 존재하는 MenuInfo Class입니다.

해당 MenuInfo를 List형태로 받아와 영수증에 세팅해주려고 합니다. MenuInfo에는 OptionInfo 리스트가 존재하니 조회할 때 주문 옵션까지 같이 조회할 수 있도록 설계해야 합니다.

  @Getter
  @NoArgsConstructor
  public static class MenuInfo {
    private long id;
    private String name;
    private long price;
    private List<OptionInfo> options;
    
    /**
     * 메뉴 정보의 간략한 정보를 저장하는 DTO를 생성한다.
     * @param id 메뉴 아이디
     * @param name 메뉴의 이름
     * @param price 메뉴 가격
     */
    @Builder
    public MenuInfo(long id, @NonNull String name, long price) {
      this.id = id;
      this.name = name;
      this.price = price;
      options = new ArrayList<ItemsBillDTO.MenuInfo.OptionInfo>();
    }

    @Getter
    @NoArgsConstructor
    public static class OptionInfo {
      private long id;
      @NonNull
      private String name;
      private long price;
      
      /**
       * 직접 생성할 경우 Builder를 통해서 생성하게 한다.
       * @param id 옵션 아이디
       * @param name 옵션 이름
       * @param price 옵션 가격
       */
      @Builder
      public OptionInfo(long id, String name, long price) {
        this.id = id;
        this.name = name;
        this.price = price;
      }
    }
  }

 

 

Mybatis xml에 쿼리를 제작합니다. resultMap을 통해 쿼리 결과를 매핑해야 하는데 이때 주의할 점이 있습니다.

내부 <collection> 태그, <assosiation> 태그를 사용할 때 'select' 속성을 사용한다면 N+1 쿼리 문제가 발생한다는 것입니다.

 

mybatis 공식 문서를 보면 해당 문제를 해결하는 방법이 있습니다. outer join을 적용하여 DTO에 매핑을 적용하는 것입니다. <id> 태그를 적절히 사용해준다면 쉽게 해결이 가능합니다. 하나씩 천천히 만들어보겠습니다.

 

DTO와 매핑할 ResultMap 제작

<id> 태그는 조회할 때 데이터를 구분해 주는 역할을 합니다. id가 같은 데이터라면 같은 데이터로 취급하여 하나의 DTO로 통합하게 됩니다. 그러므로 Menu의 PK인 id를 <id>로 등록해 주어야 합니다.

 

<collection> 태그를 사용할 때 select 속성을 사용하면 N+1 문제가 발생합니다. 조회한 결과를 가지고 N개의 SELECT를 진핸하기 때문에 collection 태그 사용 시에는 select 속성은 지양하는 것이 좋습니다.

 

select속성을 뺀다면 어떻게 조회를 해야할까요? <collection> 태그를 <resultMap>처럼 사용하면 됩니다. <id> 태그만 적절히 사용해 주면 됩니다.

 

 

option의 PK를 id로 등록해 둔다면 조회된 여러 개의 데이터 중 id가 다른 데이터를 list로 조회할 것입니다.

resultMap의 설계가 끝났으니 적용할 select 쿼리를 작성해 보겠습니다.

 

조회 쿼리 작성

 

resultMap을 잘 설계해 두었으므로 그저 간단하게 조건에 맞게만 조회해주면 됩니다.

<id> 태그로 인해 묶일것들이 자동으로 묶이기 때문입니다. 같은 id를 가진 메뉴들끼리 묶으며 옵션들을 List 형태로 만들 것입니다.

    <select id="findItemsBill" resultMap="menuInfo">
        SELECT m.id menuId, m.name menuName, m.price menuPrice,
               opt.id optionId, opt.name optionName, opt.price optionPrice
        FROM   MENU m LEFT OUTER JOIN OPTION opt
                      ON (m.id = opt.menu_id)
        WHERE  m.id IN (<foreach collection="list" item="menu" separator=",">#{menu.menuId}</foreach>)
        AND    opt.id IN (
        <foreach collection="list" item="menu" separator=",">
            <foreach collection="menu.options" item="option" separator=",">
                #{option.optionId}
            </foreach>
        </foreach>)
    </select>

 

 

실제 DB서버에 해당 조회 로직으로 인해 발생한 쿼리를 날려보겠습니다. 다음과 같이 나옵니다.

같은 menuId를 가진 녀석은 하나로 취급되고 옵션들은 다른 녀석으로 취급됩니다.

 

 

API를 실제로 사용한다면 다음과 같은 결과를 얻을 수 있습니다. 잘 조회되는 것을 확인할 수 있습니다.

쿼리는 몇번 날아갔을까요? 로그를 통해 확인해 본 결과 메뉴와 옵션을 조회하는 쿼리는 단 한번 날아간 것을 확인할 수 있었습니다.

 

 

 

로그 데이터

 



결론

 

N+1번의 쿼리를 발생시키는 것은 좋지 않은 사항입니다. mybatis 공식 문서에서 또한 제가 사용한 것과 같이 resusltMap을 설계하여 작동하는 것을 권장합니다.

위 문제를 방지하기 위해서는 select 옵션을 사용하지 않는 것이 정말 중요합니다. Outer Join을 통해 DB 콜을 최대한 줄이는 것이 좋을 것이라 생각합니다.

 

해당 기능은 메뉴 20개와 옵션 30개로 실험하였을 때 1700ms 정도의 소요시간이 걸렸지만, 리팩터링 후 460ms의 응답 시간으로 줄어들었습니다. 메뉴뿐만 아니라 주소, 회원 정보 등을 모두 조회하고 배달비까지 모두 계산하기 때문에 여기서 응답 시간을 더 줄이기는 어려울 것 같습니다.

 

끝.

Reference

https://mybatis.org/mybatis-3/ko/sqlmap-xml.html

 

댓글