[Spring Boot] 공통 예외처리 구현 - CustomException, GlobalExceptionHandler

01_공통 예외처리


📍 공통 예외처리

매번 try-catch로 예외를 처리하는 건 비효율적 → 공통 예외처리 사용

모든 예외 응답의 형식 통일

디버깅에 필요한 정보(발생 시각, 요청 경로 등) 포함

 

 

전체 구조

구성 요소 역할
BaseCode (interface) 응답 코드의 공통 규격 정의
ErrorCode (enum) 에러 종류별 HTTP 상태 코드와 메시지 관리
CustomException 비즈니스 로직에서 발생시키는 사용자 정의 예외
ErrorResponse (record) 클라이언트에게 반환하는 에러 응답 DTO
GlobalExceptionHandler 모든 예외를 한 곳에서 처리하는 핸들러

 

 

 

 

02_공통 예외처리 구현


📍 BaseCode

응답 코드의 공통 규격을 인터페이스로 정의

package com.example.global.response.base;

import org.springframework.http.HttpStatus;

public interface BaseCode {
    HttpStatus getHttpStatus();
    String getMessage();
    String name(); // enum의 name() 메서드를 그대로 사용
}

 

🔍 코드 설명

  • HttpStatus: int로 직접 상태 코드를 다루는 것보다 타입 안전하고 의미 명확

 

 

 

📍 ErrorCode

에러 종류별로 HTTP 상태 코드와 메시지 관리

새로운 에러 필요할 때 코드 추가하면 돼서 유지보수 편리

package com.example.global.response.code;

import com.example.global.response.base.BaseCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum ErrorCode implements BaseCode {
    // 400 BAD REQUEST
    INVALID_FIELD_ERROR(HttpStatus.BAD_REQUEST, "요청 필드 값이 유효하지 않습니다."),
    MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 누락되었습니다."),
    MISSING_HEADER(HttpStatus.BAD_REQUEST, "필수 요청 헤더가 누락되었습니다."),
    TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "요청 값 타입이 올바르지 않습니다."),
    INVALID_REQUEST_BODY(HttpStatus.BAD_REQUEST, "요청 본문이 올바르지 않습니다."),

    // 401 UNAUTHORIZED
    UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "인증되지 않은 사용자입니다."),

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

    // 404 NOT FOUND
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."),

    // 500 INTERNAL SERVER ERROR
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부에서 문제가 발생하였습니다.");

    private final HttpStatus httpStatus;
    private final String message;
}

 

 

 

📍 CustomException

비즈니스 로직에서 의도적으로 발생시키는 사용자 정의 예외

package com.example.global.exception;

import com.example.global.response.base.BaseCode;
import lombok.Getter;

@Getter
public class CustomException extends RuntimeException {
    private final BaseCode baseCode;

    public CustomException(BaseCode baseCode) {
        super(baseCode.getMessage());
        this.baseCode = baseCode;
    }
}

 

 

 

서비스 계층에서 던질 시

if (user == null) {
    throw new CustomException(ErrorCode.RESOURCE_NOT_FOUND);
}

 

 

 

📍 ErrorResponse

클라이언트에게 반환하는 에러 응답 DTO

package com.example.global.response.dto;

import com.example.global.response.base.BaseCode;
import com.fasterxml.jackson.annotation.JsonInclude;

import java.time.LocalDateTime;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record ErrorResponse(
        String timestamp,
        int status,
        String errorCode,
        String message,
        String path,
        Object detail
) {
    public static ErrorResponse of(BaseCode baseCode, String path) {
        return new ErrorResponse(
                LocalDateTime.now().toString(),
                baseCode.getHttpStatus().value(),
                baseCode.name(),
                baseCode.getMessage(),
                path,
                null
        );
    }

    public static ErrorResponse of(BaseCode baseCode, String path, Object detail) {
        return new ErrorResponse(
                LocalDateTime.now().toString(),
                baseCode.getHttpStatus().value(),
                baseCode.name(),
                baseCode.getMessage(),
                path,
                detail
        );
    }
}

🔍 코드 설명

  • @JsonInclude(NON_NULL): 값이 null인 필드는 응답에서 제외

 

  • 응답 예시
{
  "timestamp": "2026-06-25T10:23:14.512",
  "status": 404,
  "errorCode": "RESOURCE_NOT_FOUND",
  "message": "요청한 리소스를 찾을 수 없습니다.",
  "path": "/api/users/123"
}

 

 

 

📍 GlobalExceptionHandler

모든 컨트롤러에서 발생한 예외를 한 곳에서 처리

package com.example.global.exception;

import com.example.global.response.base.BaseCode;
import com.example.global.response.code.ErrorCode;
import com.example.global.response.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import java.time.LocalDateTime;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 사용자 정의 예외
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ErrorResponse> handleCustomException(
            CustomException e, HttpServletRequest request) {
        BaseCode errorCode = e.getBaseCode();
        return ResponseEntity
                .status(errorCode.getHttpStatus())
                .body(ErrorResponse.of(errorCode, request.getRequestURI()));
    }

    // @Valid 검증 실패 (@RequestBody)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException e, HttpServletRequest request) {
        String detail = e.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .findFirst()
                .orElse("잘못된 요청입니다.");
        return buildErrorResponse(ErrorCode.INVALID_FIELD_ERROR, request, detail);
    }

    // @Validated 검증 실패 (@RequestParam, @PathVariable)
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(
            ConstraintViolationException e, HttpServletRequest request) {
        return buildErrorResponse(ErrorCode.INVALID_FIELD_ERROR, request, e.getMessage());
    }

    // 필수 파라미터 누락
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResponseEntity<ErrorResponse> handleMissingParameter(
            MissingServletRequestParameterException e, HttpServletRequest request) {
        return buildErrorResponse(ErrorCode.MISSING_PARAMETER, request, e.getParameterName());
    }

    // 필수 헤더 누락
    @ExceptionHandler(MissingRequestHeaderException.class)
    public ResponseEntity<ErrorResponse> handleMissingHeader(
            MissingRequestHeaderException e, HttpServletRequest request) {
        return buildErrorResponse(ErrorCode.MISSING_HEADER, request, e.getHeaderName());
    }

    // 타입 불일치 (예: int 자리에 문자열 전달)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException e, HttpServletRequest request) {
        String detail = e.getRequiredType() != null
                ? String.format("'%s'은(는) %s 타입이어야 합니다.",
                    e.getName(), e.getRequiredType().getSimpleName())
                : "타입 변환 오류입니다.";
        return buildErrorResponse(ErrorCode.TYPE_MISMATCH, request, detail);
    }

    // 요청 본문 파싱 실패 (잘못된 JSON 등)
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleHttpMessageNotReadable(
            HttpMessageNotReadableException e, HttpServletRequest request) {
        return buildErrorResponse(ErrorCode.INVALID_REQUEST_BODY, request, null);
    }

    // 접근 권한 없음
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(
            AccessDeniedException e, HttpServletRequest request) {
        return buildErrorResponse(ErrorCode.ACCESS_DENIED, request, null);
    }

    // 예상하지 못한 모든 예외
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception e, HttpServletRequest request) {
        log.error("❌ 예기치 않은 서버 에러 발생 - URI: {}", request.getRequestURI(), e);
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, request.getRequestURI()));
    }

    private ResponseEntity<ErrorResponse> buildErrorResponse(
            BaseCode errorCode, HttpServletRequest request, Object detail) {
        return ResponseEntity
                .status(errorCode.getHttpStatus())
                .body(ErrorResponse.of(errorCode, request.getRequestURI(), detail));
    }
}

🔍 코드 설명

  • @RestControllerAdvice: 모든 @RestController에서 발생한 예외를 가로채 처리
  • @ExceptionHandler(예외클래스.class): 특정 예외 발생 시 호출되는 메서드 지정

 

 

주요 Spring 예외

예외 발생 시점
MethodArgumentNotValidException @RequestBody @Valid 적용 시 검증 실패
ConstraintViolationException @RequestParam, @PathVariable @Validated 적용 시 검증 실패
MissingServletRequestParameterException 필수 쿼리 파라미터 누락
MissingRequestHeaderException 필수 헤더 누락
MethodArgumentTypeMismatchException 파라미터 타입 변환 실패 (예: ?id=abc인데 Long 기대)
HttpMessageNotReadableException 요청 본문 파싱 실패 (잘못된 JSON 등)
AccessDeniedException Spring Security 인가 실패