Spring validation과 custom validation

아무리 프론트에서 입력값에 대한 유효성 체크를 한다고 하지만 클라이언트에서 보내는 값은 프론트에서의 유효성 검사에 상관없이 아무 값이나 보내는 것이 가능하기 때문에 결국 서버에서도 유효성 체크가 필요하다.
이때 Spring util이나 Apache commons에서 제공하는 StringUtils와 같은 Util 클래스를 통해 controller에서 클라이언트에서 보낸 값에 대한 유효성 체크를 해도 되지만 개발 도중에 validation 로직이 변경될 경우 실수로 변경을 적용하지 않는 부분이 생길 수도 있고(물론 단위 테스트 코드를 잘 짜놨다면 테스트가 깨지겠지만) javax 어노테이션 기반으로 적용하는 경우가 개발도 더 편하다(고 생각한다).

Spring initializr에서 validation을 위한 spring-boot-starter-validation을 지원한다.
하지만 spring-boot-starter-web를 사용할 경우 spring-boot-starter-validation에 있는 dependency를 포함하고 있기 때문에 굳이 이중으로 dependency를 추가하지 않아도 된다.

alredy included with web

pom.xml에서 dependency를 타고 들어가 보면
spring-boot-starter-web에서는 hibernate-validator dependency가
spring-boot-starter-web -> spring-webmvc -> spring-web에서는 validation-api dependency를 확인할 수 있다.


그럼 이제 코드로 validation 기능을 확인해보자.
간단하게 클라이언트가 Member 정보인 email, name, age의 값을 서버로 보내면 이 값을 체크하는 기능이다.

Spring Boot 2.1.2 버전(현재 최신 버전)으로 프로젝트를 생성하였고 lombok, web dependency를 추가하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.2.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

Member 정보인 email, name, age를 갖는 MemberDto를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.demo.web.dto;

import lombok.Data;

import javax.validation.constraints.*;

@Data
public class MemberDto {
@Email(message = "이메일 형식이 아닙니다.")
@NotBlank // default 메세지
private String email;

@NotBlank(message = "이름을 입력해주세요.")
private String name;

@Positive(message = "나이는 양수만 가능합니다.")
@Min(value = 18, message = "18세 이상만 가능합니다.")
@Max(value = 130, message = "실제 나이를 입력해주세요.")
private int age;
}

위 코드에서 처럼 javax.validation.constraint의 어노테이션을 사용하여 입력값에 대한 제약(constraint)을 걸 수 있다.
사용은 위와 같이 직관적이기 때문이기 어렵지 않게 적용할 수 있다.
message는 해당 제약이 걸렸을 경우 리턴하게 되는 문구이다. 만약 message를 쓰지 않으면 default message를 리턴한다.

javaee-spec에 java.validation 관련 모든 constraint가 리스트화 되어있다.


constraint 어노테이션의 경우 javax와 hibernate가 겹치는 것을 자주 볼 수 있는데
hibernate의 validation 어노테이션은 deprecated 되었고 주석에도 표준 제약(standard constraint)을 사용하라고 가이드하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package org.hibernate.validator.constraints;

/**
* The string has to be a well-formed email address.
*
* @author Emmanuel Bernard
* @author Hardy Ferentschik
*
* @deprecated use the standard {@link javax.validation.constraints.Email} constraint instead
*/
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@ReportAsSingleViolation
@Pattern(regexp = "")
@Deprecated
public @interface Email {
// 생략
}

이제 REST Controller를 생성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.demo.web.controller;

import com.example.demo.web.dto.MemberDto;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@RestController
public class MemberController {

@PostMapping("/member")
public String member(@RequestBody @Valid MemberDto member) {
return member.toString();
}

}

지금은 validation을 체크하기 위함이기 때문에 아무런 로직이 없는 Controller를 생성하였다.

클라이언트에서 보낸 값을 받을 때 값의 유효성 검사를 적용하기 위해서 @Valid 어노테이션을 파라미터에 추가하면된다.

이제 MemberController가 제대로 유효성 검사를 하는지에 대한 단위 테스트 코드를 작성해보자.

@WebMvcTest와 MockMvc로 테스트했을 때 제약에 걸리는 경우 response body를 제대로 리턴하지 못하여서
@SpringBootTest는 비효율적이지만 TestRestTemplate을 사용하기 위해 해당 테스트로 테스트코드를 작성하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
package com.example.demo.web;

import com.example.demo.web.dto.MemberDto;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.core.Is.is;


@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MemberControllerTest {
@Autowired
TestRestTemplate testRestTemplate;

@Test
public void member_정상_테스트() {
MemberDto memberDto = new MemberDto();
memberDto.setEmail("sonyc5720@gmail.com");
memberDto.setName("devson");
memberDto.setAge(29);

ResponseEntity<String> responseEntity = testRestTemplate.postForEntity("/member", memberDto, String.class);

assertThat(responseEntity.getStatusCode(), is(HttpStatus.OK));
}

@Test
public void email_Email_테스트() {
MemberDto memberDto = new MemberDto();
memberDto.setEmail("sonyc5720@");
memberDto.setName("devson");
memberDto.setAge(29);

ResponseEntity<String> responseEntity = testRestTemplate.postForEntity("/member", memberDto, String.class);

assertThat(responseEntity.getStatusCode(), is(HttpStatus.BAD_REQUEST));
assertThat(responseEntity.getBody(), containsString("이메일 형식이 아닙니다."));
}

@Test
public void email_NotBlank_테스트() {
MemberDto memberDto = new MemberDto();
memberDto.setEmail(" ");
memberDto.setName("devson");
memberDto.setAge(29);

ResponseEntity<String> responseEntity = testRestTemplate.postForEntity("/member", memberDto, String.class);

assertThat(responseEntity.getStatusCode(), is(HttpStatus.BAD_REQUEST));
assertThat(responseEntity.getBody(), containsString("이메일 형식이 아닙니다."));
assertThat(responseEntity.getBody(), containsString("반드시 값이 존재하고 공백 문자를 제외한 길이가 0보다 커야 합니다."));
}

@Test
public void name_NotBlank_테스트() {
MemberDto memberDto = new MemberDto();
memberDto.setEmail("sonyc5720@gmail.com");
memberDto.setName(" ");
memberDto.setAge(29);

ResponseEntity<String> responseEntity = testRestTemplate.postForEntity("/member", memberDto, String.class);

assertThat(responseEntity.getStatusCode(), is(HttpStatus.BAD_REQUEST));
assertThat(responseEntity.getBody(), containsString("이름을 입력해주세요."));
}

@Test
public void age_Pository_테스트() {
MemberDto memberDto = new MemberDto();
memberDto.setEmail("sonyc5720@gmail.com");
memberDto.setName("devson");
memberDto.setAge(-29);

ResponseEntity<String> responseEntity = testRestTemplate.postForEntity("/member", memberDto, String.class);

assertThat(responseEntity.getStatusCode(), is(HttpStatus.BAD_REQUEST));
assertThat(responseEntity.getBody(), containsString("나이는 양수만 가능합니다."));
assertThat(responseEntity.getBody(), containsString("18세 이상만 가능합니다."));
}

@Test
public void age_Min_테스트() {
MemberDto memberDto = new MemberDto();
memberDto.setEmail("sonyc5720@gmail.com");
memberDto.setName("devson");
memberDto.setAge(3);

ResponseEntity<String> responseEntity = testRestTemplate.postForEntity("/member", memberDto, String.class);

assertThat(responseEntity.getStatusCode(), is(HttpStatus.BAD_REQUEST));
assertThat(responseEntity.getBody(), containsString("18세 이상만 가능합니다."));
}

@Test
public void age_Max_테스트() {
MemberDto memberDto = new MemberDto();
memberDto.setEmail("sonyc5720@gmail.com");
memberDto.setName("devson");
memberDto.setAge(213121);

ResponseEntity<String> responseEntity = testRestTemplate.postForEntity("/member", memberDto, String.class);

assertThat(responseEntity.getStatusCode(), is(HttpStatus.BAD_REQUEST));
assertThat(responseEntity.getBody(), containsString("실제 나이를 입력해주세요."));
}
}

위와 같이 테스트 코드를 작성하였을 때 전부 green이 뜨는 것을 확인할 수 있다.

전체 메세지를 확인하고 싶다면 postman 같은 툴을 사용하면 편하게 확인할 수 있다.


물론 이게 어노테이션이 마법같이 validation 체크를 하는 것은 아니다.
javax.validation.ConstraintValidator를 구현한 Validator 클래스가 validation 작업을 해주는 것이다.

예를 들어 @Email의 경우 org.hibernate.validator.internal.constraintvalidators.bv.EmailValidator가 validation 처리를 해준다.

이는 반대로 우리가 ConstraintValidator를 구현한 커스텀 Validator를 만들면 우리도 커스텀 어노테이션을 통해 validation 체크를 할 수 있다는 것이다.
예를 들면 사용자의 email은 gmail만 사용해야한다는 정책이 생겨 이를 적용해야하는 경우이다.

이 예제를 코드로 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.demo.validation.annotation;

import com.example.demo.validation.validator.GmailValidator;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Constraint(validatedBy = GmailValidator.class) // Validator 설정
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
public @interface OnlyGmail {
String message() default "gmail 계정만 사용가능합니다."; // 디폴트 message 설정

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

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

gmail만 사용해야 하는 필드에 사용할 어노테이션인 @OnlyGmail이다.
기존 @Email 어노테이션을 바탕으로 하여 생성하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.demo.validation.validator;

import com.example.demo.validation.annotation.OnlyGmail;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class GmailValidator implements ConstraintValidator<OnlyGmail, String> { // <적용 어노테이션, 적용 타입>
@Override
public void initialize(OnlyGmail constraintAnnotation) {
// do nothing
}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value.toLowerCase().endsWith("@gmail.com");
}
}

ConstraintValidator를 구현하여 isValid 메서드의 파라미터 값이 대소문자에 관계없이 @gmail.com로 끝나는지 확인한다.
@Email이 있기 때문에 굳이 @OnlyGmail에 email 형식을 체크하는 로직은 넣지 않았다.

이렇게 만든 커스텀 validation을 MemberDto에 추가하기만하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.demo.web.dto;

import com.example.demo.validation.annotation.OnlyGmail;
import lombok.Data;

import javax.validation.constraints.*;

@Data
public class MemberDto {
@Email(message = "이메일 형식이 아닙니다.")
@OnlyGmail
@NotBlank // default 메세지
private String email;

@NotBlank(message = "이름을 입력해주세요.")
private String name;

@Positive(message = "나이는 양수만 가능합니다.")
@Min(value = 18, message = "18세 이상만 가능합니다.")
@Max(value = 130, message = "실제 나이를 입력해주세요.")
private int age;
}

이제 우리가 만든 커스텀 validation을 테스트하는 코드를 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void email_OnlyGmail_테스트() {
MemberDto memberDto = new MemberDto();
memberDto.setEmail("sonyc5720@naver.com");
memberDto.setName("devson");
memberDto.setAge(29);

ResponseEntity<String> responseEntity = testRestTemplate.postForEntity("/member", memberDto, String.class);

assertThat(responseEntity.getStatusCode(), is(HttpStatus.BAD_REQUEST));
assertThat(responseEntity.getBody(), containsString("gmail 계정만 사용가능합니다."));
}

테스트를 하면 green이 뜨는 것을 확인할 수 있을 것이다.

postman으로 해당 response를 확인하면 아래와 같다.

Share