Spring 커스텀 예외 처리

김현찬·2025년 5월 30일

Spring 커스텀 예외 처리 정리

스프링 프로젝트를 개발하다 보면 도메인별로 명확하고 통일된 방식의 예외 처리 구조가 필요하다. 이를 위해 CustomException을 활용한 글로벌 예외 처리 구조를 구축할 수 있다. 아래는 지금까지의 구현 내용을 기반으로 커스텀 예외 처리 방식에 대해 정리한 글이다.

왜 커스텀 예외가 필요한가?

도메인에 특화된 명확한 에러 메시지 제공

  • 단순한 500 오류나 NullPointerException 같은 시스템 에러 대신, "제목은 필수입니다", "이미지를 삽입해야 합니다" 같은 사용자 친화적인 메시지를 반환할 수 있다.

일관된 API 응답 형식 유지

  • 다양한 컨트롤러에서 발생하는 예외를 한 곳에서 처리하여 클라이언트는 예외 응답을 예측 가능하게 처리할 수 있다.

비즈니스 로직과 예외 로직 분리

  • 핵심 로직은 비즈니스에 집중하고, 예외 처리는 따로 관리할 수 있어 가독성과 유지보수성이 높아진다.

에러 코드 중심의 흐름 제어

  • 개발자는 ErrorCode를 중심으로 흐름을 설계하고 디버깅 및 로깅이 용이하다.

입력 유효성 검사와 명확한 예외 구분

  • 유효성 검증 실패, 권한 문제, 데이터 누락 등 다양한 상황을 명확하게 분리하여 처리 가능하다.

스택 트레이스를 줄이고 명확한 원인 파악

  • 비즈니스 요구사항에 따라 의도적으로 발생시키는 예외는 CustomException으로 명확하게 추적 가능하다.

응답 상태 코드를 정밀하게 제어 가능

  • 상태 코드와 메시지를 조합해 REST API의 신뢰도를 높일 수 있다.

기본 구조 및 클래스 역할 설명

ErrorCode Enum

  • 예외 상황을 명확하게 분리하고 코드화하기 위한 열거형. 각각의 예외에 대해 상태 코드와 메시지를 설정한다.
public enum ErrorCode {
    // 게시글 관련
    BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 게시글을 찾을 수 없습니다."),
    POST_NOT_OWNED(HttpStatus.BAD_REQUEST, "본인의 게시글만 수정 및 삭제할 수 있습니다."),
    POST_NOT_CHANGE(HttpStatus.BAD_REQUEST, "변경할 내용을 입력해야 합니다."),
    POST_NOT_TITLE(HttpStatus.BAD_REQUEST,"제목을 입력해야 합니다."),
    POST_NOT_CONTENTS(HttpStatus.BAD_REQUEST,"내용을 입력해야 합니다."),
    POST_NOT_IMAGE(HttpStatus.BAD_REQUEST,"이미지를 삽입해야 합니다."),
    TITLE_LENGTH_OVER(HttpStatus.BAD_REQUEST, "제목은 255자까지 입력 가능합니다.")
``
    // 기타 예외
    INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다.");

    private final HttpStatus status;
    private final String message;

    // 생성자 및 Getter
}

CustomException

  • 비즈니스 로직에서 예외 상황이 발생할 경우 이 클래스를 사용해 throw 한다. ErrorCode를 기반으로 상태 코드 및 메시지를 커스터마이징할 수 있다.
public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public HttpStatus getStatus() {
        return errorCode.getStatus();
    }
}

GlobalExceptionHandler

  • 모든 Controller에서 발생하는 예외를 한 곳에서 처리. CustomException 외에도 Spring의 유효성 검사 실패 등을 처리할 수 있도록 구성한다.
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ExceptionDto> handleCustomException(CustomException e) {
        ExceptionDto dto = new ExceptionDto(e.getStatus(), e.getMessage());
        return ResponseEntity.status(e.getStatus()).body(dto);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Object> handleValidationExceptions(MethodArgumentNotValidException ex) {
        List<Map<String, String>> errors = new ArrayList<>();
        for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
            Map<String, String> error = new HashMap<>();
            error.put("field", fieldError.getField());
            error.put("message", fieldError.getDefaultMessage());
            errors.add(error);
        }
        Map<String, Object> body = new HashMap<>();
        body.put("message", "입력값이 올바르지 않습니다.");
        body.put("errors", errors);
        return ResponseEntity.badRequest().body(body);
    }
}

ExceptionDto

  • 예외 발생 시 클라이언트로 반환할 응답 데이터 포맷을 정의하는 DTO 클래스.
public class ExceptionDto {
    private int status;
    private String message;

    public ExceptionDto(HttpStatus status, String message) {
        this.status = status.value();
        this.message = message;
    }
    // Getter, Setter
}

사용 예시

게시글 삭제 예외 처리

@Transactional
public void deletePost(Long id, Long currentUserId, String password) {
    Post post = postRepository.findById(id)
        .orElseThrow(() -> new CustomException(ErrorCode.BOARD_NOT_FOUND));

    if (!currentUserId.equals(post.getUser().getId())) {
        throw new CustomException(ErrorCode.POST_NOT_OWNED);
    }

    passwordManager.validatePasswordMatchOrThrow(password, post.getUser().getPassword());
    postRepository.delete(post);
}

비밀번호 검증 내부 예외

public void validatePasswordMatchOrThrow(String input, String actual) {
    if (!passwordEncoder.matches(input, actual)) {
        throw new CustomException(ErrorCode.INVALID_PASSWORD);
    }
}

게시글 생성 시 유효성 검사 예외 처리

@Transactional
public PostResponseDto createPost(Long currentUserId, String title, String contents, String imageUrl) {
    User user = userService.findUserById(currentUserId);
    Post newPost = new Post(user, title, contents, imageUrl);

    if (newPost.getTitle() == null) {
        throw new CustomException(ErrorCode.POST_NOT_TITLE);
    }

    if (newPost.getContents() == null) {
        throw new CustomException(ErrorCode.POST_NOT_CONTENTS);
    }

    if (newPost.getImageUrl() == null) {
        throw new CustomException(ErrorCode.POST_NOT_IMAGE);
    }

    if (newPost.getTitle().length() > 255) {
        throw new CustomException(ErrorCode.TITLE_LENGTH_OVER);
    }

    Post savePost = postRepository.save(newPost);

    return new PostResponseDto(
        savePost.getId(),
        savePost.getUser().getId(),
        savePost.getTitle(),
        savePost.getContents(),
        savePost.getImageUrl(),
        savePost.getUser().getUserUrl(),
        savePost.getPostLikes().size(),
        savePost.getComments().size(),
        savePost.getCreatedAt(),
        savePost.getModifiedAt()
    );
}
  1. 사용한 상태코드 정리 및 사용처

400 BAD_REQUEST

  • POST_NOT_OWNED: 작성자가 아닌 사용자가 수정/삭제를 시도할 때
  • POST_NOT_CHANGE: 변경할 내용이 비어 있을 때
  • POST_NOT_TITLE: 제목이 비어 있을 때
  • POST_NOT_CONTENTS: 내용이 비어 있을 때
  • POST_NOT_IMAGE: 이미지가 없을 때
  • TITLE_LENGTH_OVER: 제목 길이가 255자를 초과할 때

401 UNAUTHORIZED

  • INVALID_PASSWORD: 비밀번호가 일치하지 않을 때

404 NOT_FOUND

  • BOARD_NOT_FOUND: 해당 게시글을 찾을 수 없을 때

결론

CustomException과 GlobalExceptionHandler를 함께 사용하면 REST API의 에러 응답을 일관되고 명확하게 구성할 수 있다. 위 구조를 프로젝트 초기에 설계해두면 이후 유지보수 시에도 매우 유리하다. 각 클래스의 책임과 역할을 분리하면서 사용자에게는 명확한 메시지를, 개발자에게는 유연한 예외처리 체계를 제공할 수 있다.

0개의 댓글