JPA N+1 문제

Computer Science/Spring Boot

N+1문제란 무엇인가?

개발하다보면 N+1이라는 말을 자주 들을 수 있다. 먼저 N+1문제란 무엇인가?에 대해 알아보자.

N+1 문제란 연관된 객체를 조회할 때 1개의 초기 쿼리 이후 N개의 추가 쿼리가 발생하는 현상을 말한다.

 

Eager Loading과 Lazy Loading

N+1에 대해서 설명하기 이전에 JPA에서 연관관계를 설정하는 방법을 알아보자. JPA를 사용하여 엔티티간 연관관계를 설정할 때 fetchType이라는 것을 설정할 수 있다. fetchType은 Eager, Lazy 두 종류가 있는데, Eager로 설정한다면 엔티티를 조회할 때 연관 엔티티가 즉시 조회된다. 여기서 조회하는 방식은 join이나 추가 select문으로 연관 엔티티를 가져온다.

Lazy로 설정한다면 엔티티를 조회할 때 연관 엔티티를 프록시로 조회하고, 해당 객체를 실제로 접근할 때 프록시를 초기화하기 위한 SELECT문을 추가로 생성하여 가져온다.

ToMany, ToOne 연관관계

이제 연관관계를 알아보자. JPA에서 연관관계를 설정하는 방법은 ToOne(OneToOne, ManyToOne)과 ToMany(ManyToMany, OneToMany)로 크게 두 가지가 있다. 

1. ToOne(OneToOne, ManyToOne)

주문 -> 회원, 댓글 -> 게시글 등의 N:1 혹은 1:1 관계에서 자식 테이블에 FK를 걸어주는 용도이다. 해당 필드는 DB 상에서 참조하는 테이블의 PK를 가지게 된다.

// ManyToOne 예시
@Entity
public class Member {
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "club_id")
    private Club iconClub;
}
// OneToOne 예시
@Entity
class Member {
  @OneToOne(fetch = LAZY)
  @JoinColumn(name = "profile_id", unique = true)
  private Profile profile;
}

여기서의 Eager, Lazy 를 확인해보자.

만약 여기서 Eager Loading으로 연관 테이블을 조회한다면 Member를 불러올 때마다 Club, Profile의 모든 필드를 가지고 있게 될 것이다.

그렇다면 Lazy Loading이 정답인가? 라고 하기에도 애매한 것이 Member의 iconClub 정보, profile 정보가 필요할 때 추가 SELECT가 나가게 된다.

2. ToMany(ManyToMany, OneToMany)

// OneToMany 예시
@Entity
class Member {
  @OneToMany(mappedBy = "member", cascade = PERSIST, orphanRemoval = true)
  private List<Address> addresses = new ArrayList<>();
}

@Entity
class Address {
  @ManyToOne(fetch = LAZY) @JoinColumn(name = "member_id")
  private Member member;
}
// ManyToMany 예시
@Entity
class Post {
  @ManyToMany
  @JoinTable(name="post_tag",
    joinColumns = @JoinColumn(name="post_id"),
    inverseJoinColumns = @JoinColumn(name="tag_id"))
  private Set<Tag> tags = new HashSet<>();
}

여기서도 마찬가지로 Eager Loading을 사용한다면 즉시 모든 자식들에 대한 정보를 가져오기 위해 추가적인 SELECT 문이 나갈 것이고, Lazy Loading을 사용한다면 각각의 자식에 접근할 때 추가적인 쿼리를 날리게 될 것이다.

 

이 문제의 핵심은 select문을 여러 번 날리는 것이 아니라 한 번의 JOIN으로 여러 행을 모두 가져오는 것이다. 결국은 DB에서 영속성 컨텍스트로 엔티티를 올리는 과정을 여러 번에 나누어서 DB에 접근하는 것이 아니라 최소한의 DB 접근으로 해결하는 것이 목적이다.

 

N+1 문제 해결 방법

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name="member_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "club_id")
    private Club iconClub;
}

@Test
@Transactional
public void Lazy_Init_Test(){
    Member member = jpaMemberRepository.findById(802L).get();
    Long clubId = member.getIconClub().getId();

    System.out.println("------club " + clubId + " 조회 시점------");
    String clubName = member.getIconClub().getName();
    System.out.println("clubName = " + clubName);
}

이 Member 엔티티에 대해 테스트를 돌려보면 hibernate에서 다음과 같은 쿼리문으로 DB를 조회한다.

근데 여기서 이상한 점이 있다. member를 조회하고 Lazy로 설정한 Club이 프록시로 조회되어서 clubName을 가져오려고 할 때 추가적인 쿼리가 나간다는 것은 정상이다. 근데 왜 Club의 필드인 clubId를 가져올 땐 초기화를 하지 않을까? 그 이유는 Member에 FK로 프록시의 식별자인 clubId를 가지고 있기 때문에 굳이 쿼리문을 추가로 날릴 필요가 없기 때문이다.

만약 여러 Member에 대해서 각각의 clubName을 가져오고 싶다면 해당 멤버들에 대한 쿼리문이 또 나가게 될 것이다.

 

그럼 Eager로 설정하고 다시 조회해보자.

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name="member_id")
    private Long id;

    @ManyToOne(fetch = EAGER)
    @JoinColumn(name = "club_id")
    private Club iconClub;
}

@Test
public void Eager_Init_test(){
    Member member = jpaMemberRepository.findById(802L).get();
    Long clubId = member.getIconClub().getId();
    String clubName = member.getIconClub().getName();
    System.out.println("clubId = " + clubId + ", clubName = " + clubName);
}

이 테스트의 결과는 다음과 같다.

 

쿼리문을 보면 Member만 조회했는데도 Club의 모든 필드를 조회하고 있다. 이 이유는 Eager를 적용하면 영속성 컨텍스트에 부모 엔티티를 조회할 때 자식 엔티티의 인스턴스를 즉시 생성해서 가지고있기 때문이다. 이 상황에서는 JOIN으로 필드를 조회했지만 SELECT로 추가 조회를 하는 경우도 존재한다.

 

또한 이 테스트에서는 @Transactional 어노테이션이 추가되지 않은 것을 볼 수 있는데, 그 이유는 한 번의 조회로 모든 엔티티를 가져와서 트랜잭션을 유지할 필요가 없기 때문이다.

기본적으로 findById에는 @Transactional(readOnly = true)가 달려있고, 메서드가 끝나면 트랜잭션도 같이 끝나게 된다. 이후 getClubName으로 프록시의 실제 필드를 접근하면 열려있는 트랜잭션이 없기 때문에 예외가 발생하게 된다.

LazyInitializationException: @Transactional을 빼고 시도하면 위와 같은 오류가 발생한다.

실제로 로그를 확인해보면 프록시에 접근하려고 할 때 오류가 발생하는 것을 볼 수 있다.

 

이렇게 보면 그냥 Eager를 사용하면 되는거 아닌가? 라는 생각이 들 수 있지만 엔티티를 조회할 때마다 항상 연관 엔티티까지 조회하게 되면 불필요한 JOIN 혹은 SELECT가 발생하게 되고 연관관계가 복잡하게 설정되어있다면 연관 엔티티에 다른 연관관계가 있을 때 해당 엔티티까지 모두 끌어오게 된다. 따라서 Eager 설정은 되도록 지양하고 Lazy로 설정하되 필요한 부분에서 추가 쿼리가 나가지 않도록 하는 설계가 중요하다.

 

그럼 어떻게 추가 쿼리가 나가지 않도록 할까?

첫 번째 방법은 FETCH JOIN 이다.

@Query("""
  select m
  from Member m
  join fetch m.iconClub
  where m.id = :id
""")
Optional<Member> findByIdWithClubFetchJoin(@Param("id") Long id);


@Test
public void Fetch_Join_Test(){
    Member member = jpaMemberRepository.findByIdWithClubFetchJoin(802L).get();
    Long clubId = member.getIconClub().getId();
    String clubName = member.getIconClub().getName();
    System.out.println("clubId = " + clubId + ", clubName = " + clubName);
}

fetch join을 사용해서 쿼리하면 아래와 같은 쿼리문이 나간다.

Eager과 거의 같은 쿼리문이다. 하지만 엔티티 필드는 Lazy로 설정되어있어 해당 메서드만 eager처럼 동작하게 된다. 차이점은 left join과 inner join인데, 기본적으로 hibernate는 엔티티를 로드할 때 안전하게 가져오기 위해 left join을 선택한다. 연관 엔티티가 존재하지 않더라도 member를 가져와야하기 때문이다. 하지만 직접 작성한 fetch join은 left를 명시해주지 않았기 때문에 기본 inner join으로 나간 것이다.

 

두 번째 방법은 EntityGraph이다.

@EntityGraph(attributePaths = "iconClub", type = EntityGraph.EntityGraphType.FETCH)
@Query("select m from Member m where m.id = :id")
Optional<Member> findGraphById(@Param("id") Long id);


@Test
public void Entity_Graph_Test(){
    Member member = jpaMemberRepository.findGraphById(802L).get();
    Long clubId = member.getIconClub().getId();
    String clubName = member.getIconClub().getName();
    System.out.println("clubId = " + clubId + ", clubName = " + clubName);
}

EntityGraph를 사용한 결과는 다음과 같다.

EntityGraph는 "조인해서 가져와!" 가 아니라 "무엇을 즉시 가져와!" 라고 무엇을 즉시 로딩할지 지정하는 fetch 계획 힌트이다.

메서드 위에 @EntityGraph라는 어노테이션의 필드로 iconClub을 지정했고 FETCH 타입으로 가져올 것을 명시하여 메서드가 지정한 연관관계에 대해 어떻게 가져올지 정하는 것이다. FETCH 타입 이외에도 LOAD 타입을 지정할 수 있는데, LOAD도 지정한 필드를 동일하게 즉시 가져오지만 나머지 연관관계에 대한 정의가 다르다. FETCH로 지정하면 나머지 연관관계는 Lazy로 취급하고, LOAD로 지정하면 나머지 연관관계는 우리가 직접 필드에 명시해놓은 값을 따라간다.

 

* Fetch join VS EntityGraph

Fetch join: join으로 연관 관계를 한 번에 끌어오라는 jpql 문법

EntityGraph: 어떤 연관관계만 즉시 로딩할지 정하는 fetch 플랜 힌트. join으로 가져올지, select로 가져올지는 hibernate가 상황에 따라 결정(ToMany와 같은 컬렉션 필드는 추가 select와 배치로 적은 쿼리로 끌어옴)

 

추가로 단순히 조회용이라면 DTO를 사용하는 방법이 있다. 엔티티의 전체가 필요하지 않고, 특정 필드만 필요한 경우엔 DTO나 프로젝션으로 해당 필드만 채워서 반환한다. 예를 들면 모든 게시물 목록과 같은 경우이다.

public class TestDTO {
    @Data
    @AllArgsConstructor
    public static class MemberRow {
        private Long id;
        private String name;
        private String clubName;
    }
}


@Query("""
  select new likelion12.puzzle.DTO.TestDTO$MemberRow(m.id, m.name, c.name)
  from Member m
  join m.iconClub c
  where m.id = :id
""")
Optional<TestDTO.MemberRow> findRowById(@Param("id") Long id);


@Test
public void DTO_Projection_Test(){
    likelion12.puzzle.DTO.TestDTO.MemberRow memberRow = jpaMemberRepository.findRowById(802L).get();

    System.out.println("clubName = " + memberRow.getClubName() + " name = "+ memberRow.getName() + " id = " + memberRow.getId());
}

위와 같이 DTO로 필요한 필드만 조회한다면 좀 더 가볍고 N+1 문제도 일어나지 않게 할 수 있다. 실행되는 쿼리는 다음과 같다.

 

추가로 DTO와 비슷하지만 좀 더 간편한 인터페이스 프로젝션 방법도 있다.

public interface MemberRowProjection {
    Long getId();
    String getName();
    String getClubName();
}


@Query("""
  select m.id as id, m.name as name, c.name as clubName
  from Member m
  join m.iconClub c
  where m.id = :id
""")
Optional<TestDTO.MemberRowProjection> findRowByInterface(@Param("id") Long id);


@Test
public void Interface_Projection_Test(){
    likelion12.puzzle.DTO.TestDTO.MemberRowProjection memberRow = jpaMemberRepository.findRowByInterface(802L).get();

    System.out.println("clubName = " + memberRow.getClubName() + " name = "+ memberRow.getName() + " id = " + memberRow.getId());
}

출력되는 쿼리문은 DTO 프로젝션과 같다. 하지만 이 경우엔 영속성 컨텍스트에 두 엔티티 모두 올라오지 않는다. 하지만 DTO에 엔티티 자체를 넣는다면 영속성 컨텍스트가 관리하는 상태가 된다.

 

이렇게 JPA에서 N+1 문제가 무엇인지, 왜 발생하는지, 어떻게 해결하는지 확인해보았다. 단순히 조회하는 상황에서는 DTO나 인터페이스를 이용한 프로젝션이 가장 효율적이고, 이외의 상황에서는 Fetch Join과 EntityGraph를 적절히 사용하여 N+1 문제를 예방하는 것이 중요하다.

 

공부하며 작성한 글이라 잘못된 부분이 있을 수 있습니다. 틀린 부분 댓글로 피드백 남겨주신다면 반영하도록 하겠습니다!

'Computer Science > Spring Boot' 카테고리의 다른 글

재고 관리에서 발생한 동시성 문제 해결  (0) 2025.09.03
N+1 해결을 통한 성능 개선기  (0) 2025.09.02
Spring MVC와 Servlet  (0) 2025.08.29
스프링 컨테이너와 빈(Bean) - Lifecycle Callback과 Scope  (0) 2025.08.24
스프링 컨테이너와 빈(Bean) - 싱글톤과 의존관계 주입 방법  (0) 2025.08.21
'Computer Science/Spring Boot' 카테고리의 다른 글
  • 재고 관리에서 발생한 동시성 문제 해결
  • N+1 해결을 통한 성능 개선기
  • Spring MVC와 Servlet
  • 스프링 컨테이너와 빈(Bean) - Lifecycle Callback과 Scope
hojoo
hojoo
그냥 개발이 즐거운 사람
  • hojoo
    dev_record
    hojoo
  • 전체
    오늘
    어제
    • 분류 전체보기 (84)
      • Study (0)
        • 모든 개발자를 위한 HTTP 웹 기본 지식 (0)
        • Real MySQL 8.0 (0)
        • 친절한 SQL 튜닝 (0)
        • 도메인 주도 개발 시작하기 (0)
        • 대규모 시스템 설계 기초 (0)
      • Computer Science (68)
        • Problem Solving (30)
        • Data Structure (4)
        • Spring Boot (14)
        • DB (1)
        • Java (4)
        • OS (3)
        • Server (3)
        • Tech (0)
      • Security (16)
        • Reversing (15)
        • Assembly (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    21278
    Lena tutorial
    13265
    서버 증설 횟수
    백준
    bean
    리버싱 핵심원리
    x64dbg
    15973
    PE header
    n^2 배열 자르기
    프로그래머스
    Spring boot
    리버싱
    19622
    자료구조
    dreamhack.io
    DB
    9421
    servlet
    16946
    DP
    2539
    Header
    소수상근수
    HTTP
    레나 튜토리얼
    12033
    n+1
    Reversing
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
hojoo
JPA N+1 문제
상단으로

티스토리툴바