Outbox 패턴2026년 4월 30일6분 읽기

큐를 써야 할 때와 아닐 때

월 수백 건짜리 PR 처리 파이프라인에 두 개의 큐를 두려다, DB 한 테이블 + nullable 타임스탬프 + cron 폴링으로 대체한 결정 과정입니다.

#Outbox 패턴#SQS#cron#NestJS#아키텍처#YAGNI

큐를 써야 할 때와 아닐 때

첫 설계는 원대했다: SQS 두 개짜리 인프라

최근에 사이드 프로젝트로 만들고 있는 게 하나 있습니다. GitHub PR 웹훅을 받아서 머지된 PR을 모아두고, 모듈별로 묶어서 Notion에 온보딩용 내러티브를 자동 발행하는 도구입니다. 신규 개발자가 "이 코드베이스 그동안 뭐가 있었는지" 한 번에 훑을 수 있게 해주는 게 목표였습니다.

처음 그린 아키텍처는 이랬습니다.

text
GitHub Webhook ─▶ Webhook 핸들러 ─▶ SQS(pr-ingest)
                                        ▼
                                 Ingest 워커 (GitHub API 보강)
                                        ▼
                                 SQS(pr-summarize)
                                        ▼
                                 Summarize 워커 (모듈 태깅)
                                        ▼
                                 Notion 발행

깔끔해 보였습니다. 단계별로 큐가 있어서 retry·backoff·DLQ 다 무료로 따라옵니다. 그런데 며칠 곱씹어보다가 다 빼버렸습니다.

냉정하게 다시 세어본 트래픽과 비용

먼저 트래픽을 다시 봤습니다.

  • PR 머지 이벤트: 월 수백 건
  • 분당 평균: 0.01건도 안 됨
  • 피크: 릴리즈 직후 한 번에 10~20건 몰리는 정도

그리고 SQS의 비용을 다시 따져봤습니다. 요금 자체야 100만 건당 $0.40이니 월 수백 건이면 사실상 공짜가 맞습니다. 무서운 건 요금이 아니라 숨은 인프라 비용이었습니다. 워커 프로세스를 따로 띄우고, IAM 권한 체인을 짜고, 로컬에서 돌려보겠다고 LocalStack에 도커를 올리고, 큐마다 DLQ 임계치를 정하고, 그걸 또 모니터링해야 합니다. 고작 한 달에 수백 번 도는 웹훅을 위해서요. 요금은 무시할 만한데, 그 작은 트래픽에 들이는 운영 복잡도가 영 안 맞았습니다.

여기에 한 가지 더. SQS는 메시지가 소비되면 사라집니다. 그런데 이 시스템은 본질적으로 "PR 데이터를 영구 보관하고 여러 단계에서 가공"하는 워크로드입니다. 어차피 DB에 저장은 해야 합니다. 그렇다면 DB가 이미 진실의 원천(source of truth)이고, 큐는 DB의 그림자일 뿐입니다.

그래서 DB 한 테이블로 돌아왔습니다

결국 큐를 다 걷어내고 outbox 패턴으로 갔습니다. 큐가 해주던 일을 DB 테이블 하나에 떠안기는 구조입니다.

text
GitHub Webhook ─▶ Webhook 핸들러 ─▶ DB INSERT (pull_request)
                                            │
                                            │ (cron이 1분마다 폴링)
                                            ▼
                                  enrichPending() — GitHub API 보강
                                            │
                                            ▼
                                  summarizePending() — 모듈 태깅
                                            │
                                            ▼
                                  Notion 발행

웹훅 핸들러는 DB에 INSERT만 치고 즉시 200을 돌려줍니다. 거창한 메시지 브로커를 걷어내고 Postgres 테이블 하나에 데이터와 "앞으로 처리해야 할 일"을 한꺼번에 박아두는 셈입니다. 나머지는 1분마다 깨어나는 cron이 폴링으로 묵묵히 훑고 지나갑니다.

상태를 nullable 타임스탬프에 숨기기

이 패턴에서 가장 마음에 드는 트릭이 하나 있습니다. 상태를 enum 대신 nullable 타임스탬프로 인코딩하는 겁니다.

ts
// prisma/schema.prisma
model PullRequest {
  id            BigInt    @id @default(autoincrement())
  repo          String
  prNumber      Int
  title         String
  body          String?
  rawPayload    Json
  mergedAt      DateTime
  enrichedAt    DateTime?  // ← null = 아직 보강 안 됨
  summarizedAt  DateTime?  // ← null = 아직 요약 안 됨
  // ...
  @@unique([repo, prNumber])
}

이 방식은 생각보다 많은 걸 공짜로 해결해 줍니다. 별도의 상태 이력 테이블을 파지 않아도 enrichedAt IS NULL이면 보강 대기, 보강은 됐는데 summarizedAt IS NULL이면 요약 대기라는 게 그 자체로 자명합니다. 처리가 끝나는 순간 "언제 처리됐는지" 타임스탬프가 따라 남으니 enum에 별도 timestamp 컬럼을 덧댈 일도 없고요. cron 쿼리는 WHERE summarizedAt IS NULL 한 줄이면 되고, 다시 돌리고 싶을 땐 그 칼럼을 NULL로 되돌려놓기만 하면 됩니다.

ts
// summarizePending()의 핵심 쿼리
const targets = await this.prisma.pullRequest.findMany({
  where: { summarizedAt: null },
  take: 50,
})

웹훅 핸들러는 외부 API를 부르지 않습니다

이 구조를 유지하려면 웹훅 핸들러는 절대 외부 API를 호출하면 안 됩니다. GitHub REST, Claude, Notion, Slack — 다 금지입니다. 이유는 두 가지입니다.

1. GitHub 웹훅은 10초 안에 응답해야 합니다. 안 그러면 GitHub이 재전송합니다. 외부 API 호출 한두 개만 끼어도 이 SLA는 쉽게 넘깁니다.

2. 핫패스에 외부 의존성이 들어가면 모든 외부 장애가 웹훅 누락으로 이어집니다. Notion이 잠깐 느려졌다고 PR 데이터를 못 받는 건 말이 안 됩니다.

그래서 웹훅 핸들러는 정말 단순합니다.

ts
// webhook.service.ts
async ingestMergedPullRequest(payload: GitHubPullRequestPayload) {
  const pr = payload.pull_request
  if (payload.action !== "closed" || !pr.merged || pr.draft) return
 
  // payload-carried 필드만 사용 — GitHub API 호출 없음
  await this.prisma.pullRequest.upsert({
    where: { repo_prNumber: { repo: payload.repository.full_name, prNumber: pr.number } },
    create: {
      repo: payload.repository.full_name,
      prNumber: pr.number,
      title: pr.title,
      body: pr.body ?? "",
      rawPayload: payload,
      mergedAt: pr.merged_at,
    },
    update: {
      title: pr.title,
      body: pr.body ?? "",
      rawPayload: payload,
      // enrichedAt / summarizedAt 절대 미터치
    },
  })
}

특히 enrichedAt, summarizedAt은 upsert의 update 절에서 절대 건드리지 않습니다. 라벨 변경 같은 이유로 같은 번호의 webhook이 다시 와도, 이미 처리된 PR이 멋대로 재처리되지 않게 막아주는 한 줄입니다.

1분마다 깨어나는 cron 워커

@nestjs/schedule로 cron 스케줄러를 등록했습니다.

ts
// processor.scheduler.ts
@Injectable()
export class ProcessorScheduler {
  constructor(private readonly processor: ProcessorService) {}
 
  @Cron(CronExpression.EVERY_MINUTE)
  async tick() {
    await this.processor.enrichPending()
    await this.processor.summarizePending()
  }
}
ts
// processor.service.ts
async summarizePending() {
  const targets = await this.prisma.pullRequest.findMany({
    where: { summarizedAt: null },
    take: 50,
  })
  for (const pr of targets) {
    try {
      await this.summaryService.summarizePr(pr)
      await this.prisma.pullRequest.update({
        where: { id: pr.id },
        data: { summarizedAt: new Date() },
      })
    } catch (err) {
      this.logger.error(`summarize 실패 pr=${pr.id}`, err)
      // 다음 tick에서 다시 시도됨
    }
  }
}

이 단순한 루프가 큐 기능 대부분을 대신합니다. 처리에 실패해도 summarizedAtnull인 채로 남으니 다음 분 tick에서 알아서 다시 집어갑니다. 재시도가 공짜라 DLQ를 따로 만들 일도 없고, 정 N번 넘게 실패하는 row가 신경 쓰이면 failedAt 컬럼 하나 더 두고 거르면 그만입니다. 로컬에선 Postgres 하나만 띄우면 전부 재현되고, 백로그가 얼마나 쌓였는지도 SELECT count(*) WHERE summarizedAt IS NULL 한 줄이면 보입니다.

enrichment와 summary를 떼어놓다

처음엔 "enrichment 끝나야 summary 가능"으로 모델링했습니다. summarize 쿼리에 WHERE enrichedAt IS NOT NULL AND summarizedAt IS NULL이 들어 있었습니다.

며칠 뒤 이걸 또 손봤습니다. 더미 데이터로 테스트하는데 GITHUB_TOKEN이 없으니 enrichment가 못 돌아가고, 그러니 summarize도 영원히 안 돌아갔습니다. 사실 모듈 태깅(summarize)에 필요한 건 repo인데, 이건 웹훅 페이로드에 이미 있습니다. enrichment(diff·changed_files·연결 이슈)는 부가 정보일 뿐입니다.

그래서 둘을 분리했습니다.

ts
// 변경 전
async summarizePending() {
  const targets = await this.prisma.pullRequest.findMany({
    where: { enrichedAt: { not: null }, summarizedAt: null },
  })
  // ...
}
 
// 변경 후 — enrichment는 best-effort
async summarizePending() {
  const targets = await this.prisma.pullRequest.findMany({
    where: { summarizedAt: null },
  })
  // pr.repo만으로 모듈 태깅 가능
}

추가로 enrichment 자체도 GITHUB_TOKEN이 없으면 조용히 skip하도록 바꿨습니다.

ts
async enrichPending() {
  if (!this.config.githubToken) return  // best-effort, 시끄럽지 않게
  // ...
}

이렇게 하면 토큰이 없어도 모듈 태깅과 내러티브 발행은 정상 동작합니다. 한 단계가 막혀도 다음 단계는 굴러가는 거죠. 처음엔 큐 두 개로 단계를 단단히 엮을 생각만 했는데, 막상 풀어놓고 보니 안 엮는 편이 훨씬 튼튼했습니다.

그럼 큐는 언제 필요한가

이번 결정이 "SQS는 안 좋다"는 의미는 아닙니다. 이 규모와 이 워크로드에서 과했다는 것뿐입니다. 다음 조건들이 들어오면 다시 SQS(또는 Kafka)를 도입할 겁니다.

조건이유
분당 수백 건 이상 처리량DB 폴링은 락 경합과 인덱스 비용 증가
같은 이벤트를 여러 시스템이 fan-out으로 소비DB 한 테이블로는 컨슈머 그룹 관리가 부담
지연시간 SLA가 수 초 이내cron 1분 간격으론 못 맞춤
메시지 자체가 크고 보관할 가치가 없음DB 디스크 부담
별도 팀이 워커를 운영하고 싶음DB 공유보다 큐 인터페이스가 결합도 낮음

특히 지연시간이 가장 큰 분기점입니다. 이번 시스템은 "다음날 아침 출근해서 보면 어제 PR이 Notion에 정리되어 있다"가 충분한 SLA였습니다. 1분 cron으로도 차고 넘칩니다.

마치며

큐 두 개로 시작한 설계를 DB 테이블 하나로 끝냈습니다. 처음 그린 SQS 파이프라인은 언젠가 올지 모를 트래픽을 위한 거였는데, 그 사이 지금만 무거워질 뻔했습니다.

YAGNI(You Aren't Gonna Need It)가 가장 잘 통하는 영역이 인프라라는 걸 이번에 다시 느꼈습니다. 큐가 정말 필요해지는 날이 오면 그때 옮겨도 늦지 않습니다.

반대로 같은 SQS를 기꺼이 넣은 이야기도 따로 있습니다. 거긴 요청과 무거운 처리를 떼어놓을 큐가 꼭 필요한 워크로드였거든요. 또 처음부터 잡 큐 없이 DB 상태 컬럼만으로 워커를 돌린 이야기도 있는데, 거긴 DB가 이미 진실의 원천이라 큐가 군더더기였습니다. 결국 큐를 넣고 빼는 건 도구의 문제가 아니라 워크로드의 문제입니다.

#Outbox 패턴#SQS#cron#NestJS#아키텍처#YAGNI

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js