JVM 언어에서의 날짜 변환 문제
title: JVM 언어에서의 날짜 변환 문제
date: 2023-07-23
tags:
Introduction
일반적으로 날짜를 저장한다면 yyyyMMdd
형태를 이용한다.
이러한 형식의 String 값을 날짜로 변환할 때, 일반적으로 yyyyMMdd
형식(이하 ymd)을 이용한다.
Java와 Kotlin 동일하게 ymd를 사용할 때 한 가지 문제가 있다.
이 문제를 발견하고 어떻게 해결하였는지 공유한다.
Problem
Auto Conversion of ymd
ymd의 경우 일반적인 날짜가 들어온다면 정상적으로 변환해 준다.
그렇다면 02/30, 혹은 04/31과 같이 실제로 존재하지 않는 날짜가 들어오면 어떻게 될까?
class WrongLocalDateTimeFormatterTest {
private val ymdPattern = "yyyyMMdd"
private val ymdFormatter = DateTimeFormatter.ofPattern(ymdPattern)
private val wrongDates = listOf("20230230", "20230431")
private val generalDate = "20230123"
@Test
fun ymdFormatterTest() {
assertAll(
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[0], ymdFormatter) } },
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[1], ymdFormatter) } },
{ assertDoesNotThrow { LocalDate.parse(generalDate, ymdFormatter) } }
)
}
}
두 잘못된 날짜가 변환되지 않고 예외가 발생하는 것이 바람직한 방향이지만, 두 값 모두 말일로 자동 변경이 된다.
이를 그대로 이용할 수도 있겠으나, 애초에 이러한 값이 이용되지 않는 방향이 더욱 좋다고 생각한다.
이러한 문제가 발생하는 이유는 간단하다.
LocalDate.parse
실행 시, Formatter에 따라 적절한 방식으로 날짜로 변환하는 작업을 진행한다.
내장 메소드를 거치다 최종적으로 위 메소드에서 날짜로 변환이 이루어진다.
별도의 ResolverStyle을 명시하지 않았기에 기본값인 SMART
로 설정이 된다.
이로 인해 위 메소드에서 날짜가 말일보다 클 경우, 말일로 설정한다.
ymd with ResolverStyle STRICT
ResolverStyle을 명시하여 변환을 진행할 경우, 잘못된 날짜를 인식하여 Exception이 발생한다.
이로써 문제가 해결된 것처럼 보이나, 정상적인 날짜 또한 변환에 실패하는 문제가 발생한다.
class WrongLocalDateFormatterTest {
private val ymdPattern = "yyyyMMdd"
private val ymdStrictFormatter = DateTimeFormatter.ofPattern(ymdPattern).withResolverStyle(ResolverStyle.STRICT)
private val wrongDates = listOf("20230230", "20230431")
private val generalDate = "20230123"
@Test
fun ymdStrictFormatterTest() {
assertAll(
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[0], ymdStrictFormatter) } },
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[1], ymdStrictFormatter) } },
{ assertDoesNotThrow { LocalDate.parse(generalDate, ymdStrictFormatter) } }
)
}
}
내부 동작이 매우 많지만, 첫 시작부터 해당 문제가 시작된다.
첫 LocalDate.parse
메소드 진행 시 formatter의 Year 부분 값이 Year
가 아닌 YearOfEra
로 설정되어 있기 때문이다.
AbstractChronology.resolveDate
에서 Year
이라는 값이 있어야 정상적으로 날짜 변환이 이루어진다.build date
부분에서 Year
값을 찾으려고 하지만, YearOfEra
만 존재하기에 이를 찾지 못하여 날짜로 변환에 실패한다.
Formatter의 Year
값이 처음부터 YearOfEra
로 세팅되었기에 변환에 실패한 것이다.
DatetimeFormatterBuilder.parsePattern
에서 y
, M
, d
등을 Key로 사용하여 이에 대응하는 값을 가져온다.
예상과 다르게 y
는 YearOfEra
이며, 원했던 Year
는 u
에 맵핑되어있다.
기존의 ymd Formatter 또한 YearOfEra
라는 것을 확인할 수 있다.
그렇다면 초기값은 YearOfEra
이며, 중간에 Year
로 변환이 될 것이라고 추측할 수 있다.
이전에 올린 사진 중 5번째 ymd strict No Year
에는 resolveYearOfEra
메소드를 호출하는 부분이 존재한다.
IsoChronology.resolveYearOfEra
에서는 Formatter의 값과 ResolverStyle을 이용하여 Year
로 변환시키거나 그대로 놔두는 작업을 진행한다.
첫 ymd Formatter는 ResolverStyle이 SMART
이기에 네 번째 if문에서 else 분기를 통해 Year
로 변환이 된다.
하지만 STRICT
를 이용할 경우 네 번째 if문 내부의 if 분기로 넘어가며, Year
값이 없어 year
변수는 null이 된 상태이기에 이후 else 분기로 넘어가 YearOfEra
값으로 유지된다.
Solution
아까 각 알파벳이 어떠한 값으로 맵핑되는지 확인하였으므로, 이를 이용하면 된다.y
대신 u
표현을 이용한다면 원하는 대로 잘못된 날짜의 변환은 실패하며, 올바른 날짜는 변환할 수 있다.
class WrongLocalDateFormatterTest {
private val ymdPattern = "yyyyMMdd"
private val umdPattern = "uuuuMMdd"
private val ymdFormatter = DateTimeFormatter.ofPattern(ymdPattern)
private val ymdStrictFormatter = DateTimeFormatter.ofPattern(ymdPattern).withResolverStyle(ResolverStyle.STRICT)
private val umdStrictFormatter = DateTimeFormatter.ofPattern(umdPattern).withResolverStyle(ResolverStyle.STRICT)
private val wrongDates = listOf("20230230", "20230431")
private val generalDate = "20230123"
@Test
fun ymdFormatterTest() {
assertAll(
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[0], ymdFormatter) } },
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[1], ymdFormatter) } },
{ assertDoesNotThrow { LocalDate.parse(generalDate, ymdFormatter) } }
)
}
@Test
fun ymdStrictFormatterTest() {
assertAll(
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[0], ymdStrictFormatter) } },
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[1], ymdStrictFormatter) } },
{ assertDoesNotThrow { LocalDate.parse(generalDate, ymdStrictFormatter) } }
)
}
@Test
fun umdStrictFormatterTest() {
assertAll(
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[0], umdStrictFormatter) } },
{ assertThrows<DateTimeParseException> { LocalDate.parse(wrongDates[1], umdStrictFormatter) } },
{ assertDoesNotThrow { LocalDate.parse(generalDate, umdStrictFormatter) } }
)
}
}
지금까지 살펴본 모든 Formatter들을 통합한 테스트 소스이다.
물론 ymd 방식의 테스트는 모두 기존의 예상과 다르기에 실패한다.
Conclusion
지금까지 잘못된 날짜가 왜 변환이 되고, ResolverStyle만 변경하는 것은 문제를 해결할 수 없다는 것을 알아보았다.
다소 복잡하더라도 엄격한 날짜 변환 로직을 이용하고 싶다면 연도 표현 시 y
대신 u
를 이용하며, ResolverStyle을 STRICT
로 설정하면 된다.