Spring API 프로젝트에서 공통적인 에러 코드로 응답하기.
API 개발시 어떠한 처리 결과에도 동일한 포맷의 응답(response) 을 리턴하는 것은 중요하다. 아래는 가장 간단한 예이다.
{
"code": 200,
"message": "ok",
"result": {}
}
code / message 로 미리 정의된 응답 코드와 메시지를 확인할 수 있고, 정상적으로 처리가 되었을 경우 result 에서 결과를 확인할 수 있다. 디테일이 필요하다면 더 다양한 코드에 상세한 설명을 넣으면 되고, 보안을 강화한다면 메시지를 없앨 수도 있다. 이러한 공통적인 포맷을 Spring 에서 구현하는 방법을 살펴 보았다.
1. whitelabel 에러 페이지
Spring Boot 는 기본적으로 text/html 타입의 요청에 대해 whitelabel 이라는 오류 페이지를 제공한다. 기존 톰캣의 보라색 에러 페이지 보다는 괜찮아 보일 수 있다.
브라우저에서 접속했을 때에도 에러 페이지를 커스텀 하려면 properties 파일에서 whitelabel 페이지를 비활성화 하고 에러 페이지를 재정의해야 한다.
# application.properties
server.error.whitelabel.enabled=false
2. 기본 json 에러 응답
Spring Boot 는 기본적으로 RESTful 이나 application/json 타입의 요청에 대해 basicErrorController 로부터 아래와 같은 포맷으로 응답한다.
{
"timestamp": "2022-02-15T04:28:46.310+00:00",
"status": 404,
"error": "Not Found",
"path": "/a"
}
위 속성은 DefaultErrorAttributes 에서 정의되는데 속성만 추가하려면 application.properties 에서 server.error 옵션들을 수정하던지 DefaultErrorAttributes 를 상속하여 커스텀 할 수도 있다.
# message 항목 보이기
server.error.include-message=always
@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
Map<String, Object> result = super.getErrorAttributes(webRequest, options);
result.put("description", "abcdefg");
return result;
}
}
3. AbstractErrorController 상속하여 재정의
basicErrorController 처럼 ErrorController 인터페이스를 구현한 AbstractErrorController 를 상속하여 에러 응답을 재정의할 수 있다.
@RestController
@RequestMapping("${server.error.path:${error.path:/error}}")
public class CustomErrorController extends AbstractErrorController {
public CustomErrorController(ErrorAttributes errorAttributes) {
super(errorAttributes);
}
@RequestMapping
public ResponseEntity<ErrorMessageEntity> customError(HttpServletRequest request) {
HttpStatus httpStatus = getStatus(request);
CustomResponseEntity customResponseEntity = new CustomResponseEntity();
customResponseEntity.setCode(httpStatus.value());
customResponseEntity.setMessgae(httpStatus.getReasonPhrase());
customResponseEntity.setResult(null);
return new ResponseEntity<>(customResponseEntity, httpStatus);
}
}
@Entity
public class CustomResponseEntity {
private Integer code;
private String messgae;
@JsonInclude(JsonInclude.Include.NON_NULL)
private String result;
...
}
기본 에러 경로인 /error (properties 파일: server.error.path) 는 basicErrorController 에서 매핑을 시도하므로, ErrorController 인터페이스를 구현하지 않고 /error 컨트롤러를 사용한다면 런타임 에러가 발생한다.
Caused by: java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'basicErrorController' method
4. ResponseStatusException 사용
위와 같이 AbstractErrorController 를 상속하면 모든 HTTP 상태 코드에 대한 응답을 커스텀 할 수 있다. 여기에 Spring 5+ 를 사용 중이라면 RuntimeException 을 상속하는 ResponseStatusException 를 이용해 어디에서든 원하는 에러를 발생시킬 수 있다.
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid path");
특정 상황에 특정 에러 코드와 메시지로 응답하고 싶었는데 2번째 파라미터를 응답 엔티티에 넣는 것이 쉽지 않았다.
5. Custom Exception 생성
ResponseStatusException 을 대신하여 메시지도 응답할 수 있도록 CustomException 을 생성한다.
@Component
public class CustomException extends RuntimeException {
private ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return this.errorCode;
}
}
에러 메시지를 enum 으로 정리한다.
public enum ErrorCode {
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "Expired Token"),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid Token"),
SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
...
;
private final HttpStatus status;
private final String message;
ErrorCode(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
public HttpStatus getStatus() {
return this.status;
}
public String getMessage() {
return this.message;
}
}
컨트롤러 전역에서 에러를 핸들링할 수 있도록 @ControllerAdvice, @ExceptionHandler 를 작성한다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<CustomResponseEntity> myCustomException(CustomException ex) {
CustomResponseEntity customResponseEntity = new CustomResponseEntity();
ErrorCode errorCode = ex.getErrorCode();
customResponseEntity.setCode = errorCode.getStatus().value();
customResponseEntity.setMessage = errorCode.getMessage();
return new ResponseEntity<>(customResponseEntity, errorCode.getStatus());
}
}
특정 코드에 에러 발생시키기.
throw new CustomException(EXPIRED_TOKEN);
// output
{
"code": 401,
"message": "Expired Token"
}
6. 404 error 핸들러 추가
여기까지 하면 404 에러 발생시 에러 핸들러를 찾을 수 없다는 500 에러가 발생하게 되는데, dispatcher servlet 에서 요청을 처리할 수 없는 핸들러가 생긴다면 NoHandlerFoundException 예외를 발생시키도록 아래와 같이 수정한다.
# application.yaml
spring:
mvc:
throw-exception-if-no-handler-found: true
web:
resources:
add-mappings: false
위 5번 GlobalExceptionHandler 에 아래 NoHandlerFoundException 추가하여 NoHandlerFoundException 예외를 처리한다.
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<DefaultMessageEntity> handle404(NoHandlerFoundException ex) {
CustomResponseEntity customResponseEntity = new CustomResponseEntity();
customResponseEntity.setCode = HttpStatus.NOT_FOUND.value();
customResponseEntity.setMessage = HttpStatus.NOT_FOUND.getReasonPhrase()
return new ResponseEntity<>(customResponseEntity, HttpStatus.OK);
}
CustomException(EXPIRED_TOKEN) 이라는 에러를 발생시키면, @ExceptionHandler 에서 해당 예외를 처리하는 예제이다. CustomException 을 제외한 예외들은 3번에서 작성한 CustomErrorController 에 의해 처리될 것이다. 3번 5번 6번 조합!ㄱ
WRITTEN BY
- 손가락귀신
정신 못차리면, 벌 받는다.