개요
프로젝트를 진행하면서 팀원 분께서 한방 쿼리에 대한 이점을 강조하셨다.

DB 왕복을 최소화 하는 것은 서버의 리소스를 절약하는 가장 대표적인 방식 중 하나라고 생각한다.
그렇기에 해당 의견에 대해 동의했다.
그러나 수 MVP 기능이 많을 뿐만 아니라 정규화의 단계를 강하게 가져가 설계 했기에 수많은 DB Join은 필수적이었다.
대표적으로 게시물과 같은 경우 총 N개의 테이블과 묶여 있어 수많은 Join과 서브쿼리가 동반됐다.
내가 알고 있던 지식 중 조인 과정에서 주의해야 할 점 중 하나가 카테시안 곱이었다. 하나의 쿼리에서 여러 테이블을 한번에 조인할 경우 DB로부터 가져오는 행(row)의 수가 급격하게 늘어날탠데 이는 오히려 성능을 악화시키지 않을까? 라는 생각이 들었다.
??? = 게시물 10개 * 사진 10개 * 카테고리 n개 ...
더불어 팀원분께서는 DB Connection 고갈, DB 왕복 횟수 증가로 인한 네트워크 지연 등을 이유로 단일 쿼리에 대한 이점을 다시 한번 더 설명해주셨다. 앞서 생각한 나의 예상들은 결국 수치와 근거가 부족한 단순한 추측에 불과했다.
이에 대해 직접 알아보고 싶어 테스트를 진행해봤다.
1차 테스트 진행
https://github.com/yourjinKR/spring-query-test
우선 AI를 활용하여 환경을 구축하고 빠르게 테스트를 진해했다.
게시글 200개에 각각 댓글 20개와 첨부파일 5개를 생성하고, 동일한 게시글 50개를 조회했다. 워밍업 2회 후 두 전략을 각각 8회씩 순차 실행한 결과, 서버의 조회·DTO 조립 시간 중앙값(p50)은 단일 JOIN 11.22 ms, 분할 조회 5.51 ms로 측정됐다.
DB 왕복 증가 ≠ 속도 저하라는 무조건적인 명제는 아닌듯하다.
그러나 해당 환경은 단건 API 실행에 가깝기에 아래와 같은 한계점을 고려하며 추가적인 측정이 필요할거 같다.
- 행이 줄어도 쿼리가 1회에서 3회로 늘면서 추가 왕복 비용이 발생
- 식 수가 적으면 JOIN의 행 증폭이 작아 단일 조회가 유리할 수도 있음
- 동시 요청이 늘었을 때의 처리량, 지연, 커넥션 풀 대기는 아직 별도 비교가 필요
2차 테스트 진행
https://github.com/yourjinKR/spring-query-test/pull/2
- 총 60회 측정에서 정상 57회·부하 실패 3회였으며, 아래 수치는 실행별 통계의 중앙값입니다.
- 동시 요청 20개·풀 2개에서 v2는 처리량 514→984 RPS, p95 지연 68.63→36.41ms로 개선됐습니다.
- 동시 요청 20개·풀 10개에서는 처리량 639→1,382 RPS, p95 지연 67.29→47.52ms로 개선됐습니다.
- 커넥션 획득 평균도 풀 2에서 33.53→17.19ms, 풀 10에서 5.59→0.93ms로 줄었고, 두 조건 모두 5쌍 전부 v2가 유리했습니다.
- 현재 데이터·로컬 환경에서는 v2가 유리했지만, 150 req/s에서 실패 3회가 있어 안정성 우위나 다른 환경까지 일반화할 수는 없습니다.
원인 분석
단순히 테스트 결과로만 봤을 땐 다중 쿼리로 분리하는 것이 성능적으로 우세할거 같다.
그렇다면 실제로 과연 두 방식에는 어떤 트레이드 오프가 있을지 알아보자.
다시 테스트는 접어두고 본론에 다시 집중해본다.
각 방식의 트레이드 오프는 무엇일까???
| 관점 | 한 번의 쿼리 | 행 최적화 다중 쿼리 |
|---|---|---|
| DB Round Trip | 적음 | 많음 |
| 네트워크 왕복 비용 | 낮음 | 상대적으로 높음 |
| SQL 실행 횟수 | 적음 | 많음 |
| 조회 Row 수 | JOIN 구조에 따라 폭증 가능 | 필요한 Row만 조회 가능 |
| Cartesian Product | 발생 가능성 큼 | 회피 가능 |
| 중복 데이터 전송 | 많아질 수 있음 | 적음 |
| DB JOIN 비용 | 커질 수 있음 | 각 쿼리 단순 |
| 애플리케이션 조립 비용 | 낮거나 ORM이 처리 | 애플리케이션에서 조립 필요 |
| ORM 난이도 | 단순할 수도 있지만 Fetch Join 제약 존재 | 구현 코드 증가 |
| Paging | 컬렉션 JOIN 시 어려움 | 상대적으로 안정적 |
| 일관성 | 한 SELECT 시점 기준으로 자연스럽게 일관됨 | 쿼리 사이 데이터 변경 가능 |
| 작은 데이터 | 유리한 경우 많음 | 오버킬 가능 |
| 큰 1:N 관계 | 위험할 수 있음 | 유리할 가능성 큼 |
그 중에서도 DB의 왕복 Latency와 조회 Row 수가 성능에 가장 영향을 끼칠 것이라고 생각한다.
애플리케이션과 DB 사이에 네트워크 Latency가 높은 환경이라고 가정하자.
그렇다면 다중 쿼리로 설계했다면 DB 왕복의 횟수 증가가 API 성능에 큰 영향을 끼칠 것이다.
내가 테스트한 환경은 동일 네트워크 상에서 진행했기에 실제 배포 환경과는 차이점이 있을 것이다.
3차 테스트 진행 – Application과 DB 사이 Latency 증가
가설: 로컬에서 테스트는 Application과 DB 사이에 Latency가 0에 가깝다. 만약 해당 요소 사이에 Latency가 증가할 수록 v1의 성능이 더 좋아지는 역전 현상이 발생할 것이다.
Toxiproxy를 사용하여 App과 DB 사이에 TCP Proxy 삽입, 의도적으로 Latency 증가
Latency 증가량에 따라 성능차이 비교 진행
실험 결과는 다음과 같았다.
https://github.com/yourjinKR/spring-query-test/pull/3
- 현재 환경에서는 추가 RTT 0ms일 때 v2(다중 쿼리)가 대체로 우세했다.
- 추가 RTT 1ms에서는 결과가 혼재했으며, 2ms 부근부터 p50과 처리량 기준으로 v1(단일 JOIN)이 우세해지는 경향을 보였다.
- 다만 커넥션 풀 크기 10·추가 RTT 2ms에서는 v2의 p95가 더 낮아, 성능 지표에 따라 우열이 달라졌다.
- 커넥션 풀 크기 2·추가 RTT 10ms에서는 연결 대기가 증가하면서 두 방식 모두 커넥션 획득 타임아웃이 발생했다.
- 따라서 2ms는 이번 실험에서 관측한 역전 구간이며, 정확한 임계값이나 실제 배포 환경에 공통으로 적용할 기준으로 일반화하기는 어려웠다.
생각보다 조금의 Latency가 발생하더라도 성능이 역전될 수 있음을 알게됐다.
4차 테스트 진행 – 반환 Row 크기에 따른 비교
가설: Application과 DB 간 왕복 비용이 충분히 짧을 때, 다중 To-Many JOIN으로 발생하는 Cartesian Product가 커질수록 단일 JOIN의 처리 비용이 증가하고, 행 수를 최적화한 다중 쿼리가 상대적으로 유리해질 것이다
https://chatgpt.com/c/6ae22bf4-0a18-83ee-b9be-e3f0677804a8
결국 Row의 크기가 커질수록 직렬화/역직렬화 작업이 무거워질 뿐만 아니라 GC가 더 많은 객체들의 메모리를 관리하게 된다.
5차 테스트 – 종합 테스트 (필요시)
어떻게 해야할까?
1:N 관계가 여럿 있다면 무조건 다중 쿼리로 분할하는게 맞을까?
아니면 문제 시점이 명확하게 발견됐을때 개선하는게 좋을까?
우선 간단한 방식으로 먼저 구현?
한번의 쿼리로 모든 데이터를 가져온다면 특정 시점에서의 정확한 데이터를 가져오지만 쿼리 호출 사이에 데이터 변경이 있었다면 데이터들의 일관성에 문제가 있을 것이다.
또한 프로그래밍 측면에서는 복잡한 데이터를 한번에 가져올 수 있을 것이다.
List<Order> orders = repository.findWithItemsAndPayments();그러나 쿼리 분할시 아래와 같이 여러 객체들을 각각 조립해야 하는 애플리케이션 로직이 요구될 것이다.
List<Order> orders = ...
List<OrderItem> items = ...
List<Payment> payments = ...
Map<Long, List<OrderItem>> itemMap = ...
Map<Long, List<Payment>> paymentMap = ...이러한 점을 고려했을 때 가장 단순한 방식으로 API를 만든 이후에 실제 수치를 기반으로 성능 개선을 위한 수정 작업을 하는게 좋을까??
