SOPT에서 활동하며 스프링 부트 개발 시 에러 핸들링을 해주어야 할 일이 많이 있었다.
32기 때 이런 에러 핸들링을 스스로 하지 않았고, 앱잼 때도 리드 언니가 미리 해주어 서비스 단에서 커스텀 에러를 던지게 되더라도 우리가 만든 공통 형식으로 잘 찍히는 것만 확인하고 그 원리를 이해하지 않았던 것이 아쉬웠다.
그래서 이번 합동 세미나를 진행하며 에러 핸들링을 했던 것을 정리해보고자 한다 :)
1. Error을 관리할 Enum 과 CustomException 만들기
- API를 작성할 때 따로 Custom Error Handler를 만들어주지 않는다면, Runtime Error 발생 시 모두 500 Error로 Response가 전달되게 된다
{
"timestamp": "2023-11-25T22:09:39.098+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/result"
}
- 따라서 서비스 로직 내에서 클라이언트의 실수로 어떠한 로직을 실행하는데 문제가 발생한다면 이에 대한 에러를 구체적으로 전달할 필요가 있다고 생각해 Custom Exception을 만들어보기로 했다
1.1 Error Enum 만들기
- 다양한 상황에서 쓰일 Error Code를 다음과 같이 만들었다
- 상태를 담을 httpStatus(HttpStatus)와 메세지를 담을 message(String) 속성을 추가했다
package CarrotServer.exception.enums;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public enum Error {
// 400 BAD REQUEST
REQUEST_VALIDATION_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 요청입니다"),
// 404 NOT FOUND
NOT_FOUND_CATEGORY(HttpStatus.NOT_FOUND, "해당하는 카테고리를 찾을 수 없습니다"),
NOT_FOUND_CLUB(HttpStatus.NOT_FOUND, "해당하는 모임을 찾을 수 없습니다"),
NOT_FOUND_PATH(HttpStatus.NOT_FOUND, "해당 경로를 찾을 수 없습니다"),
// 500 INTERNAL SERVER ERROR
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 서버 에러가 발생했습니다");
private final HttpStatus httpStatus;
private final String message;
public int getHttpStatus(){
return httpStatus.value();
}
public String getMessage(){
return message;
}
}
1.2 CustomException 추가
- 다음과 같이 RuntimeException을 상속하여 서비스의 커스텀 에러를 만들어 주었다
- 우리는 RuntimeException 발생 시 500이 아닌 우리가 만든 에러를 반환해주고 싶어하기에 RuntimeException을 상속하여 새로운 커스텀 에러를 만들어 준 것이다
- 이번 합동세미나의 주제가 당근마켓 리디자인이었기 때문에, 나는 CarrotException이라고 이름을 지어주었다
@Getter
public class CarrotException extends RuntimeException {
private final Error error;
public CarrotException(Error error,String message) {
super(message);
this.error = error;
}
public int getHttpStatus() {
return error.getHttpStatus();
}
}
2. 공통 응답 형식에 맞추어 CumtomErrorHandler 만들기
2.1 공통 응답 형식
- 우리 조는 다음과 같은 공통 응답 형식을 지정하여 사용하고 있다
- code: Status Code
- message: Custom한 응답 형식 메시지
- data: 전달할 데이터
{
"code": 500,
"message": "알 수 없는 서버 에러가 발생했습니다",
"data": null
}
- 위와 같은 형식을 사용하기 위해 ApiResponse를 다음과 같이 제네릭 타입으로 구성하여 만들어 주었다
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class ApiResponse<T> {
private final int code;
private final String message;
private T data;
public static ApiResponse success(Success success) {
return new ApiResponse<>(success.getHttpStatus(), success.getMessage());
}
public static <T> ApiResponse<T> success(Success success, T data) {
return new ApiResponse<T>(success.getHttpStatus(), success.getMessage(), data);
}
public static ApiResponse error(Error error) {
return new ApiResponse<>(error.getHttpStatus(), error.getMessage());
}
public static <T> ApiResponse<T> error(Error error, T data) {
return new ApiResponse<T>(error.getHttpStatus(), error.getMessage(), data);
}
}
2.2 Custom Exception Handler 추가
- 다음과 같은 코드를 작성하여 컨트롤러 전역에서 발생하는 Custom 에러를 잡아주는 Handler를 만들어 주었다
@RestControllerAdvice
@Slf4j
public class CustomExceptionHandler {
@ExceptionHandler({ CarrotException.class })
protected ApiResponse handleCustomException(CarrotException ex) {
return ApiResponse.error(ex.getError());
}
@ExceptionHandler({ Exception.class })
protected ApiResponse handleServerException(Exception ex) {
log.error(ex.getMessage());
return ApiResponse.error(Error.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(NoHandlerFoundException.class)
protected ApiResponse<HashMap> handleNotFoundException(NoHandlerFoundException ex) {
HashMap<String,String> pathMap = new HashMap<>();
pathMap.put("path",ex.getRequestURL());
return ApiResponse.error(Error.NOT_FOUND_PATH, pathMap);
}
}
- @RestcontrollerAdvice
- 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해준다
- @ControllerAdvice와 달리 @ReponseBody가 붙어있어 응답을 json으로 내려준다는 점이 차이점이다
- View를 사용하지 않는 Rest API일 경우 해당 어노테이션을 사용한다
- @ExceptionHandler(XXXException.class): 발생한 XXXException에 대해서 처리하는 메서드를 작성할 수 있다
- 서비스 단에서 던져지는 에러는 컨트롤러에서 호출된 서비스에서 발생하는 것이기 때문에 컨트롤러에서 발생한 에러로 인식되는 원리라고 이해했다.
- 이에 따라 우리 서비스에서 사용하는 Custom Error와 올바르지 않은 경로에 대한 요청이 들어올 경우에 대한 NoHandlerFoundException, 이외의 에러에 대해서는 500 에러가 내려갈 수 있도록 코드를 작성했다
2.3 올바르지 않은 경로 요청에 대한 Erorr 처리
- 올바르지 않은 경로에 대해 요청을 보낼 시 위의 Custom Error Handler에 코드를 작성했어도 다음과 같은 응답값이 내려오게 된다
{
"timestamp": "2023-11-28T03:35:48.948+00:00",
"status": 404,
"error": "Not Found",
"path": "/api/clu"
}
- 이런 경우 yml에 다음과 같은 코드를 추가해주어 해결할 수 있다
spring:
mvc:
throw-exception-if-no-handler-found: true
web:
resources:
add-mappings: false
- NoHandlerFoundException 핸들링
- throw-exception-if-no-handler-found: true
- 해당 설정은 Spring MVC에서 요청에 대한 핸들러(컨트롤러)를 찾지 못했을 때 NoHandlerFoundException을 던지게 설정하는 것이다
- 이 예외를 적절하게 처리하면 사용자에게 적절한 에러 페이지를 제공하거나, 로깅 등을 통해 이 예외에 대한 정보를 기록할 수 있습니다
- 리소스 매핑 처리
- web.resources.add-mappings: false
- 이 설정은 Spring MVC가 자원(예: 이미지)에 대한 매핑을 자동으로 추가하지 않도록 한다
- 예를 들어, 정적 자원에 대한 요청이 잘못된 경로로 들어온 경우에도 NoHandlerFoundException이 발생한다
- 이 경우에도 위에서 언급한 방식으로 NoHandlerFoundException을 처리할 수 있게 된다
- 이렇게 설정하면 Not Found 에러에 대해 작성한 커스텀 에러대로 응답이 다음과 같이 내려가게 된다
{
"code": 404,
"message": "해당 경로를 찾을 수 없습니다",
"data": {
"path": "/api/clu"
}
}
3. 확인하기
- 클라이언트로부터 전달 받은 clubId를 가지고 모임 상세정보를 가져오는 로직에서, clubId가 존재하지 않을 경우 다음과 같이 커스텀 에러를 사용하고 있다
return ClubDetailResponseDTO.of(clubJpaRepository.findById(id).orElseThrow(() -> new CarrotException(Error.NOT_FOUND_CLUB, Error.NOT_FOUND_CLUB.getMessage())));
- 현재 서비스 내에 모임이 15개가 더미 데이터로 존재하는데, clubId를 100으로 하여 서버에 요청을 보내보면 다음과 같이 커스텀 에러 핸들링을 한 방식으로 예쁘게 응답값이 내려오는 것을 확인할 수 있다
{
"code": 404,
"message": "해당하는 모임을 찾을 수 없습니다",
"data": null
}
- 제 설명이 부족하여 이해가 되지 않으신다면 프로젝트 깃허브를 첨부하니 참고 부탁드립니다 :)
'Study > Backend Note' 카테고리의 다른 글
[Spring Boot] MapStruct란 무엇일까? (0) | 2023.11.29 |
---|---|
[SOPT] 파이썬과 Mysql 그리고 구글스프레드 시트를 연동하여 기획이랑 소통하는 법 (0) | 2023.07.21 |
[Spring Intro] Section 07. AOP (0) | 2023.03.07 |
[Spring Intro] Section 06. 스프링 DB 접근 기술 (0) | 2023.03.06 |
[Spring Intro] Section 05. 회원 관리 예제 - 웹 MVC 개발 (0) | 2023.03.04 |