Develop

[Spring Boot] 게시판 - 04. 전역 예외 처리 (Global Exception Handler) 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 04. 전역 예외 처리 (Global Exception Handler)

230801 2025. 11. 22. 01:14

 

안녕하세요 .ᐟ

게시판 프로젝트 4일차 입니다. 오늘은 API의 안정성을 높이는 전역 예외 처리를 구현했습니다.

 

오늘의 학습 목표

  • 예외 처리가 필요한 이유
  • @RestControllerAdvice와 @ExceptionHandler
  • Custom Exception 설계
  • 통일된 에러 응답 구현

 


 

예외 처리를 왜 해야 할까?

기존 코드의 문제점을 살펴봅시다.

// 기존 코드
public User findById(Long userId) {
    return userRepository.findById(userId)
        .orElseThrow(() -> new EntityNotFoundException("User not found"));
}

 

문제점

  1. HTTP 상태 코드가 부적절함
    • EntityNotFoundException이 발생하면 500 에러가 반환됨
    • 실제로는 404 Not Found가 적절함
  2. 에러 응답 형식이 불규칙함
    • 어떤 에러는 메시지만, 어떤 에러는 스택트레이스가 노출됨
    • 프론트엔드에서 에러 처리하기 어려움
  3. 에러 종류 구분이 어려움
    • 모든 예외가 RuntimeException 계열로 처리됨
    • 어떤 상황의 에러인지 구분하기 힘듦

 

목표: 통일된 에러 응답

{
    "timestamp": "2024-11-22T10:30:00",
    "status": 404,
    "error": "Not Found",
    "code": "4040",
    "message": "사용자를 찾을 수 없습니다. ID: 999"
}

사용할 도구

@RestControllerAdvice

  • 모든 Controller에서 발생하는 예외를 한 곳에서 처리
  • @ControllerAdvice + @ResponseBody의 조합
  • 전역 예외 처리기를 만들 때 사용

 

@ExceptionHandler

  • 특정 예외 타입을 처리하는 메서드를 지정
  • @RestControllerAdvice 클래스 내에서 사용
  • 예외 타입별로 다른 처리 로직 구현 가능

 

구현할 파일 목록

src/main/java/com/cho/board/
├── global/
│   └── exception/
│       ├── ErrorCode.java              # 에러 코드 정의
│       ├── BusinessException.java       # 기본 비즈니스 예외
│       ├── ResourceNotFoundException.java   # 404 예외
│       ├── DuplicateResourceException.java  # 409 예외
│       ├── AccessDeniedException.java       # 403 예외
│       ├── GlobalExceptionHandler.java      # 전역 예외 처리기
│       └── ErrorResponse.java               # 에러 응답 DTO

 

 


 

ErrorCode - 에러 코드 중앙 관리

HTTP 상태 코드를 기준으로 에러 코드를 정의했습니다.

@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    
    // ========== 400 Bad Request ==========
    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "4000", "잘못된 입력값입니다"),
    INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "4000", "잘못된 타입입니다"),
    DELETED_COMMENT(HttpStatus.BAD_REQUEST, "4000", "삭제된 댓글은 수정할 수 없습니다"),
    
    // ========== 403 Forbidden ==========
    ACCESS_DENIED(HttpStatus.FORBIDDEN, "4030", "접근 권한이 없습니다"),
    POST_ACCESS_DENIED(HttpStatus.FORBIDDEN, "4030", "게시글에 대한 권한이 없습니다"),
    COMMENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "4030", "댓글에 대한 권한이 없습니다"),
    
    // ========== 404 Not Found ==========
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "4040", "사용자를 찾을 수 없습니다"),
    POST_NOT_FOUND(HttpStatus.NOT_FOUND, "4040", "게시글을 찾을 수 없습니다"),
    COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "4040", "댓글을 찾을 수 없습니다"),
    CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "4040", "카테고리를 찾을 수 없습니다"),
    
    // ========== 409 Conflict ==========
    EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "4090", "이미 존재하는 이메일입니다"),
    NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "4090", "이미 존재하는 닉네임입니다"),
    
    // ========== 500 Internal Server Error ==========
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "5000", "서버 내부 오류가 발생했습니다");

    private final HttpStatus status;
    private final String code;
    private final String message;
}

 

코드 네이밍 규칙

  • HTTP 상태 코드를 그대로 사용해서 직관적으로 만들었습니다.
  • 같은 HTTP 상태 코드를 사용하는 에러들은 같은 코드를 공유하고, 구체적인 내용은 message로 구분합니다.
4000 400 Bad Request
4030 403 Forbidden
4040 404 Not Found
4090 409 Conflict
5000 500 Internal Server Error

 


 

Custom Exception - 예외 클래스 계층 구조

  • ErrorCode(enum)만 있으면 예외를 던질수가 없습니다.
    • Java 에서 throw 를 하려면 Throwable을 상속받은 클래스를 이용해야 합니다.
    • 그래서 커스텀에러를 만들고 RuntimeException을 상속받아서 던질 준비를하고, enum 을 주입받아 사용합니다.
Throwable
├── Error (시스템 에러)
└── Exception
    ├── CheckedException (컴파일 시점에 체크)
    └── RuntimeException (런타임에 발생) ← 이용할 것

 

BusinessException (기본 예외)

  • 모든 비즈니스 예외의 부모 클래스입니다.
@Getter
public class BusinessException extends RuntimeException {
    
    private final ErrorCode errorCode;
    
    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
    
    public BusinessException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }
}

 

하위 예외 클래스들

  • 각 상황에 맞는 예외 클래스를 만들어서 catch 문에서 구분이 가능하도록 했습니다.
// 404 Not Found
public class ResourceNotFoundException extends BusinessException {
    public ResourceNotFoundException(ErrorCode errorCode) {
        super(errorCode);
    }
    
    public ResourceNotFoundException(ErrorCode errorCode, String message) {
        super(errorCode, message);
    }
}

// 409 Conflict
public class DuplicateResourceException extends BusinessException { ... }

// 403 Forbidden
public class AccessDeniedException extends BusinessException { ... }

 

예외 클래스 계층 구조

RuntimeException
└── BusinessException
    ├── ResourceNotFoundException  (404)
    ├── DuplicateResourceException (409)
    └── AccessDeniedException      (403)

ErrorResponse - 통일된 에러 응답 DTO

@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
    
    private final LocalDateTime timestamp;
    private final int status;
    private final String error;
    private final String code;
    private final String message;
    private final List<FieldError> fieldErrors;
    
    @Getter
    @Builder
    public static class FieldError {
        private final String field;
        private final String value;
        private final String reason;
    }
    
    public static ErrorResponse of(int status, String error, String code, String message) {
        return ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(status)
                .error(error)
                .code(code)
                .message(message)
                .build();
    }
}

 

@JsonInclude(NON_NULL)

  • null인 필드는 JSON 응답에서 제외됩니다.
  • fieldErrors가 없는 경우 응답에 포함되지 않아 깔끔해집니다.

GlobalExceptionHandler - 전역 예외 처리기

  • 모든 예외를 한 곳에서 처리하는 핵심 클래스입니다. 
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    /**
     * BusinessException 처리
     */
    @ExceptionHandler(BusinessException.class)
    protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        log.error("BusinessException: {}", e.getMessage());
        
        ErrorCode errorCode = e.getErrorCode();
        ErrorResponse response = ErrorResponse.of(
                errorCode.getStatus().value(),
                errorCode.getStatus().getReasonPhrase(),
                errorCode.getCode(),
                e.getMessage()
        );
        
        return ResponseEntity.status(errorCode.getStatus()).body(response);
    }
    
    /**
     * @Valid 검증 실패 시 발생
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(
            MethodArgumentNotValidException e) {
        log.error("Validation failed: {}", e.getMessage());
        
        List<ErrorResponse.FieldError> fieldErrors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> ErrorResponse.FieldError.builder()
                        .field(error.getField())
                        .value(error.getRejectedValue() != null ? 
                               error.getRejectedValue().toString() : "")
                        .reason(error.getDefaultMessage())
                        .build())
                .collect(Collectors.toList());
        
        ErrorResponse response = ErrorResponse.of(
                HttpStatus.BAD_REQUEST.value(),
                HttpStatus.BAD_REQUEST.getReasonPhrase(),
                "4000",
                "입력값 검증에 실패했습니다",
                fieldErrors
        );
        
        return ResponseEntity.badRequest().body(response);
    }
    
    /**
     * 그 외 모든 예외 처리 (최후의 방어선)
     */
    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Unhandled Exception: ", e);
        
        ErrorResponse response = ErrorResponse.of(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
                "5000",
                "서버 내부 오류가 발생했습니다"
        );
        
        return ResponseEntity.internalServerError().body(response);
    }
}

 

처리 흐름

Service에서 예외 던짐
   throw new ResourceNotFoundException(ErrorCode.USER_NOT_FOUND);
        ↓
Controller에서 예외 발생
        ↓
GlobalExceptionHandler가 예외 catch
        ↓
예외 타입에 맞는 @ExceptionHandler 메서드 실행
        ↓
ErrorResponse 생성 후 클라이언트에게 반환

 

 


 

Service 리팩토링

  • 기존 코드를 Custom Exception으로 변경했습니다.

 

UserService

// 변경 전
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
    throw new DataIntegrityViolationException("Email is already exists.");
}

// 변경 후
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
    throw new DuplicateResourceException(ErrorCode.EMAIL_ALREADY_EXISTS);
}

 

PostService

// 변경 전
if (!post.getAuthor().getId().equals(userId)) {
    throw new IllegalArgumentException("Only author can update the post");
}

// 변경 후
if (!post.getAuthor().getId().equals(userId)) {
    throw new AccessDeniedException(ErrorCode.POST_ACCESS_DENIED);
}

 

CommentService

// 변경 전
throw new EntityNotFoundException("댓글을 찾을 수 없습니다.");

// 변경 후
throw new ResourceNotFoundException(ErrorCode.COMMENT_NOT_FOUND);

테스트 결과

404 Not Found

GET /users/9999
{
    "timestamp": "2024-11-22T15:30:00",
    "status": 404,
    "error": "Not Found",
    "code": "4040",
    "message": "사용자를 찾을 수 없습니다"
}

 

409 Conflict

POST /users
Content-Type: application/json

{
    "email": "이미존재하는이메일@test.com",
    ...
}
{
    "timestamp": "2024-11-22T15:31:00",
    "status": 409,
    "error": "Conflict",
    "code": "4090",
    "message": "이미 존재하는 이메일입니다"
}

 

403 Forbidden

PUT /posts/1?userId=9999
{
    "timestamp": "2024-11-22T15:32:00",
    "status": 403,
    "error": "Forbidden",
    "code": "4030",
    "message": "게시글에 대한 권한이 없습니다"
}

 

 


 

느낀 점

@RestControllerAdvice의 편리함

  • 예외 처리 로직이 한 곳에 모여있어 관리가 쉬웠습니다.
  • 각 Controller에서 try-catch를 사용하지 않아도 되어 코드가 깔끔해졌습니다.

ErrorCode enum의 장점

  • 에러 코드를 중앙에서 관리하니 일관성이 유지됩니다.
  • HTTP 상태 코드 기반으로 정리하니 직관적입니다.
  • 새로운 에러가 필요하면 enum에 추가만 하면 됩니다.

Custom Exception 계층 구조

  • BusinessException을 상속받아 확장하니 유지보수가 쉬웠습니다.
  • 예외 타입만 보고도 어떤 상황인지 파악할 수 있었습니다.