본문으로 건너뛰기
글 목록으로 돌아가기
이 글 목차
~/posts/backend

Sentry N+1 쿼리 감지

Sentry가 런타임에서 N+1 쿼리를 감지하는 방식, 병렬 실행으로 인한 오탐 사례, 그리고 해결 패턴.

Sentry가 N+1을 감지하는 방식

Sentry는 코드 구조를 분석하지 않아요. 트랜잭션 trace에서 런타임 span 패턴을 관찰해요. 단일 트랜잭션 안에서 반복되는 유사한 데이터베이스 작업이 보이면 플래그를 걸어요.

Sentry가 보는 것:
  handler.nestjs [250ms]
    ├─ db: pg.connect [4ms]
    ├─ db: pg.connect [4ms]
    ├─ db: pg.connect [4ms]
    ├─ db: pg.connect [63ms]
    ├─ db: pg.connect [7ms]
    └─ db: pg.connect [2ms]

Sentry 결론: 유사한 span 6개 반복 → N+1 Query

Sentry 문서는 이 detector를 “a set of sequential, non-overlapping database spans with similar descriptions”를 찾는 것으로 설명하고, 네 가지 기준을 덧붙여요. 관련 span의 총 duration이 50ms를 넘을 것, span 개수가 임계값(보통 5개)을 초과할 것, 각 span의 description이 잘리지 않은 전체 query일 것, 그리고 반복 그룹 앞에 database span이 최소 하나 있을 것(fingerprinting에 쓰이는 “source” span)이에요.

이 기준을 위 trace에 대보면 안 맞는 데가 있어요. 그 span 6개는 sequential이 아니라 동시에 떴고, 앞에 오는 source span도 없어요. 문서 기준대로면 플래그가 걸리지 않았어야 하는데 걸린 거예요. 저도 이 간극은 설명하지 못해요. Sentry가 이 휴리스틱에 대해 공개한 건 저 기준 페이지가 전부이고, detector 임계값은 project별로 조정할 수 있어서 실제로 돌아간 값이 문서의 기본값이 아니었을 수도 있어요. 문서 기준을 계약으로 보고, 아래 내용은 그에 대한 관측 하나로 읽어 주세요.

진짜 N+1 vs 오탐

구분진짜 N+1오탐(병렬 Fan-Out)
쿼리 수무한정(데이터에 비례)고정(코드 구조로 결정)
패턴루프 → 항목별 쿼리Promise.all() → 동시 배치
해결배치 쿼리 / eager load순차 실행 또는 무시
심각도높음(데이터와 함께 증가)낮음(데이터 무관하게 일정)

오탐 패턴: 중첩된 Promise.all()

알림을 발생시킨 코드의 형태예요. fetchPeriodStats는 내부에서 독립적인 쿼리 3개를 병렬로 실행해요.

// 외부 병렬: 2개 호출
const [current, previous] = await Promise.all([
  fetchPeriodStats(currentPeriod), // 내부 병렬: 각 3개 쿼리
  fetchPeriodStats(previousPeriod), // 내부 병렬: 각 3개 쿼리
]);
// 결과: 2 × 3 = 6개의 동시 pg.connect → Sentry가 N+1로 플래그

쿼리 수는 데이터 크기와 무관하게 6개로 고정돼요. 의도적인 병렬화이지, 항목별 루프 패턴이 아니에요.

배치를 나누면 해결되는 이유

두 기간을 병렬 대신 순차로 실행하면 반복 span 6개짜리 그룹 하나가 3개짜리 그룹 두 개로 나뉘어요. 3개는 문서에 나온 개수 임계값 아래라서 alert이 멈춘 이유로는 이게 가장 그럴듯해요. 다만 위에서 짚은 것처럼 문서 기준은 원래의 alert도 설명하지 못하니까, 증명된 메커니즘이 아니라 일어난 일로 읽어 주세요.

변경 전 (6개 동시 — N+1 트리거):
|████|████|████|████|████|████|  ← 6개의 겹치는 pg.connect

변경 후 (3 + 3 순차 — 임계값 이하):
|████|████|████|          |████|████|████|
 현재 기간                이전 기간

3개씩 두 배치는 N+1로 분류되지 않았고, 알림도 멈췄어요. 대가는 레이턴시예요. 두 번째 배치가 첫 번째와 겹칠 수 없으니까요. 제가 본 엔드포인트에서는 대략 50-80ms였는데, 이건 그 쿼리들이 우연히 그만큼 걸린 것뿐이라 각자 측정해 보는 게 맞아요. 대신 동시 커넥션 압력은 절반으로 줄어요.

핵심 포인트

  • Sentry N+1 감지는 span 기반이지, 코드 기반이 아니에요
  • Promise.all()에서 동일한 작업 타입을 쓰면 휴리스틱이 트리거돼요
  • 고정 횟수의 병렬 쿼리는 오탐이에요(데이터 의존성 없이 고정)
  • 외부 호출을 순차화하면 레이턴시를 대가로 커넥션 풀 압력을 절반으로 줄여요(제 경우 대략 50-80ms였지만, 수치는 쿼리마다 달라요)
  • Sentry가 아니더라도, fan-out을 줄이는 건 좋은 습관이에요(커넥션 풀은 한정된 공유 리소스)

순차화 vs 무시 판단 기준

아래는 제가 정리한 경험칙이지, 측정으로 얻은 임계값이 아니에요. 특히 풀 비율은 얼마나 여유를 두면 안심되는지에 대한 판단에 가까워요.

시나리오조치
cache 비활성, 모든 요청이 DB 직접 조회순차화(실제 풀 압력 발생)
cache 활성, DB 조회 드문 경우무시(오탐, cache가 흡수)
풀이 fan-out 계수의 4배 이상무시(여유 충분)
풀이 fan-out 계수의 2배 미만순차화(고갈 위험)

댓글

글 목록으로 돌아가기
enko