임대일

스프링(Spring) DTO, Service에서 유효성 검사: @NotBlank, @Pattern, @Size(feat. Validation, @Email, @NotEmpty) 본문

스프링/지식

스프링(Spring) DTO, Service에서 유효성 검사: @NotBlank, @Pattern, @Size(feat. Validation, @Email, @NotEmpty)

limdae94 2024. 7. 31. 14:58

1. 유효성 검사 시점

DTO 레코드 타입을 작성하다가 유효성 검사를 어느 시점에 수행해야 가장 최적인지 고민하면서 공부한 내용을 정리한 글이다. 좀 더 구체적인 고민하게 된 배경을 살펴보고 유효성 검사에 대표적인 어노테이션들에 대해 학습한다.

 

클라이언트가 회원 가입에 필요한 정보를 포함하여 서버로 요청을 보내는 시나리오에서 생긴 고민이다. 해당 시나리오의 프로젝트는 계층형 아키텍처(Layerd Architecture) 구조로 Controller, Service, Repository, Model로 구성되어 있다. 이러한 계층형 아키텍처 구조에서 클라이언트로부터 받은 요청 데이터에 대해 유효성 검사를 어느 시점에, 어떻게 작성해야 가장 적절한지 고민하게 됐다.

 

2. DTO와 Service에서 유효성 검사

결론부터 말하자면, 최적의 유효성 검사를 위해서는 DTO, 서비스(Service)에서 적절한 수준의 검사를 수행하는 것이 바람직하다. 그리고 복잡한 유효성 검사는 별도의 유효성 검사 클래스를 생성하여 모듈화하는 것을 권장한다. 간단하게 정리하자면 아래와 같다.

  1. DTO: 데이터 형식 및 기본 유효성 검사를 수행하여 서버에 도달하기 전에 잘못된 데이터를 차단
  2. 서비스: 서비스에서 비즈니스 로직에 따른 추가적인 검사를 수행
  3. 유효성 검사 클래스: 복잡한 유효성 검사는 별도의 유효성 검사 클래스를 생성하여 모듈화

 

3. DTO에서 유효성 검사

DTO(Data Transfer Object)는 클라이언트로부터 받은 데이터를 서버로 전달할 때 사용되는 객체이다. DTO에서 기본적인 형식의 유효성 검사를 수행하는 것이 바람직하다. 그 이유는 클라이언트로부터 잘못된 데이터가 서버의 비즈니스 로직에 도달하는 것을 방지할 수 있기 때문이다. 예를 들어 레코드 타입의 DTO에서 수행되는 유효성 검사 코드는 다음과 같다.

package com.book.hobonichi.member.model;

import com.book.hobonichi.member.model.entity.Member;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record MemberDto(
        @NotBlank(message = "이메일: 이메일은 필수 정보입니다.")
        @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$", message = "이메일: 유효한 이메일 주소를 입력해주세요.")
        @Size(min = 6, max = 42, message = "이메일: 유효한 이메일 주소를 입력해주세요.")
        String email,

        @NotBlank(message = "비밀번호: 필수 정보입니다.")
        @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}"
                , message = "비밀번호: 8~20자 영문 대소문자, 숫자, 특수문자를 조합하여 작성해야 합니다.")
        @Size(min = 8, max = 20, message = "비밀번호: 8~20자 영문 대소문자, 숫자, 특수문자를 조합하여 작성해야 합니다.")
        String password,

        @NotBlank(message = "휴대전화번호: 필수 정보입니다.")
        @Pattern(regexp = "^[0-9]{10,11}$", message = "휴대전화번호: 10~11자 숫자만 입력해주세요.")
        @Size(min = 10, max = 11, message = "휴대전화번호: 10~11자 숫자만 입력해주세요.")
        String phone,

        @Size(min = 0, max = 400, message = "자기소개: 400자 이하로 입력해주세요.")
        String content,

        String image
) {

    public Member toEntity(MemberDto memberDto) {
        return Member.builder()
                .email(memberDto.email)
                .password(memberDto.password)
                .phone(memberDto.phone)
                .content(memberDto.content)
                .image(memberDto.image)
                .build();
    }
}

 

 

@NotBlank

  • 적용대상: 문자열
  • 적용시점: 문자열이 null이 아니며 값이 비어있지 않고 공백 문자만으로 이루어져 있지 않음을 확인할 때 사용한다.

 

@NotEmpty

  • 적용대상: 문자열, 컬렉션, 맵, 배열 등
  • 적용시점: 값이 null이 아니며 컬렉션, 배열, 맵 등 다양한 타입의 데이터에 대해 비어있지 않음을 확인할 때 사용한다. 길이가 0이 아니어야 한다.

두 어노테이션 선택은 다음과 같은 기준으로 정할 수 있다.

  • @NotEmpty는 주로 컬렉션, 맵, 배열과 같이 비어있지 않은지 확인해야 할 때 사용한다.
  • @NotBlank는 문자열에 대해 비어있지 않고 공백이 아닌 실제 문자가 포함되어 있는지를 확인할 때 사용한다.

따라서, 이름, 비밀번호, 이메일, 전화번호와 같은 문자열 필드에는 @NotBlank를 사용하는 것이 더 적절하다. @NotBlank 어노테이션은 공백 문자만 있는 문자열을 허용하지 않기 때문에 더 강력한 유효성 검사를 제공한다.

 

@Email

  • 적용대상: 문자열
  • 적용시점: 이메일 형식에 맞는 데이터인지 검사할 때 사용한다. 그러나 문제가 많기 때문에 사용하는 것을 권장하지 않는다.

 

@Size

  • 적용대상: 문자열의 길이
  • 적용시점: 문자열의 최소 길이 혹은 최대 길이의 제한을 설정이 필요할 때 사용한다.

 

@Pattern

  • 적용대상: 문자열
  • 적용시점: 이메일, 전화번호 등 문자열이 특정 정규 표현식과 일치하는지 검사가 필요할 때 사용한다.

@Pattern을 사용하여 정규 표현식으로 최소 길이와 최대 길이를 지정할 수 있지만, 이는 가독성과 유지보수성 측면에서 비효율적일 수 있다. 따라서 문자열 길이는 @Size를 사용하여 문자열의 최소 길이와 최대 길이를 지정하는 것이 바람직하다.

 

지금까지 소개된 @NotBlank, @NotEmpty, Size, @Pattern은 어떤 어노테이션이 먼저 실행될지는 보장하지 않는다. 이 중 하나라도 실패하면 나머지 어노테이션의 검사는 수행하지 않을 수 있다. 예를 들어 @Size에서 실패하면 @Pattern 검사는 수행하지 않는다. 어노테이션 순서를 명시적으로 제어하고 싶은 경우에는 별도로 커스텀 Validator를 작성하거나 그룹을 사용해야 한다. 그러나 특별한 경우가 아니라면 유효성 검사 어노테이션 제어를 하지 않는다.

 

4. DTO에서 유효성 검사 결론

1. 성능 문제
여러 유효성 검사 어노테이션을 사용하는 것이 성능에 미치는 영향은 매우 미미하며, 데이터의 유효성을 보장하는 것이 더 중요하다.

 

2. 어노테이션 순서
실행 순서는 프레임워크에 따라 다를 수 있으나, 일반적으로 모든 어노테이션이 함께 실행된다.

 

3. 구체적 사용 이유
각 어노테이션은 서로 다른 유효성 검사를 수행하며, 함께 사용함으로써 더욱 강력하고 명확한 유효성 검사를 할 수 있다. 따라서, 유효성 검사 목적에 맞는 어노테이션을 적절히 조합하여 사용하는 것이 좋다.

 

 

5. 서비스에서 유효성 검사

DTO에서 기본적인 유효성 검사를 마친 후, 서비스 계층에서는 비즈니스 로직에 따른 추가적인 유효성 검사를 수행할 수 있다. 예를 들어, 이메일 중복 확인이나 비밀번호의 복잡성 검사 등을 포함할 수 있다.

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public void registerUser(UserRegistrationDTO userDto) throws ValidationException {
        validateEmail(userDto.getEmail());
        validatePassword(userDto.getPassword());
        // 유저 등록 로직
    }

    private void validateEmail(String email) throws ValidationException {
        if (userRepository.existsByEmail(email)) {
            throw new ValidationException("Email is already in use");
        }
    }

    private void validatePassword(String password) throws ValidationException {
        // 비밀번호 복잡성 검사 로직 (예: 특수문자 포함 여부 등)
    }
}

 

 

6. 별도의 유효성 검사 클래스 생성

유효성 검사가 복잡하거나 여러 서비스에서 재사용될 필요가 있을 경우, 별도의 유효성 검사 클래스를 생성하여 유효성 검사를 모듈화할 수 있다. 이렇게 하면 코드의 재사용성과 유지보수성이 향상된다.

 

@Component
public class UserValidator {

    @Autowired
    private UserRepository userRepository;

    public void validate(UserRegistrationDTO userDto) throws ValidationException {
        validateEmail(userDto.getEmail());
        validatePassword(userDto.getPassword());
    }

    private void validateEmail(String email) throws ValidationException {
        if (userRepository.existsByEmail(email)) {
            throw new ValidationException("Email is already in use");
        }
    }

    private void validatePassword(String password) throws ValidationException {
        // 비밀번호 복잡성 검사 로직
    }
}

 

결론

최적의 유효성 검사를 위해서는 각 단계에서 적절한 수준의 검사를 수행하는 것이 중요하다.

  1. DTO에서 형식 및 기본 유효성 검사를 수행하여 서버에 도달하기 전에 잘못된 데이터를 차단한다.
  2. 서비스에서 비즈니스 로직에 따른 추가적인 검사를 수행한다.
  3. 복잡한 유효성 검사는 별도의 유효성 검사 클래스를 생성하여 모듈화한다.

이러한 접근 방식을 통해 유효성 검사를 효율적으로 관리하고, 코드의 유지보수성과 확장성을 높일 수 있다.