DB

6/26 DB Connection Pool 고갈 장애 분석 및 해결 방안 리포트

Joonfluence 2025. 6. 27.

DB 장애 분석 및 해결 방안 리포트

문제 인지 시기, 26일 9:14분 최초 보고

서버 로그

Knex: Timeout acquiring a connection. The pool is probably full

Commerce Web API 서버에서는 6월 26일 00시부터 27일 00시까지 하루 동안, Knex: Timeout acquiring a connection. The pool is probably full 오류가 발생했습니다. 이로 인해, 위 슬랙에 보고된 것처럼 플랫폼 내 멤버쉽 가입, 취소/교환/반품이 제대로 이뤄지지 않는 문제가 발생했습니다.

발생 원인

해당 글에서는 문제의 근본 원인을 짚어보고, 즉각적인 조치(단기)와 안정적인 시스템을 위한 장기적인 개선 방안을 제시합니다. 또한, Neon DB의 커넥션 풀 모니터링 방법과 mikro-orm fork 관련 레퍼런스 자료도 함께 제공합니다.

1. 문제 원인 심층 분석

이번 장애는 다음 4가지 주요 원인이 복합적으로 작용한 결과입니다.

단일 원인이 아닌, 여러 복합적인 문제들이 누적되어 발생한 DB 커넥션 풀 고갈 현상입니다. 주요 원인은 잘못된 트랜잭션 처리(롤백 부재), 비효율적인 배치 및 루프 구문, 그리고 EntityManager.fork()의 남용으로 추정됩니다. 서버 재배포 시 일시적으로 정상화되었던 이유는, 재배포 과정에서 기존에 쌓여있던 유휴(Idle) 또는 비정상 커넥션들이 모두 초기화되었기 때문입니다. 하지만 근본 원인이 해결되지 않았기에, 약 1시간에 걸쳐 서서히 커넥션이 다시 고갈되는 현상이 반복된 것입니다.

1순위 원인: 루프(Loop) 내에서의 비효율적인 DB 접근 (flush, persistAndFlush)

승준님과 자훈님이 지적한 ruleset 관련 로직과 준엽님이 의심한 MEMBERSHIP_PENDING_RETRY 배치 작업에서 공통적으로 발견되는 안티패턴(Anti-pattern)입니다.

// 문제 코드 예시 (ruleset 로직)
for (const ruleset of rulesets) {
  // ... 로직 ...
  // 루프 안에서 매번 flush 또는 persistAndFlush를 호출
  await this.createRulesetRecordWithEm(em, /* ... */); // 내부적으로 flush 발생
}

private async createRulesetRecordWithEm(
  em: EntityManager,
  ruleset: Ruleset,
  customerId: number,
  confirmedCount: number,
  orderId: string
) {
  const record = new RulesetRecord()
  record.ruleset = ruleset
  record.customerId = customerId
  record.confirmedCount = confirmedCount
  record.orderId = orderId

  // Calculate expiration date based on ruleset durationType
  record.expirationDate = this.calculateExpirationDate(ruleset.durationType)

  em.persist(record)
  await em.flush()
  return record
}

위와 같이 반복문(for, while) 내부에서 flush()나 persistAndFlush()를 개별적으로 호출하는 것은 매우 비효율적입니다. 각 flush 호출은 DB와 통신하며 커넥션을 점유합니다. 만약 ruleset이 100개라면, 짧은 시간 동안 100번의 DB 왕복이 발생하며 커넥션을 점유하고 반납하는 과정이 반복됩니다. 이 과정에서 다른 API 요청들이 커넥션을 할당받지 못하고 대기하는 병목 현상이 발생할 수 있습니다. 특히 해외직구 기능 오픈과 맞물려 ruleset 관련 로직 호출이 급증하면서, 이 비효율성이 시스템 전체의 커넥션 풀을 고갈시키는 데 크게 기여했을 가능성이 높습니다.

em.persist()와 em.flush()의 역할을 명확히 구분해야 합니다. em.persist(entity)의 동작은 매우 가벼운 인메모리(in-memory) 작업입니다. DB와는 전혀 통신하지 않습니다. 단지 UnitOfWork에게 "이 엔티티는 앞으로 관리해줘. 나중에 flush할 때 DB에 저장해야 해"라고 등록만 하는 역할을 합니다. 반면, em.flush()는 persist 등으로 등록된 모든 변경사항을 모아서 실제로 DB에 동기화하는 무거운 작업입니다.

em.flush()가 하는 동작

em.flush()는 매우 무거운(expensive) 작업입니다. 이는 단순히 UPDATE 쿼리 하나를 실행하는 것과는 차원이 다른, 여러 단계의 복잡한 과정을 포함하기 때문입니다. 구체적으로 아래와 같은 동작들을 수행합니다.

  1. 변경 내역 감지 (ChangeSet Computation): flush가 호출되는 순간, MikroORM의 UnitOfWork는 현재 관리하고 있는 모든 엔티티(Entity)의 최초 상태와 현재 상태를 비교하여 변경된 내역을 찾아냅니다. 이 과정은 애플리케이션 서버의 메모리와 CPU를 사용하여 이루어집니다. 엔티티의 수가 많을수록 이 계산 부담은 커집니다.
  2. SQL 쿼리 생성 (SQL Generation): 감지된 변경 내역을 기반으로, 실제 데이터베이스에 실행할 INSERT, UPDATE, DELETE SQL 쿼리들을 생성합니다.
  3. 트랜잭션 시작 (Transaction Begin): 생성된 모든 쿼리를 하나의 원자적(Atomic) 단위로 묶기 위해 데이터베이스 트랜잭션을 시작합니다. (BEGIN 또는 START TRANSACTION)
  4. DB로 쿼리 전송 및 실행 (Database Roundtrip): 생성된 SQL 쿼리들을 네트워크를 통해 데이터베이스 서버로 전송합니다. 데이터베이스는 이 쿼리들을 받아 파싱(Parsing)하고, 실행 계획(Execution Plan)을 수립하며, 데이터에 락(Lock)을 걸고, 실제 데이터를 변경한 후, 변경 이력을 로그(WAL, Write-Ahead Log)에 기록합니다.
  5. 트랜잭션 종료 (Transaction Commit/Rollback): 모든 쿼리가 성공적으로 실행되면 트랜잭션을 커밋(COMMIT)하여 변경 사항을 최종 확정합니다. 만약 중간에 오류가 발생하면 롤백(ROLLBACK)합니다.
  6. 결과 대기 및 반환: 애플리케이션은 이 모든 과정이 끝날 때까지 기다렸다가 DB로부터 성공 또는 실패 결과를 반환받습니다.

2순위 원인: EntityManager.fork()의 오남용과 컨텍스트 관리 부재

기존에는 룰셋, 멤버쉽 등 코드 전반적으로 em.fork() 코드가 하나의 API 호출 당 새롭게 생성될 수 있는 구조로 작성되어 있었습니다. 그게 어떤 점에서 문제일까요? em.fork()는 특정 작업을 위한 격리된 EntityManager 컨텍스트를 생성하는 강력한 기능이지만, 잘못 사용하면 오히려 독이 됩니다.

// 문제 코드 예시 (배치 작업)
while (hasMoreData) {
  const em = this.membershipRepository.getEntityManager().fork(); // 루프 내에서 fork
  const [memberships, total] = await em.findAndCount(/* ... */);
  // ...
}
  • 문제점: 요청(Request) 스코프가 아닌, 로직 중간에 임의로 fork()를 호출하고 명시적으로 관리해주지 않으면 컨텍스트가 꼬이거나 커넥션이 제대로 반납되지 않는 문제를 야기할 수 있습니다. 특히 @EnsureRequestContext() 데코레이터가 보장하는 요청 단위의 명확한 컨텍스트 라이프사이클 관리의 이점을 포기하게 됩니다.
  • 올바른 사용처: fork()는 주로 긴 시간 실행되는 배치(Batch) 작업이나, 하나의 로직 안에서 완전히 독립된 여러 트랜잭션을 동시에 처리해야 하는 매우 특수한 경우에 제한적으로 사용해야 합니다. 일반적인 API 요청 처리는 @EnsureRequestContext()에 맡기는 것이 베스트 프랙티스입니다.
  • 결론: 팀에서 내린 결론처럼, @EnsureRequestContext()를 기본으로 사용하고 fork() 사용 시에는 팀 차원의 엄격한 코드 리뷰와 타당성 검토가 필요합니다. 린트(Lint) 규칙으로 경고(Warning)를 띄우는 것은 훌륭한 결정입니다.

em.fork()가 하는 동작

em.fork()가 호출되면, MikroORM은 현재 EntityManager의 완전히 독립된 복사본을 새로 생성합니다. 이 "독립된 복사본"은 다음을 포함합니다.

  1. 새롭고 비어있는 1차 캐시 (Identity Map / Unit of Work) : 이것이 가장 중요합니다. 복제된 EntityManager는 원본이 가지고 있던 엔티티에 대한 기억이 전혀 없습니다.
  2. 독자적인 트랜잭션 컨텍스트 : 원본과 별개의 트랜잭션을 가질 수 있습니다.
  3. 별도의 DB 커넥션 점유 : DB 작업이 필요할 때, 커넥션 풀에서 원본과는 별개의 커넥션을 획득합니다.

이 기능의 본래 목적은 HTTP 요청의 범위를 벗어나는 백그라운드 작업, 메시지 큐 처리, 스케줄링된 작업 등에서 각각의 태스크가 다른 태스크에 영향을 주지 않는 격리된 환경에서 안전하게 DB 작업을 수행하기 위함입니다.

em.fork() 남용 시 발생하는 문제점들

  1. 성능 저하 및 리소스 낭비

이것이 장애 상황에서 겪었던 가장 직접적인 문제입니다.

  • 커넥션 풀(Connection Pool)의 조기 고갈
    • 각각의 fork된 EntityManager는 DB 작업 시 자신만의 커넥션을 필요로 합니다. 만약 동시에 50개의 API 요청이 들어왔는데, 각각의 요청을 처리하는 로직 안에서 em.fork()를 한 번씩 호출한다고 가정해 보겠습니다.
    • 원본 EntityManager 50개 + fork된 EntityManager 50개 = 최대 100개의 DB 커넥션이 동시에 필요하게 될 수 있습니다.
    • 만약 커넥션 풀의 최대 사이즈가 50개라면, 나머지 50개의 커넥션 요청은 대기 상태에 빠지다가 결국 Timeout acquiring a connection 에러를 발생시킵니다.
    • 루프 안에서 fork를 호출하는 것은 상황을 훨씬 더 악화시킵니다. 100번 반복하는 루프 안에서 fork를 한다면 이론상 100개의 커넥션을 추가로 점유하려 시도할 수 있습니다.
  • 메모리 및 CPU 오버헤드
    • EntityManager는 내부에 1차 캐시(Identity Map)와 각종 메타데이터를 관리하는, 가볍지 않은 객체입니다. fork()를 호출할 때마다 이러한 객체들이 계속해서 새로 생성됩니다.
    • 메모리 사용량 증가: 수천, 수만 번 fork()가 호출되면 애플리케이션의 메모리 사용량이 급증하고, 이는 가비지 컬렉션(GC)에 부담을 주어 전체적인 애플리케이션 성능 저하로 이어집니다.
    • CPU 사용량 증가: 객체를 생성하고 나중에 해제하는 과정 모두 CPU 자원을 소모합니다.
  1. 데이터 정합성 문제 및 혼란 (더 위험한 문제)

성능 문제보다 더 교묘하고 위험한 문제입니다. fork()는 1차 캐시를 복사하지 않고 새로 만들기 때문에 발생합니다.

  • 동일한 데이터, 다른 객체 인스턴스
    • ORM의 중요한 원칙 중 하나는 "하나의 영속성 컨텍스트 내에서는 같은 DB 로우(Row)는 항상 같은 객체 인스턴스임을 보장한다" 는 것입니다. fork()는 이 원칙을 깨뜨립니다.
      [시나리오]
    1. 원본 em이 ID가 1인 사용자를 조회합니다: const userA = await em.findOne(User, 1);
    2. 이제 em의 1차 캐시에는 ID 1번 사용자가 userA 객체로 캐싱됩니다.
    3. const forkedEm = em.fork(); 로 새로운 EntityManager를 복제합니다.
    4. 복제된 forkedEm으로 ID가 1인 사용자를 다시 조회합니다: const userB = await forkedEm.findOne(User, 1);
    5. 이때 userA와 userB는 DB의 같은 로우를 가리키지만, 메모리 상에서는 완전히 다른 두 개의 객체입니다. (userA !== userB)
      [데이터 유실 (Lost Update) 위험] : 위 시나리오가 계속되면 끔찍한 일이 발생할 수 있습니다.
    6. 원본 로직에서 사용자 이름을 변경합니다: userA.name = '김철수';
    7. 복제된 로직에서 사용자 이메일을 변경합니다: userB.email = 'chulsoo@example.com';
    8. await forkedEm.flush(); 를 먼저 호출합니다. -> DB의 이메일이 변경됩니다.
    9. await em.flush(); 를 나중에 호출합니다. -> DB의 이름이 변경됩니다.
      이 경우는 운이 좋은 케이스입니다. 만약 두 로직이 같은 필드를 수정했다면 어떻게 될까요?
    10. 원본 로직: userA.name = '김영희';
    11. 복제된 로직: userB.name = '김철수';
    12. 어떤 flush가 나중에 호출되느냐에 따라 다른 flush의 변경사항을 아무런 경고 없이 덮어쓰게 됩니다. 이를 업데이트 유실(Lost Update) 이라고 하며, 데이터 정합성에 심각한 문제를 야기합니다.

올바른 대안: @EnsureRequestContext()

회의록에서 언급된 @EnsureRequestContext()는 이러한 문제들을 해결하기 위한 MikroORM의 공식적이고 우아한 해결책입니다.

  • 동작 방식: 이 데코레이터가 붙은 메서드가 호출될 때, MikroORM은 현재 활성화된 EntityManager 컨텍스트가 있는지 확인합니다.
  • 컨텍스트가 없으면?: 자동으로 em.fork()를 호출하여 새로운 컨텍스트를 만들어 해당 메서드에 제공합니다.
    컨텍스트가 이미 있으면?: 기존 컨텍스트를 그대로 사용합니다.
  • 장점: 개발자가 직접 fork의 생성과 해제 시점을 관리할 필요가 없습니다. 프레임워크가 메서드의 시작과 끝이라는 명확한 범위(Scope)에 맞춰 컨텍스트의 생명주기를 완벽하게 관리해 줍니다. 이는 실수를 방지하고 코드를 훨씬 깔끔하고 예측 가능하게 만듭니다.

3순위 원인: 트랜잭션 롤백(Rollback) 부재로 인한 커넥션 누수

// 발견된 문제 코드 (추정)
try {
  await em.begin(); // 트랜잭션 시작
  // ... 비즈니스 로직 ...
  await em.commit(); // 성공 시 커밋
} catch (error) {
  // 에러 발생 시 rollback 처리가 없음!
  // 이 경우, 사용된 커넥션은 풀에 반환되지 않고 '유휴 트랜잭션(idle in transaction)' 상태로 남게 됨
}

위와 같이 try-catch 구문에서 em.begin()으로 트랜잭션을 시작한 후, catch 블록에서 em.rollback()을 명시적으로 호출하지 않으면, 로직 수행 중 예외(Exception)가 발생했을 때 해당 트랜잭션이 사용하던 DB 커넥션이 풀(Pool)에 정상적으로 반환되지 않습니다. 이 커넥션은 PostgreSQL 서버 입장에서 '클라이언트가 작업을 하다 말고 응답이 없는 상태'로 인지되어, idle in transaction 상태로 무기한 대기하게 됩니다.

이러한 커넥션이 하나둘씩 쌓이면, 애플리케이션의 커넥션 풀은 가용 커넥션이 없다고 판단하고 새로운 요청에 커넥션을 할당해주지 못합니다. 이것이 바로 Timeout acquiring a connection 오류의 직접적인 원인입니다. 서버 재배포 후 약 1시간 주기로 장애가 재발한 현상은 이 '커넥션 누수'가 서서히 진행되고 있었음을 방증하는 강력한 증거입니다.

4순위 원인: 동시성 제어 없는 웹훅(Webhook) 처리

제환 님이 개선한 부분으로, 장애의 직접적인 원인은 아니더라도 시스템 부하를 가중시킨 요인입니다. 동일한 주문에 대해 여러 상태 변경 웹훅(created, status-updated 등)이 거의 동시에 들어올 때, 각각의 웹훅이 별도의 DB 커넥션을 점유하고 중복된 조회/수정 로직을 실행하는 것은 불필요한 리소스 낭비입니다. 이는 특히 트래픽이 몰리는 시간에 커넥션 풀에 부담을 가중시킵니다. Redis 기반의 FIFO 큐(Queue)를 도입하여 주문별로 웹훅을 순차 처리하고, DB 접근 횟수를 1/6로 줄인 것은 매우 효과적인 최적화입니다.

2. 해결 방안 (단기 및 장기)

가. 단기 해결 방안 (Immediate Actions)

이미 대부분 조치가 이루어졌지만, 명확한 단계별 정리를 통해 재발을 방지합니다.

  • 1단계: 비효율적인 루프 로직 수정 (Hotspot 해결)
    • 조치: 장애의 기폭제가 된 ruleset 로직과 MEMBERSHIP_PENDING_RETRY 배치 로직을 최우선으로 수정합니다. 루프 안에서 개별적으로 flush 하던 코드를, 루프 밖에서 단 한 번의 flush로 처리하도록 변경합니다.
    • 설명:이 변경만으로도 DB 커넥션 점유 시간이 극적으로 줄어들어 시스템 부하가 크게 감소합니다. @최승준_Commerce 님이 PR #531에서 진행한 작업이 이 방향성의 좋은 예시입니다.
    • // AS-IS: 루프 내 flush for (const item of items) { const entity = new Entity(); // ... await em.persistAndFlush(entity); // 문제 지점 } // TO-BE: 루프 밖 단일 flush const entitiesToPersist = []; for (const item of items) { const entity = new Entity(); // ... entitiesToPersist.push(entity); } await em.persistAndFlush(entitiesToPersist); // 훨씬 효율적
  • 2단계: em.fork() 제거 및 @EnsureRequestContext로 전환
    • 조치: API 컨트롤러, 서비스 메서드, 크론잡 등 요청의 진입점이 되는 모든 곳에서 불필요하게 사용된 em.fork()를 제거하고, 메서드 상단에 @EnsureRequestContext() 데코레이터를 붙여주는 작업을 완료합니다.
    • 설명: MikroORM이 제공하는 가장 안전하고 권장되는 방식으로 컨텍스트 관리를 일원화합니다. 이를 통해 개발자의 실수를 줄이고 프레임워크가 라이프사이클을 관리하도록 위임하여 안정성을 높입니다.
  • *3단계: 트랜잭션 롤백 추가 *
    • 조치: 코드 베이스 전체를 감사하여, em.begin() 또는 em.transactional() 등을 수동으로 사용하는 모든 try-catch 구문에 em.rollback()이 catch 블록에 포함되어 있는지 확인하고 누락된 부분을 즉시 추가합니다.
    • 설명: 이는 커넥션 누수의 가장 직접적인 원인을 제거하는 핵심 조치입니다. 예외 발생 시에도 커넥션이 풀에 반드시 반환되도록 보장하여, 시간이 지나면서 풀이 고갈되는 현상을 원천 차단합니다. @이준엽_Commerce 님이 마지막에 수정한 PR #534가 바로 이 조치에 해당합니다.

나. 장기 해결 방안 (Long-term Strategy)

안정적이고 확장 가능한 시스템을 위한 근본적인 체질 개선 방안입니다.

  • 1단계: 코드 레벨의 방어 체계 구축 (정적 분석 및 규칙)
    • 조치:
      1. ESLint 규칙을 추가하여 em.fork() 사용 시 경고(warning)를 발생시키고, PR 템플릿에 fork() 사용 시 그 타당성을 설명하는 항목을 추가합니다.
      2. 트랜잭션을 수동으로 제어하는 em.begin()의 사용을 가급적 지양하고, 대신 em.transactional(async (em) => { ... }) 콜백 함수 스타일 사용을 권장합니다. 이 방식은 에러 발생 시 자동으로 롤백을 처리해주어 개발자의 실수를 방지합니다.
      3. 린트 규칙으로 루프(for, while) 내에서 flush 계열 메서드 호출을 탐지하여 경고하는 커스텀 규칙 개발을 검토합니다.
    • 설명: 개인의 주의력에 의존하기보다, 시스템과 규칙을 통해 잠재적인 문제를 코딩 단계에서부터 원천적으로 차단하는 것이 중요합니다.
  • 2단계: 강화된 Observability (관측 가능성) 확보
    • 조치:
      1. 애플리케이션 레벨 DB 풀 모니터링: 준엽 님이 구현한 것처럼, 애플리케이션에서 직접 knex의 풀 상태(used, free, pending 등)를 주기적으로 로깅하고, Datadog 대시보드에서 시계열 그래프로 시각화합니다.
      2. Sentry 연동 강화: 미들웨어, 배치 작업 등 현재 로깅의 '회색지대'로 남아있는 모든 영역에 Sentry 에러 리포팅을 의무적으로 추가합니다.
      3. 구조화된 로깅(Structured Logging) 정착: pino 로거와 Datadog 연동을 고도화합니다. this.logger.info({ key: value }, 'message') 와 같이 객체를 첫 번째 인자로 넘겨 로그를 구조화하면, Datadog에서 @key:value 형태로 강력한 필터링 및 분석이 가능해집니다.
    • 설명: "추측하지 말고 측정하라(Don't guess, measure!)"는 엔지니어링의 격언처럼, 문제 발생 시 원인을 빠르게 파악하려면 시스템 내부를 투명하게 들여다볼 수 있는 창이 반드시 필요합니다.
  • 3단계: 아키텍처 개선 및 성능 최적화
    • 조치:
      1. 큐(Queue) 시스템 확대 적용: 주문 웹훅 처리에서 성공적으로 적용한 큐 시스템을, 시간이 오래 걸리거나 외부 API 호출이 포함된 다른 비동기 작업들로 확대 적용합니다. (예: 빌링키 생성, 알림 발송 등)
      2. 벌크(Bulk) 연산 적극 활용: 대량의 데이터를 수정하거나 삽입해야 할 때는 em.persist 후 flush 하는 ORM 방식 외에, em.nativeUpdate()나 em.nativeInsert() 같은 네이티브 벌크 연산을 검토하여 DB 부하를 최소화합니다.
      3. ORM 버전업 및 지속적인 학습: MikroORM의 최신 릴리즈 노트를 꾸준히 확인하고, 버그 수정이나 성능 개선 사항이 있다면 주기적으로 버전을 올리는 것을 고려합니다. 또한 팀 내에서 ORM의 동작 원리(특히 Unit of Work, Identity Map)에 대한 스터디를 진행하여 오남용을 방지합니다.
    • 설명: 단기적인 땜질 처방을 넘어, 아키텍처 자체의 회복탄력성(Resilience)을 높이고 성능 병목을 사전에 제거하는 노력이 장기적인 안정성을 보장합니다.

3. ECS + Neon DB 커넥션 풀 조회 방법

회의 내용 중 확인하신 바와 같이, Neon DB는 내부적으로 pgBouncer와 같은 커넥션 풀러를 사용하여 PostgreSQL 서버로의 연결을 관리합니다. 이 때문에 클라이언트(애플리케이션)에서 pg_stat_activity 같은 쿼리로 실제 DB 세션을 직접 조회하는 것이 어렵거나 불가능합니다. Neon 콘솔의 'Active Queries'는 말 그대로 현재 실행 중인 쿼리를 보여줄 뿐, 애플리케이션의 커넥션 풀 상태(대기, 사용 중, 유휴)를 보여주지는 않습니다.

따라서 가장 정확하고 유일한 방법은 애플리케이션 내부에서 직접 풀의 상태를 조회하는 것입니다.

MikroORM은 내부적으로 Knex.js를 쿼리 빌더로 사용하므로, Knex의 풀 객체에 접근하여 상태를 로깅할 수 있습니다.

[코드 예시: 주기적으로 DB 커넥션 풀 상태를 로깅하는 서비스]

import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { MikroORM } from '@mikro-orm/core';
import { PinoLogger } from 'nestjs-pino';

@Injectable()
export class DbMonitoringService {
  constructor(
    private readonly orm: MikroORM,
    private readonly logger: PinoLogger,
  ) {}

  // 1분마다 DB 커넥션 풀 상태를 로깅
  @Cron(CronExpression.EVERY_MINUTE)
  logConnectionPoolStatus() {
    try {
      // MikroORM의 EntityManager를 통해 Knex 인스턴스에 접근
      const knex = this.orm.em.getKnex();

      // Knex 인스턴스에서 pool 객체에 접근
      const pool = knex.client.pool;

      const poolStats = {
        totalCount: pool.getPoolSize(), // 풀의 전체 사이즈
        usedCount: pool.numUsed(),      // 현재 사용 중인 커넥션 수
        freeCount: pool.numFree(),      // 유휴 상태(사용 가능)인 커넥션 수
        pendingAcquires: pool.numPendingAcquires(), // 커넥션을 기다리는 요청 수
        pendingCreates: pool.numPendingCreates(),   // 새로 생성되기를 기다리는 요청 수
      };

      // 구조화된 로깅으로 Datadog에서 분석하기 용이하게 출력
      this.logger.info({ db_pool: poolStats }, 'DB Connection Pool Stats');

    } catch (error) {
      this.logger.error({ error }, 'Failed to get DB connection pool stats');
    }
  }
}

이 DbMonitoringService를 모듈에 등록하면, 1분마다 DB 커넥션 풀의 상세 상태가 JSON 형식으로 로그에 남게 됩니다. 이 로그를 Datadog에서 파싱하여 db_pool.usedCount, db_pool.pendingAcquires 등의 메트릭을 시간대별 그래프로 만들면, 장애 발생 시점의 풀 상태를 명확하게 확인할 수 있습니다.

4. mikro-orm fork() 남용 문제 관련 공신력 있는 레퍼런스

em.fork()의 오남용 문제는 MikroORM을 사용하는 많은 개발자가 겪는 혼란 중 하나입니다. 이에 대한 가장 공신력 있는 자료는 공식 문서GitHub 이슈 트래커입니다.

  1. 공식 문서: Request Context
    • 링크: https://mikro-orm.io/docs/identity-map#request-context
    • 핵심 내용: MikroORM은 각 요청이 격리된 Identity Map을 갖도록 RequestContext라는 헬퍼를 제공합니다. em.fork()는 이 RequestContext를 수동으로 생성하는 저수준(low-level) API입니다. 문서에서는 @EnsureRequestContext() 데코레이터를 사용하는 것이 대부분의 경우에 더 간단하고 안전한 방법이라고 명확히 설명합니다. fork()가 필요한 경우는 CLI 커맨드, 큐 워커, 스케줄된 작업 등 HTTP 요청 컨텍스트 외부에서 로직을 실행할 때라고 명시하고 있습니다. 즉, 일반 API 로직에서는 @EnsureRequestContext()가 정답이라는 것이 공식 입징입니다.
  2. 공식 문서: EntityManager.fork() API 문서
    • 링크: https://mikro-orm.io/api/knex/class/EntityManager#fork
    • 핵심 내용: "Creates a fork of the EntityManager with a clear identity map." (명확한 아이덴티티 맵을 가진 EntityManager의 복사본을 생성합니다.) 라는 설명 자체가 이 메서드가 새로운 작업 단위를 시작하기 위한 것임을 암시합니다. 이는 루프 안에서 매번 호출하라고 만든 기능이 아님을 알 수 있습니다.
  3. GitHub 이슈: "When should I use em.fork()?"
    • 검색 키워드: mikro-orm github issue when to use fork
    • 핵심 내용: GitHub 이슈를 검색해보면 유사한 질문들이 많습니다. 메인테이너(Martin Adámek)의 답변은 일관됩니다.
      • "Use @EnsureRequestContext() for request handlers, subscribers, etc." (요청 핸들러나 이벤트 구독자 등에는 @EnsureRequestContext()를 사용하세요.)
      • "fork() is for manual control in background jobs or scripts." (fork()는 백그라운드 작업이나 스크립트에서 수동 제어가 필요할 때를 위한 것입니다.)
      • 잘못된 fork() 사용이 커넥션 문제를 일으킬 수 있다는 다른 사용자들의 보고서와 해결 과정도 찾아볼 수 있어, 좋은 간접 학습 자료가 됩니다.

결론적으로, fork()는 "하지 말아야 할 것"이 아니라 "정확히 알고 써야 하는 것"입니다. 팀에서 내린 결론처럼, @EnsureRequestContext()를 기본으로 삼고 fork()는 예외적인 경우에만 엄격한 검토를 거쳐 사용하는 것이 이번 장애를 통해 얻은 가장 중요한 교훈일 것입니다.

반응형

댓글