에러 처리 - 커스텀 Exception
커스텀 Exception은 왜 필요한가?
비즈니스 예외를 구조화해 일관성 있게 관리하고, 의미를 명확하게 전달하기 위해 사용됩니다.
예를 들면, 하나의 요청에서 동일한 Http Status Code를 가지는 다른 예외가 발생할 수 있습니다.
@RestController
public class UserController {
@PostMapping("/api/register")
public ResponseEntity<String> registerUser(@RequestBody Map<String, String> userData) {
String email = userData.get("email");
String password = userData.get("password");
// 이메일 누락
if (email == null || email.isEmpty()) {
throw new IllegalArgumentException("이메일은 필수 입력 항목입니다.");
}
// 비밀번호가 짧음
if (password == null || password.length() < 6) {
throw new IllegalArgumentException("비밀번호는 최소 6자 이상이어야 합니다.");
}
// 이미 등록된 이메일
if ("existing@example.com".equals(email)) {
throw new IllegalStateException("이미 등록된 이메일입니다.");
}
return ResponseEntity.ok("회원 가입 성공!");
}
@ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
public ResponseEntity<String> handleBadRequestExceptions(RuntimeException ex) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body("에러 발생: " + ex.getMessage());
}
}
이렇게 예외 처리를 하고 있었다면, 프론트에서는 동일한 400 코드만 보고 정확한 에러를 파악하고 처리하기에 어려움이 있습니다.
그렇다면, 에러 메세지를 보고 판단을 해야 하는데 메세지는 언제든지 변경될 수 있기 때문에 메세지가 조금만 달라져도 프론트에서 버그가 발생할 가능성이 커집니다.
이는 유지보수 측면에서도 아주 나쁘고 프론트와 백엔드 간의 결합도가 강해지기 때문에 좋지 않은 방법입니다.
또한 로깅 및 모니터링을 할 때 메세지를 통해서 에러에 대한 통계를 수집하거나 분석하는데 매우 큰 어려움이 생깁니다.
에러 코드 | 에러 설명 | HTTP Status | 메세지 |
USER-001 | 잘못된 이메일 형식 | 400 Bad Request | "유효하지 않은 이메일 형식입니다." |
USER-002 | 비밀번호 형식 오류 | 400 Bad Request | "비밀번호는 최소 6자 이상이어야 합니다." |
USER-003 | 이미 존재하는 이메일 | 409 Conflict | "이미 등록된 이메일입니다." |
이를 해결하기 위해서 애플리케이션 고유의 에러 코드를 정의하고 위처럼 에러 코드 문서를 만들어서 공유하는 것이 좋습니다.
커스텀 예외 적용
- ErrorCode
public enum ErrorCode {
USER_INVALID_EMAIL(HttpStatus.BAD_REQUEST, "USER-001", "유효하지 않은 이메일 형식입니다."),
USER_INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER-002", "비밀번호는 최소 6자 이상이어야 합니다."),
USER_DUPLICATE_EMAIL(HttpStatus.CONFLICT, "USER-003", "이미 등록된 이메일입니다.");
private final HttpStatus httpStatus;
private final String code;
private final String message;
ErrorCode(HttpStatus httpStatus, String code, String message) {
this.httpStatus = httpStatus;
this.code = code;
this.message = message;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
public String getCode() {
return code;
}
public String getMessage() {
return message;
}
}
- CustomException
@Getter
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
- ErrorResponse
@Getter
@AllArgsConstructor
public class ErrorResponse {
private String code;
private String message;
}
- GlobalExceptionHandler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException ex) {
ErrorCode errorCode = ex.getErrorCode();
ErrorResponse errorResponse = new ErrorResponse(errorCode.getCode(), errorCode.getMessage());
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(errorResponse);
}
}
위처럼 코드를 작성하여 처리해 주시면 됩니다.
적용 후 에러응답을 보면 아래와 같습니다.
에러코드 문서화
이제 제가 만든 에러 코드를 Swagger를 이용해 문서화해보겠습니다.
@Operation(summary = "회원 조회", description = "회원 ID로 특정 회원 정보를 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "400", description = "잘못된 요청입니다."),
@ApiResponse(responseCode = "404", description = "회원 정보를 찾을 수 없습니다."),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping("/{id}")
public String getUserById(@PathVariable Long id) {
return new String("홍길동");
}
기본적으로 위와 같이 코드를 작성해서 문서작성을 하시면 됩니다.
하지만, 이렇게 작성했을 때 몇 가지 문제점이 있습니다.
- 우리가 지정한 에러 코드도 포함이 안되어있고 Enum 클래스로 작성한 ErrorCode를 활용하지 못합니다.
- 수작업으로 넣다 보니 실수로 인해서 ErrorCode에 기입한 에러 내용과 @ApiResponse에 넣은 내용이 달라질 수 있습니다.
- 메서드의 코드보다 명세가 길어져서 가독성이 떨어집니다.
그래서 작성한 ErrorCode를 활용해서 문서를 작성해야 합니다.
- ApiErrorResponses
우선, 우리가 만든 ErrorCode를 사용하기 위해서 커스텀 어노테이션을 만들어줍니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ApiResponses({})
public @interface ApiErrorResponses {
ErrorCode[] value();
}
- Target(ElementType.METHOD): 컨트롤러의 메서드에만 적용.
- Retention(RetentionPolicy.RUNTIME): 어노테이션이 생명주기가 런타임까지 유지되며 Reflection 사용 가능.
- ErrorCode[] value(): Enum을 배열형태로 받음
- SwaggerConfig
어노테이션에 적어놓은 ErrorCode를 가져와서 ErrorResponse의 형태로 만들어서 등록해 주는 SwaggerConfig를 작성해 줍니다.
@Component
public class SwaggerConfig implements OperationCustomizer {
@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
ApiErrorResponses annotation = handlerMethod.getMethodAnnotation(ApiErrorResponses.class);
// @ApiErrorResponses 애노테이션이 있는지 확인
if(annotation != null) {
// HTTP 상태 코드별로 ErrorCode를 그룹화
Map<HttpStatus, List<ErrorCode>> httpStatusGroup = Arrays.stream(annotation.value())
.collect(Collectors.groupingBy(ErrorCode::getHttpStatus));
httpStatusGroup.forEach((httpStatus, errorCodes) -> {
Content content = new Content();
MediaType mediaType = new MediaType();
// 같은 HTTP 상태 코드에서 여러 개의 예제 등록
errorCodes.forEach(errorCode -> {
ErrorResponse errorResponse = new ErrorResponse(
errorCode.getHttpStatus(),
errorCode.getCode(),
errorCode.getMessage()
);
mediaType.addExamples(errorCode.getCode(), new Example()
.description(errorCode.getMessage())
.value(errorResponse));
});
content.addMediaType("application/json", mediaType);
// HTTP 상태 코드에 대응하는 ApiResponse 추가
ApiResponse apiResponse = new ApiResponse()
.description(httpStatus.getReasonPhrase())
.content(content);
operation.getResponses().addApiResponse(String.valueOf(httpStatus.value()), apiResponse);
});
}
return operation;
}
}
HttpStatus가 겹치는 경우(400 에러 코드에 여러 개의 에러코드가 발생할 수 있는 경우)
마지막에 입력된 내용으로 덮어씌워지므로 모든 에러를 표시하기 위해서 그룹화해서 표시합니다.
- Controller
@Operation(summary = "회원가입")
@ApiErrorResponses({ErrorCode.DUPLICATE_USER, ErrorCode.INVALID_EMAIL_FORMAT, ErrorCode.INVALID_PASSWORD_FORMAT})
@PostMapping
public ResponseEntity<UserDTO> registerUser(@Valid @RequestBody UserAuthDTO userAuthDTO) {
UserDTO userDTO = new UserDTO();
return ResponseEntity.ok(userDTO);
}
위처럼 만든 Enum을 이용해서 커스텀 어노테이션을 명시해 줍니다.
명세를 작성 후 swagger-ui/index.html에서 해당 컨트롤러에 대한 명세를 확인해 보면
위의 사진처럼 HttpStatus 별로 등록한 에러를 확인할 수 있습니다.