본문 바로가기
프로젝트/개인 프로젝트 V2

7) 트러블슈팅 일기

by Thumper 2023. 6. 14.

이번 시간에는 프로젝트 V1 버전에서 해결하려 했던 문제 중 하나인 JPA N+1 문제에 대해 다뤄보려고 합니다.
주문한 상품을 조회할 때 발생했던 N+1 쿼리 문제를 어떻게 해결했는지,
그리고 판매 관리를 위해 Sale 테이블을 추가하려 했던 배경과 그에 대한 회고를 공유하려 합니다.

번외

V2에 대한 이야기는 이번 편으로 마무리될 예정입니다.
그동안 개인 프로젝트만 진행하다 보니, 자가 피드백으로는 성장에 한계가 느껴졌습니다.
그래서 이제는 팀 프로젝트를 통해 더 많은 것을 배우고자 합니다. 조금 길어졌지만, 이런 변화가 제 성장에 큰 도움이 될 것 같습니다.

주문상품 조회 시 N+1 쿼리 문제

프로젝트 V1에서 아래와 같이 ERD를 구성했습니다.

image


위의 관계를 따라 Cart, User, OrderItem, Item, ItemImg을 아래의 코드로 작성했습니다.

Cart

package com.example.webstore.domain;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Cart {

    @Id @GeneratedValue
    @Column(name = "cart_id")
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    private User user;

    @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @Builder(builderMethodName = "cartBuilder")
    public Cart(User user, List<OrderItem> orderItems) {
        this.user = user;
        this.orderItems = orderItems;
    }

    public static Cart createCart(OrderItem... orderItems) {
        Cart cart = new Cart();
        for (OrderItem orderItem : orderItems) {
            cart.addOrderItem(orderItem);
        }
        return cart;
    }

    public static Cart createCartV2(User user, OrderItem... orderItems) {
        Cart cart = new Cart();
        cart.setUpUser(user);
        for (OrderItem orderItem : orderItems) {
            cart.addOrderItem(orderItem);
        }
        return cart;
    }


    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.addCart(this);
    }

    public void setUpUser(User user) {
        this.user = user;
    }

}

User

package com.example.webstore.domain;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id @GeneratedValue
    @Column(name = "user_id")
    private Long id;

    private String loginId;
    private String password;

    private String name;
    private String email;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Item> itemList = new ArrayList<>();

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private Cart cart;

    @Builder(builderMethodName = "userBuilder")
    public User(String loginId, String password, String name, String email, Address address) {
        this.loginId = loginId;
        this.password = password;
        this.name = name;
        this.email = email;
        this.address = address;
    }

    @Override
    public String toString() {
        return "User Info {" + "id=" + id + ", name=" + name + ", loginId=" + loginId + ", password =" + password + ", address =" + address + '}';
    }
}

OrderItem

package com.example.webstore.domain;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY)
    private Cart cart;

    private int orderPrice;
    private int count;

    @Builder(builderMethodName = "orderItemBuilder")
    public OrderItem(Item item, int count) {
        item.removeStock(count);
        item.checkStatus();
        this.item = item;
        this.count = count;
    }

    public void addCart(Cart cart) {
        this.cart = cart;
    }

    public void orderAmount(int count) {
        int sum = item.getPrice()*count;
        this.orderPrice = sum;
    }

}

Item

package com.example.webstore.domain;

import com.example.webstore.exception.NotEnoughStockException;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item {

    @Id @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    private String itemName;
    private Integer price;
    private Integer stockQuantity;

    private String itemType; //상품등급
    private String categoryType; //카테고리
    private ItemSellStatus status; //판매상태

    @Builder(builderMethodName = "itemBuilder")
    public Item(String itemName, Integer price, Integer stockQuantity, String itemType, String categoryType, ItemSellStatus status) {
        this.itemName = itemName;
        this.price = price;
        this.stockQuantity = stockQuantity;
        this.itemType = itemType;
        this.categoryType = categoryType;
        this.status = status;
    }

    @Builder(builderMethodName = "secondItemBuilder")
    public Item(User user, String itemName, Integer price, Integer stockQuantity, String itemType, String categoryType, ItemSellStatus status) {
        this.user = user;
        this.itemName = itemName;
        this.price = price;
        this.stockQuantity = stockQuantity;
        this.itemType = itemType;
        this.categoryType = categoryType;
        this.status = status;
    }


    public static Item createItem(User user, String itemName, Integer price, Integer stockQuantity, String itemType, String categoryType, ItemSellStatus status) {
        Item item = new Item(itemName, price, stockQuantity, itemType, categoryType, ItemSellStatus.SELL);
        item.setUpUser(user);
        return item;
    }

    public void removeStock(int quantity) {
        int restQuantity = this.stockQuantity - quantity;
        if (restQuantity < 0) {
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restQuantity;
    }

    public void cancelCart(int quantity) {
        int rest = this.stockQuantity + quantity;
        this.stockQuantity = rest;
    }

    public void checkStatus() {
        int restQuantity = this.stockQuantity;
        if (restQuantity < 0) {
            throw new NotEnoughStockException("no stock");
        }
        this.status = ItemSellStatus.SOLD_OUT;
    }

    public void setUpUser(User user) {
        this.user = user;
    }

    @Override
    public String toString() {
        return "Item Info {" + "id=" + id + ", name=" + itemName + ", price=" + price + ", stockQuantity =" + stockQuantity + ", itemType =" + itemType + ", categoryType =" + categoryType + ", status=" + status + '}';
    }

}

ItemImg

package com.example.webstore.domain;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ItemImg {

    @Id @GeneratedValue
    @Column(name = "item_img_id")
    private Long id;

    private String originImgName; // 파일이름
    private String imgName;  //저장된 uuid 이름
    private String savePath; //파일저장경로
    private String reImgYn; //대표 이미지 여부

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @Builder(builderMethodName = "imgBuilder")
    public ItemImg(String originImgName, String imgName, String savePath, String reImgYn, Item item) {
        this.originImgName = originImgName;
        this.imgName = imgName;
        this.savePath = savePath;
        this.reImgYn = reImgYn;
        this.item = item;
    }

    @Override
    public String toString() {
        return "ItemImg Info {" + " ImgName =" + originImgName + ", storeFileName =" + imgName + ", savePath =" + savePath + '}';
    }
}



여기서 주문한 상품을 조회하기 위해서 getCartList() 로직을 만들었고, 실행했을 때 아래와 같은 쿼리가 실행되었다.

    public List<CartInfoDto> getCartList(User user) {
        List<Cart> carts = cartRepository.findByUser(user);

        List<CartInfoDto> dtoList = new ArrayList<>();
        for (Cart cart : carts) {
            for (OrderItem orderItem : cart.getOrderItems()) {
                CartInfoDto cartDto = new CartInfoDto();

                cartDto.updateCartInfo(orderItem.getId(), user.getLoginId(), orderItem.getItem(), orderItem.getCount(), orderItem.getOrderPrice());
                log.info("update Dto={}", cartDto.getItem().getStockQuantity());
                dtoList.add(cartDto);

            }
        }
        return dtoList;
    }
Hibernate: 
    select
        user0_.user_id as user_id1_5_,
        user0_.city as city2_5_,
        user0_.street as street3_5_,
        user0_.zipcode as zipcode4_5_,
        user0_.cart_id as cart_id9_5_,
        user0_.email as email5_5_,
        user0_.login_id as login_id6_5_,
        user0_.name as name7_5_,
        user0_.password as password8_5_,
        user0_.sales_id as sales_i10_5_ 
    from
        users user0_ 
    where
        user0_.login_id=?
Hibernate: 
    select
        cart0_.cart_id as cart_id1_0_0_ 
    from
        cart cart0_ 
    where
        cart0_.cart_id=?
Hibernate: 
    select
        orderiteml0_.cart_id as cart_id4_3_0_,
        orderiteml0_.order_item_id as order_it1_3_0_,
        orderiteml0_.order_item_id as order_it1_3_1_,
        orderiteml0_.cart_id as cart_id4_3_1_,
        orderiteml0_.count as count2_3_1_,
        orderiteml0_.item_id as item_id5_3_1_,
        orderiteml0_.order_price as order_pr3_3_1_ 
    from
        order_item orderiteml0_ 
    where
        orderiteml0_.cart_id=?
Hibernate: 
    select
        item0_.item_id as item_id1_1_0_,
        item0_.category_type as category2_1_0_,
        item0_.item_name as item_nam3_1_0_,
        item0_.item_type as item_typ4_1_0_,
        item0_.price as price5_1_0_,
        item0_.sales_id as sales_id9_1_0_,
        item0_.seller_id as seller_i6_1_0_,
        item0_.status as status7_1_0_,
        item0_.stock_quantity as stock_qu8_1_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?
Hibernate: 
    select
        imglist0_.item_id as item_id6_2_0_,
        imglist0_.item_img_id as item_img1_2_0_,
        imglist0_.item_img_id as item_img1_2_1_,
        imglist0_.img_name as img_name2_2_1_,
        imglist0_.is_main_img as is_main_3_2_1_,
        imglist0_.item_id as item_id6_2_1_,
        imglist0_.origin_img_name as origin_i4_2_1_,
        imglist0_.save_path as save_pat5_2_1_ 
    from
        item_img imglist0_ 
    where
        imglist0_.item_id=?

Hibernate: 
    select
        item0_.item_id as item_id1_1_0_,
        item0_.category_type as category2_1_0_,
        item0_.item_name as item_nam3_1_0_,
        item0_.item_type as item_typ4_1_0_,
        item0_.price as price5_1_0_,
        item0_.sales_id as sales_id9_1_0_,
        item0_.seller_id as seller_i6_1_0_,
        item0_.status as status7_1_0_,
        item0_.stock_quantity as stock_qu8_1_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?
Hibernate: 
    select
        imglist0_.item_id as item_id6_2_0_,
        imglist0_.item_img_id as item_img1_2_0_,
        imglist0_.item_img_id as item_img1_2_1_,
        imglist0_.img_name as img_name2_2_1_,
        imglist0_.is_main_img as is_main_3_2_1_,
        imglist0_.item_id as item_id6_2_1_,
        imglist0_.origin_img_name as origin_i4_2_1_,
        imglist0_.save_path as save_pat5_2_1_ 
    from
        item_img imglist0_ 
    where
        imglist0_.item_id=?

애플리케이션을 실행하고 주문 상품 조회 API를 호출했을 때, 총 6개의 쿼리가 발생한 것을 확인할 수 있다.

  • 누구의 주문인지 확인하기 위한 User 쿼리
  • 해당 회원의 장바구니인지 확인하는 Cart 쿼리
  • 장바구니에 담긴 주문 상품에 대한 OrderItem 쿼리
  • 각 상품에 대한 Item 및 ItemImg 쿼리

JPA 연관관계로 조회하려다보니 N + 1 쿼리가 발생한 것이다.

연관된 테이블을 첫 번째 쿼리 실행 시 한 번에 가져오지 않고, Lazy Loading 방식으로 필요한 시점에 데이터를 불러오게 되면, 그때마다 추가 쿼리가 실행되어 N+1 쿼리 문제가 발생하게 된다.

지금 주문한 상품이 2개이니 OrderItem을 조회하고, 주문수량만큼 Item과 ItemImg를 조회하는 쿼리가 추가로 발생했다.

만약 주문한 상품이 100개가 넘으면 총 203번의 쿼리가 발생할 것이다.

  • 기본 쿼리: 3개 (User, Cart, OrderItem)
  • Item 쿼리: 100개
  • ItemImg 쿼리: 100개

그래서 이렇게 연관관계가 맺어진 Entity를 한번에 가져오는 방법으로, JoIn Fetch를 선택했다.

Fetch Join으로 쿼리개선

image

Hibernate: 
    select
        orderitem0_.order_item_id as col_0_0_,
        item2_.name as col_1_0_,
        itemimg1_.save_path as col_2_0_,
        orderitem0_.count as col_3_0_,
        orderitem0_.order_price as col_4_0_ 
    from
        order_item orderitem0_ 
    left outer join
        item_img itemimg1_ 
            on (
                orderitem0_.item_id=itemimg1_.item_id
            ) cross 
    join
        item item2_ 
    where
        orderitem0_.item_id=item2_.item_id 
        and orderitem0_.cart_id=?

orderItem.item과 itemImg를 fetch join으로 변경하여 관련 데이터를 한 번의 쿼리로 가져올 수 있다.
이렇게 해서 OrderItem에 연관된 Item과 ItemImg를 한 번에 로드할 수 있어, N+1 쿼리 문제를 해결할 수 있었다.

판매관리를 위한 Sales 테이블

V1 버전에서는 구매 과정을 중심으로 구현했으나, V2 버전에서는 판매 과정 기능도 구체적으로 구현하는 것이 좋을 것 같았다.
그래서 Sales 테이블을 추가하게 되었고, 변경된 ERD는 아래와 같다.

image

회원은 각자 고유한 장바구니를 가지므로 Cart와 User는 1:1 관계로 설정하고,
OrderItem을 중간 테이블로 사용하여 Cart와 Item 사이를 연결한 것처럼 Sales를 구현하려고 했다.

회원은 판매 권한을 가지므로, User와 Sales는 1:1 관계로 설정하고,
Sales에서는 판매할 상품을 여러 개 등록할 수 있도록 Sales와 Item은 1:N 관계로 구현하려 했다.
또한, Sales에는 구매 요청에 대한 주문 정보도 포함되어야 하므로, Sales와 OrderItem 관계는 1:N 관계로 설정했다.

Sales

package springstudy.bookstore.domain.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@NoArgsConstructor
public class Sales {

    @Id @GeneratedValue
    @Column(name = "sales_id")
    private Long id;

    private int totalRevenue;

    @OneToMany(mappedBy = "sales", cascade = CascadeType.ALL)
    private List<Item> itemList = new ArrayList<>();

    @OneToMany(mappedBy = "sales", cascade = CascadeType.ALL)
    private List<OrderItem> orderItemList = new ArrayList<>();


    /*
      비지니스 로직 ;
      uploadItem : 팔고 싶은 상품 등록
      takeOrder : 주문 요청을 받다.
      cancelOrder : 주문 취소요청을 받다.
     */
    public void uploadItem(Item item) {
        itemList.add(item);
    }

    public void takeOrder(OrderItem orderItem) {
        orderItem.orderRequest();
        orderItemList.add(orderItem);
        totalRevenue += orderItem.getOrderPrice();
    }

    public void cancelOrder(OrderItem orderItem) {
        orderItem.cancelRequest();
        orderItemList.remove(orderItem);
        totalRevenue -= orderItem.getOrderPrice();
    }


}

판매상품 조회

    @Override
    public Page<GetUserItemResponse> searchOrderByUserAndItemName(String uploaderId, ItemSearch itemSearch, Pageable pageable) {

        List<GetUserItemResponse> content = queryFactory
                .select(new QGetUserItemResponse(
                        item.id.as("itemId"),
                        item.name.as("itemName"),
                        item.price,
                        item.stockQuantity,
                        itemImg.imgName,
                        itemImg.savePath,
                        item.itemType,
                        item.categoryType))
                .from(sales)
                .join(sales.itemList, item)
                .join(item.imgList, itemImg)
                .where(itemImg.isMainImg.eq(IsMainImg.Y).and(item.sellerId.eq(uploaderId)))
                .where(item.status.eq(itemStatusContains(itemSearch)))
              //  .where(sales.orderItemList.orderStatus.eq(itemSearch.getOrderStatus())
                .fetch();

        JPAQuery<GetUserItemResponse> query = queryFactory
                .select(new QGetUserItemResponse(
                        item.id.as("itemId"),
                        item.name.as("itemName"),
                        item.price,
                        item.stockQuantity,
                        itemImg.imgName,
                        itemImg.savePath,
                        item.itemType,
                        item.categoryType))
                .from(sales)
                .join(sales.itemList, item)
                .join(item.imgList, itemImg)
                .where(itemImg.isMainImg.eq(IsMainImg.Y).and(item.sellerId.eq(uploaderId)))
                .where(item.status.eq(itemStatusContains(itemSearch))
                );

        return PageableExecutionUtils.getPage(content, pageable, () -> query.fetch().size());
    }
  • join(sales.itemList, item): Sales와 Item을 1:N 관계로 조인.
  • join(item.imgList, itemImg): Item과 ItemImg를 1:N 관계로 조인.

Sales에서 판매된 모든 Item과 해당 Item에 관련된 이미지 정보를 한 번에 가져오도록 작성했습니다.



직접적인 Sales와 OrderItem 연결의 불편함

직접적인 Sales와 OrderItem 연관관계로 주문 정보를 찾으면 불편하다.

Sales와 OrderItem을 직접적으로 연결하여 주문상품 정보를 찾으려는 접근은 효율적이지 않은 것 같다고 느꼈다.
OrderItem 테이블에서 실제 주문에 해당하는 상품들의 정보, 즉 주문 수량, 가격 정보를 관리하고 있다.
그런데 sales를 통해서 찾게되면, 관계가 복잡해지고 의존성이 높아지는 문제가 있다.

로직에서 Sales 엔티티를 반복적으로 사용해야 하므로,
Sales에 대한 의존도가 높아지고, 특정 주문을 조회할 때마다 불필요한 데이터까지 함께 처리하게 되어 성능 문제가 발생할 수 있다.


결론, 주문정보는 OrderItem에서 찾자.

Sales와 OrderItem을 직접적으로 연결하여 주문상품 정보를 조회하는 것보다는,
OrderItem과 Item 간의 연관 관계를 활용해 조회하는 방식이 더 효율적이고 재사용성이 있는 것 같다.

   public List<GetOrderItemResponse> findAllByCart_id(Long id) {

        return queryFactory
                .select(new QGetOrderItemResponse(
                        orderItem.id,
                        orderItem.item.id.as("itemId"),
                        orderItem.item.name,
                        itemImg.savePath,
                        orderItem.count,
                        orderItem.orderPrice,
                        orderItem.orderStatus)
                       )
                .from(orderItem)
                .leftJoin(itemImg)
                .on(orderItem.item.id.eq(itemImg.item.id))
                .where(orderItem.cart.id.eq(id))
                .fetch();
    }

이런 식으로 fetch join하여 주문정보를 찾는게 좋을 듯 싶다.

댓글