Validation 적용기

mujik-tigers·2023년 12월 2일

on-and-off

목록 보기
2/2
post-thumbnail

안녕하세요, On & Off 프로젝트의 Hyun 입니다.

이번주 프로젝트를 진행하면서, 가장 오랜시간 고민하며 작업한 Validation 적용 과정을 블로깅하려고 합니다.


길고 투박한 검증 애노테이션 제거하기

저는 첫 번째로 검증하려는 필드위에 붙이는 @Max, @Pattern, @Size 와 같은 애노테이션을 제거하고 싶었습니다.

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

	@Pattern(regexp = "^[0-9a-z-A-z]([\-.\w]*[0-9a-zA-Z\-_+])*@([0-9a-zA-Z][\-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9}$", message = "이메일이 양식에 맞지 않습니다." )
	@Size(max = 62, message = "이메일 길이는 62자 이하입니다.")
	private String email;

	// ...
}

컨트롤러단에서 회원가입을 위해 클라이언트가 입력한 값을 받는 DTO 클래스


일반적이고 깔끔한 Bean Validation 적용방법이지만 제가 생각한 문제점은 다음과 같습니다.

  • 애노테이션 안에 하드코딩된 정보가 너무 많습니다.

  • 동일한 이메일 검증을 다른 DTO에서 하게 된다면, 다시 저 긴 @Pattern@Size 애노테이션을 붙여 사용해야 합니다.

public class EditUserForm {

	@Pattern(regexp = "^[0-9a-z-A-z]([\-.\w]*[0-9a-zA-Z\-_+])*@([0-9a-zA-Z][\-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9}$", message = "이메일이 양식에 맞지 않습니다." )
	@Size(max = 62, message = "이메일 길이는 62자 이하입니다.")
	private String email;

	// ...
}

만약 유저의 이메일을 변경하기 위해 다음과 같은 DTO를 만든다고 하면, 똑같이 여러개의 애노테이션을 붙여주어야 합니다.


  • 검증이 어떤 방식으로 수행되는지 설명하기 어렵습니다.

예를 들어, 다음의 정규식이 어떤식으로 만들어졌는지 최소한의 주석을 달기가 어렵습니다.


이러한 문제점이 있다고 생각하여 저는 다른 방식으로 애노테이션 검증을 수행하게 되었습니다.


Constraint Annotation 적용

Constraint Annotation 이란 직접 만든 ConstraintValidator 를 연결한 애노테이션을 의미합니다.
Constraint Annotation 을 검증하고 싶은 필드위에 붙임으로써, 제가 만든 Validator 의 검증을 적용할 수 있습니다.
이를 통해 DTO 코드는 다음처럼 바뀝니다.

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

	@EmailForm
	@EmailSize
	private String email;

	@NicknameForm
	private String nickname;

	@PasswordForm
	private String password;

}

DTO 클래스


이를 통해 DTO 클래스의 불필요하게 긴 하드코딩 정보들을 제거할 수 있었습니다.
Constraint Annotation 중 @EmailForm 애노테이션을 한번 살펴보겠습니다.

@Documented
@Constraint(validatedBy = EmailForm.EmailFormValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface EmailForm {

	String message() default "이메일이 양식에 맞지 않습니다.";

	Class<?>[] groups() default {};

	Class<? extends Payload>[] payload() default {};

	class EmailFormValidator implements ConstraintValidator<EmailForm, String> {
		private final String REGEX_EMAIL = "^[0-9a-z-A-z]([\\-.\\w]*[0-9a-zA-Z\\-_+])*" // 소문자 알파벳이나 숫자로 시작하는 local part
			+ "@([0-9a-zA-Z][\\-\\w]*[0-9a-zA-Z]\\.)+[a-zA-Z]{2,9}$";    // 서브 도메인, 도메인, 최상위 도메인

		private Pattern emailRegex = Pattern.compile(REGEX_EMAIL);

		@Override
		public boolean isValid(String emailInput, ConstraintValidatorContext context) {
			return emailRegex.matcher(emailInput).matches();
		}
	}

}

@Constraint(validatedBy = EmailForm.EmailFormValidator.class)

  • 연결하고 싶은 ConstraintValidator 는 다음과 같이 연결합니다.
  • 저는 가독성을 위해 애노테이션 내부에 Validator 클래스를 정의하였습니다.

ConstraintAnnotation 을 정의할 때 반드시 만들어주어야 하는 것은 message, groups, payload 입니다.
message 는 메시지 관리를 위해 사용하고, groups 는 Validation 그룹을 나누기 위해 사용하며, payload 는 심각도를 나타냅니다.

message 에 담고 싶은 에러 메시지를 적고, Validator 내부에서 정규식을 사용하여 이메일 양식에 대한 검증을 수행할 수 있었습니다. 또한 정규식에 대한 간략한 주석을 달 수도 있었습니다.

만약 다른 DTO에서 이메일 형식을 검증하는 애노테이션이 필요하다면, @EmailForm 을 붙여주는것만으로 충분할 것입니다.


중복 이메일과 중복 닉네임 검증하기

사용자가 회원가입을 하는데, 이미 존재하는 이메일이거나 이미 존재하는 닉네임을 사용하려 한다면, 이를 막는것도 검증의 역할입니다. 이전에 수행한 프로젝트에서는 중복을 검사하는 검증, 즉 DB를 사용해야 하는 검증은 서비스 계층에서 수행하였습니다.

Service 메서드1 (DTO dto) {
	Repository.existsByXXX(dto.getXXX())
    	.orElseThrow(예외);	// 검증 로직
    
    // 비즈니스 로직 ...
}

Service 메서드2 (DTO dto) {
	Repository.existsByXXX(dto.getXXX())
    	.orElseThrow(예외);	// 검증 로직 중복!!
    
    // 비즈니스 로직 ...
}

대략적인 검증 형태

그러나 이렇게 중복 검증을 수행하게 되면 중복 검증이 필요한 모든 메서드가 검증 로직을 사용하게 되므로 지저분하고 불필요한 코드 중복이 발생하게 됩니다.
가급적이면 메서드내에서 비즈니스 로직만 수행하는 것이 보기도 좋고, 코드 중복도 제거할 수 있지 않을까 생각하여 중복검증을 수행하는 Constraint Annotation 을 만들어 DTO에 붙이기로 합니다.


@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

	@EmailForm
	@EmailSize
    @EmailDuplicateCheck
	private String email;

	@NicknameForm
    @NicknameDuplicateCheck
	private String nickname;

	@PasswordForm
	private String password;

}

DTO 클래스에 중복 검증을 위한 애노테이션 @EmailDuplicateCheck, @NicknameDuplicateCheck 을 추가


이메일 중복 검사를 수행하는 @EmailDuplicateCheck 애노테이션의 코드는 다음과 같습니다.

@Documented
@Constraint(validatedBy = EmailDuplicateCheck.EmailDuplicateValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface EmailDuplicateCheck {

	String message() default "이미 존재하는 이메일입니다.";

	Class<?>[] groups() default {};

	Class<? extends Payload>[] payload() default {};

	class EmailDuplicateValidator implements ConstraintValidator<EmailDuplicateCheck, String> {
		private MemberRepository memberRepository;
		private AES256Manager aes256Manager;

		@Autowired
		public void setMemberRepository(MemberRepository memberRepository, AES256Manager aes256Manager) {
			this.memberRepository = memberRepository;
			this.aes256Manager = aes256Manager;
		}

		@Override
		public boolean isValid(String emailInput, ConstraintValidatorContext context) {
			String encryptedEmailInput = aes256Manager.encrypt(emailInput);
			return !memberRepository.existsByEmail(encryptedEmailInput);
		}

	}

}
  • ConstraintValidator 는 기본생성자가 있어야 하므로, 필요한 의존성은 setter 주입하였습니다.

다음과 같이 DB를 사용하는 Validator 를 만듦으로써 컨트롤러 계층에서 클라이언트 입력값에 대한 모든 검증을 끝낸 DTO를 순수하게 사용할 수 있게 되었습니다.

하지만 이 방식에도 문제점은 있었습니다.
이메일 검증을 할 때 사용자가 정규식을 위반하는 불가능한 이메일을 입력하였다면, 굳이 DB를 거치는 비싼 검증을 수행할 필요가 있을까요?
저는 정규식이나 길이제한을 위반하는 이메일은 DB를 거치는 중복 검증을 수행하지 않아도 된다 생각하여, 정규식이나 길이제한 검증을 통과한 경우에만 DB를 거치는 중복 검증을 수행하도록 하고 싶었습니다.


Validator 순서 적용하기

앞서 말한 문제점을 해결하기 위해 검증 애노테이션의 순서를 적용하였습니다.
검증 애노테이션의 순서를 적용하는 방법은 그룹과 @GroupSequence 를 사용하는 것입니다.

우선적으로 그룹을 만듭니다.

public interface DBUsing {
}

DB를 사용하는 그룹


그 다음엔 DTO에 DB를 사용하는 검증 애노테이션에 앞서 만든 그룹을 설정해주고, 클래스 레벨에 @GroupSequence 를 적용해, 검증 순서를 정하면 됩니다.

@GroupSequence({SignUpForm.class, DBUsing.class})
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

	@EmailForm
	@EmailSize
	@EmailDuplicateCheck(groups = DBUsing.class)
	private String email;

	@NicknameForm
	@NicknameDuplicateCheck(groups = DBUsing.class)
	private String nickname;

	@PasswordForm
	private String password;

}

다음의 설정으로 아무런 그룹도 아닌 검증 애노테이션, 즉 DB를 사용하지 않는 검증이 먼저 수행되고, 이전 검증이 성공한 경우에만 DB를 사용하는 검증을 수행하게 됩니다.

그러나 이 구조에 대해서도 마음에 들지 않는 점이 있었습니다.
바로 (groups = DBUsing.class) 를 직접 넣어주어야 된다는 점입니다.
만약 엄청나게 많은 DB 검증 애노테이션이 프로젝트에 전역적으로 있다면 관리가 수월하지 않을 것입니다.

이를 해결할 수 있을까 많은 고민을 하였습니다.
제가 시도했던 접근 방법은 DB 검증을 수행하는 Constraint Annotation 의 그룹에 DBUsing.class 를 미리 넣는 것입니다. 이렇게 하면 DTO 클래스에 직접 명시하지 않아도 되겠다고 생각했습니다.

@Documented
@Constraint(validatedBy = EmailDuplicateCheck.EmailDuplicateValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface EmailDuplicateCheck {

	String message() default "이미 존재하는 이메일입니다.";

	Class<?>[] groups() default {DBUsing.class};

	Class<? extends Payload>[] payload() default {};
    
    // ...
    
}

그러나 이 방법은 문제에 봉착하게 됩니다.

jakarta.validation.ConstraintDefinitionException: HV000077: site.onandoff.member.validator.EmailDuplicateCheck contains Constraint annotation, but the groups parameter default value is not the empty array.


Constraint Annotation 은 group 을 empty array 로 설정해야 합니다!
전 세계의 코드를 뒤져봐도 Class<?>[] groups() default {} 를 설정한 코드를 못봤는데, groups 를 설정한 코드가 없었던건, Constraint Annotation 이 group을 설정해선 안되기 때문이었습니다.

그래서 결국 울며 겨자먹기로 group 을 명시해주는 구조를 유지하게 됩니다.


그러나 또 한가지의 문제에 봉착합니다. 바로 테스트코드 작성의 어려움입니다.
저희 프로젝트에선 컨트롤러 테스트는 RestDocsSupport 클래스를 상속받아, 하나로 통합된 테스트 환경으로 수행합니다. 또한 테스트 환경은 standaloneSetup 설정으로 필요한 의존성은 initController() 메서드를 통해, 실제 테스트코드에서 컨트롤러를 주입합니다

@ExtendWith(RestDocumentationExtension.class)
public abstract class RestDocsSupport {

	protected MockMvc mockMvc;
	protected ObjectMapper objectMapper = new ObjectMapper();
	private final AccessTokenInterceptor accessTokenInterceptor = mock(AccessTokenInterceptor.class);
	private final RefreshTokenInterceptor refreshTokenInterceptor = mock(RefreshTokenInterceptor.class);

	@BeforeEach
	void setUp(RestDocumentationContextProvider provider) throws Exception {
		this.mockMvc = MockMvcBuilders.standaloneSetup(initController())
			.setControllerAdvice(GlobalExceptionHandler.class)
			.addInterceptors(accessTokenInterceptor, refreshTokenInterceptor)
			.apply(MockMvcRestDocumentation.documentationConfiguration(provider))
			.build();

		given(accessTokenInterceptor.preHandle(any(), any(), any()))
			.willReturn(true);
		given(refreshTokenInterceptor.preHandle(any(), any(), any()))
			.willReturn(true);
	}

	protected abstract Object initController();

}

컨트롤러 테스트 통합 환경


따라서 테스트코드는 다음과 같이 작성할 수 있습니다.

class MemberControllerTest extends RestDocsSupport {

	private final MemberService memberService = mock(MemberService.class);

	@Override
	protected Object initController() {
		return new MemberController(memberService);
	}

	@Test
	@DisplayName("회원가입 성공 시, Redirect URL 과 회원이 DB에 저장될때의 pk값을 반환한다.")
	void signUpSuccess() throws Exception {
		// given
		SignUpForm signUpForm = new SignUpForm("ghkdgus29@naver.com", "hyun", "1234567a!");

		given(memberService.signUp(any(SignUpForm.class)))
			.willReturn(new SignUpSuccessResponse(1L));

		// when & then
		mockMvc.perform(post("/members")
				.content(objectMapper.writeValueAsString(signUpForm))
				.contentType(MediaType.APPLICATION_JSON))
			.andDo(print())
			.andExpect(status().isOk())
			.andExpect(jsonPath("$.data.redirectURL").value("https://on-and-off.site"))
			.andExpect(jsonPath("$.data.savedMemberId").value(1))
			.andDo(document("signup-success",
				preprocessRequest(prettyPrint()),
				preprocessResponse(prettyPrint()),
				requestFields(
					fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
					fieldWithPath("nickname").type(JsonFieldType.STRING).description("닉네임"),
					fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호")
				),
				responseFields(
					fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드"),
					fieldWithPath("status").type(JsonFieldType.STRING).description("상태"),
					fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"),
					fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"),
					fieldWithPath("data.redirectURL").type(JsonFieldType.STRING).description("리디렉션 URL"),
					fieldWithPath("data.savedMemberId").type(JsonFieldType.NUMBER).description("유저 PK")
				)
			));
	}
}

서비스의 동작을 mocking 하면서 잘 실행될 줄 알았던 테스트코드에 큰 문제가 발생합니다.
바로 NullPointerException 입니다.

왜 이런 문제가 발생할까요?
컨트롤러 계층의 테스트코드이기 때문에, Service 까지는 mocking 해서 주입하지만 Repository 까지는 mocking 하지 않기 때문입니다.
DB 검증을 수행하는 ConstraintValidator 는 MemberRepository 가 필요한데, 주입해주지 않았으니 NullPointerException 이 발생합니다.

어떻게 해결해야 할 지 고민을 많이 했습니다.
이쯤되는 트러블 슈팅은 자료도 별로 없고, Validator 를 일일히 mocking 하자니 RestDocsSupport 를 상속받는 다른 컨트롤러 테스트에서도 테스트와 관련없는 Validator 를 mocking 해주어야 하니 복잡도가 올라가고, 가독성이 떨어지게 됩니다.
또한 Validator 를 mocking 하지 않고, 컨트롤러 테스트에서 자연스럽게 사용하는것이 더 좋다고 생각하기 때문에 저의 고민은 깊어져만 갔습니다.

고민하다 정신을 거의 잃기 직전에 옛 (프로그래밍) 선인들의 말씀이 떠올랐습니다.
테스트 코드를 짜기 어렵다면 그것은 리팩터링의 신호이다.

이 구조가 잘못되었기 때문에 테스트 코드 작성이 어려운것은 아닐까 생각이 들었습니다.
따라서 검증 로직의 구조를 변경합니다.


컨트롤러에서 할 검증과 서비스에서 할 검증을 나누자

이전 구조에서 발생하는 2가지 문제는 다음과 같습니다.

  • 검증 순서를 정하기 위해 group 정보를 하드코딩해야 한다.

  • 컨트롤러 테스트 코드에서 리포지토리에 대한 의존성이 필요해진다.


이를 해결하기 위해 제가 선택한 방법은 책임 분리입니다.

컨트롤러 계층의 DTO 가 검증해야 할 내용은 사용자의 입력이 정규식을 만족하는지, 길이제한을 만족하는지만 검사합니다. 따라서 컨트롤러 계층에서 서비스 계층으로 넘겨주는 DTO는 위 검증을 통과해야만 합니다.

서비스 계층의 DTO가 검증해야 할 내용은 사용자의 입력이 DB에 이미 존재하는 중복된 값이 아닌지 중복 검증을 수행합니다. 이를 통해 이전 구조에서 발생하는 2가지 문제를 모두 해결할 수 있습니다.

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SignUpForm {

	@EmailForm
	@EmailSize
	private String email;

	@NicknameForm
	private String nickname;

	@PasswordForm
	private String password;

	public UniqueSignUpForm toUnique() {
		return new UniqueSignUpForm(email, nickname, password);
	}

}

컨트롤러 계층에서 사용하는 DTO는 글자수, 정규식 만족 여부만 검증합니다.


@RestController
@RequiredArgsConstructor
public class MemberController {

	private final MemberService memberService;

	@PostMapping("/members")
	public ApiResponse<SignUpSuccessResponse> signUp(@RequestBody @Valid SignUpForm signUpForm) {
		SignUpSuccessResponse signUpSuccessResponse = memberService.signUp(signUpForm.toUnique());

		return ApiResponse.ok(ResponseMessage.SIGNUP_SUCCESS.getMessage(), signUpSuccessResponse);
	}

}

컨트롤러 계층에서 검증이 완료된 DTO는, 서비스 계층용 DTO 로 변환하여 넘겨줍니다.


@AllArgsConstructor
@Getter
public class UniqueSignUpForm {

	@EmailDuplicateCheck
	private String email;

	@NicknameDuplicateCheck
	private String nickname;

	private String password;

}

서비스 계층용 DTO에서 DB를 사용하는 중복 검사를 수행합니다.


@Service
@Transactional(readOnly = true)
@Validated
@RequiredArgsConstructor
public class MemberService {

	@Transactional
	public SignUpSuccessResponse signUp(@Valid UniqueSignUpForm signUpForm) {
		
        // 비즈니스 로직 ...
	}
}
  • 서비스 계층에서 @Valid 를 하는 경우, 클래스 레벨에 @Validated 를 붙여주어야 합니다.
  • @Transactional 과 유사하게 스프링 AOP 기반으로 검증 로직이 적용됩니다.
  • 프로그래머는 이미 중복검증이 끝난 DTO를 가지고 비즈니스 로직에만 집중할 수 있습니다.

이때 서비스 계층에서 검증을 실패할 때 발생하는 예외는 MethodArgumentNotValidException 이 아닙니다. 해당 예외는 컨트롤러 계층에서 검증을 만족하지 못했을 때 ArgumentResolver에 의해 발생하는 예외입니다.
서비스 계층에서 발생하는 예외는 ConstraintViolationException 이므로 이를 숙지하고 GlobalExceptionHandler 를 작성하였습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

	@ResponseStatus(HttpStatus.BAD_REQUEST)
	@ExceptionHandler(ConstraintViolationException.class)
	public ApiResponse<Object> handleConstraintViolationException(ConstraintViolationException exception) {
		return ApiResponse.of(HttpStatus.BAD_REQUEST, null,
			exception.getConstraintViolations().stream()
				.collect(Collectors.groupingBy(v -> parseFieldNameFrom(v.getPropertyPath())))
				.entrySet().stream()
				.map(error -> {
					Map<String, Object> fieldError = new HashMap<>();
					fieldError.put("field", error.getKey());
					fieldError.put("message", error.getValue().stream()
						.map(ConstraintViolation::getMessage)
						.collect(Collectors.joining(", ")));
					return fieldError;
				})
		);
	}

	private String parseFieldNameFrom(Path propertyPath) {
		String[] splitPath = propertyPath.toString().split("\\.");
		return splitPath[splitPath.length - 1];
	}
    
    // ...

}

이전에 작성에 실패하였던 컨트롤러 테스트 코드의 경우, 서비스 계층에서 ConstraintViolationException 을 던져주도록 mocking 하면 컨트롤러 테스트 코드를 작성할 수 있습니다.
이때 ConstraintViolationException 을 mocking 하는 과정이 다소 복잡하긴 하지만, 서비스 계층의 실패를 테스트하고 싶은 테스트 메서드에서만 복잡한 mocking 을 해주면 됩니다.

class MemberControllerTest extends RestDocsSupport {

	private final MemberService memberService = mock(MemberService.class);

	@Override
	protected Object initController() {
		return new MemberController(memberService);
	}

	@Test
	@DisplayName("회원가입 입력폼의 닉네임이나 이메일이 중복되어 회원가입 실패 시, 400 Bad Request를 반환하며, data엔 어떤 필드에서 실패하였는지 에러 메시지와 함께 응답한다.")
	void uniqueSignUpFail() throws Exception {
		// given
		SignUpForm signUpForm = new SignUpForm("ghkdgus29@naver.com", "hyun", "1234567a!");

		ConstraintViolationException exception = mock(ConstraintViolationException.class);
		Set<ConstraintViolation<?>> violations = new HashSet<>();
		ConstraintViolation mockedViolation = mock(ConstraintViolation.class);
		violations.add(mockedViolation);

		given(mockedViolation.getPropertyPath()).willReturn(PathImpl.createPathFromString("signUp.signUpForm.email"));
		given(mockedViolation.getMessage()).willReturn("이미 존재하는 이메일입니다.");
		given(exception.getConstraintViolations()).willReturn(violations);
		given(memberService.signUp(any(UniqueSignUpForm.class)))
			.willThrow(exception);

		// when & then
		mockMvc.perform(post("/members")
				.content(objectMapper.writeValueAsString(signUpForm))
				.contentType(MediaType.APPLICATION_JSON))
			.andDo(print())
			.andExpect(status().isBadRequest())
			.andDo(document("unique-signup-fail",
				preprocessRequest(prettyPrint()),
				preprocessResponse(prettyPrint()),
				requestFields(
					fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
					fieldWithPath("nickname").type(JsonFieldType.STRING).description("닉네임"),
					fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호")
				),
				responseFields(
					fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드"),
					fieldWithPath("status").type(JsonFieldType.STRING).description("상태"),
					fieldWithPath("message").type(JsonFieldType.NULL).description("메시지"),
					fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("응답 데이터"),
					fieldWithPath("data[].field").type(JsonFieldType.STRING).description("잘못 입력한 필드"),
					fieldWithPath("data[].message").type(JsonFieldType.STRING).description("에러 발생 이유")
				)
			));
	}

}

ConstraintViolationExceptionHandler 에서 사용하는 메서드들을 mocking 합니다.


ConstraintViolationExceptionHandler 에서 사용하는 메서드들은 다음과 같습니다.
ConstraintException

  • getConstraintViolations()Set<ConstraintViolation<?>> violations

ConstraintViolation

  • getPropertyPath()Path 인터페이스의 구현체, PathImpl 의 정적 메서드를 사용하여 만들었습니다.

  • getMessage() ➔ 에러 메시지


회고

이전의 프로젝트에선 크게 고민하지 않고 관성적으로 검증을 수행하였습니다.
이번 프로젝트에서는 더 좋은 구조가 무엇일까 고민하면서 검증 로직을 구성하였는데, 시간은 굉장히 많이 걸렸지만 배워간 것이 많아 좋았습니다.


참고

스프링 Custom Bean Validation 만들어서 사용해보기


Java Bean Validation 제대로 알고 쓰자


[Spring] @Valid와 @Validated를 이용한 유효성 검증의 동작 원리 및 사용법 예시



Bean Validation: How can I manually create a ConstraintViolation?


How to write Unit Test for below Exception Handler method using mockito?

profile
mujik-tigers 프로젝트 블로그

0개의 댓글