WebFlux 에서 Date type 을 url parameter 로 사용하기
Overview
LocalDateTime 같은 시간 형식을 url parameter 로 사용할 경우 기본 포맷에 맞지 않는다면 다음과 같은 에러 메세지를 보게 됩니다.
Exception: Failed to convert value of type 'java.lang.String' to required type 'java.time.LocalDateTime';
특정 포맷도 convert 할 수 있도록 하기 위해서는 어떤 설정을 해줘야 할까요? 이번 글에서는 변환 방법에 대해서 알아봅니다.
Contents
간단한 샘플 예제를 하나 만들어 보겠습니다.
public record Event(
        String name,
        LocalDateTime time
) {
}
event 의 이름과 발생 시간을 담고 있는 간단한 객체입니다. record 를 사용하여 만들어줬습니다.
@RestController
public class EventController {
    @GetMapping("/event")
    public Mono<Event> helloEvent(Event event) {
        return Mono.just(event);
    }
}
handler 는 전통적인 Controller model 을 사용해서 만들어줬습니다.
Spring WebFlux 에서는 Router function 을 사용하여 요청을 관리할 수 있지만, 이 글은 WebFlux 에 관한 내용이 아니므로 @RestController 를 통한 방식을 선택했습니다.
테스트 코드를 작성해보겠습니다.
@WebFluxTest
class EventControllerTest {
    @Autowired
    private WebTestClient webTestClient;
    @Test
    void helloEvent() {
        webTestClient.get().uri("/event?name=Spring&time=2021-08-01T12:00:00")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.name").isEqualTo("Spring")
                .jsonPath("$.time").isEqualTo("2021-08-01T12:00:00");
    }
}
테스트 코드를 실행하면 아래와 같은 요청이 발생하는 것과 같습니다.
$ http localhost:8080/event Accept=application/stream+json name==Spring time==2021-08-01T12:00
HTTP/1.1 200 OK
Content-Length: 44
Content-Type: application/stream+json
{
    "name": "Spring",
    "time": "2021-08-01T12:00:00"
}
기본 포맷으로 요청하면 정상적으로 응답을 받지만, 요청 포맷을 변경하면 어떨까요?

$ http localhost:8080/event Accept=application/stream+json name==Spring time==2021-08-01T12:00:00Z
HTTP/1.1 500 Internal Server Error
Content-Length: 131
Content-Type: application/stream+json
{
    "error": "Internal Server Error",
    "path": "/event",
    "requestId": "ecc1792e-3",
    "status": 500,
    "timestamp": "2022-11-28T10:04:52.784+00:00"
}
보시는 바와 같이 특정 포맷으로 응답을 받고 싶은 경우는 별도의 설정이 필요합니다.
1. @DateTimeFormat
가장 간단한 해결법은 변환하고 싶은 필드에 annotation 을 추가해주는 것입니다. 어떤 포맷으로 변환할 것인지 정의해주면 원하는 포맷으로 요청할 수 있습니다.
public record Event(
        String name,
        @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
        LocalDateTime time
) {
}
다시 테스트를 실행해주면 정상적으로 통과하는 것을 확인할 수 있습니다.
요청 포맷을 바꿀 수 있을 뿐이지 응답 포맷까지 변하진 않습니다. 응답 포맷 변경은 @JsonFormat 등의 annotation 을 통해서 설정 가능하지만 이 글에선 다루지 않습니다.
간단하게 문제를 해결했지만, 항상 최선의 해결책은 아닙니다. 변환해야하는 필드가 많다면 하나하나 annotation 을 붙이는 건 꽤나 귀찮은 작업이 되고, 실수로 annotation 을 작성하지 않아서 버그가 발생할 수도 있습니다. ArchUnit1 등의 test library 를 사용해서 체크하게 하는 것도 가능하지만, 코드를 파악하기 위해 들여야하는 노력이 늘어난다는 것은 부정할 수 없는 사실입니다.
2. WebFluxConfigurer
WebFluxConfigurer 를 구현하여 formatter 를 등록해주면 LocalDateTime 마다 일일히 annotation 을 작성해야 하는 일을 피할 수 있습니다.
Event 에서 @DateTimeFormat 을 제거해준 뒤 설정을 구성합니다.
@Configuration
public class WebFluxConfig implements WebFluxConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
        registrar.setUseIsoFormat(true);
        registrar.registerFormatters(registry);
    }
}
@EnableWebFlux 를 사용하게되면 mapper 를 override 하기 때문에 애플리케이션이 의도한대로 동작하지 않을 수 있습니다.2
다시 테스트를 실행해보면 annotation 없이도 통과하는 것을 확인할 수 있습니다.
특정 필드만 다른 포맷 적용하기
간단합니다. field 에 직접 작성하는 방식인 @DateTimeFormat 의 우선도가 더 높기 때문에 원하는 필드에는 @DateTimeFormat 을 작성해주면 됩니다.
public record Event(
        String name,
        LocalDateTime time,
        @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH")
        LocalDateTime anotherTime
) {
}
    @Test
    void helloEvent() {
        webTestClient.get().uri("/event?name=Spring&time=2021-08-01T12:00:00Z&anotherTime=2021-08-01T12")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.name").isEqualTo("Spring")
                .jsonPath("$.time").isEqualTo("2021-08-01T12:00:00")
                .jsonPath("$.anotherTime").isEqualTo("2021-08-01T12:00:00");
    }
URI 가 길어지면 UriComponentsBuilder 를 사용하는 것도 좋은 방법입니다.
String uri = UriComponentsBuilder.fromUriString("/event")
        .queryParam("name", "Spring")
        .queryParam("time", "2021-08-01T12:00:00Z")
        .queryParam("anotherTime", "2021-08-01T12")
        .build()
        .toUriString();
Conclusion
WebFluxConfigurer 를 사용하는 방식은 전역적으로 일관된 포맷을 구현할 수 있습니다. 특정 포맷을 적용해야하는 필드가 여러 클래스에 걸쳐 분포한 경우, @DateTimeFormat 을 붙이는 것보다 훨씬 간편하게 적용할 수 있으니 상황에 맞게 적용하시면 됩니다.
- @DateTimeFormat: 적용이 간단. 전역 설정보다 우선도가 높으므로 특정 필드만 다른 포맷을 사용해야할 경우 등 타겟팅하여 적용하는 것이 가능.
- WebFluxConfigurer: 적용이 상대적으로 복잡하지만, 프로젝트 규모가 어느 정도 커져서 일관된 설정 적용이 필요할 경우- @DateTimeFormat에 비해 압도적 유리. 일부 필드에 실수로 annotation 을 작성하지 않는 등의 휴먼 에러를 방지할 수 있음.
모든 예제 코드는 GitHub 에서 확인하실 수 있습니다.
