타입 변환과 포매팅을 사용해 유효성 검증하기

김종준·2023년 3월 9일
0

타입 변환과 포매팅을 사용해 유효성 검증하기

유효성 검증의 목적은 데이터 무결성을 확보해 애플리케이션 내 다른 레이어에서 사용 시 문제가 없도록 하는 것뿐만 아니라 사전 정의된 비즈니스 요구사항을 모두 충족하는지를 검증하는 데 있다.

애플리케이션 개발에서 데이터 유효성 검증은 늘 데이터 변환 및 포매팅과 함께 언급된다.

데이터 변환과 포메팅이 함께 언급되는 이유는 대부분 원본 데이터 형식이 애플리케이션에서 사용하는 데이터 형식과 다르기 때문이다.

스프링 타입 변환 시스템

PropertyEditor를 사용해 String 타입을 다른 타입으로 변환하기

import java.net.URL;

import org.joda.time.DateTime;

public class Singer {
  private String firstName;
  private String lastName;
  private DateTime birthDate;
  private URL personalSite;
  ...
}

위의 Singer 클래스의 birthDate 애트리뷰트에는 JodaTime이 제공하는 DateTime 클래스를 사용했다.

아래는 String 값을 JodaTime의 DateTime 타입으로 변환하는 사용자 정의 에디터 코드다.

public class DateTimeEditorRegistrar implements PropertyEditorRegistrar {
  private DateTimeFormatter dateTimeFormatter;
  
  public DateTimeEditorRegistrar(String dateFormatPattern) {
    dateTimeFormatter = DateTimeFormat.forPattern(dateFormatPattern);
  }
  
  @Override
  public void registerCustomEditors(PropertyEditorRegistry registry) {
    registry.registerCustomEditor(DateTime.class, new DateTimeEditor(dateTimeFormatter));
  }
  
  private static class DateTimeEditor extends PropertyEditorSupport {
    private DateTimeFormatter dateTimeFormatter;
    
    public DateTimeEditor(DateTimeFormatter dateTimeFormatter) {
      this.dateTimeFormatter = dateTimeFormatter;
    }
    
    @Override
    public void setAsText(String text) throws IllegalArgumentException {
      setValue(DateTime.parse(text, dateTimeFormatter));
    }
  }
}

DateTimeEditorRegistrar 클래스는 사용자 정의 PropertyEditor를 등록할 때 사용하는 PropertyEditorRegistrar 인터페이스를 구현했다.

그 뒤 String을 DateTime으로 변환하는 DateTimeEditor라는 내부 클래스를 정의했다.

스프링 타입 변환 소개

스프링 3.0부터 범용 타입 변환 시스템이 도입됐다.

해당 기능은 org.springframework.core.convert 패키지 아래에 포함돼 있다.

타입 변환 시스템은 PropertyEditor 기능의 대안을 제공하는 것 외에도 자바 타입과 POJO 간 변환에도 사용할 수 있다.

사용자 정의 컨버터 구현하기

public class StringToDateTimeConverter implements Converter<String, DateTime> {
  private static final String DEFAULT_DATE_PATTERN = "yyyy-MM-dd";
  private DateTimeFormatter dateFormat;
  
  private String datePattern = DEFAULT_DATE_PATTERN;
  
  public String getDatePattern() {
    return datePattern;
  }
  
  public void setDatePattern(String datePattern) {
    this.datePattern = datePattern;
  }
  
  @PostConstruct
  public void init() {
    dateFormat = DateTimeFormat.forPatter(datePattern);
  }
  
  @Override
  public DateTime convert(String dateString) {
    return dateFormat.parseDateTime(dateString);
  }
}

이 사용자 정의 컨버터는 Convert<Stirng, DateTime> 인터페이스를 구현한다.

Converter<String, DateTime> 인터페이스 컨버터가 String 타입을 DateTime 타입으로 변환한다는 것을 의미한다.

ConversionService 구성

PropertyEditor 대신 변환 서비스를 사용하려면 스프링의 ApplicationContext에서 org.springframework.core.convert.ConversionService 인터페이스의 인스턴스를 구성해야 한다.

@PropertySource("...")
@Configuration
@ComponetScan(basePackages = "...")
public class AppConfig {
  ...
  @Bean
  public ConversionServiceFactoryBean conversionService() {
    ConversionServiceFactoryBean conversionServiceFactoryBean = new ConversionServiceFactoryBean();
    Set<Converter> convs = new HashSet<>();
    convs.add(converter()); // 컨버터 추가
    conversionServiceFactoryBean.setConverters(convs); // 컨버터 등록
    conversionServiceFactoryBean.afterPropertiesSet();
    return conversionServiceFactoryBean;
  }
  
  @Bean
  StringToDateTimeConverter convertor() {
    StringToDateTimeConverter conv = new StringToDateTimeConverter();
    conv.setDatePattern(dateFormatPattern);
    conv.init();
    return conv;
  }
}

위의 구성에서는 스프링이 타입 변환 시스템을 사용할 수 있도록 ConversionServiceFactoryBean 클래스를 사용해서 conversionService 빈을 선언했다.

만약 변환 서비스 빈을 정의하지 않았다면 스프링은 PropertyEditor 기반 시스템을 사용한다.

타입 변환 서비스는 문자열, 숫자, 열거형, 컬렉션, 맵등 일반 타입 간 상호 변환 기능을 기본으로 제공한다.

또한, PropertyEditor를 기반으로 하는 시스템 내에서 프로퍼티로 제공된 String 타입 데이터를 특정 자바 타입으로 변환하는 기능도 제공한다.

conversionService 빈에는 String을 DateTime으로 변환하는 사용자 정의 컨버터가 구성되어 있다.

컨버터 서비스는 아래와 같이 사용할 수 있다.

ConversionService conversionService = ctx.get(ConversionService.class);

// 위에서 설명 및 설정하지 않았지만 
// 임의의 타입끼리도 변환할 수 있다.
// 아래는 임의의 타입끼리 변환하는 것을 나타낸 예제다.
AnotherSinger anotherSinger = conversionService.convert(john, AntotherSinger.class); 
// 기본 컨버터 사용 예제
String[] stringArray = conversionService.convert("a, b, c", String[].class);
Set<String> setString = conversionService.convert(listString, HashSet.class);

스프링에서 필드 포매팅하기

포매터 SPI에서 포메터를 구현하는 핵심 인터페이스는 org.springframework.format.Formatter<T> 인터페이스이다.

스프링은 자주 사용하는 타입의 포메팅에 사용할 수 있는 CurrencyFormatter, DateFormatter, NumberFormatter, PercentFormatter 등 몇 가지 인터페이스 구현체를 제공한다.

사용자 정의 포메터 구현하기

스프링의 org.springframework.format.support.FormattingConversionServiceFactoryBean 클래스를 상속해 사용자 정의 포매터를 작성할 수 있다.

FormattingConversionServiceFactoryBean 클래스는 FormattingConversionService 클래스에 쉽게 접근할 수 있도록 도와주는 팩터리 클래스다.

FormattingConversionService 클래스는 각 필드 타입에 정의된 포매팅 규칙에 맞춰 필드 포매팅을 수행할 뿐 아니라 타입 변환 시스템도 제공한다.

아래는 FormattingConversionServiceFactoryBean을 상속하며, JodaTime의 DateTime 타입을 포매팅하는 사용자 정의 포메터가 포함된 사용자 정의 클래스의 코드다.

@Componet
public calss ApplicationConversionServiceFactoryBean extends FormattingConversionServiceFactoryBean {
  ...
	private DateTimeFormatter dateFormat;
  private Set<Formatter<?>> formatters = new HashSet<>();
  
  public String getDatePattern() {
    return datePattern;
  }
  
  @Autowired(required = false)
  public void setDatePattern(String datePattern) {
    this.datePattern = datePattern;
  }
  
  @PostConstruct
  public void init() {
    dateFormat = DateTimeFormat.forPattern(datePattern);
    formatters.add(getDateTimeFormatter());
    setFormatters(formatters);
  }
  
  public Formatter<DateTime> getDateTimeFormatter() {
    return new Formatter<DateTime>() {
      @Override
      public DateTime parse(String dateTimeString, Locale locale) throws ParseException {
        return dateFormat.parseDateTime(dateTimeString);
      }
      
      @Override
      public String print(DateTime dateTime, Locale locale) {
        return dateFormat.print(dateTime);
      }
    }
  }
}

이렇게 만든 포메터는 아래와 같이 사용된다.

ConversionService conversionService = ctx.getBean("conversionService", ConversionService.class);
// String 타입을 DateTime 타입으로 변환
conversionService.converet(john.birthDate(), String.class);

스프링에서 유효성 검증

애플리케이션에서 유효성 검증은 중요한 부분을 담당한다.

도메인 객체에 유효성 검증 규칙을 적용해 모든 비즈니스 데이터가 제대로 구성됐는지, 또한 모든 비즈니스 정의를 충족하는지를 확실히 점검할 수 있다.

어디에서 데이터가 오든(웹 애플리케이션의 사용자 입력, 원격 애플리케이션에서 웹 서비스로 받거나, JMS로 받거나, 파일에서 읽거나) 상관없이 모든 유효성 검증 규칙이 중앙 저장소에 통합 관리되어 동일한 유형의 데이터에는 동일한 규칙 집합이 적용되는 것이 가장 이상적일 것이다.

데이터를 검증하려면 검증 전 각 타입에 정의된 포매팅 규칙에 따라 해당 데이터를 POJO로 변환해야 한다.

예를들어 데이터 바인딩이 끝나고 도메인 객체가 생성되면 생성된 객체를 대상으로 유효성 검증을 하면 검증 도중 발견된 에러는 반환되어 사용자에게 표시된다.

스프링 Validator 인터페이스 사용

스프링의 Validator 인터페이스를 구현하는 클래스를 작성함으로써 유효성 검증 로직을 개발할 수 있다.

아래는 이전의 Singer 클래스에서 이름 필드의 값이 비어 있으면 안된다고 할때 객체가 해당 규칙을 따르는지 검증해보려면 사용자의 정의 검증기를 생성하면 되며 사용자 정의 검증기 클래스 코드다.

@Componet
public class SingerValidator implements Validator  {
  @Override
  public boolean supports(Class<?> clazz) {
    return Singer.class.equals(clazz);
  }
  
  @Override
  public void validate(Object obj, Errors e) {
    ValidationUtils.rejectIfEmpty(e, "firstName", "firstName.empty");
  }
}

검증 클래스는 Validator 인터페이스를 구현하면서 해당 인터페이스에 정의된 두 메서드를 구현한다.

두 메서드 중 하나인 supports()인자로 전달된 클래스의 타입이 검증기가 검증할 수 있는 타입인지 여부를 나타낸다.

또 다른 메서드인 validate()전달된 객체를 대상으로 유효성을 검증한다.

이렇게 만든 검증기 클래스는 아래와 같이 사용한다.

ValidationUtils.invokeValidator(singerValidator, singer, result);

JSR-349 빈 유효성 검증

스프링은 버전 4부터 JSR-349를 완벽히 지원한다.

빈 유효성 검증 APIjavax.validation.constraints 패키지 아래에 있는 자바 애너테이션(ex @NotNull)을 도메인 객체에 적용해 제약사항 집합을 정의한다.

또한 사용자 정의 검증기를 개발한 뒤 애너테이션으로 적용할 수도 있다.

빈 유효성 검증 API를 사용하면 개발 중인 애플리케이션이 특정 유효성 검증 서비스 공급자에 결합하지 않게 된다.

빈 유효성 검증 API를 사용하면 도메인 객체에 표준 애너테이션 및 유효성 검증 로직을 구현한 API를 사용할 수 있으며, 유효성 검증 서비스 공급자가 누구인지 알 필요가 없다.

스프링은 빈 유효성 검증 API를 완벽히 지원한다.

주요 기능에는 유효성 검증 제약사항을 정의하는 JSR-349 표준 애너테이션, 사용자 정의 검증기, 스프링 ApplicationContext 내에서 JSR-349 유효성 검증 구성이 포함돼 있다.

객체 프로퍼티에 유효성 검증 제약사항 정의하기

JSR-349 유효성 검증 API를 사용한 예시다.

public class Singer {
  @NotNull
  @Size(min=2, max=60)
  private String firstName;
  
  private String lastName;
  
  @NotNull
  private Genre genre;
  
  ....
}
스프링에서 빈 유효성 검증 구성

스프링 ApplicationContext에서 빈 유효성 검증 API를 구성하려면 스프링 구성에서 org.springframework.validation.beanvalidation.LocalValidatorFactoryBean 타입 빈을 정의한다.

@Configuration
@ComponetScan(basePackages = "...")
public class AppConfig {
  @Bean
  LocalValidatorFactoryBean validator() {
    return new LocalValidatorFactoryBean();
  }
}

빈 유효성 검증 기능을 사용하려면 LocalValidatorFactoryBean 타입인 빈만 정의하면 된다.

스프링은 기본적으로 클래스패스에서 하이버네이트 검증기 라이브러리가 존재하는지 찾는다.

@Service
public class SingerValidationService {
  @Autowired
  private Validator validator;
  
  public Set<ContraintViolation<Singer>> validateSinger(Singer singer) {
    return validator.validate(singer);
  }
}

이 검증 서비스 클래스에는 javax.validation.Validator 인스턴스가 주입됐다.

LocalValidationFactoryBean을 정의한 뒤에는 애플리케이션 내 어디에서나 Validator 인터페이스를 사용할 수 있는 핸들을 가져올 수 있다.

POJO에서 유효성 검증을 하려면 Validator.validate() 메서드를 호출한다.

유효성 검증 결과는 ConstraintViolation<T> 인터페이스의 Set 타입으로 반환된다.

사용자 정의 검증기 만들기

사용자 정의 검증기를 만들려면 두 단계 절차가 필요하다.

첫 번째로 검증기로 사용할 Annotation 타입을 생성해야 한다.

두 번째로 검증 로직을 구현하는 클래스를 개발해야 한다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Constraint(validatedBy=CountrySingerValidator.class)
@Documented
public @interface CheckCountrySinger {
  String message() default "...";
  Class<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
}

@Target(ElementType.TYPE) 애너테이션은 해당 애너테이션이 클래스 레벨에만 적용됨을 의미한다.

@Constraint 애너테이션은 해당 클래스가 검증기임을 나타내며 validatedBy 애트리뷰트는 지정한 클래스 검증 로직을 제공함을 나타낸다.

클래스 몸통부에는 메서드 형태로 세 가지 애트리뷰트를 정의했는데, 이들 애트리뷰트는 다음과 같다.

  • message 제약사항 위반이 발생했을 때 반환할 메시지
  • groups 애트리뷰트는 적용 가능한 유효성 검증 그룹을 지정
    다른 그룹에 검증기를 할당하고 특정 그룹을 대상으로 유효성 검증을 할 수 있다.
  • payload 애트리뷰트는 페이로드 객체를 지정할 수 있다.
public class CountrySingerValidator implements ConstraintValidator<CheckCountrySinger, Singer> {
  @Override
  public void initialize(CheckCountrySinger constraintAnnotation) {
    
  }
  
  @Override
  public boolean isValid(Singer singer, ConstraintValidatorContext context) {
    boolena result = true;
    if(singer.getGenre() != null &&
      	(singer.isCountrySinger() &&
        	(signer.getLastName() == null || signer.getGender() == null))) {
      result = false;
    }
    return result;
  }
}

CountrySingerValidator는 CounstraintValidator<CheckCountrySinger, Singer> 인터페이스를 구현한다.

이 인터페이스는 구현한 검증기가 Singer 클래스에 적용된 CheckCountrySinger 애너테이션을 검사함을 의미한다.

이렇게 유효성 검증을 활성화하려면 다음 코드처럼 Singer 클래스에 @CheckCountrySinger 애너테이션을 적용한다.

@CheckCountrySinger
public class Singer {
  @NotNull
  @Size(min=2, max=60)
  private String firstName;
  
  private String lastName;
  
  @NotNull
  private Genre genre;
  
  ....
    
  public boolean isCountrySinger() {
    return genre == Genre.COUNTRY;
  }
}

사용자 정의 검증에 @AssertTrue 사용하기

사용자 정의 검증기를 구현하는 것 이외에 빈 유효성 검증 API에서 사용자 정의 검증을 할 수 있는 또 다른 방법으로 @AssertTure 애너테이션을 사용하는 방법이 있다.

public class Singer {
  @NotNull
  @Size(min=2, max=60)
  private String firstName;
  
  private String lastName;
  
  @NotNull
  private Genre genre;
  
  ....
  @AssertTure(message="...")
  public boolean isCountrySinger() {
    boolean result = true;
    if (genere != null &&
       		(genre.equals(Genre.COUNTRY) &&
          	(gender == null || lastName == null))) {
      result = false;
    }
    return result;
  }
}

유효성 검증이 수행되면 공급자가 검사 메서드를 호출해 결과가 참인지 확인한다.

JSR-349는 거짓이어야 하는 조건을 체크할 때 사용하는 @AssertFalse 메서드도 제공한다.

사용자 정의 검증에서 고려사항

사용자 정의 검증기 사용법과 @AssertTrue 애너테이션 사용법 중 어떤 접근법을 사용해야 할까?

일반적으로 @AssertTrue 메서드는 구현하기 쉬우며 도메인 객체의 코드 내에서 유효성 검증 규칙을 바로 확인할 수 있다.

하지만 좀 더 복잡한 로직에 사용할 유효성 검증기라면 사용자 정의 검증기를 구현하는 것이 올바른 접근 방법이다.

0개의 댓글