12월 마지막 주에만 주문번호의 연도가 내년으로 찍힌 이유
문제 발생
주문번호 앞에 날짜를 붙이는 코드였습니다.
private static final DateTimeFormatter ORDER_DATE = DateTimeFormatter.ofPattern("YYYYMMdd");
String orderNo = LocalDate.now().format(ORDER_DATE) + "-" + sequence;1년 가까이 아무 문제가 없었습니다. 그러다 작년 12월 28일 일요일, 그날 들어온 주문부터 번호가 20261228-로 시작했습니다. 아직 2025년인데 말입니다. 바로 전날 주문은 20251227-로 멀쩡했습니다.
서버 시계부터 의심했는데 시계는 정확했습니다. 더 이상한 건 1월 1일이 되자 아무것도 고치지 않았는데 20260101-로 돌아왔다는 점이었습니다. 그 나흘 동안, 주문번호 앞자리로 그날 주문을 모으던 정산 스크립트는 한 건도 찾지 못했습니다.
원인 분석
대문자 Y는 달력의 연도가 아닙니다. DateTimeFormatter 문서의 패턴 문자 표를 보면 u가 year, y가 year-of-era이고, Y는 week-based-year입니다. 그 날짜가 속한 주가 몇 년의 주인지를 찍는 문자입니다. 1년 중 거의 모든 날은 두 값이 같아서 테스트에서도 리뷰에서도 티가 나지 않습니다.
해가 바뀌는 주에서만 두 값이 갈립니다. WeekFields 문서는 1주차를 주의 첫 요일에 시작하면서 그 해의 날이 최소 일수 이상 들어 있는 주로 정의하고, 그래서 1주차가 해가 바뀌기 전에 시작할 수 있다고 적어 둡니다. 2026년 1월 1일은 목요일이었고 그 주는 일요일인 12월 28일에 시작했습니다. 12월 28일부터 31일까지는 달력으로는 2025년이지만 주로 따지면 2026년 1주차였던 겁니다.
어느 날짜가 틀어지는지는 로캘이 정합니다. DateTimeFormatterBuilder 문서에 따르면 패턴의 Y는 로캘에 맞춘 WeekFields로 처리됩니다. 한국과 미국 로캘은 주가 일요일에 시작하고 첫 주에 하루만 들어 있어도 1주차로 칩니다. 월요일에 시작하고 최소 4일이 필요한 ISO-8601 방식의 로캘에서는 해에 따라 연초 며칠이 전년도로 찍히기도 합니다. 직접 찍어 보면 이렇습니다.
LocalDate.of(2025, 12, 28).format(DateTimeFormatter.ofPattern("YYYYMMdd", Locale.KOREA)); // 20261228
LocalDate.of(2027, 1, 1).format(DateTimeFormatter.ofPattern("YYYYMMdd", Locale.KOREA)); // 20270101
LocalDate.of(2027, 1, 1).format(DateTimeFormatter.ofPattern("YYYYMMdd", Locale.UK)); // 202601011월 1일에 저절로 고쳐진 게 아니었습니다. 그대로 뒀다면 올해는 12월 27일부터 다시 2027이 찍힙니다. SimpleDateFormat도 사정이 같아서, 문서의 표에 Y는 Week year로 올라 있습니다.
같은 패턴으로 파싱을 했다면 조용히 넘어가지 않고 DateTimeParseException이 났을 겁니다. Unable to obtain LocalDate from TemporalAccessor라는 메시지와 함께요. 포맷에만 쓰고 있었으니 1년 동안 아무 신호가 없었습니다.
해결 방안
- 연도는 소문자
yyyy로 적습니다.
private static final DateTimeFormatter ORDER_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");같은 포매터로 엄격하게 파싱까지 한다면 uuuu를 씁니다. yyyy는 연대(era) 안에서의 연도라서, ResolverStyle.STRICT에서는 연대 없이 날짜로 풀리지 않고 예외가 납니다.
-
표준 형식이면 패턴을 직접 적지 않습니다.
DateTimeFormatter.BASIC_ISO_DATE는20261227을,ISO_LOCAL_DATE는2026-12-27을 만들어 줍니다. 패턴 문자열이 없으면 대소문자를 틀릴 자리도 없습니다. -
연말 날짜를 테스트에 넣어 둡니다. 오늘 날짜로만 확인하면 이 버그는 1년에 며칠만 걸립니다.
@Test
void 연말에도_달력의_연도가_찍힌다() {
assertEquals("20251228", LocalDate.of(2025, 12, 28).format(ORDER_DATE));
assertEquals("20270101", LocalDate.of(2027, 1, 1).format(ORDER_DATE));
}코드가 LocalDate.now()를 직접 부르고 있으면 이런 테스트를 쓸 수 없습니다. 날짜를 인자로 받거나 Clock을 주입받아 LocalDate.now(clock)으로 바꿔 둡니다.
-
빌드에서 막습니다. Error Prone을 쓰는 프로젝트라면
MisusedWeekYear검사가 주차(ww) 없이YYYY만 쓴 패턴을 잡습니다. 심각도가 ERROR라 컴파일이 실패합니다. IntelliJ의 "Suspicious date format pattern" 검사도 근처에w가 없는 대문자Y를 표시해 줍니다. 이 검사는MM과mm,DD와dd를 바꿔 쓴 것도 같이 봅니다.DD는 그 해의 몇 번째 날인지라서 1월에는 멀쩡하다가 3월 5일이2026-03-64로 찍힙니다. -
이미 저장된 값도 찾아 둡니다. 번호 앞 네 자리와 생성 시각의 연도가 다른 행이 그 며칠 치입니다. MySQL이라면 이렇게 찾습니다.
SELECT id, order_no, created_at
FROM orders
WHERE LEFT(order_no, 4) <> DATE_FORMAT(created_at, '%Y');created_at을 UTC로 저장한다면 1월 1일 자정 근처 주문이 섞여 나오니 시간대를 맞춰서 비교합니다. 이미 고객에게 나간 번호는 바꾸지 않았고, 정산 스크립트가 번호 대신 created_at으로 주문을 모으게 고쳤습니다.
- 주차가 정말 필요할 때는
Y를w와 같이 씁니다. 주간 리포트처럼 "2026년 53주차"가 필요한 자리라면Y가 맞는 문자입니다. ISO 주차라면DateTimeFormatter.ISO_WEEK_DATE가2026-W53-5같은 값을 만들어 줍니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.