스프링 프로젝트를 개발하다 보면 도메인별로 명확하고 통일된 방식의 예외 처리 구조가 필요하다. 이를 위해 CustomException을 활용한 글로벌 예외 처리 구조를 구축할 수 있다. 아래는 지금까지의 구현 내용을 기반으로 커스텀 예외 처리 방식에 대해 정리한 글이다.
도메인에 특화된 명확한 에러 메시지 제공
일관된 API 응답 형식 유지
비즈니스 로직과 예외 로직 분리
에러 코드 중심의 흐름 제어
입력 유효성 검사와 명확한 예외 구분
스택 트레이스를 줄이고 명확한 원인 파악
응답 상태 코드를 정밀하게 제어 가능
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
}
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();
}
}
@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);
}
}
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()
);
}
400 BAD_REQUEST
401 UNAUTHORIZED
404 NOT_FOUND
결론
CustomException과 GlobalExceptionHandler를 함께 사용하면 REST API의 에러 응답을 일관되고 명확하게 구성할 수 있다. 위 구조를 프로젝트 초기에 설계해두면 이후 유지보수 시에도 매우 유리하다. 각 클래스의 책임과 역할을 분리하면서 사용자에게는 명확한 메시지를, 개발자에게는 유연한 예외처리 체계를 제공할 수 있다.