본문 바로가기
개발 이야기/Springboot

DTO에서 LocalDateTime 유효성 체크와 에러핸들링 방법

by 농개 2024. 6. 1.
반응형

이번 포스팅에서는 실무에 많이 사용되는

@RequestBody로 선언된 DTOLocalDateTime 활용시, 참고 할만한 정보를 소개합니다.

목차

    먼저 제 피씨 환경은 아래와 같습니다.

    • Springboot 3.1.4
    • Java 17
    • Kotlin 1.8.22
    • Intellij

    LocalDateTime 타입의 날짜 포맷 허용 범위는?

    아래와 같은 데이터 클래스가 있습니다.

    data class MemberRequestDto(
        val id: Long?,
        val name: String,
        val email: String,
        val age: Int,
        val createdTime: LocalDateTime,
    )

     

    LocalDateTimeDocument를 봅시다.

    A date-time without a time-zone in the ISO-8601 calendar system, such as 2007-12-03T10:15:30.
    ...

     

    Timezone 없이 ISO-8601 캘린더 시스템. 주저리주저리...

    ISO-8601라면 국제 표준의 날짜와 시간대이며

    그렇다면 createdTime 값은 아래와 같은 패턴(Pattern)의 날짜 데이터를 셋팅 할 수있습니다.

    • yyyy-MM-dd'T'HH:mm
    • yyyy-MM-dd'T'HH:mm:ss
    • yyyy-MM-dd'T'HH:mm:ss'Z'
    • yyyy-MM-dd'T'HH:mm:ss.SSS
    • yyyy-MM-dd'T'HH:mm:ss.SSSSSS
    • yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS

    예시를 들어보겠습니다.

    • 2024-05-31T10:12:12
    • 2024-05-31T10:12:12:55
    • 2024-05-31T10:12:12:55Z
    • 2024-05-31T10:12:12:55.221
    • 2024-05-31T10:12:12:55.221122
    • 2024-05-31T10:12:12:55.221122331

    아래 데이터는 설정이 불가합니다.

    • 2024-05-31 10:12:12                               // 중간에 T가 빠짐
    • 2024-05-31 T10:12:12:55+09:00           // 타임존이 포함
    • 2024-05-31T10:12:12:55.2211223312  // 길이 초과

     

    내부적으로 Jackson 라이브러리를 사용한다

    Springboot로 애플리케이션을 개발할 때 기본적으로 사용하는

    Json 파싱 라이브러리는 jackson입니다.

    위 jackson 라이브러리 중 LocalDateTime 파싱에 사용되는 것은

    jackson-datatype-jsr310 입니다.

    jackson-datatype-jsr310은 Java 8 에 등장한 LocalDateTime를 쉽게 다룰수 있게 합니다.

     

    스프링 버전이 높다면 크게 상관 없을 것이나

    낮다면, 위 라이브러리를 별도로 추가해줘야합니다.

     

    Error Handling은 어떻게?

    @ControllerAdvice
    class CustomExceptionHandler : ResponseEntityExceptionHandler() {
    
        override fun handleHttpMessageNotReadable(
            ex: HttpMessageNotReadableException,
            headers: HttpHeaders,
            status: HttpStatusCode,
            request: WebRequest
        ): ResponseEntity<Any>? {
            logger.error(ex.message, ex)
            return ResponseEntity<Any>(ex.message, HttpStatus.BAD_REQUEST)
        }
    
        // ..(생략)
    }

    위와 같이 에러 핸들링을 위한 코드를 작성해봅니다.

    이 후 잘못된 데이터를 넘기게 되면 400에러와 함께 아래 에러메시지가 응답될 것입니다.

    JSON parse error: Cannot deserialize value of type `java.time.LocalDateTime` 
    from String "2022-10-12T12:12:2": Failed to deserialize java.time.LocalDateTime:
    (java.time.format.DateTimeParseException) Text '2022-10-12T12:12:2' could not be parsed,
    unparsed text found at index 16

     

    @JsonFormat으로 날짜 패턴 정의하기

    data class MemberRequestDto(
        val id: Long?,
        val name: String,
        val email: String,
        val age: Int,
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
        val createdTime: LocalDateTime,
    )

     

    @JsonFormat을 사용하면 날짜 패턴을 지정할수 있습니다.

    위처럼 하게되면 'Z'가 없을 때 에러를 반환하게 됩니다.

     

    만약 실무에서 엄격한 인터페이스로 날짜 패턴을 정의해야한다면

    위 같이 @JsonFormat를 사용해볼 수 있을 겁니다.

     

    ConstraintValidator 정의하여 유효성 검사

    아래와 같이 Annotation + Validator 구현을 해볼 수 있습니다.

    data class MemberRequestDto(
        val id: Long?,
        @field:NotBlank(message = "name cannot be blank")
        val name: String,
        @field:Email(message = "email is invalid")
        val email: String,
        
        // ..(생략)
    )

     

    위처럼 유효성 검사를 하기 위해서는

    jakarta.validation.constraints 라이브러리가 필요합니다.

     

    아래와 같이 build.gradle.kts에 의존성을 추가해줍니다.

    dependencies {
    
        implementation("org.springframework.boot:spring-boot-starter-actuator")
        implementation("org.springframework.boot:spring-boot-starter-web")
        implementation("org.springframework.boot:spring-boot-starter-validation") // jakarta.validation 포함됨
        
        // ..(생략)
    }

     

    그리고 커스텀 validator를 구현해봅시다.

    import jakarta.validation.ConstraintValidator
    import jakarta.validation.ConstraintValidatorContext
    import java.time.DateTimeException
    import java.time.LocalDateTime
    
    class ReservationTimeValidator : ConstraintValidator<ReservationTimeChecker, LocalDateTime> {
        override fun isValid(value: LocalDateTime?, context: ConstraintValidatorContext?): Boolean {
        	// Todo: check validation
            if (value == null) return false
    
            return try {
                return value.isAfter(LocalDateTime.now())
            } catch (e: DateTimeException) {
                false
            }
        }
    }

     

    위 기능은 입력된 날짜 값이

    현재 시간(LocalDateTime.now) 이 후의 날짜/시간 인지를 검사하는 기능의 Validator입니다.

    원하는 요구에 따라 입맛에 맛게 작성하심 될겁니다.

     

    어노테이션도 만들어 줍니다.

    import jakarta.validation.Constraint
    
    @Target(AnnotationTarget.FIELD)
    @Retention(AnnotationRetention.RUNTIME)
    @Constraint(validatedBy = [ReservationTimeValidator::class])
    annotation class ReservationTimeChecker(
        val message: String = "default message",
        val groups: Array<KClass<*>> = [],
        val payload: Array<KClass<out Any>> = []
    )

     

    사용은 아래와 같이 하면 됩니다.

    data class MemberRequestDto(
        // ..(중략)
        @field:ReservationTimeChecker("reservation time cannot be past")
        val reservationTime: LocalDateTime,
    )

     

    ConstraintValidator 정의 시, 에러 핸들링

    만약 커스텀 Validator를 구현하였다면

    아래와 같이 에러 핸들러에 메서드를 추가해줘야 합니다.

    @ControllerAdvice
    class CustomExceptionHandler : ResponseEntityExceptionHandler() {
    
        override fun handleMethodArgumentNotValid(
            ex: MethodArgumentNotValidException,
            headers: HttpHeaders,
            status: HttpStatusCode,
            request: WebRequest
        ): ResponseEntity<Any>? {
            logger.error(ex.message, ex)
    
            val message = ex.fieldErrors.firstNotNullOf { it.defaultMessage }
    
            return ResponseEntity<Any>(message, HttpStatus.BAD_REQUEST)
        }
        
        // .. (생략)
    }

     

    유효하지 않은 값으로 테스트해봅시다.

     

    에러 핸들러에서 정상적으로 캐치하고

    원하는 에러메시지를 출력할 수 있습니다.

    반응형