이 글에는 서버가 셋 나온다. 우리 서버를 부르는 쪽(클라이언트 — 브라우저나 앱), 우리 서버, 그리고 우리 서버가 일을 하다가 다시 부르는 다른 회사의 서버(결제사 같은 곳, 이 글에서는 「상대」라고 부른다)다.
클라이언트 ──요청──▶ 우리 서버 ──요청──▶ 상대 서버
◀──응답── ◀──응답──
「외부 연동」은 오른쪽 화살표, 즉 우리 서버가 상대 서버를 부르는 일이다. 「외부 연동 실패」는 그 호출이 잘못된 경우다.
예외 종류별 처리 표준은 외부 연동 실패를 5xx(서버 쪽 잘못이라는 뜻의 상태 코드 묶음)로 가르면서 이렇게 적었다.
상대가 4xx/5xx나 이상한 응답을 주면 RestClientResponseException으로 잡혀 502가 된다.
RestClientResponseException은 RestClientException 계열 중에서도 상대가 4xx·5xx 상태 코드로 응답했을 때 나는 예외다. 이 예외는 위 그림의 오른쪽(상대 → 우리 서버)에서 난다. 502는 그 상황을 우리 서버가 왼쪽의 클라이언트에게 대신 돌려주는 HTTP 상태 코드다 — “우리 서버가 부른 상대 서버가 잘못된 응답을 줬다”는 뜻이다.
「이상한 응답」까지 한 예외 타입으로 잡힌다는 것이 규칙의 전제였다. 그 전제를 재봤다.
주장 — 상대의 오류 응답과 깨진 본문 둘 다 RestClientResponseException으로 잡힌다
상대 서버가 4xx·5xx를 주거나 본문이 깨진 응답을 주면,
호출한 쪽에서 RestClientResponseException으로 잡힌다.
근거 종류는 1(기술 사실)이다. 「스프링이 이 상황에서 어떤 예외를 던지는가」는 돌려 보면 확인되는 기술의 동작이라서다. 이 실험은 있을 때와 없을 때를 견주는 대신, 상대 응답 두 가지를 실제로 만들어 각각 어떤 예외가 나는지 돌려 확인했다.
반증 조건 — 둘 중 하나라도 다른 타입이면 반증이다
아래에서 「두 환경」은 Spring Boot 3.5.16과 4.1.1 두 버전이고, 「두 경로」는 상대가 500을 주는 경우와 상대가 깨진 본문을 주는 경우다. 자세한 모양은 다음 섹션 「설계」에 있다. 「서버가 뜬다」는 서버 프로그램이 실행되어 요청을 받을 준비가 됐다는 뜻이다.
- refuted — 두 환경 중 하나라도 두 경로 중 하나에서
RestClientResponseException(하위 포함 —RestClientResponseException을 상속받아 더 구체적으로 만든 예외 클래스까지 포함한다는 뜻)이 아닌 예외가 나오거나, 예외가 아예 나지 않는다 - supported — 두 환경 모두 두 경로에서
RestClientResponseException계열이 나온다 - inconclusive — 서버가 뜨지 않거나 호출 자체가 실패한다
마지막 줄의 「호출 자체가 실패한다」는 요청이 상대 경로까지 닿지 못해 상대의 응답을 아예 못 본 경우다. 그때도 예외는 나지만, 그 예외는 「상대 응답을 받았을 때 무엇으로 잡히나」라는 질문의 답이 아니다. 그래서 refuted로 세지 않고 판정을 미룬다.
설계 — 상대 역할도 같은 앱이 맡아 응답을 정확히 통제한다
진짜 외부 서비스를 실행해(띄워) 놓고 부르면, 그 서비스가 언제 무엇을 주는지까지 실험 결과에 섞여 들어온다. 나는 상대가 줄 응답을 내 마음대로 정하고 싶었다. 그래서 앱 하나가 두 역할을 다 맡게 했다.
- 상대 역할 — 앱이 실행되면 그 안에 든 임베디드 톰캣이 함께 떠서 요청을 받는다. 앱에는 상대 서버 노릇을 할 경로 두 개를 만들어 두었다. 포트는 비어 있는 아무 번호나 쓰게 했다. 앱의 설정 파일
application.properties에server.port=0한 줄을 넣으면 그렇게 된다 - 호출 역할 — 같은 앱의
main메서드가 서버가 뜬 뒤http://localhost:<그 포트>로, 즉 자기 자신에게 요청을 보낸다.localhost는 지금 이 컴퓨터 자신을 가리키는 주소다.main은SpringApplication.run(...)으로 앱을 띄우고, 그 호출이 돌아온 다음 줄에서 실제로 잡힌 포트 번호를local.server.port라는 값으로 읽어 주소를 만든다. 이때 쓰는 도구가RestClient(스프링이 제공하는, 다른 서버를 HTTP로 불러 응답을 받아오는 도구)다. 받는 쪽이 같은 프로그램이어도 요청은 실제 HTTP로 오간다
상대 역할의 두 경로는 이렇다.
GET /upstream/500 상태 500 + 정상 JSON 본문
GET /upstream/broken 상태 200 + Content-Type: application/json + 깨진 본문 {"a":
GET은 「가져오기」 요청이라는 HTTP 메서드이고, /upstream/500은 요청을 받는 주소인 엔드포인트다. upstream은 「상대 서버 역할」이라는 뜻으로 내가 붙인 이름이다. 첫 줄은 “이 주소로 가져오기 요청이 오면 상태 500과 정상 JSON을 돌려준다”는 뜻이고, 둘째 줄은 “상태는 200인데 본문은 깨진 JSON을 돌려준다”는 뜻이다.
Content-Type: application/json은 응답 본문이 JSON 형식이라고 알려주는 헤더다 — 상태는 200(성공)인데 본문은 중간에서 끊긴 {"a": 라서, 받는 쪽은 “성공했다”는 신호와 “본문을 다 못 읽는다”는 사실을 동시에 마주한다.
호출 쪽은 응답 본문을 Payload라는 record(필드 a와 message만 가진 작은 클래스) 객체로 바꿔 받는다. 이렇게 JSON 글자를 읽어 자바 객체로 바꾸는 일이 파싱이고, 깨진 본문에서는 이 단계가 실패한다. 실험이 보려는 것은 그때 어떤 예외가 나느냐다. 실험 앱(proofs/rest-client-response-exception-coverage/app의 Probe.java)에서 상대를 부르고 예외를 기록하는 부분은 이렇다.
try {
Payload payload = client.get().uri(uri).retrieve().body(Payload.class);
return new Outcome("none", false, "no exception, body=" + payload);
} catch (Exception e) {
boolean sub = e instanceof RestClientResponseException;
// e 의 클래스 이름과 sub 를 Outcome 에 담아 돌려준다
}
한 줄씩 보면, client.get().uri(uri)는 “이 주소로 가져오기(GET) 요청을 만든다”, retrieve()는 “보내고 응답을 받아 와라”, body(Payload.class)는 “응답 본문을 Payload 객체로 바꿔 달라”는 뜻이다. 예외 없이 끝나면 "none"(예외 없음)을 기록한다. 예외가 나면 catch가 받는데, e instanceof RestClientResponseException은 “e가 RestClientResponseException이거나 그 하위 클래스의 객체인가”를 true·false로 돌려준다. 결과 줄의 s500sub·brokensub가 바로 이 값이다.
그런데 깨진 경로는 먼저 String으로 한 번 받아 서버가 의도한 것을 실제로 주는지 확인한다(raw=ok). String으로 받으면 본문 글자를 그대로 받을 뿐 객체로 바꾸지 않으니, 본문이 깨져 있어도 실패하지 않고 그 글자를 볼 수 있다. 이 확인이 없으면 「서버가 내가 생각한 걸 안 줬다」와 진짜 결과를 구분할 수 없다.
환경은 Spring Boot 3.5.16과 4.1.1이다. 파싱은 Jackson(자바 객체와 JSON을 서로 바꿔 주는 라이브러리)이 맡는데, Boot 3은 Jackson 2를, Boot 4는 Jackson 3을 쓴다(같은 날 다른 실험에서 Boot 3.5.16은 Jackson 2.21.4, Boot 4.1.1은 3.1.5로 찍혔다). Jackson 판이 다르면 파싱이 실패했을 때 다른 예외가 날 수 있어 둘 다 봤다.
결과 — 오류 응답은 잡혔고 깨진 본문은 다른 타입이었다
아래는 실험 앱의 main이 System.out.println으로 찍은 줄이다. PROBE 줄은 Boot 버전마다 한 줄씩 찍은 결과 요약이고, DIAG 줄은 잡힌 예외의 전체 클래스 이름과 예외 메시지를 그대로 찍은 진단용 줄이다. 읽기 쉽게 자바 버전 · 포트 칸과 DIAG의 버전 칸을 빼고, DIAG는 한 버전 것만 옮겼다(두 버전이 글자까지 같았다).
PROBE boot=3.5.16 raw=ok s500=InternalServerError s500sub=true broken=RestClientException brokensub=false
PROBE boot=4.1.1 raw=ok s500=InternalServerError s500sub=true broken=RestClientException brokensub=false
DIAG 500 | org.springframework.web.client.HttpServerErrorException$InternalServerError
:: 500 Internal Server Error: "{"message":"upstream-boom"}"
DIAG broken | org.springframework.web.client.RestClientException
:: Error while extracting response for type [proof.Probe$Payload] and content type [application/json]
한 줄씩 보면, raw=ok는 깨진 경로를 String으로 먼저 받아 봤을 때 서버가 의도한 깨진 JSON을 실제로 줬다는 확인이다. s500은 상대가 500을 줬을 때 잡힌 예외의 클래스 이름이고, s500sub는 그 예외가 RestClientResponseException의 하위 클래스인지(true·false)를 나타낸다. broken은 본문이 깨졌을 때 잡힌 예외의 클래스 이름이고, brokensub는 그 예외도 마찬가지로 RestClientResponseException의 하위 클래스인지를 나타낸다. DIAG 줄의 HttpServerErrorException$InternalServerError처럼 $로 이어진 이름은 중첩 클래스라는 뜻이다 — $ 앞이 바깥 클래스(HttpServerErrorException)이고 뒤가 그 안에 선언된 클래스(InternalServerError)다. 마지막 DIAG broken 줄의 메시지 Error while extracting response for type [proof.Probe$Payload] and content type [application/json]은 “응답을 Payload 타입으로 꺼내다가(응답 형식은 application/json) 오류가 났다”는 뜻이다. proof.Probe$Payload는 앞에서 말한, 실험 앱 안에 둔 Payload record다. 결과를 보면 s500sub=true·brokensub=false라서, 상대의 500은 하위 클래스로 잡혔지만 깨진 본문은 하위가 아닌 예외로 잡혔다.
판정은 refuted다. 상대가 500을 줄 때는 HttpServerErrorException$InternalServerError로, 즉 RestClientResponseException 하위로 잡혔다. 본문이 깨졌을 때는 RestClientException이고 이건 RestClientResponseException의 하위가 아니다. 두 Boot 버전이 같았다.
예상과 달랐던 점 — 갈림은 Jackson 버전이 아니라 예외 계층에 있었다
Boot 4의 Jackson 3에서 파싱 실패 경로가 달라질까 봐 두 환경을 뒀는데, 두 버전이 같은 타입을 냈다. 갈린 것은 버전이 아니라 「응답을 받았는가」와 「응답을 값으로 바꿨는가」의 차이다. 상태 코드가 오류면 응답 자체가 실패라 RestClientResponseException이고, 본문 변환이 실패하면 그건 응답이 아니라 변환의 실패라 다른 가지로 간다.
여기서 「가지」는 예외 상속 계층의 갈래다. 이 실험에 나온 예외만 그리면 이렇다(맨 위가 부모다).
RestClientException ← 깨진 본문은 이 클래스 그대로 났다
└─ RestClientResponseException ← 상대가 4xx·5xx를 줬을 때의 갈래
└─ HttpStatusCodeException
└─ HttpServerErrorException ← 5xx
└─ InternalServerError ← 500은 이것으로 났다
중간의 두 단계는 저장소에 받아 둔 스프링 소스(spring-framework 7.1.0-SNAPSHOT, Boot 4 계열)의 extends를 따라 읽어 채웠다. Boot 3.5.16이 쓰는 스프링 판의 소스는 읽지 않았다. 다만 두 Boot 버전 모두 s500sub=true라, InternalServerError가 RestClientResponseException 아래에 있다는 것은 둘 다 실험으로 확인됐다.
RestClientException이 부모이고 RestClientResponseException이 자식이다. 자식을 잡는 catch는 부모 타입 예외를 잡지 못한다. 그래서 깨진 본문의 예외는 RestClientResponseException을 잡는 catch에 걸리지 않는다.
표준대로 짠 코드에는 그래서 구멍이 생긴다. 아래처럼 502로 바꾸는 catch를 하나만 두었다고 하자.
try {
// 상대 서버를 불러 응답을 객체로 받는다
} catch (RestClientResponseException e) {
// 502를 뜻하는 우리 예외로 바꿔 던진다
}
try 안에서 상대를 부르고, 상대가 4xx·5xx를 주면 catch가 그 예외를 받아 502로 바꾼다. 「우리 예외」는 표준 글의 ErrorCodeException이다. 상태 502를 담은 에러 코드를 실어 던지면, 요청을 처리하던 쪽이 그 상태로 클라이언트에게 응답한다(모양은 표준 원칙 3의 PaymentClient에 있다).
그런데 깨진 본문의 RestClientException은 이 catch를 지나쳐 메서드 밖으로 그대로 올라간다. 이 메서드는 요청을 받은 Controller가 Service를 부르고, Service가 다시 이 메서드를 부르는 길 위에 있다. 잡히지 않은 예외는 그 길을 거꾸로 거슬러 Controller 밖까지 올라가고, 결국 @RestControllerAdvice의 Exception 핸들러(어디에도 안 걸린 예외를 마지막에 받는 곳)에 닿는다. 표준 글은 그 경로에 500 “서버 내부 오류”를 배정했다. 500은 「서버 쪽 잘못」을 뜻하는 상태 코드이고, 표준은 500 이상을 에러 등급 로그로 남긴다. 그래서 상대가 준 이상한 응답이 응답 상태로도 로그로도 우리 서버의 장애로 기록된다. 원래 502로 가야 할 실패다.
이 결과가 표준에 남기는 것 — 502 catch에 잡을 타입을 하나 더 정해야 한다
이 실험은 「502가 맞는 상태 코드인가」를 판정하지 않는다. 판정한 것은 한 타입으로 다 잡히지 않는다는 사실뿐이다.
규칙은 바로 고치지 않는다. 실험이 틀렸을 수도 있어서다. 대신 표준 글의 해당 원칙 맨 위에 > 반증: <result.md의 요약 문장> — [증명](이 글 링크) 모양의 줄을 단다. result.md는 실험 스크립트가 돌고 나서 직접 써 내는 결과 파일로, 첫머리에 판정과 요약 한 줄이 있다. 줄 앞의 >는 마크다운에서 그 줄을 인용 상자로 보이게 하는 기호다. 그 원칙을 읽는 사람이 “이 원칙은 실험으로 반증됐다”를 바로 보게 하는 표시다(예외 종류별 처리 표준 원칙 3 맨 위에 실제로 달려 있다).
그리고 이 블로그 저장소가 올라가 있는 GitHub에 이슈를 열고 kind: 모순 라벨을 붙인다. 이 블로그는 이슈를 둘로 가른다 — 「모순」은 이미 적힌 글이 틀려서 기존 글을 고쳐야 하는 일, 「미정의」는 정한 적이 없어서 새로 정해야 하는 일이다. 라벨 설명은 “두 표준이 서로를 부정한다”인데, 이번에 부딪친 것은 표준 둘이 아니라 표준의 문장과 실험 결과다. 그래도 고칠 곳이 이미 적힌 표준 글이라는 점이 같아서, 이 블로그는 반증이 나오면 kind: 모순으로 열기로 정해 두었다. 이 이슈에서 깨진 본문을 502로 볼지 다른 코드로 볼지, catch를 RestClientException까지 넓힐지를 정한다.
정리
- 상대의 500은
HttpServerErrorException$InternalServerError로 잡혔다 —RestClientResponseException하위가 맞다 - 깨진 본문은
RestClientException이라 그 catch에 안 걸린다. 두 Boot 버전이 같았다 - 표준대로 짜면 502로 갈 실패가 500으로 기록된다
- 재지 않은 것 — 연결 실패·타임아웃(정한 시간 안에 응답이 안 와 포기하는 것)의 타입, 502·503·504 배정이 HTTP 표준 문서(RFC)의 뜻에 맞는지. 표준 글은 503을 「연결 자체가 안 됨」, 504를 「응답이 안 옴」에 배정했다
자신만의 철학을 만들어가는 중입니다.
댓글남기기