빌드노트
Java에서 2월 30일을 파싱했는데 2월 28일이 됐다
Java 날짜 파싱의 기본 SMART는 2월 30일을 월말로 보정한다. 평년·윤년 입력을 직접 비교하고, uuuu와 STRICT로 잘못된 일자를 거절하는 방법 및 yyyy에 STRICT만 붙였을 때 정상 날짜가 실패하는 이유를 설명한다.
Java에서 DateTimeFormatter.ofPattern("yyyy-MM-dd")로 2023-02-30을 읽으면 예외 없이 2023-02-28이라는 LocalDate가 나올 수 있다. 이 생성 메서드의 기본 정책은 SMART이며, 해당 월의 마지막 날을 넘은 일자를 보정한다. 존재하지 않는 날짜를 입력 오류로 처리하려면 연도 패턴을 uuuu로 지정하고 ResolverStyle.STRICT를 적용한다.
먼저 LocalDate.parse(text, formatter)에 실제로 전달하는 formatter의 생성부를 확인한다. getResolverStyle()로 정책을 읽을 수 있다. yyyy-MM-dd에 STRICT만 추가하면 정상 날짜도 실패할 수 있으므로, 정책과 연도 패턴을 함께 확인해야 한다.
같은 입력을 보정할지 거절할지
다음 코드는 기존 생성 방식의 기본값을 확인하고, uuuu-MM-dd와 STRICT를 조합해 평년·윤년의 날짜를 비교한다. JDK 17 이상을 준비하고 터미널에서 java -version으로 버전을 확인한 뒤 jshell --execution local --feedback concise를 실행해 붙여 넣는다. 별도 프로젝트나 라이브러리는 필요 없다. 자신의 formatter를 조사할 때는 아래 current 대신 실제 파싱에 전달하는 객체의 정책을 출력한다.
import java.time.LocalDate;
import java.time.format.*;
import java.util.Locale;
var current = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.ROOT);
System.out.println(current.getResolverStyle());
System.out.println(LocalDate.parse("2023-02-30", current));
var strict = DateTimeFormatter.ofPattern("uuuu-MM-dd", Locale.ROOT).withResolverStyle(ResolverStyle.STRICT);
for (var text : new String[]{"2023-02-28", "2023-02-29", "2024-02-29", "2024-02-30"}) {
try {
System.out.println(text + " -> " + LocalDate.parse(text, strict));
} catch (DateTimeParseException e) {
System.out.println(text + " -> " + e.getClass().getSimpleName());
}
}
Windows 11의 OpenJDK·JShell 17.0.2에서 직접 실행한 출력이다. 실제 서비스 장애 로그가 아니라 고정한 합성 날짜의 비교 결과다. 예외는 다음 입력도 확인하기 위해 여기서 잡아 출력했다.
SMART
2023-02-28
2023-02-28 -> 2023-02-28
2023-02-29 -> DateTimeParseException
2024-02-29 -> 2024-02-29
2024-02-30 -> DateTimeParseException
첫 번째 파싱은 입력의 30일이 28일로 바뀌어 성공했다. STRICT를 적용한 네 입력에서는 평년의 2월 28일과 윤년의 2월 29일이 그대로 통과했고, 평년의 2월 29일과 윤년의 2월 30일은 거절됐다. 입력을 거절하는 코드에는 이렇게 통과해야 할 정상 날짜도 함께 넣어야 한다. 모든 날짜가 실패하는 설정을 올바른 검증으로 오해할 수 있기 때문이다.
공식 문서는 문자열에서 연·월·일 필드를 읽는 단계와, 그 필드들을 날짜로 확정하는 단계를 구분한다. ResolverStyle은 두 번째 단계를 제어한다. 따라서 문자열의 모양이 패턴에 맞았다는 사실만으로 달력에 존재하는 날짜라고 판단할 수 없다. DateTimeFormatter의 파싱 단계 (새 탭에서 열림)
ISO 연·월·일 조합에서 SMART는 일자를 1~31 범위로 검사한 뒤, 그 달의 끝을 넘으면 마지막 날로 맞춘다. STRICT는 해당 연·월에 실제로 존재하는 일자여야 통과시킨다. ResolverStyle의 SMART와 STRICT 규칙 (새 탭에서 열림)
추가 비교에서도 uuuu-MM-dd의 SMART는 2024-02-30을 2024-02-29로, 2024-04-31을 2024-04-30으로 바꿨다. 반면 2024-02-32와 2024-02-00은 예외였다. SMART가 어떤 숫자든 다음 달로 넘겨 주는 정책이라고 해석하면 안 된다. 같은 입력들의 STRICT 결과는 모두 예외였다.
STRICT에서 정상 날짜까지 실패한다면
패턴의 u는 연도, y는 연호 안의 연도를 뜻한다. 두 패턴이 서기 날짜를 똑같이 출력하더라도 파싱 중 만드는 연도 필드는 다르다. 연도 패턴의 정의 (새 탭에서 열림)
앞의 JShell에서 다음 코드를 이어 실행하면, yyyy를 유지한 채 STRICT만 적용했을 때 윤년의 정상 날짜가 어떻게 처리되는지 확인할 수 있다.
var strictY = current.withResolverStyle(ResolverStyle.STRICT);
try {
LocalDate.parse("2024-02-29", strictY);
} catch (DateTimeParseException e) {
System.out.println(e.getMessage());
}
이번 실행은 DateTimeParseException으로 실패했고 메시지에는 Unable to obtain LocalDate와 YearOfEra=2024가 들어 있었다. 같은 2024-02-29가 앞의 uuuu+STRICT에서는 통과했다. 이 차이는 윤년 계산 오류가 아니라 날짜를 확정할 연도 정보의 차이로 설명할 수 있다.
ISO 달력의 해석 규칙에 따르면 연호 없이 연호 안의 연도만 있을 때 SMART는 서기를 가정한다. STRICT는 연호를 가정하지 않아 그 필드를 그대로 남긴다. 이번 yyyy-MM-dd 입력에는 연호가 없으므로 LocalDate로 확정할 수 없었다. 연호를 받지 않는 숫자 연·월·일 입력에는 uuuu-MM-dd를 사용하는 이유다. 연호를 별도로 받는 입력 계약은 그 정보를 함께 해석하도록 설계해야 한다. IsoChronology의 YEAR_OF_ERA 처리 (새 탭에서 열림)
withResolverStyle의 반환값도 실제 파싱에 사용해야 한다. 이 메서드는 원본을 변경하지 않고 설정을 바꾼 formatter를 반환한다. 별도로 반환값을 버리고 원본으로 다시 파싱했을 때 정책은 여전히 SMART였고, 2023-02-30도 다시 2023-02-28로 보정됐다. 위 코드처럼 생성식에 연결하거나 반환값을 변수에 보관한다. withResolverStyle의 반환 계약 (새 탭에서 열림)
ISO 날짜만 받는 코드라면
입력 계약이 2024-02-29 같은 ISO 날짜라면 LocalDate.parse(text)도 사용할 수 있다. 이 오버로드는 ISO_LOCAL_DATE를 사용하며, 그 formatter는 STRICT다. 별도 실행에서 평년의 2023-02-28과 윤년의 2024-02-29는 통과했고, 2023-02-29와 2024-02-30은 예외였다. 그러므로 formatter 없는 호출에서도 잘못된 일자가 보정된 것처럼 보인다면 실제 호출과 파싱 직전의 입력을 다시 확인한다. LocalDate.parse의 기본 formatter (새 탭에서 열림), ISO_LOCAL_DATE의 정책 (새 탭에서 열림)
자체 패턴이 필요한 경우에는 구분자 등 기존 입력 계약을 유지하면서 연도 필드와 정책을 고른다. 오류를 사용자 입력 오류로 돌려주려면 파싱 경계에서 DateTimeParseException을 처리한다. 예제의 예외 출력은 결과 비교용이며, 오류를 잡은 뒤 보정한 날짜로 저장하는 처리까지 뜻하지 않는다. 허용 연도나 미래 날짜 금지 같은 업무 규칙은 달력상 유효성과 별도로 검사한다. JShell은 /exit로 종료한다.
실행 범위는 2023·2024년의 2월·4월 합성 날짜 10개를 두 연도 패턴과 SMART·STRICT로 조합한 비교다. 반환값을 버린 경우와 기본 파싱도 추가 확인했지만, JDK 21에서 재실행하지는 않았다. Java SE 17·21의 관련 공식 문서를 대조했다. 모든 값은 시각·시간대가 없는 ISO LocalDate이며, 숫자 패턴에는 Locale.ROOT를 지정했고 시스템 설정은 바꾸지 않았다. 다른 달력 체계, 연호가 포함된 입력, 0년·음수 연도·연도 상한, 지역별 날짜 문자열과 시간대 변환은 실행 검증하지 않았다.