Theme:

클라이언트가 보낸 JSON 데이터는 그냥 문자열인데, 어떻게 자바 객체가 되는 걸까요? 그리고 이 데이터가 올바른지는 어떻게 검증할까요?

개념 정의

데이터 바인딩은 HTTP 요청의 데이터(쿼리 파라미터, 폼 데이터, JSON 등)를 자바 객체의 필드에 자동으로 매핑하는 과정입니다. **검증(Validation)**은 바인딩된 데이터가 비즈니스 규칙에 맞는지 확인하는 과정입니다.

왜 필요한가

수동으로 파라미터를 꺼내서 검증하면 코드가 지저분해집니다.

JAVA
// 수동 방식 (나쁜 예)
@PostMapping("/users")
public ResponseEntity<?> createUser(HttpServletRequest request) {
    String name = request.getParameter("name");
    String email = request.getParameter("email");
    String ageStr = request.getParameter("age");

    if (name == null || name.isBlank()) return bad("이름은 필수");
    if (email == null || !email.contains("@")) return bad("이메일 형식 오류");
    int age;
    try { age = Integer.parseInt(ageStr); } catch (Exception e) { return bad("나이 형식 오류"); }
    if (age < 0 || age > 150) return bad("나이 범위 오류");
    // ... 더 많은 검증
}

바인딩과 검증을 활용하면 깔끔해집니다.

JAVA
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
    return ResponseEntity.ok(userService.create(request));
}

내부 동작

바인딩 방식 비교

어노테이션데이터 소스변환 방식용도
@RequestParam쿼리 파라미터타입 변환단일 값
@ModelAttribute쿼리 파라미터 + 폼 데이터WebDataBinder객체
@RequestBodyHTTP 본문HttpMessageConverterJSON/XML 객체

@ModelAttribute 바인딩 과정

PLAINTEXT
1. 대상 클래스의 기본 생성자로 인스턴스 생성
2. WebDataBinder가 요청 파라미터 이름과 필드 이름을 매칭
3. PropertyEditor/Converter로 타입 변환
4. 필드에 값 설정 (setter 호출)
5. @Valid가 있으면 검증 수행
JAVA
// DTO
public class SearchCriteria {
    private String keyword;
    private int page;
    private int size;
    // getter, setter
}

// GET /search?keyword=스프링&page=0&size=20
@GetMapping("/search")
public List<Product> search(@ModelAttribute SearchCriteria criteria) {
    // criteria.keyword = "스프링", criteria.page = 0, criteria.size = 20
}

@RequestBody 바인딩 과정

PLAINTEXT
1. Content-Type 헤더 확인 (application/json)
2. 적절한 HttpMessageConverter 선택 (MappingJackson2HttpMessageConverter)
3. 요청 본문을 읽어 Jackson ObjectMapper로 역직렬화
4. @Valid가 있으면 검증 수행

코드 예제

Bean Validation 기본 사용

JAVA
public class CreateUserRequest {

    @NotBlank(message = "이름은 필수입니다")
    @Size(min = 2, max = 50, message = "이름은 2~50자입니다")
    private String name;

    @NotBlank
    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String email;

    @NotNull
    @Min(value = 1, message = "나이는 1 이상이어야 합니다")
    @Max(value = 150, message = "나이는 150 이하여야 합니다")
    private Integer age;

    @Pattern(regexp = "^\\d{3}-\\d{4}-\\d{4}$", message = "전화번호 형식: 010-1234-5678")
    private String phone;
}
JAVA
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
    // 검증 통과한 데이터만 여기 도달
    return ResponseEntity.ok(userService.create(request));
}

BindingResult로 직접 에러 처리

JAVA
@PostMapping("/users")
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest 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(errors);
    }
    return ResponseEntity.ok(userService.create(request));
}

주의: BindingResult는 반드시 @Valid 파라미터 바로 다음에 위치해야 합니다.

검증 그룹 (Validation Groups)

JAVA
// 그룹 인터페이스
public interface CreateGroup {}
public interface UpdateGroup {}

// DTO
public class UserRequest {

    @Null(groups = CreateGroup.class, message = "생성 시 ID를 지정할 수 없습니다")
    @NotNull(groups = UpdateGroup.class, message = "수정 시 ID는 필수입니다")
    private Long id;

    @NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
    private String name;
}

// 컨트롤러
@PostMapping
public User create(@Validated(CreateGroup.class) @RequestBody UserRequest request) { ... }

@PutMapping("/{id}")
public User update(@Validated(UpdateGroup.class) @RequestBody UserRequest request) { ... }

중첩 객체 검증

JAVA
public class OrderRequest {

    @NotBlank
    private String orderName;

    @Valid // 중첩 객체도 검증하려면 @Valid 필요
    @NotNull
    private AddressRequest address;

    @Valid
    @NotEmpty
    private List<OrderItemRequest> items;
}

public class AddressRequest {
    @NotBlank
    private String city;

    @NotBlank
    private String street;
}

public class OrderItemRequest {
    @NotNull
    private Long productId;

    @Min(1)
    private int quantity;
}

커스텀 검증 어노테이션

JAVA
// 어노테이션 정의
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface PhoneNumber {
    String message() default "올바른 전화번호 형식이 아닙니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// 검증 로직 구현
public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {

    private static final Pattern PATTERN = Pattern.compile("^\\d{2,3}-\\d{3,4}-\\d{4}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true; // null 검증은 @NotNull에 위임
        return PATTERN.matcher(value).matches();
    }
}

// 사용
public class CreateUserRequest {
    @PhoneNumber
    private String phone;
}

서비스 계층에서도 검증

JAVA
@Service
@Validated // 클래스 레벨에 추가
public class UserService {

    public User createUser(@Valid CreateUserRequest request) {
        // 서비스 레벨에서도 검증 수행
        // ConstraintViolationException 발생 가능
    }
}

글로벌 검증 에러 처리

JAVA
@RestControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(
            MethodArgumentNotValidException ex) {

        Map<String, String> errors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage())
        );

        Map<String, Object> response = Map.of(
            "status", 400,
            "message", "요청 데이터가 올바르지 않습니다",
            "errors", errors
        );

        return ResponseEntity.badRequest().body(response);
    }
}

정리

  • @ModelAttribute는 쿼리 파라미터/폼 데이터를, @RequestBody는 JSON/XML 본문을 객체로 바인딩합니다
  • @Valid로 Bean Validation을 트리거하고, 실패 시 MethodArgumentNotValidException이 발생합니다
  • BindingResult를 파라미터로 받으면 예외 대신 직접 에러를 처리할 수 있습니다
  • @Validated의 groups로 생성/수정 시 다른 검증 규칙을 적용할 수 있습니다
  • 커스텀 어노테이션 + ConstraintValidator로 프로젝트 전용 검증 로직을 만들 수 있습니다
댓글 로딩 중...