WeeklyPaper: JPA에서 발생하는 N+1 문제의 발생 원인과 해결 방안에 대해 설명하세요.
N+1 문제란?
연관관계에서 발생하는 이슈로, 연관관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상이다.
이 포스팅에서 사용할 예시를 보자:

Member (회원) 엔티티와 Order (주문) 엔티티가 1:N 관계이다. 한 명의 회원은 여러 개의 주문을 가질 수 있다.
N+1문제가 생기는 예시: 회원 10명을 조회했는데, 각 회원의 주문 번호를 또 조회하면,
- 회원 10명을 불러오는 1번의 쿼리 + 각 회원의 주문을 불러오는 10(N)개의 쿼리 = 11 쿼리 발생
발생 이유
지연 로딩 (LAZY)전략으로 연관 관계가 설정된 엔티티를 조회할 때, 각각 개별적으로 추가 조회가 발생하기 때문이다.
Lazy Loading 구조:
Member (회원)
└── Order (주문) (지연로딩 LAZY)
Member 조회 → Order는 proxy → Order 접근 시 실제 쿼리 발생
- LAZY 설정의 의미
- Member를 조회할 때, Member에 연결된 Order 데이터는 일단 가져오지 말라고 설정한 것이다.
- Proxy의 역할
- Member를 조회하는 쿼리(SELECT * FROM member WHERE id = 1;)를 실행하면, 시스템(ORM 프레임워크)은 Member 객체를 생성해서 반환하지만, 때 LAZY 설정 때문에 실제 Order 데이터는 가져오지 않는다.
- 대신, Member 객체의 order 필드에는 가짜 Order 객체, 즉 프록시(Proxy) 객체를 넣어둔체 Member 객체를 돌려준다.
- 이 프록시 객체는 껍데기일 뿐, 내부에는 실제 주문 데이터(주문번호, 주문일자 등)가 없다. 단지 나중에 진짜 데이터를 가져올 때 필요한 정보(예: member_id)만 알고 있다.
- Order 접근 시 실제 쿼리 발생
- 코드에서 member.getOrder() 같은 메서드를 호출해서 프록시 객체에 접근하고, order.getOrderNumber()처럼 실제 데이터가 필요한 작업을 수행하는 바로 그 순간, 프록시 객체는 자신이 진짜 데이터가 아니라는 것을 알고 데이터베이스에 실제 Order 데이터를 조회하는 쿼리(SELECT * FROM order WHERE member_id = 1;)를 실행한다.
- 그리고 그 결과를 가져와 비로소 진짜 Order 객체로 채워 넣는다.
Lazy Loading VS Eager Loading
- Lazy Loading (지연 로딩)
- 데이터 로딩(조회)를 지연시키는 전략
- 어떤 데이터를 요청했을 때, 그 데이터와 연관된 모든 데이터를 한꺼번에 가져오지 않고, 그 연관된 데이터를 사용하는 시점에 가서야 데이터베이스에 조회를 요청 (쿼리 실행)하는 방식이다
- Eager Loading (즉시 로딩)
- 연관된 데이터를 처음부터 전부 다 가져오는 방식이다
참고로, Lazy가 디폴트다:
@Target({METHOD, FIELD})
@Retention(RUNTIME)
public @interface OneToMany {
Class targetEntity() default void.class;
CascadeType[] cascade() default {};
FetchType fetch() default FetchType.LAZY;
String mappedBy() default "";
boolean orphanRemoval() default false;
}
@OneToMany 어노테이션을 살펴보면 FetchType fetch() default FetchType.LAZY;을 볼 수 있다.
코드 예시
Member
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
// Getter, Setter
}
Order
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String itemName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
// Getter, Setter
}
Service
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional(readOnly = true)
public void showMembersWithOrders() {
List<Member> members = memberRepository.findAll(); // 1
// N+1 발생 지점
for (Member member : members) {
System.out.println("member = " + member.getName());
System.out.println("orders = " + member.getOrders().size()); // Lazy Loading → 쿼리 발생
}
}
}
- 첫 번째 쿼리 (1)
- 회원 10명을 모두 조회하라는 요청을 보낸다 -> memberRepository.findAll()
- SELECT * FROM member; 쿼리가 단 한 번 실행된다.
- 결과: Member 객체 10개를 받지만, 이 10개의 객체 안에 있는 order 필드는 모두 실제 데이터가 아닌 프록시(가짜) 객체다.
- 추가 쿼리 (N)
- member.getOrders().size()
- 이제 코드에서 이 10명의 회원 목록을 가지고 반복 작업을 시작한다. (예: for (Member member : memberList))
- 첫 번째 회원의 주문 정보를 사용하려고 member.getOrder()...를 호출 → 프록시가 동작하여 첫 번째 추가 쿼리가 실행 (SELECT * FROM order WHERE member_id = 1;)
- 두 번째 회원의 주문 정보를 사용하려고 member.getOrder()...를 호출 → 프록시가 동작하여 두 번째 추가 쿼리가 실행 (SELECT * FROM order WHERE member_id = 2;)
- ...
- 열 번째 회원의 주문 정보를 사용하려고 member.getOrder()...를 호출 → 프록시가 동작하여 열 번째 추가 쿼리가 실행 (SELECT * FROM order WHERE member_id = 10;)
- member.getOrders().size()
해결책
1. Fetch Join 사용하기
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m JOIN FETCH m.orders")
List<Member> findAllWithOrders();
}
JPQL에서 JOIN FETCH 구문을 사용하면, 연관된 엔티티를 한 번에 조회한다 => Eager loading: 한 번의 쿼리로 연관 객체까지 즉시 로드
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional(readOnly = true)
public void showMembersWithOrders() {
List<Member> members = memberRepository.findAllWithOrders();
for (Member member : members) {
System.out.println("member = " + member.getName());
System.out.println("orders = " + member.getOrders().size());
}
}
}
발생시 쿼리
SELECT m.*, o.*
FROM member m
JOIN orders o ON m.id = o.member_id;
OneToMany Fetch Join 시 중복이 발생할 경우, DISTINCT를 사용해면 된다. (페이징은 불가능하다).
@Query("SELECT DISTINCT m FROM Member m JOIN FETCH m.orders")
2. @EntityGraph 사용하기
- 쿼리를 작성하지 않고 fetch 전략을 재정의할 수 있는 방법
- 특정 쿼리에만 Eager처럼 작동하게 변경 가능
- JPQL 없이 선언적으로 연관 엔티티 조회 가능
- 특징
- 다양한 쿼리에서 재사용 가능, 선언적 방식
- 페이징 시에도 사용 가능
- 복합 조합 가능: @NamedEntityGraph
- 정적 타입 안전성 확보 가능
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(attributePaths = {"orders"})
@Query("SELECT m FROM Member m")
List<Member> findAllWithOrders();
}
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional(readOnly = true)
public void showMembersWithOrders() {
List<Member> members = memberRepository.findAllWithOrders();
for (Member member : members) {
System.out.println("member = " + member.getName());
System.out.println("orders = " + member.getOrders().size());
}
}
}
발생시 쿼리
SELECT m.*, o.*
FROM member m
LEFT JOIN orders o ON m.id = o.member_id;
자료
- https://www.baeldung.com/spring-hibernate-n1-problem
- 코드잇 노트
- Gemini
'Spring' 카테고리의 다른 글
| [Spring TDD] Mockito의 Mock, Stub, Spy (1) | 2025.08.18 |
|---|---|
| [JPA] 트랜잭션의 ACID 속성 중 격리성(Isolation)이 보장되지 않을 때 (0) | 2025.07.23 |
| [SpringBoot] @Controller와 @RestController의 차이점과 요청 처리 흐름 (0) | 2025.07.03 |
| [Spring] Spring AOP 개념과 실제 활용 사례 (0) | 2025.07.01 |
| [SpringBoot] 웹 서버와 WAS의 차이, 그리고 스프링 부트 내장 톰캣 (1) | 2025.06.24 |