홈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

소개JSON Formatter개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

소개|JSON Formatter|개인정보처리방침|이용약관

NestJS 슬로우 쿼리 자동 감지: 스택 트레이스 캡처부터 Slack 알림까지

정기창·2026년 1월 12일

슬로우 쿼리가 발생했을 때 "어떤 쿼리가 느렸는지"는 쉽게 알 수 있습니다. 하지만 정작 중요한 질문은 "그 쿼리를 어디서 호출했는가"입니다. 이 글에서는 Proxy 패턴을 활용해 호출 스택을 자동으로 캡처하고, Slack으로 알림을 보내는 시스템의 설계 아이디어를 정리했습니다.

왜 슬로우 쿼리 알람이 필요한가

슬로우 쿼리 모니터링은 단순히 "느린 쿼리를 찾는 것" 이상의 의미가 있습니다.

첫째, 개발자 경험(DX) 향상입니다. 슬로우 쿼리가 발생했을 때 "어디서 호출했는지" 즉시 파악할 수 있으면, 디버깅 시간이 크게 단축됩니다. 새벽에 알림이 와도 당황하지 않고, 알림 메시지만 보고 바로 원인 파일로 이동할 수 있습니다.

둘째, 인프라 비용 절감입니다. 슬로우 쿼리를 체계적으로 추적하고 최적화하면, 데이터베이스 인스턴스의 스케일다운이 가능해집니다. 불필요하게 높은 사양의 DB를 유지할 필요가 없어지고, 이는 직접적인 클라우드 비용 절감으로 이어집니다.

셋째, 서비스 안정성입니다. 슬로우 쿼리는 커넥션 풀 고갈, 타임아웃, 연쇄 장애의 원인이 됩니다. 문제가 발생하기 전에 선제적으로 감지하고 대응할 수 있는 체계가 필요합니다.

문제 상황: 슬로우 쿼리는 알겠는데, 어디서 호출한 거지?

서비스를 운영하다 보면 슬로우 쿼리 로그를 자주 마주하게 됩니다. MySQL의 slow query log나 APM 도구를 통해 어떤 SQL이 오래 걸렸는지는 파악할 수 있습니다. 문제는 그 다음입니다.

Query execution time: 12.5s
SQL: SELECT * FROM users WHERE status = 'active' AND created_at > '2024-01-01' ...

이 쿼리를 코드 어디서 호출했을까요? 대규모 프로젝트에서 같은 테이블을 조회하는 코드가 수십 군데에 흩어져 있다면, 원인을 찾는 것만으로도 상당한 시간이 소요됩니다.

기존의 쿼리 로깅 방식은 이 문제를 해결하지 못했습니다. 대부분의 ORM이나 쿼리 빌더가 제공하는 이벤트 리스너를 사용하면 SQL과 실행 시간은 알 수 있지만, 호출 위치는 알 수 없었습니다.

// 기존 방식의 한계
쿼리 이벤트 리스너 등록:
  - SQL 문자열 출력 → 가능
  - 실행 시간 측정 → 가능
  - 호출 위치 파악 → 불가능 (콜스택이 이미 사라짐)

해결 방향: 쿼리 호출 시점에 스택 캡처

문제의 핵심은 쿼리가 실행되는 시점과 스택 트레이스를 캡처할 수 있는 시점이 다르다는 것입니다.

쿼리 실행 타임라인:

1. 비즈니스 코드에서 쿼리 호출  ← 스택 캡처 가능한 시점
   ↓
2. 쿼리 빌더 객체 생성
   ↓
3. 체이닝 메서드 호출 (where, orderBy 등)
   ↓
4. 실제 쿼리 실행 (Promise)
   ↓
5. 쿼리 완료 이벤트 발생        ← 이 시점에는 스택이 이미 사라짐

쿼리 완료 이벤트가 발생하는 시점에서 스택을 찍어봐도 내부 라이브러리 코드만 보입니다. 비즈니스 로직의 호출 위치는 이미 콜스택에서 사라진 후입니다.

해결책은 Proxy 패턴입니다. 쿼리 빌더 인스턴스를 Proxy로 감싸서 쿼리 메서드가 호출되는 시점에 스택을 캡처하고, 나중에 쿼리 완료 이벤트에서 이를 연결하는 방식입니다.

전체 아키텍처

시스템은 네 개의 컴포넌트로 구성됩니다.

┌─────────────────────────────────────────────────────────────┐
│                    쿼리 실행 흐름                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  비즈니스 코드                                               │
│       │                                                     │
│       ▼                                                     │
│  ┌─────────────┐    스택 캡처    ┌───────────────────┐      │
│  │ QueryProxy  │ ─────────────▶ │   StackStorage    │      │
│  │  (Proxy)    │                │   (스택 저장소)    │      │
│  └──────┬──────┘                └─────────┬─────────┘      │
│         │                                 │                 │
│         ▼                                 │                 │
│  ┌─────────────┐                          │                 │
│  │ QueryBuilder│                          │                 │
│  │  (쿼리실행)  │                          │                 │
│  └──────┬──────┘                          │                 │
│         │                                 │                 │
│         ▼ 쿼리 완료                        ▼ 스택 조회       │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              SlowQueryDetector                       │   │
│  │  • 실행시간 체크 (임계값 기반)                         │   │
│  │  • 쿨다운 관리 (중복 알림 방지)                        │   │
│  │  • 메신저 알림 전송                                   │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

핵심 구현 1: Proxy로 쿼리 메서드 인터셉트

가장 중요한 부분은 쿼리 빌더를 감싸는 Proxy입니다. 쿼리 빌더의 모든 쿼리 메서드를 가로채서 호출 시점에 스택을 캡처합니다.

QueryProxy 설계:

추적 대상 메서드:
  - select, insert, update, delete
  - from, table, where, join
  - orderBy, groupBy, raw 등

동작 방식:
  1. 원본 쿼리 빌더를 Proxy로 래핑
  2. 메서드 접근 시 가로채기 (get trap)
  3. 추적 대상 메서드인 경우:
     a. 현재 시점의 콜스택 캡처
     b. 원본 메서드 실행
     c. 결과가 쿼리 빌더면 다시 Proxy로 래핑 (체이닝 지원)
  4. 추적 대상이 아니면 원본 그대로 반환

핵심 아이디어는 select(), insert() 같은 메서드가 호출되는 바로 그 순간에 스택을 캡처하는 것입니다. 이 시점에는 비즈니스 코드의 호출 정보가 아직 콜스택에 남아있습니다.

QueryBuilder 체이닝 처리

쿼리 빌더는 보통 체이닝 API를 제공합니다. query.select().from().where().orderBy() 같은 패턴을 지원하려면 결과 객체도 Proxy로 감싸야 합니다.

체이닝 처리 로직:

wrapQueryBuilder(queryBuilder, capturedStack):
  
  Proxy 생성:
    get(target, property):
      
      CASE property == 'then' (실행 시점):
        - 저장해둔 스택을 StackStorage에 저장
        - 원본 then 실행
        
      CASE 체이닝 메서드 (where, orderBy 등):
        - 원본 메서드 실행
        - 결과가 쿼리 빌더면 재귀적으로 래핑
        - 캡처한 스택은 계속 유지
        
      DEFAULT:
        - 원본 값 그대로 반환

중요한 점은 then() 메서드를 인터셉트하는 것입니다. 대부분의 쿼리 빌더는 Promise처럼 동작하는데, 실제로 await를 하거나 .then()을 호출할 때 쿼리가 실행됩니다. 이 시점에 스택을 저장소에 보관합니다.

핵심 구현 2: 스택 트레이스 캡처

런타임의 스택 트레이스를 파싱해서 의미 있는 정보만 추출합니다.

captureCallStack(skipFrames, maxFrames):
  
  1. 현재 스택 트레이스 획득
     - Error 객체 생성하여 stack 속성 접근
     - 또는 런타임 API 활용
  
  2. 스택 프레임 파싱
     FOR each line in stackTrace:
       - 함수명 추출
       - 파일 경로 추출
       - 라인 번호 추출
  
  3. 필터링 (노이즈 제거)
     제외 대상:
       - node_modules 내부 코드
       - 런타임 내부 코드 (node:internal 등)
       - Proxy 관련 래퍼 코드
     
     포함 대상:
       - 프로젝트 소스 코드 (.ts, .js)
       - 비즈니스 로직 파일
  
  4. 결과 반환
     [{
       functionName: "UserService.findActiveUsers",
       fileName: "./user.service.ts",
       lineNumber: 127
     }, ...]

필터링이 중요합니다. 라이브러리 내부 코드나 Proxy 관련 프레임은 제외하고, 실제 비즈니스 로직 파일만 남깁니다. 덕분에 "UserService.findActiveUsers (user.service.ts:127)" 같은 의미 있는 정보를 얻을 수 있습니다.

핵심 구현 3: 스택 저장소

비동기 컨텍스트에서 스택을 유지하기 위한 저장소입니다.

StackStorage 설계:

저장소:
  - stackMap: Map<queryId, StackFrame[]>
  - temporaryStack: StackFrame[] (단일 쿼리용)

setStack(stack, queryId?):
  IF queryId 있음:
    stackMap에 저장
  ELSE:
    temporaryStack에 저장

getAndClearStack(queryId?):
  IF queryId로 저장된 스택 있음:
    스택 조회
    해당 엔트리 삭제  ← 메모리 누수 방지
    스택 반환
  ELSE:
    temporaryStack 복사
    temporaryStack 초기화
    복사본 반환

getAndClearStack 패턴이 핵심입니다. 스택을 가져오면서 동시에 삭제합니다. 이렇게 하면 메모리 누수를 방지할 수 있습니다.

핵심 구현 4: 슬로우 쿼리 감지 및 알림

이제 모든 조각을 연결합니다. 쿼리 완료 이벤트에서 실행 시간을 체크하고, 임계값을 넘으면 알림을 보냅니다.

쿼리 로깅 설정:

setupQueryLogging(queryBuilder, dbName):

  ON 쿼리 시작:
    queryId = 쿼리 고유 식별자
    시작 시간 기록
    StackStorage에서 스택 조회하여 함께 저장
  
  ON 쿼리 완료:
    실행 시간 = 현재 시간 - 시작 시간
    저장된 스택 조회
    
    SlowQueryDetector에 전달:
      - DB명
      - SQL
      - 실행 시간
      - 호출 스택
    
    임시 저장소 정리

SlowQueryDetector: 알림 로직

SlowQueryDetector 설계:

설정값 (환경변수로 관리):
  - THRESHOLD: 슬로우 쿼리 기준 시간 (예: 5000ms)
  - COOLDOWN: 동일 쿼리 재알림 대기 시간 (예: 5분)

recentNotifications: Map<queryHash, timestamp>

detectAndNotify(queryData):

  1. 임계값 체크
     IF 실행시간 < THRESHOLD:
       RETURN (정상 쿼리)
  
  2. 쿨다운 체크 (알림 폭탄 방지)
     queryHash = hashQuery(SQL)
     lastNotified = recentNotifications.get(queryHash)
     
     IF lastNotified 존재 AND (현재시간 - lastNotified) < COOLDOWN:
       RETURN (최근에 이미 알림 보냄)
  
  3. 알림 전송
     formatMessage(queryData)
     sendToMessenger(message)
  
  4. 타임스탬프 기록
     recentNotifications.set(queryHash, 현재시간)

hashQuery(sql):
  정규화된 SQL = sql
    .replace(숫자 → '?')      // WHERE id = 123 → WHERE id = ?
    .replace(문자열 → '?')    // name = 'kim' → name = ?
    .replace(연속공백 → ' ')
  
  RETURN MD5(정규화된 SQL)

쿨다운 로직이 중요합니다. 같은 쿼리 패턴이 반복해서 느리게 실행될 때 알림 폭탄을 맞지 않으려면, 일정 시간 동안 같은 알림을 막아야 합니다. SQL의 구조만 해시하고 실제 값은 무시해서, WHERE id = 1과 WHERE id = 2를 같은 패턴으로 취급합니다.

알림 메시지 포맷

알림이 왔을 때 바로 원인을 파악할 수 있도록 메시지를 구성합니다.

알림 메시지 구조:

formatSlowQueryMessage(queryData):

  헤더:
    🐌 슬로우 쿼리 알림
  
  기본 정보:
    - Database: {dbName}
    - 실행 시간: {executionTime}ms
    - 연결 타입: read/write
  
  호출 스택 (핵심!):
    1. {functionName} ({fileName}:{lineNumber})
    2. {functionName} ({fileName}:{lineNumber})
    ...
  
  SQL 쿼리:
    {formattedSQL}

메시지 길이 제한 처리:
  IF 메시지 길이 > 플랫폼 제한:
    여러 메시지로 분할하여 전송

적용 방법

데이터베이스 연결 설정에서 쿼리 빌더 인스턴스를 래핑하면 됩니다.

적용 구조:

getDbConnection(databaseName):
  
  1. 원본 쿼리 빌더 생성
     readConnection = createConnection(readConfig)
     writeConnection = createConnection(writeConfig)
  
  2. Proxy로 래핑
     readConnection = QueryProxy.wrap(readConnection)
     writeConnection = QueryProxy.wrap(writeConnection)
  
  3. 이벤트 리스너 설정
     setupQueryLogging(readConnection, databaseName, 'read')
     setupQueryLogging(writeConnection, databaseName, 'write')
  
  RETURN { read: readConnection, write: writeConnection }

기존 코드를 수정할 필요 없이, 연결 설정 한 곳만 변경하면 모든 쿼리에 자동으로 적용됩니다.

실전 적용 결과

프로덕션에 적용한 후 알림은 이런 형태로 옵니다.

🐌 슬로우 쿼리 알림
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Database: main_db
실행 시간: 12,543ms
연결 타입: read

호출 스택:
  1. UserService.findActiveUsers (./user.service.ts:127:12)
  2. UserController.getActiveList (./user.controller.ts:45:18)
  3. Router.handle (./router.ts:89:5)

SQL Query:
  SELECT * FROM users 
  WHERE status = 'active' 
  AND created_at > '2024-01-01' 
  ORDER BY created_at DESC
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

알림을 받으면 바로 user.service.ts:127로 가서 원인을 파악할 수 있습니다. 이전에는 "어디서 호출했지?" 하며 grep으로 코드베이스를 뒤지던 시간이 사라졌습니다.

트레이드오프와 고려사항

성능 영향

모든 쿼리마다 스택 트레이스를 생성하고 파싱합니다. 벤치마크 결과 쿼리당 약 0.1~0.3ms의 오버헤드가 추가됩니다. 대부분의 애플리케이션에서는 무시할 수 있는 수준이지만, 초당 수천 건의 쿼리를 처리하는 환경이라면 주의가 필요합니다.

메모리 관리

스택 저장소와 알림 이력 저장소는 메모리를 사용합니다. 주기적으로 오래된 엔트리를 정리하는 로직이 필요합니다.

메모리 정리 로직:

cleanupOldNotifications():
  기준시간 = 현재시간 - (COOLDOWN × 2)
  
  FOR each (hash, timestamp) in recentNotifications:
    IF timestamp < 기준시간:
      recentNotifications.delete(hash)

환경별 설정

로컬, 스테이징, 프로덕션 환경별로 다른 채널을 사용하면 알림 관리가 편해집니다.

환경별 채널 설정:

channels:
  local: 개발팀 테스트 채널
  staging: 개발팀 알림 채널
  production: 운영 알림 채널 (온콜 연동)

마무리: 기술 부채에서 비용 절감으로

슬로우 쿼리 알림 시스템을 구축하면서 가장 어려웠던 부분은 비동기 컨텍스트에서 호출 스택을 유지하는 것이었습니다. Proxy 패턴으로 쿼리 메서드 호출 시점을 정확히 잡아내고, 별도의 저장소로 스택을 전달하는 방식이 결국 해답이었습니다.

이 시스템을 도입한 후 얻은 효과는 다음과 같습니다.

  • 디버깅 시간 단축: 슬로우 쿼리 발생 시 원인 파악까지 걸리는 시간이 수십 분에서 수 초로 줄었습니다.
  • 선제적 최적화: 문제가 장애로 이어지기 전에 감지하고 대응할 수 있게 되었습니다.
  • DB 스케일다운: 체계적인 쿼리 최적화를 통해 데이터베이스 인스턴스 사양을 낮출 수 있었고, 이는 직접적인 인프라 비용 절감으로 이어졌습니다.

슬로우 쿼리 모니터링은 단순히 "느린 쿼리를 찾는 것"이 아닙니다. 개발자 경험을 개선하고, 서비스 안정성을 높이며, 궁극적으로 인프라 비용을 절감하는 투자입니다. 비슷한 문제를 겪고 있다면, Proxy 패턴을 활용한 스택 캡처 방식을 고려해보시기 바랍니다.

NestJSKnex.js슬로우 쿼리모니터링Proxy 패턴Slack

관련 글

k6와 실시간 Pool 모니터링으로 시스템 한계점 찾기

k6로 시스템 한계점을 찾는 Breakpoint 테스트와 NestJS Connection Pool 실시간 모니터링 시스템을 구현한 경험. 최적 RPS를 찾기까지의 과정을 정리했습니다.

관련도 77%

NestJS에서 Drizzle ORM을 선택한 이유: TypeORM, Prisma와의 비교

새로운 SaaS 모듈에 MySQL을 도입하면서 TypeORM, Prisma, Drizzle ORM을 비교했습니다. 각 ORM의 장단점과 Drizzle을 선택한 이유를 실제 코드 예시와 함께 정리했습니다.

관련도 76%

작은 서비스에도 모니터링이 필요한 이유 - NestJS + Prometheus + Grafana Cloud 구축기

작은 서비스도 리소스 제약 때문에 모니터링이 필수입니다. NestJS에 Prometheus와 Grafana Cloud를 연동하여 효율적인 리소스 관리 시스템을 구축하는 방법을 알아보세요.

관련도 75%