본문 바로가기
Spring/MVC

[Spring MVC] Bean Validation과 전송 객체(DTO) 분리: 실무형 검증 및 예외 설계 정리

by coding_whale 2026. 5. 27.
반응형

1. 도입부 (Introduction)

웹 애플리케이션을 개발할 때 클라이언트로부터 유입되는 입력 데이터 검증(Validation)은 시스템의 안전성과 데이터 무결성을 보장하는 최전선 방어벽이다. 하지만 검증 조건이 늘어날수록 컨트롤러나 비즈니스 레이어는 값의 유무나 크기를 확인하는 지루한 if 분기문으로 가득 차게 된다. 이러한 보일러플레이트 코드는 핵심 비즈니스 로직을 가리고 유지보수를 어렵게 만드는 주범이다.

이러한 수동 검증 지옥을 탈출하고 애노테이션 선언만으로 검증 구조를 표준화할 수 있도록 지원하는 자바 기술 표준이 바로 Bean Validation이다. 이번 글에서는 스프링부트 환경에서 Bean Validation의 구동 메커니즘을 심층 분석하고, 실무에서 마주치는 '등록과 수정의 비즈니스 규칙 충돌'을 우아하게 해결하는 객체 분리 전략, 그리고 실무 엔터프라이즈 환경에서 필수적으로 채택하는 전역 예외 처리 인터셉터 통합 구조까지 한눈에 이해할 수 있도록 상세히 정리해 본다.

 

[Spring MVC] 검증(Validation) 정리 : 수동 검증부터 @Validated와 현대적 전역 API 검증까지

1. 도입부 (Introduction)웹 애플리케이션을 개발할 때 가장 중요하면서도 빈번하게 비즈니스 결함이 발생하는 지점이 바로 '입력 데이터 검증'이다. 만약 사용자가 상품 등록 폼에서 가격에 숫자가

myblog01150.tistory.com

 

 

2. 주요 특징 및 핵심 로직 (Main Features & Logic)

Bean Validation은 특정한 프레임워크에 종속된 기술이 아니라, 자바 진영의 기술 표준 사양(Jakarta Bean Validation, 과거 JSR-380)이다. 인터페이스와 검증 애노테이션의 모음집에 가까우며, 실무에서는 이 표준 사양을 구현한 Hibernate Validator를 주로 사용한다.

스프링부트는 이를 보다 쉽게 다룰 수 있도록 spring-boot-starter-validation 라이브러리를 통해 통합을 자동으로 지원한다.

 

 

3. 상세 가이드 및 심층 분석 (Detailed Guide)

3.1 의존성 설정 및 기본 동작 구조

프로젝트 빌드 시점에 아래와 같이 단 하나의 라이브러리를 주입하는 것만으로 스프링의 유기적인 글로벌 검증 환경이 세팅된다.

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}

이 의존성이 들어오면 스프링 부트는 애플리케이션 실행 시점에 자바 빈 제약 조건을 해석할 LocalValidatorFactoryBean을 컨텍스트에 자동 주입한다. 개발자는 검증이 필요한 자바 객체의 필드 상단에 다음과 같은 애노테이션을 자유롭게 명시하면 된다.

@Data
public class Item {

    private Long id;
    
    @NotBlank(message = "상품 이름은 필수입니다.")
    private String itemName;
    
    @NotNull(message = "가격을 입력해 주세요.")
    @Range(min = 1000, max = 1000000, message = "가격은 {min}원에서 {max}원 사이여야 합니다.")
    private Integer price;
    
    @NotNull(message = "수량을 입력해 주세요.")
    @Max(value = 9999, message = "수량은 최대 {value}개까지만 등록할 수 있습니다.")
    private Integer quantity;
}
  • @NotBlank: null을 허용하지 않을 뿐만 아니라 빈 문자열("")과 공백만으로 구성된 문자열(" ")까지 완벽하게 차단한다.
  • @NotNull: 어떠한 형태의 null 값도 허용하지 않는다.
  • @Range: 하이버네이트 자체 지원 애노테이션으로, 정의된 범위($1,000 \le x \le 1,000,000$)에 속해야 한다.
  • @Max: 지정한 최댓값($9999$) 이하의 값만 유효성을 인정한다.

(참고: 순수 자바 가상머신 내에서 구동할 때는 ValidatorFactory를 수동 빌드해 검증해야 하나, 스프링 환경에서는 디스패처 서블릿 단계에서 검증이 진행되므로 직접 구현할 필요가 없다.)

 

3.2 타입 변환과 검증의 순서 (LifeCycle)

초보 개발자들이 가장 많이 오해하는 부분 중 하나가 바로 "바인딩 오류가 발생해도 Bean Validation이 처리해 주겠지"라는 생각이다. 스프링 MVC 데이터 바인딩 생명주기는 다음과 같은 엄격한 2단계 순서에 따라 처리된다.

[클라이언트 요청] 
      ↓
[1단계: 타입 변환 (Type Binding)]
   ├─ 문자열 "1000" -> Integer 변환 성공 (성공) ──> [2단계: Bean Validation 검증 진입]
   └─ 문자열 "천원" -> Integer 변환 실패 (실패) ──> [typeMismatch FieldError 등록 및 즉시 중단]

 

 

3.3 커스텀 메시지 탐색 우선순위

유효성 검증 실패 시 생성되는 메시지 코드는 내부의 MessageCodesResolver에 의해 다음과 같이 계층형으로 동적 생성되어 errors.properties 또는 messages.properties를 탐색한다.

예를 들어 item 객체의 itemName 필드에서 @NotBlank 검증이 누락되었다면, 스프링은 아래 4단계의 우선순위로 매핑 문구를 추적한다.

  1. NotBlank.item.itemName (가장 구체적인 객체명 + 필드명 조합)
  2. NotBlank.itemName (필드명 단독 조합)
  3. NotBlank.java.lang.String (타입 단독 조합)
  4. NotBlank (애노테이션 기본 코드)
# src/main/resources/errors.properties
NotBlank={0}은(는) 공백일 수 없습니다.
Range={0}의 범위는 {2}에서 {1} 사이여야 합니다.

만약 프로젝트 공통 메시지 파일에 매핑 코드가 선언되어 있지 않다면, 두 번째 우선순위로 애노테이션 자체의 message 속성에 명시된 하드코딩 문구가 채택되며, 이마저도 공백일 경우에 한해 라이브러리가 내부적으로 품고 있는 영어/한글 기본 번역값("공백일 수 없습니다.")이 폴백 처리되어 반환된다.

 

3.4 오브젝트 오류(Global Error)와 자바 코드 매핑 전략

특정 필드가 아니라 "가격과 수량의 곱한 합계가 $10,000$원 이상이어야 한다"와 같은 전역 제약 조건은 클래스 레벨에 @ScriptAssert 애노테이션을 부여하여 해결할 수 있다.

@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item { ... }

하지만 자바 JDK 버전이나 런타임 환경에 따라 내부 자바스크립트 엔진(Nashorn 등)의 호환성 문제가 생길 수 있고, 선언된 검증 식이 복잡해질수록 코드 가독성을 극도로 저해한다.

따라서 실무에서는 필드 에러 영역만 애노테이션으로 정제하고, 여러 필드가 뒤섞인 전역 오브젝트 에러는 컨트롤러나 서비스 비즈니스 단에서 자바 코드로 직접 수동 검사하여 분기 처리하는 모델을 표준으로 취급한다.

// 특정 필드를 벗어나는 오브젝트 오류 처리는 코드 작성이 깔끔하다.
if (item.getPrice() != null && item.getQuantity() != null) {
    int totalPrice = item.getPrice() * item.getQuantity();
    if (totalPrice < 10000) {
        bindingResult.reject("totalPriceMin", new Object[]{10000, totalPrice}, null);
    }
}

 

 

4. 실무 적용 및 객체 분리 설계 (Enterprise Best Practices)

4.1 등록과 수정 비즈니스의 대치 상황

동일한 도메인 엔티티 객체인 Item을 등록 폼 수신부와 수정 폼 수신부에서 동시에 재사용하면 심각한 정책 마찰을 마주치게 된다.

  • 등록 시: 데이터베이스에 저장되기 전이므로 id 값은 null 상태여야만 한다. 수량은 대량 사재기를 방지하기 위해 최대 9999개까지만 허용해야 한다.
  • 수정 시: 어떤 행을 수정할지 식별해야 하므로 기본 키가 필수적으로 주입되어야 한다. 즉, id 필드가 @NotNull이어야 한다. 하지만 대량 벌크 유통을 위해 수량 제한은 제한 없이 무제한으로 풀고 싶다.

도메인 객체 하나만 사용한다면 수정용 정책(id 필수) 때문에 등록 시 에러가 터지고, 등록용 정책(수량 9999개 제한) 때문에 수정을 막는 병목 현상이 발생한다.

 

4.2 Groups 기능의 도입과 실무적 한계

이러한 마찰을 처리하기 위해 표준 스펙은 마킹용 인터페이스를 전달하는 Groups 기능적 옵션을 마련해 두고 있다.

// 마킹용 인터페이스 선언
public interface SaveCheck {}
public interface UpdateCheck {}
@Data
public class Item {
    @NotNull(groups = UpdateCheck.class) // 수정 조건에서만 트리거
    private Long id;
    
    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;
    
    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
    private Integer price;
    
    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Max(value = 9999, groups = SaveCheck.class) // 등록 조건에서만 트리거
    private Integer quantity;
}

이후 컨트롤러 파라미터 영역에서 @Validated(SaveCheck.class) 또는 @Validated(UpdateCheck.class)를 붙여 타겟 그룹을 지정한다.

하지만 이 방식은 실무에서 철저히 무시당하는 편이다. 검증 제약 조건이 20~30가지가 넘어가면 자바 코드 안에 가독성 떨어지는 중괄호 {} 배열 형태의 그룹 옵션이 무한히 붙게 되며, 도메인 클래스 하나가 너무 비대해져 비즈니스 파악을 차단하기 때문이다.

 

4.3 폼 전송용 전용 객체(DTO) 분리 전략

현대 엔터프라이즈 환경에서 채택하는 핵심 가이드라인은 도메인 엔티티를 웹 파라미터 수신 객체로 절대 직접 노출하지 않고, 화면의 성격에 부합하는 전송용 폼 객체를 완전히 분리하여 구현하는 방식이다.

[등록 화면 전송] ──> [ItemSaveForm DTO] ──> [Controller (검증)] ──> [Item 엔티티 변환] ──> [DB 저장]
[수정 화면 전송] ──> [ItemUpdateForm DTO] ──> [Controller (검증)] ──> [Item 엔티티 변환] ──> [DB 저장]

 

① 등록 전용 DTO 객체

@Data
public class ItemSaveForm {

    @NotBlank(message = "상품 이름은 필수입니다.")
    private String itemName;
    
    @NotNull(message = "가격을 입력해 주세요.")
    @Range(min = 1000, max = 1000000)
    private Integer price;
    
    @NotNull(message = "수량을 입력해 주세요.")
    @Max(value = 9999) // 등록 시에만 엄격하게 수량 상한을 고정한다.
    private Integer quantity;
}

② 수정 전용 DTO 객체

@Data
public class ItemUpdateForm {

    @NotNull(message = "수정 타겟의 식별자 ID는 필수입니다.") // 수정 시에는 ID가 필수 검증 대상이 된다.
    private Long id;
    
    @NotBlank(message = "상품 이름은 필수입니다.")
    private String itemName;
    
    @NotNull(message = "가격을 입력해 주세요.")
    @Range(min = 1000, max = 1000000)
    private Integer price;
    
    // 수량 필드는 @Max 애노테이션이 없으므로, 수정 시 무제한 변경을 무결하게 허용한다.
    private Integer quantity;
}

 

 

5. 실전 엔터프라이즈 통합: Global Exception Handling과 @RestControllerAdvice

실제 서비스 중인 대규모 프로젝트 코드를 뜯어보면, 컨트롤러 파라미터에 BindingResult를 함께 선언하여 검사하는 메서드는 단 하나도 찾을 수 없다.

왜냐하면 컨트롤러 메서드마다 if (bindingResult.hasErrors()) 블록을 구성하는 행위 자체가 극심한 중복이자 유지보수 코드를 늘리는 부작용이기 때문이다. 이를 해결하는 실무의 표준 구조를 설계해 본다.

 

5.1 BindingResult 제거와 예외 이송 원리

컨트롤러 파라미터에서 BindingResult를 과감하게 제거하면, @Valid 검증이 실패했을 때 스프링 MVC 아규먼트 리졸버는 내부적으로 예외를 가두는 대신 MethodArgumentNotValidException을 터트린다.

이 예외는 스프링 전역 호출 흐름으로 던져지며, 이를 전역 인터셉터 역할을 수행하는 @RestControllerAdvice 클래스가 캡쳐하여 정형화된 JSON 공통 포맷으로 응답한다.

 

5.2 엔터프라이즈급 실무 통합 코드 구현

① 공통 API 표준 응답 포맷 (Contract DTO)

모든 API 응답 구조를 정형화하여 백엔드-프론트 간의 명확한 통신 규약을 확립한다.

@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
    private boolean success;
    private T data;
    private String code;
    private String message;
    private List<FieldErrorDetail> errors; // 다중 필드 에러 상세 보관용
}

② 필드별 세부 에러 보관용 DTO

어느 필드에서 어떤 잘못된 값이 들어와 예외가 터졌는지 프론트엔드가 인지할 수 있는 핵심 메타데이터 규격을 설계한다.

@Getter
@AllArgsConstructor
public class FieldErrorDetail {
    private String field;
    private String rejectedValue;
    private String reason;
}

③ 전역 통합 예외 통제 센터 (GlobalExceptionHandler)

컨트롤러 바깥에서 발생하는 검증 실패 예외를 포착하고 공통 바디를 반환하는 아키텍처 코드를 정립한다.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * JSON 바디 데이터 검증(@RequestBody + @Valid) 실패 시 던져지는 예외를 총괄한다.
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationException(
            MethodArgumentNotValidException e, HttpServletRequest request) {
        
        log.error("Validation 실패 포착 - URI: {}, Exception: {}", 
                request.getRequestURI(), e.getClass().getSimpleName());

        // 1. BindingResult 내부에 쌓인 필드 단위 에러들을 공통 세부 DTO 객체로 변환한다.
        List<FieldErrorDetail> errorDetails = e.getBindingResult().getFieldErrors().stream()
                .map(err -> new FieldErrorDetail(
                        err.getField(),
                        err.getRejectedValue() == null ? "" : err.getRejectedValue().toString(),
                        err.getDefaultMessage()
                ))
                .collect(Collectors.toList());

        // 2. 사전에 조율된 공통 ApiResponse 구조에 실패 메시지와 에러 목록을 탑재한다.
        ApiResponse<Void> responseBody = ApiResponse.<Void>builder()
                .success(false)
                .code("ERR_INVALID_REQUEST_PAYLOAD")
                .message("요청 본문 검증 도중에 조건 오류가 감지되었습니다.")
                .errors(errorDetails)
                .build();

        // 3. HTTP 상태 코드 400(Bad Request)과 함께 최종 JSON 문서를 클라이언트에게 보낸다.
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(responseBody);
    }
}

④ 최종 컨트롤러 구현 (매우 깔끔해진 소스)

@Slf4j
@RestController
@RequestMapping("/api/items")
@RequiredArgsConstructor
public class ItemApiController {

    private final ItemService itemService;

    @PostMapping("/add")
    public ApiResponse<Long> addItem(@RequestBody @Validated ItemSaveForm form) {
        log.info("API 컨트롤러 가동 - 상품 등록 데이터 수신");
        
        // 지저분한 BindingResult나 수동 분기가 사라졌다. 오직 핵심 비즈니스 한 줄만 전개된다.
        Long savedId = itemService.save(form);
        
        return ApiResponse.<Long>builder()
                .success(true)
                .data(savedId)
                .message("성공적으로 등록되었습니다.")
                .build();
    }
}

 

 

6. 결론 및 질문 정답 해설 (Conclusion & Answers)

Bean Validation은 중복된 데이터 정합성 코드를 애노테이션 기반으로 혁신하여 도메인 모델을 명확하게 보호해 주는 핵심 가치가 있다. 실무에서는 엔티티 객체의 groups 기능을 사용하기보다 전송용 DTO 객체를 단단하게 분리하고, 지저분한 예외 처리는 @RestControllerAdvice로 완전 일원화하여 통제해야 깨끗하고 유연성 있는 코드를 설계해 나갈 수 있다.

마지막으로 본 가이드라인의 핵심 골자를 관통하는 3가지 질문에 대해 실무 기준의 정답을 완벽히 제시하며 글을 마무리한다.

❓ 자가 진답 복습 질문 및 모범 정답

Q1. 어떤 필드에 숫자가 아닌 문자열 데이터가 인입되어 바인딩(타입 변환)에 실패했다면, 스프링은 해당 필드에 정의된 @NotNull이나 @Range 같은 Bean Validation 제약 조건을 실행하는가? 그 이유는 무엇인가?

  • 정답 및 해설: 실행하지 않는다. 스프링 MVC의 바인딩 및 유효성 검증 라이프사이클에 의하면, 1단계인 타입 바인딩 과정이 완료된 정상 필드들에 한해서만 2단계인 Bean Validation 유효성 애노테이션 검증을 전개한다. 문자열 등의 유입으로 타입 바인딩 자체가 원천 실패한 경우, 메모리에 값이 아예 적재되지 못했으므로 논리적인 유효 범위를 대조하는 작업 자체가 불가능하다. 대신 해당 필드는 즉시 타입 변환 실패 예외인 typeMismatch FieldError를 적재하고 검증을 스킵한다.

 

Q2. Bean Validation 오류 메시지를 커스텀하기 위해 errors.properties에 코드를 매핑할 때, 스프링이 구체적인 오류 코드부터 범용적인 오류 코드까지 탐색하는 4단계 메시지 코드 생성 순서는 어떻게 되는가? (@NotBlank, 객체명 item, 필드명 itemName 기준)

  • 정답 및 해설: 스프링의 MessageCodesResolver가 아래의 4단계 우선순위로 메시지 키를 계층 탐색한다.
    1. NotBlank.item.itemName (구체적인 오류 명칭 + 소속 객체명 + 필드명)
    2. NotBlank.itemName (오류 명칭 + 필드명)
    3. NotBlank.java.lang.String (오류 명칭 + 자바 타입 자료형)
    4. NotBlank (오류 명칭 단독 / 최하위 폴백용)

 

Q3. 실무 대규모 엔터프라이즈 환경에서 수십 개의 컨트롤러에 BindingResult 파라미터와 if (bindingResult.hasErrors()) 검증 분기 코드가 중복 전개되는 현상을 막기 위해 도입하는 스프링의 핵심 전역 아키텍처 컴포넌트는 무엇인가?

  • 정답 및 해설: @RestControllerAdvice 전역 예외 처리 클래스이다. 컨트롤러 매개변수에서 BindingResult를 아예 선언하지 않음으로써 검증 실패 시 스프링 내부적으로 MethodArgumentNotValidException 예외가 의도적으로 터져 밖으로 방출되게 유도한다. 이 방출된 예외를 전역 어드바이스 클래스 내에서 @ExceptionHandler(MethodArgumentNotValidException.class)를 선언하여 단일 창구로 모조리 통제 및 처리하고, 공통 규약 형식의 JSON 포맷으로 응답을 단일화시킨다.
반응형