Post

외부 연동 장애 포스트모템 - 66시간 장애에서 배운 것

외부 연동 장애 포스트모템 - 66시간 장애에서 배운 것

사건 개요

항목내용
장애 시간금요일 18:30 ~ 월요일 12:53
지속 시간약 66시간
영향 범위외부 제휴사 대출 사전승인 요청 전체 실패
근본 원인외부 데이터 포맷 변경(v2)으로 인한 Serializer 불일치

시스템 구성

제휴사와의 대출 사전승인 연동은 Lambda를 프록시로 두고, 내부 심사 시스템이 처리하는 구조였다.

flowchart LR
    A[제휴사] -->|사전승인 요청| B[Lambda]
    B --> C[내부 심사 시스템]
    C -->|결과| D[Lambda]
    D -->|콜백| A
    style C fill:#ff6b6b,stroke:#333,color:#fff

문제는 내부 심사 시스템(빨간색)에서 발생했다.


무슨 일이 있었나

근본 원인: Serializer 타입 불일치

건강보험 데이터를 제공하는 외부 API가 v2로 업데이트되면서 응답 형식이 리스트에서 딕셔너리로 변경됐다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# === v1: 리스트 형태 ===
health_data = [
    {"year": 2025, "month": 1, "amount": 150000},
    {"year": 2025, "month": 2, "amount": 160000},
]
Serializer(data=health_data, many=True)  # OK ✓


# === v2: 딕셔너리 형태로 변경 ===
health_data = {
    "insurance_type": "health",
    "payments": [
        {"year": 2025, "month": 1, "amount": 150000},
    ]
}
Serializer(data=health_data, many=True)  # FAIL ✗
# → TypeError: "item list expected, got dict"

Django REST Framework의 many=True는 내부적으로 ListSerializer를 사용하며, 입력이 반드시 리스트여야 한다. dict를 전달하면 즉시 실패한다.

추가 버그: 빈 문자열 처리

1
2
3
{"expired_date": None}       # → None 허용 → OK
{"expired_date": "2025-12-31"} # → 날짜 파싱 성공 → OK
{"expired_date": ""}          # → 빈 문자열 파싱 실패 → ValidationError

외부 API에서 “값 없음”을 None 대신 빈 문자열("")로 보내는 경우가 있었다.


왜 66시간이나 걸렸나

이 장애의 교훈은 버그 자체가 아니라 발견까지 걸린 시간에 있다.

gantt
    title 장애 타임라인 (66시간)
    dateFormat YYYY-MM-DD
    axisFormat %m/%d (%a)
    section 장애
    장애 시작 (금 18:30)         :crit, f1, 2025-11-07, 3d
    section 모니터링
    Sentry 알람 지연 + 주말      :crit, s1, 2025-11-07, 3d
    section 복구
    출근 후 발견 + 핫픽스 배포    :done, m1, 2025-11-10, 1d

모니터링 사각지대

모니터링 도구상태문제
Sentry에러 수집 O, 알람 2일 지연알림 임계값 설정 미비
CloudWatch에러 로그 O, 알람 미설정외부 API 전용 모니터링 없음
개발자금요일 퇴근주말간 확인 불가

에러는 실시간으로 기록되고 있었지만, 아무도 알림을 받지 못했다.


사전 용량 계획은 했었다

제휴사 연동 전에 인프라 용량 계획을 수립했다:

flowchart TD
    subgraph plan["연동 전 용량 계획"]
        A["웹 서버: 예비 인스턴스 가동"] 
        B["평가 서버: CloudWatch Agent 설치"]
        C["DB: CPU 34% → 읽기 전용 분리"]
        D["외부 API: 스로틀링 코드 준비"]
    end
    subgraph miss["빠진 것"]
        E["❌ 모니터링 알람 설정"]
        F["❌ 배포 후 검증 체크리스트"]
    end

인프라가 버틸 수 있는지는 확인했지만, 문제 발생 시 알려주는 시스템은 없었다. “용량 계획”과 “모니터링”은 별개의 문제다.


재발 방지: 배포 후 4단계 체크리스트

이 사건 이후 도입한 필수 체크리스트:

flowchart TD
    A[배포 완료] --> B{1. 트래픽 확인}
    B -->|정상 요청 유입 확인| C{2. CloudWatch 확인}
    C -->|에러율 정상| D{3. Sentry 확인}
    D -->|신규 에러 없음| E{4. DB 적재 확인}
    E -->|데이터 정상 저장| F[배포 검증 완료 ✓]
    B -->|요청 없음/에러 급증| G[즉시 롤백 검토]
    C -->|에러율 급증| G
    D -->|신규 에러 발견| G
    E -->|데이터 미적재| G

추가 모니터링 조치

flowchart LR
    subgraph alerts["신규 CloudWatch 알람"]
        A["외부 API 에러율 > 10%"]
        B["응답 시간 > 5초"]
        C["요청 수 = 0 (30분간)"]
    end
    alerts -->|알림| D[Slack 채널]

느낀 점

1. “잘 되는 것 같다”는 감각을 믿지 말자

특히 외부 연동은 실제 트래픽이 들어와야만 문제를 발견할 수 있다. Lambda 뒤에 숨어있는 서비스는 직접 호출하지 않으면 에러를 알 수 없다.

2. Sentry만 믿으면 안 된다

Sentry는 에러를 수집하지만, 알림 전달에는 지연이 있을 수 있다. 인프라 레벨 모니터링(CloudWatch 등)이 1차 방어선이어야 한다. Sentry는 2차다.

3. 금요일 저녁 배포는 피하자

모니터링 없이 주말을 넘기면 66시간 장애가 된다. 배포 타이밍만 화~목 오전으로 바꿔도 대부분의 장애를 반나절 내에 잡을 수 있다.

4. many=True는 입력 타입에 민감하다

Django REST Framework의 many=True는 내부적으로 ListSerializer를 사용한다. 외부 API 응답은 언제든 바뀔 수 있으므로, 방어적으로 타입을 체크하는 것이 안전하다:

1
2
3
4
# 방어적 처리 예시
if isinstance(health_data, dict):
    health_data = [health_data]
serializer = MySerializer(data=health_data, many=True)
This post is licensed under CC BY 4.0 by the author.