| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
- 단위테스트
- 스프링부트
- CSS
- Visual Studio Code
- SpringBoot
- 자바
- HashMap
- 문자열
- 정규식
- javascript
- 테스트자동화
- junit5
- input
- Eclipse
- js
- 자바스크립트
- Java
- html
- Array
- ArrayList
- list
- java테스트
- 자바문법
- junit
- IntelliJ
- math
- string
- 배열
- 인텔리제이
- vscode
- Today
- Total
어제 오늘 내일
[Spring Boot / JPA] "쿼리 한 번 날렸는데 100개가 더 나간다고?" N+1 문제와 해결법 (Fetch Join) 본문
[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());
}
결과 (최악의 시나리오)
SELECT * FROM TEAM(1번: 팀 10개 가져옴)SELECT * FROM MEMBER WHERE TEAM_ID = 1(팀 A의 멤버 조회)SELECT * FROM MEMBER WHERE TEAM_ID = 2(팀 B의 멤버 조회)- ...
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번!)
쿼리가 획기적으로 줄어듭니다.
마치며
오늘의 결론입니다.
- N+1 문제는 연관된 엔티티를 조회할 때 쿼리가 폭발하는 현상이다.
- 모든 연관관계는 기본적으로 지연 로딩(LAZY)으로 설정해라.
- 성능이 중요한 곳에는 Fetch Join을 써서 한 방 쿼리로 가져와라.
- 컬렉션 페이징이 필요하다면
default_batch_fetch_size를 켜라.
이 4가지만 기억하면 JPA 성능 문제의 80%는 해결된 셈입니다.
다음 포스팅에서는 "DB가 꼬이면 안 되니까!" 트랜잭션(@Transactional)의 동작 원리와 롤백 정책에 대해 알아보겠습니다.
