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 인가 실패 |
'Back-end > SpringBoot' 카테고리의 다른 글
| [Spring Boot] 헬스 체크(Health Check) 구현 (0) | 2026.06.26 |
|---|---|
| [Spring Boot] 공통 응답 처리 구현 (0) | 2026.06.26 |
| [Spring Boot] JPA Auditing으로 BaseTimeEntity 구현 (0) | 2026.06.25 |
| [Spring Boot] Chap 4 - 도서 관리 서비스 JPA 사용 및 트랜잭션 (0) | 2026.01.22 |
| [Spring Boot] Chap 3 - 도서 관리 서비스 역할 분리 및 스프링 컨테이너 (0) | 2026.01.21 |