스프링

에러 처리 - 커스텀 Exception

영범 2025. 2. 5. 01:24

커스텀 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("홍길동");
}

 

기본적으로 위와 같이 코드를 작성해서 문서작성을 하시면 됩니다.

하지만, 이렇게 작성했을 때 몇 가지 문제점이 있습니다.

  1. 우리가 지정한 에러 코드도 포함이 안되어있고 Enum 클래스로 작성한 ErrorCode를 활용하지 못합니다.
  2. 수작업으로 넣다 보니 실수로 인해서 ErrorCode에 기입한 에러 내용과 @ApiResponse에 넣은 내용이 달라질 수 있습니다.
  3. 메서드의 코드보다 명세가 길어져서 가독성이 떨어집니다.

그래서 작성한 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 별로 등록한 에러를 확인할 수 있습니다.