Post

외부 API 연동은 호출보다 실패 모델이 먼저다

외부 API 연동에서 더 먼저 설계해야 하는 것은 정상 호출 코드가 아니라 실패 응답 분류, retry 경계, 검증 가드라는 점을 운영 사고를 통해 정리한 글이다.

이번 사고는 이렇게 시작됐다.
같은 시리즈의 외부 API 클라이언트 중 하나가 path segment를 빠뜨린 채 잘못 호출됐고, 정부 API가 그 실패를 평문 오류 본문으로 돌려줬고, 애플리케이션은 그걸 일반 retry 대상으로 처리하면서 다른 잡까지 같이 흔들렸다.

직접적인 원인은 더 구체적이었다.
같은 kr-go-data-client 모듈 안에서 KrGoDataFeelsLikeClient만 기존 클라이언트들과 다른 URL 구성 방식을 가졌고, 다른 4개 클라이언트와 달리 IntegrationTest도 없었다.

외부 API 연동을 붙일 때는 보통 정상 호출 코드부터 만든다.
요청을 보내고, 응답을 파싱하고, DTO로 바꾸고, 서비스 로직에 연결하는 흐름이다.

하지만 운영 사고를 만들었던 건 늘 그 반대편이었다.
문제는 path가 빠진 호출, text/plain으로 오는 오류 본문, 일관되지 않은 status code, 그리고 그걸 일반 예외로 묶어 무한 재시도하는 코드였다.

이 글에서 말하고 싶은 핵심은 단순하다.

외부 API 연동에서 먼저 설계해야 하는 것은 호출 성공 경로가 아니라 실패 모델이다.

물론 이 말은 모든 실패를 처음부터 완벽하게 정의할 수 있다는 뜻은 아니다.
현실의 외부 API는 문서가 부실한 경우가 많고, HTTP 표준을 어긴 채 구현된 경우도 흔하다.
그래서 더 중요한 건 모든 실패를 미리 아는 것이 아니라, 이상한 실패를 빨리 감지하고, 다시 같은 형태로 당하지 않게 테스트와 운영 기준에 흡수하는 것이다.

성공 응답 DTO는 대개 한 번 맞추면 끝난다.
반면 실패 응답은 문서와 다를 수 있고, 제공자마다 제각각이며, 심지어 같은 API도 상황에 따라 전혀 다른 형태로 내려온다.
운영에서 시스템을 흔드는 건 대개 이쪽이다.

정상 호출 코드가 있는데도 운영에서 무너진 이유

이번 문제는 같은 시리즈의 외부 API 클라이언트 중 하나가 기존 패턴을 어긴 데서 시작됐다.

같은 모듈 안에는 비슷한 성격의 클라이언트가 여러 개 있었다.
Pollen, Asos, FeelsLike, KEco, KmaSpecialNews처럼 같은 계열의 클라이언트들이 있었고, 대부분은 이런 식으로 움직였다.

  • 생성자에서 baseUrl, serviceKey, segmentId를 받는다
  • URL은 $baseUrl/$segmentId/{service}/{op} 형태로 조립한다

그런데 문제를 만든 FeelsLike 클라이언트 하나만 segmentId를 받지 않았다.
결과적으로 실제 구현은 $baseUrl?serviceKey=...처럼 path segment가 빠진 채 호출하고 있었다.

더 나쁜 건 코드 주석에는 정상 endpoint가 적혀 있었다는 점이다.
리뷰할 때 코드를 대충 보면 “주석도 맞고, 다른 클라이언트와 비슷해 보이네”라고 넘어가기 쉽다.
하지만 구현은 이미 처음부터 broken 상태였다.

이 문제는 mock 테스트로는 거의 드러나지 않는다.
URL을 어떻게 조립했는지보다, “호출 결과를 어떤 DTO로 매핑하는가”에 테스트 초점이 맞춰져 있기 때문이다.
실제 운영 API를 치기 전까지는 path가 빠졌는지조차 모를 수 있다.
더구나 이 케이스에서는 다른 4개 클라이언트에는 모두 IntegrationTest가 있었는데, FeelsLike만 그 가드가 비어 있었다.

결국 운영에서 처음 드러난 사실은 이것이었다.

  • 호출은 정상처럼 시도된다
  • 실제 API는 path 없는 요청에 평문 500이나 이상한 오류 본문을 내려준다
  • 애플리케이션은 이걸 일반 실패로만 본다
  • retry가 붙어 있으면 실패가 조용히 증폭된다

즉 문제의 시작점은 “호출 코드가 없었다”가 아니라, 실패를 어떤 종류로 볼지 전혀 정의하지 않은 상태에서 호출 코드를 먼저 만든 것이었다.

외부 API에서 가장 위험한 건 비정상 응답의 불일치다

외부 API를 다뤄보면 문서상 스펙과 실제 실패 응답은 꽤 자주 다르다.
특히 정부나 공공 OpenAPI는 이 차이가 더 크다.

우리가 흔히 기대하는 실패 응답은 이런 식이다.

  • status code가 의미 있게 구분된다
  • Content-Type이 일관된다
  • 오류 바디가 JSON 스키마를 지킨다

그런데 실제 운영에서는 종종 이렇게 온다.

  • 인증 오류인데 text/plain
  • quota 초과인데 text/xml
  • SOAP fault인데 200 또는 500
  • 같은 계열 API인데 엔드포인트마다 오류 형식이 다름

즉 “정상 응답은 JSON이고, 실패 응답도 대충 JSON이겠지”라는 가정이 가장 먼저 깨진다.

Ktor에서 ContentNegotiation { jackson() }만 믿고 response.body<Map<String, Any>>() 같은 식으로 처리하면 이런 순간 바로 깨진다.
application/json이 아니라는 이유로 NoTransformationFoundException이 나거나, 본문 파싱 실패가 일반 예외로 흘러간다.

여기서 중요한 건 파싱 기술이 아니다.
실패 모델을 어디까지 세분화해서 볼 것인가가 더 중요하다.

내가 지금은 먼저 확인하는 질문은 이렇다.

  • 이 API는 정상 응답 외에 어떤 포맷으로 실패할 수 있는가
  • 이 실패는 재시도해도 되는가
  • 이 실패는 서비스 장애인가, 호출 계약 위반인가, 사용자 입력 문제인가
  • 예외 메시지에 남겨야 할 최소 단서는 무엇인가

예를 들어 정부 OpenAPI 계열에서는 Content-Type보다 본문 패턴이 더 신뢰할 만한 경우가 많았다.
OpenAPI_ServiceResponse, <errMsg>, <returnAuthMsg> 같은 패턴이 보이면 이건 단순 파싱 실패가 아니라 API 제공자가 보내는 명시적 fault로 취급하는 쪽이 맞았다.

이걸 그냥 일반 예외로 두면 “다시 시도해볼 수 있는 일시적 실패”와 “다시 해도 절대 성공하지 않을 호출”이 한 바구니에 들어간다.
그리고 그 순간 retry가 사고를 만든다.

Retry는 복구 전략이 아니라 장애 확산 경로가 될 수 있다

retry는 안정성을 높이는 도구처럼 보인다.
실제로도 맞다. 단, 다시 시도해도 되는 실패만 다시 시도할 때만 그렇다.

문제는 외부 API 실패를 잘못 분류하면 retry가 복구 전략이 아니라 증폭 장치가 된다는 점이다.

이번 사례를 단순화하면 흐름은 이랬다.

  1. path가 잘못된 요청이 나간다
  2. 외부 API가 text/plain 또는 SOAP fault를 돌려준다
  3. 애플리케이션은 이걸 일반 예외로 본다
  4. 공통 retry 로직이 같은 요청을 반복한다
  5. 같은 dispatcher나 worker pool을 다른 잡들과 공유하고 있으면 폭주가 번진다
  6. 표면 증상은 “외부 API 파싱 실패”가 아니라 “Quartz misfire”, “다른 잡 지연”, “운영 로그 폭증”으로 나타난다

이 단계가 무서운 이유는 원인과 증상이 분리되기 때문이다.

  • 원래 원인: 외부 API 호출 계약 위반
  • 중간 확산: 잘못된 retry 정책
  • 최종 증상: 다른 잡 misfire, worker starvation, 로그 폭주

운영에서는 마지막 증상만 먼저 보이기 쉽다.
그러면 처음엔 스케줄러를 의심하고, worker 수를 늘릴까 고민하고, thread pool 설정을 보게 된다.
하지만 실제로 먼저 고쳐야 하는 건 retry 대상의 정의다.

그래서 retry를 붙일 때는 “예외가 나면 다시 한다”가 아니라 최소한 이렇게 나누는 편이 낫다.

  • 네트워크 일시 장애, timeout, 일시적 5xx: retry 가능 후보
  • 인증 오류, path 누락, 요청 파라미터 오류, 명시적 OpenAPI fault: NonRetryable
  • 파싱 실패: 원문 일부와 Content-Type을 남기고 원인 분류 후 결정

retry는 예외 처리의 default가 아니라, 정의된 실패 분류표 위에서만 허용되는 정책이어야 한다.

운영에서 먼저 필요한 가드는 IntegrationTest와 패턴 일관성이다

이런 사고를 겪고 나면 “테스트를 더 추가해야지”라는 결론으로 가기 쉽다.
맞는 말이지만, 어떤 테스트를 어디에 둘지까지 정리되지 않으면 또 비슷한 사고가 난다.

내가 지금 외부 API 클라이언트를 볼 때 가장 먼저 체크하는 건 두 가지다.

1. 같은 시리즈의 기존 클라이언트와 패턴이 같은가

새 클라이언트를 추가할 때는 먼저 같은 모듈 안의 기존 클라이언트를 비교한다.

  • 생성자 시그니처가 같은가
  • URL 구성 방식이 같은가
  • 필요한 path segment, service code, query parameter가 빠지지 않았는가

이건 새 코드를 “독립적으로” 읽는 것보다 훨씬 강한 가드다.
외부 API 클라이언트는 대개 시리즈별로 패턴이 비슷하기 때문에, 하나만 다르게 생겼다면 그게 의도인지 실수인지 바로 의심해야 한다.

2. 각 클라이언트마다 실제 API를 치는 IntegrationTest가 있는가

mock 테스트는 파싱 로직과 서비스 조합을 확인하는 데는 좋다.
하지만 URL 조립, 실제 응답 포맷, Content-Type 변칙, path 누락은 실제 호출 전에는 잘 안 드러난다.

그래서 외부 API 클라이언트에는 가능한 한 *IntegrationTest.kt를 두는 편이 좋다.

  • 환경변수가 없으면 skip
  • 로컬에서는 실제 키를 넣고 수동 실행 가능
  • 최소 한 번이라도 실제 API를 치면 URL 계약 위반이 바로 드러남

이번 사고도 그 차이를 그대로 보여줬다.
같은 모듈의 다른 클라이언트들은 실제 호출 테스트가 있었고, FeelsLike만 비어 있었기 때문에 path 누락이 가장 늦게 드러났다.

CI에서 항상 돌리기 어렵더라도, “아예 없는 상태”와 “필요할 때 바로 돌릴 수 있는 상태”는 차이가 크다.

실제로 체크리스트는 이 정도면 충분했다.

  • 같은 시리즈의 기존 클라이언트와 생성자 시그니처가 같은가
  • URL 구성에 필요한 path segment가 빠지지 않았는가
  • *IntegrationTest.kt가 같이 추가됐는가
  • 비정상 응답이 NonRetryable로 분류되는가

이 네 가지가 빠지면, 성공 응답 DTO를 아무리 예쁘게 만들어도 운영에서는 다시 흔들린다.

실패 모델을 완벽히 모를 때는 빨리 감지하고 팀의 기준으로 흡수해야 한다

문제는 여기서 끝나지 않는다.
실제로는 실패 모델을 먼저 정의하고 싶어도 그럴 수 없는 API가 많다.

  • 문서에는 JSON 오류라고 써 있는데 실제로는 text/plain이 온다
  • 4xx/5xx를 지키지 않고 200에 fault 본문을 싣는다
  • 같은 제공자 안에서도 엔드포인트마다 오류 형식이 다르다

이런 환경에서는 “설계 단계에서 실패 모델을 전부 정의하자”가 현실적인 요구가 아닐 수 있다.
대신 운영에서 처음 보는 실패를 빨리 눈치채고, 그 사실을 다음 코드와 테스트에 남기는 루프가 필요하다.

내가 지금 더 중요하게 보는 건 세 가지다.

1. 실패를 빨리 인지할 수 있어야 한다

파싱 실패나 외부 API 오류를 그냥 일반 예외 한 줄로 남기면 다음에도 같은 문제를 다시 겪는다.
최소한 아래 단서는 남아야 한다.

  • status code
  • Content-Type
  • 응답 본문 일부
  • 어떤 클라이언트와 어떤 endpoint였는지

그래야 “일시적 장애인지”, “호출 계약 위반인지”, “문서와 다른 실패 포맷인지”를 나중에라도 분류할 수 있다.

2. 한 번 본 실패는 테스트로 옮겨야 한다

운영에서 처음 본 이상 응답은 지식으로만 남기면 금방 사라진다.
가능하면 다음 둘 중 하나로 옮겨야 한다.

  • IntegrationTest에 실제 재현 케이스 추가
  • 최소한 parsing/failure classification 테스트에 fixture 추가

중요한 건 “이상한 실패를 봤다”가 아니라, 그 실패 때문에 같은 코드가 다시 merge되지 않게 만드는 것이다.

3. 운영에서 얻은 실패 지식을 팀에 전파해야 한다

외부 API 문제는 한 클라이언트에서만 끝나지 않는 경우가 많다.
같은 제공자 계열의 다른 API도 비슷한 방식으로 깨질 가능성이 높다.

그래서 운영에서 본 실패는 개인 메모로 끝내기보다 팀의 공통 기준으로 바꾸는 편이 낫다.

  • 어떤 fault 패턴은 NonRetryable로 본다
  • 어떤 제공자는 Content-Type보다 본문 패턴을 먼저 본다
  • 새 클라이언트에는 *IntegrationTest.kt를 같이 추가한다

이런 기준이 공유되지 않으면, 한 번 겪은 사고가 이름만 바뀐 채 다시 반복된다.

실패 모델은 호출 코드보다 먼저 정해야 한다

외부 API 연동 코드를 작성할 때 많은 팀이 먼저 정하는 건 이런 것들이다.

  • 어떤 클라이언트를 쓸까
  • DTO는 어떻게 만들까
  • 응답 모델은 어떻게 나눌까
  • 재시도는 몇 번 할까

그런데 운영 기준으로는 그보다 먼저 정해야 할 것이 있다.

  • 이 API는 어떤 방식으로 실패하는가
  • 어떤 실패는 retry하면 안 되는가
  • 어떤 실패는 즉시 경보를 올려야 하는가
  • 파싱 실패를 단순 예외로 둘 것인가, 도메인 예외로 승격할 것인가
  • 이 실패가 다른 잡과 worker pool에 어떤 영향을 줄 수 있는가

이 질문에 답하지 않은 채 호출 코드를 먼저 짜면, 코드는 금방 완성된다.
하지만 운영에 올린 뒤부터는 실패가 시스템 전체를 돌아다니기 시작한다.

외부 API 연동의 품질은 성공 응답 DTO에서 결정되지 않는다.
오히려 실패 응답 분류표, retry 경계, 검증 가드, 그리고 운영 시 관측 단서에서 결정된다.

호출 성공 코드는 비교적 빨리 만든다.
실패 모델은 대개 사고를 한 번 겪고 나서야 선명해진다.

그래서 가능한 범위에서는 먼저 정의하는 편이 낫고,
처음부터 다 정의할 수 없는 부분은 모니터링으로 빨리 감지하고, 테스트에 녹이고, 팀의 운영 기준으로 공유하는 루프로 메워야 한다.

외부 API 연동에서는 호출보다 실패 모델이 먼저다.

This post is licensed under CC BY 4.0 by the author.