본문 바로가기
💻 개발 이야기/Spring framework

[Spring] RestTemplate 에러 핸들러 커스텀하기

by Jinseong Hwang 2022. 7. 10.

 

안녕하세요. 황진성입니다.

오늘은 Spring Web에서 제공하는 RestTemplate의 ErrorHandler를 커스텀해보겠습니다.

 

 

목적


우리는 RestTemplate으로 클라이언트를 생성해서, 또 다른 서버로 동기(sync) 요청을 보낼 수 있습니다.

 

RestTemplategetForEntity 메서드로 GET 요청을 보내면, Response로 받는 JSON을 객체 형태로 변환해서 반환해줍니다.

RestTemplate restTemplate = new RestTemplate();
ResponseEntity<User> response = restTemplate.getForEntity(
    "http://localhost:8080/users/1",
    User.class
);
User user = response.getBody();

 

하지만 요청에 실패할 경우, RestTemplateDefaultResponseErrorHandler에 의해 아래 예외가 발생합니다.

  • HttpClientErrorException (HTTP Status code가 4xx 인 경우)
  • HttpServerErrorException (HTTP Status code가 5xx 인 경우)

DefaultResponseErrorHandler.handleError()

 

여기서 문제점이 있는데요. 요청이 실패하더라도 실패한 이유가 여러 가지 있을 수 있습니다.

하지만 오로지 위 2가지 예외만 발생한다는 것입니다.

 

Exception message를 파싱 해서 원인을 알아낼 수도 있지만, 썩 좋은 방법 같진 않습니다.

 

어떤 원인으로 인해 요청에 실패했는지, 경우에 따라 서로 다른 로직을 적용하고 싶다면 RestTemplate의 Error Handler를 커스텀해서 상황별 서로 다른 예외를 발생시켜 줄 수 있습니다.

 

구현해 봅시다.

 

 

사전 준비


 

User에는 id, name, age 값을 가집니다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class User {

    private int id;
    private String name;
    private int age;
}

 

List 컬렉션을 DB로 가정하고, 미리 User 5명을 만들어서 저장해둡니다.

그리고 User의 ID로 User를 찾을 수 있는 findUserById 메서드가 있습니다.

원하는 id의 User를 찾지 못했다면, CustomException을 발생시킵니다.

@Service
public class UserService {

    private static final List<User> users;

    static {
        users = new ArrayList<>(List.of(
            new User(1, "eddy.a", 20),
            new User(2, "eddy.b", 10),
            new User(3, "eddy.c", 18),
            new User(4, "eddy.d", 27),
            new User(5, "eddy.e", 5)
        ));
    }

    public User findUserById(int id) {
        return users.stream()
                    .filter(user -> user.getId() == id)
                    .findAny()
                    .orElseThrow(CustomException::new);
    }
}
public class CustomException extends RuntimeException {
}

 

 

그리고 id를 Request URI 마지막에 넣어주면, ResponseEntity<User>를 반환하는 컨트롤러를 구현했습니다.

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping("/users/{id}")
    public ResponseEntity<User> getUsers(@PathVariable int id) {
        return ResponseEntity.ok(userService.findUserById(id));
    }
}

 

 

테스트


 

 

저는 RestTemplateBean으로 등록해서, 주입받아서 사용할 예정입니다.

@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate basicRestTemplate() {
        return new RestTemplate();
    }
}

 

JUnit을 활용한 테스트 코드로 검증해봅시다.

 

User ID가 1~5 인 경우에는, 요청을 보냈을 때 예외가 발생하지 않아야 합니다.

 

저는 랜덤 값의 테스트 신뢰도를 높이기 위해 100회 반복했습니다.

(하지만 운영 환경에 대한 테스트라면, 랜덤 값을 사용하는 테스트는 좋은 방식이 아닙니다.)

 

Test 1 : getUser_success_using_basicRestTemplate()

  • 1 이상 6 미만의 랜덤 정수를 ID로, 요청을 보내면 예외가 발생하지 않아야 합니다.

Test 2 : getUser_failure_using_basicRestTemplate() 

  • 6 이상의 랜덤 정수를 ID로, 요청을 보내면 예외가 발생해야 합니다.
  • 서비스 레이어(UserService)에서는 CustomException을 발생시키지만, 컨트롤러 레이어(UserController)에서 예외가 감지되었기 때문에 500 Internal Server Error를 응답으로 RestTemplate으로 넘겨줍니다.
  • RestTemplateDefaultResponseErrorHandlerHttpServerErrorException을 발생시킵니다. 따라서 HttpServerErrorException 발생 여부로 검증합니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerTest {

    @LocalServerPort
    int port;

    @Autowired
    RestTemplate basicRestTemplate;

    private static final int TEST_REPS = 100;

    private static final String URI_FORMAT = "http://localhost:%d/users/%d";
    private static final int MIN_ID = 1; // inclusive
    private static final int MAX_ID = 6; // exclusive

    // --- Using Basic RestTemplate

    @RepeatedTest(TEST_REPS)
    void getUser_success_using_basicRestTemplate() {
        // Given
        int randomId = ThreadLocalRandom.current().nextInt(MIN_ID, MAX_ID);

        // When & Then
        assertDoesNotThrow(
            () -> basicRestTemplate.getForEntity(
                String.format(URI_FORMAT, port, randomId),
                User.class
            )
        );
    }

    @RepeatedTest(TEST_REPS)
    void getUser_failure_using_basicRestTemplate() {
        // Given
        int randomId = ThreadLocalRandom.current().nextInt(MAX_ID, Integer.MAX_VALUE);

        // When & Then
        assertThrows(
            HttpServerErrorException.class,
            () -> basicRestTemplate.getForEntity(
                String.format(URI_FORMAT, port, randomId),
                User.class
            )
        );
    }
}

 

이 테스트는 정상적으로 통과합니다.

  • 1~5 범위의 ID에 대해서는 예외가 발생하지 않으며,
  • 6 이상의 ID에 대해서는 HttpServerErrorException이 발생하는 것을 확인했습니다.

 

 

ErrorHandler 구현


@Component
public class CustomResponseErrorHandler implements ResponseErrorHandler {

    @Override
    public boolean hasError(ClientHttpResponse response) throws IOException {
        return response.getStatusCode().is4xxClientError() ||
               response.getStatusCode().is5xxServerError();
    }

    @Override
    public void handleError(ClientHttpResponse response) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        InternalServerErrorVO internalServerErrorVO = objectMapper.readValue(getResponseBody(response), InternalServerErrorVO.class);
        if (HttpStatus.INTERNAL_SERVER_ERROR == HttpStatus.valueOf(internalServerErrorVO.getStatus()) &&
            internalServerErrorVO.getError().equals("Internal Server Error")) {
            throw new CustomException();
        }

        DefaultResponseErrorHandler defaultResponseErrorHandler = new DefaultResponseErrorHandler();
        defaultResponseErrorHandler.handleError(response);
    }

    private byte[] getResponseBody(ClientHttpResponse response) {
        try {
            return FileCopyUtils.copyToByteArray(response.getBody());
        } catch (IOException ignored) {
        }
        return new byte[0];
    }
}

 

RestTemplate에 들어가는 Error Handler를 커스텀하기 위해선, ResponseErrorHandler 인터페이스를 구현해야 합니다.

구현한 각 메서드에 대해 조금 더 자세하게 알아봅시다.

 

hasError(response)

이 메서드에서 True를 반환하면, handleError 메서드로 response가 넘어갑니다.

구현 조건은 DefaultResponseErrorHandler와 거의 동일합니다.

  • response의 Status code가 4xx or 5xx 라면 True를 반환합니다.
  • 그 외의 상황이라면 False를 반환합니다.

 

handleError(response)

실질적으로 response를 읽고, 처리 로직이 들어가는 메서드입니다.

  • getResponseBody 메서드를 통해 response의 body에 담겨 있는 메시지를 String 타입(JSON)으로 가져옵니다.
  • String 형식의 JSON을 ObjectMapper를 활용해 객체 타입으로 변환합니다. 저는 InternalServerErrorVO 를 만들었습니다.
@Getter
@NoArgsConstructor
public class InternalServerErrorVO {

    String timestamp;
    int status;
    String error;
    String path;
}
  • 저는 response status code가 500이고, error가 "Internal Server Error" 라면, CustomException이 발생하도록 구현했습니다. 이 부분은 상황에 맞게 구현하시면 됩니다.
  • response body의 내용이 아무 조건도 만족하지 못한다면, DefaultResponseErrorHandler로 처리를 위임하도록 했습니다. 그러면 HttpClientErrorException 혹은 HttpServerErrorException이 발생하겠죠.

 

getResponseBody(response)

response에서 body를 가져오는 메서드입니다. 이 부분은 DefaultResponseErrorHandler의 구현을 동일하게 가져왔습니다.

 

 

 

그리고 이전에 작성했던 RestTemplateConfig 를 조금 수정해봅시다.

@RequiredArgsConstructor
@Configuration
public class RestTemplateConfig {

    private final CustomResponseErrorHandler customResponseErrorHandler;

    @Bean
    public RestTemplate basicRestTemplate() {
        return new RestTemplate();
    }

    @Bean
    public RestTemplate customRestTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setErrorHandler(customResponseErrorHandler);
        return restTemplate;
    }
}

 

우선 Component로 등록했던 CustomResponseErrorHandler를 생성자 주입 방식으로 가져옵니다.

그리고 "customRestTemplate" 이름을 가진 Bean을 하나 더 생성해줍니다.

customRestTemplate에는 setErrorHandler 메서드를 활용해 Error Handler를 등록해줍니다.

(사실상 DefaultResponseErrorHandler 에서 CustomResponseErrorHandler 로 교체해주는 과정입니다.)

 

 

다시 테스트


 

아까 작성했던 테스트 코드에 CustomErrorHandler 를 사용하는 테스트를 추가해봅시다.

 

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerTest {

    @LocalServerPort
    int port;

    @Autowired
    RestTemplate basicRestTemplate;

    @Autowired
    RestTemplate customRestTemplate;

    private static final int TEST_REPS = 100;

    private static final String URI_FORMAT = "http://localhost:%d/users/%d";
    private static final int MIN_ID = 1; // inclusive
    private static final int MAX_ID = 6; // exclusive

    // --- Using Basic RestTemplate

	// ... 위에 있음 ...

    // --- Using Custom RestTemplate

    @RepeatedTest(TEST_REPS)
    void getUser_success_using_customRestTemplate() {
        // Given
        int randomId = ThreadLocalRandom.current().nextInt(MIN_ID, MAX_ID);

        // When & Then
        assertDoesNotThrow(
            () -> customRestTemplate.getForEntity(
                String.format(URI_FORMAT, port, randomId),
                User.class
            )
        );
    }

    @RepeatedTest(TEST_REPS)
    void getUser_failure_using_customRestTemplate() {
        // Given
        int randomId = ThreadLocalRandom.current().nextInt(MAX_ID, Integer.MAX_VALUE);

        // When & Then
        assertThrows(CustomException.class,
                     () -> customRestTemplate.getForEntity(
                         String.format(URI_FORMAT, port, randomId),
                         User.class
                     )
        );
    }
}

 

기존의 RestTemplate(basicRestTemplate)과 마찬가지로, customRestTemplate도 필드 주입받습니다.

 

이 테스트는 정상적으로 통과합니다.

  • 1~5 범위의 ID에 대해서는 예외가 발생하지 않으며,
  • 6 이상의 ID에 대해서는 CustomException이 발생하는 것을 확인했습니다.

 

테스트 방식도 이전에 작성했던 것과 동일한데요. 변경된 부분이 하나 존재합니다.

실패했을 경우, 저희가 정의한 CustomException이 발생한다는 점입니다.

 

여기까지 입니다.

 

 

이제 저희는 RestTemplate을 활용해서, 실패 원인에 따라 서로 다른 예외를 발생시킬 수 있게 되었습니다.

예외 타입을 보고 서로 다른 비즈니스 로직으로 분기할 수도 있습니다. 😀

 

 

 

 

이 글에서 사용된 동작 가능한 코드는 제 Github에 있습니다.

 

GitHub - JinseongHwang/spring-playground: Spring, Java, Study

:seedling:Spring, :coffee:Java, :books:Study. Contribute to JinseongHwang/spring-playground development by creating an account on GitHub.

github.com

 

감사합니다.

 

 

 

Appendix

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 를 사용한 이유


 

@SpringBootTest 인터페이스 내부의 코드를 살펴보겠습니다.

@SpringBootTest

WebEnvironment 라는 필드를 가지고 있고, 디폴트 값으로 WebEnvironment.MOCK 값을 가집니다.

이는, 별도의 설정이 없다면 실제 Tomcat을 띄우지 않고 테스트를 진행합니다.

하지만 저희는 실제로 요청을 보내는 테스트를 진행했으므로, Tomcat 서버를 실제로 띄워줘야 합니다.

 

 

@SpringBootTest.WebEnvironment

@SpringBootTest 에 보면 WebEnvironment 라는 이름의 enum 타입을 볼 수 있는데요.

RANDOM_PORT 뿐만 아니라, DEFINED_PORT 도 Tomcat을 띄우는 것을 확인할 수 있습니다.

 

근데 왜 RANDOM_PORT를 사용할까요?

그 이유는 RANDOM_PORT를 사용할 경우 저희 테스트 환경이 다른 프로세스와의 포트 충돌을 예방할 수 있기 때문입니다.

 

아래 블로그를 참고하셔도 좋습니다.

 

참고자료