스프링에서는 JSR303 기반 애노테이션 기반으로, 일관성 있는 유효성 검증을 진행할 수 있다.
@NotNull
, @NotEmpty
, @Email
등은 검증 가능하지만, 비즈니스 요구사항이 따르는 유효성 검증은 별도로 해야 한다.
예시
차랑 예약 시스템이고 예약 기간에 대한 유효성 검증이 필요하다. 검증 조건은 간략하게 두 가지 정도로 한다.
- 존재하는 주차장이어야 한다.
- 퇴차일시가 입차일시보다 앞이면 안 된다.
- 예약일수가 내부적으로 정해진 최대 예약 일수를 초과해서는 안 된다.
데이터는 아래와 같은 형태로 들어온다.
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에만 위 로직을 사용할 수 있다. 범용적으로 사용하려면 두 가지 방법이 가능할 것 같다.
- DTO를 계층화해서
Create
,Update
에서 모두 사용하는DateTime
필드나zoneId
같은 공통 필드를 상위 클래스로 정의한다.→ 하지만Create
와Update
사이에 의존성이 생긴다. 상위 클래스에 함께 묶이기 때문이다. 한쪽을 변경할 때도 항상 다른 한쪽은 문제 없는지 검증해야 한다는 단점. 여러 개의 Validator를 정의하지 않아도 된다는 장점 - 비즈니스 로직을
Validator
에서 분리
DTO마다Validator
를 구현해야 해서 중복 코드가 생긴다. 의존성이 줄어든다는 장점이 있다. 업데이트와 크리에이트는 서로 무관하므로 어떻게 수정해도 상관없다. 각각의Validator
는 비즈니스 로직이 담긴 별도의 클래스를 호출해서 유효성을 검증할 수 있다.
- 뭐가 더 좋을까? 모르겠다.
- 지금 생각난 건데 이걸 더 세분화해서 분리할 수도 있을 것 같다.
- 날짜 검증 애노테이션
- zoneId 검증 애노테이션
- 그 외 더 많은 로직이 있다면 더 많은 애노테이션이 필요해진다는 단점이...
- 흐음... 아니면 그냥 그대로 도메인 클래스에서...
'Spring' 카테고리의 다른 글
Spring Scheduler 테스트 하기 (0) | 2022.11.28 |
---|---|
Springboot 통합 테스트로 불안한 리팩토링에서 벗어나기 (2) | 2022.08.24 |
Spring ArgumentResolver (0) | 2022.05.12 |
@ControllerAdivce를 사용한 예외 처리 로직 분리 (0) | 2021.05.15 |
초간단 Spring Scheduler 적용 (0) | 2021.03.02 |