Spring Boot 3에서 @Valid, BindingResult, @ControllerAdvice로 입력 검증을 적용하고, 공통 에러 응답 포맷을 설계해 일관된 API를 만드는 방법을 정리합니다.
도입 (문제 상황)
API를 만들다 보면 “필수값이 빠졌는데 500이 떨어져요”, “어떤 API는 errors 배열이고 어떤 API는 message 하나예요” 같은 상황을 자주 만나게 됩니다. 검증은 넣었는데 응답 포맷이 제각각이라 프런트/모바일에서 예외 처리가 더 어려워지기도 해요. 이 글에서는 Spring Boot 3 기준으로 검증과 에러 응답을 한 번에 정리해 보겠습니다.
핵심 개념: Spring Boot Validation이 중요한 이유와 표준화 포인트
검증(Validation)은 단순히 “값이 비었는지”를 확인하는 기능이 아니라, 계약(Contract) 을 지키게 만드는 장치입니다. 클라이언트가 잘못된 요청을 보내면 서버는 예측 가능한 방식으로 거절해야 하고(보통 400), 그 거절 응답은 모든 API에서 일관돼야 합니다.
1) @Valid / @Validated: “검증 트리거”를 어디에 거는가
@Valid는 주로 요청 DTO(@RequestBody, @ModelAttribute) 에 붙여 검증을 트리거합니다.- 컨트롤러 메서드 파라미터에
@Valid를 붙이면, Spring이 바인딩 후 Bean Validation(Jakarta Validation)을 실행합니다. - 그룹 검증이 필요하면
@Validated를 고려하지만, 일반적인 CRUD에서는@Valid만으로 충분한 경우가 많습니다.
2) BindingResult: 예외를 던질지, 컨트롤러에서 처리할지
@Valid대상 바로 뒤에BindingResult(또는Errors)를 두면 검증 실패 시 예외를 던지지 않고 컨트롤러로 흐름이 들어옵니다.- 반대로
BindingResult가 없으면 검증 실패 시 보통MethodArgumentNotValidException(JSON 바디) 또는BindException(쿼리/폼)이 발생하고, 이를 전역 예외 처리로 표준화하기 좋습니다.
실무에서는 “모든 검증 실패는 전역에서 같은 포맷으로 응답”이 목표라면, 컨트롤러에서 BindingResult로 분기하지 않고 예외 기반으로 통일하는 편이 유지보수에 유리한 경우가 많습니다(특정 화면에서만 커스텀 처리 필요할 때만 BindingResult를 씁니다).
3) @ControllerAdvice: 에러 응답의 ‘관문’을 하나로 모으기
@ControllerAdvice + @ExceptionHandler는 컨트롤러 전반에서 발생하는 예외를 한 곳에서 잡아 HTTP 상태 코드, 에러 코드, 메시지, 필드 오류 목록을 표준화합니다.
특히 검증 실패는 “필드별로 무엇이 왜 틀렸는지”가 중요합니다. 메시지 하나로 뭉개면 디버깅도 어렵고 UX도 나빠져요. 그래서 보통 다음처럼 나눕니다.
- top-level: timestamp, path, errorCode, message
- details: fieldErrors[{field, rejectedValue, reason}] 또는 violations
4) 공통 에러 포맷 설계: “프런트가 분기하지 않게”
아래 항목은 에러 포맷을 설계할 때 자주 쓰는 체크리스트입니다.
| 항목 | 권장 이유 | 예시 |
|---|---|---|
| errorCode | 화면/클라이언트가 안정적으로 분기 | VALIDATION_FAILED, RESOURCE_NOT_FOUND |
| message | 사람에게 보여줄 요약 | "요청 값이 올바르지 않습니다." |
| fieldErrors | 폼/필드 하이라이트에 필요 | [{ "field":"email", "reason":"must be a well-formed email address" }] |
| path | 어떤 API에서 났는지 추적 | "/api/users" |
| traceId(선택) | 로그 상관관계 | "a1b2c3..." |
에러 포맷은 “예쁘게”보다 “안정적으로”가 핵심입니다. 버전이 바뀌어도 errorCode와 fieldErrors 구조가 유지되면 클라이언트는 덜 고생합니다.
flowchart TD
A["Client request"] --> B["Spring MVC binding"]
B --> C{"Validation pass?"}
C -- "Yes" --> D["Controller logic"]
C -- "No" --> E["Exception (MethodArgumentNotValidException)"]
E --> F["@ControllerAdvice handler"]
F --> G["Standard error response (400)"]

검증 실패는 예외로 모아 @ControllerAdvice에서 공통 포맷으로 응답하는 흐름입니다.
코드 예제: @Valid + @ControllerAdvice로 공통 에러 응답 만들기 (복붙 실행)
아래 예제는 Spring Boot 3.x(Java 17)에서 바로 실행 가능한 최소 구성입니다.
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation' // Jakarta Validation
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
application.yml (선택)
server:
port: 8080
spring:
jackson:
serialization:
write-dates-as-timestamps: false
예제 API: 회원 가입 요청 DTO + 컨트롤러
package com.example.demo.user;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateUserRequest(
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,
@NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.")
String password,
@NotBlank(message = "이름은 필수입니다.")
@Size(max = 20, message = "이름은 20자 이하여야 합니다.")
String name
) {}
package com.example.demo.user;
public record CreateUserResponse(
Long id,
String email,
String name
) {}
package com.example.demo.user;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public CreateUserResponse create(@RequestBody @Valid CreateUserRequest request) {
// 예제이므로 저장 로직은 생략하고 고정 응답
return new CreateUserResponse(1L, request.email(), request.name());
}
}
공통 에러 응답 포맷 + 전역 예외 처리(@ControllerAdvice)
package com.example.demo.error;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiErrorResponse(
Instant timestamp,
int status,
String errorCode,
String message,
String path,
List<FieldErrorItem> fieldErrors
) {
public record FieldErrorItem(
String field,
Object rejectedValue,
String reason
) {}
}
package com.example.demo.error;
import jakarta.servlet.http.HttpServletRequest;
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.time.Instant;
import java.util.List;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiErrorResponse> handleValidation(
MethodArgumentNotValidException ex,
HttpServletRequest request
) {
List<ApiErrorResponse.FieldErrorItem> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(this::toFieldErrorItem)
.toList();
ApiErrorResponse body = new ApiErrorResponse(
Instant.now(),
HttpStatus.BAD_REQUEST.value(),
"VALIDATION_FAILED",
"요청 값이 올바르지 않습니다.",
request.getRequestURI(),
fieldErrors
);
return ResponseEntity.badRequest().body(body);
}
private ApiErrorResponse.FieldErrorItem toFieldErrorItem(FieldError fe) {
Object rejected = fe.getRejectedValue();
return new ApiErrorResponse.FieldErrorItem(
fe.getField(),
rejected,
fe.getDefaultMessage()
);
}
}
실행 & 테스트
정상 요청:
curl -X POST http://localhost:8080/api/users \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"password123","name":"kim"}'
검증 실패 요청(비밀번호 짧음, 이메일 형식 오류):
curl -X POST http://localhost:8080/api/users \
-H 'Content-Type: application/json' \
-d '{"email":"not-email","password":"123","name":""}'
예상 응답(예시):
{
"timestamp": "2026-03-09T10:12:30.123Z",
"status": 400,
"errorCode": "VALIDATION_FAILED",
"message": "요청 값이 올바르지 않습니다.",
"path": "/api/users",
"fieldErrors": [
{ "field": "email", "rejectedValue": "not-email", "reason": "이메일 형식이 올바르지 않습니다." },
{ "field": "password", "rejectedValue": "123", "reason": "비밀번호는 8자 이상이어야 합니다." },
{ "field": "name", "rejectedValue": "", "reason": "이름은 필수입니다." }
]
}
실무 팁
💡 실무에서는
BindingResult를 남발하면 에러 포맷이 퍼집니다. 화면별로 “이 컨트롤러만 다른 에러 형태”가 생기기 쉬워요. 특별한 이유가 없다면 검증 실패는 예외로 통일하고,@ControllerAdvice에서 한 번에 포맷을 맞추는 쪽이 운영 비용이 낮습니다.
💡 실무에서는
에러 메시지(localization)와 에러 코드(errorCode)를 분리해 두는 게 안전합니다. 사용자에게 보여줄 문구는 바뀔 수 있지만, 클라이언트 분기 기준인errorCode는 최대한 고정해야 합니다. 메시지는 i18n(메시지 소스)로 관리하고,errorCode는 enum/상수로 관리해 보세요.
핵심 요약
@Valid로 검증을 트리거하고, 검증 실패는 전역 예외 처리로 모아 표준화하는 게 유지보수에 유리합니다.@ControllerAdvice에서MethodArgumentNotValidException을 잡아fieldErrors기반의 공통 포맷을 만드세요.- 에러 응답은
errorCode(기계용)와message(사람용)를 분리하면 변경에 강해집니다.
다음 글: [계층형 구조(Controller/Service/Repository) 잘 나누는 법]