카테고리 없음

[Spring] Validation

JABHACK 2024. 12. 27. 17:55

Validation

📌 특정 데이터(주로 클라이언트의 요청 데이터)의 값이 유효한지 확인하는 단계를 의미한다.

  • Controller의 주요한 역할 중 하나는 Validation 이다. HTTP 요청이 정상인지 검증한다.

 

 

Validation을 사용하는 이유

  • 주문서 작성 페이지에서 잘못된 입력값으로 인해 서버에 오류가 발생한다면?
  • ex) 휴대폰 번호에 숫자가 아닌 문자가 들어간 경우
  • 서버의 문제로 인해 작성 페이지에서 Error 페이지로 이동된다면?
  • Error 페이지로 이동되어 작성중인 폼이 모두 리셋되어 다시 작성해야 한다면?
  • 이러한 서비스의 유저는 굉장한 불편함을 겪게된다.

 

Validation의 역할

  1. 검증을 통해 적절한 메세지를 유저에게 보여주어야 한다.
  2. 검증 오류로 인해 정상적인 동작을 하지 못하는 경우는 없어야 한다.
  3. 사용자가 입력한 데이터는 유지된 상태여야 한다.

 

실제 서버를 운영하다보면 기능의 의도와 다른 다양한 사용 방법들을 보게된다. ex) Enter로 입력이 완료되도록 만들었지만 누군가는 Click, Tab + Enter를 누르듯

 

검증의 종류

  1. 프론트 검증
    • 해당 검증은 유저가 조작할 수 있음으로 보안에 취약하다.
    • 보안에 취약하지만 그럼에도 꼭 필요하다
    • ex) 비밀번호에 특수문자가 포함되어야 한다면 즉각적인 alert 가능 → 유저 사용성 증가
  2. 서버 검증
    • 프론트 검증없이 서버에서만 검증한다면 유저 사용성이 떨어진다.
    • API Spec을 정의해서 Validation 오류를 Response 예시에 남겨주어야 한다.
      • API 명세서를 잘 만들어야 그에 맞는 대응을 할 수 있다.
    • 서버 검증은 선택이 아닌 필수이다.
  3. 데이터베이스 검증
    • Not Null, Default와 같은 제약조건을 설정한다.
    • 최종 방어선의 역할을 수행한다.
기본적으로 프론트, 서버, 데이터베이스 모두 검증을 꼼꼼하게 하는것이 바람직하다. Validation으로 수많은 Error와 문제들을 방지할 수 있다.

 

 

BindingResult

📌 Spring에서 기본적으로 제공되는 Validation 오류를 보관하는 객체이다. 주로 사용자 입력 폼을 검증할 때 많이 쓰이고 Field Error와 ObjectError를 보관한다.

  • Errors 인터페이스를 상속받은 인터페이스이다.
  • Errors 인터페이스는 에러의 저장과 조회 기능을 제공한다.
  • BindingResult는 addError() 와 같은 추가적인 기능을 제공한다.
  • Spring이 기본적으로 사용하는 구현체( 특정 인터페이스나 추상 클래스를 실질적으로 구현한 클래스 )는 BeanPropertyBindingResult 이다.

 

파라미터에 BindingResult가 없는 경우

@Data
public class MemberCreateRequestDto {
    private Long point;
    private String name;
    private Integer age;
}

// View 반환
@Controller
public class BingdingResultController {
	
    @PostMapping("/v1/member")
    public String createMemberV1(@ModelAttribute MemberCreateRequestDto request, Model model) {
        // Model에 저장
        System.out.println("/V1/member API가 호출되었습니다.");
        model.addAttribute("point", request.getPoint());
        model.addAttribute("name", request.getName());
        model.addAttribute("age", request.getAge());

        // Thymeleaf Template Engine View Name
        return "complete";
    }

}

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h1>Member 생성이 완료되었습니다!</h1>
    <ul>
        <li><span th:text="${point}">포인트</span></li>
        <li><span th:text="${name}">이름</span></li>
        <li><span th:text="${age}">나이</span></li>
    </ul>
</body>
</html>

 

파라미터에 BindingResult가 있는 경우

@Controller
public class BindingResultController {

    @PostMapping("/v2/member")
    public String createMemberV2(
            // 1. @ModelAttribute 뒤에 2. BindingResult가 위치한다.
            @ModelAttribute MemberCreateRequestDto request,
            BindingResult bindingResult,
            Model model
    ) {

        System.out.println("/V2/member API가 호출되었습니다.");

        // BindingResult의 에러 출력
        List<ObjectError> allErrors = bindingResult.getAllErrors();
        System.out.println("allErrors = " + allErrors);

        // Model에 저장
        model.addAttribute("point", request.getPoint());
        model.addAttribute("name", request.getName());
        model.addAttribute("age", request.getAge());

        return "complete";
    }
    
}

@ModelAttribute는 파라미터를 필드 하나하나에 바인딩한다. 파라미터에 Binding Result가 함께 있는 경우 만약 그중 하나의 필드에 오류가 발생하면 해당 필드를 제외하고 나머지 필드들만 바인딩 된 후 Controller가 호출된다.

 

 

Bean Validation

📌 특정 필드 검증의 경우 빈값, 길이, 크기, 형식 과 같은 간단한 로직이다. 이러한 로직들을 모든 프로젝트에 적용할 수 있도록 표준화 한 것이 Bean Validation이다.

@PostMapping("/v3/member")
public String createMemberV3(
        // 1. @ModelAttribute 뒤에 2. BindingResult가 위치한다.
        @ModelAttribute MemberCreateRequestDto request,
        BindingResult bindingResult,
        Model model
) {

    System.out.println("/V3/member API가 호출되었습니다.");

    // 3. Validation
    if (request.getAge() == null || request.getAge() < 0) {
        // BindingResult FieldError 추가
        bindingResult.addError(
                new FieldError("request", "age", "age 필드는 필수이며 0 이상의 값이어야 합니다.")
        );
    }

    // error 처리
    if (bindingResult.hasErrors()) {
        System.out.println("Error를 처리하는 로직");
        // error 페이지 반환
        return "error";
    }

    // Model에 저장
    model.addAttribute("point", request.getPoint());
    model.addAttribute("name", request.getName());
    model.addAttribute("age", request.getAge());

    return "complete";
}

실행결과

  • 검증 기능을 매번 BingdingResult 예시처럼 코드로 구현하는것은 번거로운 일이다.
  • 이 경우 Controller의 크기가 너무 커지며 단일 책임 원칙에 위배된다.
  • 개발자들은 불편함을 참지 않는다.

 

Bean Validation

  • 객체의 필드나 메서드에 제약 조건을 설정하여, 올바른 값을 가지고 있는지 검증하는 표준화된 방법
  1. Bean Validation은 기술 표준 인터페이스이다.
  2. 다양한 Annotation들과 여러가지 Interface로 구성되어 있다.
    • Bean Validation(인터페이스) 구현체인 Hibernate Validator를 사용한다.

Hibernate Validator 공식문서

 

The Bean Validation reference implementation. - Hibernate Validator

Express validation rules in a standardized way using annotation-based constraints and benefit from transparent integration with a wide variety of frameworks.

hibernate.org

Hibernate Validator의 Hibernate는 ORM JPA Hibernate와 이름만 같고 상관없는 기술이다.

 

 

Bean Validation 적용 예시

@Getter
public class SignUpRequestDto {
	
	@NotBlank
	private String name;

	@NotNull
	@Range(min = 1, max = 120)
	private Integer age;

}

@Controller
public class BeanValidationController {

		@PostMapping("/model-attribute")
    public String beanValidationV1(
            @Validated @ModelAttribute SignUpRequestDto dto
    ) {
        // 로직
        
        // ViewName
        return "complete";
    }

}

@RestController
public class BeanValidationRestController {

		@PostMapping("/request-body")
    public String beanValidationV2(
            @Validated @RequestBody SignUpRequestDto dto
    ) {
        // 로직
        
				// 문자 Data 반환
        return "회원가입 완료";
    }

}
  • Annotation을 적용시키는것 만으로 Validation을 아주 쉽게 적용할 수 있다.
  • Controller에 개발자가 기본적인 검증 로직을 작성할 필요가 없어졌다.
  • RestController의 @RequestBody에도 사용할 수 있다.

 

 

Field Error

📌 스프링의 유효성 검증(Validation) 과정에서 발생하는 필드 수준의 에러 정보를 나타내는 객체

  • 유효성 검증 실패 시, 스프링은 각 필드에 대한 검증 결과를 FieldError 객체로 저장하며, 이를 통해 어떤 필드에서 어떤 에러가 발생했는지를 확인 가능

 

Bean Validation 적용

  • 코드예시
    • 의존성 추가(build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-validation'
  • implementation 뒤에 version이 따로 적혀있지 않은 이유 → Spring Boot 기능
  • External Libraries
    • 프로젝트에 적용된 라이브러리 목록

  • 파일경로 jakarta.validation-api-${version}.jar
    • jakarta.validation.constraints

  • 사용할 수 있는 다양한 Annotation들을 확인할 수 있다.
  • 파일경로 org.hibernate.validator:hibernate-validator:${version}
    • 위 Annotation 들이 동작하게 만들어주는 Validator

  1. jakarta.validation-api : Interface
  2. org.hibernate.validator:hibernate-validator : 구현체

 

실제코드 import

  • @Range Annotation은 Hibernate Validator 에서만 동작하는것
  • @NotBlank, @NotNull 은 validation 표준 인터페이스

 

사용된 Annotation 정리

  • @NotBlank
    1. null을 허용하지 않는다.
    2. 공백(” “)을 허용하지 않는다. 하나 이상의 문자를 포함해야한다.
    3. 빈값(””)을 허용하지 않는다.
    4. CharSequence 타입 허용
      • String은 CharSequence(Interface)의 구현체이다.
  • @NotNull
    1. null을 허용하지 않는다.
    2. 모든 타입을 허용한다.
  • @NotEmpty
    1. null을 허용하지 않는다.
    2. 빈값(””)을 허용하지 않는다.
    3. CharSequence, Collection, Map, Array 허용
@Data
public class TestDto {
	// 테스트 하고싶은 Annotation으로 변경 가능
	@NotBlank
	private String stringField;
	
	@NotNull
	@Range(min = 1, max = 9999)
	private Integer integerField;
	

}

import static org.assertj.core.api.Assertions.assertThat;

public class BeanValidationTest {
	
	@Test
	void beanValidation() {
		
		// Spring과 통합하면 아래 두줄의 코드는 사용하지 않는다.
		ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
		Validator validator = factory.getValidator();
		
		// Test 하고싶은 상황을 만들어서 검증 가능
		TestDto dto = new TestDto();
		dto.setStringField(" ");
		dto.setIntegerField(1);
		
		// DTO를 검증
		Set<ConstraintViolation<TestDto>> violations = validator.validate(dto);

		// 검증 결과가 예상대로 발생했는지 확인
		// 검증에 걸린 필드가 있어야 함
    assertThat(violations).isNotEmpty();
    // 2개의 제약 위반 발생
    assertThat(violations.size()).isEqualTo(2);
		
		// Validation에 걸린 내역을 출력
		for(ConstraintViolation<TestDto> violation : violations) {
			// 아래의 결과에 Message가 있으면 Validation에 걸린것.
			// Default Message가 있기 때문에 출력됨
			// Message를 수정하고싶다면 Annotation 속성값(message="입력")으로 설정할 수 있다.
			// 결과가 비어있으면 Validation에 걸리지 않은것.
			System.out.println("violation = " + violation.getMessage());
		}
		
	}

}

 

출력결과

  • 테스트 통과
  • Default Message가 출력된다.
 

Hibernate Validator 8.0.1.Final - Jakarta Bean Validation Reference Implementation: Reference Guide

Validating data is a common task that occurs throughout all application layers, from the presentation to the persistence layer. Often the same validation logic is implemented in each layer which is time consuming and error-prone. To avoid duplication of th

docs.jboss.org

import가 org.hibernate.validator로 시작하면 하이버네이트를 사용할 때만 제공되는 검증 기능으로 다른 구현체로 validator를 교체하였을 경우 동작하지 않는다. 하지만 org.hibernate.validator를 대부분 사용한다.

 

 

Validator

📌 유효성 검증(Validation) 인터페이스입니다. 특정 객체에 대한 데이터 유효성을 검사하고, 유효하지 않은 경우 오류 메시지를 생성하여 처리할 수 있도록 설계되었습니다. 이 인터페이스는 커스텀 유효성 검증 로직을 구현하거나, 스프링의 유효성 검증 프레임워크와 통합할 때 사용됩니다.

  • 단순히 Annotation을 선언해주면 검증이 완료되는 이유는 Validator(Validation을 사용하는것)가 존재하기 때문이다. 
  • Spring Boot는 validation 라이브러리를 설정하면 'org.springframework.boot:spring-boot-starter-validation'자동으로 Bean Validator를 Spring에 통합되도록 설정해준다.

동작 순서

  • Spring Boot Application 실행 시 자동으로 Bean Validator가 통합된다.
  1. LocalValidatorFactoryBean 을 Global Validator로 등록한다.
    • Class Diagram

  1. Global Validator가 Default로 적용되어 있으니 @Valid, @Validated 만 적용하면 된다.
  2. Bean Validation Annotation이 있으면 검증을 수행한다.     ex) @NotNull, @NotBlank, @Max 등등..
  3. Validation Error가 발생하면 FieldError, ObjectError를 생성하여 BindingResult에 담아준다.

 

@Valid, @Validated 차이점

  1. @Valid 는 JAVA 표준이고 @Validated 는 Spring 에서 제공하는 Annotation이다.
  2. @Validated 를 통해 Group Validation 혹은 Controller 이외 계층에서 Validation이 가능하다.
  3. @Valid 는 MethodArgumentNotValidException 예외를 발생시킨다.
  4. @Validated 는 ConstraintViolationException 예외를 발생시킨다.

 

Validator 적용

  • Validator 적용 전
    • @ModelAttribute 각각의 필드 타입에 맞추어 바인딩(변환) 시도
      • 성공 : Controller 정상 호출
      • 실패 : TypeMismatch FieldError 발생
  • Validator 적용 후
    • @ModelAttribute → 각 필드 바인딩 → 성공한 필드만 Bean Validation 적용
      • Integer 타입 필드에 문자가 오면 애초에 검증의 의미가 없다.
      • 성공 : String 필드에 문자입력 → 바인딩 성공 → String 필드에 Bean Validation 적용
      • 실패 : Integer 필드에 문자입력 → 바인딩 실패 → bindingResult에 TypeMismatch FieldError 추가 → 바인딩에 실패한 필드는 값이 없음(null) → Bean Validation 적용하지 않음
Bean Validator는 바인딩에 실패한 필드는 Bean Validation을 적용하지 않는다. 바인딩(변환)에 성공한 필드만이 Bean Validation을 적용하는 의미가 있다.

 

 

 

에러 메세지

📌 Spring의 Bean Validation은 Default로 제공하는 Message들이 존재하고 임의로 수정할 수 있다

 

 

Error Message

  • Bean Validation을 적용하고 BindingResult에 등록된 검증 오류를 확인해보면 오류가 Annotation 이름으로 등록되어 있다.
@Data
public class TestDto {

    @NotBlank
    private String stringField;

    @NotNull
    @Range(min = 1, max = 9999)
    private Integer integerField;

}

@Slf4j
@RestController
public class BeanValidationController {

    @PostMapping("/error-message")
    public String beanValidation(
            @Validated @ModelAttribute TestDto dto,
            BindingResult bindingResult
    ) {
				// bindingResult Field Error 출력
        if (bindingResult.hasErrors()) {
            return String.valueOf(bindingResult.getFieldError());
        }

        // 성공시 문자열 반환
        return "회원가입 성공";
    }

}

 

Postman

 

  • integerField 는 비워두고 stringField 만 값(”문자열”) 입력
  • 출력결과
Field error in object 'testDto' on field 'integerField': rejected value [null]; codes [NotNull.testDto.integerField,NotNull.integerField,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [testDto.integerField,integerField]; arguments []; default message [integerField]]; default message [널이어서는 안됩니다]

NotNull 오류 코드를 기반으로 MessageCodesResolver 를 통해 메세지 코드 생성

 

Spring 에서는 오류 메시지 코드관리를 위해 MessageCodesResolver 인터페이스의 구현체인 DefaultMessageCodesResolver를 기본으로 사용한다.

 

 

에러 메세지 수정하기

  1. NotNull.Object.fieldName
    • Annotation의 message 속성 사용
@Data
public class TestDto {

	@NotBlank(message = "메세지 수정 가능")
	private String stringField;

}

  1. NotNull.fieldName(MessageSource)
    • 필드명에 맞춘 사용자 정의 Message
  2. NotNull.FieldType(MessageSource)
    • 필드 타입에 맞춘 사용자 정의 Message
  3. NotNull
    • Annotation 자체에 포함된 Default Message
  • 네가지 방법중 Annotation의 message 속성을 사용하여 에러 메세지를 수정하면 됩니다.
MessageSource란 Spring에서 지원하는 인터페이스로 메세지의 국제화를 위해 사용된다.

 

 

Object Error

  • 필드 단위가 아닌 객체 전체에 대한 오류를 나타낸다. 예를들어 두 필드 간의 관계를 검증할 때 ObjectError를 통해 해당 오류를 BindingResult에 기록할 수 있다.
지금까지 위에서 배운 내용은 객체가 아닌 필드 단위를 검증해서 발생하는 Field Error에 대한 내용

 

  • @ScriptAssert
    • 코드예시
      • 비밀번호와 비밀번호 확인 필드가 동일한지 검증
@Data
@ScriptAssert(lang = "javascript", script = "_this.password === _this.confirmPassword", message = "Passwords do not match")
public class UserRegistrationDto {

    @NotBlank(message = "Password is required")
    private String password;

    @NotBlank(message = "Confirm password is required")
    private String confirmPassword;
}
  • 실제로 @ScriptAssert는 제약사항 때문에 사용하지 않는다.
  • 실무에서는 훨씬 복잡한 Validation들이 필요하지만 대응이 불가능하다.
  • ex) 다른 객체끼리의 비교 혹은 DB조회 결과와 비교
  • Object Error의 경우 Java 코드로 직접 Validation 한다.
  • Java 코드로 구현하기
    • 요구사항 : 총 구매 가격이 10000원 이상이여야 한다.
      • price * count ≥ 10000
@Getter
@AllArgsConstructor
public class OrderRequestDto {

    @NotNull
    @Range(min = 1000)
    private Integer price;

    @NotNull
    @Range(min = 1)
    private Integer count;

}

@Slf4j
@RestController
public class BeanValidationController {

    @PostMapping("/object-error")
    public String objectError(
            @Validated @ModelAttribute OrderRequestDto requestDto,
            BindingResult bindingResult
    ) {

        // 합이 10000원 이상인지 확인
        int result = requestDto.getPrice() * requestDto.getCount();
        if (result < 10000) {
            // Object Error
            bindingResult.reject("totalMin", new Object[]{10000, result}, "총 합이 10000 이상이어야 합니다.");
        }
        // Error가 있으면 출력
        if (bindingResult.hasErrors()) {
            log.info("errors={}", bindingResult);
            return bindingResult.getAllErrors().get(0).getDefaultMessage();
        }

        // 성공로직 ...
        return "성공";
    }
    
}
  • Object Error는 로직으로 구현하면 된다.
  • Postman

 

 

 

Bean Validation의 충돌

📌 등록, 수정 API에서 각각 다른 Validation이 적용된다면?

 

요구사항

  • 상품
    • id (식별자)
    • name (이름)
    • price (가격)
    • count (재고)
  • 상품 등록 API
    • 식별자 값은 필수가 아니다.
    • name은 null, “”, “ “을 허용하지 않는다.
    • price는 10 ~ 10000 사이의 숫자로 생성한다.
    • count는 1 ~ 999 사이의 숫자로 생성한다.
  • 상품 수정 API
    • 식별자 값이 필수이다.
    • name은 null, “”, “ “을 허용하지 않는다.
    • price는 무제한으로 허용한다.
    • count는 1 ~ 999 사이의 숫자로 생성한다.
일반적으로 수정 API를 만들 때 식별자 id값을 항상 Controller에서 받도록 구성한다. HTTP 요청은 사용자가 임의로 변경하여 요청할 수 있음으로 항상 서버에서 최종적으로 추가 검증을 진행 해야한다. ex) 게시글 수정시 요청자 본인이 쓴 글인지 확인한다.

 

Product 저장 API

@Data
public class ProductRequestDto {

		// 식별자는 Database에서 자동생성
		
    @NotBlank
    private String name;

    @NotNull
    @Range(min = 10, max = 10000)
    private Integer price;

    @NotNull
    @Range(min = 1, max = 999)
    private Integer count;
}

@Slf4j
@RestController
public class ConflictValidationController {

    @PostMapping("/product")
    public String save(
            @Validated @ModelAttribute ProductRequestDto requestDto
    ) {
				log.info("생성 API가 호출 되었습니다.");
        // Validation 성공시 repository 저장로직 호출
        return "상품 생성이 완료되었습니다";
    }

}

 

Product 수정 API

@Data
public class ProductRequestDto {
	
		@NotBlank
    private String name;

		// price 무제한 요구사항 반영
    @NotNull
    private Integer price;

    @NotNull
    @Range(min = 1, max = 999)
    private Integer count;
}

@Slf4j
@RestController
public class ConflictValidationController {
    @PutMapping("/product/{id}")
    public String update(
            @PathVariable Long id,
            @Validated @ModelAttribute ProductRequestDto test
    ) {
        log.info("수정 API가 호출 되었습니다.");
        // Validation 성공시 repository 수정로직 호출
        return "상품 수정이 완료되었습니다.";
    }
}

@PathVariable의 required 속성의 기본값은 true이다.

 

 

해결방법

  1. 저장할 Object를 직접 사용하지 않고 SaveRequestDto, UpdateRequestDto 따로 사용한다.
  2. Bean Validation의 groups 기능을 사용한다.

 

groups

  • Bean Validation의 groups 속성은 다양한 유효성 검사 시나리오를 정의할 때 사용된다. 동일한 객체에 대한 검증을 상황에 따라 다르게 적용하고 싶을 때 groups를 활용할 수 있다.
// 저장용 group
public interface SaveCheck {

}

// 수정용 group
public interface UpdateCheck {

}

@Data
public class ProductRequestDtoV2 {

    // 저장, 수정 @NotBlank Validation 적용
    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
    private String name;

    // 사용하는 모든곳에서 @NotNull Validation 적용
    @NotNull
    // 저장만 @Range 반영
    @Range(min = 10, max = 10000, groups = SaveCheck.class)
    private Integer price;

    @NotNull
    @Range(min = 1, max = 999)
    private Integer count;

}

@Slf4j
@RestController
public class ProductController {

    @PostMapping("/v2/product")
    public String save(
            // 저장 속성값 설정
            @Validated(SaveCheck.class) @ModelAttribute ProductRequestDtoV2 requestDtoV2
    ) {
        log.info("생성 API가 호출 되었습니다.");
        // Validation 성공시 repository 저장로직 호출
        return "상품 생성이 완료되었습니다";
    }

    @PutMapping("/v2/product/{id}")
    public String update(
            @PathVariable Long id,
            // 수정 속성값 설정
            @Validated(UpdateCheck.class) @ModelAttribute ProductRequestDto test
    ) {
        log.info("수정 API가 호출 되었습니다.");
        // Validation 성공시 repository 수정로직 호출
        return "상품 수정이 완료되었습니다.";
    }

}

 

 

 

 

groups VS DTO 분리

📌 Bean Validation의 충돌이 발생하는 경우 대부분 DTO를 분리하는 방법이 적절하다.

 

  • groups VS DTO 분리
    • groups 속성을 사용하면 등록과 수정시 각각 다르게 Validation이 적용된다.
      • 가독성이 떨어지고 코드 복잡도가 올라간다.
    • 실무에서는 등록 폼과 수정 폼 자체를 분리해서 사용하기 때문에 DTO 분리 방법을 사용하면 된다.
    • 단, 네이밍은 일관성있게 작성해야 한다.(SaveRequestDto, UpdateRequestDto)
  • DTO 분리
    • 실제로 간단한 프로젝트를 개발해보면 저장, 수정시 Request가 비슷한 경우가 있다.
    • 각각의 장단점이 존재하지만 어설프게 하나로 합칠 경우 유지보수시 엄청난 경험을 할 수 있다.
    • RequestDto가 변한다는건 해당 API의 스펙 자체가 변경되어 많은 수정이 발생한다.
    • 실무에서는 거의 발생하지 않는 경우기 때문에 간단한게 아니라면 대부분 분리하도록 하자!
  • @Validated VS @Valid
    1. @Validated

  • 속성값이 존재한다.
  • spring이 제공하는 Annotation
  1. @Valid

  • 속성값이 존재하지 않는다, groups 기능 지원하지 않는다.
  • groups 기능을 사용하려면 @Validated를 사용해야 한다.
  • Java 표준 Annotation

 

 

@ModelAttribute, @RequestBody

📌 @Valid, @Validated@ModelAttribute뿐만 아니라 @RequestBody에도 적용할 수 있다. @ModelAttribute는 요청 파라미터 혹은 Form Data(x-www-urlencoded)를 다룰 때 사용하고 @RequestBody 는 HTTP Body Data를 Object로 변환할 때 사용한다.

 

@RequestBody 적용

@Data
public class ExampleRequestDto {

	@NotBlank
	private String field1;

	@NotNull
	@Range(min = 1, max = 150)
	private Integer field2;

}

@Slf4j
@RestController
public class RequestBodyController {
	
    @PostMapping("/example")
    public Object save(
            @Validated @RequestBody ExampleRequestDto dto,
            BindingResult bindingResult
    ) {
        log.info("RequestBody Controller 호출");

        if(bindingResult.hasErrors()) {
            log.info("validation errors={}", bindingResult);
            // Field, Object Error 모두 JSON으로 반환
            return bindingResult.getAllErrors();
        }

        // 성공 시 RequestDto 반환(의미 없음)
        return dto;
    }

}

 

 

  • Rest API 요청의 세가지 경우의 수
    1. 성공 요청: 성공

  • Controller 정상 호출
  • 응답 반환
  1. 실패 요청: JSON을 객체로 변환하는 것 자체가 실패

  • field2에 String 입력
    • JSON → Object 변환 실패
  • 중요! Controller가 호출되지 않는다.
  • 반드시 JSON → Object로 변환이 되어야 Validation이 진행된다.
  1. 검증 오류 요청: JSON을 객체로 변환하는 것은 성공, 검증에서 실패

  • field2의 값에 범위를 넘어서는 값 입력 @Range(max = 150)
    • bindingResult.getAllErrors() 가 MessageConverter에 의해 JSON으로 변환되어 반환된다.
    • Controller를 실제로 호출한다.
    • log로 작성한 bindingResult error들이 콘솔에 출력된다.

 

정리

  • @ModelAttribute와 @RequestBody 차이점
    1. @ModelAttribute
      • 각각의 필드 단위로 바인딩한다.
      • 특정 필드 바인딩이 실패하여도 나머지 필드는 정상적으로 검증 처리할 수 있다.
      • 특정필드 변환 실패
        • 컨트롤러 호출, 나머지 필드 Validation 적용
    2. @RequestBody
      • 필드별로 적용되는것이 아니라 객체 단위로 적용된다.
      • MessageConverter가 정상적으로 동작하여 Object로 변환하여야 Validation이 동작한다.
      • 특정필드 변환 실패
        • 컨트롤러 미호출, Validation 미적용
  • 추가내용
    • bindingResult.getAllErrors()는 FieldError와 ObjectError 모두 반환한다.
    • Spring은 MessageConverter를 이용해 Error 객체들을 변환하여 응답한다.
    • RequestDTO 의 경우, 생성, 수정, 삭제, 모두 비슷하게 생겼어도 따로 분리해서 사용하자.
    • 작성한 코드는 예시일 뿐 실제로는 API Spec에 맞는 응답을 만들어 클라이언트에 전달 해야한다.
      • @ControllerAdvice