Spring/Basic

Rest API에서 date type의 Parameter 처리

갈색왜성 2019. 9. 27. 17:45
반응형

최근에 Rest API를 추가할 때 datetime type의 parameter를 처리하는 과정에서 살짝 고생을 했다. 그냥 String으로 받아 Parameter Validation과정에서 Convert해서 쓰면 될 문제였는데, 가끔 발동되는 오기로 인해 관련 내용을 파게 됐고 그 내용을 정리해 보았다.

 

 

실험 환경
  • Spring Boot v2.19
  • JDK 1.8
예제 코드
...
@RestController
public class DateController {

    @PostMapping("/date")
    public ResponseEntity<String> dateTest(@RequestParam("date") Date date) {
       
    	SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd HH:mm:ss");

    	return ResponseEntity.ok(dateFormat.format(date));
    }
 
    @PostMapping("/localdate")
    public ResponseEntity<String>  localDate(@RequestParam("localDate") LocalDate localDate) {
    	
    	DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");

    	return ResponseEntity.ok(localDate.format(formatter));
    }
 
    @PostMapping("/localdatetime")
    public ResponseEntity<String>  dateTime(@RequestParam("localDateTime") LocalDateTime localDateTime) {
    	
    	DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd HH:mm:ss");

    	return ResponseEntity.ok(localDateTime.format(formatter));
    }
	
}
...

Controller에 각각 java.util.Date, java.ime.LocalDate, java.time.LocalDateTime parameter로 받아오는 Rest API를 구성했다. 그리고 각각 '2019-01-02T12:34:56.000Z', '2019-01-02', '2019-01-02T12:34:56.000Z'를 인자값으로 해서 curl command로 POST request를 보내면 아래와 같이 오류가 발생한다. (%3A는 특수 문자 ':'이 변경된 것이다.)

...
[browndwarf@localhost work]# curl -X POST "http://localhost:8080/date?date=2019-01-02T12%3A34%3A56.000Z" -H  "accept: */*"
{"timestamp":"2019-09-01T05:52:14.907+0000","status":400,"error":"Bad Request","message":"Failed to convert value of type 'java.lang.String' to required type 'java.util.Date'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam java.util.Date] for value '2019-01-02T12:34:56.000Z'; nested exception is java.lang.IllegalArgumentException","path":"/date"}
...
[browndwarf@localhost work]# curl -X POST "http://localhost:8080/localdate?localDate=2019-01-02" -H  "accept: */*"
{"timestamp":"2019-09-01T05:52:44.823+0000","status":400,"error":"Bad Request","message":"Failed to convert value of type 'java.lang.String' to required type 'java.time.LocalDate'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam java.time.LocalDate] for value '2019-01-02'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [2019-01-02]","path":"/localdate"}

[browndwarf@localhost work]# curl -X POST "http://localhost:8080/localdatetime?localDateTime=2019-01-02T12%3A34%3A56.000Z" -H  "accept: */*"
{"timestamp":"2019-09-01T05:53:41.940+0000","status":400,"error":"Bad Request","message":"Failed to convert value of type 'java.lang.String' to required type 'java.time.LocalDateTime'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam java.time.LocalDateTime] for value '2019-01-02T12:34:56.000Z'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [2019-01-02T12:34:56.000Z]","path":"/localdatetime"}
...

오류 내용들 중에 하나를 확인해보면 String으로 전달된 인자값이 LocalDateTime(Date, LocalDate)으로 변환 시에 오류가 발생했다는 것이다.  

Failed to convert value of type 'java.lang.String' to required type 'java.time.LocalDateTime'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam java.time.LocalDateTime] for value '2019-01-02T12:34:56.000Z'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [2019-01-02T12:34:56.000Z]

해결방법 1. @DateTimeFormat annotation 사용 - 1

위의 문제를 가장 간단하게 해결하는 방법 중 하나는 Parsing을 어떻게 해야할 지 @DateTimeFormat annotation을 통해 Hint를 주는 것이다.

...
import org.springframework.format.annotation.DateTimeFormat.ISO;

@PostMapping("/date")
    public ResponseEntity<String> dateTest(
    		@RequestParam("date") 
			@DateTimeFormat(iso = ISO.DATE_TIME) Date date) {
...
    @PostMapping("/localdate")
    public ResponseEntity<String>  localDate(
    		@RequestParam("localDate") 
			@DateTimeFormat(iso = ISO.DATE) LocalDate localDate) {
...
    @PostMapping("/localdatetime")
    public ResponseEntity<String>  dateTime(
    		@RequestParam("localDateTime") 
			@DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime localDateTime) {
...

위와 같이 Code를 수정하면 Parameter로 전달되는 date, localDate, localDateTime 인자들은 ISO 8601에 따라 Parsing되게 된다. ISO 8601에서는 Datetime인 경우에는 yyyy-MM-dd'T'HH:mm:ss.SSSXXX, Date type은 yyyy-MM-dd, Time type은 HH:mm:ss.SSSXXX의 형식으로 정의하고 있고 이 형식에 맞지 않는 값이 입력되는 경우에는 이전과 동일하게 오류를 발생시키고 끝난다. 아래는 ISO 8601로 쓰여진 예이다.

  • Datetime : 2019-01-31T11:30:59.000Z
  • Date : 2019-01-31
  • Time :11:30:59.000Z

위와 같이 Code를 수정한 후에 실행하면 정상적으로 잘 동작하는 것을 확인할 수 있다. 

...
[root@localhost work]# curl -X POST "http://localhost:8080/date?date=2019-01-02T12%3A34%3A56.000Z" -H  "accept: */*"
20190102 21:34:56
...
[root@localhost work]# curl -X POST "http://localhost:8080/localdate?localDate=2019-01-02" -H  "accept: */*"
20190102
...
[root@localhost work]# curl -X POST "http://localhost:8080/localdatetime?localDateTime=2019-01-02T12%3A34%3A56.000Z" -H  "accept: */*"
20190102 12:34:56
...

(살짝 주목해야 할 내용 중에 하나는 Date type은 Timezone 정보가 포함되어 있어서 출력 시에 Asia/Seoul의 시간 값으로 변환되어(+09:00) 출력되고 있고, LocalDateTime의 경우에는 Timezone 정보가 제외되어 있기 때문에 Timezone에 의한 시간 변환이 없다. date=2019-01-02T12:34:56.000+09:00으로 Timezone을 Asia/Seoul로 맞춰 Rest API를 호출하면 아래와 같은 값을 얻을 수 있다.)

...
[root@localhost work]# curl -X POST "http://localhost:8080/date?date=2019-01-02T12%3A34%3A56.000%2B09%3A00" -H  "accept: */*"
20190102 12:34:56
...
[root@localhost work]# curl -X POST "http://localhost:8080/localdatetime?localDateTime=2019-01-02T12%3A34%3A56.000%2B09%3A00" -H  "accept: */*"
20190102 12:34:56
...

 

해결방법 2. @DateTimeFormat annotation 사용 - 2 (with Pattern)

위 방법의 최대 단점은 ':'과 같은 특수 문자를 포함시켜야 한다는 점이다. URL을 사용할 때에 이런 특수 문자들은 'unsafe ASCII character'라 해서 '%'와 2자리의 Hexa Code로 Encoding 되는데(ex. ':'는 '%3A'로 변환됨) 가독성과 사용성을 떨어뜨린다. 특히 Path Variable로 datetime 인자를 사용할 때는 좀 더 그렇다. 이럴 경우에는 @DateTimeFormat  annotation의 iso 값 대신 pattern 값을 통해, 개발자 의도에 맞춰 입력 Pattern을 설정할 수 있다.

...
	@PostMapping("/date/{value}")
    public ResponseEntity<String> dateTest(
    		@PathVariable("value") 
			@DateTimeFormat(pattern="yyyyMMddHHmmss") Date date) {
	...
    @PostMapping("/localdate/{value}")
    public ResponseEntity<String>  localDate(
    		@PathVariable("value") 
			@DateTimeFormat(pattern="yyyyMMdd") LocalDate localDate) {
	...
    @PostMapping("/localdatetime/{value}")
    public ResponseEntity<String>  dateTime(
    		@PathVariable("value") 
			@DateTimeFormat(pattern="yyyyMMddHHmmss") LocalDateTime localDateTime) {
...

위와 같이 @DateTimeFormat(pattern="...")을 사용하면 입력 Format을 정의할 수 있게 된다. 아래는 실행 결과이다. 입력 값이 변경된 것에 주목하자.

...
[root@localhost work]# curl -X POST "http://localhost:8080/date/20190102123456" -H  "accept: */*"
20190102 12:34:56
...
[root@localhX POST "http://localhost:8080/localdate/20190102" -H  "accept: */*"
20190102
...
[root@localhost work]# curl -X POST "http://localhost:8080/localdatetime/20190102123456" -H  "accept: */*"
20190102 12:34:56
...

 

해결방법 3. WebMvcConfigurer Interface의 구현 Class 정의

Datetime type의 인자를 요구하는 API가 1~2개라면 위의 방법으로 충분하다. 하지만 그런 API의 수가 많고, 여러 Controller에 걸쳐 정의되어 있다면 Web Project 전체적으로 적용하는 것이 좀 더 수월할 것이다.(단, 모두 같은 Format으로 입력을 받을 경우에 한해서다.) Spring Framework에서는 WebMvcConfigurer  Interface(Spring Framework 5 기준, 이전 버젼에서는 WebMvcConfigurerAdapter  class를 사용해야 함)를 통해 전체 Rest API의 입력 Format을 정의할 수 있다.

아래 Code와 같이 WebMvcConfigureraddFormatters() 함수를 Override해서 인자로 넘어온 FormatterRegistry type의 registry에 Date나 DateTime type을 Parsing하는 Formatter을 새로 구성해서 등록하면 된다.

 

간단하게 ISO 8601로 Parsing되게 설정하려면 아래와 같이 setUseIsoFormat() 함수를 사용하면 된다.

...
@Configuration
public class DateTimeFormatConfiguration implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
        registrar.setUseIsoFormat(true);
        registrar.registerFormatters(registry);
    }
}
...

setDateFormatter()setDateTimeFormatter() 함수를 통해 좀 더 명시적으로 설정할 수도 있다.

...
@Configuration
public class DateTimeFormatConfiguration implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
        registrar.setDateFormatter(DateTimeFormatter.ISO_DATE);
        registrar.setDateTimeFormatter(DateTimeFormatter.ISO_DATE_TIME);
        registrar.registerFormatters(registry);
    }
}
...

setDateFormatter()setDateTimeFormatter()DateTimeFormatter.ofPattern(...)을 통해 임의의 Format을 사용할 수 있게 설정할 수도 있다. 해결방법 2에서 언급했던 예와 같은 형식으로 Parsing하길 원한다면 아래의 Code와 같이 작성하면 된다.

...
@Configuration
public class DateTimeFormatConfiguration implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
        registrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyyMMdd"));
        registrar.setDateTimeFormatter(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        registrar.registerFormatters(registry);
    }
}
...

기존에 사용했던 @DateTimeFormat annotation을 제거해서 Controller Code를 아래와 같이 만들고, 제일 위에 있는 DateTimeFormatConfiguration class와 함께 실행해 보았다.

...
    @PostMapping("/date")
    public ResponseEntity<String> dateTest(@RequestParam  Date date) {
		...
	}
	
    @PostMapping("/localdate")
    public ResponseEntity<String>  localDate(@RequestParam LocalDate localDate) {
		...
	}
    @PostMapping("/localdatetime")
    public ResponseEntity<String>  dateTime(@RequestParam LocalDateTime localDateTime) {
		...
	}
...
...
[dohoon@localhost pgbadger-11.1]$ curl -X POST "http://localhost:8080/localdate?localDate=2019-01-02" -H  "accept: */*"
20190102
...
[dohoon@localhost pgbadger-11.1]$ curl -X POST "http://localhost:8080/localdatetime?localDateTime=2019-01-02T12%3A34%3A56.000Z" -H  "accept: */*"
20190102 12:34:56
...
[dohoon@localhost pgbadger-11.1]$ curl -X POST "http://localhost:8080/date?date=2019-01-02T12%3A34%3A56.000Z" -H  "accept: */*"
{"timestamp":"2019-10-31T02:20:15.631+0000","status":400,"error":"Bad Request","message":"Failed to convert value of type 'java.lang.String' to required type 'java.util.Date'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam java.util.Date] for value '2019-01-02T12:34:56.000Z'; nested exception is java.lang.IllegalArgumentException","path":"/date"}
...

실행 결과를 확인하면 LocalDate, LocalDateTime type을 인자로 받을 때는 해당 값이 정상적으로 Parsing되는 결과를 확인할 수 있으나, Date Type을 인자로 받을 때에는 제일 처음에 봤던 오류가 다시 발생하고 있음을 확인 할 수 있다. 그 이유는 DateTimeFormatterRegistar  class의 Code를 보면 알 수 있다.

 

DateTimeFormatterRegistrar class
...
public class DateTimeFormatterRegistrar implements FormatterRegistrar {
...
	@Override
	public void registerFormatters(FormatterRegistry registry) {
		DateTimeConverters.registerConverters(registry);

		...

		// Efficient ISO_LOCAL_* variants for printing since they are twice as fast...

		registry.addFormatterForFieldType(LocalDate.class,
				new TemporalAccessorPrinter(
						df == DateTimeFormatter.ISO_DATE ? DateTimeFormatter.ISO_LOCAL_DATE : df),
				new TemporalAccessorParser(LocalDate.class, df));

		registry.addFormatterForFieldType(LocalTime.class,
				new TemporalAccessorPrinter(
						tf == DateTimeFormatter.ISO_TIME ? DateTimeFormatter.ISO_LOCAL_TIME : tf),
				new TemporalAccessorParser(LocalTime.class, tf));

		registry.addFormatterForFieldType(LocalDateTime.class,
				new TemporalAccessorPrinter(
						dtf == DateTimeFormatter.ISO_DATE_TIME ? DateTimeFormatter.ISO_LOCAL_DATE_TIME : dtf),
				new TemporalAccessorParser(LocalDateTime.class, dtf));
                
		...
	}
...
}
...

DateTimeFormatterRegistar class의 registerFormatters() 함수를 보면 DateTime type의 인자를 Parsing하기 위한 Formatter를 등록하는 과정(addFormatterForFieldType() 함수 호출)에서 LocalDate, LocalDateTime type에 대한 Formatter는 등록해 주고 있지만, Date type에 대해서는 등록해 주는 구문이 존재하지 않는다. 이로 인해 Date type의 인자가 들어올 때 Parsing 오류가 발생하는 것이다.

 

해결방법 4. 직접적인 Formatter bean 생성 및 등록

위의 문제를 해결하는 방법으로는 DateTimeFormatterRegistar  class를 통하지 않고 직접 특정 Type의 인자를 위한 Formatter bean을 직접 만들어 주는 방법이 있다.

...
public interface FormatterRegistry extends ConverterRegistry {

    /**
     * Adds a Formatter to format fields of the given type.
     * On print, if the Formatter's type T is declared and {@code fieldType} is not assignable to T,
     * a coercion to T will be attempted before delegating to {@code formatter} to print a field value.
     * On parse, if the parsed object returned by {@code formatter} is not assignable to the runtime field type,
     * a coercion to the field type will be attempted before returning the parsed field value.
     * @param fieldType the field type to format
     * @param formatter the formatter to add
     */
    void addFormatterForFieldType(Class<?> fieldType, Formatter<?> formatter);
    ...
}
...

addFormatterForFieldType()를 정의한 FormatterRegistrar  Interface를 보면 특정 Type에 대해 Formatter 구현체를 인자로 전해주게 되어있고 궁극적으로는 Formatter<Data Type>과 같은 형식의 Formatter의 구현체가 등록되게 된다.

 

Formatter Interface의 구현 Class를 만들기 위해 Code를 살펴보면 In/Out을 위해 정의된 Parser  Interface와 Printer Interface를 상속 받고 있고, Printer  interface의 print()함수와 Parser  interface의 parse() 함수의 구현체를 Bean으로 선언하면 된다.

...
public interface Formatter<T> extends Printer<T>, Parser<T> {

}
...
public interface Printer<T> {

	/**
	 * Print the object of type T for display.
	 * @param object the instance to print
	 * @param locale the current user locale
	 * @return the printed text string
	 */
	String print(T object, Locale locale);

}
...
public interface Parser<T> {

	/**
	 * Parse a text String to produce a T.
	 * @param text the text string
	 * @param locale the current user locale
	 * @return an instance of T
	 * @throws ParseException when a parse exception occurs in a java.text parsing library
	 * @throws IllegalArgumentException when a parse exception occurs
	 */
	T parse(String text, Locale locale) throws ParseException;

}
...

 

Date, LocalDate, LocalDateTime을 위한 Formatter Interface 구현체를 아래의 예와 같이 만들었고, @bean annotation을 통해 bean으로 선언했다. 각각 "yyyyMMddHHmmss", "yyyyMMdd" Format을 기준으로 Input Value를 Parsing하게 했고, ISO 8601와 같은 형식으로 출력하게 만들었다.

...
@Configuration
public class DateTimeConfig {

	@Bean
    public Formatter<LocalDate> localDateFormatter() {
        return new Formatter<LocalDate>() {
            @Override
            public LocalDate parse(String text, Locale locale) {
                return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyyMMdd", locale));
            }

            @Override
            public String print(LocalDate object, Locale locale) {
                return DateTimeFormatter.ISO_DATE.format(object);
            }
        };
    }


    @Bean
    public Formatter<LocalDateTime> localDateTimeFormatter() {
        return new Formatter<LocalDateTime>() {
            @Override
            public LocalDateTime parse(String text, Locale locale) {
                return LocalDateTime.parse(text, DateTimeFormatter.ofPattern("yyyyMMddHHmmss", locale));
            }

            @Override
            public String print(LocalDateTime object, Locale locale) {
                return DateTimeFormatter.ISO_DATE_TIME.format(object);
            }
        };
    }

    @Bean
    public Formatter<Date> DateFormatter() {
        return new Formatter<Date>() {
            @Override
            public Date parse(String text, Locale locale) throws ParseException {
            	SimpleDateFormat dt = new SimpleDateFormat("yyyyMMddHHmmss", locale); 
            	return dt.parse(text); 
            }

            @Override
            public String print(Date object, Locale locale) {
            	return new SimpleDateFormat("yyyyMMdd HH:mm:ss").format(object);
            }
        };
    }
}
...

 

위의 설정과 함께 이전 Controller Code를 다시 실행하면 정상적으로 동작하게 된다.

...
[root@localhost work]# curl -X POST "http://localhost:8080/date?date=20190102123456" -H  "accept: */*"
20190102 12:34:56

[root@localhost work]# curl -X POST "http://localhost:8080/localdate?localDate=20190102" -H  "accept: */*"
20190102

[root@localhost work]# curl -X POST "http://localhost:8080/localdatetime?localDateTime=20190102123456" -H  "accept: */*"
20190102 12:34:56
...

 

부록. Swagger에서의 Datetime 입력 (with SpringFox)

순수 Rest API만 고려하면 위의 내용으로 해결되지만, Swagger UI를 통해 API를 Call하는 경우에는 문제가 있다. <Pic 1>과 같이 Swagger UI에서는 ISO 8601 format에 맞지 않는 DateTime type의 인자가 입력된 경우 아예 API를 Call하는 것 조차 허용하지 않기 때문에, Swagger UI를 사용할 수 없게 된다.(기본 설정으로는 <pic 2>와 같은 입력만 허용한다.)

 

<pic 1. 임의 Pattern의 Datetime 인자를 막는 순간>

 

 

이를 해결하려면 Swagger UI에서 Datetime Type에 대한 Validation Check를 Disable 해야하는데, 필자가 Swagger UI 지원 목적으로 사용하는 SpringFox Library의 경우에는 아래와 같이 Docket Instance를 만드는 과정에서 Date, LocalDate, LocalDateTime type의 인자를 String 형식으로 허용해서 해결할 수 있다. 

...
@Configuration
@EnableSwagger2
public class SwaggerConfig {
	
	@Bean
    public Docket defaultDoc() {
    	return new Docket(DocumentationType.SWAGGER_2)
...
            	.directModelSubstitute(Date.class, String.class)        			
        		.directModelSubstitute(LocalDate.class, String.class)
        		.directModelSubstitute(LocalDateTime.class, String.class)
 ...
    }

...
}
...

 

Reference

https://www.baeldung.com/spring-date-parameters

https://blog.codecentric.de/en/2017/08/parsing-of-localdate-query-parameters-in-spring-boot/

https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#format-configuring-formatting-mvc