1. 문제 상황: 콘솔 창을 뒤덮은 폭포수 같은 SELECT 쿼리
Study-Flow 프로젝트에서 스터디 게시판의 게시글 상세 조회 API를 개발하던 중이었습니다. 하나의 게시글에는 여러 개의 '댓글'이 달리고, 각 댓글에는 또 여러 개의 '대댓글(자식 댓글)'이 달리는 계층형 구조(Post ➔ Comment ➔ Child Comment)로 설계했습니다.
기능이 정상적으로 동작하는 것을 확인하고 안심하고 있었는데, 서버의 Hibernate 쿼리 로그를 켠 순간 경악을 금치 못했습니다.

Hibernate: select ... from posts p1_0 where p1_0.id=?
Hibernate: select ... from users u1_0 where u1_0.id=?
Hibernate: select ... from comments c1_0 where c1_0.post_id=?
-- 문제의 시작: 대댓글을 찾기 위해 부모 댓글 수만큼 쿼리가 무한 증식
Hibernate: select ... from comments c1_0 where c1_0.parent_id=?
Hibernate: select ... from comments c1_0 where c1_0.parent_id=?
Hibernate: select ... from comments c1_0 where c1_0.parent_id=?
단일 게시글을 조회했을 뿐인데, 대댓글을 로딩하기 위해 부모 댓글의 개수만큼 SELECT 쿼리가 추가로 발생하는 전형적인 N+1 문제가 터진 것입니다. 만약 트래픽이 몰리는 실서비스였다면 DB 커넥션 풀이 순식간에 고갈될 심각한 병목 지점이었습니다.
2. 1차 시도와 한계: JOIN FETCH 만능주의의 함정
N+1 문제를 해결하기 위해 가장 먼저 떠올린 것은 JPA의 교과서적인 해결책인 **JOIN FETCH**였습니다. 처음에는 아래와 같이 쿼리를 직접 작성하여 부모 댓글과 자식 댓글, 유저 정보를 한 방에 끌어오려 시도했습니다.
@Query("SELECT c FROM Comment c " +
"LEFT JOIN FETCH c.children ch " +
"LEFT JOIN FETCH c.user u " +
"WHERE c.post.id = :postId AND c.parent IS NULL " +
"ORDER BY c.createdAt ASC")
List<Comment> findAllByPostIdWithUserAndPost(@Param("postId") Long postId);
하지만 이 방식에는 치명적인 실무적 한계가 존재했습니다.
- 다중 컬렉션 패치 조인의 부작용: 게시글(Post) ➔ 댓글(Comment) ➔ 대댓글(Children)처럼 1:N 관계가 여러 깊이로 중첩될 경우, JOIN FETCH를 남발하면 데이터가 뻥튀기되는 **카테시안 곱(Cartesian Product)**이 발생합니다.
- 인메모리 페이징 위험: 컬렉션(List)을 패치 조인한 상태에서 페이징(Pageable) 처리를 하려 하면, JPA는 DB에서 페이징을 하지 않고 **모든 데이터를 서버 메모리(RAM)로 끌고 와서 페이징을 시도(applying in memory)**하므로 OOM(Out Of Memory) 장애를 유발할 수 있습니다.
3. 최종 해결: default_batch_fetch_size 도입
복잡한 계층 구조에서는 무리하게 JOIN FETCH 하나로 모든 것을 묶으려는 시도가 오히려 독이 된다는 것을 깨달았습니다. 대신, 지연 로딩(Lazy Loading)의 장점을 살리면서 N+1 쿼리만 하나로 묶어주는 스마트한 최적화 기법을 도입했습니다.
application.yml 파일에 다음과 같이 Batch Size 설정을 추가했습니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
이 설정은 JPA가 지연 로딩을 할 때, 지정된 사이즈(1000)만큼의 ID를 모아서 단일 IN 쿼리로 한 방에 조회하도록 지시하는 마법 같은 옵션입니다.
4. 성과 및 로그 비교
설정을 적용한 후 동일한 게시글 상세 조회 API를 다시 호출해 보았습니다.

Hibernate:
select ...
from comments c1_0
where c1_0.parent_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
아까 수십 번씩 폭포수처럼 쏟아지던 parent_id=? 쿼리들이 사라지고, 단 1번의 IN 쿼리로 아주 깔끔하게 병합되어 실행되는 것을 확인했습니다!
5. 느낀 점
N+1 문제가 발생했을 때 맹목적으로 JOIN FETCH나 @EntityGraph만 고집하는 것이 정답은 아니라는 것을 배웠습니다. 단순한 1:1, N:1 관계에서는 패치 조인이 훌륭한 무기지만, 이번 댓글 시스템처럼 1:N 관계가 중첩되거나 페이징이 필요한 복잡한 상황에서는 default_batch_fetch_size를 활용한 IN 쿼리가 성능과 안정성 측면에서 훨씬 우아한 해결책임을 체감한 소중한 경험이었습니다.