어제 오늘 내일

[Spring Boot / JPA] "쿼리 한 번 날렸는데 100개가 더 나간다고?" N+1 문제와 해결법 (Fetch Join) 본문

IT/SpringBoot

[Spring Boot / JPA] "쿼리 한 번 날렸는데 100개가 더 나간다고?" N+1 문제와 해결법 (Fetch Join)

hi.anna 2026. 3. 13. 07:50

 

JPA를 쓰다 보면 로직은 완벽한데 페이지 로딩이 엄청 느릴 때가 있습니다.
로그를 확인해 보니 SELECT 쿼리가 끝도 없이 실행되고 있다면? 축하합니다. 당신은 N+1 문제에 당첨되셨습니다.

이것은 JPA가 너무 자동화를 잘해줘서 생기는 부작용인데요. 원리만 알면 아주 쉽게 잡을 수 있습니다.

 


 

1. N+1 문제가 뭔가요?

쉽게 말해 "1번의 쿼리로 N개의 데이터를 가져왔는데, 그 N개의 데이터를 조회하기 위해 N번의 추가 쿼리가 나가는 현상"입니다.

상황: 팀(Team)과 멤버(Member) 조회

팀 10개를 조회하고, 각 팀에 소속된 멤버들의 이름을 출력한다고 가정해 봅시다.

// 1. 팀 10개를 조회 (쿼리 1번)
List<Team> teams = teamRepository.findAll();

// 2. 루프를 돌며 멤버 조회
for (Team team : teams) {
    // 여기서 문제가 터집니다!
    // team.getMembers()를 호출하는 순간, 멤버를 가져오는 쿼리가 또 나갑니다.
    System.out.println("멤버 수: " + team.getMembers().size());
}

결과 (최악의 시나리오)

  1. SELECT * FROM TEAM (1번: 팀 10개 가져옴)
  2. SELECT * FROM MEMBER WHERE TEAM_ID = 1 (팀 A의 멤버 조회)
  3. SELECT * FROM MEMBER WHERE TEAM_ID = 2 (팀 B의 멤버 조회)
  4. ...
  5. SELECT * FROM MEMBER WHERE TEAM_ID = 10 (팀 J의 멤버 조회)

총 쿼리 수: 1(최초) + 10(추가) = 11번
만약 팀이 1,000개라면? 1,001번의 쿼리가 나갑니다. DB가 비명을 지르겠죠?

 


 

2. "지연 로딩(Lazy)"으로 해결 안 되나요?

많은 분들이 오해하시는데, FetchType.LAZY로 설정한다고 N+1이 사라지는 게 아닙니다. 시점만 뒤로 미룰 뿐입니다.

  • 즉시 로딩 (EAGER): findAll() 하자마자 N개의 쿼리가 즉시 나갑니다. (더 위험함)
  • 지연 로딩 (LAZY): findAll() 때는 안 나가지만, 루프 돌면서 getMembers()호출하는 시점에 결국 나갑니다.

결국, 어떻게든 DB를 N번 더 가게 됩니다.

 


 

3. 해결사 등장: 페치 조인 (Fetch Join) ★★★

가장 확실하고 많이 쓰이는 해결책입니다.
"야, JPA! 나 팀 조회할 건데, 그때 멤버 데이터도 SQL 조인(Join)으로 한 방에 긁어와 줘!" 라고 명령하는 것입니다.

일반적인 join과 다릅니다. 일반 조인은 연관된 엔티티를 조회하지 않고 필터링만 하지만, fetch join연관된 엔티티까지 함께 영속화(Select) 합니다.

사용법 (JPQL)

Repository 인터페이스에 @Query를 사용해서 직접 작성합니다.

public interface TeamRepository extends JpaRepository<Team, Long> {

    // 일반 조인: select t from Team t join t.members m (멤버 데이터 안 가져옴)

    // 페치 조인: select t from Team t join fetch t.members (멤버까지 싹 다 가져옴!)
    @Query("select t from Team t join fetch t.members")
    List<Team> findAllWithMembers();
}

결과

이렇게 하면 실제 나가는 SQL은 딱 1개입니다.

SELECT T.*, M.* FROM TEAM T 
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID

N+1 문제가 깔끔하게 해결되었습니다!

 


 

4. 또 다른 방법: @EntityGraph

"JPQL 짜기 귀찮은데..." 하시는 분들을 위한 어노테이션 기반 방법입니다.

// attributePaths에 같이 가져올 필드명을 적어줍니다.
@EntityGraph(attributePaths = {"members"})
List<Team> findAll();

내부적으로 LEFT OUTER JOIN을 사용하여 페치 조인과 똑같이 동작합니다. 간단한 조회에는 이걸 쓰고, 복잡한 조건이 필요하면 JPQL Fetch Join을 쓰는 게 좋습니다.

 


 

5. 보너스 팁: 페이징이 필요할 땐? (@BatchSize)

그런데 Fetch Join을 쓰면 치명적인 단점이 하나 있습니다. OneToMany(1:N) 관계를 페치 조인하면 페이징(Paging) 처리를 메모리에서 합니다. (데이터가 많으면 Out of Memory로 서버가 죽습니다!)

이때는 BatchSize라는 옵션을 씁니다. N개의 쿼리를 1개로 줄이진 못하지만, 1000개를 10개로 획기적으로 줄여줍니다.

설정 (application.yml)

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100 # 보통 100~1000 사이 추천

동작 원리 (IN 절 사용)

-- 기존: WHERE TEAM_ID = 1, WHERE TEAM_ID = 2 ... (100번)
-- 변경: WHERE TEAM_ID IN (1, 2, 3, ... 100) (1번!)

쿼리가 획기적으로 줄어듭니다.

 


 

마치며

오늘의 결론입니다.

  1. N+1 문제는 연관된 엔티티를 조회할 때 쿼리가 폭발하는 현상이다.
  2. 모든 연관관계는 기본적으로 지연 로딩(LAZY)으로 설정해라.
  3. 성능이 중요한 곳에는 Fetch Join을 써서 한 방 쿼리로 가져와라.
  4. 컬렉션 페이징이 필요하다면 default_batch_fetch_size를 켜라.

이 4가지만 기억하면 JPA 성능 문제의 80%는 해결된 셈입니다.

다음 포스팅에서는 "DB가 꼬이면 안 되니까!" 트랜잭션(@Transactional)의 동작 원리와 롤백 정책에 대해 알아보겠습니다.

 

 

반응형
Comments