Spring

ConstraintValidator 그리고 유효성 검사에 대한 고민..

voider 2022. 5. 16. 21:46

스프링에서는 JSR303 기반 애노테이션 기반으로, 일관성 있는 유효성 검증을 진행할 수 있다.

@NotNull , @NotEmpty , @Email 등은 검증 가능하지만, 비즈니스 요구사항이 따르는 유효성 검증은 별도로 해야 한다.

예시

차랑 예약 시스템이고 예약 기간에 대한 유효성 검증이 필요하다. 검증 조건은 간략하게 두 가지 정도로 한다.

  1. 존재하는 주차장이어야 한다.
  2. 퇴차일시가 입차일시보다 앞이면 안 된다.
  3. 예약일수가 내부적으로 정해진 최대 예약 일수를 초과해서는 안 된다.

데이터는 아래와 같은 형태로 들어온다.

data class 차량예약DTO(
    var zoneId: Int = 0, //예약할 주차장ID
    var carNo: String = "",
    var startDateTime: LocalDateTime,
    var endDateTime: LocalDateTime,
    var mobileNo: String? = null,
    var visitReason: String? = null,
)

DTO에서 엔티티로 변환하기 전에 두 번째 비즈니스 요구사항을 검증할 수 있다.

fun toEntity(): Entity {
    ...
    if(startDateTime > endDateTime)
        throw RuntimeException("...");
    ...
}

그런데 첫 번째(존재하는 주차장인가?)와 세 번째(최대일수를 초과하는가?) 비즈니스 요구사항은 검증할 수 없다. 기존 설정값이 어떻게 되는지 현재 시점에서 알 수 없기 때문이다.

그럼 선택지는 두 개다.

  • 현재 단계에서 검증할 수 있는 것만 검증하고 나머지는 미룬다.
  • 검증을 나중으로 미룬다.

여기서 검증을 나중으로 미루는 게 더 나은 선택일 것 같다. 왜냐하면, 2번 요구사항만 검증하고 1, 3번은 나중으로 미루는 것은 유효성 검증 로직이 분산된다. 결국 비즈니스 요구사항 검증이라는 하나의 테두리로 묶을 수 있는 로직을 따로따로 처리하는 것은 한 곳에서 몰아서 처리하는 것보다 이해하기 어렵기 때문이다.

그러면 이것을 도메인 로직에서 처리할 수 있다. .

class Service {
    ...
    fun process(dto: DTO) {
        val zone = zoneRepo.findByIdOrNull(dto.id) ?: throw RuntimeException("..")
        val entity = dto.toEntity()
        entity.checkValidReservation() // 유효한 검증인지 엔티티 내부에서 검증
    }
}

class Entity {
    ...
    fun checkValidReservation() {
        zone.checkTime(startDateTime, endDateTime)
    }
}

class Zone() {
    fun checkTime(startDateTime: LocalDateTime, endDateTime: LocalDateTime) {
    val minimumDateTime = getMinimumDateTime()
    val maximumDateTime = getMaximumDateTime()
    val df = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
    //신청일이 최대신청가능일을 초과하는가?
    val isGreaterThanMaxApplyDays =
        ChronoUnit.DAYS.between(startDateTime.toLocalDate(), endDateTime.toLocalDate()) > this.maxApplyDays

    if (isGreaterThanMaxApplyDays)
      throw BadRequestException("예약은 최대 ${this.maxApplyDays}일까지 할 수 있습니다.")

    if (startDateTime.isAfter(endDateTime))
      throw BadRequestException("예약 시작 시간은 예약 종료 시간과 동일하거나 이전이어야합니다.")

    //입차일시은 입차 상한 ~ 입차상한 + 최대 신청 가능일 이내여야 한다
    if (!(startDateTime.between(minimumDateTime, maximumDateTime)))
      throw BadRequestException("예약 시작일이 잘못되었습니다. 예약 가능한 기간은 다음과 같습니다. ${df.format(minimumDateTime)} ~ ${df.format(maximumDateTime)}")
    }
}

도메인 클래스에 유효성 검증 로직이 추가되긴 하지만 문제는 없다. 이 방법도 괜찮다고 생각한다. 하지만 좀더 고민해보면 유효성 검증 로직은 비즈니스 로직과 분리해서 별도로 관리하는 게 유지보수 측면에서 더 편할 것 같다

컨트롤러에서 요청이 들어오는 순간 유효성 검사를 실행하면 비즈니스 레벨에서는 이것이 올바른 데이터인가를 따지는 부가적인 로직없이 순수한 비즈니스 로직에만 집중할 수 있을 것이다. 우리가 간단한 필드들을 @NotNull 같은 Validation 애노테이션으로 미리 검증하는 것처럼 말이다.

javax.validation.ConstraintValidator 인터페이스를 구현함으로써 복잡한 비즈니스 검증도 웹 레이어에서 끝낼 수 있다. ConstraintValidator 는 인터셉터 레벨에서 동작하는데 과연 인터셉터 레벨에서 퍼시스턴스 레이어에 접근하는 건 맞느냐? 하면 잘 모르겠다. 그럼에도 비즈니스 레벨에서 다루는 데이터가 유효한지 신경쓰지 않고 코딩할 수 있다는 것은 큰 이점이다.


//Validation 애노테이션 정의
@Constraint(validatedBy = [WhitelistValidator::class])
@Target(
    AnnotationTarget.CLASS,
    AnnotationTarget.ANNOTATION_CLASS,
)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
annotation class ValidWhitelist(
    val message: String = "{}" ,
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

//Validator 
@Component
class WhitelistValidator(
    private val zoneRepo: ZoneRepository,
): ConstraintValidator<ValidWhitelist, CreateDTO> { 
        @Transactional
    override fun isValid(
        param: CreateDTO,
        context: ConstraintValidatorContext
    ): Boolean {

                val zone = zoneRepo.findByIdOrNull(param.zoneId)
                if (zone == null) {
            addConstraintViolation(
                context,
                "zone id ${param.zoneId} is not found",
            )
            return true
        }
        return false

                val whitelist = param.toEntity()
        val startDateTime = whitelist.startDateTime
        val endDateTime = whitelist.endDateTime
        val minimumDateTime = getMinimumDateTime()
        val maximumDateTime = getMaximumDateTime(zone)
        val df = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
        //신청일이 최대신청가능일을 초과하는가?
        val isGreaterThanMaxApplyDays =
            ChronoUnit.DAYS.between(startDateTime.toLocalDate(), endDateTime.toLocalDate()) > zone.config.maxApplyDays

        if (isGreaterThanMaxApplyDays) {
            addConstraintViolation(
                context,
                "예약은 최대 ${zone.config.maxApplyDays + 1}일까지 할 수 있습니다.",
            )
            return true
        }

        if (startDateTime.isAfter(endDateTime)) {
            addConstraintViolation(
                context,
                "예약 시작 시간은 예약 종료 시간과 동일하거나 이전이어야합니다.",
            )
            return true
        }

        //입차일시은 입차 상한 ~ 입차상한 + 최대 신청 가능일 이내여야 한다
        if (!(startDateTime.between(minimumDateTime, maximumDateTime))) {
            addConstraintViolation(
                context,
                "예약 시작일이 잘못되었습니다. 예약 가능한 기간은 다음과 같습니다. ${df.format(minimumDateTime)} ~ ${df.format(maximumDateTime)}",
            )
            return true
        }
        return false
    }

        private fun addConstraintViolation(
        context: ConstraintValidatorContext,
        errorMessage: String,
    ) {
        context.disableDefaultConstraintViolation()
        context.buildConstraintViolationWithTemplate(errorMessage)
            .addConstraintViolation()
    }
}

자세한 구현 내용은 이 블로그 참고 했습니다.

이렇게 하면 유효성 검증을 웹 레이어에서 끝낼 수 있다. 그런데 이렇게 했을 때도 문제가 생긴다.

ConstraintValidator<ValidWhitelist, CreateDTO> 

이렇게 정의했기 때문에 Update 할 때 재사용할 수 없다.

하나의 DTO에만 위 로직을 사용할 수 있다. 범용적으로 사용하려면 두 가지 방법이 가능할 것 같다.

  1. DTO를 계층화해서 Create , Update 에서 모두 사용하는 DateTime 필드나 zoneId 같은 공통 필드를 상위 클래스로 정의한다.→ 하지만 CreateUpdate 사이에 의존성이 생긴다. 상위 클래스에 함께 묶이기 때문이다. 한쪽을 변경할 때도 항상 다른 한쪽은 문제 없는지 검증해야 한다는 단점. 여러 개의 Validator를 정의하지 않아도 된다는 장점
  2. 비즈니스 로직을 Validator 에서 분리
    DTO마다 Validator 를 구현해야 해서 중복 코드가 생긴다. 의존성이 줄어든다는 장점이 있다. 업데이트와 크리에이트는 서로 무관하므로 어떻게 수정해도 상관없다. 각각의 Validator 는 비즈니스 로직이 담긴 별도의 클래스를 호출해서 유효성을 검증할 수 있다.

  • 뭐가 더 좋을까? 모르겠다.
  • 지금 생각난 건데 이걸 더 세분화해서 분리할 수도 있을 것 같다.
    • 날짜 검증 애노테이션
    • zoneId 검증 애노테이션
    • 그 외 더 많은 로직이 있다면 더 많은 애노테이션이 필요해진다는 단점이...
    • 흐음... 아니면 그냥 그대로 도메인 클래스에서...