SQS를 빼고 cron + DB outbox로 간 이야기 — 이 규모엔 큐가 과했다
월 수백 건짜리 PR 처리 파이프라인에 두 개의 큐를 두려다, DB 한 테이블 + nullable 타임스탬프 + cron 폴링으로 대체한 결정 과정입니다.
SQS를 빼고 cron + DB outbox로 간 이야기 — 이 규모엔 큐가 과했다
1. 시작은 SQS 두 개짜리 설계였다
최근에 사이드 프로젝트로 만들고 있는 게 하나 있습니다. GitHub PR 웹훅을 받아서 머지된 PR을 모아두고, 모듈별로 묶어서 Notion에 온보딩용 내러티브를 자동 발행하는 도구입니다. 신규 개발자가 "이 코드베이스 그동안 뭐가 있었는지" 한 번에 훑을 수 있게 해주는 게 목표였습니다.
처음 그린 아키텍처는 이랬습니다.
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 한 테이블만으로 같은 일을 하려면 어떻게 해야 할까요?
GitHub Webhook ─▶ Webhook 핸들러 ─▶ DB INSERT (pull_request)
│
│ (cron이 1분마다 폴링)
▼
enrichPending() — GitHub API 보강
│
▼
summarizePending() — 모듈 태깅
│
▼
Notion 발행
웹훅 핸들러는 DB INSERT만 하고 즉시 200을 돌려줍니다. 나머지는 별도 cron이 알아서 처리합니다. 이게 outbox 패턴의 본질입니다. 트랜잭션 한 번에 데이터와 "처리해야 할 일"이 함께 박혀있고, 비동기 워커가 폴링으로 처리합니다.
4. 상태 머신을 nullable timestamp로 인코딩
이 패턴에서 가장 마음에 드는 트릭이 하나 있습니다. 상태를 enum 대신 nullable 타임스탬프로 인코딩하는 겁니다.
// 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= "보강 대기",enrichedAt IS NOT NULL AND summarizedAt IS NULL= "요약 대기". - 언제 처리됐는지 자동 기록 — enum이라면 별도
enrichedAtTimestamp컬럼을 둬야 합니다. - cron 쿼리가 단순 —
WHERE summarizedAt IS NULL한 줄. - 재처리가 쉬움 — 다시 돌리고 싶으면
UPDATE ... SET summarizedAt = NULL.
// 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 데이터를 못 받는 건 말이 안 됩니다.
그래서 웹훅 핸들러는 정말 단순합니다.
// 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 절에서 절대 건드리지 않습니다. 한 번 처리된 PR이 다시 들어와도(라벨 변경 같은 이유로 같은 번호의 webhook이 또 옵니다) 재처리되지 않도록 보호합니다. 멱등성의 핵심입니다.
6. ProcessorScheduler — 1분마다 깨어나는 워커
@nestjs/schedule로 cron 스케줄러를 등록했습니다.
// 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()
}
}// 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·연결 이슈)는 부가 정보일 뿐입니다.
그래서 둘을 분리했습니다.
// 변경 전
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하도록 바꿨습니다.
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. 마치며
이 결정에서 얻은 룰을 정리합니다.
- 트래픽을 먼저 보고 인프라를 정하라. 월 수백 건과 초당 수백 건은 같은 도구를 쓰면 안 됩니다.
- DB가 이미 진실의 원천이라면 큐는 DB의 그림자다. outbox 패턴은 작은 시스템에서 큐가 줄 효용 대부분을 무료로 줍니다.
- 상태 머신은 nullable 타임스탬프로 인코딩하면 자명해진다. enum + 별도 timestamp 두 컬럼보다 단순합니다.
- 핫패스에 외부 호출 금지. 웹훅·인터셉터·미들웨어가 SLA를 깨뜨리지 않게 외부 API는 비동기 워커로 분리하세요.
- 단계 간 의존성은 가능한 한 끊어라. A가 끝나야 B가 가능하다는 결합은 한 단계가 막히면 전체가 막힙니다.
YAGNI(You Aren't Gonna Need It)가 가장 잘 통하는 영역이 인프라입니다. 미래의 확장을 위해 지금 큐 두 개를 쓰는 건, 미래는 안 오고 지금이 무거워지는 결과를 만들기 쉽습니다. 필요해질 때 옮겨도 늦지 않습니다.