
JPA N+1 문제 — 왜 생기고 어떻게 해결할까?
들어가며
JPA는 객체 중심으로 데이터베이스를 다룰 수 있게 해주는 매우 편리한 기술입니다. 복잡한 SQL을 직접 작성하지 않아도 되고, 객체 간의 관계를 자연스럽게 표현할 수 있어서 많은 개발자들이 즐겨 사용합니다.
그런데 이 편리함 뒤에는 한 가지 함정이 숨어 있습니다. 바로 N+1 문제입니다. 코드는 아무 문제없이 돌아가는 것처럼 보이지만, 내부에서는 수십, 수백 개의 쿼리가 조용히 실행되고 있을 수 있습니다. 데이터가 적을 때는 체감이 안 되지만, 운영 환경에서 데이터가 쌓이기 시작하면 서비스 전체가 느려지는 치명적인 병목이 됩니다.
이 글에서는 N+1 문제가 왜 발생하는지, 그리고 어떻게 해결할 수 있는지를 코드와 함께 차근차근 살펴보겠습니다.
N+1 문제란?
N+1 문제는 이름 그대로입니다. 1번의 쿼리로 N개의 결과를 가져온 후, 각 결과마다 추가로 N번의 쿼리가 실행되는 현상입니다.
예를 들어 회원이 100명 있다고 가정해보겠습니다. 전체 회원 목록을 조회하는 쿼리가 1번 실행됩니다. 그런데 각 회원의 주문 목록을 가져오기 위해 100번의 추가 쿼리가 발생합니다. 결국 고작 회원 목록 하나를 보여주기 위해 총 101번의 쿼리가 데이터베이스로 날아가는 셈입니다.
sqlSELECT FROM member; -- 1번
SELECT FROM orders WHERE member_id = 1; -- +1
SELECT FROM orders WHERE member_id = 2; -- +1
SELECT FROM orders WHERE member_id = 3; -- +1
-- ... 회원 수만큼 반복
이것이 바로 N+1 문제입니다. 작은 규모에서는 괜찮아 보이지만, 데이터가 수천 건이 넘어가는 순간부터 서비스는 눈에 띄게 느려지기 시작합니다.
왜 발생하는 걸까?
아래와 같은 연관 관계가 있다고 가정합니다.
java@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders;
}
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
}
그리고 아래처럼 전체 회원을 조회한 뒤 주문 목록을 출력하는 코드를 작성했습니다.
javaList members = em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
for (Member m : members) {
System.out.println(m.getOrders().size()); // 이 시점에 N번 쿼리 발생
}
m.getOrders()를 호출하는 순간, 지연 로딩(LAZY)으로 설정된 orders가 실제 데이터베이스에서 조회됩니다. 회원이 100명이라면 이 시점에 100번의 쿼리가 추가로 실행됩니다.
EAGER로 바꾸면 해결될까요?
많은 분들이 처음에 이런 생각을 합니다. "LAZY 대신 EAGER로 바꾸면 한 번에 다 가져오니까 해결되지 않을까?"
결론부터 말씀드리면, 그렇지 않습니다. JPQL은 작성된 쿼리를 그대로 SQL로 번역해서 실행하고, Fetch 전략은 그 이후에 별도로 처리됩니다. EAGER든 LAZY든 JPQL로 조회하면 연관 엔티티를 위한 추가 쿼리는 여전히 N번 발생합니다.
오히려 EAGER는 필요하지 않은 데이터도 항상 함께 로딩하기 때문에 예측하기 어려운 성능 문제를 유발합니다. 그래서 연관 관계는 항상 LAZY로 설정하는 것이 기본 원칙입니다.
해결 방법 4가지
1. Fetch Join — 가장 기본적인 해결책
Fetch Join은 N+1 문제를 해결하는 가장 직접적인 방법입니다. JPQL에서 JOIN FETCH 키워드를 사용하면, 연관 엔티티를 단 한 번의 JOIN 쿼리로 함께 조회할 수 있습니다.
javaList members = em.createQuery(
"SELECT m FROM Member m JOIN FETCH m.orders", Member.class)
.getResultList();
실행되는 SQL을 보면 Member와 Order가 하나의 쿼리로 합쳐진 것을 확인할 수 있습니다.
sqlSELECT m., o.
FROM member m
INNER JOIN orders o ON o.member_id = m.id;
Spring Data JPA에서는 @Query 어노테이션과 함께 사용합니다.
javapublic interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m JOIN FETCH m.orders")
List<Member> findAllWithOrders();
}
다만 한 가지 중요한 제약이 있습니다. 컬렉션을 Fetch Join하면서 동시에 페이징을 적용할 수 없습니다. Hibernate가 경고를 발생시키고 모든 데이터를 메모리에 올린 뒤 페이징을 처리하기 때문에, 데이터가 많으면 오히려 더 위험할 수 있습니다. 페이징이 필요한 상황에서는 뒤에 소개할 Batch Size를 사용해야 합니다.
@EntityGraph — 더 간결한 표현
@EntityGraph는 Fetch Join을 어노테이션으로 간결하게 표현하는 방법입니다.
javapublic interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(attributePaths = {"orders"})
List<Member> findAll();
}
내부적으로는 LEFT OUTER JOIN을 사용해 Fetch Join과 동일하게 동작합니다. 별도의 JPQL을 작성하지 않아도 되기 때문에 간단한 조회 상황에서 코드가 훨씬 깔끔해집니다.
Batch Size — 페이징 상황의 핵심
페이징과 컬렉션 조회를 동시에 해야 할 때 가장 중요한 방법입니다. Fetch Join으로는 페이징이 불가능하기 때문에, 이 상황에서는 Batch Size를 반드시 알고 있어야 합니다.
Batch Size를 설정하면, 기존에 N번 개별로 나가던 쿼리를 SQL의 IN절로 묶어서 한 번에 처리합니다.
sql-- 기존: 100번 쿼리
SELECT FROM orders WHERE member_id = 1;
SELECT FROM orders WHERE member_id = 2;
-- ... 100번 반복
-- Batch Size 적용 후: 1번 쿼리
SELECT * FROM orders WHERE member_id IN (1, 2, 3, ... , 100);
application.yml에 아래 한 줄을 추가하면 전역으로 적용됩니다.
yamlspring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
실무에서는 이 설정을 100에서 1000 사이로 전역 적용하는 것을 강력히 권장합니다. 특정 엔티티에만 적용하고 싶다면 @BatchSize 어노테이션을 사용하면 됩니다.
java@BatchSize(size = 100)
@OneToMany(mappedBy = "member")
private List orders;
상황별로 어떤 방법을 써야 할까?
상황권장 방법페이징 없는 단순 조회Fetch Join / @EntityGraph페이징 + 컬렉션 조회Batch Size다중 컬렉션 조회Batch Size성능이 극도로 중요한 APIDTO 직접 조회
실무에서 가장 많이 사용하는 조합은 default_batch_fetch_size 전역 설정 + 필요한 곳에만 Fetch Join입니다. 이 두 가지만 잘 적용해도 대부분의 N+1 문제는 해결할 수 있습니다.
마치며
N+1 문제는 JPA를 사용하는 모든 개발자가 반드시 이해하고 대응해야 하는 문제입니다. 코드가 정상적으로 동작하는 것처럼 보여도, 내부에서는 수백 개의 쿼리가 조용히 실행되고 있을 수 있습니다.
가장 중요한 습관은 show-sql: true 옵션을 켜두고 실행 쿼리를 항상 확인하는 것입니다. 눈으로 직접 쿼리를 보는 것만으로도 문제를 훨씬 빠르게 발견할 수 있습니다.
오늘 소개한 네 가지 방법을 상황에 맞게 적용하면서, 더 안정적이고 빠른 서비스를 만들어 나가시길 바랍니다.