본문 바로가기

Spring/Spring

스프링 부트에서 Request 유효성 검사하는 방법, 서버 개발한다면 꼭 해야하는 작업 Spring Validation

반응형

스프링부트에서 Request로 오는 객체(DTO)를 어떻게 검증하는가에 대한 이야기

데이터 검증(validation)은 여러 계층에 걸쳐서 발생하는 흔한 작업이다.

어떻게하면 깔끔하게 유효성 검사를 할 수 있을지 생각해보고 얻은 방법을 공유하고자 한다.

스프링에서 가장 기본적인 validation은 Bean validation이다.

Bean validation

Bean validation은 클래스 "필드"에 특정 annotation을 적용하여 필드가 갖는 제약 조건을 정의하는 구조로 이루어진 검사다.

validator가 그 클래스로 생성된 객체의 유효성 여부를 확인한다.

어떠한 비즈니스적 로직에 대한 검증이 아닌, 객체 자체의 필드에 대한 검증을 한다.

예제 코드를 보고 테스트해본다.

plugins {
    id 'org.springframework.boot' version '2.2.2.RELEASE'
    id 'io.spring.dependency-management' version '1.0.8.RELEASE'
    id 'java'
}

group = 'com.tistory.jeong-pro'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
    developmentOnly
    runtimeClasspath {
        extendsFrom developmentOnly
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}

test {
    useJUnitPlatform()
}

기본적인 build.gradle 설정값이다. (h2, web, dev-tool, jpa 추가)

package com.tistory.jeongpro.book.controller;

import javax.validation.Valid;

import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.tistory.jeongpro.book.domain.dto.AddBookRequestDto;
import com.tistory.jeongpro.book.service.BookService;

import lombok.AllArgsConstructor;

@RestController
@AllArgsConstructor
public class BookController {
    private BookService bookService;

    @PostMapping("/books")
    public void save(@RequestBody @Valid AddBookRequestDto addBookRequestDto, BindingResult bindingResult) {
        if(bindingResult.hasErrors()) {
            bindingResult.getAllErrors()
                .forEach(objectError->{
                    System.err.println("code : " + objectError.getCode());
                    System.err.println("defaultMessage : " + objectError.getDefaultMessage());
                    System.err.println("objectName : " + objectError.getObjectName());
                });
            return;
        }
        bookService.save(addBookRequestDto.toEntity());
    }
}

BookController를 만들어서 책(Book)을 등록하는 API 서버를 만든다.

@RequestBody를 통해서 Request의 Body에 있는 값(JSON)을 사전에 정의한 AddBookRequestDto 클래스 객체로 바인딩하고(이 역할은 Jackson2HttpMessageConverter클래스가 함), @Valid를 통해서 validator가 이 객체의 유효성 검사를 진행하도록 유도하는 구조다.

만약 @Valid를 통해서 validator가 검증했을 때 그 객체가 유효하지 않은 객체라면 어떻게 될까?

그럴 땐 Controller의 메서드의 파라미터로 있는 "BindingResult" 인터페이스를 확장한 객체로 들어온다.

즉, bindingResult.hasError() 메서드로 확인해보면 유효성 검사에 실패했을 때 해당 값이 true로 나와 에러가 있다고 확인해준다.

package com.tistory.jeongpro.book.service;

import com.tistory.jeongpro.book.domain.entity.Book;

public interface BookService {
    public void save(Book book);
}

BookService 인터페이스를 만들었다. 앞으로 만들 서비스와 repository 클래스는 실제 코드와 비슷하게 만들기 위해 작성한 부분이다. validation과 큰 연관은 없다.

package com.tistory.jeongpro.book.service;

import org.springframework.stereotype.Service;

import com.tistory.jeongpro.book.domain.entity.Book;
import com.tistory.jeongpro.book.repository.BookRepository;

import lombok.AllArgsConstructor;

@Service
@AllArgsConstructor
public class BookServiceImpl implements BookService {
    private BookRepository bookRepository;
    @Override
    public void save(Book book) {
        bookRepository.save(book);
    }
}

BookService를 구현하는 서비스를 만들었다. 단순하게 repository에 저장하는 메서드를 추가했다.

package com.tistory.jeongpro.book.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import com.tistory.jeongpro.book.domain.entity.Book;

public interface BookRepository extends JpaRepository<Book, Long> {

}

@Repository없이도 잘 등록되는 것을 알 수 있다.

package com.tistory.jeongpro.book.domain.entity;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 500, nullable = false)
    private String title;
    @Column(nullable = false)
    private String author;

    @Builder
    public Book(String title, String author) {
        this.title = title;
        this.author = author;
    }
}

Entity 클래스로 데이터베이스에 저장되는 객체는 따로 정의했다.

@Column annotation으로 제약조건을 정의했는데 이 내용은 DB 필드에 적용되는 값으로 유효성 검사와는 상관이없다. (최종적으로 DB에 저장될 값이니 이 조건에 맞는 유효성 검사를 진행해야하는데 여기서 말하는 DTO 즉, 인터페이스에서 오는 값의 검사와는 거리가 있다.)

package com.tistory.jeongpro.book.domain.dto;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import com.tistory.jeongpro.book.domain.entity.Book;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class AddBookRequestDto {

    @NotBlank(message="title is mandatory")
    @Size(max=100, message="title size must be less than 500 charactors")
    private String title;

    @NotNull(message = "author is not null")
    private String author;

    public Book toEntity() {
        return Book.builder()
            .title(this.title)
            .author(this.author)
            .build();
    }
}

AddRequest를 위한 클래스를 따로 만들었다.

여기에 앞서 말한 annotation을 붙였다. (@NotBlank는 null이 아니고 빈 문자열도 아니여야 하는 조건을 갖는 annotation이다. @Size는 필드가 갖는 크기를 지정할 수 있는 annotation이다. @Max를 써도 된다.)

→ RequestDTO로 별도로 분리한 이유는 Entity객체를 그대로 사용하는 것은 좋지 않기 때문이다.

Entity와 DTO를 분리해야하는 이유

  • Entity와 관련된 코드들은 많은데 비해 DTO의 경우는 상대적으로 적다. 그런 상황에서 Entity는 변경될 가능성이 DTO에 비해 상대적으로 적다. 만약 Entity를 Request, Response에 사용하게 되면 변경 가능성이 높아지고 동시에 같이 변경되는 코드들이 늘어나기 때문에 코드 유지보수를 생각했을 때 Entity와 DTO는 분리해야 한다. (간혹 DTO는 setter가 필요하고 Entity는 setter가 없어야 하기 때문이라고 착각할 수 있는데 DTO에 setter가 없어도 값이 잘 들어올 것이다. (Jackson2HttpMessageConverter클래스 내부에서 objectMappger를 쓰는데 이 것은 setter가 필요 없다. 그래도 결국은 DTO와 Entity를 분리한다. 안정적인 Entity를 건드리지 말자.)
  • JSON타입이 아닌 경우에는 Query Parameter를 쓸텐데요. 이 때는 Jackson2HttpMessageConverter가 아닌 Spring의 WebDataBinder를 사용합니다. 이는 기본적으로 JavaBean 방식을 쓰는데 이때는 setter가 필요합니다.

'생성자'를 이용한 DI를 하는 이유

  • BookController가 BookService를 Setter 또는 멤버 변수(필드)를 이용해서 DI를 진행할 경우, BookController에 테스트를하기 위해 임의로 만든 BookService 객체를 주입하려고 해도 방법이 다소 어렵다. 생성자를 이용한다면 new BookController(MockBookService)로 쉽게 테스트를 진행할 수 있다. (출처 : https://jojoldu.tistory.com/129)
  • 스프링 레퍼런스에서도 생성자로 DI를 쓸 것을 권장하기도 하며, lombok 라이브러리의 @AllArgsConstructor를 이용할 수 있기 때문이기도 하다.

예제와 같이 검증이 필요한 빈(객체)에다가 javax.validation.constraints 패키지 이하에 있는 annotation을 확인해보고 적절하게 사용하면 된다.

이제 위에 있는 예제 코드를 실행한 결과를 보도록 하자. (Client에서 메세지를 보내줘야하는데 이 부분은 postman이라는 프로그램을 이용했다.)

{
    "title":"booksTitle",
    "author":"jeong",
}

위의 JSON포맷의 데이터를 application/json 포맷으로 보내보면 다음과 같이 정상적으로 작동하여 DB에 저장되는 것을 확인할 수 있다.

다음은 author값 대신에 authorr라고 데이터를 만들어 보냈을 때 에러다.

author라는 필드가 없으므로 null이 될 수 없다며 에러를 리턴했다. 유효성 검사에 실패했을 때 BindingResult 객체로 바인딩되는 것을 볼 수 있다. (hasErrors())

Custom annotation

javax.validation.constrains 패키지에 있는 annotation으로 모든 제약조건을 설명할 수 있는 건 아닐 것이다.

이럴 때는 필요한 제약조건을 갖는 custom annotation을 만들어서 사용하면 된다.

예를 들어서 e-book에서 적용되는 폰트 값을 입력한다고 해보자.

제약 조건은 그 입력 받은 폰트 값이 서버가 이해할 수 있는 폰트 값들의 집합에 포함되어야 한다는 조건이라고 가정하고 코드를 구현해본다.

처음 해야할 것은 custom annotation을 만드는 것이다.

package com.tistory.jeongpro.book.validation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

@Documented
@Constraint(validatedBy = FontConstraintValidator.class)
@Target({ ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface FontConstraint {
    String message() default "Invalid Font";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

이렇게 적어주면 custom annotation이 만들어진다.

하나씩 살펴보면, 우선 annotation 이름이다. 여기서는 FontConstraint라고 지었으니 나중에 사용할 때 @FontConstraint 라고 될 것이다. 만약 @Font 이렇게 쓰고 싶으면 이름을 Font라고 지으면 된다.

다음으로는 default로 나타날 메시지다. 문제 발생시에 기본적으로 보이는 메세지가 "Invalid Font"가 된다.

@Target을 통해서 메서드나 필드에 적용되는 annotation임을 지정했고, @Constraint(validatedBy = ...)으로 이 어노테이션이 지정됐을 때 "어떤 validator(class)가 처리를 담당할지"를 정해준다.

package com.tistory.jeongpro.book.validation;

import java.util.Arrays;
import java.util.List;

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

public class FontConstraintValidator implements ConstraintValidator<FontConstraint, String>{
    public static final List<String> fonts = Arrays.asList("serif", "sans-serif", "d2coding", "verdana");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return value != null && fonts.contains(value.toLowerCase()) ;
    }
}

이제 @FontConstraint annotation을 처리해줄 validator를 구현해주면 된다.

반드시 ConstraintValidtor 인터페이스를 구현해야한다.

여기서는 String 객체에 대해 유효성 검사를 진행할 것임을 지정했고, 그 String 객체가 null이 아니고, 사전에 지정된 폰트 종류중에 하나가 아니면 유효하다고 판단하는 isValid 메서드를 구현했다.(override 메서드)

package com.tistory.jeongpro.book.domain.dto;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import com.tistory.jeongpro.book.domain.entity.Book;
import com.tistory.jeongpro.book.validation.FontConstraint;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class AddBookRequestDto {

    @NotBlank(message="title is mandatory")
    @Size(max=100, message="title size must be less than 500 charactors")
    private String title;

    @NotNull(message = "author is not null")
    private String author;

    @NotNull(message = "font is not null")
    @FontConstraint(message = "this font cannot be used")
    private String font;

    public Book toEntity() {
        return Book.builder()
            .title(this.title)
            .author(this.author)
            .font(this.font)
            .build();
    }
}

이제 custom annotation을 적용해본다. 다음과 같이 font 속성을 DTO에 추가했고, @FontConstraint annotation을 적용했다. (toEntity메서드도 font를 추가했다.)

기존 controller 소스를 수정할 필요가 없으니 바로 테스트해본다.

{
    "title":"booksTitlebooksTitlebooksTitle",
    "author":"jeong",
    "font":"serif2"
}

postman을 이용해서 위의 값을 POST방식으로 보냈더니 다음과 같은 콘솔 로그가 찍혔다.

(serif2는 없는 폰트니까 아래와 같이 에러가 남)

code : FontConstraint
defaultMessage : this font cannot be used
objectName : addBookRequestDto

잘 적용된 것을 확인했다.

@PathVariable, @RequestParam은 어떻게 유효성 검사를 할까?

만약 DTO안에 멤버 변수 타입이 primitive type 이나 String 클래스 외에 또 다른 클래스가 있다면, 해당 클래스를 Validation하려면 그 DTO에 @Valid를 적용하고 해당 클래스에서 다시 annotation을 적용하면 된다. (아래 예시 참조)

package com.tistory.jeongpro.book.domain.dto;

import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import com.tistory.jeongpro.book.domain.entity.Book;
import com.tistory.jeongpro.book.validation.FontConstraint;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class AddBookRequestDto {

    @NotBlank(message="title is mandatory")
    @Size(max=100, message="title size must be less than 500 charactors")
    private String title;

    @NotNull(message = "author is not null")
    private String author;

    @NotNull(message = "font is not null")
    @FontConstraint(message = "this font cannot be used")
    private String font;

    @Valid
    private InnerObject innerObject;

    public Book toEntity() {
        return Book.builder()
            .title(this.title)
            .author(this.author)
            .font(this.font)
            .build();
    }
}

InnerObject라는 클래스를 만들었고, 이 클래스의 유효성을 검사하려면 @Valid 라고 annotation을 적용해야한다. 그리고 아래처럼 InnerObject라는 클래스 정의하는 부분에서 똑같이 검사에 적용할 annotation을 적용하면 된다.

@NoArgsConstructor
@Getter
@Setter
public class InnerObject {
    @NotNull
    @Max(value=10)
    @Min(value=0)
    private int count;
}

@Validated

@PathVariable과 @RequestParam으로 들어오는 값은 @Validated annotation을 컨트롤러 클래스에 적용하면 된다.

추가로 파라미터로 받는 부분에 원하는 조건을 갖는 annotation을 붙여 확인해본다.

package com.tistory.jeongpro.book.controller;

import javax.validation.ConstraintViolationException;
import javax.validation.constraints.Email;

import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Validated
public class UserController {
    @GetMapping("/users/{id}")
    public String getUserInformationByPathVariable(@PathVariable("id") @Email String id) {
        return "hello";
    }
    @GetMapping("/users")
    public String getUserInformationByRequestParameter(@RequestParam("id") @Email String id) {
        return "world";
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ConstraintViolationException.class)
    public Object exception(Exception e) {
        return e.getMessage();
    }
}

원래는 아래와 같은 에러결과가 리턴되지만 @ExceptionHandler를 통해 예외도 제어하면 좋은 처리를 할 수 있다.

참고 사이트

반응형