문제상황
Eatda 서비스의 Datadog 모니터링 대시보드에서 P95 500 ms 이하 SLO 알람이 연속적으로 발생했습니다.
/api/shop, /api/store, /api/cheer API의 응답 시간이 주기적으로 500 ms를 초과했고,
이는 일시적인 트래픽 증가가 아닌 구조적 병목으로 의심되었습니다.

쿼리 로그를 확인해보니 세 API가 공통적으로 EntityGraph와 Pageable을 함께 사용하고 있어
이 조합이 원인일 가능성이 높았습니다.
따라서 데이터 접근 방식과 쿼리 실행 구조를 점검하기로 했습니다.
원인 분석
EntityGraph는 연관된 엔티티를 함께 조회하기 위해 Hibernate가 fetch join을 수행하도록 지시합니다.
문제는 여기에 Pageable이 결합될 때 발생합니다.
Hibernate는 페이지 처리를 위해 count query와 select query를 각각 실행하지만,
EntityGraph가 적용된 상태에서는 루트 엔티티 기준의 limit offset을 안전하게 적용하지 못합니다.
이때 Hibernate는 아래와 같은 경고를 출력합니다.
HHH0008304: firstResult/maxResults specified with collection fetch; applying in memory
이는 DB에서의 페이징이 완전하지 않아 결과 집합을 애플리케이션 단에서 스트리밍 형태로 보정한다는 의미입니다.
결국 DB는 fetch join으로 인한 row 폭증과 정렬 부담을 지게 되고,
그 결과 쿼리 실행 시간이 길어지며 SLO 기준(500 ms 이하) 을 주기적으로 초과하게 됨을 알 수 있었습니다.
해결 방법 검토
원인을 파악한 뒤, 가장 먼저 떠오른 방법은 두 가지였습니다.
- 쿼리 분리(Query Split)
- 배치 페치(Batch Fetch)
두 접근법 모두 EntityGraph를 유지한 채 페이징을 정상적으로 처리하기 위한 시도이지만, Trade-off가 뚜렷했습니다.
1. 쿼리 분리 (Query Split)
가장 직관적인 방법은 조회 쿼리를 두 단계로 나누는 것입니다.
1. 첫 번째 쿼리에서는 Pageable을 이용해 ID 목록만 가져옵니다.
2. 두 번째 쿼리에서 그 ID 목록을 기준으로 EntityGraph를 적용해 연관 데이터를 함께 조회합니다.
이 방식의 장점은 DB에서 페이징이 정상적으로 동작하고, EntityGraph로 인해 연관 데이터도 한 번에 로드할 수 있다는 점입니다.
하지만 단점도 명확했습니다.
- 쿼리가 두 번 실행되어야 하므로 코드 복잡도가 증가
- 서비스 계층까지 쿼리 로직이 확장되어 계층 간 역할이 모호해짐
- 기존 구조와 결합도가 높아, 리팩터링 범위가 커짐
결국 단순히 쿼리 효율을 높이기 위해 서비스 레이어의 책임이 흔들리는 건
프로젝트의 아키텍처 방향과 맞지 않다고 판단했습니다.
추가적으로 QueryDSL 등 커스텀 쿼리 도구를 활용하면 Repository 계층에서 쿼리 분리가 가능하지만
학습 및 도입 리소스가 적지 않기에 제외했습니다.
2. 배치 페치 (Batch Fetch)
두 번째 선택지는 BatchSize를 이용하는 방식이었습니다.
EntityGraph를 제거하고, Lazy 로딩이 발생하더라도 한 번의 배치로 여러 연관 데이터를 불러오게 하는 전략입니다.
이 방법은 1 + N 쿼리 자체를 완전히 없애는 것이 아니라,
N개의 쿼리를 일정 크기로 묶어 한 번에 처리해버리는 것입니다.
즉, 완벽한 해결책이라기보단 메모리 과다 사용을 방지하면서도 SLO를 지키는 방법 이라는 생각이 들었습니다.
장점은 아래와 같습니다.
- 코드 변경 범위가 작음
- Repository, Service 레이어 로직에 영향이 없음
- 성능이 일정 수준 안정적으로 개선됨
두 방식을 비교했을 때,
현재 Eatda의 데이터 규모와 개발 리소스 등을 고려하면
배치 페치 방식이 가장 현실적인 대안이라고 판단했습니다.
이후 프로덕션 환경의 데이터 상태를 기준으로 최적 배치 사이즈를 결정하기 위해
실제 RDS 데이터를 샘플링하여 엔티티별 평균 연관 수를 측정하기로 했습니다.
배치 사이즈 결정 및 테스트 결과
배치 페치를 적용하기로 결정한 후, 가장 먼저 해야 할 일은 최적 배치 크기를 정하는 것이었습니다.
데이터 분석
프로덕션 RDS를 기준으로 실제 데이터를 확인해본 결과, 대부분의 엔티티는 5개 이하의 연관 데이터를 보유하고 있었습니다.
일부 예외 케이스를 고려해도 10개를 넘는 경우는 드물었기에,10과 30 두 가지 크기를 후보로 두고 성능을 비교하기로 했습니다.
- 배치 크기 10 → 현재 데이터 분포에 가장 근접한 값
- 배치 크기 30 → 데이터 증가에 대비한 여유값
테스트 시행
각 배치 크기별로 가장 긴 레이턴시를 가진 /api/shop 엔드포인트를 10회 호출하며 평균 응답 시간을 측정했습니다.
| 구분 | 배치 미적용 | 크기 10 | 크기 30 |
| 쿼리 실행 방식 | N+1 문제 발생 | Batch Fetch 적용 | Batch Fetch 적용 |
| 총 실행 쿼리 수 | 41개 | 5개 | 5개 |
| 평균 레이턴시 | 약 45 ms | 약 20.5 ms | 약 23.5 ms |
테스트 결과
배치 페치를 적용하자 쿼리 수가 41 → 5개(약 87% 감소) 로 줄었고,
평균 응답 시간은 45 ms → 약 21 ms(54% 단축) 되었습니다.
배치 크기 10과 30의 차이는 크지 않았는데, 이는 현재 데이터 규모가 작고 각 엔티티의 연관 수가 제한적이기 때문입니다.
따라서 단기적으로는 10, 장기적으로는 데이터 증가를 감안해 30을 유지하기로 결정했습니다.
결론 및 개선점
결과적으로 EntityGraph + Pageable 조합에서 발생하던 인메모리 페이징 문제를
BatchSize 기반의 배치 페치로 완화하며 다음과 같은 효과를 얻을 수 있었습니다.
- 쿼리 수 41 → 5개, 평균 레이턴시 480 ms → 194 ms (60% 감소)
- P95 응답 시간 480 ms → 322 ms로 개선, 알람 해소
- 서비스 전체의 SLO 안정성 확보 및 개발팀 피로도 완화


향후 개선 방향
현재 배치 페치 방식은 데이터 규모가 작은 MVP 단계에서는 충분하지만,
향후 트래픽 증가나 데이터 확장 시에는 다음과 같은 추가 개선이 필요합니다.
- QueryDSL 기반 커스텀 페이징 쿼리 도입
- EntityGraph를 유지하면서도 DB 레벨에서 페이징을 적용하기 위해
ID 조회 쿼리와 연관 데이터 조회 쿼리를 분리하는 구조
- EntityGraph를 유지하면서도 DB 레벨에서 페이징을 적용하기 위해
- 쿼리 캐시 / 2차 캐시 검토
- 읽기 빈도가 높은 조회 API에 캐시를 일부 적용해, 배치 크기 한계로 인한 쿼리 누적 부하를 완화
이번 개선 과정은 SLO 위반이라는 문제의 신호를 포착하고, 원인을 레이어별로 추적해 내려간 여정이었습니다.
상위 지표에서 시작해 인프라, 애플리케이션, 그리고 ORM 레벨까지 이어진 Top-down 접근은 서비스 전체의 흐름을 다시 이해하는 과정이기도 했습니다.
단편적으로 성능을 얼마나 개선했는가보다,
그 개선이 실제 서비스 경험과 SLO 안정성에 어떤 의미를 갖는지 고민하고 체감할 수 있었던 경험이었습니다.
'YAPP' 카테고리의 다른 글
| Facade 계층을 활용한 트랜잭션 분리와 보상 로직으로 정합성 개선 (0) | 2025.12.16 |
|---|---|
| Eatda 운영 개선기: Datadog Terraform, 알람 분리, 월간 리포트 자동화 (0) | 2025.11.30 |
| Datadog을 활용한 SLI/SLO 모니터링 및 Burn Rate 알람 적용기 (0) | 2025.09.18 |
| 격리된 Prod 복제 환경에서 안전하게 DB 마이그레이션 수행하기 (2) | 2025.08.29 |
| API Latency Postmortem - 2 : 이미지 업로드 아키텍처 개선으로 주요 API 레이턴시 단축 (3) | 2025.08.28 |