[JPA] N+1 문제 및 해결(@EntityGraph vs fetch join)

Lui.Slki·2026년 2월 5일

개발 성장일지

목록 보기
3/6

N+1 문제란,

  • N+1은 리스트 쿼리 1번(+1) 실행 후,
    리스트 안의 각 엔티티에서 LAZY 연관 필드를 접근할 때마다 N번의 추가 쿼리가 나가는 현상이다.

  • "부모 리스트 조회" 1번

  • "자식/연관 엔티티 로딩" N번
    → 총 1 + N(또는 1 + 2N, 1 + 3N ...) 쿼리가 발생한다.

N+1은 데이터 정합성을 깨는 동시성 문제라기보다, 요청당 DB 쿼리를 폭증시켜 지연/타임아웃을 유발하는 성능 문제다.
다만 지연이 커지면 사용자 재시도나 중복 요청을 유발해, 결과적으로 동시성 이슈가 더 잘 드러나는 환경을 만들 수 있다.

왜 생기는가(LAZY + 반복 접근 패턴)

JPA에서 연관관계를 LAZY로 두면, 연관 엔티티는 처음 조회 시점에서는 실제로 가져오지 않고 프록시(대리 객체) 만 넣어둔다.

그리고 코드에서 연관 필드를 "진짜로" 접근하는 순간에,
그제서야 DB에 쿼리를 날려서 로딩한다.

문제는 "리스트 조회 + 루프/스트림에서 연관 접근" 조합이 너무 흔하다는 것.

예시)

  • 예약 리스트 10개 조회(1쿼리)
  • 각 예약에서 timeSlot, lesson을 LAZY로 읽음 (각 10 쿼리)
  • 결과: 1 + 10 + 10 = 21쿼리

EntityGraph와 Fetch Join

N+1을 해결하는 핵심은 단순하다.
LAZY연관을 나중에 하나씩 가져오지 말고, 처음 조회할 때 필요한 연관을 같이 가져오면 된다.
이걸 대표적으로 해결하는 방법이 Fetch Join 과 @EntityGraph다.

Fetch Join(JPQL로 강제 함께 로딩)

fetch Joi은 JPQL에서 join fetch 를 직접 써서, 연관 엔티티를 무조건 같이 로딩하게 만드는 방식이다.

@Query("""
select r from LessonReservation r
join fetch r.timeSlot
join fetch r.lesson
where r.user.userId = :userId
""")
List<LessonReservation> findMyReservationsFetch(Long userId);

특징

  • 어떤 연관을 가져올지 쿼리에 명시적으로 드러남
  • 복잡한 조건/조인/튜닝이 필요하 경우 제어가 강함
  • 대신 JPQL이 길어질 수 있고, 상황에 따라 distinct나 페이징 문제가 생길 수 있음(특히 컬렉션 조인)

@EntityGraph (조회 메서드에 '옵션'처럼 붙이는 방식)

@EntityGraph는 "이 메서드로 조회할 때는 이 연관들을 같이 가져와"를 Repository 메서드에 선언하는 방식이다.

@EntityGraph(attributePaths = {"timeSlot", "lesson"})
List<LessonReservation> findAllByUser_UserIdOrderByRequestedDtDesc(Long userId);

특징

  • 기존 네이밍 쿼리(findAllBy...)를 유지하면서 N+1을 쉽게 막을 수 있음
  • "해당 메서드는 항상 이 연관이 필요하다" 같은 경우에 가독성과 유지보수가 좋음
  • 내부적으로는 JPA가 해당 연관을 한번에 로딩하도록 쿼리를 굿겅한다 (보통 join 형태)

둘의 차이 요약

@EntityGraph

  • AKA fetch join의 간편 ver.
  • 특정 API/List 에서는항상 필요한 연관이 고정되어 있음
  • 메서드 네이밍 쿼리를 유지하고 싶음
  • 특히 ManyToOne, OneToOne 같은 to-one 연관을 같이 로딩할 때 깔끔

Fetch Join

  • 조건/조인 구조가 복잡해서 쿼리를 직접 컨트롤하고 싶음
  • 성능 튜닝을 위해 "어떤 join을 하는지"를 코드에서 명확히 보고 싶음
  • 필요할 때만 선택적으로 fetch 해야함

주의사항

to-one은 비교적 안전

  • ManyToOne, OneToOne은 결과 row가 크게 뻥튀기 되지 않아서
    @EntityGraph든 fetch join이든 적용이 상대적으로 안전하다.

to-many(컬렉션) fetch는 조심

  • OneToMany같은 컬렉션을 fetch join 하면 결과 row가 중복되거나,
    페이징이 깨질 수 있는 문제가 자주 생긴다.
  • 이런 경우는 DTO 프로젝션, 배치 패치 사이즈, 별도 조회 등 전략을 같이 고려해야 한다.

결론

  • 항상 필요한 to-one 연관을 같이 가져오겠다 → @EntityGraph가 가장 간단
  • 쿼리를 직접 설계해서 가져오는 걸 통제해야겠다 → fetch join이 낫다

0개의 댓글