데이터 바인딩과 검증 — 클라이언트가 보낸 데이터를 어떻게 객체로 만들까
클라이언트가 보낸 JSON 데이터는 그냥 문자열인데, 어떻게 자바 객체가 되는 걸까요? 그리고 이 데이터가 올바른지는 어떻게 검증할까요?
개념 정의
데이터 바인딩은 HTTP 요청의 데이터(쿼리 파라미터, 폼 데이터, JSON 등)를 자바 객체의 필드에 자동으로 매핑하는 과정입니다. **검증(Validation)**은 바인딩된 데이터가 비즈니스 규칙에 맞는지 확인하는 과정입니다.
왜 필요한가
수동으로 파라미터를 꺼내서 검증하면 코드가 지저분해집니다.
// 수동 방식 (나쁜 예)
@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("나이 범위 오류");
// ... 더 많은 검증
}
바인딩과 검증을 활용하면 깔끔해집니다.
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.create(request));
}
내부 동작
바인딩 방식 비교
| 어노테이션 | 데이터 소스 | 변환 방식 | 용도 |
|---|---|---|---|
@RequestParam | 쿼리 파라미터 | 타입 변환 | 단일 값 |
@ModelAttribute | 쿼리 파라미터 + 폼 데이터 | WebDataBinder | 객체 |
@RequestBody | HTTP 본문 | HttpMessageConverter | JSON/XML 객체 |
@ModelAttribute 바인딩 과정
1. 대상 클래스의 기본 생성자로 인스턴스 생성
2. WebDataBinder가 요청 파라미터 이름과 필드 이름을 매칭
3. PropertyEditor/Converter로 타입 변환
4. 필드에 값 설정 (setter 호출)
5. @Valid가 있으면 검증 수행
// 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 바인딩 과정
1. Content-Type 헤더 확인 (application/json)
2. 적절한 HttpMessageConverter 선택 (MappingJackson2HttpMessageConverter)
3. 요청 본문을 읽어 Jackson ObjectMapper로 역직렬화
4. @Valid가 있으면 검증 수행
코드 예제
Bean Validation 기본 사용
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;
}
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
// 검증 통과한 데이터만 여기 도달
return ResponseEntity.ok(userService.create(request));
}
BindingResult로 직접 에러 처리
@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)
// 그룹 인터페이스
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) { ... }
중첩 객체 검증
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;
}
커스텀 검증 어노테이션
// 어노테이션 정의
@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;
}
서비스 계층에서도 검증
@Service
@Validated // 클래스 레벨에 추가
public class UserService {
public User createUser(@Valid CreateUserRequest request) {
// 서비스 레벨에서도 검증 수행
// ConstraintViolationException 발생 가능
}
}
글로벌 검증 에러 처리
@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로 프로젝트 전용 검증 로직을 만들 수 있습니다
댓글 로딩 중...