던플 개발기 (2): 외부 API 점검을 정상 운영 조건으로 다루기
지난 편에서 시세 수집 파이프라인을 만들었습니다. 그런데 이 파이프라인이 의존하는 네오플 API는, 던전앤파이터 게임이 점검에 들어갈 때마다 같이 멈춥니다. 정기 점검은 보통 목요일이고, 긴급 점검도 있고, 끝나는 시각은 단축되거나 연장됩니다.
처음에는 이걸 "가끔 일어나는 사고"로 다루려 했습니다. 그러다 방향을 바꿨습니다. 외부 공급자에 의존하는 시스템에서 점검과 장애는 예외가 아니라 정상적인 운영 조건입니다. 이 글은 그 전제 위에서 수집기가 어떻게 버티는지 — 멈출 때 깔끔하게 멈추고, 멈춘 동안에도 데이터를 거짓 없이 보여 주고, 복구할 때 폭주하지 않게 — 정리한 기록입니다.
한눈에 보면
- 네오플 점검은
HTTP 503+ error codeDNF980으로 확인합니다. 점검 시간은 변하므로 코드에 하드코딩하지 않습니다. - 공급자 상태를 6개(HEALTHY/PLANNED·UNPLANNED_MAINTENANCE/DEGRADED/RECOVERING/DISABLED) 기계로 두고, 상태에 따라 dispatcher가 수집 job 생성을 멈춥니다.
- 점검 중 실패한 메시지는 DLQ로 보내지 않습니다. 정상적인 멈춤이지 독약 메시지가 아니기 때문입니다.
- 모든 에러를 같게 처리하지 않습니다. 점검·throttle·credential·transient·request bug·parse 실패를 분류해 다르게 대응합니다.
- API 중단 시간은 매물 0개가 아닙니다.
collection_gaps로 기록하고, 차트는 0이 아니라 끊긴 구간으로 그립니다. - 복구는 천천히 합니다. 밀린 job을 한꺼번에 재생하지 않고 jitter를 주며 인기 아이템부터 재개합니다.
점검은 사고가 아니라 일정이다
던전앤파이터 Open API의 점검은 게임 점검 시간과 동일하게 진행됩니다. 공식 결과 코드 문서에 HTTP 503 / DNF980이 시스템 점검으로 정의돼 있어, 호출 응답만으로 점검을 식별할 수 있습니다.
여기서 한 가지 유혹을 의식적으로 피했습니다. "목요일 오전엔 점검이니 그때 수집을 끄자"처럼 시간을 코드에 박는 것입니다. 점검은 단축되기도 연장되기도 하고 긴급 점검도 있습니다. 시간을 하드코딩하면 그 예외마다 코드가 틀립니다. 그래서 점검 여부는 시계가 아니라 응답으로 판단하고, 운영자가 미리 아는 예정 점검만 별도로 등록해 둡니다.
공급자 상태 기계
수집기가 "지금 네오플이 어떤 상태인가"를 한곳에서 알도록, 공급자 상태를 DB에 저장하고 Redis에 짧게 캐시합니다. 상태에 따라 dispatcher의 동작이 달라집니다.
| 상태 | 의미 | dispatcher 동작 |
|---|---|---|
HEALTHY |
정상 | 평상시 예산으로 job 생성 |
PLANNED_MAINTENANCE |
등록된 예정 점검 | 새 job 생성 중단 |
UNPLANNED_MAINTENANCE |
503/DNF980 감지 |
새 job 생성 중단 |
DEGRADED |
오류율이 임계 초과 | 예산 축소 또는 중단 |
RECOVERING |
probe 성공, 완만한 복구 | jitter 주며 점진 생성 |
DISABLED |
credential·정책·kill switch | 수동 해제 전 호출 금지 |
stateDiagram-v2
[*] --> HEALTHY
HEALTHY --> PLANNED_MAINTENANCE: 예정 점검 창 시작
HEALTHY --> UNPLANNED_MAINTENANCE: 503 / DNF980
HEALTHY --> DEGRADED: 오류율 임계 초과
PLANNED_MAINTENANCE --> RECOVERING: probe 연속 성공
UNPLANNED_MAINTENANCE --> RECOVERING: probe 연속 성공
DEGRADED --> RECOVERING: probe 연속 성공
RECOVERING --> HEALTHY: 호출량·오류율 안정
HEALTHY --> DISABLED: credential 오류 / kill switch
DISABLED --> HEALTHY: 수동 해제중요한 건 현재 상태만 덮어쓰지 않고 전이 이력(provider_status_events)을 남긴다는 점입니다. "언제 점검에 들어갔고 언제 복구됐는지"가 나중에 차트의 빈 구간을 설명해 주기 때문입니다.
점검과 장애를 구분한다
겉으로는 둘 다 "호출이 안 된다"지만, 대응은 정반대입니다.
예정 점검(planned). 운영자가 공식 점검 공지를 보고 provider_maintenance_windows에 시작·종료 예정 시각과 출처 URL을 등록합니다. 창이 시작되면 dispatcher는 job을 만들지 않습니다. 이미 큐에 들어간 job은 점검 상태를 확인한 뒤 ack하고 next_collect_at을 뒤로 미룹니다. 예정 종료 시각 이후에는 저비용 대표 요청으로 probe를 보냅니다(네오플에 별도 health endpoint가 있다고 가정하지 않고, 일반 호출과 같은 adapter를 씁니다).
예정에 없던 장애(unplanned). 워커가 503/DNF980을 받으면 즉시 UNPLANNED_MAINTENANCE로 전환하고 새 job 생성을 멈춥니다.
첫 DNF980
→ 새 provider job 중단
→ jitter 주며 다음 probe 예약
→ 저장된 데이터는 계속 제공
→ freshness를 점검 상태로 노출두 경우 모두 점검으로 실패한 메시지를 DLQ로 보내지 않습니다. DLQ는 "처리할 수 없는 독약 메시지"를 위한 곳이지, "지금은 점검이라 잠깐 안 되는 정상 상황"을 위한 곳이 아닙니다. 이 구분을 흐리면 점검이 끝날 때마다 DLQ가 쌓여 진짜 문제를 가립니다.
timeout, connection reset, DNS 오류, HTTP 500, API999, DNF999는 일시 오류로 보고 짧은 exponential backoff와 full jitter로 제한된 횟수만 재시도합니다. rolling window 안에서 실패율이 임계를 넘으면 circuit breaker를 열고 DEGRADED로 내립니다. 임계값은 코드에 마법 숫자로 박지 않고 환경 변수로 둡니다.
NEOPLE_PROVIDER_ENABLED
NEOPLE_PROVIDER_REQUESTS_PER_SECOND / _PER_MINUTE
NEOPLE_PROVIDER_TIMEOUT_MS
NEOPLE_PROVIDER_TRANSIENT_MAX_ATTEMPTS
NEOPLE_PROVIDER_CIRCUIT_FAILURE_THRESHOLD / _WINDOW_SECONDS
NEOPLE_PROVIDER_PROBE_INTERVAL_SECONDS
NEOPLE_PROVIDER_RECOVERY_SUCCESS_THRESHOLD에러를 분류한다
"호출 실패"를 한 덩어리로 처리하면 점검에도 재시도하고, credential 오류에도 재시도하다 계정을 막히게 만듭니다. 그래서 네오플 공식 결과 코드를 기준으로 분류표를 두고 endpoint별로 다르게 처리합니다.
| 분류 | 예시 | 처리 |
|---|---|---|
| 점검 | 503, DNF980 |
점검 상태 전환, 수집 중단, probe 예약, DLQ 금지 |
| Throttle | API002, API008 |
token bucket 축소, 다음 window로 지연 |
| Credential | API000, API003~005 |
DISABLED, 즉시 경고, 수동 조치 전 재시도 금지 |
| 일시 오류 | timeout, 500, API999, DNF999 |
제한된 backoff 재시도, 임계 초과 시 circuit open |
| 요청 버그 | API006, DNF007~009 등 |
재시도 금지, 안전 메타데이터 기록, DLQ, 배포 점검 |
| 리소스 없음 | DNF003, DNF004 |
endpoint별 정책, 반복 시 item 비활성화 후보 |
| parse 실패 | 런타임 schema 검증 실패 | 저장 금지, payload 격리, 경고 |
특히 404를 전부 장애로 취급하지 않습니다. 만료되거나 팔려서 사라진 경매 상품은 정상적인 경쟁 조건입니다. 그래서 endpoint별 error mapper를 따로 둡니다.
중단 시간은 0이 아니다
가장 신경 쓴 부분입니다. API가 멈춘 시간은 "매물이 0개였다"는 뜻이 아닙니다. 이걸 헷갈리면 차트에 0원짜리 골짜기가 생기고, 급락 계산이 엉망이 됩니다.
- 공급자 중단 구간은
collection_gaps에 저장합니다. - 집계에는 누락 구간을 포함하지 않습니다. 차트는 그 구간을 0이 아니라 끊긴 구간으로 그립니다.
- 매물·거래·차트 응답에는
observedAt,freshness,providerStatus를 함께 실어 보냅니다. - 인기·급등·급락 계산은 stale 임계를 넘긴 아이템을 제외하거나 이전 snapshot을 유지합니다.
freshness는 다섯 단계로 사용자에게 그대로 노출합니다. 상태를 숨기는 것보다 정직하게 보여 주는 편이 신뢰를 얻습니다.
FRESH 방금 수집됨
DELAYED 예상보다 늦음
STALE 오래됨
MAINTENANCE 점검 중, 저장된 데이터 제공
NO_DATA 아직 첫 수집 전화면 상단에는 점검·지연 banner를 띄우고, 매물과 차트에는 마지막 갱신 시각을 표시합니다. 최초 데이터가 없을 때도 빈 매물 목록이 아니라 "준비 중" 상태를 보여 줍니다. 빈 화면은 "고장 났다"로 읽히지만, 마지막 갱신 시각이 붙은 화면은 "잠깐 멈췄을 뿐"으로 읽힙니다.
복구는 천천히
점검이 끝나는 순간 밀린 아이템을 전부 호출하면, 복구하자마자 공급자와 우리 시스템에 같은 부하가 몰립니다. 점검 직후가 가장 위험한 구간인 셈입니다.
flowchart LR
M[MAINTENANCE / DEGRADED] --> P{probe 연속 성공?}
P -->|예| R[RECOVERING]
R --> T[token bucket 점진 회복]
T --> J[next_collect_at에 jitter 분산]
J --> H[인기 아이템 우선 재개]
H --> OK[HEALTHY]
P -->|아니오| M핵심은 "밀린 overdue job을 전부 재생하지 않는다"입니다. probe가 연속 성공하면 RECOVERING으로 바꾸고, token bucket을 천천히 채우면서 next_collect_at에 jitter를 줘 호출을 흩뜨립니다. 인기 아이템부터 재개하고, 호출량과 오류율이 안정되면 HEALTHY로 돌아갑니다.
한 가지는 솔직히 인정하고 설계했습니다. 판매 이력 API는 최근 100건·1개월 범위만 주므로, 점검이 길면 그 사이 거래를 완전히 복구하지 못할 수 있습니다. 이때 없는 데이터를 추정으로 메우지 않습니다. collection_gaps를 영구로 남겨, 차트가 실제보다 매끈해 보이지 않게 합니다.
격리도 위생이 필요하다
parse 실패나 요청 버그를 진단하려면 문제가 된 payload를 남겨야 하는데, 여기에 함정이 있습니다. 원문을 통째로 저장하면 API key나 seller 같은 불필요한 정보까지 남습니다. 그래서 격리 저장에는 payload hash, parser version, endpoint, 안전하게 축약한 표본만 남깁니다. 진단에 필요한 최소한만 남기고 나머지는 버리는 게 원칙입니다.
내부 실패도 외부 실패와 분리해서 다룹니다. PostgreSQL 일시 오류는 트랜잭션 롤백 후 SQS visibility timeout으로 재시도하고, Redis 오류는 캐시를 우회해 DB 읽기를 유지하며, rollup이 실패하면 지난 편에서 말한 대로 purge를 멈춥니다.
정리하면
이 편의 전제는 하나였습니다. 외부 API의 점검과 장애는 예외가 아니라 정상 운영 조건이다.
- 점검은 시계가 아니라 응답(
503/DNF980)으로 판단하고, 상태 기계로 dispatcher 동작을 제어한다. - 점검·throttle·credential·일시 오류·버그·parse 실패를 분류해 다르게 대응하고, 점검 실패는 DLQ로 보내지 않는다.
- 중단 시간을 0으로 기록하지 않는다.
collection_gaps와 freshness로 데이터의 한계를 정직하게 드러낸다. - 복구는 jitter와 우선순위로 천천히 한다. 없는 데이터는 추정으로 메우지 않는다.
(1)의 "거짓 정밀도를 만들지 않는다"는 원칙이 장애 대응에서도 그대로 이어집니다. 멈춘 걸 멈췄다고, 모르는 걸 모른다고 말하는 시스템이 길게 봤을 때 더 믿음직합니다.
다음 편에서는 화면 쪽으로 넘어가, 이 상태들(freshness·점검 banner)을 프론트엔드에서 어떻게 드러내는지와 던플마켓 디자인 시스템 이야기를 다루겠습니다.
