들어가며
우리 프로그램에서는 @RestControllerAdvice를 사용하여
모든 @RestController에서 발생하는 예외에 대하여 로그를 찍고 슬랙에 알림을 보내고 있다
대부분이 @RestControllerAdvice에서 잘 처리되나 몇몇 에러의 경우 스프링의 BasicErrorController 포맷으로 응답값이 왔다
따라서 우리 프로그램에서는 아래와 같이 두 가지 형태로 에러 메시지가 왔다
물론 모든 에러가 @RestControllerAdvice에서 처리된다면 가장 좋겠지만
BasicErrorController 에서 에러가 처리되는 경우에도
로그를 찍고 슬랙에 알림을 보내며, 오른쪽과 같은 에러 형식으로 보내주고 싶었다!
슬랙 에러 알림 관련 포스팅
https://seowoolog.tistory.com/62
기본 json 에러 응답
스프링 부트는 기본적으로 RESTful 이나 application/json 타입의 요청에 대해 basicErrorController로 부터 아래와 같은 형식으로 응답한다
{
"timestamp": "2022-02-15T04:28:46.310+00:00",
"status": 404,
"error": "Not Found",
"path": "/loginsss"
}
여기서 속성만 추가하려면 application.yml에서 아래와 같이 설정을 통해 더 많은 정보를 받을 수 있다
//application.yml
server:
error:
include-exception: false
include-stacktrace: always
include-message: always
include-binding-errors: always
{
"timestamp": "2023-09-05T15:54:47.244+00:00",
"status": 500,
"error": "Internal Server Error",
"trace": "org.springframework.orm.ObjectRetrievalFailureException: Object [id=2] was not of the specified subclass [FIS.iLUVit.domain.User] : Discriminator: ; nested exception is org.hibernate.WrongClassException: Object [id=2] was not of the s~~~",
"message": "Object [id=2] was not of the specified subclass [FIS.iLUVit.domain.User] : Discriminator: ; nested exception is org.hibernate.WrongClassException: Object [id=2] was not of the specified subclass [FIS.iLUVit.domain.User] : Discriminator: ",
"path": "/child/463"
}
하지만 message나 trace까지 클라이언트 응답값으로 가는 것은 보안상 위협이 있다고 했기 때문에 좋은 방법이 아니다.
따라서 직접 BasicErrorControllr를 커스텀하여 log로 에러 메시지를 찍고, 슬랙에 알림을 보내는 코드도 추가하기로 하였다!
BasicErrorResult 생성
import org.springframework.http.HttpStatus;
public interface ErrorResult {
HttpStatus getHttpStatus();
String getMessage();
}
import FIS.iLUVit.exception.exceptionHandler.ErrorResult;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@Getter
@RequiredArgsConstructor
public enum BasicErrorResult implements ErrorResult {
/**
* 4XX 클라이언트 에러
*/
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."),
FORBIDDEN_REQUEST(HttpStatus.FORBIDDEN, "요청이 서버에서 거부되었습니다."),
UNKNOWN_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "알 수 없는 클라이언트 에러입니다."),
/**
* 5XX 서버 에러
*/
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에서 처리 중에 오류가 발생했습니다."),
UNKNOWN_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 서버 에러입니다."),
UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 오류가 발생했습니다."),
;
private final HttpStatus httpStatus;
private final String message;
}
현재 프로그램에서는 AuthNumberErrorResult, BoardErrorResult, CenterErrorResult 등 ErrorResult interface를 구현한 여러 이이넘들이 있고, 특정 도메인이 아닌 전역에서 나는 에러에 대해서 응답값을 생성하기 위해 BasiceErrorReulst라는 enum을 만들었다.
httpStatus와 message를 가지는 enum 을 생성해 오류 코드와 메시지를 명시적으로 관리하고 유지 보수하기 쉽게 하였다.
ErrorResponse 생성
@Getter
@Builder
public class ErrorResponse {
private HttpStatus status;
private String error;
public static ErrorResponse from(ErrorResult errorResult){
return ErrorResponse.builder()
.status(errorResult.getHttpStatus())
.error(errorResult.getMessage())
.build();
}
public static ResponseEntity<ErrorResponse> toResponseEntity(ErrorResult errorResult) {
return ResponseEntity
.status(errorResult.getHttpStatus())
.body(
ErrorResponse.builder()
.status(errorResult.getHttpStatus())
.error(errorResult.getMessage())
.build()
);
}
public static ResponseEntity<ErrorResponse> toResponseEntity(HttpStatus httpStatus, String message) {
return ResponseEntity
.status(httpStatus)
.body(
ErrorResponse.builder()
.status(httpStatus)
.error(message)
.build()
);
}
}
클라이언트에게 보낼 에러 형식인 ErrorResponse Dto 다.
나는 상태코드인 status와 에러메시지인 error만 보내기로 하였지만, 필요에 따라 다양한 필드를 추가하여 response를 줄 수 있다!
@Builder 와 정적 팩토리 메서드를 사용하여 의미 있는 이름을 부여하여 어떤 종류의 객체가 생성되는지 명확하게 하였고,
객체를 생성하는 복잡한 로직을 클래스 내부에 숨겼다.
CustomErrorController 생성
우선 전체코드이다
import FIS.iLUVit.exception.BasicErrorResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.web.servlet.error.AbstractErrorController;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpServletRequest;
@Controller
@Slf4j
@RequestMapping("${server.error.path:${error.path:/error}}")
public class CustomErrorController extends AbstractErrorController {
private final SlackErrorLogger slackErrorLogger;
public CustomErrorController(ErrorAttributes errorAttributes, SlackErrorLogger slackErrorLogger) {
super(errorAttributes);
this.slackErrorLogger = slackErrorLogger;
}
@RequestMapping
public ResponseEntity<ErrorResponse> customError(HttpServletRequest request) {
HttpStatus httpStatus = getStatus(request);
String error = getErrorAttributes(request, ErrorAttributeOptions.of(ErrorAttributeOptions.Include.MESSAGE)).get("message").toString();
ErrorResult errorResult;
log.error("[BasicErrorHandler {} {} errMessage={}\n",
request.getMethod(),
request.getRequestURI(),
error);
if (httpStatus.is4xxClientError()) { // 4XX 에러 발생
errorResult = map4xxClientError(httpStatus);
slackErrorLogger.sendSlackAlertWarnLog(error,request); // 슬랙 클라이언트 에러 알림 보내기
} else if (httpStatus.is5xxServerError()) { // 5XX 에러 발생
errorResult = map5xxServerError(httpStatus);
slackErrorLogger.sendSlackAlertErrorLog(error,request); // 슬랙 서버 에러 알림 보내기
} else {
errorResult = BasicErrorResult.UNKNOWN_ERROR;
}
return ErrorResponse.toResponseEntity(errorResult);
}
private ErrorResult map4xxClientError(HttpStatus httpStatus) {
switch (httpStatus) {
case BAD_REQUEST:
return BasicErrorResult.INVALID_REQUEST;
case NOT_FOUND:
return BasicErrorResult.RESOURCE_NOT_FOUND;
case FORBIDDEN:
return BasicErrorResult.FORBIDDEN_REQUEST;
default:
return BasicErrorResult.UNKNOWN_CLIENT_ERROR;
}
}
private ErrorResult map5xxServerError(HttpStatus httpStatus) {
switch (httpStatus) {
case INTERNAL_SERVER_ERROR:
return BasicErrorResult.INTERNAL_SERVER_ERROR;
default:
return BasicErrorResult.UNKNOWN_SERVER_ERROR;
}
}
}
하나씩 쪼개서 설명하겠다~!
@Controller
@Slf4j
@RequestMapping("${server.error.path:${error.path:/error}}")
public class CustomErrorController extends AbstractErrorController {
private final SlackErrorLogger slackErrorLogger;
public CustomErrorController(ErrorAttributes errorAttributes, SlackErrorLogger slackErrorLogger) {
super(errorAttributes);
this.slackErrorLogger = slackErrorLogger;
}
...
}
우선 스프링 기본 에러를 커스텀 하기 위해서는 ErrorController를 구현한 AbstractErrorController를 extends 해야한다.
우리는 슬랙에 에러 알림을 보내는 코드도 추가해야하기 때문에 생성자에서 slackErrorLogger도 의존성 주입해주었다!
@RequestMapping
public ResponseEntity<ErrorResponse> customError(HttpServletRequest request) {
HttpStatus httpStatus = getStatus(request);
String error = getErrorAttributes(request, ErrorAttributeOptions.of(ErrorAttributeOptions.Include.MESSAGE)).get("message").toString();
ErrorResult errorResult;
log.error("[BasicErrorHandler {} {} errMessage={}\n",
request.getMethod(),
request.getRequestURI(),
error);
if (httpStatus.is4xxClientError()) { // 4XX 에러 발생
errorResult = map4xxClientError(httpStatus);
slackErrorLogger.sendSlackAlertWarnLog(error,request); // 슬랙 클라이언트 에러 알림 보내기
} else if (httpStatus.is5xxServerError()) { // 5XX 에러 발생
errorResult = map5xxServerError(httpStatus);
slackErrorLogger.sendSlackAlertErrorLog(error,request); // 슬랙 서버 에러 알림 보내기
} else {
errorResult = BasicErrorResult.UNKNOWN_ERROR;
}
return ErrorResponse.toResponseEntity(errorResult);
}
- getStatus와 getErrorAttributes 를 이용해 상태코드인 HttpStatus와 에러 메시지인 error 를 받아온다
- 이후 log에 request 정보와 error를 그대로 찍어서 이후 상태를 추적하고 디버깅이 유용하게 한다.
- 만약 4XX 에러가 발생 한다면 map4xxClientError 함수를 통해 ErrorReult를 받아오고 sendSlackAlertWarnLog를 호출하여 슬랙에 클라이언트 에러 알림을 보낸다
- 만약 5XX 에러가 발생하면 map5xxServerError 함수를 통해 ErrorResult를 받아오고 sendSlackAlertErrorLog를 호출하여 슬랙에 서버 에러 알림을 보낸다
정리하자면 ,
클라이언트에게 보내는 ErrorResponse는 정해진 ErrorResult를 보내 errorMessage나 stackTrace가 노출되지 않게 하여 보안상 위험이 없게 하고
로그나 슬랙에 에러 메시지를 보낼 때는 에러 메시지를 보내 디버깅이 좀 더 편리하게 한다!
마치며
여담이지만, 우리 프로그램에서는 trace를 사용하고 있는데 trace에 대한 이해도가 낮아 더 많이 헤맨 것 같다
처음에는 trace에서 "Cannot invoke \ "java.lang.Long.longValue()\" because the return value of
\"java.lang.ThreadLocal.get()\ is null" 이라는 에러가 나서 정작 문제가 되는 에러 메세지를 받을 수 없었다.
이후 traceSupports에서 null 처리를 제대로 해 준 뒤에야 제대로 된 에러 메시지를 받을 수 있었다.
어떤 경우에 BasicErrorController에서 에러가 처리되고 어떤 경우 @RestControllerAdvice에서 처리되는지 아직 의문인 부분이 많다..
모든 경우가 @RestControllerAdvice에서 처리되는 게 맞는 건지 BasicErrorController의 response 와 섞여서 에러가 나는 것이 당
한 건지도 잘 모르겠다..흠