728x90

결제·주문·정산 같은 장기 실행 로직을 다루는 팀이면 최근 1~2년 사이에 한 번쯤 "Temporal 도입 검토"라는 문서를 봤을 거다. 그리고 그 문서 옆에 요즘 Restate가 같이 올라온다. 원문 글의 요지는 딱 한 줄로 압축된다. "쓸 코드로 고르지 말고, 운영할 것으로 골라라(pick on what you have to operate, not what you have to write)."

실제로 두 엔진 모두 각 단계를 저널(journal)에 기록하고 크래시 후 리플레이해서 "카드는 긁혔는데 재고는 안 잡힌" 상태를 방지한다. 내구성 자체를 차별점으로 파는 영업은 걸러도 된다. 차이는 저널 주변에 있다. 어디에 저장하고, 무엇이 그걸 돌리고, 내 앱이 어떤 모양으로 쪼개져야 하는가.

Durable Execution이 필요해지는 순간

주문 처리 핸들러를 예로 들자. 카드 승인 → 재고 예약 → 메일 발송 → 배송 상태 갱신. 이 중간에 파드가 OOMKill 되면 메모리에 있던 진행 상태는 전부 날아간다. 그래서 우리는 보통 이런 걸 직접 만든다.

  • 진행 상태를 담는 order_saga 테이블과 상태 enum
  • 멱등키 테이블 + PG사 재조회로 "이미 승인됐나?" 확인
  • 실패 단계만 다시 태우는 재시도 워커, 그리고 지수 백오프 큐
  • 보상 트랜잭션(환불, 재고 해제) 코드와 그 코드의 실패 처리

이게 나쁘다는 게 아니다. 다만 이 코드는 도메인 로직보다 빨리 늘어나고, 장애 리포트의 절반은 여기서 나온다. Durable execution 엔진은 이 "상태 저장 + 재시도 + 이어서 실행"을 프레임워크로 흡수한다. 완료된 스텝은 저널에 남고, 프로세스가 되살아나면 저널을 리플레이해 끝난 단계를 건너뛰고 정확히 멈춘 지점부터 이어간다.

Temporal은 클러스터, Restate는 바이너리 한 개(단, 별표 붙음)

Temporal: 4개 서비스 + 외부 DB + 내 워커

Temporal 서버는 한 프로세스가 아니다. 게이트웨이 역할의 Frontend, 워크플로 상태와 타이머를 소유하는 History, 태스크 큐를 호스팅하는 Matching, 그리고 Temporal 자체 시스템 워크플로용 내부 Worker. 각각 별도 프로세스에 자기 gRPC 엔드포인트를 가지며, 규모가 커졌을 때 독립적으로 스케일하려고 이렇게 쪼개 놨다.

그리고 이 클러스터는 스스로 아무것도 기억하지 못한다. 영속성은 외부 DB(실무에선 PostgreSQL/MySQL, 로컬 개발용 SQLite)가 담당한다. 오래된 블로그 보고 Cassandra를 떠올렸다면 접어라. 원문에 따르면 Cassandra는 Server v1.21에서 deprecated, v1.24에서 제거됐다. 검색(Visibility)도 오해가 많은데, Server v1.20부터 SQL DB가 Advanced Visibility를 지원하므로 Elasticsearch가 필수는 아니다. "볼륨이 커지면 결국 쓰게 될 것"이 정직한 표현이다.

여기에 하나 더. 내 워크플로 코드는 Worker라는 별도 배포 단위에서 돌고, 클러스터의 태스크 큐를 long-poll한다. 즉 도입은 "서버 하나 띄우기"가 아니라 클러스터 + DB + (나중에) 검색 클러스터 + API/워커 분리다.

// workflows.ts — 오케스트레이션. 결정적(deterministic)이어야 한다
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';

const { greet } = proxyActivities<typeof activities>({
  startToCloseTimeout: '1 minute',
});

export async function example(name: string): Promise<string> {
  // Date.now(), Math.random(), 직접 I/O 전부 금지
  return await greet(name);
}

greet를 직접 부르지 않고 proxyActivities를 통과시키는 이유가 핵심이다. 워크플로 코드는 복구할 때마다 이벤트 히스토리로 리플레이되므로, 같은 입력이면 항상 같은 커맨드 시퀀스를 뱉어야 한다. 아니면 Temporal이 비결정성 에러를 내고 진행을 거부한다. 대신 이 모델 덕분에 "30일 sleep 후 로컬 변수 그대로 깨어나기"가 가능하다. 프로세스를 살려두는 게 아니라 히스토리를 다시 재생해 상태를 복원하니까.

Restate: DB가 바이너리 안에 들어있다

Restate는 반대쪽에 베팅한다. Rust로 쓰인 단일 바이너리에 스트림 처리 아키텍처, 그리고 저널과 durable state를 담는 내장 RocksDB가 함께 들어있다. 프로비저닝할 Postgres도, 돌봐야 할 Elasticsearch도 없다.

다만 원문이 짚은 정직한 별표: "단일 바이너리"가 "프로세스 하나로 끝"을 뜻하지는 않는다. HA를 원하면 그 바이너리 여러 인스턴스를 띄우고, RocksDB가 주기적으로 오브젝트 스토어(S3/GCS/Azure Blob)에 스냅샷을 남겨야 한다. 그래야 노드가 죽어도 다른 노드가 상태를 복구하고 로그를 트림할 수 있다. 그래서 실제 비교는 "1 프로세스 vs 클러스터"가 아니라 "같은 바이너리 몇 개 + 버킷 하나" vs "4종 서비스 + RDB + (옵션) 검색 클러스터 + 쪼갠 워커"다. 그래도 운영 표면적 차이는 여전히 극적이다.

import * as restate from "@restatedev/restate-sdk";

// Virtual Object: id로 키잉된 상태 + 단일 writer 보장
export const myObject = restate.object({
  name: "MyObject",
  handlers: {
    myHandler: async (ctx: restate.ObjectContext, greeting: string) => {
      // ctx.sleep(), awakeable 등 durable 연산은 ctx 경유
      return `${greeting} ${ctx.key}!`;
    },
  },
});

restate.serve({ services: [myObject] });

Restate의 간판 프리미티브인 Virtual Object는 id로 키잉된 상태 엔티티다. 자기만의 KV 상태를 갖고, 특정 객체 상태는 한 번에 하나의 핸들러만 변경하는 단일 writer 보장이 붙는다. 별도 상태 저장소 없이 keyed state를 얻는 셈이다. 외부 이벤트를 기다리는 durable promise(awakeable), ctx.sleep 기반 durable timer도 있고, SDK는 TypeScript/Java·Kotlin/Python/Go/Rust를 지원한다. 중요한 건 Temporal식 강한 결정성 계약을 핸들러에 같은 방식으로 강요하지 않는다는 점이다. durable 스텝은 context를 통과하고, 프레임워크가 저널을 리플레이한다.

실무 관점: 5분 실습, 운영 표면적, 그리고 함정

일단 띄워보기

$ temporal server start-dev --db-filename temporal.db
CLI 1.x.x (Server 1.x.x, UI 2.x.x)

Server:  localhost:7233
UI:      http://localhost:8233
Metrics: http://localhost:64000/metrics

(버전 문자열과 포트는 릴리스마다 다르다. 이건 로컬 개발용 dev 서버이고, 프로덕션은 앞서 말한 4종 서비스 + RDB 구성이다. 여기서 "로컬은 한 줄인데 왜 운영은 이렇게 복잡하냐"는 갭이 도입 검토에서 가장 자주 새는 지점이다.)

$ docker run --name restate -d --rm \
    -p 8080:8080 -p 9070:9070 -p 9071:9071 \
    docker.io/restatedev/restate:latest

$ restate deployments register http://host.docker.internal:9080
✅ Successfully registered deployment  (services: MyObject)

# 서비스 호출 (ingress)
$ curl -s localhost:8080/MyObject/alice/myHandler -H 'content-type: application/json' -d '"Hello"'
"Hello alice!"

포트/플래그는 버전에 따라 달라질 수 있으니 공식 문서 확인이 필요하다. 다만 흐름 자체는 "바이너리 실행 → 내 핸들러 프로세스 등록 → HTTP 호출"이 끝이다.

운영 표면적 비교

  • 배포 토폴로지: Temporal = Frontend/History/Matching/내부 Worker + 내 Worker Deployment + API Deployment. Restate = 바이너리 N개 + 내 핸들러 서비스.
  • 스토리지 의존성: Temporal = PostgreSQL/MySQL(+ 볼륨 커지면 ES/OpenSearch). Restate = 로컬 디스크(RocksDB) + 오브젝트 스토리지 버킷.
  • 커플링: Temporal 구성은 API·워커·서버 셋이 서로를 물고 있어 하나가 죽으면 나머지가 같이 흔들린다. 원문이 인용한 DBOS 벤치마크(경쟁사가 자기 제품과 비교한 자료다)에서는 샘플 앱에 Temporal을 붙이며 100줄 이상 수정, 110→187줄 증가, 두 서비스 분리 + 세 번째(Temporal 서버) 런타임 의존이 생겼다고 한다. 자연법칙처럼 인용할 숫자는 아니고, Restate 수치로 옮겨 붙이면 더 안 된다. 다만 밑에 깔린 아키텍처 주장은 Temporal 공식 문서로도 검증되는 사실이다.
  • 장애 복구: Temporal은 DB가 진실의 원천이라 DB HA/백업 전략이 곧 워크플로 복구 전략이다. Restate는 노드 로컬 RocksDB + 오브젝트 스토어 스냅샷이라, 스냅샷 대상 버킷을 설정하지 않은 채 단일 노드로 굴리는 것이 가장 위험한 구성이다.

흔한 함정

1) 코드 한 줄 고쳤는데 진행 중 워크플로가 멈춘다. Temporal 도입 팀의 통과의례다. 워크플로 함수 안에서 액티비티 호출 순서를 바꾸거나 분기를 추가하면 리플레이 시 커맨드 시퀀스가 어긋난다. SDK·버전에 따라 문구는 다르지만 대략 이런 형태의 에러를 보게 된다.

DeterminismViolationError: Workflow activation completed with a different
command list than the one in the event history.
  expected: ScheduleActivityTask(greet)
  actual:   StartTimer

io.temporal.worker.NonDeterministicException: Failure handling event 5 of type
'EVENT_TYPE_ACTIVITY_TASK_SCHEDULED' during replay

해법은 정공법뿐이다. 워크플로 로직 변경은 버전 게이팅(patch/versioning API)으로 감싸고, 태스크 큐를 분리해 신규 워크플로만 새 코드로 받는 식으로 배포한다. Date.now()·Math.random()·직접 HTTP 호출은 워크플로 안에서 전부 금지어라는 걸 팀 컨벤션과 린트로 못 박아야 한다.

2) long-poll과 프록시 타임아웃. Temporal 워커는 태스크 큐를 long-poll 한다. 앞에 idle timeout이 짧은 LB/프록시(또는 사내 방화벽)를 두면 폴이 계속 끊기면서 context deadline exceeded 류의 로그가 도배된다. gRPC 경로에는 가급적 L4로 통과시키고 idle timeout을 폴 주기보다 넉넉히 잡는 편이 안전하다(정확한 기본값은 SDK 문서 확인 필요).

3) "ES 없이 가자"고 결정한 다음 운영에서 후회. v1.20부터 SQL만으로 Advanced Visibility가 되지만, 워크플로 수가 늘고 커스텀 속성으로 조회하기 시작하면 DB 부하로 돌아온다. "ES 필수"는 미신, "결국 필요해질 것"은 사실 — 이 중간 지점을 도입 문서에 명시해 두자.

4) Restate에서 상태를 ctx 밖에 두기. 핸들러 안에서 전역 변수나 외부 캐시에 진행 상태를 들고 있으면 리플레이 시 그 값은 복원되지 않는다. durable하게 남길 값은 반드시 context를 경유해야 한다. Virtual Object 키 설계도 초기에 잡아야 한다. 단일 writer 보장은 같은 키에 대한 것이라, 키를 너무 굵게 잡으면 그게 곧 직렬화 병목이 된다(주문번호 단위 vs 사용자 단위 같은 결정).

정리: 무엇을 언제 고를까

한 줄 요약: 코드 델타는 생각보다 작고, 운영 델타는 생각보다 크다. 새벽 2시에 당신이 붙잡을 대상이 무엇인지로 고르면 후회가 적다.

  • Restate 쪽에 기울 때: 팀에 전담 플랫폼 인력이 없다. 이미 굴리는 RDB/ES에 또 하나를 얹기 부담스럽다. 필요한 건 "이 핸들러를 크래시에 강하게 만들고 약간의 durable state를 붙이기"이고, 전면적인 워크플로 오케스트레이션 세계관까지는 원치 않는다. 마이크로서비스/이벤트 핸들러/에이전트처럼 백엔드 전반에 얇게 깔고 싶다.
  • Temporal 쪽에 기울 때: 30일 대기·복잡한 보상 트랜잭션·child workflow가 얽힌 정말 험한 장기 오케스트레이션이 있다. 재시도/타임아웃 설정을 세밀하게 통제해야 한다. 조직에 이미 Postgres·ES 운영 역량과 SRE가 있다. 혹은 그냥 Temporal Cloud에 돈을 내고 클러스터 운영을 아웃소싱할 수 있다(규모 있게 갈 거라면 보통 이게 합리적이다).

국내 환경 도입 팁

  • EKS: Temporal은 History/Matching/Frontend를 별도 Deployment + HPA로 잡고, 내 워커는 태스크 큐별로 분리해 스케일 축을 나눈다. RDS(Postgres) 파라미터·커넥션 수 산정을 초기에 해두지 않으면 워커 스케일아웃이 곧 DB 커넥션 고갈로 이어진다. Restate는 상태를 로컬 디스크에 두므로 StatefulSet + 적절한 PVC(가급적 gp3 이상), 스냅샷용 S3 버킷과 IRSA 권한이 세트다.
  • 온프레미스: 오브젝트 스토리지가 없으면 Restate HA 전제가 흔들린다. MinIO 같은 S3 호환 스토리지를 함께 검토하고, 지원 여부는 공식 문서로 확인하자.
  • 마이그레이션: 기존 사가 테이블을 한 번에 걷어내지 말 것. 신규 주문 흐름만 durable execution으로 받고 기존 건은 옛 워커가 소진시키는 이중 운영 기간을 두는 게 안전하다. 그리고 어느 쪽을 택하든 워크플로 히스토리/저널 보존 기간과 스토리지 증가율을 도입 첫 주에 측정해 두자. 이걸 놓치면 반년 뒤 디스크가 먼저 알려준다.

참고 자료

728x90
728x90

1. 무슨 일이 있었나 — 그리고 왜 남 얘기가 아닌가

GitHub Actions와 GitHub Pages가 6시간 넘게 대규모 장애·성능 저하를 겪었다. 증상은 대략 이렇게 정리된다.

  • 워크플로우 실행 실패와 지연, 큐 적체
  • 웹훅 처리량이 정상 대비 약 15% 수준까지 떨어져 push/PR 기반 CI 트리거 자체가 안 걸림
  • 자체 호스팅(셀프호스티드) 러너까지 작업 할당이 마비

세 번째 항목이 핵심이다. "우리는 러너를 우리 EC2/온프렘에 띄워놨으니 GitHub 죽어도 빌드는 돌겠지"라고 믿고 있던 팀들이 그날 같이 멈췄다. 나도 예전 팀에서 비용 절감 목적으로 셀프호스티드로 옮기면서 은근히 "가용성도 같이 좋아지겠네"라고 생각했었는데, 그건 착각이었다.

Hacker News 논의에서 나온 배경 설명도 흥미롭다. AI 코딩 에이전트의 자동 커밋·PR·백그라운드 폴링이 폭증하면서 시스템 용량 한계를 넘었다는 분석, 그리고 GitHub COO 발언 인용으로 Actions 주간 사용량이 2023년 5억 분에서 2026년 21억 분으로 늘었다는 수치가 언급됐다. Azure 인프라 이관 과정의 병목과 컨트롤 플레인 집중 구조에 대한 비판도 함께 나왔다. 다만 이건 커뮤니티 분석이고, 공식 포스트모템 기준의 근본 원인은 GitHub 상태 페이지/인시던트 리포트 확인이 필요하다.

어쨌든 실무자 입장에서 결론은 하나다. CI/CD가 외부 SaaS 단일 컨트롤 플레인에 물려 있으면, 그건 우리 배포 파이프라인의 SPOF다. 이 글에서는 왜 셀프호스티드 러너까지 죽는지 구조를 뜯어보고, 우리 파이프라인의 SPOF를 진단하는 방법과 장애 중에도 배포를 이어가는 현실적 우회 전략을 정리한다.

2. 셀프호스티드 러너가 같이 죽는 이유 — 컨트롤 플레인 의존 구조

오해부터 풀자. 셀프호스티드 러너는 "내 서버에서 도는 빌드 머신"이지 "독립적인 CI 시스템"이 아니다. 러너 프로세스가 하는 일을 순서대로 보면 이렇다.

  1. 러너가 GitHub의 Actions 서비스에 롱폴링으로 붙어서 대기한다 (아웃바운드 HTTPS)
  2. 이벤트(push/PR/schedule)가 발생하면 GitHub 쪽 스케줄러가 잡을 큐에 넣고 러너에 할당한다
  3. 러너가 잡 메시지를 받아 워크플로우 YAML을 해석하고 스텝을 실행한다
  4. 로그 스트림, 스텝 상태, 아티팩트 업로드를 계속 GitHub API로 올린다

1~2번이 전부 GitHub 컨트롤 플레인이다. 비유하자면 셀프호스티드 러너는 내 차고에 세워둔 차인데 시동 키는 본사에 있는 구조다. 차는 멀쩡한데 키 발급 서버가 죽으면 그냥 주차장 장식이다. 웹훅 처리량이 15%로 떨어졌다는 건 애초에 "출발 지시" 자체가 안 나갔다는 뜻이고, 스케줄러가 흔들리면 지시가 나가도 러너에 배정이 안 된다.

러너가 살아 있는지, 컨트롤 플레인과 붙어 있는지는 러너 호스트에서 바로 확인할 수 있다.

# 러너 서비스 상태와 최근 로그
$ sudo systemctl status actions.runner.myorg-myrepo.runner-01
● actions.runner.myorg-myrepo.runner-01.service - GitHub Actions Runner
     Active: active (running) since Tue 2026-02-10 09:12:03 KST; 3h 41min ago

$ tail -n 5 ~/actions-runner/_diag/Runner_*.log
[INFO] Listening for Jobs
[WARN] Retrying connection to the server. Attempt 3
[ERR ] GitHub Actions is temporarily unavailable. Retrying in 30 seconds.

여기서 Listening for Jobs가 찍혀 있는데도 잡이 안 내려오면 러너 문제가 아니라 상류 문제다. 이때 러너를 재시작하는 건 아무 소용이 없다(오히려 등록 API까지 흔들리면 재등록이 안 돼서 상황이 더 나빠진다). 장애 중 흔한 자해 행위 1순위가 "일단 러너 재시작"이다.

상태를 스크립트로 물어볼 때는 GitHub 상태 API를 쓰는 게 정확하다.

$ curl -s https://www.githubstatus.com/api/v2/components.json \
  | jq -r '.components[] | select(.name|test("Actions|Pages|Webhooks")) 
           | "\(.name)\t\(.status)"'
Actions         major_outage
Webhooks        degraded_performance
Pages           partial_outage

이 한 줄을 슬랙 알림에 붙여두면, 장애 때 "우리 코드가 문제인가?"로 낭비하는 30분을 아낄 수 있다. 실제로 장애 초기에 팀 채널에 가장 많이 올라오는 질문이 "제 PR만 안 도는 건가요?"다.

3. 우리 파이프라인의 SPOF 진단과 내구성 설계

3-1. SPOF 체크리스트

배포 경로를 단계별로 쪼개서, 각 단계가 GitHub에 얼마나 물려 있는지 표로 그려보길 권한다. 실제로 그려보면 생각보다 GitHub 의존이 촘촘하다.

  • 트리거: push/PR 웹훅. → GitHub 의존 100%. 웹훅이 안 오면 아무것도 시작 안 됨
  • 소스: actions/checkout이 github.com에서 clone. → 미러 레포가 없으면 의존 100%
  • 빌드 캐시: actions/cache는 GitHub 캐시 서비스. → 캐시 미스 시 빌드 시간 폭증
  • 아티팩트: upload/download-artifact, GHCR(ghcr.io). → GHCR도 GitHub 도메인
  • 시크릿: Repository/Org Secrets. → 여기가 진짜 위험. GitHub이 죽으면 수동 배포 시 시크릿 값을 꺼낼 방법이 없다
  • 배포 승인: Environments의 required reviewers. → GitHub UI 의존

이 중 우선순위를 딱 하나만 고르라면 시크릿이다. 빌드는 로컬에서 어떻게든 돌리지만, DB 접속 정보나 배포 키가 GitHub Secrets에만 있으면 긴급 배포 자체가 불가능하다. Secrets는 쓰기 전용이라 UI에서 값을 다시 볼 수도 없다. Vault, AWS Secrets Manager, GCP Secret Manager 중 아무거나 SoT(Source of Truth)로 두고 GitHub Secrets는 거기서 동기화된 사본으로 취급하는 게 맞다.

3-2. 아티팩트·레지스트리 이중화

컨테이너 이미지를 GHCR에만 올리고 있다면, 롤백조차 못 하는 상황이 생긴다. 푸시를 두 곳으로 나누는 건 몇 줄이면 된다.

- name: Build and push (multi-registry)
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: |
      ghcr.io/${{ github.repository }}:${{ github.sha }}
      ${{ secrets.ECR_REGISTRY }}/myapp:${{ github.sha }}

비용은 스토리지 두 벌 + 푸시 트래픽 정도. 롤백 가능성을 사는 값으로는 싸다. 여기서 트레이드오프는 태그 정합성인데, 한쪽 푸시만 성공하고 다른 쪽이 실패하는 부분 성공 케이스를 어떻게 처리할지 정해둬야 한다. 우리는 "ECR 푸시 실패는 파이프라인 실패"로 잡고, 대신 재시도 3회를 걸어뒀다.

3-3. 백업 파이프라인은 "존재"가 아니라 "훈련"이 중요하다

GitLab CI나 Jenkins를 백업으로 두는 팀은 꽤 있는데, 1년에 한 번도 안 돌려본 백업 파이프라인은 장애 때 반드시 깨져 있다. 크리덴셜 만료, 러너 이미지 노후화, 빌드 스크립트 드리프트가 쌓인다. 최소한 주 1회 스케줄로 백업 파이프라인에서 빌드까지만이라도 돌려 녹슬지 않게 해야 한다. 배포까지는 안 해도 된다. 빌드가 성공하는지만 보면 드리프트의 90%는 잡힌다.

원문에서 언급된 Forgejo, GitLab, Woodpecker CI, Depot 같은 대안 이관 움직임도 같은 맥락이다. 다만 전면 이관은 러너 관리·시크릿·권한 체계를 통째로 옮기는 일이라 만만치 않다. 현실적으로는 완전 이관보다 핵심 서비스만 이중 파이프라인이 비용 대비 효율이 좋다고 본다.

3-4. 흔한 함정 — 실제로 만나는 에러들

함정 1: 재시도를 걸었는데 큐만 더 막는다. 장애 중 if: failure()로 재실행을 자동화해두면, 복구 직후 몰린 잡이 한꺼번에 터지면서 동시성 한도를 넘긴다. 그때 나오는 에러가 이거다.

The job was not started because the runner group does not have 
enough capacity. Waiting for a runner to pick up this job...

##[error]The self-hosted runner: runner-01 lost communication with the server. 
Verify the machine is running and has a healthy network connection. 
Anything in your workflow that terminates the runner process, 
starves it for CPU/Memory, or blocks its network access can cause this error.

lost communication with the server 메시지가 특히 헷갈린다. 문구만 보면 우리 머신 네트워크 문제 같은데, 컨트롤 플레인 장애 때도 똑같이 뜬다. 러너 호스트에서 ping, curl https://api.github.com이 멀쩡한지부터 확인하고, 멀쩡하면 상류 장애로 판단하고 손을 떼는 게 낫다.

함정 2: 웹훅 유실은 조용히 지나간다. 웹훅 처리량이 15%로 떨어졌다는 건, 나머지 85%의 push가 실패 표시도 없이 그냥 CI가 안 돈다는 뜻이다. PR 화면에 체크가 안 뜨는 게 아니라 아예 아무 체크도 안 붙는다. 브랜치 보호에서 required status check을 걸어놨다면 머지가 전부 막히고, 안 걸어놨다면 테스트 없이 머지된 커밋이 흘러 들어간다. 후자가 더 무섭다. 복구 후에 반드시 해당 시간대 머지 커밋을 훑고 워크플로우를 수동 재실행해야 한다.

# 장애 시간대 이후 커밋에 워크플로우 수동 재실행
$ gh run list --branch main --limit 5
STATUS  NAME       WORKFLOW  BRANCH  EVENT  ID
X       fix: cache CI        main    push   1029384756
-       chore: bump CI       main    push   1029384700

$ gh workflow run ci.yml --ref main -f reason="post-incident replay"
✓ Created workflow_dispatch event for ci.yml at main

이걸 하려면 워크플로우에 workflow_dispatch: 트리거가 미리 들어 있어야 한다. 없으면 장애 중에 트리거를 추가하는 커밋을 푸시해야 하는데, 그 푸시의 웹훅도 안 갈 수 있다. 지금 당장 모든 배포 워크플로우에 workflow_dispatch를 넣어두자. 비용 0, 효과 확실.

함정 3: Pages를 상태 페이지로 쓰고 있다. 의외로 많다. 서비스 장애 공지 페이지를 GitHub Pages로 호스팅해두면, 이번처럼 Actions와 Pages가 동시에 죽을 때 공지를 띄울 수단이 사라진다. 상태 페이지는 반드시 본 서비스와 다른 사업자에 둬야 한다.

3-5. 장애 중 배포를 이어가는 현실적 우회

순서를 정해두면 당황하지 않는다. 우리 팀 런북은 대략 이 순서다.

  1. 판단(5분): githubstatus API 확인 → 상류 장애면 개별 디버깅 중단, 채널 공지
  2. 동결: 급하지 않은 배포는 전부 홀드. 장애 중 배포는 롤백 수단도 같이 죽어 있다는 걸 잊지 말 것
  3. 핫픽스만 우회: 이미 레지스트리에 올라간 이미지가 있으면 그 태그로 배포. 없으면 로컬 빌드 → 백업 레지스트리 푸시 → 배포 도구(ArgoCD/Helm 등)로 직접 배포
  4. 기록: 우회 배포한 커밋 SHA와 이미지 태그를 채널에 남긴다. 복구 후 정규 파이프라인과 상태를 맞추기 위한 필수 정보다
  5. 복구 후 정합성 확인: 우회 배포분을 정규 파이프라인으로 재빌드해 SHA가 일치하는지 검증

3번에서 로컬 빌드로 배포할 때 가장 자주 사고 나는 게 재현성이다. 평소 CI 러너 이미지와 로컬 환경이 다르면 결과물이 달라진다. 그래서 빌드 환경을 컨테이너로 고정해두는 게 장애 때 빛을 본다. docker run --rm -v $PWD:/src build-env:pinned make build 정도로 CI와 동일한 이미지에서 빌드할 수 있게 만들어두자.

5번의 정합성 확인을 빼먹으면, 몇 주 뒤에 "왜 프로덕션 이미지가 main 브랜치와 다르지?"라는 미스터리로 돌아온다. 실제로 겪어봤고, 원인 찾는 데 하루 날렸다.

4. 정리 — 한 줄 요약과 적용 우선순위

한 줄 요약: 셀프호스티드 러너는 데이터 플레인만 우리 것이고 컨트롤 플레인은 여전히 GitHub이다. 그래서 GitHub이 죽으면 우리 러너도 같이 죽는다.

전면 이중화를 당장 하라는 얘기는 아니다. 팀 규모와 배포 빈도에 따라 필요한 수준이 다르다. 투자 대비 효과 순으로 정리하면 이렇다.

  • 모든 팀 — 오늘 당장(1시간 내): 배포 워크플로우에 workflow_dispatch 추가, githubstatus API 슬랙 알림, 상태 페이지를 Pages 밖으로 이전
  • 배포가 주 1회 이상인 팀 — 이번 스프린트: 시크릿 SoT를 외부 시크릿 매니저로 이전, 컨테이너 레지스트리 이중 푸시, 장애 런북 문서화
  • 배포 중단이 곧 매출 손실인 팀 — 분기 과제: 백업 파이프라인 구축 + 주 1회 자동 검증, 빌드 환경 컨테이너 고정, 소스 미러 레포

반대로 안 해도 되는 것도 짚자. 사내 배포용 툴이나 주 1회 미만 배포하는 레포까지 이중화하는 건 관리 비용만 늘린다. "6시간 멈춰도 되는 것"과 "30분도 안 되는 것"을 먼저 분류하는 게 첫 작업이다.

그리고 이번 장애의 배경으로 지목된 AI 에이전트발 트래픽 폭증은, 사실이라면 앞으로도 반복될 가능성이 높은 종류의 문제다. 우리 조직도 Copilot이나 각종 에이전트를 붙이면서 CI 실행 횟수가 알게 모르게 늘고 있는지, 한 번쯤 사용량을 뽑아볼 만하다. 남의 부하 문제가 아니라 우리도 그 부하의 일부일 수 있다.

참고 자료

728x90
728x90

이번에 고른 글은 코드 한 줄 없는 글이다. Stephen Wolfram(Mathematica·Wolfram Language를 만든 그 사람)이 아내 Elise Cawley를 추모하며 쓴 글이고, 제목에 1961–202636 Wonderful Years가 들어가 있다. Hacker News 상단에 올라와 있었다.

먼저 분명히 해둘 것: 원문 본문을 가져오지 못했다. 그래서 글에 어떤 이야기가 담겼는지, 어떤 문장이 인상적이었는지는 쓰지 않는다. 개인의 죽음과 가족사를 추측으로 채우는 건 하면 안 되는 일이다. 내용은 원문을 직접 읽는 쪽을 권한다. 세부 사실은 공식 게시물 확인 필요.

도입: 부고를 읽고 나서 인프라 담당자가 떠올린 질문

이런 글이 HN에 오르면 댓글창은 대체로 애도로 채워지지만, 나는 매번 직업병처럼 다른 생각이 든다. 사람이 갑자기 없어졌을 때, 그 사람에게만 묶여 있던 것들은 어떻게 되는가.

거창한 이야기가 아니다. 5년 굴려보면 이런 일은 생각보다 자주 만난다.

  • 퇴사자 GPG 키로만 암호화된 secrets.enc.yaml
  • 개인 계정 MFA에 묶인 AWS 루트, 도메인 레지스트라, 앱스토어 계정
  • 10년 전에 한 사람이 짜고 아무도 안 건드린 배치 스크립트
  • 사내 위키에 없고 그 사람 머릿속에만 있던 "장애 나면 이거 먼저 확인" 순서

퇴사는 최소한 예고가 있다. 사고나 병은 없다. 재해복구 계획에 리전 장애는 있는데 사람 장애는 없는 조직이 대다수다. 그래서 이 글은 부고에 대한 감상문이 아니라, 그걸 계기로 실제로 돌려본 점검 절차 정리다.

핵심: 버스 팩터는 "지식"이 아니라 "복호화 가능성"의 문제다

버스 팩터(bus factor)는 보통 "몇 명이 빠지면 프로젝트가 멈추나"로 설명된다. 실무에서 더 정확한 정의는 이렇다.

어떤 자산에 대해, 접근 경로가 단 하나의 사람 계정에만 존재하는가.

지식은 그래도 시간을 들이면 복원된다. 코드를 읽고, 로그를 뒤지고, 재구성한다. 복원 불가능한 건 암호학적으로 잠긴 것이다. 비유하면, 창고 위치를 모르는 건 며칠 걸리는 문제고 창고 열쇠가 세상에 한 개뿐이었던 건 영구 손실이다.

그래서 우선순위는 이 순서가 맞다.

  1. 복구 불가: 단일 GPG/age 키로 암호화된 시크릿, 개인 TOTP에만 걸린 루트 계정, 개인 소유 도메인
  2. 복구 비싸다: 문서 없는 수동 운영 절차, 로컬에만 있는 terraform state
  3. 복구 가능: 코드 이해, 아키텍처 파악

1번부터 손대야 하는데, 실제로는 3번(문서화 스프린트)만 하고 끝내는 조직을 너무 많이 봤다.

실무 관점: 30분이면 돌려보는 점검 3종과 흔한 함정

1) 리포지토리 단독 소유자 스캔

파일별로 커밋한 사람이 한 명뿐인 경로를 뽑는다. 문서화 우선순위 정하는 데 이만한 근거가 없다.

#!/usr/bin/env bash
# bus-factor.sh — 최근 3년간 커밋 저자가 1명뿐인 파일 목록
git ls-files | while read -r f; do
  n=$(git log --since="3 years ago" --format='%ae' -- "$f" | sort -u | wc -l)
  [ "$n" -eq 1 ] && printf '%s\t%s\n' \
    "$(git log -1 --format='%ae' -- "$f")" "$f"
done | sort | uniq -c | sort -rn | head
$ ./bus-factor.sh
     47 kim@example.com   deploy/legacy-batch/
     12 kim@example.com   scripts/db-failover.sh
      9 park@example.com  terraform/modules/vpn/
$ # kim 한 사람이 68개 파일의 유일한 저자 → 여기부터 페어링

주의: uniq -c로 묶으려면 경로를 디렉터리 단위로 자르는 게 실용적이다. 위 출력은 형태 예시로, 숫자는 각자 리포에 따라 완전히 달라진다.

2) 시크릿 복호화 권한자 확인

SOPS를 쓴다면 암호화 파일 메타데이터에 수신자 목록이 그대로 박혀 있다. 이걸 안 보고 넘어가는 팀이 정말 많다.

$ grep -A4 'pgp:' secrets/prod.enc.yaml | grep fp
    fp: 9A1B2C3D4E5F60718293A4B5C6D7E8F901234567

$ gpg --list-keys 9A1B2C3D4E5F60718293A4B5C6D7E8F901234567
pub   rsa4096 2019-04-02 [SC] [expired: 2024-04-01]
      9A1B2C3D4E5F60718293A4B5C6D7E8F901234567
uid           [ expired] Former Dev <kim@example.com>

수신자가 한 명이거나, 만료됐거나, 이미 퇴사자면 그 파일은 사실상 시한폭탄이다. 이럴 때 CI에서 튀어나오는 게 이 에러다.

$ sops -d secrets/prod.enc.yaml
Failed to get the data key required to decrypt the SOPS file.

Group 0: FAILED
  9A1B2C3D4E5F60718293A4B5C6D7E8F901234567: FAILED
    - | could not decrypt data key with PGP key:
      | github.com/ProtonMail/go-crypto/openpgp error: Could not
      | load secring: open /home/runner/.gnupg/secring.gpg: no
      | such file or directory

Recovery failed because no master key was able to decrypt the
file. In order for SOPS to recover the file, at least one key
has to be successful, but none were.

이 메시지를 배포 직전에 처음 보면 그날 릴리스는 끝난 거다. 해결은 결국 수신자를 늘려두는 것뿐이다. 개인 키 대신 KMS/Vault 같은 서비스 키를 1차로 두고, 개인 키는 보조로만 넣는 구성을 권한다.

$ sops updatekeys secrets/prod.enc.yaml   # .sops.yaml 규칙 반영
$ sops -d secrets/prod.enc.yaml | head -1
db_password: ENC-was-here

3) 개인에게 묶인 클라우드 접근 경로

$ aws iam generate-credential-report >/dev/null
$ aws iam get-credential-report --query Content --output text \
  | base64 -d | awk -F, 'NR==1||$4=="false"||$8=="true"' \
  | cut -d, -f1,4,8,9 | column -t -s,
user            mfa_active  access_key_1_active  access_key_1_last_rotated
<root_account>  true        false                N/A
kim             false       true                 2021-03-11T04:22:00+00:00
ci-deployer     false       true                 2025-06-02T11:03:00+00:00

여기서 kim처럼 MFA 없고 4년 넘게 안 돌린 키가 나오면 보안 문제이면서 동시에 승계 문제다. 컬럼 인덱스는 credential report 포맷에 따라 다르니 base64 -d | head -1로 헤더 먼저 확인하는 게 안전하다. 정확한 필드 순서는 AWS 공식 문서 확인 필요.

트레이드오프와 대안

여기서 늘 부딪히는 게 승계 가능성 vs 최소 권한이다. 아무나 프로덕션 시크릿을 열 수 있게 만들면 그건 다른 종류의 사고다. 현실적인 타협안 세 가지:

  • break-glass 계정: 평소엔 잠겨 있고 사용 시 알림이 터지는 비상 계정. TOTP 시드를 종이로 인쇄해 금고에 넣는 방식이 여전히 유효하다. 대신 연 1회 실제로 열어보는 훈련이 없으면 무용지물이다.
  • 키 분할: Vault unseal key처럼 Shamir 방식으로 나눠 3명 중 2명이 모이면 복구. 운영 부담이 커서 정말 최상위 자산에만 쓴다.
  • 소유권 대장: 도메인, 결제 수단, SaaS 관리자, 코드 서명 인증서를 표 하나로 관리. 지루하지만 투자 대비 효과가 가장 크다. 도메인은 whois example.com | grep -i expir 정도만 크론에 걸어둬도 최악은 피한다.

흔한 함정 하나 더. 계정을 즉시 비활성화했다가 그 계정이 소유하던 리소스가 같이 죽는 케이스다. Terraform state를 개인 버킷에 두고 있었으면 이런 걸 보게 된다.

$ terraform plan
Error: Failed to get existing workspaces: Unable to list objects in
S3 bucket "kim-tfstate-2020" with prefix "env:/": operation error S3:
ListObjectsV2, https response error StatusCode: 403, api error
AccessDenied: Access Denied

그래서 오프보딩 순서는 "삭제"가 아니라 정지 → 소유 리소스 이전 → 삭제다. 계정을 비활성화하되 30~90일 보존하는 유예 기간을 정책으로 박아두는 게 낫다.

정리

한 줄 요약: 재해복구 시나리오에 "핵심 인력 한 명이 오늘부로 영구히 부재" 항목을 넣고, 지식보다 암호학적 단일 소유권을 먼저 없애라.

언제 해야 하나. 팀이 5명을 넘어가고 프로덕션 시크릿이 생긴 순간부터다. 이미 늦은 팀이라면 위 3종(단독 저자 스캔 / SOPS 수신자 점검 / MFA·오래된 키 리포트)만 이번 주에 돌려봐도 대체로 한두 개는 걸린다. 걸리면 그게 당신 조직의 진짜 SPOF다.

그리고 이 글의 출발점이 된 원문은 기술 문서가 아니다. 링크는 아래에 두었고, 애도를 전한다.

참고 자료

728x90
728x90

백엔드 좀 만져본 사람이라면 Postgres LISTEN/NOTIFY라는 게 있다는 건 다들 안다. 근데 실무에서 실제로 쓰냐고 물어보면 열에 아홉은 "그거 안 쓰죠, 확장 안 되잖아요"라고 답한다. 나도 그랬다. 실시간 이벤트 뿌릴 일 있으면 반사적으로 Redis Pub/Sub이나 Kafka부터 떠올렸다.

그런데 최근 DBOS 엔지니어링 블로그에서 나온 글(GeekNews 링크)이 이 편견을 정면으로 반박한다. 요지는 간단하다. 단순 구현은 초당 2,900건에서 막히는 게 맞다. 하지만 알림을 버퍼링해서 배치로 보내면 단일 서버에서 초당 최대 6만 건까지 뚫린다는 것. 20배 차이다. 오늘은 이게 왜 그런지, 그리고 실무에서 언제 이 카드를 꺼내야 하는지를 정리해본다.

1. Postgres LISTEN/NOTIFY란 무엇인가

구조 자체는 정말 단순하다. 한 세션이 특정 채널을 LISTEN하고, 다른 세션이 그 채널로 NOTIFY를 보내면, 대기 중이던 세션이 즉시 깨어난다. 별도 브로커도, 별도 프로세스도 필요 없다. DB 하나로 끝난다.

직접 두 개의 psql 세션을 띄워서 확인해보자. 첫 번째 터미널:

-- 세션 A (리스너)
LISTEN chat_events;

두 번째 터미널:

-- 세션 B (발신자)
NOTIFY chat_events, 'new message id=42';

그러면 세션 A에서 이런 게 뜬다:

Asynchronous notification "chat_events" with payload
"new message id=42" received from server process with PID 12345.

실무에서 자주 쓰는 패턴은 테이블에 트리거를 걸어서 INSERT가 일어날 때마다 자동으로 NOTIFY를 쏘는 방식이다. 예를 들어 LLM 응답 토큰을 스트리밍하거나, 채팅 메시지가 들어올 때 읽기 프로세스를 즉시 깨우는 용도다.

CREATE OR REPLACE FUNCTION notify_stream_insert()
RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify('stream_' || NEW.stream_id, NEW.id::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER stream_insert_notify
AFTER INSERT ON streams
FOR EACH ROW EXECUTE FUNCTION notify_stream_insert();

이렇게 하면 읽기 프로세스는 폴링 없이 대기하다가 새 조각이 기록되는 순간 깨어나서 읽는다. 지연 시간도 낮고 정확성도 확보된다. 여기까지는 아무 문제 없다.

2. 왜 '느리다'는 오해가 생겼나 — 전역 배타적 잠금의 정체

문제는 부하가 올라갈 때 터진다. 위 트리거 방식으로 스트림 쓰기를 계속 밀어넣으면, 큰 Postgres 서버에서도 초당 2,900건 언저리에서 병목이 걸린다. 그런데 이상한 건, 이때 CPU도 메모리도 IOPS도 널널하다는 점이다. 리소스는 남아도는데 처리량이 안 나온다. 전형적인 잠금 경합 증상이다.

원인은 NOTIFY의 커밋 경로에 있는 전역 배타적 잠금이다. 왜 이런 잠금이 필요할까? 핵심은 커밋 순서 보장이다.

Postgres는 알림이 반드시 트랜잭션 커밋 순서대로 전달되도록 보장한다. 이걸 위해 모든 발신 알림을 커밋 순서와 정확히 일치하는 전역 내부 큐에 넣는다. 그런데 트랜잭션마다 커밋에 걸리는 시간이 제각각이라, 커밋이 완료되기 전에는 순서를 확정할 수 없다. 그래서 Postgres는 NOTIFY를 포함한 트랜잭션이 커밋을 시작하면 전역 잠금을 잡고, 커밋이 완전히 끝나고 fsync()로 디스크에 내려갈 때까지 놓지 않는다.

비유하자면 이렇다. 은행 창구가 여러 개 있는데(그룹 커밋), NOTIFY가 붙은 손님은 "내 순번을 정확히 지켜야 한다"는 이유로 창구 하나를 혼자 독점하면서 서류에 도장 찍고 금고에 넣는 것까지 다 끝나야 다음 사람이 들어온다. 나머지 창구는 놀고 있다. 이게 그룹 커밋을 못 쓰게 만드는 지점이다.

결과적으로 처리량은 "Postgres가 개별 트랜잭션을 하나씩 순차 커밋하는 속도"를 절대 넘을 수 없다. 여러 트랜잭션을 한 번의 fsync로 묶는 그룹 커밋 최적화가 무력화되니까. CPU와 디스크가 놀고 있는데도 처리량이 안 오르는 이유가 바로 이거다.

참고로 Postgres 19에 들어갈 관련 패치가 있긴 한데, 원문에 따르면 이 패치는 전역 잠금을 제거하지 않는다. 대신 "채널이 많고 각 리스너가 특정 채널 하나만 기다리는" 제한적 케이스를 최적화하는 것이라, 위에서 말한 병목 자체를 해소하진 못한다.

3. 배치 버퍼링으로 처리량 끌어올리기 — 초당 6만 건의 원리

여기서 관점 전환이 나온다. 핵심 통찰은 이거다: 대부분의 LISTEN/NOTIFY 용도에서 알림은 '진실의 원천'이 아니다.

무슨 말이냐면, 실제 데이터는 streams 테이블에 이미 저장되어 있다. 알림은 그냥 "테이블 확인해봐"라는 신호일 뿐이다. 그렇다면 알림 자체가 완벽한 전역 순서나 완전한 내구성을 가질 필요가 없다. 순서가 조금 뒤바뀌거나 알림 몇 개가 유실돼도, 데이터베이스 테이블만 정확하면 복구할 수 있다.

이 발상에서 나오는 구조가 알림 버퍼링 + 배치 전송이다. 개별 쓰기마다 NOTIFY를 쏘지 않는다. 대신:

  • 스트림 쓰기는 그냥 테이블에 INSERT만 하고 빠르게 커밋한다 (전역 잠금 안 잡음 → 그룹 커밋 활용 가능)
  • 보내야 할 알림은 메모리 버퍼에 모아둔다
  • 백그라운드에서 주기적으로 버퍼를 비우면서, 모아둔 알림을 하나의 배치 트랜잭션으로 한 번에 NOTIFY한다

이렇게 하면 전역 잠금을 "개별 쓰기마다"가 아니라 "버퍼 플러시할 때만" 잡는다. 잠금 획득 횟수가 확 줄어든다. 개별 쓰기 트랜잭션은 알림 전송과 분리되어 빠르게 진행되고, 그룹 커밋 최적화도 살아난다.

의사코드로 표현하면 이런 그림이다:

# 애플리케이션 레벨 배치 버퍼 (개념 예시)
buffer = []

def on_stream_write(stream_id, chunk_id):
    # 쓰기 자체는 알림과 분리 - 빠르게 커밋
    db.insert("streams", stream_id=stream_id, id=chunk_id)
    buffer.append(f"stream_{stream_id}")

# 백그라운드에서 주기적 플러시 (예: 수 ms 간격)
def flush_notifications():
    if not buffer:
        return
    channels = set(buffer)   # 중복 채널 제거
    buffer.clear()
    with db.transaction() as tx:   # 하나의 배치 트랜잭션
        for ch in channels:
            tx.execute("SELECT pg_notify(%s, '')", (ch,))
    # 이 트랜잭션 커밋 때만 전역 잠금 1회 획득

원문 벤치마크에 따르면 이 최적화 구현은 동시 읽기 프로세스가 있는 환경에서 초당 최대 6만 건의 스트림 쓰기를 처리했다. 초기 구현 대비 20배다. 그리고 중요한 건 이때 Postgres CPU가 완전히 포화됐다는 점이다. 즉 병목이 잠금 경합이 아니라 데이터베이스 자체의 실제 처리 한계로 옮겨갔다는 뜻이다. 잠금 때문에 리소스가 놀던 상태에서, 리소스를 다 쓰는 상태가 됐다.

4. 놓치기 쉬운 함정 — 알림 유실과 커밋 순서

여기서 "어? 알림을 메모리에 모아두면 프로세스 죽으면 날아가는 거 아냐?"라는 의문이 당연히 든다. 맞다. 버퍼에 남아있는 상태에서 프로세스가 죽으면 그 알림들은 전달되지 않는다.

원문의 해법은 저빈도 폴링을 보조 수단으로 병행하는 것이다. 읽기 프로세스는 알림을 기다리는 동시에, 낮은 빈도로 테이블을 주기적으로 조회해서 "알림 없이 기록된 데이터"가 있는지 확인한다. 알림은 즉시성을 위한 것이고, 폴링은 유실 복구용 안전망이다. 유실된 것만 건지면 되니까 폴링 빈도가 낮아도 되고, 그래서 DB에 부담도 크지 않다.

이 이중 구조 덕분에 처리량을 6만 건까지 끌어올린 상태에서도 지연 시간은 15~100ms 범위를 유지한다. 정상 경로는 알림으로 빠르게, 예외 경로는 폴링으로 안전하게. 이게 핵심 설계 포인트다.

실무에서 진짜 마주치는 함정들

함정 1: 8,000바이트 페이로드 상한. LISTEN/NOTIFY 페이로드에는 크기 제한이 있다. 큰 JSON을 통째로 알림에 실으려다가 이걸 만난다:

ERROR:  payload string too long

이게 뜨면 설계를 잘못한 거다. 애초에 알림은 신호일 뿐이니, 페이로드에는 행 ID나 시퀀스 번호만 넣고 실제 데이터는 테이블에서 읽어야 한다. HN 댓글에서도 지적됐듯, 알림에 임의 크기 데이터를 실으려 한다면 그건 알림 시스템을 잘못 쓰고 있다는 신호다. 웹 게임의 일시적 상태 이벤트처럼 8KB를 넘는 데이터를 그대로 뿌려야 하는 용도라면 이 방식 자체가 안 맞는다.

함정 2: 소비자 오프셋 추적 누락. 원문에서 다루지 않은, 하지만 실무에서 반드시 부딪히는 부분이다. "소비자가 어디까지 읽었는지"를 어떻게 추적할 것인가? 알림이 유실될 수 있으니, 소비자는 시퀀스 번호나 오프셋 기반으로 "내가 마지막으로 읽은 지점 이후의 새 메시지"를 조회할 수 있어야 한다. 이걸 잘못 구현하면 소비자끼리 경쟁 상태(race condition)가 생기거나, 시퀀스 번호 할당 지점에 또 다른 잠금 경합이 생긴다. 배치 버퍼링으로 NOTIFY 병목은 풀었는데, 정작 시퀀스 발급에서 다시 직렬화되는 자충수를 두지 않도록 조심해야 한다.

함정 3: VACUUM과 디스크 경합. HN에 올라온 실전 경험담이 뼈아프다. 어떤 CTO가 LISTEN/NOTIFY 위에 자체 큐를 올렸다가, 확장하면서 Postgres 내부 동작을 우회해야 했고, RDS에서 원인 파악이 어려운 디스크 경합이 심해졌으며, 해당 테이블의 VACUUM이 악몽이 됐다고 한다. 스트림 테이블처럼 INSERT가 폭발적으로 일어나는 테이블은 dead tuple이 빠르게 쌓이므로, autovacuum 설정과 파티셔닝/주기적 정리 전략을 반드시 함께 설계해야 한다.

5. 실무 아키텍처 패턴 — 언제 쓰고 언제 Kafka·Redis로 가야 하나

이 글의 진짜 가치는 "6만 건 되니까 무조건 써라"가 아니다. HN 최고 추천 댓글이 정곡을 찌른다. 확장성은 이분법이 아니라 연속적인 척도다. 초당 6만 건은 어떤 시스템엔 10만 배 과하고, 어떤 시스템엔 10만 배 부족하다.

내가 정리한 판단 기준은 이렇다.

LISTEN/NOTIFY로 충분한 경우:

  • 이미 Postgres가 진실의 원천이고, 실시간 알림이 "테이블 확인해라" 신호 수준인 경우
  • 비관적 최대 부하를 계산했을 때 10배 여유를 둬도 6만 건 안에 들어오는 경우
  • 별도 인프라(Redis/Kafka/SQS)를 운영할 팀 여력이 없고, DB와의 트랜잭션 일관성이 중요한 경우
  • 채팅, LLM 토큰 스트리밍, 실시간 대시보드 갱신처럼 지연 100ms 이내면 충분한 대화형 용도

외부 브로커로 가야 하는 경우:

  • 메시지 자체가 진실의 원천이고, 강한 순서 보장과 내구성이 필수인 경우 (→ Kafka)
  • 저장 후 전달(store-and-forward), 재처리, 컨슈머 그룹 같은 큐 본연의 기능이 필요한 경우 (→ SQS, Kafka)
  • 순간적 트래픽 폭증(스파이크)을 흡수해야 하는 경우 — HN 댓글 지적대로 시스템을 무너뜨리는 건 평상시 트래픽이 아니라 갑작스러운 폭증이다. 큐는 버퍼 역할을 하지만 LISTEN/NOTIFY는 그렇지 않다
  • 큐의 수명 주기를 DB와 독립적으로 운영하고 싶은 경우 (패치/장애 격리)

한 가지 꼭 짚고 넘어갈 점. 원문 벤치마크는 96코어·384GB RAM짜리 대형 서버에서 나온 수치다(HN 댓글에서 지적됨). 이건 원문이 더 명확히 밝혔어야 하는 부분이다. 당신의 db.t3.medium에서 6만 건이 나올 거라고 기대하면 안 된다. 데이터베이스는 수직 확장이 가능하지만 그 자체도 한계가 있고, 읽기 복제본과 리전 간 이중화까지 넣으면 프로덕션 클러스터 하나에 연 10만 달러가 넘어가기도 한다. 하드웨어 스펙과 처리량은 세트로 봐야 한다.

실제로 잘 굴린 사례도 HN에 있다. LISTEN/NOTIFY와 Rust GraphQL 구독 브로커를 조합한 케이스인데, 사용자 구독은 수만 개였지만 LISTEN 연결은 호스트당 하나씩 총 3~4개뿐이었다. 모든 변경을 각 호스트로 보내고, 실제 사용자 구독 관리는 호스트가 담당하게 한 것이다. "확장 안 된다"고 여겨지는 방식도 연결 구조를 잘 설계하면 충분히 잘 돌아간다는 좋은 예다.

6. 벤치마크 재현과 운영 모니터링 포인트

원문 저자들이 전체 벤치마크 코드를 dbos-postgres-benchmark 저장소에 공개해뒀다. 직접 재현해보고 싶다면 이걸 돌려보는 게 제일 확실하다. 다만 앞서 말했듯 결과는 하드웨어 스펙에 크게 좌우되니, 자기 환경 스펙에서 돌려봐야 의미가 있다.

운영에 들어갔다면 이런 것들을 봐야 한다. 잠금 경합이 병목인지 확인하는 쿼리:

-- NOTIFY 관련 대기 이벤트 확인
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE wait_event IS NOT NULL
GROUP BY 1, 2
ORDER BY 3 DESC;

여기서 NotifyQueue나 커밋 관련 잠금 대기가 상위에 계속 잡힌다면, 아직 개별 NOTIFY 방식으로 병목에 걸려 있다는 신호다. 배치 버퍼링 전환을 검토할 시점이다.

또 하나, NOTIFY 큐가 얼마나 차 있는지도 봐야 한다. 느린 소비자 하나가 큐를 막을 수 있다는 HN 지적이 있었는데(고정 크기 전역 큐 특성), Postgres는 이 사용률을 함수로 제공한다:

-- 비동기 알림 큐 사용률 (0.0 ~ 1.0)
SELECT pg_notification_queue_usage();
 pg_notification_queue_usage
-----------------------------
                        0.02
(1 row)

이 값이 1.0에 가까워지면 큐가 꽉 차서 NOTIFY 자체가 막힐 수 있다. 느린 리스너가 있는지, LISTEN 걸어놓고 실제로 소비 안 하는 유령 세션이 있는지 점검해야 한다. 정상 운영 환경에서는 이 값이 0에 가깝게 유지되는 게 맞다. 슬금슬금 올라간다면 소비 쪽에 문제가 있는 거다.

정리

한 줄 요약: 기본 LISTEN/NOTIFY는 커밋 순서 보장을 위한 전역 잠금 때문에 초당 2,900건에서 막히지만, 알림을 신호로만 쓰고 배치 버퍼링 + 저빈도 폴링을 병행하면 대형 서버 기준 초당 6만 건까지 뚫린다.

누가 언제 써야 하나. Postgres가 이미 진실의 원천이고, 예상 부하에 10배 여유를 둬도 감당 가능하며, 별도 브로커 운영 부담을 지기 싫은 팀이라면 충분히 좋은 선택지다. 채팅이나 LLM 스트리밍 같은 대화형 실시간 용도에 특히 잘 맞는다. 반대로 메시지가 진실의 원천이거나, 강한 내구성·순서·재처리가 필요하거나, 트래픽 스파이크를 버퍼로 흡수해야 한다면 그건 Kafka나 SQS의 영역이다.

728x90

+ Recent posts