4번째 필수 강좌 Part 1. ch4. 1강 요약
과거에는 try-catch 문으로 특정 예외를 잡고, 예외가 발생한 경우에는 특정한 결과값을 반환하도록 하는 식으로 예외를 처리했다. 그러나 이렇게 실패에 대한 응답을 만든 경우에는 예외 결과값도 함께 받아야 하고, 정보값에 실패값도 포함하는 이질적인 구조를 만들어야 해서 로직의 복잡도가 올라가고, 재활용성이 떨어진다는 문제가 있다.
때문에 try-catch로 특정값을 반환하기보다는 void로 주고, 성공한 경우에 대해서만 코드를 짜고 예외는 따로 컨트롤러에서 예외 핸들러를 만들어 처리하는 방법을 사용했다. 예외가 발생하면 이를 잡아 예외 메시지를 담고 있는 객체를 반환하는 형식이다.
예외 발생 시 반환할 응답 객체 DMakerResponse를 만든다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DMakerErrorResponse {
private DMakerErrorCode errorCode;
private String errorMessage;
}
위의 응답은 DMakerException과 비슷한 구조를 가지고 있다. 일반적으로 실무에서는 API 별로 성공 때 내려주는 응답들은 다 개별적인 형태를 취하고, 성공 외에 실패가 뜬 경우에는 이렇게 공통된 실패 DTO 객체를 구조에 맞춰 반환하는 방식을 사용한다고 한다.
@ExceptionHandler(DMakerException.class)
public DMakerErrorResponse handleException(DMakerException e,
HttpServletRequest request){
log.error("errorCode: {}, url: {}, message: {}",
e.getDMakerErrorCode(), request.getRequestURI(), e.getDetailMessage());
return DMakerErrorResponse.builder()
.errorCode(e.getDMakerErrorCode())
.errorMessage(e.getDetailMessage())
.build();
}
DMakerExcpetion을 받아 DMakerErrorResponse를 반환하는 ExceptionHandler를 만들었다. 먼저 DMakerException과 HttpServletRequest를 함께 받으며, 발생한 예외 코드와 URI, 에러 메시지를 로그에 출력한다. 마지막으로 발생한 예외를 DMakerErrorResponse 객체로 build 하여 반환하게 된다.
중복된 memberId를 가진 Developer 객체를 생성할 경우, DUPLICATED_MEMBER_ID 응답을 받아오는 것을 확인할 수 있었다.
추가로, Exception Handler로 내려간 응답은 예외가 발생했더라도 200 OK로 내려가기 때문에 매치가 되지 않는다. 이는 @ResponseStatus 애너테이션을 통해 다른 값으로 바꿔줄 수 있다.
@ResponseStatus(value = HttpStatus.CONFLICT)
위의 줄을 추가하고 다시 같은 중복된 Developer를 생성할 경우 다른 StatusCode를 받는 것을 확인할 수 있다.
Rest API 등에서는 HttpStatus를 잘 사용해 에러 메시지를 내릴 것을 권장하지만, 실무에서는 StatusCode를 변경하기보다는 200 OK로 받은 후 세부정보에서 정확한 에러의 종류와 원인에 대한 정보를 내려주는 추세라고 한다.
이 경우에서도, memberId 중복으로 인한 conflict가 일어났다고 하지만 memberId가 아닌 다른 요소가 중복되어 에러가 발생했을 수도 있고, 발생한 예외와 HttpStatus가 완전히 딱 떨어지지 않는 경우도 많기 때문이다. 또 에러 코드만으로 에러를 판별하기는 어렵기 때문에, 이를 억지로 끼워맞추기보다는 따로 예외를 정의하여 반환하는 게 더 낫다.
위에서는 ExceptionHandler를 컨트롤러 내부에 작성해주었다. 이는 곧 해당 컨트롤러 내에서 발생하는 예외들을 핸들링해준다는 뜻으로, 다른 컨트롤러에서 발생하는 예외는 잡아주지 못한다. 에를 들어 CMakerController가 새로 생겼다면, 이 컨트롤러에서 예외 핸들러를 또 새로 만들어주어야 한다.
컨트롤러가 한두개면 크게 문제되지 않지만, 서비스 규모가 커져서 컨트롤러가 여러 개가 되면 불편해진다.
하여 조금 더 글로벌하게 예외 처리를 할 수 있도록 예외 핸들러 클래스를 만드는 방법이 있다.
이 때 사용하는 애너테이션은 @ExceptionHandler가 아닌 @RestControllerAdvice로, 각 컨트롤러에 조언을 해주는 느낌의 특수한 클래스이다.
또, DMakerException으로 예상하지 못했던 오류나 컨트롤러 내부로 진입하기도 전에 에러가 발생하더라도 받아 예외처리를 해줄 수도 있다. 예를 들면 POST 메서드 요청만 받는 /create-developer에 GET으로 요청이 들어왔을 경우 반환하는 HttpRequestMethodNotSupportedException,
Bean 검증을 통과하지 못한 경우 발생하는 MethodArgumentNotValidException등이 있다.
이 외에도 예상하지 못한 예외를 처리하고 싶다면 Exception.class를 받으면 된다.
@RestControllerAdvice
@Slf4j
public class DMakerExceptionHandler {
@ExceptionHandler(DMakerException.class)
public DMakerErrorResponse handleException(DMakerException e,
HttpServletRequest request){
log.error("errorCode: {}, url: {}, message: {}",
e.getDMakerErrorCode(), request.getRequestURI(), e.getDetailMessage());
return DMakerErrorResponse.builder()
.errorCode(e.getDMakerErrorCode())
.errorMessage(e.getDetailMessage())
.build();
}
@ExceptionHandler(value = {
HttpRequestMethodNotSupportedException.class,
MethodArgumentNotValidException.class,
})
public DMakerErrorResponse handleBadRequest(
Exception e, HttpServletRequest request){
log.error("url: {}, message: {}",
request.getRequestURI(), e.getMessage());
return DMakerErrorResponse.builder()
.errorCode(DMakerErrorCode.INVALID_REQUEST)
.errorMessage(DMakerErrorCode.INVALID_REQUEST.getMessage())
.build();
}
}
예외처리를 하지 않는다면, 1차적으로는 예외가 발생했을 때 디폴트로 지정된 메시지가 출력되게 된다.
필수값이 빠진 채로 요청을 받았다면, 예외처리를 했을 때 어떤 요소가 빠져서 예외가 발생했다고 커스텀된 예외가 발생하는 것과 달리 그저 Bad Request만이 반환된다. 프레임워크에 종속된 Error message가 자동으로 생성되기 때문에 정확히 어떤 오류가 발생한 것인지 알 수 없고, 불필요한 에러가 발생하게 된다. 또, 이렇게 정의되지 않은 응답으로 error message가 전달될 경우 프론트에서 처리하기도 까다롭다.
error message가 그대로 반환되면 에러 스택이나 sql 쿼리 문장이 노출될 수도 있다. 이 경우에는 테이블의 구조 및 내부 설계를 추측할 수 있어 보안 등에 위협이 되기 때문에 예외 처리에 신경써야 한다.
컨트롤러는 Presentation 레이어로, 요청을 받고 응답을 주는 역할을 한다. 컨트롤러 단에서는 요청값을 잘 받아왔는지 정도만 체크하고, 비즈니스 로직은 섞이지 않는 것이 좋다. 특정 트랜잭션을 구분해야 하는 경우에만 컨트롤러 상에서 이를 분리하는 로직을 넣어줄 뿐 그 외의 로직은 포함하지 않는 것이 좋다.
또, null을 반환하도록 만드는 것은 null 체크가 필연적으로 수반되는 불편함이 있고 다루기 어렵기 때문에 되도록이면 의도적으로 반환하게 만들지 않는 편이 좋다.