외부 API 응답을 곧이곧대로 믿으면 안 되는 이유

외부 API 응답을

새벽 배포 직후 알림이 울리고, 로그를 열어보니 결제 API가 갑자기 amount 필드를 문자열 "1,000"으로 보내서 파서가 통째로 죽어 있는 상황, 한 번쯤 겪어보셨을 거예요. 외부 API 응답을 그대로 믿으면 안 되는 이유는 간단합니다. 상대 서버가 문서와 다르게 필드를 바꾸거나, 일부 데이터만 누락된 채 200 OK를 돌려주는 순간 내 서비스가 함께 무너지기 때문이에요. 이 글에서는 스키마 검증과 부분 실패 처리를 실제 코드로 어떻게 붙이는지, 그리고 이 방식이 안 통하는 상황까지 짚어보겠습니다.

외부 API 응답을 그대로 믿으면 안 되는 이유가 뭘까요?

이유는 크게 두 가지예요. 첫 번째는 계약(schema)이 언제든 조용히 바뀔 수 있다는 점입니다. 외부 API 제공사가 필드 타입을 바꾸거나 새 필드를 추가해도 별도 공지 없이 배포하는 경우가 실무에서 드물지 않아요.

두 번째는 응답이 “전부 성공” 아니면 “전부 실패”로 딱 나뉘지 않는다는 점이에요. 100개 항목을 조회했는데 그중 3개만 내부 오류로 null이 섞여 오는 경우, HTTP 상태 코드는 200이지만 데이터는 반쯤 망가진 상태입니다. 실제로 공공데이터포털처럼 여러 기관이 데이터를 취합해 제공하는 공공데이터포털 API에서도 문서에 명시된 필드와 실제 응답 필드가 기관별로 미묘하게 다른 사례가 자주 보고돼요. 여러 제공기관의 데이터를 한 창구로 모으는 구조라 이런 불일치가 생기기 쉽습니다.

스키마 검증 없이 파싱하면 이런 사고가 납니다

가장 흔한 실수는 response.json()["price"]처럼 딕셔너리 키를 바로 꺼내 쓰는 코드예요. 필드가 없거나 타입이 바뀌면 KeyErrorTypeError가 요청 처리 도중 던져지고, 이 예외를 어디서도 잡지 않았다면 사용자에게는 500 에러 화면만 보입니다.

더 위험한 건 타입이 조용히 바뀌는 경우예요. price가 정수 1000에서 실수 1000.5로 바뀌어도 파이썬은 별다른 오류 없이 계산을 진행해버리고, 결제 금액이 미세하게 틀어진 채로 며칠간 아무도 모르고 지나갈 수 있습니다. 아래는 Pydantic 2.x(공식 문서 기준 docs.pydantic.dev)로 이 문제를 막는 최소 예시예요.

from pydantic import BaseModel, ValidationError

class PriceItem(BaseModel):
    id: int
    name: str
    price: float

raw = {"id": 1, "name": "무선 키보드", "price": "free"}

try:
    item = PriceItem.model_validate(raw)
except ValidationError as e:
    print(e)

모니터 앞에서 API 오류 코드를 디버깅하는 개발자

실행하면 아래와 비슷한 에러가 출력됩니다.

1 validation error for PriceItem
price
  Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='free', input_type=str]

price가 숫자로 변환 불가능한 문자열이라는 사실이 예외 던지기 전에 걸러진다는 점이 핵심이에요. 문제가 결제 로직 깊숙한 곳이 아니라 API 경계에서 바로 드러납니다.

Pydantic으로 응답을 검증했을 때 달라지는 것들

검증 계층을 두면 얻는 이득은 세 가지 정도로 정리할 수 있어요. 아래 표는 검증을 안 했을 때와 했을 때를 비교한 내용입니다.

항목 검증 없음 Pydantic 검증 적용
타입 불일치 발견 시점 계산·저장 로직 실행 중 (늦음) API 경계에서 즉시
에러 메시지 TypeError, 위치 파악 어려움 어느 필드가 문제인지 명시
로깅 별도 구현 필요 ValidationError.errors()로 구조화된 리스트 확보
유지보수 파싱 코드 곳곳에 방어 로직 분산 모델 클래스 한 곳에 계약 명시

다만 여기서 주의할 점이 있어요. model_config = {"extra": "forbid"}처럼 엄격 모드를 켜두면, 외부 API가 문서에 없던 필드를 새로 추가했을 때 정상적인 응답까지 검증 실패로 처리해버립니다. 실무에서는 알 수 없는 필드를 무시하는 기본 동작(extra="ignore")을 유지하고, 꼭 필요한 필드만 타입을 엄격히 검사하는 편이 안전한 경우가 많아요.

부분 실패는 전부 성공/전부 실패로 나누면 안 됩니다

목록 100개를 조회하는 API를 예로 들어볼게요. 이 중 3개 항목만 스키마 검증에 실패했다고 전체 요청을 실패 처리하면, 정상인 97개 데이터까지 화면에 못 보여주는 손해가 생겨요. 반대로 검증 실패를 그냥 무시하고 넘어가면 깨진 데이터가 사용자 눈에 그대로 노출됩니다.

아래처럼 항목 단위로 검증하고, 실패한 항목만 따로 모아 로그로 남기는 방식이 실무에서 자주 쓰여요.

서킷 브레이커 패턴으로 서버 네트워크 장애를 차단하는 개념 이미지

valid_items, failed_items = [], []

for raw in response.json()["items"]:
    try:
        valid_items.append(PriceItem.model_validate(raw))
    except ValidationError as e:
        failed_items.append({"raw": raw, "errors": e.errors()})

print(f"성공 {len(valid_items)}건, 실패 {len(failed_items)}건")

이렇게 하면 실패한 3건은 별도 모니터링 채널로 보내 나중에 원인을 확인하고, 나머지 97건은 정상적으로 서비스에 노출할 수 있어요. HTTP 상태 코드 자체가 정상(2xx)인지 여부와, 응답 바디 안 데이터가 온전한지는 완전히 다른 문제라는 점을 코드로 분리해두는 셈입니다.

재시도·서킷 브레이커·폴백, 이 순서로 조합해 보세요

스키마 검증은 “응답 내용”의 문제를 잡아주지만, “응답 자체가 안 온다”는 문제는 다른 층위에서 다뤄야 해요. 네트워크 타임아웃이나 5xx 에러가 반복되는 상황에서 계속 재시도만 하면 외부 API도, 내 서비스도 같이 느려집니다. 이때 쓰는 패턴이 서킷 브레이커(Circuit Breaker)예요.

  • 재시도: 일시적 오류(타임아웃, 5xx)에만 짧은 지수 백오프로 2~3회 재시도
  • 서킷 브레이커: 실패율이 임계치를 넘으면 일정 시간 동안 요청을 아예 보내지 않고 즉시 실패 처리
  • 폴백: 캐시된 이전 응답이나 기본값으로 화면을 채워 서비스 중단을 피함

세 가지는 순서대로 쌓는 구조예요. 재시도로도 안 되면 서킷 브레이커가 회로를 끊고, 회로가 끊긴 동안에는 폴백 데이터로 버티는 식입니다. 단, 폴백 데이터가 실시간성이 중요한 값(재고 수량, 결제 가능 여부 같은 것)이라면 오래된 캐시를 보여주는 것 자체가 사용자에게 더 큰 혼란을 줄 수 있어요. 이 경우엔 폴백 없이 명확한 에러 화면을 띄우는 편이 나을 때도 있습니다.

오늘 붙여야 할 검증 코드와 남는 트레이드오프

정리하면 외부 API 응답을 그대로 믿으면 안 되는 이유는 스키마가 조용히 바뀌고, 실패가 전부/전무로 나뉘지 않기 때문이었어요. 가장 먼저 손댈 곳은 결제 금액이나 재고 수량처럼 틀리면 손해가 바로 나는 필드입니다. 이 필드부터 Pydantic 같은 검증 계층을 씌우고, 목록 응답이라면 항목 단위 성공/실패를 분리하는 코드를 추가해 보세요.

다만 검증을 촘촘히 할수록 API 제공사가 문서에 없는 필드를 추가했을 때 오탐이 늘어나는 트레이드오프는 남아 있어요. 그래서 처음부터 모든 필드를 엄격 모드로 잠그기보다, 핵심 필드 몇 개만 엄격하게 검사하고 나머지는 느슨하게 받아들이는 절충안으로 시작하는 편을 권해드립니다.

무중단 배포인데 진행 중이던 작업이 끊기는 이유, drain 시간과 긴 작업 분리로 해결하기

Leave a Comment