sol 개발 블로그 로고
Published on

Spring JPA의 레코드 사이 관계를 해제했을 때 문제점

Authors
  • avatar
    Name
    Chan Sol OH
    Twitter

목차

문제 상황

문제의 원인은 외래키로 연결되지 않는 두 레코드를 fetch join해서 아무런 결과를 얻지 못하는 겁니다. spring-templates 프로젝트를 진행하던 중 신기한 상황에 직면했습니다.

클라이언트가 주문하고 결제 요청 후 결제가 실패했다면, 롤백을 해줘야합니다. 롤백은 결제 요청 전 감소시켰던 재고와 PENDING_ORDER에서 PROCESSING으로 상품의 상태를 바꾼 작업을 원상 복귀시키는 겁니다. 롤백을 한 후 클라이언트에게 주문 결과를 전달해야합니다. 문제는 여기서 주문 결과를 전달할 때 발생합니다.

  1. 주문과 상품은 1:N으로 양방향 연관관계를 가지고 있습니다.
  2. 롤백을 하면서 결제 실패한 주문과 그 상품의 연관관계를 다 끊습니다.
  3. N+1 문제를 해결하기 위해 주문과 상품을 Fetch Join으로 묶고 결제 id를 기준으로 읽습니다.
  4. 읽은 주문 엔티티를 dto로 변환하고 클라이언트에게 전달합니다.
  5. 그 결과 결제 id에 맞는 주문 레코드를 찾을 수 없다고 에러가 발생하지만, DB에는 주문 데이터가 온전히 존재하는 상황입니다.

DB에 대해 잘 알고 있으시다면 위 순서에서 문제를 찾아낼 수 있을겁니다. 바로 연관관계를 끊고 Fetch join 쿼리를 작성한겁니다. 저는 fetch join을 N+1 문제 해결의 만능키로 생각했지만 현실은 그렇지 않았습니다.

fetch join은 주문 테이블과 상품 테이블을 join하고 상품 테이블의 주문 외래키와 주문 id가 같은 레코드를 합쳐서 보여주게 됩니다. 롤백에 해당된 주문과 연관된 상품의 외래키들을 전부 null로 업데이트 했고 이 외래키를 바탕으로 검색하면 레코드가 하나도 나오지 않는건 당연한겁니다.

해결 방안

OrderRepository.java
@Repository
public interface OrderRepository extends
        JpaRepository<Order, Long> {

    // 첫 시도
    @Query("select m from Order m join fetch m.actualProducts where m.paymentId = :id")
    Optional<Order> findByPaymentIdWithFetch(Long id);

    // 해결책
    Optional<Order> findByPaymentId(Long id);
}

결론적으로 결제 id를 가지는 주문을 찾기 위해 Lazy loading을 선택했고, findByPaymentId를 사용하는 부분에서는 주문 엔티티에서 상품 데이터를 사용하지 않도록했습니다. 만약 상품 데이터도 사용하게 된다면 Lazy loading이 실제로 일어나서 아무런 값도 불러오지 않을겁니다.

결론

항상 연관관계가 있는 엔티티를 쿼리할 때는 해당 엔티티만 쓰는지 연관된 엔티티까지 전부 쓰는지 같은 엔티티 사용 범위를 결정하고 필요하다면 어떻게 데이터를 불러올지 결정해야합니다.

쿼리 방법 선택

  1. 연관된 엔티티도 필요한가? -> no : Lazy Loading (사용되는 엔티티만 써서 DB와 서버 부하를 줄임, 문제 상황처럼 예상치 못한 상황을 방지) -> yes : 2번으로
  2. Query method만 사용하는가? -> yes : Eager Loading (Query method를 사용하면 연관 테이블을 자동으로 join 해줌) -> no : fetch join (jpql에 명시적으로 join을 붙여서 N+1 문제가 발생하지 않도록 해줌)

이번 문제 상황에는 연관 엔티티가 꼭 필요하진 않았던 상황이기 때문에 Lazy Loading을 사용해서 문제를 해결했습니다~!