들어가며
Spring Boot 환경에서 REST API 개발 시, @Valid 검증 오류를 어떻게 처리할지는 중요한 아키텍처 결정 사항입니다. 많은 개발팀에서 컨트롤러에서 BindingResult를 직접 처리하는 패턴을 사용하고 있지만, 이는 관심사 분리 원칙을 위반하고 코드 품질을 저하시키는 안티패턴입니다.
본 아티클에서는 왜 컨트롤러에서 BindingResult를 직접 처리하면 안 되는지, 그리고 GlobalExceptionHandler를 활용한 중앙화된 예외 처리 전략이 더 나은 선택인지 기술적으로 분석해보겠습니다.

Spring MVC 검증 처리 메커니즘의 이해
HandlerAdapter의 검증 처리 전략
Spring MVC의 HandlerAdapter는 BindingResult 매개변수의 존재 여부에 따라 서로 다른 검증 처리 전략을 사용합니다:
// BindingResult 없음: 검증 실패 시 MethodArgumentNotValidException 발생
@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@Valid @RequestBody UserDto userDto) {
// HandlerAdapter가 검증 실패 시 예외를 던짐
return ResponseEntity.ok(userService.save(userDto));
}
// BindingResult 있음: 검증 실패해도 예외 발생 안함, 개발자가 직접 처리
@PostMapping("/users")
public ResponseEntity<?> createUser(@Valid @RequestBody UserDto userDto, BindingResult bindingResult) {
// HandlerAdapter가 검증 결과를 bindingResult에 저장, 예외 던지지 않음
if (bindingResult.hasErrors()) {
// 개발자가 직접 처리해야 함
}
return ResponseEntity.ok(userService.save(userDto));
}
핵심: BindingResult는 단순히 검증 결과를 담는 객체가 아니라, Spring에게 "검증 실패 시 어떻게 처리할지"를 알려주는 신호 역할을 합니다.
안티패턴: 컨트롤러에서 BindingResult 직접 처리
문제가 되는 코드 패턴
@RestController
@RequestMapping("/api/v1")
public class UserController {
@PostMapping("/users")
public ResponseEntity<ApiResponse<UserDto>> createUser(
@Valid @RequestBody CreateUserRequestDto request,
BindingResult bindingResult // 문제의 시작
) {
// 컨트롤러가 검증 처리 담당 - 관심사 분리 원칙 위반
if (bindingResult.hasErrors()) {
Map<String, String> errors = new HashMap<>();
bindingResult.getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity.badRequest()
.body(ApiResponse.fail("검증 실패", errors));
}
// 실제 비즈니스 로직은 한 줄
UserDto createdUser = userService.createUser(request);
return ResponseEntity.ok(ApiResponse.success("사용자 생성 완료", createdUser));
}
@PostMapping("/products")
public ResponseEntity<ApiResponse<ProductDto>> createProduct(
@Valid @RequestBody CreateProductRequestDto request,
BindingResult bindingResult // 같은 패턴 반복
) {
// 동일한 검증 처리 로직이 모든 컨트롤러에서 반복됨
if (bindingResult.hasErrors()) {
// 복사-붙여넣기된 코드
Map<String, String> errors = new HashMap<>();
bindingResult.getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity.badRequest()
.body(ApiResponse.fail("검증 실패", errors));
}
ProductDto createdProduct = productService.createProduct(request);
return ResponseEntity.ok(ApiResponse.success("상품 생성 완료", createdProduct));
}
}
이 패턴의 구체적인 문제점
1. 단일 책임 원칙(SRP) 위반
// 컨트롤러가 너무 많은 책임을 가짐
public class UserController {
// 책임 1: HTTP 요청/응답 처리
// 책임 2: 데이터 검증 오류 처리 ← 이것이 문제
// 책임 3: 비즈니스 로직 호출
// 책임 4: 응답 형태 결정
}
2. 코드 중복(DRY 원칙 위반)
동일한 검증 오류 처리 로직이 모든 컨트롤러 메서드에서 반복됩니다.
3. 일관성 보장 불가
개발자마다 다른 방식으로 검증 오류를 처리할 가능성이 높아집니다.
4. 테스트 복잡도 증가
컨트롤러 테스트에서 검증 로직까지 함께 테스트해야 합니다.
권장 패턴: GlobalExceptionHandler 중앙화
올바른 아키텍처 설계
// 컨트롤러: HTTP 처리에만 집중
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/users")
public ResponseEntity<ApiResponse<UserDto>> createUser(
@Valid @RequestBody CreateUserRequestDto request // BindingResult 제거
) {
// 검증 실패 시 MethodArgumentNotValidException 발생 → GlobalExceptionHandler가 처리
UserDto createdUser = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("사용자 생성 완료", createdUser));
}
@PostMapping("/products")
public ResponseEntity<ApiResponse<ProductDto>> createProduct(
@Valid @RequestBody CreateProductRequestDto request
) {
// 동일한 패턴, 깔끔한 코드
ProductDto createdProduct = productService.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("상품 생성 완료", createdProduct));
}
}
// GlobalExceptionHandler: 모든 예외 처리를 중앙화
@RestControllerAdvice
@RequiredArgsConstructor
@Slf4j
public class GlobalExceptionHandler {
private final MessageSource messageSource;
// @Valid 검증 실패 시 발생하는 예외 처리
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ApiResponse<ValidationErrorResponse>> handleValidationException(
MethodArgumentNotValidException exception
) {
log.warn("Validation failed: {}", exception.getBindingResult().getObjectName());
// BindingResult는 여기서 처리 - 적절한 위치
ValidationErrorResponse errorResponse = createValidationErrorResponse(exception.getBindingResult());
return ResponseEntity.badRequest()
.body(ApiResponse.fail("입력 데이터 검증에 실패했습니다.", errorResponse));
}
// 비즈니스 로직 예외 처리
@ExceptionHandler(BaseException.class)
protected ResponseEntity<ApiResponse<Void>> handleBusinessException(BaseException exception) {
log.warn("Business logic error: {}", exception.getMessage());
ErrorCode errorCode = exception.getErrorCode();
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ApiResponse.fail(errorCode.getMessage(), null));
}
private ValidationErrorResponse createValidationErrorResponse(BindingResult bindingResult) {
List<FieldError> fieldErrors = bindingResult.getFieldErrors().stream()
.map(error -> FieldError.builder()
.field(error.getField())
.message(messageSource.getMessage(error, Locale.getDefault()))
.rejectedValue(error.getRejectedValue())
.build())
.toList();
return ValidationErrorResponse.builder()
.fieldErrors(fieldErrors)
.build();
}
}
책임의 명확한 분리
// 각 레이어의 명확한 책임
┌─────────────────┐
│ Controller │ ← HTTP 요청/응답 처리만
├─────────────────┤
│ Service │ ← 비즈니스 로직 처리
├─────────────────┤
│ Repository │ ← 데이터 접근 처리
├─────────────────┤
│GlobalException │ ← 모든 예외 처리 (검증 오류 포함)
│ Handler │
└─────────────────┘
실무 적용 가이드라인
1. 표준화된 검증 오류 응답 설계
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ValidationErrorResponse {
private final List<FieldErrorDetail> fieldErrors;
private final List<String> globalErrors;
@Getter
@Builder
public static class FieldErrorDetail {
private final String field;
private final String message;
private final Object rejectedValue;
private final String code;
}
}
2. 계층화된 예외 처리 전략
// 비즈니스 예외와 검증 예외의 명확한 분리
@RestControllerAdvice
public class GlobalExceptionHandler {
// 입력 데이터 검증 예외 (400 Bad Request)
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ApiResponse<ValidationErrorResponse>> handleValidation(...) {
// 클라이언트 입력 오류
}
// 비즈니스 로직 예외 (도메인별 상태 코드)
@ExceptionHandler(BaseException.class)
protected ResponseEntity<ApiResponse<Void>> handleBusiness(...) {
// 비즈니스 규칙 위반
}
// 시스템 예외 (500 Internal Server Error)
@ExceptionHandler(Exception.class)
protected ResponseEntity<ApiResponse<Void>> handleSystem(...) {
// 예상치 못한 시스템 오류
}
}
3. 모니터링 및 로깅 전략
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ApiResponse<ValidationErrorResponse>> handleValidationException(
MethodArgumentNotValidException exception,
HttpServletRequest request
) {
// 구조화된 로깅
String requestUri = request.getRequestURI();
String method = request.getMethod();
String objectName = exception.getBindingResult().getObjectName();
log.warn("Validation failed - URI: {} {}, Object: {}, Errors: {}",
method, requestUri, objectName,
exception.getBindingResult().getErrorCount());
// 메트릭 수집
meterRegistry.counter("validation.errors",
"endpoint", requestUri,
"method", method).increment();
ValidationErrorResponse errorResponse = createValidationErrorResponse(exception.getBindingResult());
return ResponseEntity.badRequest()
.body(ApiResponse.fail("입력 데이터 검증에 실패했습니다.", errorResponse));
}
예외적 상황: 언제 컨트롤러에서 BindingResult를 사용할까?
복잡한 다단계 검증이 필요한 경우
@PostMapping("/complex-validation")
public ResponseEntity<ApiResponse<ResultDto>> complexValidation(
@Valid @RequestBody RequestDto request,
BindingResult bindingResult // 예외적으로 사용
) {
// 1단계: 기본 Bean Validation 검증
if (bindingResult.hasErrors()) {
return handleValidationErrors(bindingResult);
}
// 2단계: 비즈니스 컨텍스트 기반 검증
validateBusinessContext(request, bindingResult);
if (bindingResult.hasErrors()) {
return handleValidationErrors(bindingResult);
}
// 3단계: 외부 시스템 연동 검증
validateExternalSystem(request, bindingResult);
if (bindingResult.hasErrors()) {
return handleValidationErrors(bindingResult);
}
ResultDto result = service.process(request);
return ResponseEntity.ok(ApiResponse.success("처리 완료", result));
}
주의: 이런 경우에도 검증 로직 자체는 별도 서비스나 컴포넌트로 분리하는 것이 좋습니다.
마이그레이션 전략
단계적 리팩터링 접근법
// Phase 1: GlobalExceptionHandler 구현
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ApiResponse<ValidationErrorResponse>> handleValidation(...) {
// 중앙화된 검증 처리 로직
}
}
// Phase 2: 새로운 API부터 적용
@PostMapping("/new-endpoint")
public ResponseEntity<ApiResponse<DataDto>> newEndpoint(@Valid @RequestBody RequestDto request) {
// BindingResult 없이 깔끔하게
return ResponseEntity.ok(ApiResponse.success("성공", service.process(request)));
}
// Phase 3: 기존 API 점진적 리팩터링
@PostMapping("/legacy-endpoint")
public ResponseEntity<ApiResponse<DataDto>> legacyEndpoint(@Valid @RequestBody RequestDto request) {
// BindingResult 제거, GlobalExceptionHandler가 처리
return ResponseEntity.ok(ApiResponse.success("성공", service.process(request)));
}
팀 차원의 코딩 표준 수립
코드 리뷰 체크리스트
validation_handling_checklist:
- "컨트롤러에서 BindingResult 사용하고 있지 않은가?"
- "동일한 검증 처리 로직이 반복되고 있지 않은가?"
- "GlobalExceptionHandler에서 모든 검증 오류를 처리하고 있는가?"
- "검증 오류 응답 형태가 표준화되어 있는가?"
architecture_principles:
- "컨트롤러는 HTTP 처리에만 집중한다"
- "검증 오류 처리는 GlobalExceptionHandler가 담당한다"
- "예외 처리는 중앙화한다"
- "일관된 응답 형태를 유지한다"
결론
BindingResult 자체는 Spring Framework의 유용한 메커니즘입니다. 문제는 컨트롤러에서 이를 직접 처리하는 것입니다.
핵심 원칙
- 컨트롤러는 HTTP 요청/응답 처리에만 집중해야 합니다
- 검증 오류 처리는 GlobalExceptionHandler가 담당해야 합니다
- 관심사 분리를 통해 각 레이어의 책임을 명확히 해야 합니다
- 중앙화된 예외 처리로 일관성을 보장해야 합니다
실무 적용 가이드
- 새로운 API 개발 시에는 BindingResult를 컨트롤러에서 사용하지 않기
- GlobalExceptionHandler에서 MethodArgumentNotValidException으로 검증 오류 처리
- 복잡한 검증이 필요한 특수 상황에서만 예외적으로 BindingResult 사용 고려
- 팀 차원에서 명확한 예외 처리 가이드라인 수립
이러한 접근 방식을 통해 더 깔끔하고 유지보수하기 좋은 Spring Boot 애플리케이션을 구축할 수 있다고 생각합니다.
References
'Spring > 이론' 카테고리의 다른 글
| Spring Boot 환경에서의 직렬화(Serialization) 이해하기 (3) | 2025.10.22 |
|---|---|
| Entity 식별자 전략: UUID vs Auto Increment vs Snowflake (0) | 2025.10.01 |
| 데이터베이스 인덱스의 구조와 특징 (0) | 2025.06.18 |
| JPA 엔티티에서 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용하는 이유 (0) | 2025.06.16 |
| Static 메서드, 언제 사용하고 언제 피해야 할까? (0) | 2025.06.16 |