JPA N+1 문제 해결 5가지 전략

JPA N+1 문제란?

N+1 문제는 JPA에서 가장 흔하고 치명적인 성능 이슈입니다. 연관 엔티티를 조회할 때 1번의 쿼리로 부모 N건을 가져온 뒤, 각 부모마다 1번씩 자식 쿼리가 추가 실행되어 총 N+1번의 쿼리가 발생합니다.

이 글에서는 N+1이 발생하는 정확한 메커니즘, 5가지 해결 전략(Fetch Join, EntityGraph, BatchSize, Subselect, DTO Projection), 각 전략의 트레이드오프, 그리고 실전 모니터링 방법을 다룹니다.

N+1 발생 메커니즘

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}
// N+1 발생 코드
List<Team> teams = teamRepository.findAll(); // 쿼리 1회: SELECT * FROM team

for (Team team : teams) {
    // 각 team마다 추가 쿼리 발생!
    System.out.println(team.getName() + ": " + team.getMembers().size());
    // 쿼리 N회: SELECT * FROM member WHERE team_id = ?
}
// 총 쿼리: 1 + N회

FetchType.LAZY여도 EAGER여도 N+1은 발생합니다. EAGER는 findAll() 시점에 즉시 N번 추가 쿼리를 실행하고, LAZY는 컬렉션 접근 시점에 실행합니다. EAGER가 더 위험한 이유는 사용하지 않는 경우에도 항상 쿼리가 실행되기 때문입니다.

해결 전략 1: Fetch Join (JPQL)

가장 기본적이고 강력한 해결책입니다.

public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
    List<Team> findAllWithMembers();
    // 단 1번의 쿼리: SELECT t.*, m.* FROM team t
    //                INNER JOIN member m ON t.id = m.team_id
}

주의사항:

  • DISTINCT 필수 — Fetch Join은 카테시안 곱으로 결과가 부풀어남
  • 페이징 불가Pageable과 함께 사용하면 메모리에서 페이징 (HHH000104 경고)
  • 2개 이상의 컬렉션 Fetch Join 불가MultipleBagFetchException 발생
// ❌ MultipleBagFetchException 발생
@Query("SELECT t FROM Team t JOIN FETCH t.members JOIN FETCH t.sponsors")
List<Team> findAllWithMembersAndSponsors();

// ✅ 해결: 하나는 Set으로 변경
@OneToMany(mappedBy = "team")
private Set<Sponsor> sponsors = new HashSet<>();  // List → Set

해결 전략 2: @EntityGraph

JPQL 없이 선언적으로 Fetch 전략을 지정합니다.

public interface TeamRepository extends JpaRepository<Team, Long> {

    // 방법 1: attributePaths로 직접 지정
    @EntityGraph(attributePaths = {"members"})
    @Query("SELECT t FROM Team t")
    List<Team> findAllWithMembers();

    // 방법 2: Named EntityGraph
    @EntityGraph(value = "Team.withMembersAndSponsors")
    List<Team> findAll();
}

// Named EntityGraph 정의
@Entity
@NamedEntityGraph(
    name = "Team.withMembersAndSponsors",
    attributeNodes = {
        @NamedAttributeNode("members"),
        @NamedAttributeNode("sponsors")
    }
)
public class Team { ... }

EntityGraph는 내부적으로 LEFT OUTER JOIN을 사용합니다. Fetch Join(INNER JOIN)과 다르게 연관 데이터가 없는 부모도 결과에 포함됩니다.

해결 전략 3: @BatchSize

N+1을 1+⌈N/size⌉로 줄입니다. Fetch Join이 불가능한 상황(페이징 등)에서 유용합니다.

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @BatchSize(size = 100)  // IN 절로 최대 100개씩 묶어서 조회
    private List<Member> members;
}

// 실행되는 쿼리:
// SELECT * FROM team                                          (1회)
// SELECT * FROM member WHERE team_id IN (1,2,3,...,100)       (1회)
// SELECT * FROM member WHERE team_id IN (101,102,...,200)     (1회)
// → 팀 500개일 때: 1 + 5 = 6회 (N+1이면 501회)

글로벌 설정도 가능합니다:

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

장점: 페이징과 완벽 호환, 기존 코드 수정 불필요. Transaction 전파 전략과도 충돌 없음.

해결 전략 4: @Fetch(FetchMode.SUBSELECT)

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @Fetch(FetchMode.SUBSELECT)
    private List<Member> members;
}

// 실행되는 쿼리:
// SELECT * FROM team WHERE ...                               (1회)
// SELECT * FROM member WHERE team_id IN                      (1회)
//   (SELECT id FROM team WHERE ...)  ← 서브쿼리로 한번에!

BatchSize와 달리 항상 2번의 쿼리로 해결됩니다. 단, 원본 쿼리를 서브쿼리로 재실행하므로 원본이 복잡하면 성능이 떨어질 수 있습니다.

해결 전략 5: DTO Projection

엔티티 대신 필요한 필드만 DTO로 직접 조회하면 N+1 자체가 발생하지 않습니다.

// Interface Projection
public interface TeamSummary {
    String getName();
    int getMemberCount();
}

public interface TeamRepository extends JpaRepository<Team, Long> {
    @Query("SELECT t.name AS name, SIZE(t.members) AS memberCount FROM Team t")
    List<TeamSummary> findAllSummaries();
}

// Class DTO Projection
public record TeamMemberDto(String teamName, String memberName) {}

@Query("SELECT new com.example.dto.TeamMemberDto(t.name, m.name) " +
       "FROM Team t JOIN t.members m")
List<TeamMemberDto> findAllTeamMembers();

DTO Projection은 영속성 컨텍스트를 거치지 않으므로 가장 성능이 좋습니다. 읽기 전용 API에 적극 활용하세요.

전략 비교

  • Fetch Join: 1회 쿼리, 페이징 불가, 컬렉션 2개 이상 불가. 단순 조회에 최적
  • EntityGraph: LEFT JOIN 기반, 선언적, Fetch Join과 같은 제약. Spring Data와 궁합 좋음
  • BatchSize: 1+⌈N/size⌉회, 페이징 호환, 글로벌 설정 가능. 범용적 해결책
  • Subselect: 항상 2회, 서브쿼리 기반. 단순 쿼리에 효과적
  • DTO Projection: 1회, N+1 원천 차단, 읽기 전용. 성능 최우선 시 선택

N+1 탐지 및 모니터링

Hibernate 쿼리 로깅

# application.yml — 개발 환경 전용
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        # 쿼리 통계 활성화
        generate_statistics: true

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.stat: DEBUG

쿼리 카운트 테스트

// datasource-proxy 라이브러리 활용
@SpringBootTest
class NPlus1Test {

    @Autowired EntityManager em;

    @Test
    void 팀_목록_조회시_쿼리_2회_이내() {
        // given
        QueryCountHolder.clear();

        // when
        List<Team> teams = teamRepository.findAllWithMembers();
        teams.forEach(t -> t.getMembers().size()); // 강제 초기화

        // then
        QueryCount count = QueryCountHolder.getGrandTotal();
        assertThat(count.getSelect()).isLessThanOrEqualTo(2);
    }
}

실전 권장 조합

# 1. 글로벌 BatchSize 설정 (안전망)
spring.jpa.properties.hibernate.default_batch_fetch_size=100

# 2. 모든 연관관계 LAZY 기본
# → @ManyToOne, @OneToOne도 LAZY로 명시

# 3. 핫 쿼리는 Fetch Join 또는 EntityGraph
# 4. API 응답용은 DTO Projection
# 5. 쿼리 카운트 테스트로 회귀 방지

마무리

JPA N+1 문제는 “발생하지 않게 하는 것”이 아니라 “발생했을 때 빠르게 탐지하고 적절한 전략으로 해결하는 것”이 핵심입니다. 글로벌 BatchSize로 안전망을 깔고, 핫 쿼리에는 Fetch Join을, 읽기 전용 API에는 DTO Projection을 적용하세요. Actuator + Micrometer로 슬로우 쿼리를 모니터링하고, R2DBC로의 마이그레이션도 장기적으로 고려해 보세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux