들어가며
JPA를 사용하는 백엔드 개발자라면 한 번쯤은 마주치게 되는 문제가 있습니다. 바로 N+1 문제입니다. 분명 하나의 쿼리로 끝날 것 같았는데, 콘솔 로그를 열어보면 수십, 수백 개의 쿼리가 쏟아지는 경험을 해보셨을 겁니다. 이 글에서는 N+1 문제의 원인을 정확히 이해하고, 실무에서 바로 적용할 수 있는 해결법을 정리하겠습니다.
1. N+1 문제란 무엇인가
N+1 문제는 연관 관계가 설정된 엔티티를 조회할 때, 1번의 쿼리로 N개의 엔티티를 가져온 뒤, 각 엔티티의 연관된 데이터를 가져오기 위해 추가로 N번의 쿼리가 실행되는 현상입니다. 총 1 + N번의 쿼리가 발생하기 때문에 N+1 문제라고 부릅니다.
간단한 예시로 이해하기
팀(Team)과 회원(Member)이 일대다 관계라고 가정하겠습니다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
}
이제 모든 팀을 조회하고, 각 팀의 회원 목록에 접근한다고 해봅시다.
List<Team> teams = teamRepository.findAll(); // 쿼리 1번: SELECT * FROM team
for (Team team : teams) {
System.out.println(team.getMembers().size());
// 팀마다 쿼리 1번씩: SELECT * FROM member WHERE team_id = ?
}
팀이 10개라면 총 11번의 쿼리(1 + 10)가 발생합니다. 팀이 100개면 101번, 1000개면 1001번입니다. 데이터가 많아질수록 성능이 급격히 저하되는 치명적인 문제입니다.
2. 즉시 로딩(EAGER)과 지연 로딩(LAZY)의 차이와 N+1 발생 시점
즉시 로딩 (FetchType.EAGER)
연관된 엔티티를 즉시 함께 로딩하는 전략입니다. 엔티티를 조회하는 시점에 연관 데이터도 함께 가져옵니다.
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private List<Member> members;
EAGER 로딩은 JPQL을 사용할 때 N+1 문제가 발생합니다. findAll()이 내부적으로 SELECT t FROM Team t라는 JPQL을 실행하면, JPA는 먼저 Team을 모두 가져온 뒤 EAGER 설정을 보고 각 Team의 members를 즉시 추가 쿼리로 가져옵니다.
지연 로딩 (FetchType.LAZY)
연관된 엔티티를 실제로 사용하는 시점에 로딩하는 전략입니다. 프록시 객체를 반환해 두었다가, 해당 데이터에 접근할 때 쿼리를 실행합니다.
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members;
LAZY 로딩은 연관 데이터에 실제로 접근하는 시점에 N+1 문제가 발생합니다. team.getMembers().size()처럼 프록시를 초기화하는 순간 추가 쿼리가 나갑니다.
핵심 정리
EAGER든 LAZY든 N+1 문제는 발생합니다. 차이는 발생 시점뿐입니다. EAGER는 조회 즉시, LAZY는 접근 시점에 발생합니다. 그래서 JPA 공식 권장 사항은 모든 연관 관계를 LAZY로 설정하고, 필요한 시점에 적절한 해결법을 적용하는 것입니다.
3. 해결법 1: Fetch Join (JPQL JOIN FETCH)
가장 널리 사용되는 해결법입니다. JPQL에서 JOIN FETCH 키워드를 사용하면, 연관된 엔티티를 한 번의 쿼리로 함께 조회합니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
}
실행되는 SQL은 다음과 같습니다.
SELECT t.*, m.*
FROM team t
INNER JOIN member m ON t.id = m.team_id
단 한 번의 쿼리로 팀과 회원 데이터를 모두 가져옵니다.
주의사항: 데이터 중복과 DISTINCT
일대다 관계에서 Fetch Join을 사용하면 조인으로 인해 데이터가 뻥튀기됩니다. 팀 1개에 회원 3명이면 같은 팀 데이터가 3줄로 나옵니다. 이를 방지하려면 DISTINCT를 사용합니다.
@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
참고로 Hibernate 6(Spring Boot 3.x)부터는 JPQL의 DISTINCT가 자동으로 엔티티 중복을 제거해주므로, 명시적으로 작성하지 않아도 됩니다.
Fetch Join의 한계
- 컬렉션 Fetch Join은 1개만 가능합니다. 둘 이상의 컬렉션을 동시에 Fetch Join하면
MultipleBagFetchException이 발생합니다. - 페이징 API 사용 불가입니다. 컬렉션 Fetch Join 시
setFirstResult,setMaxResults를 적용하면 메모리에서 페이징 처리가 되어 매우 위험합니다. Hibernate가 경고 로그를 남깁니다:HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
4. 해결법 2: @EntityGraph
@EntityGraph는 Spring Data JPA에서 제공하는 어노테이션으로, JPQL을 직접 작성하지 않고도 Fetch Join과 동일한 효과를 낼 수 있습니다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@EntityGraph(attributePaths = {"members"})
@Query("SELECT t FROM Team t")
List<Team> findAllWithMembers();
// 메서드 이름 기반 쿼리에도 사용 가능
@EntityGraph(attributePaths = {"members"})
List<Team> findAll();
}
@EntityGraph는 내부적으로 LEFT OUTER JOIN을 사용합니다. Fetch Join이 기본적으로 INNER JOIN을 사용하는 것과 차이가 있으므로, 회원이 없는 팀도 조회 결과에 포함됩니다.
여러 연관 관계를 동시에 로딩
@EntityGraph(attributePaths = {"members", "sponsors"})
@Query("SELECT t FROM Team t")
List<Team> findAllWithMembersAndSponsors();
attributePaths에 여러 필드를 지정하면 됩니다. 다만 이 역시 컬렉션이 2개 이상이면 MultipleBagFetchException 위험이 있으므로, 컬렉션 타입을 Set으로 변경하는 것을 고려해야 합니다.
5. 해결법 3: @BatchSize
@BatchSize는 N+1 문제를 완전히 제거하지는 않지만, N+1을 N/BatchSize + 1로 최적화합니다. SQL의 IN 절을 사용하여 여러 엔티티의 연관 데이터를 한꺼번에 가져옵니다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
이렇게 설정하면 회원 데이터를 가져올 때 다음과 같은 쿼리가 실행됩니다.
SELECT * FROM member WHERE team_id IN (?, ?, ?, ... , ?)
팀이 100개 이하라면 딱 2번의 쿼리(팀 조회 1번 + 회원 일괄 조회 1번)로 끝납니다.
글로벌 설정
엔티티마다 어노테이션을 붙이는 대신, application.yml에서 전체 애플리케이션에 일괄 적용할 수 있습니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이 설정 하나로 모든 연관 관계에 BatchSize가 적용됩니다. 실무에서 매우 유용한 설정입니다.
6. 각 해결법의 장단점 비교
| 구분 | Fetch Join | @EntityGraph | @BatchSize |
|---|---|---|---|
| 쿼리 수 | 1번 | 1번 | 1 + N/size번 |
| JPQL 작성 | 필요 (JOIN FETCH 명시) | 불필요 (어노테이션) | 불필요 |
| 페이징 지원 | 컬렉션 조회 시 불가 | 컬렉션 조회 시 불가 | 가능 |
| 다중 컬렉션 | 1개만 가능 | Set으로 변경 시 가능 | 제한 없음 |
| 글로벌 적용 | 불가 (쿼리별 설정) | 불가 (메서드별 설정) | 가능 (yml 설정) |
| 조인 방식 | INNER JOIN | LEFT OUTER JOIN | 조인 없음 (IN 절) |
| 데이터 중복 | 발생 가능 (DISTINCT 필요) | 발생 가능 | 발생하지 않음 |
| 적용 난이도 | 중간 | 쉬움 | 매우 쉬움 |
7. 실무에서의 베스트 프랙티스
원칙 1: 모든 연관 관계는 LAZY로 설정하라
@ManyToOne과 @OneToOne은 기본값이 EAGER이므로 반드시 fetch = FetchType.LAZY를 명시하세요. @OneToMany와 @ManyToMany는 기본값이 LAZY이므로 그대로 두면 됩니다.
@ManyToOne(fetch = FetchType.LAZY) // 반드시 LAZY로!
@JoinColumn(name = "team_id")
private Team team;
원칙 2: 글로벌 BatchSize를 기본으로 깔아라
default_batch_fetch_size: 100을 application.yml에 설정하면, 별도의 코드 수정 없이도 N+1 문제의 충격을 크게 줄일 수 있습니다. 이것은 안전망 역할을 합니다.
원칙 3: 성능이 중요한 곳은 Fetch Join으로 최적화하라
API 응답 속도가 중요한 화면이나 대량 데이터 처리 로직에서는 Fetch Join을 적용하여 쿼리를 1번으로 줄이세요. 단, 페이징이 필요한 곳에서는 BatchSize가 더 적합합니다.
원칙 4: 컬렉션 페이징은 BatchSize로 해결하라
일대다 관계에서 페이징이 필요하다면 Fetch Join 대신 BatchSize를 사용하세요. Fetch Join + 페이징 조합은 메모리 내 페이징이라는 치명적인 문제를 일으킵니다.
// 올바른 방법: ToOne 관계만 Fetch Join + BatchSize로 컬렉션 해결
@Query("SELECT t FROM Team t JOIN FETCH t.league") // ManyToOne은 Fetch Join
Page<Team> findAllWithLeague(Pageable pageable);
// members(OneToMany)는 BatchSize로 자동 최적화
원칙 5: 쿼리 로그를 반드시 확인하라
개발 환경에서 SQL 로그를 켜두고 예상대로 쿼리가 나가는지 항상 확인하세요.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
정리
JPA N+1 문제는 ORM의 편리함 뒤에 숨어 있는 대표적인 성능 함정입니다. 하지만 원리를 이해하고 적절한 해결법을 적용하면 충분히 제어할 수 있습니다.
- LAZY 로딩을 기본으로 설정하고
- 글로벌 BatchSize로 안전망을 깔고
- 성능이 중요한 곳에서는 Fetch Join으로 정밀 최적화하세요
이 세 가지만 기억하면 실무에서 N+1 문제로 고생하는 일은 크게 줄어들 것입니다. 쿼리 로그를 확인하는 습관과 함께, JPA를 더 안전하고 효율적으로 사용하시길 바랍니다.
'JPA' 카테고리의 다른 글
| Spring Data JPA 고급 기능 - Specification, Projection, Auditing (0) | 2026.04.03 |
|---|---|
| JPA 성능 최적화 실전 - 벌크 연산부터 2차 캐시까지 (0) | 2026.04.02 |
| QueryDSL 실전 가이드 - 동적 쿼리부터 페이징까지 (0) | 2026.03.26 |
| JPA 영속성 컨텍스트 완벽 이해 - 1차 캐시부터 변경 감지까지 (0) | 2026.03.26 |