JPA

[JPA] N+1 문제 원인 & 해결

tudamoa 2025. 6. 24. 23:40

✅ 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 테이블
  • 호출 과정
  1. 게시글(Article)을 가져옴. (select * from Article 1번 실행 예상)
  2. 각 게시글은 작성자(Member)와 연관됨.
  3. 게시글 목록에 작성자를 보여주기 위해 작성자 수 만큼 Member 테이블에서 작성자 정보를 불러와야 함.
  4. 작성자(Author)를 불러오기 위해 N개의 select문을 더 실행하게 됨.

10개의 게시글에 6명의 작성자가 있기 때문에 게시글을 조회하기 위한 select문 1번에, 작성자를 조회하기 위한 select문이 6번 추가로 실행 됨.

 

❗ 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
 
📌 3-2. @Fetch(FetchMode.SUBSELECT)
 

 

@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