✅ N+1 문제란?
ORM(Object-Relational Mapping) 프레임워크에서 1번의 쿼리로 N개의 데이터를 조회했을 때
데이터와 연관된 엔티티를 조회하기 위해 N번의 추가 쿼리가 발생하는 성능 문제를 말한다.
즉,
1번의 쿼리를 날리려고 했지만 의도치 않게 N번의 쿼리가 더 나가게 되는 문제다.
🔍 예시

상황
- Spring으로 간단한 게시판 구현
- 10개의 게시글 화면 출력 [ID Title Author Date Hits]
- 6명의 작성자(Author)가 존재 [kwon, 11, 12, 33, 44, 55]
- 테이블
- 게시글의 ID, Title, Date, Hits를 저장하는 Article 테이블
- 로그인ID, PW, 작성자(Author)를 저장하는 Member 테이블
- 호출 과정
- 게시글(Article)을 가져옴. (select * from Article 1번 실행 예상)
- 각 게시글은 작성자(Member)와 연관됨.
- 게시글 목록에 작성자를 보여주기 위해 작성자 수 만큼 Member 테이블에서 작성자 정보를 불러와야 함.
- 작성자(Author)를 불러오기 위해 N개의 select문을 더 실행하게 됨.

❗ 1번의 쿼리를 날리는 것 같았지만 의도치 않게 6(N)번의 쿼리가 더 날라가게 됨.
❗ N+1 문제 발생
✅ 1. LAZY일 때 N+1 문제 발생 예시
💬 기본 전제
- @ManyToOne(fetch = FetchType.LAZY) ← 연관 엔티티는 필요할 때만 조회
💡 예시 상황
List<Article> articles = articleRepository.findAll(); // 게시글 10개 조회
for (Article a : articles) {
System.out.println(a.getAuthor().getName()); // 각 게시글의 작성자 이름 출력
}
💡 쿼리 동작 순서
-- 1번: 게시글 목록 조회
select * from article;
-- 10번: 작성자 10명 조회 (루프 안에서 필요 시점에 개별 쿼리)
select * from member where id = 1;
select * from member where id = 2;
...
select * from member where id = 10;
❗ 총 1 + N = 11번 쿼리 → N+1 문제 발생
✅ 2. EAGER일 때 N+1 문제 발생 예시
💬 기본 전제
- @ManyToOne(fetch = FetchType.EAGER) ← 연관 엔티티를 즉시 함께 조회
💡 예시 상황 (코드는 똑같음)
List<Article> articles = articleRepository.findAll(); // 게시글 10개 조회
for (Article a : articles) {
System.out.println(a.getAuthor().getName()); // 각 게시글의 작성자 이름 출력
}
💡 쿼리 동작 순서
-- 1번: 게시글 조회
select * from article;
select * from member where id = 1;
select * from member where id = 2;
...
select * from member where id = 10;
-- 즉시 10번: 작성자 각각 EAGER 로딩 (JPA가 자동 조인 안 하고 개별 쿼리 실행하는 경우)
❗ 총 1 + N = 11번 쿼리 → N+1 문제 발생
🚨 왜 이런 일이 벌어질까?
지연로딩(LAZY)
- JPA의 연관관계는 기본적으로 지연로딩(LAZY) 으로 설정함.
- 즉, 연관된 엔티티는 실제로 접근하는 순간에 쿼리를 실행.
- @ManyToOne(fetch = LAZY) 관계에서 루프 내에서 .getMember() 같은 코드가 실행되면,
그 때마다 DB에서 select 발생 → 이게 N번
즉시로딩(EAGER)
- EAGER라고 해도 JPA 구현체(Hibernate 등)가 자동으로 join fetch를 하지 않음.
- 단지 연관 객체를 즉시 로딩하겠다는 의미일 뿐, 어떻게 로딩할지는 전략에 따라 달라짐.
- 결국, EAGER도 개별 select로 처리되면 N+1 문제와 동일한 쿼리 패턴 발생.
✅ N+1 문제 해결 방법 3가지
1. Fetch Join (JPQL JOIN FETCH)
📌 개념
JPA의 JPQL에서 연관 엔티티를 명시적으로 JOIN하여 한 번의 쿼리로 함께 조회하는 방법이다.
JPQL에서 성능 최적화를 위해 제공하는 기능이다.
JOIN과 달리, 연관 엔티티를 즉시 로딩해서 실제 객체로 채워준다.
기본적으로는 inner join으로 동작하고 left join이라고 명시하면 outer join이 된다.
@Query("SELECT a FROM Article a JOIN FETCH a.author")
List<Article> findAllWithAuthor();
| 항목 | 설명 |
| 동작 방식 | SQL의 JOIN처럼 동작하며 Article과 Member를 한 번에 조회, 영속성 컨텍스트 1차 캐시에 저장되어 바로 활용될 수 있음 |
| 대상 | @ManyToOne, @OneToMany 모두 가능 |
| 장점 | N+1 완전 차단, 쿼리 한 번에 모든 데이터 조회 |
| 단점 | 컬렉션에서 페이징 불가, 복잡한 조인 시 중복 문제 발생, LAZY 설정 사용 불가능 |
💡 사용 예
- 상세 페이지나 리스트에서 연관 객체를 반드시 출력해야 할 경우
- 페이징이 필요 없는 경우
기본적으로는 LAZY로딩을 사용하는게 좋고, 필요할 때만 fetch join을 사용해서 EAGER를 이용하는게 좋다.
2. @EntityGraph
📌 개념
Spring Data JPA에서 연관 엔티티를 fetch join처럼 미리 불러오도록 지시(EAGER로 동작)하는 선언적 방법이다.
left outer join이 사용되며 페이징과 함께 사용 가능하다.
@EntityGraph(attributePaths = "author")
Page<Article> findAll(Pageable pageable);
| 항목 | 설명 |
| 동작 방식 | 내부적으로 join fetch를 자동 생성 |
| 대상 | @ManyToOne, @OneToMany |
| 장점 | 페이징(Pageable) 가능, 코드 간결 |
| 단점 | 복잡한 조인에는 한계, JPQL처럼 유연한 제어 어려움 |
💡 사용 예
- 게시판 목록처럼 페이징 + 연관 데이터 출력이 필요한 경우
- 별도 쿼리 작성 없이 간단히 성능 최적화 하고 싶을 때
3. 📦 BatchSize / FetchMode.SUBSELECT
📌 개념
Hibernate 전용 기능으로 연관 객체를 모아서 한꺼번에 조회함으로써 지연 로딩(LAZY) 상태에서도 N+1 문제를 완화한다.
추가 조회 쿼리를 없애는 것이 아닌 추가 조회 쿼리를 1개로 줄이는 방향으로 문제를 해결한다.
📌 3-1. @BatchSize
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 10)
private Member author;
- 지연 로딩이 발생할 때 ID를 모아 IN 쿼리로 한 번에 조회 (추가 쿼리 1개)
- 전역 설정도 가능:
spring.jpa.properties.hibernate.default_batch_fetch_size=10
@OneToMany(mappedBy = "member")
@Fetch(FetchMode.SUBSELECT)
private List<Article> articles;
- JPA가 연관 데이터를 개별 조회하는 대신 서브쿼리로 한 번에 조회 (추가 쿼리 1개)
| 항목 | 설명 |
| 대상 | @ManyToOne, @OneToMany, 컬렉션 |
| 장점 | LAZY 그대로 유지하면서도 N+1 완화, 페이징 사용 가능, 복잡한 조인 시에 좋음 |
| 단점 | Hibernate 전용, 동작 방식이 눈에 안 보여 디버깅 어려움 |
💡 사용 예
- 성능은 중요하지만 fetch join이나 entitygraph 쓰기 어려운 경우
- @OneToMany 컬렉션의 지연로딩이 문제일 때
✅ 결론
N+1 문제는 단순히 설정 하나로 해결되지 않는다. 막 사용한다고 해서 좋은 것도 아니다. 중요한 건 전략적으로 제어하는 것이다.
데이터 조회 패턴, 화면 구성, 쿼리 양을 종합적으로 고려해서 전략적으로 최적화해야 한다.
예를 들어,
- 페이징 목록이라면 @EntityGraph
- 상세 조회라면 fetch join
- 컬렉션이 엮여 있다면 @BatchSize나 FetchMode.SUBSELECT
처럼 각 상황에 맞는 도구를 적절히 사용하는 게 중요하다.
'JPA' 카테고리의 다른 글
| [JPA] QueryDSL 이란? (0) | 2025.06.28 |
|---|---|
| [JPA] 다중성이란? (0) | 2025.06.26 |
| [JPA] Fetch Join이란? (0) | 2025.06.25 |