BACKEND/SPRING

스프링부트 유효성 검사와 예외처리 - 유효성 검사

우진하다 2023. 8. 27. 23:17

애플리케이션의 유효성 검사(validation)는 중요한 부분입니다. 
사용자로부터 입력받은 데이터의 유효성을 검사하여 데이터의 정확성, 안정성, 및 신뢰성을 보장하려고 합니다. 
스프링 부트(Spring Boot)에서도 유효성 검사를 위한 다양한 기능과 라이브러리(예: JSR 380: Bean Validation 2.0)를 제공합니다.

 

일반적인 애플리케이션 유효성 검사의 문제점.

데이터 검증 로직의 문제점

분산된 로직: 계층별로 유효성 검사를 진행하면 검증 로직이 각 클래스별로 분산되어 있어 관리하기 어렵습니다.
중복된 코드: 검증 로직에는 의외로 중복이 많이 발생하여, 여러 곳에서 유사한 기능의 코드가 반복적으로 나타날 수 있습니다.
검증 코드의 길이: 검증해야 할 값이 많아지면, 검증 로직이 길어져 코드의 복잡도가 증가하고 가독성이 떨어집니다.
이러한 문제점은 코드의 관리와 유지 보수를 어렵게 만들며, 실수나 오류의 가능성을 높일 수 있습니다.

Bean Validation

Bean Validation은 이러한 문제점을 해결하기 위해 2009년부터 자바 진영에서 제공하는 데이터 유효성 검사 프레임워크입니다.

어노테이션 기반: Bean Validation은 어노테이션을 사용하여 다양한 데이터 유효성 규칙을 적용할 수 있습니다. 
예를 들어, @NotNull, @Size, @Min, @Max 등의 어노테이션을 사용해 필드나 메소드에 유효성 규칙을 지정할 수 있습니다.

도메인 모델과의 통합: 유효성 검사 로직을 DTO나 도메인 모델에 직접 연결함으로써, 
각 계층에서 검증 로직을 재사용하고 중복을 줄일 수 있습니다. 이로 인해 유효성 검사 로직이 한 곳에 집중되어 관리가 용이해집니다.

가독성 향상: 어노테이션 기반의 유효성 검사로 인해, 코드의 가독성이 향상되며 유효성 규칙의 의미가 명확해집니다.

Bean Validation을 사용하면, 코드의 중복을 최소화하고 가독성을 향상시키면서도 강력한 유효성 검사 기능을 제공받을 수 있습니다.

 

Hibernate Validator

Hibernate Validator는 Bean Validation의 구현체입니다. 이는 데이터 유효성 검사를 위한 자바 명세의 실제 구현을 제공합니다.

스프링 부트는 이 Hibernate Validator를 그 유효성 검사 표준으로 사용하고 있습니다. 
Hibernate Validator는 JSR-303 명세의 구현체로써, 도메인 모델 내의 필드값 검증을 어노테이션을 통해 직관적이고 효과적으로 수행할 수 있게 도와줍니다.

이를 통해 개발자는 각 필드에 적용할 유효성 규칙을 어노테이션 형태로 선언함으로써, 
중복 코드의 작성 없이 통일된 방식으로 데이터의 유효성을 체크할 수 있게 됩니다.

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

public class User {

    @NotBlank(message = "이름은 필수 항목입니다.")
    private String name;

    @Email(message = "올바른 이메일 형식이 아닙니다.")
    private String email;

    @Size(min = 8, max = 14, message = "비밀번호는 8자리 이상, 14자리 이하로 설정해주세요.")
    private String password;

    // 생성자, getter, setter 등 기타 메서드들...

}

 

스프링 부트에서의 유효성 검사

스프링 부트용 유효성 검사 관련 의존성 추가

원래 스프링 부트의 유효성 검사 기능은 spring-boot-starter-web에 포함되어 있었습니다. 
그러나 스프링 부트 2.3 버전부터는 별도의 라이브러리로 제공하고 있어, 프로젝트에 명시적으로 추가해야 합니다.

// Maven (pom.xml)

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>


// Gradle (build.gradle)

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

위의 의존성을 프로젝트에 추가함으로써 스프링 부트 환경에서 Hibernate Validator를 통한 Bean Validation 기능을 사용할 수 있게 됩니다. 이렇게 추가된 유효성 검사 라이브러리를 활용하면, 도메인 모델이나 DTO에 정의된 유효성 규칙에 따라 자동으로 데이터의 유효성 검사가 수행되며, 규칙에 위반되는 경우 에러 응답을 반환하게 됩니다.

스프링 부트의 유효성 검사

계층 간의 데이터 전송
스프링 부트 프로젝트에서는 이러한 계층 간의 데이터 전송에 대체로 DTO (Data Transfer Object) 객체를 활용합니다. DTO는 계층 간의 데이터 전송을 담당하며, 특정 계층의 로직이나 상태에 종속되지 않는 순수한 데이터 객체입니다.

유효성 검사의 중요성
DTO를 통한 데이터 전송은 사용자의 입력을 애플리케이션 내부로 가져오거나, 애플리케이션의 내부 데이터를 외부로 전달하는 중요한 역할을 합니다.
이런 데이터 전송 과정에서 유효하지 않은 데이터가 시스템 내부로 들어오는 것을 방지하기 위해 유효성 검사가 필요합니다.
따라서, 스프링 부트에서의 유효성 검사는 주로 DTO 객체를 대상으로 수행됩니다. DTO 객체에 정의된 유효성 규칙을 통해 데이터의 정합성을 보장하며, 이를 통해 애플리케이션의 안정성과 데이터의 품질을 높일 수 있습니다.

 

Bean Validation 어노테이션 정리

문자열 검증

Null 검증 - @Null: 해당 값이 null만을 허용합니다.
NotNull 검증 - @NotNull: null을 허용하지 않습니다.
Empty 검증 - @NotEmpty: null과 빈 문자열("")을 허용하지 않습니다. / @NotBlank: null, 빈 문자열(""), 공백 문자를 모두 허용하지 않습니다.

최댓값 / 최솟값 검증(주로 숫자 타입에 사용)

@DecimalMin(value = "number"): 지정된 "number" 이상의 값을 허용합니다.
@DecimalMax(value = "number"): 지정된 "number" 이하의 값을 허용합니다.
@Min(value = number): 지정된 number 이상의 값을 허용합니다.
@Max(value = number): 지정된 number 이하의 값을 허용합니다.

값의 범위 검증

@Positive: 양수만 허용합니다.
@PositiveOrZero: 0을 포함한 양수만을 허용합니다.
@Negative: 음수만 허용합니다.
@NegativeOrZero: 0을 포함한 음수만 허용합니다.

시간에 대한 검증

@Future: 현재보다 미래의 날짜/시간만 허용합니다.
@FutureOrPresent: 현재 포함, 미래의 날짜/시간만 허용합니다.
@Past: 현재보다 과거의 날짜/시간만 허용합니다.
@PastOrPresent: 현재 포함, 과거의 날짜/시간만 허용합니다.

이메일 검증

@Email: 이메일 형식을 검사합니다.

자릿수 범위 검증

@Digits(integer = intVal, fraction = fracVal): intVal 길이의 정수부와 fracVal 길이의 소수부를 가진 숫자만 허용합니다.

Boolean 검증

@AssertTrue: 값이 true인지 확인합니다.
@AssertFalse: 값이 false인지 확인합니다.

문자열 길이 검증

@Size(min = number, max = number): 지정 범위 이상, 이하의 범위를 허용합니다.

정규식 검증

@Pattern(regexp = "정규식 패턴"): 주어진 정규식 패턴과 일치하는 값만을 허용합니다.

import javax.validation.constraints.*;

public class UserDTO {

    @NotNull(message = "이메일은 필수입니다.")
    @Email(message = "유효한 이메일 형식을 입력해주세요.")
    private String email;
    
    @Size(min = 8, max = 20, message = "비밀번호는 8~20자 사이여야 합니다.")
    private String password;

    @PositiveOrZero(message = "나이는 0 이상이어야 합니다.")
    private int age;

    @Digits(integer = 3, fraction = 2, message = "최대 3자리 정수와 2자리 소수를 허용합니다.")
    private Double salary;

    @Future(message = "미래의 날짜를 선택해주세요.")
    private LocalDate appointmentDate;

    @Pattern(regexp = "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", message = "유효한 SSN 형식을 입력해주세요 (예: 123-45-6789).")
    private String ssn;

    // getter, setter, etc...
}

 

import org.springframework.http.ResponseEntity;
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 javax.validation.Valid;

@RestController
public class UserController {

    @PostMapping("/register")
    public ResponseEntity<String> registerUser(@Valid @RequestBody UserDTO userDTO, BindingResult result) {
        if(result.hasErrors()) {
            // 유효성 검사에 실패한 경우 에러 메시지 반환
            return ResponseEntity.badRequest().body(result.getAllErrors().get(0).getDefaultMessage());
        }
        
        // 유효성 검사에 성공한 경우, 사용자 등록 로직 수행 (여기서는 단순히 성공 메시지 반환)
        return ResponseEntity.ok("사용자 등록 성공");
    }
}

이 예제 코드는 UserDTO 클래스에 유효성 검사를 위한 어노테이션들을 적용한 것입니다. 각 어노테이션에는 message 속성을 통해 유효하지 않을 경우 출력할 메시지도 설정할 수 있습니다.

이러한 DTO 객체는 주로 클라이언트로부터 받은 요청 데이터의 유효성을 검사하기 위해 사용됩니다. 실제로 이 DTO를 사용하면서 유효성 검사를 수행하려면 스프링의 컨트롤러에서 @Valid 어노테이션을 함께 사용하면 됩니다.

@Valid 어노테이션을 통해 UserDTO 객체의 유효성 검사를 수행합니다.
유효성 검사의 결과는 BindingResult 객체에 담겨져 있습니다.
result.hasErrors()를 통해 유효성 검사에 문제가 있는지 확인할 수 있습니다.
result.getAllErrors().get(0).getDefaultMessage()를 통해 첫 번째 에러 메시지를 가져올 수 있습니다.

 

@Validated 활용

스프링에서는 기본적으로 Java의 Bean Validation을 지원하는 @Valid 어노테이션을 사용하여 요청 바디나 폼의 객체에 대한 유효성 검사를 수행할 수 있습니다. 그러나 스프링은 @Validated라는 별도의 어노테이션도 제공합니다. 이 어노테이션은 @Valid의 기능을 확장하여 몇 가지 추가 기능을 제공하는데, 그 중 하나가 검증 그룹입니다.

기본 사용

@Validated는 메서드 레벨, 파라미터 레벨, 혹은 클래스 레벨에서 사용될 수 있습니다. 이는 객체 내부의 유효성 검사 어노테이션이 적용된 필드를 검사하기 위해 사용됩니다.

@RestController
public class UserController {

    @PostMapping("/users")
    public ResponseEntity<UserDTO> createUser(@Validated @RequestBody UserDTO userDTO) {
        // ... 처리 ...
    }
}

 

검증 그룹 사용

검증 그룹은 특정 상황에서만 일부 검증 규칙을 적용하고 싶을 때 유용하다. 예를 들어, 사용자를 등록할 때와 사용자 정보를 수정할 때 다른 검증 규칙을 적용하고 싶을 수 있다.

public interface OnCreate {}
public interface OnUpdate {}
public class UserDTO {

    @NotNull(groups = OnCreate.class)
    private Long id;

    @NotNull
    @Size(min = 5, max = 100)
    private String name;

    // ... 다른 필드와 getter, setter ...
}
@RestController
public class UserController {

    @PostMapping("/users")
    public ResponseEntity<UserDTO> createUser(
    @Validated(OnCreate.class) @RequestBody UserDTO userDTO
    ) {
        // ... 사용자 생성 로직 ...
    }

    @PutMapping("/users/{id}")
    public ResponseEntity<UserDTO> updateUser(
    @PathVariable Long id, 
    @Validated(OnUpdate.class) @RequestBody UserDTO userDTO
    ) {
        // ... 사용자 정보 수정 로직 ...
    }
}

 

 커스텀 Validation 추가

스프링 프레임워크는 유효성 검사를 위한 많은 어노테이션들을 제공합니다. 그러나 때로는 프로젝트나 비즈니스 요구 사항에 맞춰 특정한 검증 규칙을 정의해야 할 경우가 있습니다. 이 때, 커스텀 유효성 검사를 사용할 수 있습니다.

@Target 어노테이션
이 어노테이션은 커스텀 어노테이션을 어디서 사용할 수 있는지 정의합니다.
아래 예제에서는 필드에서만 사용할 수 있게 설정됨.

@Retention 어노테이션
이 어노테이션은 커스텀 어노테이션의 지속 범위를 정의합니다.
RUNTIME: JVM이 계속 참조할 수 있음.
CLASS: 컴파일러가 클래스를 참조할 때까지만 유지.
SOURCE: 컴파일 전까지만 유지.

@Constraint 어노테이션
검증 로직을 수행하는 ConstraintValidator 구현체와 연결합니다.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TelephoneValidator.class)
public @interface Telephone {

    String message() default "Invalid telephone number";

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

    Class<? extends Payload>[] payload() default {};
}
public class TelephoneValidator implements ConstraintValidator<Telephone, String> {

    @Override
    public void initialize(Telephone telephone) {
        // 초기화 로직 (필요한 경우에만 구현)
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // null 값 검증은 @NotNull과 같은 다른 어노테이션을 사용
        }

        // 실제 전화번호 검증 로직
        return value.matches("[0-9()-]*");
    }
}