5장. 인프라 신뢰성·비밀 관리·장애 대응

학습 목표

  • 이중 키(Dual-Key / Overlapping Validity) 수용 구조를 통해 JWT 서명 시크릿, 웹훅 시크릿, API 키, TLS 인증서를 서비스 중단 없이 회전(Rotation)하고, 키 유출 시 긴급 교체하는 절차를 수립한다.
  • 정기 스냅샷 백업과 WAL 기반 시점 복구(Point-in-Time Recovery, PITR)를 복구 목표 시점(RPO)과 복구 목표 시간(RTO) 관점에서 비교하고, 정기 복구 리허설로 백업의 유효성을 검증한다.
  • 롤링 배포 환경에서 애플리케이션과 데이터베이스 스키마가 공존하도록 Expand-Contract(병렬 변경, Parallel Change) 3단계 무중단 마이그레이션을 설계하고 파괴적 DDL 잠금을 피한다.
  • OpenTelemetry 기반의 로그·메트릭·분산 트레이스(trace_id)로 엣지부터 DB·LLM까지의 병목을 추적하고, 수집 파이프라인에서 민감 정보와 비밀값을 마스킹(Scrubbing)한다.
  • 클라우드·AI 인프라의 비용 및 사용량 이상 징후 경보(ソフト 한도)와 자동 차단 상한(하드 한도), 서킷 브레이커를 결합해 연쇄 장애와 비용 폭증을 막는다.
  • 장애 발생 시 "원인 분석보다 피해 완화와 격리가 먼저"라는 원칙 아래 기능 플래그·트래픽 롤백 런북을 실행하고 비난 없는 사후 회고(Blameless Postmortem)를 작성한다.

들어가며

인프라 엔지니어들 사이에 전해 내려오는 격언이 있습니다. "모든 시스템은 결국 고장 나고, 모든 비밀키는 언젠가 교체해야 하며, 테스트해 보지 않은 백업은 백업이 아니다."

첫 번째 시험대는 **비밀값 회전(Secret Rotation)**입니다. 보안 정책상 분기마다 세션 서명 시크릿이나 웹훅 검증 키를 바꿔야 하는데, 새 키로 환경 변수를 덮어쓰고 배포했더니 기존에 로그인해 있던 사용자 수만 명이 동시에 로그아웃되거나 배포가 진행되는 3분 동안 절반의 웹훅이 검증 실패(401)로 유실됩니다. 더 급한 상황은 개발자 실수로 API 키가 공개 저장소에 노출되어 10분 안에 키를 폐기해야 할 때입니다.

두 번째 시험대는 데이터베이스 사고와 스키마 변경입니다. 오후 2시 17분에 배포된 배치 스크립트의 버그가 WHERE 절 없이 UPDATE를 실행해 고객 등급 데이터를 모두 덮어썼습니다. 어제 새벽 0시에 찍어 둔 일일 스냅샷으로 되돌리면 오늘 오전부터 오후 2시 16분까지 쌓인 14시간 치 결제와 시험 응시 기록이 날아갑니다. 또한 무중단 배포 중에 컬럼 이름을 바꾸려고 ALTER TABLE RENAME COLUMN을 실행하는 순간, 아직 구버전 코드를 돌리고 있는 서버 인스턴스들이 일제히 column does not exist 오류를 뿜으며 죽어 나갑니다.

이 장에서는 서비스를 단 1초도 멈추지 않으면서 비밀키와 데이터베이스 스키마를 안전하게 바꾸는 기술, 어떤 데이터 손실 사고에서도 원하는 시각(초 단위)으로 되살려내는 PITR 백업 설계, 분산 환경의 블랙박스를 여는 관측성(Observability)과 로그 마스킹, 그리고 새벽 3시에 경보가 울렸을 때 당황하지 않고 5분 안에 출혈을 멈추는 장애 대응 런북을 배웁니다.

핵심 개념

무중단 비밀값·인증서 회전: 이중 키(Dual-Key) 수용 패턴

세션 쿠키 서명 키(SESSION_SECRET), 내부 서비스 간 인증 토큰, 외부 결제 웹훅 서명 키, 데이터베이스 계정 비밀번호를 바꿀 때 왜 서비스 중단이나 인증 실패가 일어날까요?

전통적인 단일 키(Single-Key) 구조에서는 코드에 키가 딱 하나(SECRET_KEY)만 들어 있기 때문입니다.

  1. 오전 10시 00분에 발급된 사용자 세션 쿠키는 구버전 키(Key_V1)로 서명되어 브라우저에 저장되어 있습니다.
  2. 오전 10시 10분에 관리자가 서버의 환경 변수를 새 키(Key_V2)로 바꾸고 롤링 배포를 시작합니다.
  3. 배포가 진행되는 동안 일부 서버 인스턴스는 아직 Key_V1을 쓰고 일부는 Key_V2를 씁니다.
  4. 배포가 끝난 뒤에도 사용자가 들고 오는 Key_V1 서명 쿠키는 Key_V2만 아는 새 서버에서 모두 "위조된 서명"으로 거부되어 전 사용자가 강제 로그아웃됩니다! 외부 웹훅도 발신자 측에서 키를 바꾸는 시점과 수신자(우리 서버)가 재배포되는 시점을 0.001초 단위로 동시에 맞출 수 없으므로 반드시 요청 유실 구간이 생깁니다.

이 문제를 해결하는 표준 아키텍처가 바로 이중 키 수용(Dual-Key Acceptance, 또는 다중 키 슬롯 CURRENT + PREVIOUS) 패턴입니다. 핵심 아이디어는 **"서명(발급)은 항상 최신 키 하나로만 하되, 검증(수용)은 최신 키와 직전 구버전 키의 목록을 모두 허용하는 유예 기간(Grace Period)을 둔다"**는 것입니다.

// 무중단 회전을 지원하는 이중 키(키 링) 검증 구조 예시
// SESSION_SECRETS="v2_new_secret_key,v1_old_secret_key" 형태로 주입
const keyRing = (process.env.SESSION_SECRETS ?? "")
  .split(",")
  .map((k) => k.trim())
  .filter(Boolean);

export function signToken(payload: string): string {
  // 새로 발급하는 토큰은 언제나 첫 번째 키(최신 키, keyRing[0])로만 서명한다.
  return hmacSign(payload, keyRing[0]);
}

export function verifyToken(token: string): boolean {
  // 검증할 때는 최신 키부터 시도하고, 실패하면 직전 키(keyRing[1])로도 검증을 시도한다.
  return keyRing.some((secret) => hmacVerify(token, secret));
}

이 구조를 갖추면 어떤 비밀키든 다음 4단계 무중단 회전 절차로 단 한 건의 실패 없이 교체할 수 있습니다.

단계 수행 작업 서명(발급) 키 검증(허용) 키 목록 사용자·웹훅 영향
1단계 (준비·병행 수용) 새 키(V2)를 생성해 검증 허용 목록에 추가 배포 V1 (또는 V2) [V2, V1] 모두 허용 중단 0건 (V1과 V2 어느 쪽이 와도 모두 통과)
2단계 (발신 전환) 새 토큰 발급 및 외부 웹훅 발신자 설정을 V2로 전환 V2 [V2, V1] 모두 허용 새 요청은 V2로 찍히고, 기존 V1 세션도 유예 기간 동안 정상 통과
3단계 (유예 및 관측) 기존 토큰 만료 시간(TTL) 동안 대기하며 로그에서 V1 매칭 건수가 0으로 줄어드는지 확인 V2 [V2, V1] V1 사용량 0건 수렴 확인
4단계 (구 키 폐기) 검증 목록에서 V1을 제거하고 클라우드/벤더에서 V1을 영구 폐기(Revoke) V2 [V2] 단독 회전 완료 (유출 위험 제거)

데이터베이스 비밀번호를 무중단으로 회전할 때도 원리는 똑같습니다. 같은 권한을 가진 두 개의 DB 사용자(app_user_blue, app_user_green)를 번갈아 쓰는 교대 사용자(Alternating Users) 전략을 쓰면, 현재 앱이 blue 계정으로 운영 중일 때 green 계정의 비밀번호를 새로 바꾼 뒤 앱 연결 문자열을 green으로 배포하고, 모든 이전 연결이 빠진 뒤 blue 계정을 잠글 수 있어 1초의 연결 오류도 발생하지 않습니다.

키 노출 사고 시 정기 회전과 긴급 폐기(Emergency Revocation)의 판단 기준

그렇다면 만약 개발자가 실수로 .env 파일을 공개 GitHub 저장소에 푸시했거나 공격자에게 키가 탈취된 것이 확인된 **비상 상황(Active Compromise)**에서도 위처럼 느긋하게 24시간 유예 기간을 두고 회전해야 할까요?

상황에 따라 판단이 달라집니다.

  1. 우리가 직접 발급자·소비자를 통제하는 외부 API 키(예: OpenAI API 키, AWS IAM 키, Supabase service_role 키):
    • 먼저 새 키(V2)를 즉시 발급받아 우리 프로덕션 환경 변수에 배포해 정상 서비스 트래픽을 1~2분 안에 V2로 옮긴 뒤, **유예 기간 없이 즉시 구 키(V1)를 콘솔에서 영구 폐기(Revoke/Delete)**합니다. 만약 공격자가 지금 이 순간에도 유출된 V1 키로 데이터를 빼내고 있거나 수백만 원의 API 요금을 발생시키고 있다면, 새 키 배포보다 구 키의 즉각적인 폐기·차단이 우선입니다(일시적인 에러가 데이터 전체 유출보다 훨씬 낫기 때문).
  2. 사용자 세션 서명 키(SESSION_SECRET)가 유출된 경우:
    • 공격자가 임의의 관리자 세션 쿠키를 스스로 위조해 만들어낼 수 있는 상태이므로, V1을 검증 목록에 남겨 두면 공격자의 위조 쿠키도 계속 통과합니다. 따라서 이 경우에는 정기 회전과 달리 V1을 즉시 검증 목록에서 제거해 모든 기존 세션을 강제 만료시키고 사용자가 다시 로그인하게 만들어야 안전합니다.

백업 전략과 시점 복구(PITR): RPO·RTO 설계와 복구 리허설

인프라 신뢰성을 평가할 때 가장 먼저 정의하는 두 가지 시간 지표가 있습니다.

  • RPO (Recovery Point Objective, 복구 목표 시점): "최대 얼마만큼의 과거 데이터 유실까지 감내할 수 있는가?"(시간의 양). 예를 들어 RPO가 24시간이면 어제 백업 이후 오늘 쌓인 24시간 치 데이터가 날아가도 된다는 뜻이고, RPO가 1분이면 사고 직전 1분 이내의 상태까지 데이터를 살려내야 한다는 뜻입니다.
  • RTO (Recovery Time Objective, 복구 목표 시간): "장애가 발생한 순간부터 서비스를 다시 정상 가동하기까지 최대 몇 분/몇 시간 안에 끝내야 하는가?"(복구 소요 시간).

데이터베이스 백업 방식은 크게 **정기 스냅샷(Periodic Snapshot / Dump)**과 **시점 복구(Point-in-Time Recovery, PITR)**로 나뉘며, 이 둘이 보장하는 RPO는 하늘과 땅 차이입니다.

구분 일일 정기 스냅샷 (Daily Snapshot) 시점 복구 (Point-in-Time Recovery, PITR)
작동 원리 하루 한 번(예: 새벽 03:00) 디스크 전체 또는 논리 덤프를 찍어 보관 기초 물리 백업(Base Backup) 위에 변경이 일어날 때마다 미리 쓰기 로그(WAL, Write-Ahead Log)를 지속 아카이빙
달성 가능한 RPO 최대 24시간 데이터 유실 (새벽 3시 백업 후 오후 2시에 사고가 나면 11시간 치 증발) 수초~수분 이내 (초 단위 시각 지정 가능)
치명적 실수(실수로 DROP/DELETE 실행) 복구 그날 하루 동안 일어난 모든 정상 거래까지 포기해야 함 실수 쿼리가 실행된 14:17:05 바로 직전인 14:17:04 시점까지 WAL을 재생해 데이터 손실 없이 복원 가능

따라서 결제, 자격증 발급, 고객 문서처럼 데이터 유실을 허용할 수 없는 프로덕션 데이터베이스에는 반드시 **PITR(지속적 WAL 아카이빙)**을 켜야 합니다.

하지만 백업을 켜 두는 것만으로는 절반밖에 한 것이 아닙니다. 실무에서는 막상 장애가 터져 백업을 복원하려 했을 때 (1) 백업 파일이 손상되어 있거나, (2) 암호화 키(KMS) 권한이 없거나, (3) 복원하는 데만 8시간이 걸려 RTO를 훌쩍 넘기거나, (4) 데이터베이스는 복원했는데 Storage 버킷의 파일들과 정합성이 안 맞는 일이 부지기수로 일어납니다. 그래서 분기마다 **운영과 격리된 새 인스턴스에 실제로 PITR 백업을 복원해 무결성 쿼리를 돌려보고 소요 시간(실제 RTO)을 측정하는 정기 복구 리허설(Restore Drill)**을 의무화해야 합니다.

무중단 스키마 마이그레이션: Expand-Contract(병렬 변경) 패턴

클라우드 서비스는 배포할 때도 멈추지 않고 돌아갑니다(롤링 배포 또는 카나리 배포). 즉 새 버전의 애플리케이션 코드(App v2)가 뜨는 수분 동안에도 기존 버전의 애플리케이션 코드(App v1)가 동시에 살아 있어 같은 데이터베이스를 함께 읽고 씁니다. 또한 만약 App v2에 버그가 발견되어 10분 뒤 다시 App v1으로 즉시 롤백해야 할 수도 있습니다.

이 사실을 잊고 데이터베이스 컬럼 이름을 full_name에서 display_name으로 바꾸겠다며 다음과 같은 마이그레이션을 한 번에 실행하면 어떻게 될까요?

-- 절대 금지! (즉시 서비스 장애와 롤백 불능을 부르는 파괴적 단일 단계 마이그레이션)
ALTER TABLE users RENAME COLUMN full_name TO display_name;

이 SQL이 실행되는 순간 아직 살아 있는 App v1 인스턴스들이 SELECT full_name FROM users를 날릴 때마다 전부 500 Internal Server Error를 내며 쓰러집니다! 설령 App v2로 다 넘어갔다 해도, App v2에서 다른 버그가 나와 롤백 버튼을 누르면 App v1은 이미 바뀐 스키마와 호환되지 않아 롤백조차 불가능한 외통수에 갇힙니다.

이를 해결하는 유일한 정석 기법이 바로 Expand-Contract(확장-수축, 또는 병렬 변경 Parallel Change) 패턴입니다. 파괴적 변경을 한 번에 하지 않고 하위 호환성(Backward Compatibility)을 지키는 3~4단계의 점진적 배포로 쪼개어 진행합니다.

단계 데이터베이스 변경 애플리케이션 배포 롤백 가능 여부 및 안전성
1단계: 확장 (Expand) 기존 full_name은 그대로 두고, 새 컬럼 display_name을 NULL 허용(Nullable)으로 추가 (ADD COLUMN display_name text) App v1이 그대로 돌아감 (full_name만 읽고 씀) full_name이 그대로 있으므로 App v1 100% 정상 동작
2단계: 이중 쓰기 및 백필 (Dual-Write & Backfill) 과거 행들의 full_name 값을 배치(소규모 청크 단위)로 display_name에 복사(Backfill) App v2 배포: 데이터를 쓸 때는 full_name과 display_name **양쪽에 모두 기록(Dual-Write)**하고, 읽을 때는 display_name(없으면 full_name)을 읽음 언제든 App v1으로 즉시 롤백해도 full_name에 최신 데이터가 계속 쌓여 있어 데이터 유실 0건!
3단계: 전환 완료 (Cutover) 모든 행이 채워졌는지 확인 후 제약 조건 검증 (NOT NULL VALIDATE) App v3 배포: 이제 코드에서 full_name 참조를 완전히 지우고 display_name만 읽고 씀 모든 인스턴스가 더 이상 구 컬럼을 참조하지 않음
4단계: 수축 (Contract) 모든 App v3 안정화 확인 후, 비로소 구 컬럼 full_name을 삭제 (DROP COLUMN full_name) App v3 정상 운영 무중단 스키마 변경 완수

PostgreSQL에서 대용량 테이블에 DDL을 실행할 때는 **테이블 잠금(Lock)**도 함께 조심해야 합니다.

  • 일반 CREATE INDEX는 인덱스를 다 만들 때까지 테이블의 쓰기(INSERT/UPDATE/DELETE)를 통째로 막으므로, 운영 중에는 반드시 쓰기를 막지 않는 **CREATE INDEX CONCURRENTLY**를 사용해야 합니다(단, CONCURRENTLY는 트랜잭션 블록 BEGIN ... COMMIT 안에서는 실행할 수 없으므로 마이그레이션 도구에서 트랜잭션 래핑을 끄고 실행).
  • 새 외래 키나 체크 제약 조건을 추가할 때도 테이블 전체를 스캔하며 잠그는 대신 ADD CONSTRAINT ... NOT VALID(기존 행 검사 생략, 즉시 완료)를 먼저 실행한 뒤 VALIDATE CONSTRAINT(쓰기를 막지 않는 약한 잠금으로 백그라운드 검증)로 나누어 실행하고, 모든 마이그레이션 세션에는 SET lock_timeout = '3s'를 걸어 만약 잠금 대기가 길어지면 서비스 전체가 멈추는(Lock Queue Pile-up) 대신 마이그레이션이 먼저 안전하게 실패하도록 설계합니다.

관측성(Observability)의 세 기둥과 OpenTelemetry 분산 추적

장애가 터졌을 때 "어딘가 느린 것 같아요"와 "서울 리전 함수에서 시작된 trace_id=4bf92f... 요청이 Supavisor를 거쳐 DB 쿼리는 4ms에 끝났으나, 외부 LLM API 스팬에서 28.4초 동안 멈춰 있었다"는 하늘과 땅 차이입니다. 시스템 내부 상태를 밖에서 파악하는 능력인 **관측성(Observability)**은 세 기둥으로 이루어집니다.

관측성의 세 기둥 답하는 질문 인프라·AI 운영에서의 대표 지표
메트릭 (Metrics) "지금 증상이 있는가? 얼마나 나쁜가?" (숫자 시계열 집계) 요청률(RPS), 오류율(5xx/429 비율), 지연 시간 백분위수(p50, p95, p99), DB 활성 연결 수, 시간당 토큰 소비량
로그 (Logs) "그 순간 무슨 사건이 일어났는가?" (구조화된 개별 이벤트 JSON) 요청 경로, 상태 코드, 에러 스택, 폴백 발동 사유, 감사 이벤트 (반드시 검색 가능한 JSON 구조화 로그 사용)
트레이스 (Traces) "요청 하나가 어디서 시간을 다 썼는가?" (구간별 스팬 Span 연결) W3C traceparent / OpenTelemetry trace_id로 묶인 [엣지 → 앱 함수 → DB 쿼리 → AI Gateway → LLM 호출] 워터폴

여기서 평균 지연 시간(Average Latency)만 보는 것은 매우 위험합니다. 99번의 캐시 적중 요청이 10ms에 끝나고 1번의 AI 추론 요청이 20,000ms(20초)가 걸렸다면, 평균은 210ms로 아주 훌륭해 보이지만 실제 사용자는 심각한 지연을 겪고 있기 때문입니다. 따라서 인프라 SLO(서비스 수준 목표)와 경보는 반드시 꼬리 지연 시간(Tail Latency)인 p95, p99를 기준으로 잡아야 합니다.

로그 스크러빙(Log Scrubbing)과 민감 정보 마스킹

관측성 파이프라인을 구축할 때 절대 어겨서는 안 되는 보안 철칙이 바로 **로그 스크러빙(Log Scrubbing / Redaction, 민감 정보 가림)**입니다. 디버깅을 편하게 하겠다며 HTTP 요청의 전체 헤더(headers)나 요청 본문(body), 환경 변수를 그대로 console.log나 OpenTelemetry 스팬 속성에 덤프하면, 다음 값들이 외부 로그 수집 서비스(Datadog, Sentry, CloudWatch 등)에 평문으로 영구 저장됩니다.

  • Authorization: Bearer ... 헤더 및 세션 쿠키(Cookie, Set-Cookie)
  • API 키(sk-...), 데이터베이스 연결 문자열(postgresql://user:password@...)
  • 사용자의 비밀번호, 주민등록번호, 신용카드 번호, 개인 민감 프롬프트 원문

따라서 애플리케이션 로거와 OpenTelemetry Collector 단계에서 password, secret, token, authorization, cookie, api_key 키와 패턴을 자동으로 [REDACTED]로 치환하는 스크러버(Scrubber)를 강제하고, 사용자 식별이 필요할 때는 이메일 원문 대신 비가역 해시 식별자와 trace_id를 남겨야 합니다.

비용·사용량 이상 징후 경보와 서킷 브레이커(Circuit Breaker)

클라우드와 AI API는 쓴 만큼 돈을 내는 종량제(Pay-as-you-go)입니다. 전통적인 물리 서버는 트래픽이 몰리면 서버가 다운되고 끝났지만, 오토스케일링이 켜진 클라우드 함수와 LLM API는 코드에 무한 재시도 버그가 있거나 지갑 고갈 공격(Denial of Wallet)이 들어오면 하루 밤사이에 수천만 원의 요금 폭탄으로 돌아옵니다.

따라서 비용과 자원 통제는 **알림(Soft Limit)**과 **강제 차단(Hard Limit)**의 이중 잠금장치로 설계해야 합니다.

  1. 이상 징후 경보 (Anomaly Detection & Budget Alerts): 일일 예상 비용의 50%, 80%, 100% 임계치뿐만 아니라, 평소 시간당 평균 소비 속도의 3배 이상 급증할 때 즉시 온콜(On-call) 채널로 경보를 보냅니다.
  2. 예산 상한과 속도 제한 (Hard Caps & Quotas): 새벽 3시에는 아무도 이메일 경보를 보지 못할 수 있습니다. 따라서 프로바이더·AI Gateway·애플리케이션 수준에서 사용자당/조직당 일일 토큰 상한과 동시 실행 상한을 코드로 강제해, 한도를 넘으면 자동으로 요청을 거절(429)하거나 저비용 경량 모델로 강등(Graceful Degradation)시킵니다.
  3. 지수 백오프(Exponential Backoff with Jitter)와 서킷 브레이커(Circuit Breaker): 하위 DB나 외부 LLM API가 과부하로 실패하고 있는데 모든 서버 인스턴스가 0.1초마다 일제히 재시도를 폭격하면(Thundering Herd / Retry Storm), 잠깐 흔들리던 시스템이 완전히 무너집니다. 재시도는 반드시 대기 시간을 1초 → 2초 → 4초로 늘리고 무작위 지터(Jitter)를 섞어 분산시키며, 연속 실패율이 임계치를 넘으면 서킷 브레이커를 열어(Open) 일정 시간 동안 하위 호출을 즉시 차단하고 대체 응답(Fallback)을 반환해 하위 시스템이 숨을 돌리고 복구될 시간을 주어야 합니다.

장애 격리·롤백 런북(Runbook)과 비난 없는 사후 회고(Blameless Postmortem)

실제 프로덕션 장애가 발생했을 때 가장 치명적인 실수는 "도대체 왜 이 버그가 났는지 코드를 한 줄씩 뜯어보며 원인을 다 밝힌 뒤에 고쳐서 재배포하겠다"며 1시간 동안 장애 상태를 방치하는 것입니다!

사고 대응의 제1원칙은 언제나 **"완화 우선(Mitigate First, Investigate Later) — 출혈부터 멈춘다"**입니다.

표준 장애 대응 런북은 다음 5단계 순서로 움직입니다.

  1. 탐지 및 심각도 분류 (Detect & Triage): SLO 위반 경보(예: 5분간 5xx 오류율 5% 초과)가 울리면 즉시 사고 지휘관(Incident Commander)을 지정하고 영향 범위(전체 중단 P1 vs 일부 기능 지연 P2)를 판별합니다.
  2. 즉각적인 격리 및 완화 (Isolate & Mitigate — 5분 이내 목표):
    • 방금 배포한 코드가 원인일 가능성이 조금이라도 있다면, 코드를 고쳐 새로 빌드하려 하지 말고 **즉시 직전 정상 배포본으로 롤백(Vercel Instant Rollback 등)**합니다.
    • 특정 신기능이나 외부 연동이 원인이라면 **기능 플래그(Feature Flag / Kill Switch)**를 꺼서 문제 기능만 격리하고 핵심 서비스는 살려 둡니다.
    • 외부 LLM 프로바이더 장애라면 AI Gateway 폴백을 강제 활성화하고, 공격 트래픽이라면 WAF 차단 규칙이나 Rate Limit을 올립니다.
  3. 상태 소통 (Communicate): 사내 이해관계자와 고객 상태 페이지(Status Page)에 현재 증상과 완화 조치 현황을 투명하게 공유합니다.
  4. 근본 원인 분석 및 검증 후 복구 (Resolve & Verify): 출혈을 멈춘 뒤 로그와 트레이스(trace_id), 메트릭을 분석해 근본 원인을 고치고 회귀 테스트를 통과한 뒤 정상 모드로 복구합니다.
  5. 비난 없는 사후 회고 (Blameless Postmortem): 장애가 끝난 뒤 "누가 실수했는가(사람 탓)"를 추궁하면 다음부터 엔지니어들은 장애와 실수를 숨기게 됩니다. 대신 **"어떤 시스템적·구조적 허점이 그 실수가 프로덕션 장애까지 이어지도록 허용했는가?"**를 묻고, 시간대별 타임라인, 5 Whys 근본 원인, 그리고 구체적인 담당자와 기한이 적힌 **재발 방지 액션 아이템(예: CI에 파괴적 마이그레이션 린터 추가, 서킷 브레이커 도입)**을 남겨 시스템을 한 단계 더 단단하게 만듭니다.

깊이 읽기

대규모 PostgreSQL 운영에서 무잠금(Zero-Lock) DDL 체크리스트

5장에서 배운 Expand-Contract 패턴을 실제로 수행할 때도, PostgreSQL의 내부 잠금 메커니즘을 모르면 "단순히 컬럼을 추가하거나 제약 조건을 거는 줄 알았는데 사이트가 3분 동안 멈추는" 사고를 겪을 수 있습니다.

왜 그럴까요? PostgreSQL에서 ALTER TABLE은 명령의 종류에 따라 ACCESS EXCLUSIVE 잠금(읽기인 SELECT까지 포함해 해당 테이블의 모든 쿼리를 차단하는 최고 수준 잠금)을 요구하기 때문입니다.

비록 ALTER TABLE ADD COLUMN new_col text; 자체는 메타데이터만 바꾸므로 1밀리초 만에 끝나지만, 만약 그 순간 해당 테이블에 50초짜리 느린 분석 쿼리가 이미 돌고 있었다면 어떻게 될까요?

  1. 느린 쿼리가 테이블에 약한 읽기 잠금(ACCESS SHARE)을 쥐고 있습니다.
  2. 우리가 실행한 ALTER TABLE은 ACCESS EXCLUSIVE 잠금을 얻기 위해 느린 쿼리가 끝날 때까지 대기열(Lock Queue)에 줄을 섭니다.
  3. 진짜 참사는 여기서 일어납니다! 그 뒤로 들어오는 수천 개의 일반 사용자 SELECT와 INSERT 쿼리들은 원래 느린 쿼리와는 같이 돌 수 있었지만, 앞줄에 서서 ACCESS EXCLUSIVE를 기다리고 있는 ALTER TABLE 뒤로 전부 줄을 서며 블로킹됩니다! 결과적으로 1밀리초짜리 ALTER TABLE 하나가 느린 쿼리 뒤에 줄을 서는 바람에 50초 동안 전체 서비스의 모든 조회·쓰기가 멈춰 버립니다.

따라서 프로덕션 PostgreSQL 마이그레이션 스크립트는 반드시 다음 안전 수칙을 코드로 내장해야 합니다.

-- 1) 잠금 대기 시간을 짧게 제한해, 만약 3초 안에 잠금을 못 얻으면
-- 서비스 쿼리를 막지 않고 마이그레이션 자신이 즉시 실패 후 재시도하게 한다.
SET lock_timeout = '3s';
SET statement_timeout = '30s';

-- 2) 새 외래 키나 체크 제약 조건은 먼저 NOT VALID로 즉시(메타데이터만) 걸고,
ALTER TABLE orders
  ADD CONSTRAINT orders_amount_positive CHECK (amount >= 0) NOT VALID;

-- 3) 이후 별도 단계에서 VALIDATE CONSTRAINT로 읽기/쓰기를 막지 않고 검증한다.
ALTER TABLE orders
  VALIDATE CONSTRAINT orders_amount_positive;

멀티 리전·멀티 벤더 AI 서비스의 계층별 헬스 체크와 우아한 성능 저하(Graceful Degradation)

외부 의존성이 많은 AI 서비스에서 "부가 기능 하나의 장애가 전체 서비스 장애로 번지지 않게(Bulkhead, 격벽 패턴)" 설계하는 것은 신뢰성 엔지니어링의 핵심입니다.

예를 들어 사용자가 자격증 시험을 치르는 핵심 경로와, 시험이 끝난 뒤 AI가 오답 학습 가이드를 생성해 주는 부가 경로가 한 서버에 있다고 해 봅시다. 만약 외부 LLM API가 전면 중단되었을 때, 서버의 전체 헬스 체크(/api/health)가 LLM API 생존 여부까지 묶어서 503을 반환하도록 짜여 있다면 어떻게 될까요? 로드밸런서는 "우리 서버가 죽었다!"고 판단해 시험 응시와 로그인, 성적표 조회까지 아우르는 사이트 전체를 내려 버립니다.

따라서 의존성은 반드시 **핵심 경로(Critical Path / Hard Dependency)**와 **비핵심 경로(Non-critical Path / Soft Dependency)**로 분리해야 합니다.

  • 로드밸런서와 DNS가 참조하는 **생존 헬스 체크(Liveness / Readiness Check)**는 오직 애플리케이션 프로세스와 주 데이터베이스처럼 그 서버가 정상 트래픽을 받을 수 있는 필수 조건만 검사합니다.
  • 외부 LLM API, 추천 엔진, 이메일 발송기 같은 비핵심·외부 의존성에 장애가 감지되면 서버를 죽이는 대신 우아한 성능 저하(Graceful Degradation) 모드로 전환합니다. 즉 핵심 시험 응시와 채점은 100% 정상 제공하고, AI 해설 영역만 "현재 AI 해설 생성이 지연되고 있어 잠시 후 알림으로 보내 드립니다(Queues에 적재)"라는 안내로 대체해 사용자 경험을 지킵니다.

실무 장면

온빛페이: 단 한 명의 로그아웃도 없이 완수한 서명 키 회전과 유출 API 키 긴급 폐기

AI 기반 기업 경비 정산 서비스를 운영하는 '온빛페이'는 보안 감사 준비 중 두 가지 과제를 동시에 마주했다. 하나는 1년 동안 한 번도 바꾸지 않은 사용자 세션 JWT 서명 시크릿(AUTH_SECRET)과 카드사 웹훅 검증 키를 무중단으로 교체하는 정기 회전 작업이었고, 다른 하나는 그날 오후 외주 개발자가 테스트용 스크립트를 개인 공개 GitHub Gist에 올리면서 프로덕션 영수증 OCR용 외부 AI API 키가 평문으로 노출된 긴급 보안 사고였다.

플랫폼 보안 리드는 두 사건의 성격이 전혀 다름을 파악하고 서로 다른 두 절차를 동시에 지휘했다.

  1. 노출된 외부 AI API 키 → 긴급 폐기(Emergency Revocation) 절차 (소요 시간 4분):
    • 이미 공개 인터넷에 노출된 키는 1분 1초가 급하므로 유예 기간을 두지 않았다. 즉시 벤더 콘솔에서 새 API 키(Key_New)를 발급받아 Vercel Production 환경 변수에 주입·재배포(2분 소요)해 정상 OCR 요청이 새 키로 나가게 한 직후, 노출된 구 키(Key_Old)를 벤더 콘솔에서 **즉시 영구 삭제(Revoke)**했다. 이어서 노출 시점부터 폐기 시점까지 18분간의 AI Gateway 로그를 전수 조사해 외부 IP에서 호출된 비정상 요청이 없었음을 확인했다.
  2. 세션 서명 시크릿 및 웹훅 키 → 이중 키(Dual-Key) 무중단 회전 절차:
    • 현재 접속 중인 경리 담당자 3,000명의 세션을 끊거나 실시간 법인카드 승인 웹훅을 단 1건이라도 놓치면 안 되므로 4단계 이중 키 절차를 밟았다.
    • 먼저 검증 코드가 [신규 키, 기존 키] 배열을 순서대로 모두 허용하도록 배포(1단계)한 뒤, 토큰 서명과 카드사 웹훅 발신 설정을 신규 키로 전환(2단계)했다. 세션 만료 주기인 24시간 동안 기존 키로 검증된 요청 수를 메트릭으로 모니터링해 0건으로 수렴한 것을 확인(3단계)한 다음, 마지막으로 환경 변수에서 기존 키를 완전히 제거(4단계)했다. 단 한 명의 강제 로그아웃도, 단 한 건의 웹훅 유실도 없이 키 회전이 끝났다.

해담클리닉: 잘못된 배포로 덮어쓴 예약 데이터를 3분 전으로 되돌린 PITR과 Expand-Contract

비대면 진료 예약을 돕는 '해담클리닉'에서는 화요일 오후 3시 12분, 예약 테이블의 doctor_name 컬럼을 practitioner_id로 바꾸고 상태값을 정리하는 배포가 진행되었다. 그런데 개발자가 두 가지 치명적인 실수를 저질렀다. 첫째, 구버전 앱 인스턴스가 아직 떠 있는 상태에서 ALTER TABLE appointments DROP COLUMN doctor_name을 바로 실행해 버려 구버전 서버들이 일제히 500 오류를 뿜었다. 둘째, 데이터 정리 마이그레이션 SQL에 WHERE status = 'expired' 조건이 빠져 오후 3시 12분 10초에 실행된 쿼리가 전체 미래 예약 14,000건의 상태를 모두 'cancelled'(취소됨)로 덮어써 버렸다!

경보가 울리자 사고 지휘관(IC)은 런북에 따라 즉시 움직였다.

시각 단계 실행 조치 및 근거
15:13 격리 및 출혈 중단 추가 데이터 오염을 막기 위해 예약 변경 기능 플래그(Kill Switch)를 즉시 '점검 안내(읽기 전용)'로 전환하고, 애플리케이션 배포를 직전 정상 버전으로 즉시 롤백
15:15 PITR 시점 복원 아침 일일 스냅샷(04:00)으로 돌리면 오늘 오전~오후 예약 11시간 치가 증발함. Supabase PITR(WAL 아카이빙)을 사용해 사고 쿼리 실행 5초 전인 15:12:05 시점으로 정밀 복원 실행
15:24 정합성 검증 및 재개 복원된 데이터에서 14,000건의 예약 상태와 오전 신규 예약이 단 1건의 유실도 없이 온전함을 확인하고 서비스를 정상 재개 (실제 RTO 12분, RPO 수초 달성)
익일 Expand-Contract 재설계 및 회고 비난 없는 사후 회고를 열어 (1) CI에서 DROP COLUMN/RENAME COLUMN이 앱 코드 제거보다 먼저 실행되면 빌드를 차단하는 규칙 추가, (2) 스키마 변경을 Expand(컬럼 추가) → Dual-Write & Backfill → Cutover → Contract(구 컬럼 삭제) 4단계로 분리해 무중단 재배포 완료

자주 틀리는 지점

  1. "비밀키를 회전할 때는 대시보드에서 기존 환경 변수 값을 새 키로 바로 덮어쓰고 재배포하면 무중단으로 끝난다." 단일 키를 바로 덮어쓰면 배포 도중 인스턴스 간 불일치가 생기고, 기존 키로 서명된 활성 세션이나 전환 직전의 외부 웹훅이 모두 실패합니다. 반드시 [신규 키, 기존 키]를 동시에 검증 허용하는 이중 키(Dual-Key) 유예 단계를 거쳐야 합니다.
  2. "매일 새벽에 전체 데이터베이스 스냅샷 백업을 찍고 있으니 오후에 실수로 데이터를 날려도 아무 손실 없이 복구할 수 있다." 일일 스냅샷만 있으면 새벽 백업 시각부터 오후 사고 시각까지 쌓인 반나절 치 데이터는 복구할 수 없습니다(RPO 최대 24시간). 초·분 단위 시점 복원을 위해서는 WAL 기반의 **시점 복구(PITR)**가 켜져 있어야 합니다.
  3. "무중단 롤링 배포가 지원되는 플랫폼이니 데이터베이스 컬럼 이름도 ALTER TABLE RENAME COLUMN으로 한 번에 바꾸면 된다." 롤링 배포 중에는 구버전 코드와 신버전 코드가 동시에 같은 데이터베이스를 바라봅니다. 컬럼 이름을 바로 바꾸거나 삭제하면 구버전 인스턴스가 즉시 오류를 내고 롤백도 불가능해지므로 반드시 Expand-Contract(병렬 변경) 단계를 밟아야 합니다.
  4. "디버깅을 빠르게 하려면 모든 요청의 headers와 body 원문을 그대로 중앙 로그 서버에 남기는 것이 좋다." 요청 헤더와 본문에는 Authorization 토큰, 세션 쿠키, 비밀번호, API 키, 개인정보가 들어 있어 로그 저장소가 곧 제2의 유출 경로가 됩니다. 수집 전에 민감 필드를 자동으로 마스킹(Scrubbing)하고 trace_id로 추적해야 합니다.
  5. "서비스 평균 응답 시간(Average Latency)이 200ms이니 모든 사용자가 쾌적하게 쓰고 있다." 빠른 정적/캐시 응답이 대다수일 때 평균값은 소수의 극심한 지연(20초 이상 걸리는 AI/DB 병목)을 가려 버립니다. 신뢰성 지표는 반드시 **p95, p99 백분위수(Tail Latency)**로 봐야 합니다.
  6. "프로덕션 장애가 나면 성급하게 롤백하지 말고 먼저 로그를 분석해 왜 버그가 났는지 원인을 완전히 찾은 뒤 수정 패치를 배포해야 한다." 원인 분석에 1시간을 쓰는 동안 고객 피해는 눈덩이처럼 불어납니다. 장애 대응의 철칙은 선(先) 완화·격리(즉시 롤백, 킬 스위치, 폴백), 후(後) 원인 분석입니다.

한눈에 정리

핵심 주제 시험과 실무에서 기억할 핵심 문장
이중 키(Dual-Key) 회전 서명·발급은 최신 키(V2)로 하되, 검증은 [V2, V1]을 모두 허용하는 유예 기간을 두어 무중단으로 회전한다
긴급 키 폐기 키가 외부에 노출된 비상 사고 시에는 새 키 배포 후 유예 기간 없이 노출된 구 키를 즉시 영구 폐기(Revoke)하고 감사 로그를 조사한다
RPO·RTO와 PITR RPO는 허용 가능한 데이터 유실 시간, RTO는 목표 복구 소요 시간이다. 초 단위 복원과 실수 쿼리 되돌리기에는 WAL 기반 PITR과 정기 복구 리허설이 필수다
Expand-Contract 마이그레이션 1) 확장(Nullable 추가) → 2) 이중 쓰기·백필 → 3) 읽기 전환 → 4) 수축(구 컬럼 삭제) 순서로 하위 호환성과 롤백 가능성을 지킨다
무잠금 DDL 대용량 인덱스는 CREATE INDEX CONCURRENTLY, 제약 조건은 NOT VALID 후 VALIDATE, 세션에는 짧은 lock_timeout을 건다
관측성과 로그 스크러빙 메트릭(p95/p99), 구조화 로그, 분산 트레이스(trace_id)를 잇고, 토큰·키·쿠키·비밀번호·개인정보는 수집 전 마스킹(Redaction)한다
비용 상한과 서킷 브레이커 경보(Soft Limit)와 코드 차단 상한(Hard Limit)을 함께 두고, 지수 백오프+Jitter 및 서킷 브레이커로 재시도 폭풍과 지갑 고갈을 막는다
장애 대응과 사후 회고 완화 우선(즉시 롤백·킬 스위치) → 원인 분석·검증 → 비난 없는 사후 회고(시스템적 재발 방지 액션 아이템 도출) 순서를 지킨다

공식 문서로 확인하기

셀프 체크

5-1. 웹훅 서명 검증 시크릿이나 세션 서명 키를 서비스 중단과 요청 유실 없이 교체하기 위한 '이중 키(Dual-Key) 수용 패턴'의 4단계를 순서대로 설명하고, 반대로 외부 API 키가 공개 GitHub에 유출되었을 때는 왜 유예 기간 없이 즉시 폐기해야 하는지 비교하라.

5-2. 복구 목표 시점(RPO)과 복구 목표 시간(RTO)의 뜻을 각각 정의하고, 매일 새벽 4시에만 스냅샷을 찍는 데이터베이스에서 오후 2시에 실수로 DELETE 쿼리를 실행했을 때 왜 스냅샷만으로는 데이터 유실 없이 복구할 수 없는지 설명한 뒤 이를 해결하는 백업 방식을 쓰라.

5-3. 무중단 롤링 배포 환경에서 운영 중인 테이블의 컬럼을 변경할 때 ALTER TABLE RENAME COLUMN을 바로 실행하면 왜 장애가 나고 롤백까지 막히는지 설명하고, 이를 해결하는 Expand-Contract(병렬 변경)의 단계를 순서대로 쓰라.

5-4. 관측성(Observability)에서 평균 지연 시간(Average) 대신 p95·p99 백분위수 지표를 봐야 하는 이유를 설명하고, 분산 트레이스(trace_id)와 구조화 로그를 수집할 때 반드시 제거하거나 가려야(Scrubbing) 할 민감 항목을 세 가지 이상 쓰라.

5-5. 하위 데이터베이스나 외부 LLM API가 일시적 과부하에 빠졌을 때 모든 서버가 짧은 주기로 즉시 재시도를 반복하면 어떤 현상(Retry Storm)이 벌어지는지 설명하고, 이를 완화하기 위한 재시도 방식과 차단 패턴 두 가지를 쓰라.

5-6. 설계 과제: 신규 배포 직후 결제 API의 500 오류율이 15%로 치솟는 P1 장애 경보가 새벽 2시에 울렸다. 당직 엔지니어가 "코드를 디버깅해서 수정 패치를 새로 짜겠다"고 할 때 사고 지휘관이 왜 이를 말려야 하는지 설명하고, 처음 10분 안에 취해야 할 표준 완화 조치와 장애 종료 후 작성할 회고(Postmortem)의 원칙을 서술하라.

실습 과제

실습

Expand-Contract 무중단 스키마 마이그레이션 및 롤백 계획서 작성하기 (50분)

  1. 현재 수백만 건의 데이터가 들어 있는 users 테이블에서 단일 문자열 컬럼인 phone을 국가 코드가 분리된 phone_country와 phone_number 두 컬럼으로 무중단 분리하려 한다.
  2. 1단계(Expand), 2단계(Dual-Write & 청크 단위 Backfill), 3단계(읽기 전환 및 NOT VALID → VALIDATE 제약 조건), 4단계(Contract)로 나눈 배포 순서표와 각 단계의 SQL(SET lock_timeout = '3s' 포함)을 작성한다.
  3. 만약 2단계 배포 직후 애플리케이션 버그가 발견되어 이전 버전 코드로 즉시 롤백했을 때, 왜 데이터 유실이 발생하지 않는지 두 문장으로 증명한다.

완료 기준: 4단계 중 어느 시점에서도 구버전 앱과 신버전 앱이 동시에 작동할 수 있고, 테이블 전체를 오래 잠그는 DDL이 없으며, 2단계에서의 무손실 롤백 근거가 명확하다.

실습

이중 키 회전 코드와 P1 장애 대응 런북 작성하기 (45분)

  1. 환경 변수 WEBHOOK_SECRETS="v2_secret,v1_secret"을 읽어 HMAC-SHA256 서명을 생성(항상 첫 번째 키 사용)하고 검증(목록 내 키 순회 허용 및 어느 키 슬롯이 매칭되었는지 구조화 로그에 기록하되 실제 키 값은 절대 로그에 남기지 않음)하는 함수를 작성한다.
  2. 다음 두 가지 사고 시나리오에 대해 5분 이내 완화 조치, 확인해야 할 대시보드 지표(trace_id, p99, 비용 상한), 그리고 재발 방지 대책이 담긴 1페이지 런북을 표로 작성한다:
    • 시나리오 A: 외부 LLM API의 연속 타임아웃으로 서버리스 함수 동시 실행 수가 급증할 때
    • 시나리오 B: 잘못된 데이터베이스 마이그레이션 배포로 API 오류율이 치솟을 때

완료 기준: 키 검증 코드에 평문 시크릿 로깅이 전혀 없고 구버전 키 사용량을 추적할 수 있으며, 두 런북 모두 '선 완화(폴백·롤백·서킷 브레이커) 후 원인 분석' 순서를 따르고 있다.