1. 현재 에러 처리의 문제

// 서비스 코드
throw new RuntimeException("게시글을 찾을 수 없습니다: " + id);
throw new RuntimeException("비밀번호가 일치하지 않습니다");
throw new RuntimeException("본인이 작성한 글만 수정할 수 있습니다");
throw new RuntimeException("이미 존재하는 사용자 이름입니다");

전부 RuntimeException입니다. Spring은 이걸 다 500으로 처리합니다. 하지만 각 에러의 성격이 다릅니다:

게시글을 찾을 수 없습니다     → 404 Not Found
비밀번호가 일치하지 않습니다   → 401 Unauthorized
본인 글만 수정 가능           → 403 Forbidden
이미 존재하는 사용자         → 409 Conflict
제목이 비어있음              → 400 Bad Request

이걸 구분하려면 커스텀 예외 클래스가 필요합니다.


2. 커스텀 예외 설계

에러 코드 Enum

package com.myname.board.exception;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    // 400 Bad Request
    INVALID_INPUT("잘못된 입력값입니다", HttpStatus.BAD_REQUEST),

    // 401 Unauthorized
    INVALID_PASSWORD("비밀번호가 일치하지 않습니다", HttpStatus.UNAUTHORIZED),
    INVALID_TOKEN("유효하지 않은 토큰입니다", HttpStatus.UNAUTHORIZED),

    // 403 Forbidden
    ACCESS_DENIED("접근 권한이 없습니다", HttpStatus.FORBIDDEN),

    // 404 Not Found
    POST_NOT_FOUND("게시글을 찾을 수 없습니다", HttpStatus.NOT_FOUND),
    COMMENT_NOT_FOUND("댓글을 찾을 수 없습니다", HttpStatus.NOT_FOUND),
    USER_NOT_FOUND("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND),

    // 409 Conflict
    DUPLICATE_USERNAME("이미 존재하는 사용자 이름입니다", HttpStatus.CONFLICT),
    DUPLICATE_EMAIL("이미 존재하는 이메일입니다", HttpStatus.CONFLICT);

    private final String message;
    private final HttpStatus httpStatus;
}

에러 코드를 Enum으로 관리하면 에러 메시지가 흩어지지 않고 한 곳에 모입니다. 새 에러가 필요하면 여기에 추가만 하면 됩니다.

커스텀 예외 클래스

package com.myname.board.exception;

import lombok.Getter;

@Getter
public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;

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

    public CustomException(ErrorCode errorCode, String detail) {
        super(errorCode.getMessage() + ": " + detail);
        this.errorCode = errorCode;
    }
}

RuntimeException을 상속하므로 @Transactional에서 자동 롤백됩니다. ErrorCode를 가지고 있어서, 예외 처리기가 적절한 HTTP 상태 코드를 반환할 수 있습니다.


3. 전역 예외 처리기 — @RestControllerAdvice

모든 Controller에서 발생하는 예외를 한 곳에서 처리합니다. 이전에 각 메서드마다 try-catch를 쓰는 게 왜 나쁜지 배웠습니다. @RestControllerAdvice가 그 문제를 해결합니다.

package com.myname.board.exception;

import com.myname.board.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 1. 커스텀 예외 처리
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ApiResponse<Void>> handleCustomException(CustomException e) {
        log.warn("CustomException: {}", e.getMessage());
        return ResponseEntity
                .status(e.getErrorCode().getHttpStatus())
                .body(ApiResponse.error(e.getMessage()));
    }

    // 2. 유효성 검증 예외 처리 (@Valid 실패 시)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(
            MethodArgumentNotValidException e) {

        Map<String, String> errors = new HashMap<>();
        e.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });

        log.warn("Validation failed: {}", errors);
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error("입력값 검증에 실패했습니다"));
    }

    // 3. 그 외 모든 예외 (예상 못한 에러)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
        log.error("Unexpected error: ", e);
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.error("서버 내부 오류가 발생했습니다"));
    }
}

@ExceptionHandler가 하는 일:

handleCustomException: 우리가 만든 CustomException을 처리합니다. ErrorCode에 정의된 HTTP 상태 코드와 메시지를 반환합니다.

handleValidationException: @Valid 검증이 실패하면 Spring이 MethodArgumentNotValidException을 던집니다. 어떤 필드가 어떤 이유로 실패했는지 추출해서 반환합니다.