본문 바로가기
빌드노트 검색

빌드노트

RestClient의 400 응답 본문은 예외 안에 남아 있다

RestClient의 기본 400·500 예외에서 상태, Content-Type, 본문을 읽고 성공 DTO와 오류 DTO를 분리한다. Spring 6.2.12와 loopback 서버로 본문 보존, toEntity의 예외, onStatus의 스트림 소비 차이를 실제 비교했다.

Spring RestClient에서 retrieve().body(Pet.class)를 호출했는데, 서버가 보낸 오류 JSON 대신 예외만 받는 경우가 있다. 기본 설정은 4xx·5xx 응답을 성공 타입으로 변환하기 전에 예외로 보낸다. 오류 본문은 기본 예외에 보관되므로, 성공 DTO와 오류 응답을 읽는 경로를 나누면 된다.

정확히는 retrieve()를 호출한 순간에 요청하거나 예외를 던지는 것이 아니다. body()나 toEntity()로 응답을 추출할 때 요청과 상태 처리가 진행된다. 따라서 확인할 곳은 서버의 예외 처리기가 아니라 클라이언트의 호출부와 상태 처리 설정이다. Spring 6.2.12의 retrieve API (새 탭에서 열림)

호출부에서 상태·미디어타입·본문을 함께 꺼낸다

기존 호출을 다음처럼 감싸면 성공 값과 오류 정보를 각각 확인할 수 있다. client는 사용 중인 RestClient, path는 그 호출의 기존 경로다. 예제의 Pet은 {"name":"sample"}을 받는 성공 타입이며, 자신의 성공 DTO로 바꾼다. RestClientResponseException은 org.springframework.web.client, MediaType은 org.springframework.http에서 가져온다. JSON 변환에는 Jackson과 해당 메시지 컨버터가 필요하다.

record Pet(String name) {}
try {
    Pet pet = client.get().uri(path).retrieve().body(Pet.class);
    System.out.println("pet=" + pet);
} catch (RestClientResponseException ex) {
    var headers = ex.getResponseHeaders();
    MediaType type = headers != null ? headers.getContentType() : null;
    System.out.println("exception=" + ex.getClass().getSimpleName());
    System.out.println("status=" + ex.getStatusCode().value());
    System.out.println("type=" + type);
    System.out.println("body=" + ex.getResponseBodyAsString());
    throw ex;
}

마지막 throw ex는 오류를 읽은 뒤에도 실패를 호출자에게 전달한다. 본문을 출력했다는 이유로 빈 성공 값이나 null을 반환하지 않는다. 위 출력은 합성 데이터로 내용을 확인하는 예다. 실제 응답에 개인정보나 토큰이 들어 있다면 필요한 필드만 마스킹해 기록한다.

getResponseBodyAsString()은 응답의 Content-Type에 지정된 문자셋을 쓰고, 없으면 UTF-8을 쓴다. 문자열로 해석하기 전 바이트가 필요하면 getResponseBodyAsByteArray()를 사용한다. 이들은 이미 예외에 저장된 데이터를 읽는 메서드다. 응답 예외 API (새 탭에서 열림)

같은 성공 타입에 세 응답을 보냈다

별도 재현에서는 Windows, OpenJDK 17.0.2, Spring Framework 6.2.12, Jackson 2.18.4를 사용했다. HTTP 클라이언트는 JdkClientHttpRequestFactory로 고정했고, 서버는 127.0.0.1의 임시 포트에만 열었다. 실제 계정이나 외부 API는 호출하지 않았다. 다음은 같은 body(Pet.class)에 보낸 합성 응답과 실제 관찰이다.

  • 200, application/json;charset=UTF-8, {"name":"sample"}: Pet[name=sample]로 변환됐다.
  • 400, application/json;charset=UTF-8, {"code":"INVALID_NAME","message":"name is required"}: HttpClientErrorException.BadRequest가 발생했고, 예외에서 상태·미디어타입·위 본문을 그대로 읽었다.
  • 500, text/plain;charset=UTF-8, temporary failure: HttpServerErrorException.InternalServerError가 발생했고, 텍스트 본문도 그대로 읽었다.

두 오류의 예외에 저장된 바이트는 서버가 보낸 바이트와 같았고, 문자열을 두 번 읽어도 내용이 유지됐다. 오류 응답이 성공 DTO와 다른 모양이어도 기본 경로에서는 먼저 상태 예외가 발생했다. 500의 텍스트를 억지로 Pet으로 변환하다 실패한 것이 아니다.

retrieve()만 호출했을 때 서버 요청 수는 0이었고, 이어서 body()를 호출하자 1이 되며 400 예외가 발생했다. body()를 toEntity(String.class)로 바꾼 비교에서도 기본 400 예외가 발생했다. 상태와 헤더를 얻으려고 toEntity()로 바꾸는 것만으로 오류 경로를 우회할 수는 없다. ResponseSpec의 기본 오류 처리 (새 탭에서 열림)

오류 JSON은 별도 타입으로 변환한다

상태와 미디어타입을 확인한 다음에 오류 스키마를 적용한다. 다음은 상대 API가 위의 400 JSON을 반환한다는 계약이 있을 때, 앞의 catch 안에서 throw ex 직전에 넣을 코드다. RestClientException도 org.springframework.web.client에서 가져온다. 변환 실패가 원래 HTTP 오류를 가리지 않도록 보조 예외로 붙인다.

record ApiError(String code, String message) {}
if (ex.getStatusCode().value() == 400 && type != null
        && MediaType.APPLICATION_JSON.isCompatibleWith(type)) {
    try {
        ApiError error = ex.getResponseBodyAs(ApiError.class);
        if (error != null) System.out.println("code=" + error.code());
    } catch (RestClientException decodeFailure) {
        ex.addSuppressed(decodeFailure);
    }
}

재현의 400에서는 code=INVALID_NAME을 얻었고, 500 텍스트는 이 변환 조건에 들어가지 않았다. getResponseBodyAs()에는 해당 타입·미디어타입을 처리할 컨버터가 필요하다. 기본 RestClient 상태 처리기가 만든 예외에는 변환 함수가 연결되지만, 직접 생성한 예외까지 같은 조건이라고 가정해서는 안 된다. Spring 6.2.12 기본 상태 처리 구현 (새 탭에서 열림)

위 조건은 이 API의 application/json 계약을 대상으로 한다. application/problem+json 등 다른 타입을 쓰는 API라면 그 계약에 맞게 조건과 DTO를 정한다. 미디어타입이 JSON이어도 본문이 비었거나 스키마가 틀릴 수 있으므로, 원래 상태와 원문을 먼저 확보해 두는 순서가 필요하다.

기존 onStatus가 본문을 먼저 읽고 있지는 않은가

이 방법은 기본 상태 처리가 남아 있을 때의 해법이다. 이미 호출에 onStatus가 있거나 빌더에 defaultStatusHandler가 있다면 그 처리기가 무엇을 읽고 던지는지 확인한다. 공식 문서에서 onStatus는 기본 오류 처리의 변경 지점이다. RestClient 오류 처리 설명 (새 탭에서 열림)

같은 서버로 상태 처리기 세 가지도 비교했다. 오류 본문을 읽어 상태·미디어타입·본문을 담은 사용자 예외를 던지면, 200은 Pet으로 받고 400 JSON·500 텍스트는 그 예외에 보존할 수 있었다. 이런 처리가 필요할 때는 성공 타입으로 돌아가지 않도록 오류 처리기 안에서 실패 경로를 끝낸다.

반대로 일치한 onStatus가 아무것도 읽지 않고 정상 반환하면, 기본 오류 예외를 억제할 수 있다. 이 조건에서 toEntity(String.class)는 본문과 함께 여전히 400인 응답을 반환했다. 400이 200으로 바뀐 것은 아니므로 이후 코드가 상태를 판정해야 한다. 본문을 먼저 읽고 정상 반환한 처리기에서는 뒤의 body(String.class)가 null이었다. 이 실험에서는 응답 스트림이 다시 제공되지 않았다. 읽은 내용을 보관하지 않은 채 성공 DTO 변환으로 계속 진행시키면 필요한 오류 정보가 사라질 수 있다.

처리기 순서도 영향을 준다. 재현에서 호출별 onStatus가 일치하면 빌더 처리기의 호출 횟수는 0이었다. 호출별 처리기를 빼면 빌더 처리기가 실행됐다. 설정을 고칠 때는 처리기를 추가하기 전에 먼저 일치하는 기존 처리기를 확인한다. 기본 처리기 적용 순서 (새 탭에서 열림)

상태를 포함한 결과를 직접 분기하려고 exchange()를 선택할 수도 있다. 다만 이 경로에는 상태 처리기가 적용되지 않는다. 재현에서도 500 응답을 읽었지만 빌더 처리기는 호출되지 않았다. 콜백에서 상태 판정과 본문 읽기를 직접 해야 하며, 일반 exchange 콜백이 끝나면 응답이 닫히므로 스트림만 밖으로 돌려줘 나중에 읽어서는 안 된다. exchange의 처리 범위 (새 탭에서 열림)

이번 확인은 작은 정상 JSON·오류 JSON·텍스트 응답과 위 상태 처리 조합에 한정한다. 빈 응답, 잘못된 JSON, 대용량 본문, 중간 읽기 실패, 연결 실패는 실행하지 않았다. 특히 HTTP 응답을 받지 못한 연결 오류에는 꺼낼 서버 오류 본문 자체가 없을 수 있다. 기본 예외의 본문이 비어 있다면 처리기의 선행 소비뿐 아니라 실제 서버 응답과 읽기 실패 여부도 별도로 확인해야 한다.

← 전체 글 보기