https://github.com/next-step/spring-basic-roomescape-playground/pull/189
이번 미션을 진행하면서 멘토님께 피드백을 받으며 제가 겪었던 문제와 해결 과정을 다시 한번 정리해 보고자 이 글을 작성하게 되었습니다.
모든 테스트를 통과하고 동료들의 코드 리뷰까지 마친 새로운 기능이 있었습니다. 팀과 소속 멤버 목록을 보여주는 간단한 대시보드 기능이었죠. 제 로컬 환경의 소규모 테스트 데이터로는 모든 것이 완벽해 보였습니다. 하지만 실제 데이터가 쌓인 스테이징 환경에 배포된 순간, API 응답 시간은 예상과 달리 매우 느렸습니다. 화면에는 끝없이 로딩 아이콘만 돌고 있었죠. 모든 개발자가 두려워하는 순간, 즉 표면 아래에 무언가 깊이 잘못되었다는 것을 깨닫는 순간이었습니다.
가장 먼저 애플리케이션 로그를 확인했지만, 특별한 에러는 발견되지 않았습니다. 코드는 간결했고 로직도 단순했기에 더욱 의아했습니다. 실마리는 show-sql: true
설정을 켜고 나서야 잡혔습니다. 단 한 번의 API 호출에 제 콘솔은 수백, 수천 개의 SELECT
문으로 뒤덮였습니다. 이것이 바로 제가 악명 높은 N+1 문제와 처음으로 마주한 순간이었습니다.
N+1 문제란, 연관관계가 설정된 엔티티를 조회할 때 하나의 쿼리로 N개의 데이터를 가져온 후, 관련된 하위 엔티티를 얻기 위해 N번의 추가 쿼리가 발생하는 현상을 말합니다. 예를 들어, 10개의 Team
엔티티를 조회하면, 팀 목록을 위한 쿼리 1번과 각 팀에 속한 멤버 목록을 가져오기 위한 쿼리 10번이 추가로 실행되는 것이죠. 총 쿼리 수는 1 + 10 = 11번이 됩니다. 만약 팀이 1000개라면, 상상만 해도 끔찍한 일이 데이터베이스에 벌어질 겁니다.
근본적인 원인은 ORM이 객체 지향 패러다임과 관계형 데이터베이스 사이의 간극을 메우는 방식에 있었습니다. JPQL에서 findAll()
은 SELECT t FROM Team t
와 같은 쿼리로 변환되는데, 이 쿼리만으로는 연관된 멤버 정보까지 필요한지 미리 알지 못합니다.
처음에는 단순히 FetchType.EAGER
를 사용하면 해결될 것이라 생각했습니다. @OneToMany(fetch = FetchType.EAGER)
로 엔티티 설정을 바꾸고 다시 테스트를 실행했습니다.
하지만 결과는 충격적이었습니다. N+1 문제는 사라지지 않았고, SQL 로그는 이전과 동일한 패턴을 보였습니다. 여기서 제가 간과했던 중요한 점은, JPQL 쿼리는 기본적으로 엔티티에 정의된 전역 페치 전략(@FetchType
)을 우선적으로 고려하지 않는다는 것이었습니다. Team
을 조회하는 JPQL 쿼리가 실행된 후에야 JPA는 EAGER
설정을 확인하고, 그 약속을 지키기 위해 N개의 추가 쿼리를 실행했던 것입니다.
이 경험을 통해 Eager 로딩은 N+1의 해결책이 아니며, 오히려 문제를 예측하기 더 어렵게 만들 수 있는 위험한 선택일 수 있음을 깨달았습니다.
다음으로 저는 많은 분들이 권장하는 대로 FetchType.LAZY
를 적용했습니다. 어차피 @OneToMany
의 기본값이기도 했죠. findAll()
을 다시 실행하자 로그에는 단 하나의 쿼리만 기록되었습니다. 처음에는 문제가 해결되었다고 생각하며 안도했습니다.
하지만 문제는 사라진 것이 아니라 뒤로 미뤄졌을 뿐이었습니다. members
컬렉션은 실제 데이터가 아닌 프록시 객체로 존재하다가, 서비스 로직에서 멤버 수를 세거나 API 응답을 위해 JSON으로 변환하는 등 실제로 데이터에 접근하는 시점에 지연된 N개의 쿼리가 결국 실행되었습니다. 문제는 해결된 것이 아니라, 더 예측하기 어려운 곳으로 이동했을 뿐이었습니다.
핵심은 데이터를 언제 가져오느냐(즉시 vs. 지연)가 아니라, 어떻게 가져오느냐(하나의 큰 쿼리 vs. 여러 개의 작은 쿼리)의 문제였습니다. Eager와 Lazy 모두 findAll()
같은 일반적인 조회 방식과 함께 사용될 때는 비효율적인 쿼리를 유발할 수 있었습니다. 이를 통해 저는 FetchType
같은 암시적인 설정에 의존하기보다, 필요한 데이터를 어떻게 가져올지 JPA에게 명시적으로 알려주는 방법이 필요하다는 것을 배우게 되었습니다.
JOIN FETCH
로 통제권을 되찾다해결책을 찾던 중 fetch join
을 발견했습니다. 이는 SQL의 조인과는 조금 다른, "이 쿼리를 실행할 때, 연관된 엔티티나 컬렉션도 함께 초기화해줘"라고 JPA에게 지시하는 JPQL 전용 명령어였습니다.
SELECT t FROM Team t JOIN FETCH t.members
이 JPQL 쿼리를 적용하자, SQL 로그에는 LEFT JOIN
을 포함한 단 하나의 SELECT
문만 나타났습니다. Team
과 Member
데이터가 한 번에 조회되었고, N+1 문제는 거짓말처럼 사라졌습니다. API 응답 속도도 눈에 띄게 빨라졌습니다.
여기서 JOIN
과 JOIN FETCH
의 차이점을 짚고 넘어가야 합니다.
SELECT t FROM Team t JOIN t.members m
: 일반적인 조인입니다. members
를 조건으로 사용할 수는 있지만, 오직 Team
객체만 반환합니다. t.members
는 여전히 프록시 상태라 나중에 접근하면 N+1이 발생합니다.SELECT t FROM Team t JOIN FETCH t.members
: 페치 조인입니다. 이 쿼리는 t.members
컬렉션이 완전히 초기화된 Team
객체를 반환합니다. 추가 쿼리는 발생하지 않습니다.문제 해결의 기쁨도 잠시, 페치 조인을 여러 @OneToMany
관계에 적용하자 새로운 문제와 마주했습니다. 결과에 부모 엔티티가 중복으로 가득 찼습니다. 하나의 Team
에 5명의 Member
가 있다면, 자바 리스트에는 5개의 동일한 Team
객체 참조가 포함되었습니다. 이는 SQL 조인이 **카테시안 곱(Cartesian Product)**을 만들기 때문입니다.
해결책은 두 가지였습니다.
DISTINCT
: SELECT DISTINCT t FROM Team t JOIN FETCH t.members
를 사용하면, JPQL이 SQL 레벨뿐만 아니라 애플리케이션 메모리에서도 중복을 제거해주어 문제를 해결할 수 있었습니다.Set
사용: 컬렉션 타입을 List
에서 Set
으로 변경하면, Set
자료구조의 특성상 중복이 자연스럽게 제거되었습니다.다음 요구사항인 페이징 처리를 위해 리포지토리 메서드에 Pageable
객체를 추가하자 더 큰 문제가 발생했습니다. 하이버네이트는 firstResult/maxResults specified with collection fetch; applying in memory!
라는 경고를 뱉었고, 이내 OutOfMemoryError
가 발생하며 애플리케이션이 멈춰 섰습니다.
데이터베이스는 '다(many)' 쪽과 조인된 결과에서 LIMIT
와 OFFSET
을 올바르게 적용하기 어렵습니다. 이 때문에 하이버네이트는 데이터베이스에서 전체 데이터를 메모리로 가져온 후 페이징을 시도했고, 이는 곧 메모리 초과로 이어진 것입니다.
여기서 얻은 중요한 교훈은 이것입니다. 컬렉션(@OneToMany
)에 대한 페치 조인과 페이징(Pageable
)은 함께 사용할 수 없다. 페치 조인은 특정 ID로 상세 정보를 조회하는 등, 페이징이 없는 단일 조회를 위한 강력한 도구이지만, 일반적인 목록 조회에는 적합하지 않다는 것을 깨달았습니다. 모든 상황에 맞는 만능 해결책은 없으며, 상황에 맞는 적절한 도구를 선택해야 함을 배우는 계기가 되었습니다.
페이징 문제를 해결하기 위한 대안을 찾다가 배치 페치를 알게 되었습니다. 이 방식은 하나의 거대한 조인 대신, 먼저 부모 엔티티를 페이징하여 가져온 뒤, 지연 로딩된 자식 컬렉션에 접근할 때 N개의 쿼리를 보내는 대신 IN
절을 사용하는 단일 쿼리로 한 번에 가져오는 방식입니다.
@BatchSize(size=100)
나 default_batch_fetch_size
설정은 IN
절에 들어갈 ID의 개수를 제한합니다. 예를 들어 배치 크기가 100일 때 250개 팀의 멤버를 초기화해야 한다면, 100개씩 두 번, 나머지 50개 한 번, 총 3번의 추가 쿼리로 해결됩니다. 이는 250번의 쿼리보다 훨씬 효율적입니다.
application.yml
에 spring.jpa.properties.hibernate.default_batch_fetch_size: 100
한 줄을 추가하는 것만으로, 코드 수정 없이 애플리케이션 전체의 지연 로딩 성능을 최적화할 수 있습니다. 최소한의 노력으로 최대의 효과를 얻는 '안전망' 같은 설정입니다.@BatchSize(size = 25)
어노테이션을 필드에 직접 추가할 수도 있습니다.이 두 해결책의 장단점을 명확히 이해하는 것이 중요했습니다.
기능 | Fetch Join (ToMany ) | Batch Size (ToMany ) | 추천 사용 사례 |
---|---|---|---|
쿼리 수 | 1개 (하나의 큰 쿼리) | (여러 개의 작은 쿼리) | 페이징 없는 상세 조회 (findByIdWithDetails 등) |
페이징 지원 | 불가능 (OOM 위험) | 가능 (Pageable 과 완벽 호환) | 모든 페이징 목록 조회 |
데이터 전송량 | 높음 (부모 데이터 중복 발생) | 최적화됨 (중복 없음) | 부모 테이블이 클 때 효율적 |
구현 복잡도 | 낮음 (JPQL 키워드) | 낮음 (전역 설정 또는 어노테이션) | 둘 다 간단함 |
이 비교를 통해 명확한 결론에 도달할 수 있었습니다. 페이징이 필요한 대부분의 목록 조회에서는 Batch Size
가 훨씬 우수하고 안정적인 범용 솔루션입니다. Fetch Join
은 페이징이 필요 없는 특정 상세 조회 시나리오를 위해 아껴두는 것이 현명합니다.
hibernate.default_batch_fetch_size
(예: 100)를 전역으로 설정하여 기본 성능을 확보합니다.@ToMany
연관관계는 FetchType.LAZY
로 유지합니다.@ToOne
관계는 카테시안 곱 문제가 없으므로 페이징 쿼리에서도 페치 조인을 자유롭게 사용합니다.@ToMany
에 대한 페치 조인은 페이징이 없는 특정 상세 조회에만 제한적으로 사용합니다.쿼리가 복잡해지면서 JPQL의 단점들이 느껴지기 시작했습니다.
t.naem
) 같은 실수는 런타임에야 발견됩니다.이러한 문제의 해결책으로 QueryDSL을 도입했습니다. QueryDSL은 자바 코드로 타입-세이프(type-safe)하게 쿼리를 작성하게 해주는 라이브러리입니다.
장점은 명확했습니다.
team.naem
과 같은 오타는 컴파일 단계에서 바로 잡힙니다.BooleanBuilder
등을 통해 복잡한 검색 쿼리를 깔끔하고 안전하게 작성할 수 있습니다.앞서 다룬 페치 조인을 QueryDSL로 작성하면 다음과 같습니다.
JPQL: select distinct t from Team t join fetch t.members where t.id = :id
QueryDSL:
QTeam team = QTeam.team;
QMember member = QMember.member;
Team result = queryFactory
.selectFrom(team)
.join(team.members, member).fetchJoin()
.where(team.id.eq(id))
.fetchOne();
QueryDSL로의 전환은 단순한 문법 변경을 넘어, 데이터 접근 계층의 신뢰성과 유지보수성을 근본적으로 향상시키는 과정이었습니다. 버그를 줄이고 개발 속도를 높이며, 더 복잡한 기능을 자신 있게 구축할 수 있는 기반이 되었습니다.
이번 성능 문제를 해결하는 과정은 좌절스럽기도 했지만, 제가 사용하는 프레임워크의 내부 동작을 깊이 이해하는 소중한 계기가 되었습니다. 이번 미션을 통해 얻은 핵심 교훈을 요약하며 글을 마칩니다.
FetchType.LAZY
는 문제를 숨길 뿐 해결책이 아닙니다.Fetch Join
은 강력하지만, 컬렉션과 함께 사용할 때 페이징이 불가능하다는 명백한 한계가 있습니다.Batch Size
는 페이징 목록 조회를 위한 현실적이고 가장 우선적인 해결책으로, 성능과 편의성 사이의 훌륭한 균형점을 제공합니다.batch size
설정과 특정 조회에 fetch join
을 사용하는 하이브리드 전략이 가장 견고한 접근 방식입니다.성능은 나중에 고려할 문제가 아니라, 개발 과정에서 항상 염두에 두어야 할 장인정신의 일부라는 것을 다시 한번 느꼈습니다.