🔍 왜 Projection이 Fetch Join보다 느려졌는지 궁금하다면
GC, 객체 생성 수, Hibernate 동작까지 포함한 전체 분석은 아래 원본 문서에 정리했습니다.
GitHub 원본 문서 보기


요약

항목 Before After
지속 가능한 최대 RPS ~26 RPS (붕괴) 120 RPS 안정
개선 포인트 Lazy N+1 + 10K payload Fetch Join + DB 레벨 20자 preview
  • 핵심 발견: 병목이 DB(N+1) → GC(객체 생성) → Payload(직렬화) 순으로 이동함
  • 환경: 4C/16GB, PostgreSQL 17 + TimescaleDB, k6 120 RPS × 90s, seed=777 고정

배경

NodeController API의 처리량이 EdgeController 대비 절반 수준(데이터 양은 Node 200만 vs Edge 400만)으로 낮게 나와 원인을 추적했다. 로그 확인 결과 node_note_link 테이블을 Lazy 로딩으로 가져오며 N+1 쿼리가 발생 중이었다.


1차: JPA Fetch 전략 비교 (120 RPS, 웜업 30 RPS 2m)

Lazy / Batch Fetch / Fetch Join 세 가지를 동일 조건(120 RPS, seed=777, 3회)으로 비교했다.

전략 특징 P95 결과
Lazy Loading N+1 쿼리 다수 발생 최악
Batch Fetch (default_batch_fetch_size) N+1 일부 완화 중간
Fetch Join 왕복 쿼리 최소화 최상

추가로 work_mem을 8MB → 128MB로 늘려봤지만 효과 없었다. 해시/정렬이 병목이 아니라 쿼리 왕복 횟수가 문제였고, 디스크 스필도 확인되지 않았다.

Fetch Join으로 확정


2차: JSON Aggregation 시도

UI 요구사항으로 노드 목록에 noteSubject(제목)도 함께 반환해야 했다. 행 폭증 문제(Node 10개 × Link 10개 = JOIN 결과 100행)를 해소하려고 json_agg + GROUP BY를 시도했다.

  • EXPLAIN ANALYZE 결과는 양호, 행 수 감소 확인
  • 그러나 k6 부하 결과: p95 증가

원인: 집계·정렬·JSON 직렬화 CPU 비용이 행 수 절감 효과를 압도했다.

JSON Aggregation 보류

→ EXPLAIN은 좋아 보였지만 실제 성능은 악화

🔍 실제 EXPLAIN vs k6 결과 비교 보기 -> GitHub에서 보기


3차: 스키마 변경 + 조회 전략 비교

스키마 개선

node_note_link 테이블에 note_subject 컬럼을 직접 추가하고, note.subject 변경 시 자동 동기화되도록 DB 트리거를 적용했다. (애플리케이션 코드 최소 변경)

비교 대상 4종

테스트 설명
500자 / 3테이블 / JSON Aggregation DB에서 json_agg로 묶음
500자 / 2테이블 / Projection DTO로 필요한 필드만
20자 / 2테이블 / Projection 프리뷰 버전
500자 / 2테이블 / Fetch Join 기존 방식 개선판

결과

JSON Aggregation ≪ 500자 Projection ≪ 20자 Projection ≪ 500자 Fetch Join

Projection이 Fetch Join보다 느린 이유:

  • 두 방식 모두 DB에서 100행을 읽지만
  • Fetch Join은 Hibernate 1차 캐시 기반으로 부모(Node) 엔티티를 deduplicate — 자식(Link)만 컬렉션에 추가
  • Projection은 DTO 100개를 전부 새로 생성 → 힙 객체 수 약 10배 차이
  • GC Pause: Fetch Join 5 ms vs Projection 6 ms → p95까지 전파

4차: Payload 크기 영향 분석 (content 1만자 vs 20자)

동일한 Fetch Join 기반에서 content 처리 위치만 달리해 비교했다.

케이스 설명
DB 레벨 프리뷰 substring(content, 1, 20) — 20자만 조회·반환
원문 그대로 반환 1만자(≈30KB)를 그대로 응답
APP 레벨 프리뷰 1만자 조회 후 앱에서 20자로 절단

결과 요약

  • DB 레벨 프리뷰: 120 RPS 안정
  • 원문 반환: 26 RPS 구간에서 p95 붕괴 시작 (→ GC STW 누적 → 처리율 급락)
  • APP 레벨 프리뷰: 원문보다 안정적이지만 26 RPS에서 동일 붕괴 발생
    • GC/할당 압력이 주 원인, JSON 직렬화 비용도 tail latency에 유의미한 영향

동일 p95 구간 기준 유지 가능한 RPS 차이: 약 5배


최종 결정

항목 결정
조회 전략 Fetch Join 유지
목록 반환 DB 레벨 substring(content, 1, 20) preview
상세 조회 별도 API — 원문 Lazy Loading

결국 병목은 DB에서 끝나지 않았다.
Fetch Join보다 DTO Projection이 더 느렸고, 병목은 DB → GC → Payload 순으로 이동했다.

📊 GC pause, allocation, 실제 부하 로그까지 포함한 전체 분석 보기
GitHub 원본 문서 보기


핵심 인사이트

성능 병목은 DB에서 끝나지 않는다. N+1을 제거하면 GC가, GC를 줄이면 Payload가 다음 병목으로 드러난다. 각 단계마다 실측 없이 예상으로 결론 내리면 반대 결과가 나온다 (Projection < Fetch Join).