개발바닥곰발바닥
728x90

[Spring Boot] DTO Validation 그룹화 및 TEST

오늘은 DTO 입력 값을 검증하는 방법과 Validation을 그룹화하고 Sequence를 만들어 검증 순서를 지정하여 테스트 코드까지 구현하는 방법을 작성해보려고 한다.

간단한 DTO 중에서도 회원가입 요청에 대한 DTO를 검증하는 과정을 살펴볼 것이다.

우선 사용자 입력을 받는 DTO에 유효성 검증이 없으면 ID나 패스워드, 이메일 등에 규칙과 다른 값이 들어오더라도 이를 미리 막을 수가 없다. 물론 프론트엔드에서 입력 값을 검증하긴 하지만 클라이언트에서 전송하는 값은 조작이 쉽기 때문에 백엔드 쪽에서도 이중으로 검증을 수행해야 안전하다.

시작하기에 앞서 validation을 위한 의존성 추가가 필요하다.

build.gradle에 아래 내용과 같이 의존성을 추가해준다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

DTO 입력 값 유효성 검증

우선 회원가입 DTO 클래스를 생성해준다.

ID는 Null이나 빈 값이 입력될 수 없고, 영어로 시작해야 하며 4글자에서 15글자 사이의 길이를 가져야 한다.

Password는 8글자 이상 20글자 이하의 길이를 가져야 한다. 보안을 위해 특수문자, 알파벳 대소문자, 숫자 등이 조합되어야 하는 정규식을 작성할 수도 있지만 간단하게 길이 제한만 걸어두었다.

Validation 어노테이션에 대한 공식문서는 https://www.baeldung.com/javax-validation에서 살펴볼 수 있다.

public class AuthRequest {
	@NotBlank(message = "아이디를 입력해주세요.", groups = NotEmptyGroup.class)
	@Pattern(regexp = "^[a-zA-Z]+[a-zA-Z0-9_]{3,14}$", message = "아이디 형식이 틀립니다.", groups = PatternCheckGroup.class)
	private String id;
	@Size(message = "비밀번호는 8글자 이상, 20글자 이하입니다.", min = 8, max = 20)
	private String password;
}

잘 보면 Validation 어노테이션 마지막에 groups가 지정된 것을 볼 수 있는데, 이것이 바로 Validation 그룹화를 지정해주는 부분이다.

다음으로는 AuthRequest DTO를 받는 Controller를 작성한다.

signup 메서드를 보면 @Validated가 AuthRequest 앞에 붙어 있는 것을 볼 수 있는데, 이 어노테이션을 붙여줘야 DTO에 설정했던 검증이 작동한다.

@RestController
@RequestMapping("/auth")
@CrossOrigin()
@RequiredArgsConstructor
public class AuthController {

	private final AuthService authService;

	@PostMapping("/signup")
	public ResponseEntity<BasicResponse> signup(
		@Validated(ValidationSequence.class) @RequestBody AuthRequest authRequest) {
		// ...
	}

Validated 안에 ValidationSequence.class 가 적혀 있는 것을 볼 수 있는데 저게 바로 검증 순서를 지정해주는 클래스이다.

그룹화해서 순서를 지정하지 않으면, 발생한 모든 검증 에러를 리스트로 뱉어주기 때문에 Response에서 클라이언트에 전달하기가 애매해진다.

다음으로는 Validation 그룹화와 순서 지정하는 방법에 대해 알아보겠다.

Validation 그룹화

검증 Group 별로 순서를 지정하기 위해 ValidationGroups 클래스 안에 인터페이스들을 작성한다.

public class ValidationGroups {
	public interface NotEmptyGroup {
	}

	public interface LengthCheckGroup {
	}

	public interface PatternCheckGroup {
	}

}

이 게시글에서 자세히 다루지는 않지만 Validation에 대해 그룹화를 하면, @Validated로 검증할 때 원하는 그룹의 검증만 수행하도록 할 수 있다.

public class TestService{
    @Validated(ValidationGroups.class) // 메서드 호출 시 ValidationGroups 그룹이 지정된 제약만 검사한다.
    public void sendAdMessage(@Valid Test test) {
        // Do Something
    }

Validation Sequence

아까 작성했던 그룹을 이용해 시퀀스를 설정한다.

ValidationSequence 인터페이스를 생성한 뒤에 @GroupSequence 어노테이션으로 그룹들 간의 순서를 지정한다.

@GroupSequence({Default.class, NotEmptyGroup.class, LengthCheckGroup.class, PatternCheckGroup.class})
public interface ValidationSequence {
}

이제 순서를 지정하기 위한 작업은 모두 마쳤으니 테스트 코드를 통해 결과를 확인해보도록 하자

Validation Test 코드 작성

AuthDtoTest 클래스를 생성해서 테스트 코드를 작성해보았다.

Validation을 Test하기 위해 ValidatorFactory로 Validator 인스턴스를 생성한다.

public class AuthDtoTest {
	private static ValidatorFactory factory;
	private static Validator validator;

	@BeforeAll
	public static void init() {
		factory = Validation.buildDefaultValidatorFactory();
		validator = factory.getValidator();
	}

	@AfterAll
	public static void close() {
		factory.close();
	}

	@Nested
	@DisplayName("아이디 검증")
	class IdTest {
		@Test
		@DisplayName("아이디가 빈 값이면 에러 발생")
		void id_notblank_validation_fail() {
			//given
			AuthRequest authRequest = new AuthRequest("", "123456789");
			//when
			Set<ConstraintViolation<AuthRequest>> violations = validator.validate(authRequest,
				NotEmptyGroup.class);
			//then
			Assertions.assertThat(violations).isNotEmpty();
		}

		@Test
		@DisplayName("아이디 형식이 맞지 않으면 에러 발생")
		void id_pattern_validation_fail() {
			//given
			AuthRequest authRequest = new AuthRequest("1a1as", "123456789");
			//when
			Set<ConstraintViolation<AuthRequest>> violations = validator.validate(authRequest,
				PatternCheckGroup.class);
			//then
			Assertions.assertThat(violations).isNotEmpty();
		}

		@Test
		@DisplayName("아이디 형식이 맞으면 성공")
		void id_pattern_validation_success() {
			//given
			AuthRequest authRequest = new AuthRequest("fourword", "eightword");
			//when
			Set<ConstraintViolation<AuthRequest>> violations = validator.validate(authRequest, PatternCheckGroup.class);
			//then
			Assertions.assertThat(violations).isEmpty();
		}
	}

Set<ConstraintViolation<AuthRequest>>에 Validation 위반 사항을 담아서 검증이 실패한 항목이 있는지 확인하는 테스트들이다.

validator의 vaildate 메서드에서 authRequest에 대한 Validation을 수행하는데, 이 때 아까 만들었던 ValidationGroup을 지정하여 해당 그룹에 대한 검증을 실행할 수 있다.

보너스) 전역 에러 핸들러 (GlobalExceptionHandler)

컨트롤러에서 Validated를 수행하면 Validation 에러에 대한 처리 방법으로 크게 두 가지가 있다.

  1. 해당 메서드 안에서 bindingResult를 받아 에러를 처리하는 방법
  2. 컨트롤러 내에서 @ExceptionHandler 를 통해 에러를 처리하는 방법

그런데 이렇게 처리할 경우, 1번은 API가 늘어날 수록 , 2번은 컨트롤러가 많아질 수록 중복코드가 늘고 유지보수가 어려워지는 단점이 있다.

이럴 때 예외처리를 전역적으로 할 수 있게 AOP가 적용된 @RestControllerAdvice 를 사용하면 중복코드를 줄일 수 있다.

@RestControllerAdvice
public class GlobalExceptionHandler {
	@ExceptionHandler(MethodArgumentNotValidException.class)
	public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException exception) {
		return ResponseEntity.badRequest()
			.body(new ErrorResponse(exception.getBindingResult().getFieldError().getDefaultMessage(), "400"));
	}

}

위처럼 GlobalExceptionHandler 클래스를 생성해서 @RestControllerAdvice 어노테이션을 선언해주면, Controller에서 발생하는 에러에 대해 @ExceptionHandler 메서드들로 처리하여 Response Body로 응답해줄 수 있다.

 

 

이전에는 예외 처리를 컨트롤러마다 try-catch로 해주면서 굉장히 불편하고 비효율적이라고 느꼈었다.

이번 프로젝트를 진행하면서 Spring framework가 제공해주는 Validated (Valid는 javax) 에 대해 알게되고, 단위 테스트 코드 작성 방법과 전역적으로 예외를 처리할 수 있는 @RestControllerAdvice 를 배울 수 있어서 한 단계 더 레벨업 한 느낌이 든다.

 

참고 문서

https://meetup.toast.com/posts/223

https://velog.io/@banjjoknim/RestControllerAdvicehttps://tecoble.techcourse.co.kr/post/2020-07-28-global-exception-handler/

 

Spring에서 전역 예외 처리하기

Spring에서 예외 처리하는 방법은 여러 가지가 있다. 메서드에서 try/catch…

tecoble.techcourse.co.kr

 

728x90
profile

개발바닥곰발바닥

@bestinu

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!