[JPA] @OneToOne 양방향 연관관계의 N+1 문제

오형상·2025년 7월 2일
0

TodayTable

목록 보기
11/12
post-thumbnail

문제 상황

@OneToOne(fetch = FetchType.LAZY)로 설정한 BrandImageFile 연관관계에서, 주문 목록을 조회하는 findMyOrders() 쿼리 실행 시 예상치 못한 N+1 문제가 발생했습니다. ImageFile 필드를 명시적으로 호출하거나 참조하지 않았음에도, ImageFile 테이블에서 brand_id로 조회하는 쿼리가 수십 건 이상 반복 실행되었습니다.

1. 연관관계

@Entity
public class Brand {
    
    @OneToOne(mappedBy = "brand", fetch = FetchType.LAZY)
    private ImageFile imageFile;
}
@Entity
public class ImageFile {

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "brand_id")
    private Brand brand;
}

2. Querydsl 조회 로직

queryFactory.selectFrom(order)
    .join(order.delivery, delivery).fetchJoin()
    .join(order.orderItemList, orderItem).fetchJoin()
    .join(orderItem.item, item).fetchJoin()
    .join(item.brand, brand).fetchJoin()
    // ImageFile은 fetchJoin 하지 않음
    .where(조건...)
    .fetch();

로그 결과

Hibernate는 ImageFile에 대해 수십 건의 개별 쿼리를 실행:

select i1_0.image_file_id, i1_0.brand_id, i1_0.image_url, i1_0.item_id
from ImageFile i1_0
where i1_0.brand_id=?

이는 결과적으로 ImageFile을 LAZY로 설정했음에도 각 Brand에 대해 지연 로딩이 트리거된 것이며, N+1 문제입니다.


원인 분석

@OneToOne(fetch = FetchType.LAZY)연관관계의 주인인 쪽에서만 실제로 Lazy 로딩이 동작합니다. 이 문제의 핵심은 null은 프록시로 감쌀 수 없다는 Hibernate의 설계 제약에서 비롯됩니다.

1. 연관관계의 주인이 아닌 쪽에서는 LAZY 동작이 어려움

  • BrandImageFile 구조에서 Brand.imageFile 필드는 mappedBy를 통해 연관관계의 주인이 아니며, 이 상태에서 fetch = LAZY를 설정해도 실제로는 Lazy가 동작하지 않습니다.
  • 이유는 Hibernate가 Brand.imageFilenull을 넣을지, 프록시를 넣을지 결정하려면 DB를 조회해봐야 하기 때문입니다.

2. Hibernate의 한계: null은 프록시로 감쌀 수 없다

  • Hibernate는 Lazy 로딩을 위해 보통 프록시 객체를 먼저 넣어둡니다.
  • 하지만 연관관계가 선택적일 경우 (nullable), 그 연관관계가 실제 존재하는지 확인하지 않으면 null인지 프록시인지 알 수 없습니다.
  • 프록시는 객체를 감쌀 수는 있지만, null은 감쌀 수 없습니다.
  • 따라서 Hibernate는 Brand.imageFile이 존재하는지 확인하기 위해 매번 SELECT 쿼리를 실행하게 되고, 이것이 N+1 문제로 이어집니다.

해결 방법

1. ImageFile도 fetchJoin으로 명시적으로 조회

  • 조인 대상이 많아질수록 쿼리 복잡도 및 결과 중복 증가
.join(brand.imageFile, imageFile).fetchJoin()

2. 연관관계 단방향으로 변경

  • ImageFile에서만 @OneToOne(fetch = LAZY) 방향을 유지
  • BrandImageFile을 참조하지 않도록 설계
@Entity
public class ImageFile {

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "brand_id")
    private Brand brand;
}

결과 및 느낀점

Brand에서 ImageFile을 참조하는 흐름만 존재했고, ImageFile에서 Brand를 직접 참조할 필요는 없었습니다. 따라서, 의미 없는 양방향 연관관계로 인해 불필요한 지연 로딩 쿼리가 발생하느니, 단방향으로 단순화하는 것이 도메인 설계와 성능 측면 모두에서 더 적절하다고 판단했습니다.

이번 경험을 통해 JPA의 @OneToOne 연관관계에서만 발생하는 프록시 및 지연 로딩의 제약사항을 명확히 이해하게 되었습니다.

  • @OneToOne(fetch = LAZY)로 설정하더라도 연관관계의 주인이 아닌 쪽에서는 Lazy 로딩이 동작하지 않을 수 있다는 점

  • 그 이유는 Hibernate가 null인 경우를 프록시로 감쌀 수 없기 때문이며, 연관된 엔티티가 존재하는지 확인하기 위해 결국 쿼리를 실행하게 된다는 점을 새롭게 알게 되었습니다.

또한, 실질적으로 필요하지 않은 무의미한 양방향 연관관계는 오히려 성능과 유지보수 측면에서 악영향을 줄 수 있다는 점을 체감했고, 앞으로는 연관관계의 방향성과 필요성에 대해 더 신중하게 판단해야겠다고 느꼈습니다.

0개의 댓글