Spring Boot Validation 공통 에러처리는 단순한 코드 정리 작업이 아니라 운영 안정성과 직결되는 설계 결정입니다. 외부 연동 API에서 500 에러는 그 자체가 신호입니다. “서버가 예상하지 못한 상황을 만났다”는 뜻이고, 호출 측은 무엇을 잘못했는지, 재시도가 가능한지조차 알지 못합니다. 잘 설계된 API라면 잘못된 요청은 400대 코드와 명확한 메시지로 돌려보내고, 진짜 예외적인 상황만 500으로 남깁니다.
이 글에서는 입력값 검증부터 비즈니스 예외, 공통 에러 핸들러까지 이어지는 3단 방어선을 정리합니다. 500 에러를 거의 0에 가깝게 줄이고, 호출 측이 즉시 대응할 수 있는 에러 응답을 표준화하는 실무 패턴을 함께 살펴보시기 바랍니다.
왜 500 에러는 최대한 피해야 하는가
HTTP 500은 단순히 “에러가 났다”는 표시가 아니라 서버가 자기 자신도 이해하지 못한 상황을 만났다는 의미입니다. 호출 측 입장에서 500은 다음과 같은 의문을 남깁니다.
| 질문 | 호출 측이 알 수 없는 것 |
|---|---|
| 내 요청이 잘못된 것인가? | 그렇다면 어디가 잘못됐는지 |
| 다시 시도하면 되는가? | 멱등성 보장 여부 |
| 데이터가 일부 저장됐는가? | 트랜잭션 처리 상태 |
| 언제까지 기다려야 하는가? | 일시적 장애인지 영구적인지 |
이 모든 모호함은 결국 운영팀의 전화 한 통으로 이어집니다. 외부 연동 파트너에게 “어떤 요청이 어떤 시점에 실패했는지” 회신하기 전에는 분쟁이 끝나지 않습니다.
또한 500 에러는 모니터링 지표 자체를 오염시킵니다. 정상적인 비즈니스 거절(예: 잔액 부족, 권한 없음)까지 500으로 처리하면 알람이 무의미하게 울리고, 진짜 장애가 발생해도 묻혀버립니다. 그래서 잘 설계된 API에서는 다음 원칙이 적용됩니다.
- 잘못된 요청: 400 Bad Request + 어떤 필드가 왜 잘못됐는지
- 인증 실패: 401 Unauthorized
- 권한 없음: 403 Forbidden
- 리소스 없음: 404 Not Found
- 비즈니스 규칙 위반: 409 Conflict 또는 422 Unprocessable Entity
- 예상하지 못한 진짜 예외: 500 Internal Server Error
이 분류가 코드에서 자동으로 적용되도록 만드는 것이 바로 3단 방어선의 목적입니다.
1단 방어선: Bean Validation으로 입력값을 막는다
가장 바깥쪽 방어선은 컨트롤러로 들어오는 요청 자체를 검증하는 것입니다. Spring Boot에서는 Jakarta Bean Validation(jakarta.validation)을 어노테이션 하나로 적용할 수 있습니다.
@Getter
@Setter
public class OrderRequest {
@NotBlank(message = "고객명은 필수입니다")
@Size(max = 50, message = "고객명은 50자 이하여야 합니다")
private String customerName;
@NotBlank(message = "휴대폰 번호는 필수입니다")
@Pattern(regexp = "^01[016789]\\d{7,8}$", message = "휴대폰 번호 형식이 올바르지 않습니다")
private String phone;
@NotNull(message = "주문 금액은 필수입니다")
@Min(value = 1000, message = "주문 금액은 1,000원 이상이어야 합니다")
private Integer amount;
@Email(message = "이메일 형식이 올바르지 않습니다")
private String email;
}
컨트롤러에서는 @Valid 한 줄로 검증이 활성화됩니다.
@PostMapping("/api/v1/orders")
public OrderResponse register(@RequestBody @Valid OrderRequest request) {
return orderService.register(request);
}
이 단계에서 검증에 실패하면 Spring은 MethodArgumentNotValidException을 던집니다. 이 예외를 공통 핸들러에서 잡아 400 응답으로 변환하면, 호출 측은 어떤 필드가 어떤 이유로 거절됐는지 즉시 알 수 있습니다. 비즈니스 로직까지 내려가지 않으므로 자원 낭비도 없습니다.
자주 쓰는 검증 어노테이션은 다음과 같이 분류됩니다.
| 분류 | 어노테이션 | 용도 |
|---|---|---|
| 필수값 | @NotNull, @NotBlank, @NotEmpty | null·공백·빈 컬렉션 거절 |
| 길이/크기 | @Size, @Min, @Max | 문자열 길이, 숫자 범위 |
| 포맷 | @Pattern, @Email, @URL | 정규식, 이메일, URL 형식 |
| 날짜 | @Past, @Future, @PastOrPresent | 과거/미래 날짜 |
| 컬렉션 | @Valid (재귀 적용) | 중첩 객체·리스트 검증 |
2단 방어선: 비즈니스 예외를 명시적으로 던진다
Bean Validation은 형식을 검증하지만, 비즈니스 규칙까지 잡지는 못합니다. 예를 들어 “고객명 형식은 맞지만 이미 동일 주문이 존재한다”거나 “주문은 가능하지만 잔액이 부족하다” 같은 경우는 서비스 계층에서 판별해야 합니다.
이 시점에서 가장 흔히 보이는 안티 패턴은 RuntimeException이나 일반 Exception을 그대로 던지는 것입니다. 그렇게 되면 결국 500으로 처리됩니다. 대신 비즈니스 예외를 명시적인 클래스로 모델링해야 합니다.
public enum ErrorCode {
DUPLICATE_ORDER("4090", "이미 등록된 주문입니다", HttpStatus.CONFLICT),
INSUFFICIENT_BALANCE("4091", "잔액이 부족합니다", HttpStatus.CONFLICT),
CUSTOMER_NOT_FOUND("4041", "존재하지 않는 고객입니다", HttpStatus.NOT_FOUND),
INVALID_PAYMENT_METHOD("4221", "지원하지 않는 결제 수단입니다", HttpStatus.UNPROCESSABLE_ENTITY);
private final String code;
private final String message;
private final HttpStatus status;
// 생성자, getter 생략
}
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() { return errorCode; }
}
서비스 계층에서는 다음과 같이 사용합니다.
@Service
public class OrderService {
public OrderResponse register(OrderRequest request) {
if (orderRepository.existsByCustomerKey(request.getCustomerKey())) {
throw new BusinessException(ErrorCode.DUPLICATE_ORDER);
}
if (account.getBalance() < request.getAmount()) {
throw new BusinessException(ErrorCode.INSUFFICIENT_BALANCE);
}
// ... 정상 처리
}
}
비즈니스 예외를 명시화하면 두 가지 이점이 생깁니다. 첫째, 에러 코드와 HTTP 상태가 한 곳에 정의되어 일관성이 유지됩니다. 둘째, 새로운 예외 케이스를 추가할 때 enum에 한 줄만 추가하면 되므로 변경이 단순해집니다.
3단 방어선: @RestControllerAdvice로 공통 처리한다
마지막 단계는 모든 예외를 한 곳에서 받아 표준 응답으로 변환하는 것입니다. Spring Boot의 @RestControllerAdvice가 그 역할을 합니다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// 1단 방어선에서 잡힌 검증 실패
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
List<FieldError> details = e.getBindingResult().getFieldErrors().stream()
.map(f -> new FieldError(f.getField(), f.getDefaultMessage()))
.toList();
ErrorResponse body = ErrorResponse.of("4000", "요청 값이 올바르지 않습니다", details);
return ResponseEntity.badRequest().body(body);
}
// 2단 방어선에서 던진 비즈니스 예외
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
ErrorCode code = e.getErrorCode();
ErrorResponse body = ErrorResponse.of(code.getCode(), code.getMessage(), null);
return ResponseEntity.status(code.getStatus()).body(body);
}
// 마지막 안전망: 정말 예상하지 못한 예외만 500
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("[UNEXPECTED] {}", MDC.get("traceId"), e);
ErrorResponse body = ErrorResponse.of("5000", "일시적인 오류가 발생했습니다", null);
return ResponseEntity.internalServerError().body(body);
}
}
여기서 중요한 두 가지 디테일이 있습니다.
첫째, 마지막 Exception.class 핸들러는 반드시 둡니다. 이걸 두지 않으면 예상하지 못한 예외가 그대로 흘러나가 스택트레이스가 응답에 포함되어 보안 위험이 됩니다. 더불어 호출 측에 “일시적 오류”라는 안전한 메시지로 응답을 통일할 수 있습니다.
둘째, 500이 발생했을 때는 반드시 traceId와 함께 로그를 남깁니다. 이전 글에서 다룬 traceId 패턴과 결합하면, 500이 발생한 단 한 건의 요청도 전 구간 추적이 가능합니다.
에러 응답 구조를 표준화한다
호출 측이 가장 싫어하는 것 중 하나는 에러 응답 구조가 엔드포인트마다 다른 것입니다. 어떤 API는 {"message": "..."}, 어떤 API는 {"error": {"code": ...}}, 어떤 API는 평문 문자열을 돌려주는 식입니다. 이런 상황에서는 호출 측이 에러 처리 코드를 분기별로 작성해야 합니다.
따라서 모든 API에서 동일한 에러 응답 구조를 사용해야 합니다.
public record ErrorResponse(
String code, // 자체 정의 에러 코드 (예: "4090")
String message, // 사람이 읽을 수 있는 메시지
List<FieldError> errors, // 필드 단위 검증 오류 (선택)
String traceId, // 추적용 ID
String timestamp // ISO-8601
) {
public static ErrorResponse of(String code, String message, List<FieldError> errors) {
return new ErrorResponse(code, message,
errors,
MDC.get("traceId"),
ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
}
}
Spring Boot 3.x부터는 RFC 7807을 따르는 ProblemDetail이 기본 제공됩니다. 외부 표준을 따르고 싶다면 ProblemDetail을 사용하고, 사내 규약이 이미 정해져 있다면 위와 같은 자체 ErrorResponse로 통일하시기 바랍니다. 중요한 것은 표준의 선택이 아니라, 모든 엔드포인트에 동일하게 적용되는 것입니다.
잘못된 에러 응답 vs 잘 정리된 에러 응답
// 잘못된 예: 500으로 통일된 모호한 응답
{ "status": 500, "error": "Internal Server Error" }
// 잘 정리된 예: 호출 측이 즉시 대응 가능한 응답
{
"code": "4090",
"message": "이미 등록된 주문입니다",
"errors": null,
"traceId": "8c4b1b6e-4a1a-4f3e-b2a8-9d8b6f2c1a3e",
"timestamp": "2026-05-17T10:23:11+09:00"
}
후자의 응답을 받은 호출 측은 “재시도해도 의미가 없는 중복 주문”임을 즉시 판단하고, traceId를 첨부해 문의를 보낼 수 있습니다.
운영 체크리스트: 500을 0에 가깝게 만들기
신규 API를 오픈하기 전에 다음 항목을 점검하시기 바랍니다.
첫째, 요청 DTO에 Bean Validation 어노테이션이 빠짐없이 적용되어 있는지 확인합니다. 컨트롤러에 @Valid를 빠뜨리면 검증이 작동하지 않습니다.
둘째, 모든 비즈니스 예외가 BusinessException을 통해 던져지는지 확인합니다. RuntimeException을 직접 던지는 코드가 남아 있다면 500으로 처리됩니다.
셋째, @RestControllerAdvice에 최종 Exception.class 핸들러가 있는지 확인합니다. 이것이 마지막 안전망입니다.
넷째, 500 응답 발생 시 traceId와 함께 ERROR 레벨 로그가 남는지 확인합니다. 로그 모니터링 시스템(ELK, Loki 등)에서 500 발생 시 즉시 알림이 가도록 연동합니다.
다섯째, 에러 응답 구조가 모든 엔드포인트에서 동일한지 확인합니다. 신규 API 추가 시 자동으로 같은 구조가 적용되도록 핸들러를 한 곳에 모읍니다.
여섯째, 500 발생 빈도를 주간 지표로 추적합니다. 0건이 이상적이고, 발생 시 모두 원인 분석 후 비즈니스 예외로 승격하거나 버그를 수정해야 합니다.
자주 묻는 질문 (FAQ)
Q1. Bean Validation과 비즈니스 예외를 굳이 나눌 필요가 있나요?
검증 시점과 책임이 다르기 때문에 분리해야 합니다. Bean Validation은 컨트롤러 단에서 요청 형식을 막고, 비즈니스 예외는 서비스 단에서 상태와 규칙을 막습니다. 둘을 섞으면 컨트롤러가 비즈니스 로직을 알게 되거나, 서비스가 검증 메시지 포맷을 알게 되어 책임이 흐려집니다.
Q2. @Valid와 @Validated의 차이는 무엇인가요?
@Valid는 Jakarta Bean Validation 표준 어노테이션으로 단일 그룹 검증과 중첩 객체 검증에 사용합니다. @Validated는 Spring 전용 어노테이션으로 검증 그룹(Group) 지정과 클래스 단위 검증(@Service, @Controller 메서드 파라미터)에 사용합니다. 일반적인 컨트롤러 본문 검증은 @Valid로 충분합니다.
Q3. ProblemDetail과 자체 ErrorResponse 중 무엇이 좋나요?
신규 프로젝트라면 ProblemDetail(RFC 7807) 을 권장합니다. 표준을 따르므로 다른 시스템과의 호환성이 좋고, Spring 6.0+에서 기본 지원됩니다. 다만 이미 사내에 자체 에러 응답 규약이 자리잡혀 있다면, 일관성 유지를 위해 기존 구조를 유지하시기 바랍니다.
Q4. 모든 에러를 400으로 만들면 500은 정말 사라지나요?
이론적으로는 그렇지만, 현실에서는 OutOfMemoryError, NullPointerException 같은 진짜 버그, 외부 시스템 장애, DB 커넥션 풀 고갈 등 예측 불가능한 상황이 남습니다. 이것들은 500으로 정직하게 응답하고 즉시 알림이 가도록 해야 합니다. 목표는 “500을 0으로 만드는 것”이 아니라 “500이 떴다면 진짜 사고” 임을 보장하는 것입니다.
Q5. 에러 응답에 너무 자세한 정보를 담으면 보안 문제가 되지 않나요?
맞습니다. 응답에는 자체 에러 코드와 사람이 읽을 메시지만 담고, 스택트레이스나 SQL 쿼리, 내부 경로 같은 정보는 절대 노출하면 안 됩니다. 그런 상세 정보는 서버 로그에만 남기고, 응답에는 traceId만 포함시켜 호출 측이 문의 시 사용하도록 합니다.
마무리
Spring Boot Validation 공통 에러처리의 본질은 코드 정리가 아니라 운영 메시지의 품질 관리입니다. 잘못된 요청은 400대로 명확히 거절하고, 비즈니스 규칙 위반은 적절한 에러 코드로 응답하며, 진짜 예외만 500으로 남깁니다. 이 3단 방어선이 자리잡으면 운영 알림은 정확해지고, 외부 연동 파트너와의 분쟁은 줄어들며, 디버깅 시간도 크게 단축됩니다.
핵심을 다시 정리하면 다음과 같습니다. 1단계는 @Valid와 Bean Validation으로 입력 형식을 막고, 2단계는 BusinessException과 ErrorCode enum으로 비즈니스 규칙을 명시화하며, 3단계는 @RestControllerAdvice로 모든 예외를 표준 응답으로 변환합니다. 여기에 에러 응답 구조를 통일하고 traceId를 함께 노출하면, 한 번의 장애 신고도 수 분 안에 추적할 수 있는 API가 완성됩니다.
핵심 요약
- 500 에러는 호출 측에 추적 단서를 주지 못하므로 진짜 예외 상황에만 사용합니다.
- 1단 방어선은
@Valid+ Bean Validation 어노테이션으로 입력 형식을 막습니다.- 2단 방어선은
BusinessException+ErrorCodeenum으로 비즈니스 규칙을 명시화합니다.- 3단 방어선은
@RestControllerAdvice+Exception.class최종 핸들러로 모든 예외를 표준화합니다.- 에러 응답은 자체 코드·메시지·traceId·timestamp로 통일하고, 스택트레이스는 응답에 노출하지 않습니다.
“Spring Boot Validation 공통 에러처리”에 대한 1개의 생각