안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.
스프링 부트(Spring Boot)와 JPA를 사용해 게시판이나 쇼핑몰을 만들다 보면, 어느 날 콘솔 창을 보고 기겁하게 되는 순간이 찾아옵니다.
"어? 나는 분명 전체 게시글을 가져오라고 findAll() 딱 한 번 호출했는데, 왜 콘솔 창에 SELECT 쿼리가 수십, 수백 개씩 미친 듯이 쏟아지는 거지?!"
에러가 나서 서버가 꺼지는 것은 아니지만, 내버려두면 서비스가 느려지다 못해 결국 DB 서버를 터뜨려버리는 무시무시한 시한폭탄! 백엔드 개발자 면접 단골 질문 1순위이기도 한 이 현상을 바로 'N+1 문제'라고 부릅니다.
오늘은 이 N+1 문제의 원인을 아주 명쾌한 '마트 심부름' 비유로 알아보고, 실무에서 가장 많이 쓰이는 해결책인 Fetch Join(패치 조인)까지 3분 만에 완벽하게 정리해 드리겠습니다!
1. JPA N+1 문제, 도대체 왜 일어날까? (비효율적인 마트 심부름 비유)
N+1 문제란, 처음에 요청한 1번의 쿼리로 N개의 데이터를 가져왔는데, 그 N개의 데이터가 가지고 있는 '연관된 데이터'를 채우기 위해 추가로 N번의 쿼리가 더 실행되는 현상을 말합니다. (총 1 + N 번 실행)

게시글(Post)과 댓글(Comment)이 있는 상황을 상상해 봅시다. 여러분은 "게시글 10개와 각각의 댓글들을 화면에 보여줘야지!" 하고 postRepository.findAll()을 호출했습니다.
- 최초 1번의 쿼리: JPA가 DB 마트에 가서 게시글 10개를 싹 긁어옵니다. (1번 왕복)
- 지연 로딩(Lazy Loading)의 함정: 그런데 JPA는 효율성을 위해 처음엔 '댓글' 데이터를 진짜로 안 가져오고 가짜(프록시)로 비워둡니다.
- 추가 N번의 쿼리: 화면에 댓글을 보여주려고 post.getComments()를 호출하는 순간! JPA는 그제야 "앗, 1번 게시글의 댓글이 필요하네? 마트 다녀올게!" 하고 쿼리를 날립니다. 2번 게시글 댓글을 위해 또 마트에 갑니다. 3번 게시글을 위해 또... 이렇게 총 10번(N번)을 추가로 왕복합니다.
게시글이 10개면 11번 왕복하지만, 만약 조회한 게시글이 10,000개라면? 무려 10,001번의 쿼리가 터지면서 서버가 기절하게 되는 것입니다.
2. 해결 방법: 마트 한 번 갈 때 다 털어오기 (Fetch Join)
이 바보 같은 왕복 달리기를 멈추는 가장 확실하고 우아한 실무 해결책은 바로 Fetch Join(패치 조인)을 사용하는 것입니다.

기본적으로 제공되는 findAll() 대신, 우리가 직접 "게시글을 가져올 때 댓글도 함께(Join) 가져와라!"라는 전용 쿼리를 만들어주면 됩니다.
스프링 데이터 JPA를 사용하신다면 Repository 인터페이스에 @Query 어노테이션을 사용하여 아주 쉽게 적용할 수 있습니다.
Java
public interface PostRepository extends JpaRepository<Post, Long> {
// 🚨 기존 방식 (N+1 문제 발생)
// List<Post> findAll();
// ✅ 해결 방식 (Fetch Join 사용)
@Query("SELECT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();
}
이제 컨트롤러나 서비스에서 findAllWithComments()를 호출하면, JPA는 데이터베이스에 딱 1번의 거대한 SELECT 쿼리만 날려서 게시글과 댓글 데이터를 한 번에 조인(Join)하여 가져옵니다.
게시글이 1만 개라도 마트 왕복은 단 1번으로 끝납니다! 쿼리 폭주가 마법처럼 사라지는 것을 콘솔 창에서 확인하실 수 있습니다.
3. 🚨 추가 꿀팁: 페이징(Paging)이 필요하다면 Fetch Join을 조심하세요!
"오, 그럼 무조건 Fetch Join만 쓰면 되겠네요?"
안타깝게도 컬렉션(@OneToMany 관계인 댓글 목록 등)을 Fetch Join 할 때, 페이징 처리(Pageable)를 같이 사용하면 또 다른 끔찍한 에러(메모리 초과 경고)가 발생합니다.
데이터가 뻥튀기되면서 DB에서 페이징을 못 하고, 서버 메모리로 수만 건의 데이터를 다 끌고 와서 자르려다 터지기 때문입니다.
페이징이 필요할 때는 Fetch Join 대신 application.yml에 아래의 '배치 사이즈(Batch Size)' 옵션을 추가하는 것이 정석입니다.
YAML
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # 💡 "마트 심부름 갈 때 100개씩 한 박스로 묶어서 가져와!"
이렇게 설정해 두면 10,000번 왕복할 것을 100번 단위로 묶어서 100번 왕복으로 확 줄여주어 훌륭한 타협점이 됩니다.
💡 [데브프리의 심층 면접 꿀팁: "댓글 말고 '좋아요' 목록까지 둘 다 패치 조인해 주세요!"]
면접관이 "게시글을 가져올 때, '댓글' 목록과 '좋아요' 목록을 동시에 패치 조인(Fetch Join)할 수 있나요?"라고 묻는다면 완벽한 함정 질문입니다!
JPA에서는 @OneToMany 관계인 자식 컬렉션(List)을 2개 이상 동시에 패치 조인하려고 하면 MultipleBagFetchException이라는 끔찍한 에러를 뱉으며 서버가 터집니다.
데이터가 곱하기(카테시안 곱)로 어마어마하게 부풀려지기 때문이죠.
따라서 자식 컬렉션이 여러 개일 때는 가장 중요한 1개만 패치 조인을 걸고, 나머지는 방금 배운 default_batch_fetch_size 옵션에게 맡겨서(지연 로딩 + IN 쿼리 묶음) 해결하는 것이 실무의 정석이라는 점을 꼭 어필해 보세요!
4. 요약: 트러블슈팅 체크리스트
쿼리가 폭주하는 N+1 문제가 터졌다면 아래 3가지를 점검하세요.
- 지연 로딩 상태 확인: 먼저 모든 연관관계 매핑(@ManyToOne, @OneToMany 등)이 FetchType.LAZY(지연 로딩)로 잘 설정되어 있는지 확인합니다. (즉시 로딩인 EAGER는 N+1 문제의 주범이므로 절대 실무에서 쓰면 안 됩니다.)
- Fetch Join 적용: 연관된 데이터를 한 번에 뿌려줘야 하는 API라면, Repository에 @Query("... JOIN FETCH ...")를 작성하여 1번의 쿼리로 묶어서 가져옵니다.
- 페이징 여부 확인: 만약 해당 목록이 페이징(Pageable)이 필요한 목록이라면 Fetch Join 대신 default_batch_fetch_size 설정을 활용합니다.
마무리
코딩을 처음 배울 때는 "기능이 제대로 동작하느냐"가 가장 중요하지만, 백엔드 개발자로서 한 단계 성장하기 위해서는 "얼마나 효율적으로 동작하느냐(최적화)"를 고민해야 합니다.
화면은 똑같이 예쁘게 잘 뜨더라도, 뒤에서 1만 번의 쿼리가 터지는 서버와 단 1번의 쿼리로 끝나는 서버는 수용할 수 있는 트래픽의 규모가 하늘과 땅 차이입니다. 오늘 배운 Fetch Join 원리를 무기 삼아, 쿼리 로그를 매의 눈으로 모니터링하는 멋진 성능 최적화 전문가가 되시길 바랍니다!
오늘 준비한 가이드가 여러분의 어지러운 콘솔 창을 깨끗하게 정리해 주었기를 바랍니다. 막히는 부분이 있다면 언제든 댓글로 남겨주세요.
지금까지 데브프리였습니다. 감사합니다!