개발 일기

Insert가 많을 때 소요 시간 개선하기 - (1) myBatis forEach 적용 본문

Tech/배워서 남주기

Insert가 많을 때 소요 시간 개선하기 - (1) myBatis forEach 적용

flow123 2023. 1. 16. 05:09

문제

판매자의 제품 등록 기능에서 Insert 쿼리가 다수 발생한다는 것을 발견했습니다.
Disk I/O는 웹 서비스 성능에 많은 영향을 미치는 중요 모니터링 지표입니다.  디스크의 데이터 처리 속도가 메모리나 CPU에 비해 너무 느리기 때문입니다. DISK I/O를 줄이는 것은 DB 성능 개선의 핵심 이기도 합니다.

이번 글에서는 쿼리를 여러번 날렸을 때, 나타나는 지연 현상을 개선 해보겠습니다. 

개요 

네이버 쇼핑, 배달의 민족 등 평소 이용하는 이커머스 서비스를 보면, 소비자가 다양한 옵션을 구성할 수 있도록 제품이 제공됩니다. 이를 충족하기 위해, 판매자가 제품을 등록할 때, 다양한 옵션 그룹과 옵션을 설정할 수 있어야 합니다. 

 

제품 등록 POST 요청 값의 예시입니다. 

제품(샐러드) 에 옵션이 있을 경우 optionSet에 옵션 그룹을 등록하는 DTO와 이에 속하는 옵션 DTO 가 리스트 형태로 들어갑니다.  

{
  "name": "샐러드",
  "price": 2000,
  "status": "AVAILABLE",
  "description": "맛나요",
  "singleType": false,
  "categoryId": 1,
  "optionSet": [
    {
      "optionGroupRegister": {
        "optionGroupName": "중량",
        "requiredOption": true,
        "exclusive": true,
        "minimumOrder": 1,
        "maximumOrder": 1
      },
      "optionDetailRegisterList": [
        {
          "name": "30g",
          "price": 0
        },
        {
          "name": "60g",
          "price": 2000
        }
      ]
    },
    {
      "optionGroupRegister": {
        "optionGroupName": "토핑",
        "requiredOption": false,
        "exclusive": false,
        "minimumOrder": 1,
        "maximumOrder": 2
      },
      "optionDetailRegisterList": [
        {
          "name": "견과류",
          "price": 1000
        },
        {
          "name": "크랜베리",
          "price": 500
        }
      ]
    }
  ]
}

참고 - DB 스키마 

아래 스키마에서 참고하실 수 있듯, 옵션 그룹은 제품 Id를 외래키로 참조하고,  

옵션은 옵션 그룹 Id를 외래키로 참조하는 상황입니다. 

 

소개

위의 테이블 관계에서 볼 수 있듯, 제품 - 옵션 그룹 (ex. 맛) - 옵션 (ex. 딸기맛, 포도맛 )은 일대 다 관계 입니다.

옵션 그룹을 저장하려면 제품을 포함해야 하고, 옵션을 저장하려면 옵션 그룹 ID 가 필요합니다. .

결과적으로, 옵션 그룹이 N개 이고 옵션이 M개 일 경우 N+N*M , 즉 N(1+M)개의 쿼리가 추가로 발생하는 것을 알 수 있습니다. 

Elastic APM으로 모니터링 시, 아래와 같이 N(1+M)개 쿼리가 날아감을 알 수 있습니다. 

 

ProductService 

 

기존 코드 

public void addProduct(LoginUser user, ProductRegisterRequest request) {
  Product product = request.toProduct(user);
  productRepository.save(product);

  if (request.getOptionSet() != null) {
    for (OptionSet optionSet : request.getOptionSet()) {
      OptionGroupRegister optionGroupRegister = optionSet.getOptionGroupRegister();
      OptionGroup optionGroup = optionGroupRegister.toOptionGroup(user, product);
      optionGroupRepository.save(optionGroup);

      List<OptionDetailRegister> optionDetailRegisterList = optionSet.getOptionDetailRegisterList();
      List<Option> options = optionSet.toOptions(optionDetailRegisterList, optionGroup);
      for(Option option: options){
        optionRepository.save(option);
      }
    }
  }
}

변경 

*변경 코드는 마지막 for 문을 돌지 않고, list를 한번에 저장합니다. 이외에는 동일합니다. 

public void addProduct(LoginUser user, ProductRegisterRequest request) {
  Product product = request.toProduct(user);
  productRepository.save(product);

  if (request.getOptionSet() != null) {
    for (OptionSet optionSet : request.getOptionSet()) {
      OptionGroupRegister optionGroupRegister = optionSet.getOptionGroupRegister();
      OptionGroup optionGroup = optionGroupRegister.toOptionGroup(user, product);
      optionGroupRepository.save(optionGroup);

      List<OptionDetailRegister> optionDetailRegisterList = optionSet.getOptionDetailRegisterList();
      List<Option> options = optionSet.toOptions(optionDetailRegisterList, optionGroup);
      optionRepository.save(options);
    }
    }
  }

 

OptionMapper.xml 

 

기존

<insert id="insert" keyProperty="id" useGeneratedKeys="true">
  insert into `option` (name, price, option_group_id, seller_id)
  values (#{name}, #{price}, #{optionGroupId}, #{sellerId});
</insert>

변경 

 

-option은 mysql 예약어기 때문에 `` 로 닫아 줍니다. 

-여러 개의 옵션을 list 에 담아 전달하므로, collection = "list"로 지정합니다. item은 전달받은 인자 값을 지칭할 이름입니다. 

list 내에는 , 구분자가 사용되므로, separator 에는 ","를 넣어줍니다. 

<insert id="insert" keyProperty="id" parameterType="java.util.List" useGeneratedKeys="true">
  insert into `option` (name, price, option_group_id, seller_id)
  values
  <foreach collection="list" index="index" item="option" separator=",">
    (#{option.name}, #{option.price}, #{option.optionGroupId}, #{option.sellerId})
  </foreach>
</insert>

결론 

forEach를 적용해서 옵션의 개수 만큼 날아가는 쿼리를 한 개로 줄일 수 있었습니다. 

기존에는 옵션 그룹이 N개 이고 옵션이 M개 일 경우 N+N*M , 즉 N(1+M)개의 쿼리가 추가로 발생하던 상황이었습니다. 

forEach를 활용해 collection 형태로 insert 함으로써, N*M -> N으로 쿼리 개수를 줄여, 현재는 총 2N개의 쿼리가 발생합니다.  

 

참고 

https://www.whatap.io/ko/blog/41/

https://kimvampa.tistory.com/180

https://velog.io/@qotndus43/MyBatis-foreach-%EA%B5%AC%EB%AC%B8%EC%9D%84-%ED%86%B5%ED%95%B4-N-1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0

Comments