문제 상황1
주문(Order)은 여러개의 주문 아이템(OrderItem)과 연결되어 있습니다.
각 주문 아이템은 상품정보를 가지고 있고, 주문과도 연관관계를 가지고 있습니다.
하나의 주문 내역을 가져오는 과정에서 2번의 쿼리문이 나왔습니다.
문제 원인
주문(Order)과 주문 아이템(OrderItem)은 ManyToOne 관계이므로 주문을 조회할때는 기본적으로 fetch.LAZY 전략을 사용합니다.
따라서 주문 아이템(OrderItem)은 프록시 객체로 저장되지만 전달하는 DTO에서 아래와 같은 코드를 사용함으로서 N+1 문제가 발생합니다.
@Getter
public class OrderDto {
private Long orderId;
private Long memberNumber;
private LocalDateTime orderDate;
private OrderStatus status;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
this.orderId = order.getOrderId();
this.memberNumber = order.getMemberNumber();
this.orderDate = order.getOrderDate();
this.status = order.getStatus();
this.orderItems = order.getOrderItems().stream()
.map(OrderItemDto::new)
.toList();
}
}
해결 방안
1. dto에서 orderItem을 없애거나 연관관계를 끊는다.
2. join을 이용하여 한 번에 가져온다. -> Fetch 전략 사용
처음에 dto에서 쿼리문이 나가는걸 발견하지 못하고 어디서 쿼리문 두 번 나가는 건지 궁금했습니다.
Order에는 OrderItem이 ManyToOne이므로 기본 전략이 Fetch.LAZY 이므로 단순히 불러온다고 쿼리문이 두 번 나갈리가 없으니까요.
@SpringBootTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
@Transactional
void testOrderEntityIsProxyOrNot() {
Long orderId = 1L; // 가정: 데이터베이스에 이미 존재하는 orderId
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderException(ErrorCode.ORDER_NOT_FOUND));
boolean isInitialized = Hibernate.isInitialized(order.getOrderItems());
assertThat(isInitialized).isFalse();
order.getOrderItems().size();
isInitialized = Hibernate.isInitialized(order.getOrderItems());
assertThat(isInitialized).isTrue();
}
}
테스트 코드를 짜보니 order.getOrderItems().size() 전에는 초기화되지 않은 것을 확인할 수 있었습니다.
강제로 불러온 이후에는 초기화 된 것으로 봐서 FetchType.LAZY는 적용이 되어 있다고 생각했습니다.
이러면 메소드 문제는 아니라고 판단했고, 작성했던 dto를 다시 봤는데 생성자에서 주문 아이템을 불러와서 발생한 문제였습니다.
Order와 OrderItem의 관계를 생각해봤을 때, 주문을 조회하는 시점에는 주문 내역에 있어야 합니다.
따라서 dto를 수정하지 않는 것이 나은 선택이라 생각되어 연관관계를 모두 끊는 방식은 맞지 않다고 생각했습니다.
이제 2번을 적용하여야 하는데 fetch를 적용하는 방법은 JPQL로 fetch를 하는 방법과 @EntityGraph 어노테이션 전략이 있었습니다.
쿼리를 이용한 방법이 조금 더 구체적인 조건으로 가져올 수 있는 방법이지만 단일 엔티티인 Order를 조회하는데 있어서는 @EntityGraph가 나은 전략이라고 판단되었습니다.
단순히 findById에 적용하게되면, 다른 서비스에서 Order를 가져오기 위해 findById 할 때 관련없는 OrderItem까지 조회될 수도 있으므로 Jpa 네이밍 규칙을 적용하여 다소 지저분하지만 findOrderWithOrderItemsByOrderId를 만들어 여기에 EntityGraph를 적용했습니다.
문제 상황2
주문(Order)은 여러개의 주문 아이템(OrderItem)과 연결되어 있습니다.
각 주문 아이템은 상품정보를 가지고 있고, 주문과도 연관관계를 가지고 있습니다.
전체 주문 내역을 가져오는 과정에서 양방향 연관관계로 인하여 stack overflow 문제가 발생했습니다.
문제 원인
전체 주문 정보를 가져올 때에 findAll() 메소드는 연관관계를 가져오지 못합니다.
따라서 주문이 1번에 대해 연관된 주문 아이템인 N개의 갯수를 가져옵니다.
그리고 이 N개의 주문 아이템이 N번의 주문을 불러옵니다.
해결 방안
1. 역시 모든 엔티티의 관계를 끊는다.
2. 동일하게 fetch 전략을 사용한다.
3. 방향을 한 쪽으로 정해주고, default_batch_fetch_size 사용
OrderService에서 Order와 OrderItem은 관련 있는 Entity입니다.
앞서서 이미 단건 조회 방식을 fetch로 1번 방안은 배제하고 fetch 전략을 사용해도 될 듯 했습니다.
그렇지만 fetch는 필요한 모든 엔티티를 불러오게 되므로 큰 메모리 낭비가 발생합니다.
예를 들어 Order에 있는 OrderItem이 극단적으로 10000개이면 10000개를 모두 조회하게됩니다.
전체를 조회할 때에는 페이징을 주로 사용하므로 이를 사용할 수 있는 전략을 짜야 합니다.
따라서 3번 방법을 선택했습니다.
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
@JoinColumn(name = "order_id")
private Order order;
먼저 @JsonIgnore를 통해 양방향 연관관계에서 모두 조회가 되는 것을 끊어줍니다.
이렇게 하면 아래와 같이 쿼리문이 OrderItem만큼 나가는 것을 볼 수 있습니다.
다음으로 application.yml에 아래와 같은 전략을 적용해줍니다.
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
database-platform: org.hibernate.dialect.MySQL8Dialect
여기서 default_batch_fetch_size는 IN 쿼리로 관련된 엔티티를 한 번에 조회할 수 있는 쿼리입니다.
10부터 1000이 적정값인데 하나의 Order안에 1000개의 orderItem은 너무 많다 생각하여 100으로 지정하였습니다.
정리
이렇게 ManyToOne에서 그리고 OneToMany에서 발생하는 N+1 문제를 해결하였습니다.
문제를 해결하며 지금은 규모가 크지 않아서 이런 코드 방식이 괜찮은 해결 방법인 것 같지만 규모가 커지면 단순히 이런 조건 뿐만 아니라 필요한 조건이 많아질 것이라고 생각합니다.
다음번에 동일한 문제를 마주친다면 가독성과 확장성에 열려있는 QueryDSL을 사용하여 해결해볼 것 같습니다.
'성장기록 > 개인프로젝트' 카테고리의 다른 글
sequel pro - caching_sha2_password 오류 (0) | 2024.08.03 |
---|---|
기존 개인 프로젝트 개선 계획 (0) | 2024.07.31 |
트러블 슈팅 - 동시 구매 시 재고관리에 대한 고민 2. 동시 구매시 재고관리 접근 (0) | 2024.03.11 |
트러블 슈팅 - 동시 구매 시 재고관리에 대한 고민 1. 재고 관리 프로세스 (0) | 2024.03.08 |
jwt 토큰을 관리하는 방법에 대한 고민 (0) | 2024.03.03 |
남에게 설명할 때 비로소 자신의 지식이 된다.
포스팅이 도움되셨다면 하트❤️ 또는 구독👍🏻 부탁드립니다!! 잘못된 정보가 있다면 댓글로 알려주세요.