모든 연관 데이터를 단일 LEFT JOIN 쿼리로 조회하던 구조에서 OneToMany 관계인 SosImage를 별도 쿼리로 분리하여 Row Explosion 문제를 해결하고 조회 안정성을 확보했습니다.
그 과정에서 알게 된 내용을 정리해 보겠습니다
문제 상황
SOS 상세 조회 API를 구현하면서 아래 모든 데이터를 단일 QueryDSL 쿼리에서 LEFT JOIN으로 한 번에 조회하도록 설계했습니다.
- Member 정보 (badge)
- ProfileImage
- Business 정보 (name, address)
- BusinessCode
- Sos 정보
- SosImage 리스트
문제는 SosImage가 OneToMany 관계라는 점이었습니다. 이미지가 5개라면 결과 Row도 5개가 생성됩니다. 데이터는 하나인데 이미지 수만큼 행이 복제되는 Row Explosion 현상이 발생했습니다.
원인 분석
근본적인 원인은 두 가지였습니다.
첫째,
Collection(OneToMany) 데이터를 JOIN으로 함께 조회하려 한 점입니다.
QueryDSL에서 Collection JOIN을 사용하면 부모 Row가 자식 데이터 수만큼 증가하고, DB 실행 계획이 복잡해지며, 네트워크 전송량도 늘어납니다. 이를 해결하려면 distinct나 groupBy가 추가로 필요한데, 이 역시 DB CPU 사용량을 높이는 원인이 됩니다
.
둘째,
데이터를 "한 번에 가져오는 것"이 항상 성능적으로 유리하다는 잘못된 전제입니다. 단건 조회와 컬렉션 조회를 목적에 따라 분리하지 않고 동일한 방식으로 접근한 것이 문제였습니다. 동시 접속자가 많아질수록 이 구조는 DB Connection Pool에 직접적인 압박을 가하고, 서비스 확장 시 장애로 이어질 수 있는 구조였습니다.
해결 방법 — 쿼리 분리 전략 (Query Separation)
상세 조회 쿼리를 역할에 따라 두 개로 분리했습니다.
1. Base 정보 조회 (fetchOne)
Sos, Member, Business, ProfileImage, BusinessCode를 한 번에 조회합니다. 이 데이터들은 모두 OneToOne 또는 ManyToOne 관계이므로 JOIN을 사용해도 Row가 증가하지 않습니다.
2. 이미지 리스트 별도 조회
SosImage 테이블만 단독으로 조회하고, 필요한 칼럼(storageKey)만 가져옵니다.
SosDetailResponse base = baseQuery.fetchOne();
List<String> imageKeys = imageQuery.fetch();
base.setImageKeys(imageKeys);
두 쿼리 결과를 애플리케이션 레벨에서 조합하는 방식으로, DB에는 단순한 쿼리 두 번만 요청됩니다.
이 구조를 선택한 이유
JOIN으로 한 번에 가져오는 방식(1 Query)이 직관적으로 더 효율적으로 느껴질 수 있지만, Collection이 포함되는 순간 이야기가 달라집니다. Row Explosion이 발생하면 DB가 처리해야 하는 실제 데이터 양이 늘어나고, 이를 정리하기 위한 추가 연산까지 발생합니다.
쿼리 횟수를 줄이는 것보다 각 쿼리를 단순하게 유지하는 것이 고트래픽 환경에서 훨씬 안정적입니다.
추가로, 상세 조회는 ID 기반 단건 조회이기 때문에 이 구조는 향후 Redis 캐싱을 적용하기에도 매우 용이합니다.
개선 결과
| 쿼리 구조 | 단일 쿼리 + 다중 LEFT JOIN | 쿼리 분리 (Base + Image) |
| Row Explosion | 이미지 수만큼 Row 증가 | 없음 |
| DB 실행 계획 | 복잡 | 단순 |
| 캐시 적용 가능성 | 어려움 | 용이함 |
정리
Collection을 포함하는 상세 조회에서 단일 JOIN 쿼리는 오히려 독이 될 수 있습니다. OneToOne / ManyToOne 관계는 JOIN으로 함께 조회하고, OneToMany 컬렉션은 별도 쿼리로 분리하는 패턴이 실무에서 성능과 안정성 모두를 확보할 수 있는 구조입니다. 쿼리 횟수가 아니라 각 쿼리의 복잡도와 반환 Row 수를 기준이 중요합니다.
이전 김영한 강사님의 강의를 보면서 Collection을 조인 시 무엇보다 조심하라던 말이 있었는데 그 말을 직접 체험했었던 것 같습니다
'❄️ 트러블 슈팅' 카테고리의 다른 글
| [트러블슈팅] Redis 호출 5번을 Pipeline으로 1번에 끝내기 (0) | 2026.03.23 |
|---|---|
| [트러블슈팅] getReferenceById를 이용한 연관 관계 저장 최적화 (0) | 2026.03.22 |
| [트러블 슈팅]공고 상세 조회 API의 불필요한 DB 쿼리 줄이기 (0) | 2026.03.22 |
| [JWT 트러블슈팅] 매 요청마다 DB 조회가 발생하는 문제와 해결 방법 (0) | 2026.03.22 |
| [트러블슈팅] @OneToOne(mappedBy) LAZY 로딩이 동작하지 않는 이유와 해결 방법 (0) | 2026.03.22 |