Skip to Content

8장. Date와 Calendar는 왜 실패했나 — java.time 이전의 세계

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

이름과 내용이 다른 Date → 가변 Calendar와 공유 포맷터의 함정 → java.time의 불변 타입 분리.

면접 실전 질문: ① java.util.Date가 실제로 담는 값은 무엇인가? ② Calendar.MONTH의 0 시작이 위험한 이유는? ③ SimpleDateFormat을 공유하면 왜 경쟁 조건이 생기는가?


java.util.Date는 이름과 달리 날짜가 아니라 순간 하나를 담은 가변 상자였다 — Parts 1~2가 애써 구분한 순간·지역 시각·오프셋·타임존을, 초기 자바는 애초에 구분할 이름조차 갖지 못했다.

면접 실전 질문: ① java.util.Date는 정확히 무엇을 감싸고 있으며, 왜 이름과 실제 내용이 다른가? ② Calendar.MONTH가 0부터 시작하는 게 왜 단순 실수가 아니라 구조적 문제인가? ③ SimpleDateFormatstatic 필드로 공유하면 왜 위험한가?


배경 — 이름 하나에 순간을 욱여넣다

7장은 문자열 표준을 정리하며 질문 하나를 남겼습니다. 전송·저장 경계를 오갈 규격은 정해졌는데, 그 문자열이 자바 코드 안으로 들어온 다음엔 무엇이 되는가. 이 질문에 자바가 처음 내놓은 답이 java.util.Date입니다. 1996년, 자바 1.0과 함께 태어났습니다.

이름부터 문제였습니다. Parts 1~2에서 우리는 네 가지를 구분하는 법을 배웠습니다. 순간(instant, 시간 축 위의 한 점), 지역 시각(wall-clock time, 벽시계 눈금), 오프셋(UTC와의 차이), 타임존(그 오프셋을 만드는 규칙의 집합). 6장은 지역 시각 하나가 존재하지 않거나 두 번 존재할 수 있다는 걸, 7장은 오프셋과 타임존이 서로 다른 정보라는 걸 실측으로 보여줬습니다. 이 구분이 없으면 어떤 일이 벌어지는지, Date가 그대로 증거입니다. Date라는 이름은 “날짜”를 말하지만, 내부에 든 건 1970년 1월 1일부터 흐른 밀리초 하나입니다. 그러니까 Date는 사실 4장에서 본 epoch 정수를 포장한 순간입니다. 날짜도, 지역 시각도, 타임존도 아니에요. 이름이 내용과 어긋난 첫 번째 자리입니다.

스토리 — 1년 만에 폐기된 API, 그리고 그 후계자

Date가 원래 하려던 일은 훨씬 많았습니다. 1.0 시절 Date는 연·월·일·시·분·초를 직접 넣고 빼는 생성자와 접근자를 갖고 있었고, 문자열 포맷·파싱까지 스스로 해냈습니다. 그런데 이 설계가 오래가지 못했습니다. 1997년, 자바 1.1에서 이 기능 대부분이 deprecated로 처리됩니다. 1.0이 나온 지 1년 만입니다. 이유는 국제화였습니다. 연·월·일을 다루는 방식도, 문자열을 포맷하는 방식도 나라마다 다른데, Date의 API는 그 차이를 받아낼 자리가 없었습니다. 그 자리를 대신하라고 새로 나온 게 java.util.Calendarjava.text.DateFormat이었습니다.

문제는 CalendarDate의 결함을 고치면서 자기 결함을 새로 만들었다는 겁니다. 가장 유명한 자리부터 봅니다. 월(MONTH)은 0부터 시작합니다. 1월이 0, 12월이 11이에요. 반면 같은 클래스 안에서 일(DAY_OF_MONTH)은 1부터 시작합니다. 한 클래스 안에 기준이 다른 두 필드가 나란히 있는 겁니다. 실제로 확인해 봤습니다.

import java.util.Calendar; import java.util.GregorianCalendar; public class CalendarMonthTrap { public static void main(String[] args) { Calendar cal = new GregorianCalendar(); // "2024년 3월 15일"을 적으려는 의도로 3을 넣었다. cal.set(2024, 3, 15); System.out.println("cal.set(2024, 3, 15) 이후 cal.getTime() = " + cal.getTime()); System.out.println("cal.get(Calendar.MONTH) = " + cal.get(Calendar.MONTH) + " (0=1월 ... 3=4월)"); System.out.println("cal.get(Calendar.DAY_OF_MONTH) = " + cal.get(Calendar.DAY_OF_MONTH) + " (1부터 시작)"); } }
cal.set(2024, 3, 15) 이후 cal.getTime() = Mon Apr 15 00:58:23 UTC 2024 cal.get(Calendar.MONTH) = 3 (0=1월 ... 3=4월) cal.get(Calendar.DAY_OF_MONTH) = 15 (1부터 시작)

3월 15일을 적으려고 3을 넣었는데 결과는 4월 15일입니다. MONTH는 0부터 세니까 3은 네 번째 달, 즉 4월이거든요. 이건 오타 한 번으로 끝나는 실수가 아닙니다. DAY_OF_MONTH가 1부터 시작한다는 걸 이미 알고 있는 개발자일수록, MONTH도 당연히 1부터일 거라 가정하고 코드를 씁니다. 같은 API 하나가 서로 다른 세는 방식을 나란히 들고 있으니, 아는 사람도 매번 틀립니다.

여기에 또 한 겹이 붙습니다. Calendar.getInstance()를 부르면 JVM의 기본 타임존을 그 자리에서 조용히 붙잡습니다. 어느 타임존을 쓸지 명시하지 않으면, 이 코드를 실행하는 서버가 어느 지역에 배포됐는지에 따라 Calendar 인스턴스가 담는 값 자체가 달라집니다. 호출한 쪽 코드는 아무것도 지정하지 않았는데, 결과는 실행 환경에 따라 달라지는 겁니다. 그리고 Calendar는 애초에 달력 체계·타임존·순간을 객체 하나 안에 한꺼번에 쥐고 있습니다. 5장에서 본 “타임존은 파일”이라는 독립된 데이터, 6장에서 본 “지역 시각은 순간이 아니다”라는 구분 — 이 모든 걸 하나의 가변 객체에 뭉쳐 넣은 게 Calendar의 설계입니다. 개념을 나누지 않으면 나중에 그 개념들이 서로를 침범합니다.

핵심 — 가변성과 기본 타임존, 두 개의 함정

Date가 초창기 접근자를 잃었다고 해서 위험이 사라진 건 아닙니다. Date에는 여전히 setTime()이 남아 있고, Date는 여전히 가변(mutable) 객체입니다. 어떤 객체에게 Date를 하나 건네주면, 그 객체는 원본과 같은 인스턴스를 쥐게 됩니다. 복사본이 아니에요. 원본을 들고 있는 다른 코드가 setTime()을 부르면, 건네받은 쪽의 값도 같이 바뀝니다.

import java.util.Date; public class DateMutability { static class Reservation { private final Date createdAt; Reservation(Date createdAt) { this.createdAt = createdAt; } Date getCreatedAt() { return createdAt; } } public static void main(String[] args) { Date original = new Date(1_700_000_000_000L); Reservation reservation = new Reservation(original); System.out.println("생성 시점 reservation.createdAt = " + reservation.getCreatedAt()); // 호출한 쪽은 그저 "원본" Date를 손에 쥐고 있었을 뿐인데, // setTime() 한 줄로 reservation 내부의 값까지 같이 바뀐다. original.setTime(0L); System.out.println("original.setTime(0) 이후 reservation.createdAt = " + reservation.getCreatedAt()); System.out.println("두 참조가 같은 객체인가? = " + (original == reservation.getCreatedAt())); } }
생성 시점 reservation.createdAt = Tue Nov 14 22:13:20 UTC 2023 original.setTime(0) 이후 reservation.createdAt = Thu Jan 01 00:00:00 UTC 1970 두 참조가 같은 객체인가? = true

ReservationcreatedAtfinal로 선언해뒀지만 소용없습니다. final이 막는 건 필드가 다른 객체를 가리키게 되는 것뿐이지, 그 객체 내부 상태가 바뀌는 것은 못 막습니다. 예약이 생성된 시각처럼 한 번 정해지면 다시는 바뀌면 안 되는 값에, 아무 코드나 setTime() 한 줄로 손댈 수 있는 문 하나가 열려 있는 셈입니다.

두 번째 함정은 기본 타임존입니다. Date.toString()은 저장된 밀리초 값을 사람이 읽을 문자열로 바꿀 때 JVM의 기본 타임존을 씁니다. Date 객체 자체에는 타임존 정보가 없는데, 화면에 찍을 땐 실행 환경의 타임존을 빌려 씁니다. 그러니 같은 순간을 가리키는 같은 객체가, 어느 서버에서 출력하느냐에 따라 다른 문자열로 보입니다.

import java.util.Date; import java.util.TimeZone; public class DefaultZoneTrap { public static void main(String[] args) { Date instant = new Date(1_700_000_000_000L); TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); System.out.println("JVM 기본 zone = Asia/Seoul -> instant.toString() = " + instant); TimeZone.setDefault(TimeZone.getTimeZone("UTC")); System.out.println("JVM 기본 zone = UTC -> instant.toString() = " + instant); TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")); System.out.println("JVM 기본 zone = America/New_York -> instant.toString() = " + instant); System.out.println("세 줄 모두 같은 Date 인스턴스, 같은 getTime()=" + instant.getTime() + " (ms) 를 가리킨다."); } }
JVM 기본 zone = Asia/Seoul -> instant.toString() = Wed Nov 15 07:13:20 KST 2023 JVM 기본 zone = UTC -> instant.toString() = Tue Nov 14 22:13:20 UTC 2023 JVM 기본 zone = America/New_York -> instant.toString() = Tue Nov 14 17:13:20 EST 2023 세 줄 모두 같은 Date 인스턴스, 같은 getTime()=1700000000000 (ms) 를 가리킨다.

같은 getTime() 값 하나가 문자열로는 세 가지 얼굴을 보입니다. 이게 “로컬에서는 멀쩡히 되는데 서버에 올리면 시각이 어긋난다”는 흔한 버그의 정체입니다. 개발자 노트북의 기본 타임존과 배포된 서버(흔히 UTC로 맞춰진)의 기본 타임존이 다르면, 로그에 찍힌 시각, 알림에 적힌 시각, 화면에 뜬 시각이 전부 그 차이만큼 어긋납니다. 코드 어디에도 버그가 없는데도요.

마지막 함정은 DateFormat의 구현체인 SimpleDateFormat입니다. 이 클래스는 스레드 안전하지 않습니다. format()parse()가 내부적으로 Calendar 필드 같은 가변 상태를 공유해 씁니다. 그런데 겉모습은 상태 없는 유틸리티처럼 보여서, 실무에서 가장 흔히 저지르는 실수가 이걸 static 필드로 만들어 여러 스레드가 공유하는 것입니다. 직접 재현해 봤습니다. 스레드 50개가 같은 SimpleDateFormat 인스턴스로 같은 값을 200번씩 포맷·파싱합니다.

import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; public class SimpleDateFormatRace { // 실무에서 가장 흔한 실수 그대로: static 필드로 공유. static final SimpleDateFormat SHARED = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static void main(String[] args) throws InterruptedException { int threadCount = 50; int iterationsPerThread = 200; Date fixed = new Date(1_700_000_000_000L); final String expected; synchronized (SHARED) { expected = SHARED.format(fixed); } System.out.println("기대하는 포맷 결과 = " + expected); AtomicInteger wrongResult = new AtomicInteger(0); AtomicInteger exceptionCount = new AtomicInteger(0); CountDownLatch latch = new CountDownLatch(threadCount); for (int t = 0; t < threadCount; t++) { new Thread(() -> { try { for (int i = 0; i < iterationsPerThread; i++) { try { String formatted = SHARED.format(fixed); if (!expected.equals(formatted)) { wrongResult.incrementAndGet(); } Date parsed = SHARED.parse(expected); if (parsed.getTime() != fixed.getTime()) { wrongResult.incrementAndGet(); } } catch (ParseException | NumberFormatException | ArrayIndexOutOfBoundsException | StringIndexOutOfBoundsException e) { exceptionCount.incrementAndGet(); } } } finally { latch.countDown(); } }).start(); } latch.await(); System.out.println("스레드 수 = " + threadCount + ", 스레드당 반복 = " + iterationsPerThread); System.out.println("잘못된(기대와 다른) 결과 횟수 = " + wrongResult.get()); System.out.println("예외 발생 횟수 = " + exceptionCount.get()); } }
기대하는 포맷 결과 = 2023-11-14 22:13:20 Exception in thread "Thread-21" java.lang.ClassCastException: class sun.util.calendar.Gregorian$Date cannot be cast to class sun.util.calendar.JulianCalendar$Date ... at java.base/sun.util.calendar.JulianCalendar.getCalendarDateFromFixedDate(JulianCalendar.java:186) at java.base/java.util.GregorianCalendar.computeFields(GregorianCalendar.java:2385) at java.base/java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1563) ... 스레드 수 = 50, 스레드당 반복 = 200 잘못된(기대와 다른) 결과 횟수 = 4873 예외 발생 횟수 = 4045

기대와 다른 결과가 10,000번 중 4,873번, 잡아둔 예외 클래스 목록에도 없던 ClassCastException이 별도 스레드를 죽이며 튀어나온 게 4,045번입니다.

경쟁 조건이라 정확한 횟수와 예외 종류는 실행할 때마다, JDK 버전마다 달라집니다. 위 숫자는 한 번의 실행 결과일 뿐이고, 다시 돌리면 IndexOutOfBoundsException이 나오기도 합니다. 변하지 않는 건 “매번 재현된다”는 사실 자체입니다.

format()parse()가 내부 Calendar 상태를 스레드 간 조율 없이 그대로 주무르다 보니, 한 스레드가 필드를 절반쯤 고쳐놓은 순간 다른 스레드가 그 값을 읽어 계산을 이어갑니다. 운이 나쁘면 자바 내부 캘린더 구현이 서로 다른 두 스레드의 상태를 뒤섞어 캐스팅 자체가 깨집니다. 재현하려고 애쓸 필요도 없었습니다. 스레드 50개, 반복 200번이면 바로 터집니다. 이게 왜 유명한 프로덕션 버그의 단골 원인인지, 숫자가 그대로 답합니다.

정리

  • Date는 이름과 다르게 순간(밀리초 카운터)을 담는다. Parts 1~2가 구분한 순간·지역 시각·오프셋·타임존 중 Date가 실제로 가진 건 순간 하나뿐이면서, 이름은 “날짜”라고 말한다.
  • 1.0(1996)에서 1.1(1997)로 넘어가며 1년 만에 Date의 연·월·일 접근자와 생성자 대부분이 deprecated 됐다. 이유는 국제화 — 자리를 대신한 게 CalendarDateFormat이다.
  • Calendar는 자기 결함을 새로 만들었다. MONTH는 0부터, DAY_OF_MONTH는 1부터 시작하는 불일치가 한 클래스 안에 공존한다(실측: set(2024, 3, 15) → 4월 15일). getInstance()는 JVM 기본 타임존을 조용히 붙잡고, 달력 체계·타임존·순간을 객체 하나에 뭉쳐 담는다.
  • Date는 가변 객체다. setTime()으로 다른 코드가 들고 있는 참조까지 바꿀 수 있다(실측 확인). final 필드도 내부 상태 변경은 막지 못한다.
  • Date.toString()은 JVM 기본 타임존을 빌려 쓴다. 같은 순간, 같은 객체가 개발자 노트북과 UTC 서버에서 다른 문자열로 찍힌다(실측 확인) — “로컬에선 되는데 서버에선 안 맞는” 시간 버그의 흔한 근원.
  • SimpleDateFormat은 스레드 안전하지 않다. static 필드로 공유해 스레드 50개가 동시에 쓰면 잘못된 결과와 예외가 곧바로 터진다(한 번의 실측: 10,000회 중 오류 4,873회, 예외 4,045회 — 경쟁 조건이라 횟수와 예외 종류는 실행·JDK 버전마다 달라지고, 재현된다는 사실만 변하지 않는다).
  • 이 모든 실패는 한 가지로 수렴한다. 개념을 나눌 이름이 없었다. 순간과 지역 시각을 같은 상자에 담고, 가변 상태를 공유하고, 기본 타임존을 암묵적으로 빌려 쓴 자리마다 버그가 숨어 있었다. 2014년 자바 8과 함께 나온 java.time(JSR-310, 스티븐 콜본이 자신의 Joda-Time 경험을 바탕으로 설계)이 다르게 한 건 딱 두 가지다. 불변(immutable) 객체로 만들고, 서로 다른 개념마다 서로 다른 타입을 줬다.

생각해볼 질문: “서로 다른 개념마다 서로 다른 타입”이라는 원칙을 실제로 적용하면 자바는 순간 하나에도 Instant, LocalDateTime, ZonedDateTime, OffsetDateTime 같은 여러 타입을 나란히 두게 됩니다. 왜 하나로 통일하지 않고 굳이 여러 타입으로 쪼갰을까요? 그리고 그중에서도 가장 자주 헷갈리는 두 타입 — 타임존 없는 LocalDateTime과 타임존 없이 순간만 가리키는 Instant — 은 언제 어느 쪽을 써야 할까요?

9장 · Instant vs LocalDateTime — 무엇을 언제 쓰는가

Last updated on