[Spring] 코틀린 스프링에서 Validation 적용 방법과 주의점
코틀린 스프링에서 Spring Validation을 적용하기 위해서는 꼭 알아둬야 할 것이 있다.
📌 서론
코틀린 스프링으로 개발하던 도중 독특한 문제를 만났다.
프로젝트에 spring validation을 적용시켜 requestDto의 유효성을 검증하도록 설계했다. 왜냐하면 코틀린이 null safe 한 언어라지만 클라이언트 측에서 보내는 데이터까지 모두 컨트롤할 수 없기 때문에 클라이언트 요청을 바로 validation으로 검증하여 데이터의 무결성을 보장하고자 했다.
그래서 나는 검증하고자 하는 DTO 필드에 @NotBlank 어노테이션을 적어줬고 HTTPie(Postman)을 사용해 validation을 적용한 필드에 값을 넣지 않고 api요청을 보내봤다. (일부로 오류를 발생시키기 위함) 근데.... 왜 validation에 성공하는 걸까..? 실패해야 하는데 성공 응답코드인 200과 응답이 나와버렸다.
requestBody에 값을 담지 않았으니 당연히 spring validation이 동작해서 예외가 발생했어야 하는데 validation이 성공하는 괴상한 현상이 발생했고 무엇 때문인지 원인을 찾아보기 시작했다.
가장 먼저 생각난 것은 지금 내가 사용하는 언어는 Java가 아닌 Kotlin이라는 것이었다. 분명 코틀린은 자바와 다른 무언가 있을 것이다. 그러니까 이런 오류가 생기는 거 아닐까? 뭔가를 빼먹은 것 같다는 생각이 계속 머릿속을 맴돌았다.
코틀린을 자바처럼 사용해야만 validation을 사용할 수 있나? @JvmStatic? @JvmField? 이런 것을 사용해야 하나? 이런 생각으로 접근해 봤지만 분명 이 문제는 아닐 것이라고 생각했다. 스프링 개발자분들께서 이렇게 허술하게 Kotlin+Spring을 설계했을 리가 없다.
모르겠으면 검색을 해보자!
역시 많은 선배님들께서 같은 고민을 하셨고 얻은 insight를 공유해 주셨다. 검색결과 코틀린 스프링에서는 validation 어노테이션 앞에 @field:, @get: 이런 식으로 추가적인 정보를 적어줘야만 제대로 동작한다는 것을 알게 되었다. 그래서 나는 바로 단위 테스트를 작성하여 이것에 대한 검증을 진행했다. 지금부터 그 결과를 공유한다.
1. 코틀린의 위치 지정자 이해하기
개념 정리
- 코틀린에서 어노테이션을 property에 적용할 때는 @field:, @get:, @set:, @param: 등의 사용 위치 지정자를 사용하지 않으면, 기본적으로 어노테이션은 @get:을 사용하게 된다고 한다. 즉, property의 getter 메서드에 적용된다는 말이다.
@field: 이해하기
- @field 위치 지정자는 어노테이션을 프로퍼티의 필드에 직접 적용한다. 이는 주로 데이터 바인딩이나 유효성 검사에서 사용된다.
- 하단의 예시처럼 작성하면 해당 필드(name)가 빈 문자열이 아니어야 함을 보장한다.
import jakarta.validation.constraints.NotBlank
data class User(
@field:NotBlank(message = "이름은 필수입니다.")
val name: String
)
@get: 이해하기
- @get: 위치 지정자는 어노테이션을 프로퍼티의 게터 메서드에 적용한다.
- 하단의 예시처럼 작성하면 name을 읽을 때(get) 빈 문자열이 아닌지 확인한다.
import jakarta.validation.constraints.NotBlank
data class User(
@get:NotBlank(message = "이름은 필수입니다.")
val name: String
)
@set: 이해하기
- @set: 위치 지정자는 어노테이션을 프로퍼티의 세터 메서드에 적용한다.
- 하단의 예시처럼 작성하면 name을 설정(set)할 때 빈 문자열이 아닌지 확인한다.
import jakarta.validation.constraints.NotBlank
data class User(
@set:NotBlank(message = "이름은 필수입니다.")
var name: String
)
@param: 이해하기
- @param: 위치 지정자는 생성자 파라미터에 어노테이션을 적용한다. 이는 주로 생성자 인자에 유효성 검사를 적용할 때 사용한다.
- 하단의 예시처럼 작성하면 객체를 생성할 때(생성자) name이 빈 문자열이 아닌지 확인한다.
import jakarta.validation.constraints.NotBlank
data class User(
@param:NotBlank(message = "이름은 필수입니다.")
val name: String
)
2. validation이 적용되지 않은 이유가 뭘까?
문제점 분석
- 코틀린에서 어노테이션을 프로퍼티에 직접 적용하면, 기본적으로 그것은 프로퍼티의 getter 메서드에 적용된다. 따라서, @NotBlank를 사용하면 @get:NotBlank와 동일하게 동작한다는 것이다.
- 나는 이것을 잘 모르고 필드에 그냥 @NotBlank만 적어주고 사용했었다. 그렇기에 내가 사용했던 것은 @get:이었고 코틀린의 특성상 @field:를 사용하지 않으면 유효성 검사가 제대로 동작하지 않는다. 그래서 내가 실패해야 하는 검사를 계속 통과한 것이었다. 그 이유는 하단의 내용을 통해 이해할 수 있을 것이다.
다음 두 가지 코드는 동일한 효과를 가진다.
- 하단의 두 코드는 모두 content 프로퍼티의 getter 메서드에 @NotBlank 어노테이션을 적용한다. 그러나, 코틀린에서는 이러한 어노테이션이 getter 메서드에 적용되었을 때, 스프링 validation이 제대로 동작하지 않는다. 스프링 validation은 필드에 직접 어노테이션이 적용되기를 원하기 때문이다.
- 따라서, 스프링 validation이 제대로 동작하도록 하려면 @field:NotBlank를 명시적으로 사용해야 한다. 이는 필드 자체에 어노테이션을 적용하므로, 스프링 validation이 올바르게 검증을 수행할 수 있게 해 준다.
data class MyDto(
@NotBlank(message = "내용은 필수입니다.")
val content: String
)
data class MyDto(
@get:NotBlank(message = "내용은 필수입니다.")
val content: String
)
올바른 사용예제는 다음과 같다.
- 이렇게 작성하면 스프링 validation이 content 필드를 올바르게 검증할 수 있다.
import javax.validation.constraints.NotBlank
data class MyDto(
@field:NotBlank(message = "내용은 필수입니다.")
val content: String
)
3. 단위 테스트로 검증해 보자
2가지 DTO 클래스 생성하기 (data class)
- 하나의 class에는 @field: 를 붙이고 다른 클래스에는 @get:을 사용한다. (NotBlank만 적으면 이게 @get:이랑 동일하다.)
import jakarta.validation.constraints.NotBlank
data class FieldDTO(
// 필드 값에 대한 검증을 수행
@field:NotBlank(message = "내용은 필수입니다.")
val content: String
)
data class GetterDTO(
// getter 메서드의 반환 값에 대한 검증을 수행
@NotBlank(message = "내용은 필수입니다.")
val content: String
)
테스트 코드 작성 (단위테스트)
- 하단의 코드는 실제로 작성한 코드이다. 이에 대한 설명은 다음 목차에서 진행한다.
import jakarta.validation.Validation
import jakarta.validation.Validator
import org.assertj.core.api.Assertions
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@DisplayName("[단위 테스트] - 코틀린에서 Spring validation 라이브러리 유효성 검사 테스트")
class ValidationTest {
private val validator: Validator = Validation.buildDefaultValidatorFactory().validator
@DisplayName("[FieldDTO 필드 전체 검사] FieldDTO의 content가 빈 값일 때 유효성 검사에 실패한다.")
@Test
fun `FieldDTO should fail validation when content is blank`() {
val dto = FieldDTO(content = "")
val violations = validator.validate(dto)
assertEquals(1, violations.size)
assertEquals("내용은 필수입니다.", violations.first().message)
}
@DisplayName("[content 필드만 검사] FieldDTO의 content가 빈 값이면 content 필드에 대한 유효성 검사가 실패한다.")
@Test
fun `Manual validation for FieldDTO should fail when content is blank`() {
val dto = FieldDTO(content = "")
val violations = validator.validateProperty(dto, "content")
assertEquals(1, violations.size)
assertEquals("내용은 필수입니다.", violations.first().message)
}
@DisplayName("FieldDTO의 content가 빈 값이 아닐 때 유효성 검사에 통과한다.")
@Test
fun `FieldDTO should pass validation when content is not blank`() {
val dto = FieldDTO(content = "유효한 내용")
val violations = validator.validate(dto)
assertEquals(0, violations.size)
}
@DisplayName("GetterDTO의 content가 빈 값이 아닐 때 유효성 검사에 통과한다.")
@Test
fun `GetterDTO should pass validation when content is not blank`() {
val dto = GetterDTO(content = "유효한 내용")
val violations = validator.validate(dto)
assertEquals(0, violations.size)
}
@DisplayName("GetterDTO의 content가 빈 값일 때는 유효성 검사에 실패해야 하지만 검증이 동작하지 않아서 성공한다.")
@Test
fun `GetterDTO should pass validation when content is blank`() {
val dto = GetterDTO(content = "")
val violations = validator.validate(dto)
assertEquals(0, violations.size)
}
@DisplayName("GetterDTO에서는 content가 빈 값일 때 validation의 오류 메시지가 저장되어 있어야 하지만 실제로는 비어 있다.")
@Test
fun `validation should fail when content in GetterDTO is blank`() {
val dto = GetterDTO(content = "")
val violations = validator.validate(dto)
assertEquals(0, violations.size)
assertThat(violations).extracting("message").containsExactly() // 예외 메시지가 비었는지 확인
}
@DisplayName("GetterDTO의 content가 빈 값일 때 유효성 검증을 시도하면 성공했기에 에러 메시지가 남지 않는다.")
@Test
fun `GetterDTO should fail validation when content is blank`() {
val dto = GetterDTO(content = "")
val violations = validator.validate(dto)
Assertions.assertThatThrownBy { violations.first().message }
.isInstanceOf(NoSuchElementException::class.java)
.hasMessage("Collection is empty.")
}
}
모든 테스트는 성공했다.
4. @field를 적어준 FieldDTO 테스트 및 결과 분석
FieldDTO를 보자
- NotBlank앞에 @field: 지정자를 적어주었다.
FieldDTO 테스트 1
- content에 빈 값인 ""를 넣어주고 테스트를 진행하면 violations의 사이즈가 1이 되어야만 한다. 즉 1개의 오류가 발생해야 한다는 의미이다. 오류 메시지의 내용도 내가 설정해 준 "내용은 필수입니다."가 나와야만 한다.
- 테스트 결과 원했던 대로 빈 값을 넣었을 때 spring validation이 제대로 동작하여 오류를 반환했다.
FieldDTO 테스트 2
- 이번 테스트는 테스트 1과 동일하지만 조금 다른 부분이 있다. 바로 특정 필드를 지정해서 검증한다는 것이다.
- content는 위의 테스트와 똑같이 빈 값인 ""를 넣어줬다. 다만 조금 다른 부분은 validator.validateProperty() 메서드를 사용해서 dto 객체 내부의 content라는 필드를 지정해서 검증하도록 명령했다.
- 테스트를 진행해 보면 1번 테스트와 동일한 결과를 얻을 수 있다. 다만 이 테스트를 작성하고 나니 이렇게 원하는 필드를 직접 검증하는 게 좀 더 확실한 테스트 방법인 것 같다는 생각이 든다. (개인의 취향이 아닐까..?)
FieldDTO 테스트 3 : 결론
- 이번에는 FieldDTO의 content 필드에 "유효한 내용"이라는 값을 추가한다.
- 테스트를 진행한 결과 violations의 사이즈는 0이 된다. 즉, 유효성 검증에 통과했다는 것이다.
- 이 테스트를 통해 spring validation을 위해 @field를 적어주면 spring validation이 제대로 동작한다는 것을 검증했다.
5. @field를 사용하지 않는 GetterDTO 테스트 및 결과 분석
GetterDTO를 보자
- @NotBlank만을 사용하고 있는 것을 알 수 있다.
- 최상단에서 설명했지만 한번 더 설명하자면 코틀린에서 어노테이션을 프로퍼티에 직접 적용하면, 기본적으로 그것은 프로퍼티의 getter 메서드에 적용된다. 따라서, @NotBlank를 사용하면 @get:NotBlank와 동일하게 동작한다는 것이다.
- 즉, 지금 이 코드는 @get:NotBlank(message = "내용은 필수입니다.")와 동일하다는 의미다.
GetterDTO 테스트 1
- spring validation을 적용했으니 content가 빈 값이 아니라면 당연히 테스트에 통과해야 한다.
- 테스트 결과 문제없이 통과한다.
GetterDTO 테스트 2 (핵심)
- 이 테스트가 핵심 테스트이다. 왜냐하면 GetterDTO의 content에 빈 값인 ""를 넣어주고 spring validation의 동작을 확인했는데 violations.size를 0을 반환했기 때문이다. (테스트에서 validation이 제대로 동작하지 않아서 오류가 없다고 응답한 것이다.)
- 이 말은 오류가 없냐(0이냐)고 물어봤는데 테스트에서 그렇다고 응답한 것이랑 같다. 근데 ""는 공백이니 당연히 validation이 동작했다면 오류가 발생하여 size의 결과는 1이 나왔어만 했다. 그런데 실제로 결과는 0이 나온 것이다.
- 이 테스트를 통해 알게 된 것은 @get: 을 사용해서는 spring validation의 기능을 적용시킬 수 없다는 것이다.
GetterDTO 테스트 3
- 마지막으로 content에 "" 빈 값을 넣어주고 validation검증을 했을 때 예외 메시지가 존재하는지 비었는지 검증해 보자.
- 결과는 예외가 발생하지 않았기에 메시지는 존재하지 않았다. (Collection is empty)
- 즉, @get:으로는 validation 예외가 발생하지 않는다는 것이며 이것은 validation 처리를 하지 못한다는 말이다.
6. 결론
내가 kotlin에서 spring validation을 사용하려는 이유는 다음과 같다.
- 코틀린은 널 안정성(Null safe)을 기본적으로 지원하지만, 클라이언트에서 들어오는 요청 데이터는 서버 측에서 유효성 검사가 필요하다. 이때 스프링 validation을 사용하여 DTO 클래스의 유효성 검사를 통해 데이터의 무결성을 보장할 수 있다.
컨트롤러에는 @Valid를 적용시키고 필드에는 @field:를 꼭 사용해야 한다.
- 컨트롤러에서 @Valid를 사용하여 요청 Body의 객체에 대해 유효성 검사를 실행한다.
- 또한 DTO에서는 필드에 직접 @field를 적용시켜줘야 한다.
data class RequestDTO(
@field:NotBlank(message = "Name cannot be blank")
val name: String,
)
// 컨트롤러에서는 @Valid를 적용시켜준다.
@RestController
class ValidationTestController {
@PostMapping("/test/valid")
fun testValidation(@Valid @RequestBody request: RequestDTO): String {
return "테스트 응답"
}
}
결론
- @field:를 사용하여 필드에 직접 유효성 검사를 적용해야 클라이언트의 요청 데이터를 정확히 검증할 수 있다.
- @get:를 사용하면 getter 메서드에 유효성 검사가 적용되며, 컨트롤러에서 @Valid를 사용한 유효성 검사에는 적용되지 않는다.