N+1 문제란?
JPA에서 가장 흔하고 치명적인 성능 문제입니다. 부모 엔티티 1건을 조회할 때, 연관된 자식 엔티티를 건별로 추가 쿼리하여 총 N+1번의 쿼리가 발생하는 현상입니다.
배달 앱에 비유하면 이해가 쉽습니다. 10개 가게의 메뉴를 확인하는데, 가게 목록 1번 조회 + 각 가게 메뉴 10번 조회 = 총 11번 API 호출이 발생하는 것과 같습니다. 한 번에 “가게와 메뉴를 함께” 요청하는 것이 효율적입니다.
엔티티 설정과 N+1 재현
// Team.java / Member.java — N+1 문제 재현용 엔티티
package com.example.entity;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 기본 페치 전략: LAZY (지연 로딩)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
public Team() {}
public Team(String name) { this.name = name; }
// getter/setter 생략
public Long getId() { return id; }
public String getName() { return name; }
public List<Member> getMembers() { return members; }
}
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne(fetch = FetchType.LAZY) // LAZY 권장
@JoinColumn(name = "team_id")
private Member team;
public Member() {}
public Member(String username, Team team) {
this.username = username;
this.team = team;
}
// getter 생략
public String getUsername() { return username; }
public Team getTeam() { return team; }
}
// N+1 발생 코드
// List<Team> teams = teamRepository.findAll();
// 실행 쿼리: SELECT * FROM team (1번)
//
// for (Team team : teams) {
// team.getMembers().size(); // 각 팀마다 추가 쿼리
// // SELECT * FROM member WHERE team_id = 1 (N번)
// // SELECT * FROM member WHERE team_id = 2
// // SELECT * FROM member WHERE team_id = 3 ...
// }
// 팀이 100개면 총 101번 쿼리 실행!
해결법 1: Fetch Join (JPQL)
// TeamRepository.java — Fetch Join으로 N+1 해결
package com.example.repository;
import com.example.entity.Team;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface TeamRepository extends JpaRepository<Team, Long> {
// Fetch Join — 한 번의 쿼리로 Team + Member 함께 조회
@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
// 실행 쿼리: SELECT t.*, m.* FROM team t
// INNER JOIN member m ON t.id = m.team_id
// → 쿼리 1번으로 완료!
// 주의: 컬렉션 2개 이상 Fetch Join 시 MultipleBagFetchException 발생
// 해결: Set 사용 또는 @BatchSize 병행
}
// 서비스 계층에서 사용
// @Service
// @Transactional(readOnly = true)
// public class TeamService {
// private final TeamRepository teamRepository;
//
// public List<TeamDto> getAllTeamsWithMembers() {
// return teamRepository.findAllWithMembers()
// .stream()
// .map(TeamDto::from)
// .toList();
// // 결과: 쿼리 1번, 모든 팀+멤버 로드 완료
// }
// }
해결법 2: @BatchSize와 EntityGraph
// BatchSize와 EntityGraph 활용
package com.example.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.BatchSize;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// @BatchSize — IN 절로 묶어서 조회
@BatchSize(size = 100)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
// BatchSize 적용 시 쿼리 변화:
// 기존(N+1): SELECT * FROM member WHERE team_id = ? (100번)
// BatchSize: SELECT * FROM member WHERE team_id IN (1,2,...,100) (1번)
// → 101번 → 2번으로 감소!
}
// EntityGraph — 어노테이션으로 페치 전략 지정
// TeamRepository.java
// public interface TeamRepository extends JpaRepository<Team, Long> {
//
// @EntityGraph(attributePaths = {"members"})
// @Query("SELECT t FROM Team t")
// List<Team> findAllWithMembersGraph();
// // Fetch Join과 동일한 효과, JPQL 수정 없이 적용 가능
//
// // 동적 EntityGraph
// @EntityGraph(attributePaths = {"members", "members.tasks"})
// List<Team> findByNameContaining(String name);
// }
해결법 3: DTO 프로젝션
// TeamSummaryDto.java — DTO 프로젝션으로 필요한 데이터만 조회
package com.example.dto;
// 인터페이스 기반 프로젝션 (Spring Data JPA)
public interface TeamSummary {
String getName();
int getMemberCount();
}
// 클래스 기반 프로젝션
public record TeamDetailDto(
Long id,
String name,
long memberCount
) {}
// Repository에서 사용
// public interface TeamRepository extends JpaRepository<Team, Long> {
//
// // 인터페이스 프로젝션 — 프록시 생성
// @Query("SELECT t.name AS name, SIZE(t.members) AS memberCount FROM Team t")
// List<TeamSummary> findTeamSummaries();
//
// // 생성자 프로젝션 — new 연산자 사용
// @Query("SELECT new com.example.dto.TeamDetailDto(t.id, t.name, COUNT(m)) " +
// "FROM Team t LEFT JOIN t.members m GROUP BY t.id, t.name")
// List<TeamDetailDto> findTeamDetails();
//
// // 결과: 엔티티가 아닌 DTO로 직접 매핑
// // → 영속성 컨텍스트 관리 비용 없음
// // → 필요한 컬럼만 SELECT
// }
2차 캐시 설정
// 2차 캐시 설정 — Ehcache 3 기반
// build.gradle
// implementation 'org.hibernate.orm:hibernate-jcache'
// implementation 'org.ehcache:ehcache:3.10.8'
// application.properties
// spring.jpa.properties.hibernate.cache.use_second_level_cache=true
// spring.jpa.properties.hibernate.cache.region.factory_class=jcache
// spring.jpa.properties.hibernate.cache.use_query_cache=true
// spring.jpa.properties.jakarta.persistence.sharedCache.mode=ENABLE_SELECTIVE
// Team.java — 캐시 대상 엔티티
package com.example.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // 2차 캐시 활성화
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // 컬렉션 캐시
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
// 캐시 동작:
// 1차 조회: DB 쿼리 실행, 결과를 2차 캐시에 저장
// 2차 조회: 2차 캐시에서 즉시 반환 (DB 쿼리 없음)
// 엔티티 수정: 캐시 자동 무효화
public Team() {}
public Long getId() { return id; }
public String getName() { return name; }
public List<Member> getMembers() { return members; }
}
// 캐시 전략 선택 가이드:
// READ_ONLY → 변경 없는 코드 테이블 (국가, 카테고리)
// READ_WRITE → 읽기 많고 쓰기 적은 일반 엔티티
// NONSTRICT_READ_WRITE → 동시 수정 가능성 낮은 경우
// TRANSACTIONAL → JTA 환경에서 트랜잭션 캐시 보장
성능 모니터링 설정
// application.properties — 쿼리 분석용 설정
// spring.jpa.show-sql=true // SQL 출력
// spring.jpa.properties.hibernate.format_sql=true // SQL 포맷팅
// spring.jpa.properties.hibernate.generate_statistics=true // 통계 수집
// logging.level.org.hibernate.SQL=DEBUG // SQL 로깅
// logging.level.org.hibernate.orm.jdbc.bind=TRACE // 바인드 파라미터
// 통계 출력 예시:
// Session Metrics {
// 1234 nanoseconds spent acquiring 1 JDBC connections;
// 5678 nanoseconds spent executing 2 JDBC statements;
// 0 nanoseconds spent executing 0 JDBC batches;
// 12345 nanoseconds spent performing 3 L2C hits;
// 0 nanoseconds spent performing 0 L2C misses;
// }
실전 팁
JPA 성능 최적화의 우선순위를 정리하면 다음과 같습니다.
- 1순위: N+1 감지 —
spring.jpa.show-sql=true로 개발 중 쿼리 수를 항상 확인합니다. 쿼리가 예상보다 많으면 N+1을 의심합니다 - 2순위: Fetch 전략 — 연관 관계는 기본 LAZY로 설정하고, 필요한 곳에서만 Fetch Join 또는 EntityGraph로 즉시 로딩합니다
- 3순위: BatchSize — 글로벌
default_batch_fetch_size를 100~1000 사이로 설정하여 N+1을 일괄 방지합니다 - 4순위: DTO 프로젝션 — 조회 전용 API는 엔티티 대신 DTO로 직접 매핑하여 영속성 컨텍스트 비용을 제거합니다
- 5순위: 2차 캐시 — 변경이 적고 조회가 빈번한 엔티티에 선택적으로 적용합니다
@Transactional(readOnly = true)를 조회 메서드에 적용하면 Hibernate 스냅샷 비교를 건너뛰어 성능이 향상됩니다