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

SQS를 빼고 cron + DB outbox로 간 이야기 — 이 규모엔 큐가 과했다

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

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

SQS를 빼고 cron + DB outbox로 간 이야기 — 이 규모엔 큐가 과했다


1. 시작은 SQS 두 개짜리 설계였다

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

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

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

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


2. 이 규모에 SQS가 적절한가

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

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

그리고 SQS의 비용 구조도 다시 봤습니다.

항목내용
요청 과금100만 건당 $0.40 — 월 수백 건이면 거의 공짜
운영 부담거의 없음 (Serverless)
AWS 종속있음
숨은 비용개발 환경 구축, IAM 권한, 로컬 테스트용 LocalStack, 워커 프로세스 분리, 두 큐의 DLQ 정책, 모니터링

요금 자체는 무시할 만하지만, 이 규모에서 큐를 두 개 쓰자고 들이는 운영 복잡도가 크다는 게 보였습니다. 워커 프로세스를 별도로 띄우고, IAM 정책을 짜고, 로컬에서 LocalStack 셋업하고, 큐별 DLQ 임계치를 정해야 합니다. 월 수백 건 이벤트를 위해서요.

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


3. DB-driven outbox 패턴

대안은 outbox 패턴이었습니다. 큐 없이 DB 한 테이블만으로 같은 일을 하려면 어떻게 해야 할까요?

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

웹훅 핸들러는 DB INSERT만 하고 즉시 200을 돌려줍니다. 나머지는 별도 cron이 알아서 처리합니다. 이게 outbox 패턴의 본질입니다. 트랜잭션 한 번에 데이터와 "처리해야 할 일"이 함께 박혀있고, 비동기 워커가 폴링으로 처리합니다.


4. 상태 머신을 nullable timestamp로 인코딩

이 패턴에서 가장 마음에 드는 트릭이 하나 있습니다. 상태를 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])
}

이게 왜 좋냐면:

  1. 상태가 자명함enrichedAt IS NULL = "보강 대기", enrichedAt IS NOT NULL AND summarizedAt IS NULL = "요약 대기".
  2. 언제 처리됐는지 자동 기록 — enum이라면 별도 enrichedAtTimestamp 컬럼을 둬야 합니다.
  3. cron 쿼리가 단순WHERE summarizedAt IS NULL 한 줄.
  4. 재처리가 쉬움 — 다시 돌리고 싶으면 UPDATE ... SET summarizedAt = NULL.
ts
// summarizePending()의 핵심 쿼리
const targets = await this.prisma.pullRequest.findMany({
  where: { summarizedAt: null },
  take: 50,
})

5. 핫패스 불변식 — 웹훅 핸들러는 외부 호출 금지

이 구조를 유지하려면 웹훅 핸들러는 절대 외부 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, summarizedAtupsert update 절에서 절대 건드리지 않습니다. 한 번 처리된 PR이 다시 들어와도(라벨 변경 같은 이유로 같은 번호의 webhook이 또 옵니다) 재처리되지 않도록 보호합니다. 멱등성의 핵심입니다.


6. ProcessorScheduler — 1분마다 깨어나는 워커

@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에서 다시 시도됨
    }
  }
}

장점이 명확합니다.

  • 재시도 자동 — 실패하면 summarizedAt = null이 그대로니까 다음 분에 다시 처리됩니다.
  • DLQ 불필요 — N번 실패한 row는 별도 컬럼(failedAt, failedReason)을 추가해서 거르면 됩니다.
  • 로컬 개발 단순 — Postgres 하나만 띄우면 끝. LocalStack 필요 없음.
  • 관측 단순SELECT count(*) WHERE summarizedAt IS NULL 한 줄로 백로그 크기 확인.

7. enrichment / summary는 decouple

처음엔 "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, 시끄럽지 않게
  // ...
}

이렇게 하면 토큰이 없어도 모듈 태깅·내러티브 발행은 정상 동작합니다. 단계 사이의 의존성을 줄이는 것은 분산 시스템 설계의 기본기인데, 처음엔 큐 두 개로 단단히 묶을 생각만 했었습니다.


8. 그럼 SQS는 언제 필요한가?

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

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

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


9. 마치며

이 결정에서 얻은 룰을 정리합니다.

  1. 트래픽을 먼저 보고 인프라를 정하라. 월 수백 건과 초당 수백 건은 같은 도구를 쓰면 안 됩니다.
  2. DB가 이미 진실의 원천이라면 큐는 DB의 그림자다. outbox 패턴은 작은 시스템에서 큐가 줄 효용 대부분을 무료로 줍니다.
  3. 상태 머신은 nullable 타임스탬프로 인코딩하면 자명해진다. enum + 별도 timestamp 두 컬럼보다 단순합니다.
  4. 핫패스에 외부 호출 금지. 웹훅·인터셉터·미들웨어가 SLA를 깨뜨리지 않게 외부 API는 비동기 워커로 분리하세요.
  5. 단계 간 의존성은 가능한 한 끊어라. A가 끝나야 B가 가능하다는 결합은 한 단계가 막히면 전체가 막힙니다.

YAGNI(You Aren't Gonna Need It)가 가장 잘 통하는 영역이 인프라입니다. 미래의 확장을 위해 지금 큐 두 개를 쓰는 건, 미래는 안 오고 지금이 무거워지는 결과를 만들기 쉽습니다. 필요해질 때 옮겨도 늦지 않습니다.

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

황호민

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