Spring) Validation은 어떻게 하는 게 좋을까?

오지석·2024년 5월 20일

TODO API를 만들면서 간단하게 값에 대한 Validation을 할 필요가 생겼다.

@Valid

요구사항은 TODO가 생성되거나 수정될 때 제목은 1자 이상 200자 이내여야 한다는 것과 내용은 1자 이상 1000자 이하여야 한다는 것.

Spring에서 Controller쪽 코드에서 annotation을 통해 Validation을 구현하기 위해서는 우선 dependencies에 해당 모듈을 명시해줘야 한다.

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-validation")
}

그리고 Contrller 쪽 코드에서 createTodo 쪽에는 이렇게 @Valid annotation을 달면 BeanValidator가 자동으로 해당 값에 대한 Validation을 진행한다.

@Operation(
    summary = "TODO 생성",
)
@PostMapping
fun createTodo(
    @Valid
    @RequestBody
    createTodoRequest: CreateTodoRequestDto,
): TodoResponseDto {
    println(createTodoRequest.title)
    println(createTodoRequest.title.length)
    return todoService.createTodo(createTodoRequest)
}

@field:Size

data class CreateTodoRequestDto(
    @field:Size(min = 1, max = 200, message = "제목은 1자 이상 200자 이하여야 합니다.")
    val title: String,
    val writer: String,
    @field:Size(min = 1, max = 1000, message = "내용은 1자 이상 1000자 이하여야 합니다.")
    val content: String,
)

dto 쪽에는 이렇게 필요한 값에 대한 간단한 (@NotNull, @NotBlank 등) Validation 내용을 적는다.
kotlin에서는 반드시 @field:를 붙여줘야 자바로 디컴파일 해봤을 때 constructor가 아닌 field에 annotation이 생긴다.

public final class CreateTodoRequestDto {
   @Size(
      message = "제목은 1자 이상 200자 이하여야 합니다.",
      min = 1,
      max = 200
   )
   @NotNull
   private final String title;
public final class CreateTodoRequestDto {
   @NotNull
   private final String title;
...

public CreateTodoRequestDto(@Size(message = "제목은 1자 이상 200자 이하여야 합니다.",min = 1,max = 200) @NotNull String title, @NotNull String writer, @NotNull String content) {
    Intrinsics.checkNotNullParameter(title, "title");
    Intrinsics.checkNotNullParameter(writer, "writer");
    Intrinsics.checkNotNullParameter(content, "content");
    super();
    this.title = title;
    this.writer = writer;
    this.content = content;
}

위 쪽이 field:를 붙였을 때 아래 쪽이 안붙였을 때의 디컴파일된 코드이다.

Enum

좀 다른 케이스지만 비슷한 맥락에서 고민해볼 문제라고 생각하기 때문에 추가로 적겠다.
@PathParam으로 정렬을 오름차순으로 할지 내림차순으로 할지를 입력받고 싶었다.
처음에는 String으로 Controller에서 받고 Service 레이어에서 해당 내용이 SortDirection Enum 안에 있는 값인지를 검증하는 식으로 코드를 구성했다.
딱히 문제가 되는 점은 없었지만 이것도 어떻게 보면 입력 값에 대한 Validation이고 앞서 살펴봤던 것들과 비슷하다는 느낌을 받았다.
그러면 이것도 Controller에서 검증을 하거나 아니면 애초에 Enum으로 입력을 받으면 되지 않을까 생각했다.
예제 코드를 봤던 것에서 String으로 받고 나중에 검증하는 식으로 짜여있던 걸 봐서 너무 고민 없이 따라했던 것 같다.
여튼 Enum으로 짜보니 Swagger에서도 Select 박스로 나오고 나쁘지 않은 것 같았다.

enum class SortDirection {
    ASCENDING,
    DESCENDING,
}

@Operation(
    summary = "TODO 목록 조회",
    description = "TODO 목록을 작성일을 기준으로 정렬하여 조회합니다.",
)
@GetMapping
fun getTodos(
    @RequestParam
    sortDirection: SortDirection?,
): List<TodoResponseDto> {
    return todoService.getTodoList(
        sortDirection ?: SortDirection.DESCENDING,
    )
}

다른 건 다 마음에 들었는데 Http 요청을 실제로 보내는 쪽에서는 이 Enum에 해당하지 않는 값을 보낼 수 있다.
http://some-mockup-api/todos?sortDirection=DESCENDING 여기에 DESCENDING이나 ASCENDING이 아닌 값을 얼마든지 넣을 수 있기 때문이다.
그럴 경우에 No enum constant 패키지명.SortDirection.Enum이_아닌_값라는 메시지를 가진IllegalArgumentException을 발생시키는데 문제는 이 에러에 대한 처리를 Spring이 해주기 때문에 에러 메시지를 규격화하기가 힘들다는 것이다.

참고

여담

1) 의존성을 따로 명시하지 않더라도 @Valid annotation이 import가 가능해서 좀 헤맸다.
코드는 다 짜놨는데 Validation이 안되더라.
@Valid annotation이 포함되어있는 jakarta.validation을 언제부터 의존하는건지? 궁금해서 build.gradle.kts에서 주석처리도 해보고 없애고 다시 빌드도 해보고 캐시도 날려봤는데 여전히 import가 가능한 걸 보면 project를 처음에 spring boot project로 만들어서 그런건가 싶다.
그렇다고 해도 의존성을 명시하고 있지 않은 패키지까지 import가 되는거라면 뭔가 이상한 것 같다 좀 더 알아보는게 좋을 것 같다.

2) Controller와 Service는 양쪽 다 Validation을 진행할 수 있다.
보통 Contoller에서는 간단하게 입력값의 제한사항 정도를 체크하고 비지니스 로직적인 Validation은 Service에서 진행한다고 한다.
이 때 위의 sortDirection은 굳이 따지자면 Controller에서 처리하는 쪽이 맞다고 생각했기 때문에 enum을 사용했는데 막상 에러 메시지를 보니 너무 처리되지 않은, 의도하지 않은 듯한 메시지가 툭 튀어나와서 고민을 하게 됐다.
일단은 좀 더 명시적으로 값을 제한할 수 있는 쪽이 좋다고 생각하기 때문에 Enum을 써서 짠 코드로 어떻게 예외를 처리할 수 있을지 생각해봐야겠다.
답이 없는 문제를 너무 오래 고민하는 건 좋지 않지만 그래도 생각해보고 어떤 근거로 어느 쪽을 선택했는지 정리하는 습관을 들여야겠다.

3) 값에 대한 Validation이 관심사의 분리라는 관점에서 Controller나 Service 두 곳 중 하나에 몰려있어야 하는게 아닐까라고 생각을 했어서 gpt-4o 에게 물어봤었다 그에 대한 대답은

Controller에서 Validation
장점:
1. 빠른 피드백 제공: 사용자의 입력이 잘못되었을 때, 즉시 피드백을 제공할 수 있습니다.
2. 명확한 책임 분리: Controller는 사용자 입력을 받고, 그 입력이 유효한지 체크한 후, Service에 전달하는 역할을 합니다.
3. 단순화된 서비스 로직: 서비스 레이어는 비즈니스 로직에 집중할 수 있습니다.

단점:
1. 중복 코드 발생 가능성: 여러 Controller에서 동일한 Validation 로직이 반복될 수 있습니다.
2. 테스트 어려움: Validation 로직이 여기저기 흩어져 있으면 테스트가 복잡해질 수 있습니다.

Service에서 Validation
장점:
1. 재사용성: 여러 Controller에서 동일한 Validation 로직을 사용할 수 있습니다.
2. 중앙 집중화: Validation 로직이 한 곳에 모여 있어 관리가 용이합니다.
3. 비즈니스 로직과 긴밀한 연관: 일부 Validation은 비즈니스 규칙과 밀접하게 연관되어 있으므로, 서비스 레이어에서 처리하는 것이 자연스러울 수 있습니다.

단점:
1. 추가적인 레이어 필요: 모든 입력이 서비스 레이어를 거쳐야 하므로, 간단한 Validation을 위해서도 서비스 호출이 필요합니다.
2. 지연된 피드백: 잘못된 입력에 대한 피드백이 늦어질 수 있습니다.

결론
많은 경우, 기본적인 입력 Validation(형식, 필수값 등)은 Controller에서 수행하고, 비즈니스 로직과 관련된 복잡한 Validation은 Service에서 처리하는 접근이 권장됩니다. 이렇게 하면 각 레이어의 역할이 명확해지고, 코드의 재사용성과 유지보수성이 높아집니다.

따라서, 상황에 따라 다르겠지만, 일반적으로 간단한 Validation은 Controller에서, 복잡한 비즈니스 로직과 관련된 Validation은 Service에서 처리하는 것이 좋습니다.

0개의 댓글