스프링으로 프로젝트를 진행하다 보면 정해진 입력값이 잘못되거나 없거나 하는 등 다양한 예외들이 발생할 것이다. 아래와 같이 흔히 JPA로 데이터를 가져올 때도 findById를 사용할 때 잘못될 경우를 대비하여 exception을 던질 것이다.
User user = userRepository.findById(user.getId())
.orElseThrow(() -> new IllegalStateException(
"userId가 " + user.getId() + "인 user를 찾지 못했습니다."));
그렇다면 위에 코드처럼 findById를 통해 데이터를 가져오려고 했지만 예외가 발생했을 때에는 클라이언트한테는 어떻게 표시될까?
뭐라고 하는지도 모를 이상한 에러가 보여질 것이다.
그렇기에 이러한 예외들이 발생하였을 때 예외가 무엇인지 파악하고 적절한 페이지나 문구를 사용자에게 전달하여 무엇 때문에 오류가 발생했는지를 알려줘야 사용자들도 확실히 이해할 수 있을 것이다. 이럴 때 필요한 것이 @ExceptionHandler이다.
@ExceptionHandler를 다루기 전에 간단히 커스텀 exception 에 대해 알아보겠다.
우리가 예외를 던질 때 자주사용하는 것은 IllegalStateException 나 RuntimeException 일 것이다. 그렇지만 우리가 코드를 볼 때 예외를 저런 이름을 하는 것보다 상황에 맞는 이름으로 하는 것이 훨씬 더 가독성이 있고 효율적일 것이다. 이럴 때 커스텀 exception을 만들어서 사용하면 된다. 아래는 예시 코드이다.
public class NotExistsUserException extends IllegalStateException {
public NotExistsUserException(String message) {
super(message);
}
}
User user = userRepository.findById(user.getId())
.orElseThrow(() -> new NotExistsUserException(
"userId가 " + user.getId() + "인 user를 찾지 못했습니다."));
첫 번째 코드처럼 IllegalStateException를 상속받는 NotExistsUserException를 구현하였고 findById에서의 예외처리를 진행할 때 NotExistsUserException를 사용했다. 이런 식으로 작성을 한다면 IllegalStateException를 사용했을 때와 똑같이 동작할 것이지만 좀 더 가독성 있고 상황에 맞는 이름의 exception을 사용할 수 있다.
이제 이런 커스텀 exception 에 걸렸을 때 각 exception에 따라 클라이언트에게 전달하는 것을 알아보겠다. 클라이언트로부터 요청을 받고 응답을 보내는 것은 controller에서 처리한다. 만약 로직을 수행하는 중간에 예외가 발생한다면 각 예외마다 설정된 @ExceptionHandler를 통해 사용자에게 원래의 로직을 진행하는 응답이 아닌 예외를 알리는 응답을 보낼 수 있다.
각 controller에다 @ExceptionHandler를 통한 메서드를 만들 수 있다. 하지만 그렇게 하기보단 모든 @ExceptionHandler를 한 곳에 모아두는 것이 더 효율적인 것이다. 아래의 간단한 예시 코드를 먼저 한번 봐보자.
@RestControllerAdvice
public class ExceptionAdvice {
@ExceptionHandler(AlreadyExistException.class)
public ResponseEntity<ErrorResponse> handleAlreadyExistException(AlreadyExistException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(AlreadySignedUpException.class)
public ResponseEntity<ErrorResponse> handleAlreadySignedUpException(
AlreadySignedUpException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(NotAuthorizedOrExistException.class)
public ResponseEntity<ErrorResponse> handleNotAuthorizedOrExistException(
NotAuthorizedOrExistException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(NotExistsException.class)
public ResponseEntity<ErrorResponse> handleNotExistsException(NotExistsException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(NotIncludedMapException.class)
public ResponseEntity<ErrorResponse> handleNotIncludedMapException(NotIncludedMapException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(WrongGroupIdException.class)
public ResponseEntity<ErrorResponse> handleWrongGroupIdException(WrongGroupIdException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(WrongMapTypeException.class)
public ResponseEntity<ErrorResponse> handleWrongMapTypeException(WrongMapTypeException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(WrongInputException.class)
public ResponseEntity<ErrorResponse> handleWrongInputException(
WrongInputException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(NotAuthorizedRedirectUriException.class)
public ResponseEntity<ErrorResponse> handleNotAuthorizedRedirectUriException(
NotAuthorizedRedirectUriException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(OAuth2AuthenticationProcessingException.class)
public ResponseEntity<ErrorResponse> handleOAuth2AuthenticationProcessingException(
OAuth2AuthenticationProcessingException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage()));
}
}
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
@Getter
public class ErrorResponse {
private String errorMessage;
}
ExceptionAdvice를 보면 @RestControllerAdvice가 붙여져 있다. 이는 @RestControtroller에서 발생한 예외를 한곳에서 다룰 수 있게 해 준다. 만약 @Controller에서 발생한 예외를 다루고 싶다면 @ControllerAdvice를 붙여주면 된다.
각 메서드마다 @ExceptionHandler가 붙여져 있다. 이는 발생할 수 있는 예외의 종류마다 설정한 것이고 괄호에는 자신이 만든 커스텀 exception을 담으면 된다. 매서드의 매개변수에도 타입을 커스텀 exception으로 전달받을 수 있도록 한다.
일반적인 RestController 처럼 응답을 status와 body를 전달한다. 하지만 status는 200, 201, 204 등과 같이 일반적인 것과는 다르게 badRequest(400), notFiund(404) 등 잘못된 결과가 나왔다는 것을 알리기 위한 status를 전달하면 된다. 거기다 body에는 getMessage를 통해 내용을 전달할 수 있다. 위의 findById에서 예외가 발생했을 시에는 "userId가 " + user.getId() + "인 user를 찾지 못했습니다." 이란 메시지가 입력으로 주어진 Id가 user.getId() 자리를 차지한 상태로 전달될 것이다.
이 두가지를 가지고 프론트에서 클라이언트에게 에러가 발생했다는 것을 알리는 페이지를 보여준다던가 하는 등 에러 처리를 진행할 수 있다. 물론 다르게 전달하고 구현하여도 문제는 없다.
그다음 @RestControllerAdvice와 @ExceptionHandler가 잘 동작되는지를 테스트를 해야 한다.
아래는 간단한 예시 코드이다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class ExceptionAdviceTest {
@Mock
private SampleController samPleController;
private MockMvc mockMvc;
@BeforeEach
public void setUp() throws Exception {
this.mockMvc = MockMvcBuilders
.standaloneSetup(sampleController)
.setControllerAdvice(new ExceptionAdvice())
.build();
}
@Test
void handleAlreadyExistException() throws Exception {
when(sampleController.test()).thenThrow(
new AlreadyExistException("test"));
mockMvc.perform(get("/test"))
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("errorMessage").value("test"));
}
}
위와 같이 테스트 코드를 작성하였다. test코드를 보면은 sampleController에 test매서드가 동작되면 에러를 던지도록 하였고 그 에러는 테스트하고자 하는 ExceptionHandler에 해당하는 exception을 지정하였다. 즉, sampleController는 테스트 용도로 만든 더미 controller인 것이다.
이러한 방식으로 구현하여도 되고 mock객체를 사용한다던가 원하는 데로 예외를 발생시키기만 하면 된다.
mockMvc로 get 요청을 받으면 test 매서드가 실행돼서 @ExeptionHandler로 인한 예외 응답이 올 것이다. status 코드와 errorMessage를 확인함으로써 동작이 이뤄지는지를 확인할 수 있다.
여기서 중요한 것은 @Before setUp 매서드에서 진행한 내용이다. 컴퓨터는 sampleController를 실행해도 ExceptionAdvice에 @ExceptionHandler가 있다는 것을 알지 못한다. 그렇기에 이러한 클래스가 있다는 것을 알려주기 위해서 위와 같이 작성을 하였다. 이렇게 작성하지 않으면 에러가 발생을 할 것이다.
물론 이건 단순히 에러를 던짐으로써 응답이 보내지는 것을 테스트하는 것뿐이다.
통합 테스트를 통해 에러가 발생하는 로직을 수행하여 테스트를 하는 것도 중요하다.
참고
https://dev-jejeb.tistory.com/50
https://bamdule.tistory.com/92
https://freehoon.tistory.com/109
'Spring' 카테고리의 다른 글
Spring Controller를 REST 방식으로 변화 - Rest API (0) | 2022.02.23 |
---|---|
스프링 MVC에 대한 간단 정리 (0) | 2022.02.09 |
스프링 프레임워크의 특징 및 의존성 주입 (0) | 2021.12.06 |
springboot 이미지파일을 aws 서버에 보관하고 가져오기(s3) (0) | 2021.09.16 |
SpringBoot Controller Test 작성 및 spring-security에서의 test (0) | 2021.08.31 |