1. 시작의 배경
최근 JWT(JSON Web Token) 인증 기능을 리팩토링 및 단위테스트 구현을 하면서, 그동안 깊게 고민하지 않고 작성했던 '시간(Time)을 다루는 방식'에 대해 정리하는 시간을 갖게 되었다. 처음 코드를 구현했을 때는 매우 단순하게 생각했다.
LocalDateTime now = LocalDateTime.now();
LocalDateTime expiresAt = now.plusMinutes(30);
현재 시각을 구하고, 30분을 더해 만료 시각을 계산한다. 문법적으로 완벽하고 직관적이며 읽기 쉽다.
하지만 코드를 깊게 들여다보고 분산 환경을 고려하기 시작하자 여러 가지 문제가 수면 위로 드러났다.
- JWT 만료 시간을 정말 `LocalDateTime`으로 표현하는 것이 맞을까?
- 글로벌 서비스에서 서버의 타임존(Time Zone)이 변경되면 이 코드는 어떻게 될까?
- 서로 다른 지역에 배포된 서버들이 완전히 동일한 시간을 바라본다고 보장할 수 있을까?
- 테스트 코드에서 현재 시간을 원하는 시점으로 고정하려면 어떻게 해야 할까?
- `30 * 60 * 1000` 같은 밀리초(millis) 기반의 마술 숫자(Magic Number) 계산은 안전할까?
시간(Time)을 바라보는 4가지 질문
조금 더 깊이 조사해 보니 이는 단순히 `LocalDateTime` 하나만의 문제가 아니었다.
우리가 막연하게 '시간'이라고 부르는 개념 속에는 서로 다른 성격의 질문들이 얽혀 있었다.
- "지금 현재 시각은 몇 시인가?" -> 현재 시각의 공급처
- "특정 이벤트가 발생한 시점은 언제인가?" -> 타임존에 독립적인 절대 시점
- "토큰의 유효 기간은 얼마나 되는가?" -> 두 시점 사이의 시간 길이
- "사용자가 화면에서 보는 예약 시간은 몇 시인가?" -> 사람이 인지하는 날짜와 시간
Java Time API는 이러한 질문들에 정확히 대응하기 위해 `Clock`, `Instant`, `Duration`, `LocalDateTime`, `ZonedDateTime` 등의 다양한 타입을 제공한다. 하지만 많은 개발자가 각 타입의 정확한 존재 이유를 모른 채, 익숙하다는 이유로 다음과 같은 코드를 습관적으로 작성하곤 한다.
// 가독성이 떨어지는 밀리초 계산
long expirationMillis = 30 * 60 * 1000;
// 타임존과 테스트 가능성이 고려되지 않은 시간 계산
LocalDateTime createdAt = LocalDateTime.now();
LocalDateTime expiredAt = createdAt.plusMinutes(30);
당장 실행하는 데는 문제가 없다. 하지만 이러한 코드가 쌓이면 테스트하기 어려워지고, 타임존 변경 시 장애가 발생하며, 원인을 찾기 힘든 시간 관련 버그가 시스템에 숨어들게 된다. 당장에 JWT 관련 단위테스트를 꽤 여러 케이스로 구현을 도전한 경험이 있다면 시간은 반드시 부딪히는 문제이다.
이 글에서 다룰 핵심 원칙
이번 JWT 리팩토링을 계기로 Java Time API의 핵심 타입들을 단순히 소개하는 것을 넘어, 실무 관점의 명확한 선택 기준을 정리해 보았다. JWT, Audit(생성/수정일), 로그, 예약 시스템 등 실제 파트별 예시를 통해 최종적으로 다음과 같은 실무 원칙을 도출해 보고자 한다.
- 현재 시각은 `Clock`을 통해 가져온다.
- 절대 시점은 `Instant`로 저장한다.
- 시간의 길이는 `Duration`으로 표현한다.
- 사람이 보는 시간은 `LocalDateTime` 또는 `ZonedDateTime`으로 다룬다.
그럼 먼저, 우리가 흔히 '시간'이라고 뭉뚱그려 표현하는 개념을 자바에서 제공하는 시간 API를 통해 4가지 관점으로 나누어 구체적으로 살펴볼 수 있다. 그전에 먼저 4가지 관점을 살펴보기 전에 시간이라는 개념을 이해하자.
2. Java API 뒤에 숨은 시공간의 약속: UTC, GMT, 그리고 Epoch Time
Java Time API를 제대로 선택하려면, 먼저 코드가 실행되는 현실 세계의 시간 기준들을 이해해야 한다. 우리가 흔히 "지금 몇 시야?"라고 묻는 단순한 질문 뒤에는 전 세계 엔지니어들이 합의한 복잡한 표준이 숨어있기 때문이다.
1) GMT와 UTC: 기준점의 진화
우리가 가장 자주 접하는 두 단어이지만, 이 둘은 엄연히 다르다.


- GMT (Greenwich Mean Time, 그리니치 평균시): 19세기부터 사용된 개념으로, 영국 런던의 그리니치 천문대를 기준으로 하는 '지구 자전 주기' 기반의 시간이다. 문제는 지국의 자전 주기가 미세하게 불규칙하다는 점이다. 이 때문에 현대 과학과 정밀한 컴퓨터 시스템에서는 더 이상 GMT를 절대적인 기준으로 삼지 않다.
- UTC (Coordinated Universal Time, 협정 세계시): GMT의 오차를 보완하기 위해 1972년에 제정된 현대 표준 시간대이다. 자전 주기가 아닌, 오차가 거의 없는 '원자시계'를 기준으로 삼는다. 기술적인 문서나 소스코드에서 "기준 시간"을 의미할 때는 GMT가 아닌 UTC를 사용하는 것이 정확하다.
💡 쉽게 말하자면, GMT는 과거의 기준, UTC는 현대 과학과 컴퓨터가 사용하는 정밀한 기준이다.
두 시간의 차이는 소수점 단위 초에 불과하지만, 시스템을 설계할 때는 항상 UTC를 기준으로 생각해야 한다.
2) 타임존(Time Zone)과 오프셋(Offset): 공간과 시간의 연결
지구는 둥글고 자전하기 때문에 지역마다 해가 뜨고 지는 시간이 다릅니다. 이 차이를 표현하기 위해 두 가지 개념이 존재합니다.
- 오프셋(Offset): UTC 기준 시간과 얼마나 시간 차이가 나는지를 단순히 `+` 또는 `-`로 표현한 것이다. 예를 들어 한국은 UTC보다 9시간 빠르기 때문에 +09:00으로 표현한다.
- 타임존(Time Zone): 특정 국가나 지역이 법적으로 채택하여 사용하는 시간 규칙의 '이름'이다. 주로 Asia/Seoul, America/New_York 형태로 표현된다.
"타임존은 단순히 오프셋과 같은 것 아닌가요?"
충분히 생각할 수 있다. 그러나 치명적인 차이가 있다. 바로 서머타임(DST, Daylight Saving Time) 때문이다. 예를 들어 뉴욕(America/New_York)은 평소에는 UTC-5(오프셋)이지만, 여름철 서머타임이 적용되면 UTC-4로 오프셋이 변한다. 즉, 하나의 타임존 안에서 시기에 따라 오프셋이 달라질 수 있다. 글로벌 서비스를 개발할 때 단순히 '숫자(오프셋)'만 저장하면 서머타임이 적용되는 국가의 사용자는 원치 않는 시간 오류를 겪게 된다.
💡서머타임(Summer Time): 여름철에 낮 시간을 효율적으로 활용하고 에너지를 절약하기 위해 표준시보다 시계를 1시간 앞당기는 제도이다. 공식 명칭은 일광 절약 시간제(Daylight Saving Time, DST)으로 한국은 서머타임을 시행하지 않기 때문에, 해당 제도를 시행하는 국가와 거래하거나 연락할 때 시차가 1시간씩 줄어든다.
3) 에포크 타임(Epoch Time): 컴퓨터가 시간을 기억하는 방법
인간은 '2026년 6월 16일 오후 2시'처럼 년, 월, 일, 시, 분, 초로 시간을 인지한다. 하지만 컴퓨터에게 이런 텍스트 형태는 계산하기 너무 복잡하고 무겁다. 타임존에 따라 해석도 달라진다. 그래서 컴퓨터는 전 세계 어디서나 동일한 하나의 '기준점'을 세웠다.
- 1970년 1월 1일 00:00:00 UTC
이 시점을 `0`으로 잡고, 이후로 흘러간 시간을 초(Seconds) 또는 밀리초(Milliseconds) 단위의 숫자로만 기록하는 방식을 에포크 타임(Epoch Time) 또는 유닉스 타임(Unix Time)이라고 부른다. 예를 들어 한국에서 "지금"을 보든, 미국에서 "지금"을 보든, 지구상의 모든 컴퓨터가 가리키는 에포크 타임 정수 값은 완벽히 동일하다. 타임존이라는 인간의 편의를 걷어낸, 우주적이고 절대적인 시점인 셈이다.
2.1 다시 본문으로 돌아와서
이제 앞서 던졌던 4가지 관점에 대한 질문을 이 시공간의 개념들과 연결해 볼 수 있다.
- "JWT 만료 시간이나 로그 발생 시점처럼, 타임존에 영향받지 않는 절대적인 시점은 어떻게 표현해야 할까?"
- 인간의 시간(년/월/일)이 아닌, 전 세계 어디서나 동일한 UTC 기반의 에포크 타임으로 다루어야 안전한다.
- "예약 시스템처럼 사용자의 거주 지역과 서머타임을 고려해야 하는 시간은 어떻게 표현해야 할까?"
- 단순히 시/분만 저장해서는 안 되며, 변하는 오프셋을 포괄하는 정확한 타임존(Asia/Seoul 등) 정보가 함께 결합되어야만 한다.
결국 Java Time API가 제공하는 수많은 타입들은, 우리가 방금 살펴본 UTC, 오프셋, 타임존, 에포크 타임이라는 개념을 개발자가 실수 없이 다룰 수 있도록 쪼개어 놓은 도구 상자이다. 그럼 이제 이 개념들이 자바의 `Instant`, `LocalDateTime`, `Clock`과 어떻게 1:1로 매핑되는지, 그리고 왜 기존 코드가 위험했는지 본격적으로 살펴보자.
3. 시간을 바라보는 4가지 관점
시간(Time)은 백엔드 개발에서 가장 흔하게 다루는 데이터 중 하나다. 하지만 의외로 많은 버그와 설계 미숙이 시간 데이터를 다룰 때 발생한다. 그 이유는 우리가 서로 다른 개념의 시간을 단순히 '시간'이라는 하나의 단어로 뭉뚱그려 생각하기 때문이다. 다음의 질문들을 다시 살펴보자.
- "지금 현재 시각은 몇 시인가?" -> 현재 시각의 공급처
- "특정 이벤트가 발생한 시점은 언제인가?" -> 타임존에 독립적인 절대 시점
- "토큰의 유효 기간은 얼마나 되는가?" -> 두 시점 사이의 시간 길이
- "사용자가 화면에서 보는 예약 시간은 몇 시인가?" -> 사람이 인지하는 날짜와 시간
모두 시간에 관한 이야기 같지만, 데이터 모델링 관점에서는 완전히 다른 성격의 문제들이다. Java Time API는 이러한 차이를 엄격하게 구분하기 위해 여러 타입을 제공한다. 코드를 작성하기 전, 시간을 바라보는 4가지 관점부터 명확히 정립해야 한다.
2-1. 현재 시간을 알려주는 시계 (`Clock`)
첫 번째 관점은 현재 시간을 제공하는 주체가 누구냐는 것이다.
- "지금 시각은 몇 시인가?"
- "현재 시간을 기준으로 만료 시간을 계산하고 싶은가?"
우리는 흔히 현재 시간을 구할 때 `Instant.now()`나 `LocalDateTime.now()`를 사용한다. 하지만 이 메서드들은 내부적으로 시스템 시계(System Clock)에게 현재 시각을 물어보고 있는 것이다. Java Time API에서는 이 시계의 역할을 `Clock`이라는 추상 클래스로 격상시켜 담당하게 한다.
// 1. UTC 기준의 Clock 객체 생성
Clock clock = Clock.systemUTC();
// 2. UTC 기준의 현재 시간 (Instant)
Instant now = Instant.now(clock);
여기서 명심해야 할 핵심은 현재 시각은 애플리케이션이 직접 만들어내는 값이 아니라, 외부(시계)로부터 공급받는 값이라는 점이다. 이 관점은 추후 시스템의 '테스트 가능성(Testability)'을 확보하는 데 엄청난 차이를 만들어낸다.
2-2. 절대적인 특정 시점 (Point in Time)
두 번째는 타임존에 흔들리지 않는 지구상 고유의 절대적인 순간이다.
- 사용자가 로그인한 시각
- 주문이 생성된 시각 (Audit Timestamp)
- JWT 발급 시각 및 만료 시각
- 시스템 로그 및 이벤트 발생 시각
이 정보들은 "언제 발생했는가?"라는 질문에 답한다. 이때 중요한 것은 특정 국가의 달력이나 시계가 아니다. 세계 어디에서 보더라도 완전히 동일한 '그 순간'을 가리키는 것이 핵심이다. 예를 들어, 지구상의 한 시점인 `2026-06-16T05:00:00Z` (UTC)는 다음과 같이 표현된다.
- 서울(Asia/Seoul) 기준: `2026-06-16 14:00:00`
- 뉴욕(America/New_York) 기준: `2026-06-16 01:00:00`
지역에 따라 시계의 숫자는 다르게 보이지만, 실제 사건이 일어난 '그 물리적인 순간'은 단 하나다. 이러한 시간대(타임존)에 의존하지 않고, UTC(협정 세계시)를 기준으로 시간의 특정 한 지점을 표현하는 절대적인 시간을 사용하는 타입이 바로 `Instant`다.
2-3. 시간의 길이 (Time Amount)
세 번째는 특정 시점과 시점 사이의 물리적인 거리, 즉 시간의 길이다.
- JWT 유효 시간 (예: 30분)
- Redis TTL (예: 5분)
- Cache 만료 시간 (예: 1시간)
- API 재시도 간격 (예: 10초)
이 정보들은 "언제"가 아니라 "얼마나"를 의미한다. '30분'이라는 데이터 그 자체는 특정 시각이 아니다. 현재 시각이라는 기준점이 더해져야만 비로소 의미 있는 시점이 된다. 백엔드 시간 계산의 가장 기본 공식은 다음과 같다.
- `특정 시점` + `시간 길이` = `새로운 시점`
Java Time API에서는 이러한 연속적인 시간의 길이를 `Duration`이라는 타입으로 표현한다.
Duration expiration = Duration.ofMinutes(30);
2-4. 사람이 보는 날짜와 시간 (Human-Readable Date-Time)
마지막은 사람이 일상생활에서 인식하고 사용하는 달력 위와 시계 속의 시간이다.
- 2026년 12월 25일 오전 9시
- 내일 오후 2시 미팅
이 정보들은 인간에게는 직관적이지만 컴퓨터에게는 불완전한 데이터다. 타임존(Time Zone) 정보가 없으면 지구상 어디를 뜻하는지 알 수 없기 때문이다. 예를 들어 `2026-06-16 09:00:00`이라는 값만 주어지면, 이것이 서울의 오전 9시인지, 런던의 오전 9시인지 컴퓨터는 해석할 수 없다. 그래서 Java Time API는 이를 세분화하여 두 가지 타입을 제공한다.
- LocalDateTime: 단순히 날짜와 시간의 숫자 조합만 표현한다.
타임존 정보가 없으므로 절대적인 시점을 가리킬 수 없다.LocalDateTime local = LocalDateTime.of(2026, 6, 16, 9, 0); - ZonedDateTime: 날짜와 시간에 더해 시간대(Time Zone) 정보를 명확히 포함한다.
따라서 컴퓨터가 절대 시점으로 변환할 수 있다.ZonedDateTime zoned = ZonedDateTime.of(2026, 6, 16, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"));
2-5. 네 가지 관점 한눈에 정리하기
지금까지 살펴본 4가지 관점을 표로 요약하면 다음과 같다.
| 관점 | 핵심 질문 | 대표 타입 | 실무 예시 |
| 현재 시각 | "지금 몇 시인가?" | `Clock` | 현재 시각 공급 (Test Mocking용) |
| 특정 시점 | "언제 발생했는가?" | `Instant` | JWT 만료 시각, 생성/수정일(Audit) |
| 시간의 길이 | "얼마나 지속되는가?" | `Duration` | JWT 유효 기간, Redis TTL 설정 |
| 사람이 보는 시간 | "어느 지역의 몇 시인가?" | `LocalDateTime`, `ZonedDateTime` |
사용자의 생일, 알림 예약, 글로벌 캘린더 일정 |
실무에서 발생하는 대부분의 시간 버그는 이 개념들을 혼용할 때 발생한다. 절대 시점이 필요한 곳에 타임존이 없는 `LocalDateTime`을 저장하거나, 시간의 길이를 타입 안정성이 없는 `long millis`로 관리하거나, 코드 내부에서 `now()`를 직접 호출해 시계를 감추어 버릴 때 설계가 꼬이기 시작한다. 반대로 이 4가지 관점을 정확히 구분하고 적절한 타입을 매핑하면 코드가 견고해진다. 이제 이러한 개념들을 바탕으로, Java Time API가 제공하는 전체적인 지도를 한눈에 살펴보자.
3. Java Time API 핵심 타입 한눈에 보기
Java 8부터 도입된 Java Time API(java.time)는 날짜와 시간을 하나의 거대한 클래스로 해결하려 하지 않는다. 대신 철저히 분리된 역할에 따라 각각의 전용 타입을 제공한다. 덕분에 우리는 코드에 사용된 타입만 보더라도 해당 데이터가 어떤 비즈니스 의미를 가지는지 직관적으로 파악할 수 있다. 실무에서 마주할 Java Time API의 전체 지도를 한눈에 살펴보자.
3-1. 날짜와 시간의 파편 (Local 시리즈)
📅 LocalDate
- 의미: 타임존이 없는 날짜만 표현한다.
LocalDate birthday = LocalDate.of(1995, 1, 1);
LocalDate today = LocalDate.now();
- 실무 예시: 생일, 공휴일, 정산 기준일, 특정 행사 날짜
⏰ LocalTime
- 의미: 타임존이 없는 시간만 표현한다.
LocalTime openingTime = LocalTime.of(9, 0);
- 실무 예시: 매장 영업 시작 시간, 알람 설정 시간, 매일 반복되는 배치 스케줄
📆 LocalDateTime
- 의미: 날짜와 시간을 결합하여 표현하지만, 여전히 타임존 정보는 없다.
LocalDateTime meetingTime = LocalDateTime.of(2026, 6, 16, 14, 30);
- 실무 예시: UI 입력 화면의 임시 값, 국내 전용 서비스의 게시글 작성 시각 표시
3-2. 글로벌 표준과 시간대 (Zoned & Absolute 시리즈)
🌐 ZonedDateTime
- 의미: 날짜, 시간에 더해 시간대(Time Zone, ZoneId) 정보까지 명확히 포함한다.
ZonedDateTime seoulTime = ZonedDateTime.of(2026, 6, 16, 14, 30, 0, 0, ZoneId.of("Asia/Seoul"));
- 실무 예시: 글로벌 항공/호텔 예약 시스템, 다국적 캘린더 서비스, 사용자 지역별 알림 예약
🌍 Instant
- 의미: UTC(그리니치 천문대)를 기준으로 하는 지구상 고유의 절대 시점을 표현한다.
Instant now = Instant.now();
- 실무 예시: JWT 발급 및 만료 시각, DB Audit(생성/수정일), 시스템 로그 기록 시각, 이벤트 발행 시각
3-3. 인프라스트럭처 타입 (Amount & Provider)
⏳ Duration
- 의미: 초(second)와 나노초(nano) 단위로 측정되는 연속적인 시간의 길이를 표현한다.
Duration ttl = Duration.ofMinutes(30);
- 실무 예시: JWT 유효 기간(TTL), Redis 캐시 만료 시간, API HTTP 타임아웃, 재시도(Retry) 간격
⚙️ Clock
- 의미: 현재 시각을 애플리케이션 외부로부터 안전하게 주입해 주는 시간 공급자 역할을 한다.
Instant now = Instant.now(clock);
Clock clock = Clock.systemUTC();
- 실무 예시: 테스트 코드를 위한 현재 시간 고정(Mocking), 시스템 간 유연한 시간대 동기화
핵심 타입 한눈에 비교하는 요약표
| 타입 | 실제 의미 | 타임존 포함 여부 | 대표 실무 사용 사례 |
| `LocalDate` | 날짜만 표현 | ❌ | 사용자의 생일, 국가 공휴일 |
| `LocalTime` | 시간만 표현 | ❌ | 매장의 영업 시작/종료 시각 |
| `LocalDateTime` | 날짜 + 시간 | ❌ | UI 화면 입력값, 단순 전시용 시각 |
| `ZonedDateTime` | 날짜 + 시간 + 타임존 | ✅ | 글로벌 예약, 다국적 사용자 일정 관리 |
| `Instant` | UTC 기준 절대 시점 | ✅ (UTC 고정) | JWT 만료일, DB Audit, 시스템 로그 |
| `Duration` | 연속적인 시간 길이 | - | JWT TTL, Redis 캐시 만료 정책 |
| `Clock` | 현재 시각 공급자 | 설정 가능 | 시간 의존성 제거, 테스트 코드 작성 |
3-5. 백엔드 실무에서 추천하는 최적의 아키텍처 조합
모든 타입을 같은 빈도로 사용할 필요는 없다.
모던 백엔드 애플리케이션의 핵심 아키텍처는 주로 다음과 같은 역할 분담 구조를 가져간다.
- 데이터 저장 (Storage): 언제나 `Instant`
- 화면 표현 (Presentation): 필요에 따라 `LocalDateTime` 또는 `ZonedDateTime`
- 만료/지연 시간 (TTL): 언제나 `Duration`
- 현재 시각 공급 (Provider): 언제나 `Clock`
결국 실무 시간 설계의 핵심은 모든 타입을 골고루 쓰는 것이 아니라,레이어와 목적에 맞는 정확한 타입을 선택하는 안목을 갖추는 것이다. 그럼 다음 장에서는 우리에게 가장 익숙하지만, 동시에 가장 많은 오해를 사고 있는 LocalDateTime에 대해 완벽하게 해부해 보자.
4. LocalDateTime 완벽 이해하기
Java Time API를 사용할 때 개발자들이 가장 먼저, 그리고 가장 자주 접하게 되는 타입은 단연 `LocalDateTime`이다. 실제로 많은 프로젝트에서 생성일, 수정일, 예약 시간, 만료 시간 등 거의 모든 시간 데이터에 이 타입을 전역적으로 사용하곤 한다. 문법이 직관적이고 사용하기 쉬워서 처음에는 거의 만능 치트키처럼 보이지만, `LocalDateTime`은 생각보다 아주 명확한 한계를 가진 타입이다. 이를 오용하면 시스템 규모가 커질 때 원인 불명의 시간 버그를 유발하게 된다.
4-1. LocalDateTime이란?
LocalDateTime은 이름 그대로 '로컬(특정 지역)의 날짜와 시간'을 표현하는 타입이다.
2026-06-16 14:30:00
사람이 보기에는 컴퓨터 모니터 우측 하단에 있는 시계처럼 아주 자연스럽고 완벽한 시간으로 보이다. 하지만 컴퓨터 입장에서는 이 데이터를 해석하기 위해 필수적인 정보가 하나 빠져 있다. 바로 시간대(Time Zone) 정보가 없다.
주요 API 사용 방법
// 1. 현재 시스템 기준 날짜와 시간 생성
LocalDateTime now = LocalDateTime.now();
// 2. 특정 날짜와 시간 직접 지정 생성
LocalDateTime meetingTime = LocalDateTime.of(2026, 6, 16, 14, 30);
// 3. 시간 연산 (직관적인 가감)
LocalDateTime expiresAt = now.plusMinutes(30);
// 4. 선후 관계 비교
if (now.isAfter(expiresAt)) {
// 만료 처리 로직
}
이처럼 API 자체는 메서드 이름만 봐도 기능을 알 수 있을 정도로 직관적이다. 이 높은 접근성 때문에 많은 개발자가 시간 로직을 짤 때 별다른 고민 없이 LocalDateTime을 선택하게 된다.
4-2. 왜 LocalDateTime이 편하게 느껴질까?
- 인간 중심의 높은 가독성: `2026-06-16 14:30:00` 문자열 그대로 읽히므로 뇌 거침없이 직관적으로 이해된다.
- UI/UX 레이어와의 완벽한 싱크: 예약 화면이나 관리자 페이지의 달력 컴포넌트(`input type="datetime-local"`)에서 사용자가 선택한 값은 타임존이 없는 날짜와 시간이다. 이 입력값은 자연스럽게 `LocalDateTime`으로 매핑된다.
- 단순한 코드 흐름: 타임존 계산에 머리를 싸맬 필요 없이 `plusMinutes(30)`만 하면 끝나므로 초기 개발 속도가 빠르다.
1) LocalDateTime의 치명적인 한계: 타임존의 부재
문제는 시스템이 물리적으로 분산되거나 글로벌 환경으로 나아갈 때 터진다. `LocalDateTime`에는 타임존 정보가 없기 때문이다.
2026-06-16 14:30:00
이 데이터가 서울 기준인지, 도쿄 기준인지, 뉴욕 기준인지 시스템은 알 길이 없다. 사람은 '한국 서비스니까 당연히 서울 시간이겠지'라며 문맥으로 판단하지만, 컴퓨터는 자비가 없다.
2) ⚠️ 같은 문자열, 완전히 다른 물리적 시점
만약 도커(Docker) 컨테이너 설정을 잘못했거나 AWS 인프라 리전을 변경하여 서버 환경이 달라지면 다음과 같은 대참사가 일어난다.
- 서울에 있는 API 서버: 2026-06-16 14:30:00 (한국 시간)
- 뉴욕에 있는 이중화 서버: 2026-06-16 14:30:00 (미국 동부 시간)
두 서버의 메모리에 올라온 `LocalDateTime` 문자열은 완벽히 일치한다.
하지만 실제 지구상에서 두 서버가 가리키는 순간은 13시간의 시차가 있는 전혀 다른 물리적 시점이다.
- `LocalDateTime` = `날짜` + `시간`(Time Zone 없음)
3) 절대 시점(Point in Time)을 표현할 수 없는 이유
앞서 던진 의문인 JWT 만료 시간 코드를 다시 보자.
LocalDateTime issuedAt = LocalDateTime.now();
LocalDateTime expiresAt = issuedAt.plusMinutes(30);
언뜻 보면 무결해 보이지만, 만약 토큰을 발급하는 인증 서버(서울 리전)와 토큰을 검증하는 자원 서버(버지니아 리전)의 타임존 설정이 다르면 어떻게 될까? 혹은 서버 운영 중에 타임존 환경 변수가 슬쩍 바뀌기라도 한다면? 만료 시각을 해석하는 기준이 서버마다 달라져 유효한 토큰이 거부되거나, 만료된 토큰이 통과하는 보안 사고가 날 수 있다. JWT의 핵심은 "지구상 어디에서 검증하든 정확히 동일한 절대적 순간에 만료되어야 한다"는 점이다. `LocalDateTime`은 이 절대적인 순간을 보장해 주지 못한다.
4) 데이터베이스 저장 시의 가독성 늪
많은 프로젝트의 엔티티(Entity)에서 버릇처럼 다음과 같이 설계한다.
@Column(name = "created_at")
private LocalDateTime createdAt;
서버 타임존이 Asia/Seoul로 평생 고정되어 있다면 당장은 잘 돌아가는 것처럼 보인다. 하지만 DB 백업본을 다른 리전에 복구하거나, 인프라를 이전할 때 DB에 적힌 2026-06-16 14:30:00이 원래 어떤 타임존 기준의 데이터였는지 추적하기가 불가능해진다.
다시 한번 강조하지만 LocalDateTime은 사람이 모니터로 보기 좋은 시간일 뿐, 시스템 간 공유할 수 있는 절대 시점이 아니다.
5) LocalDateTime 적정 사용 사례 (Best)
타임존에 영향을 받지 않고, 어느 지역에서 보든 그 숫자가 유지되어야 하는 비즈니스 요구사항에는 `LocalDateTime`이 정답이다.
- 사용자의 생일이나 기념일: 1995-01-01은 미국의 뉴욕에서 맞이하든 한국의 서울에서 맞이하든 해당 날짜 그대로 유지되어야 한다.
- UI 입력 화면의 날짜/시간 데이터: 사용자가 예약 폼에 입력한 날짜와 시간을 임시로 담아두는 DTO 객체.
- 지역 한정 비즈니스 규칙: "매일 오전 09:00에 정산 배치를 돌린다" 와 같이 물리적 시점이 아닌 시계 바늘의 위치가 중요할 때.
6) LocalDateTime 오용 사례 (Worst)
다음과 같은 항목에는 절대로 LocalDateTime을 사용해서는 안 된다.
- JWT 관련 Claim: 발급 시각(`iat`), 만료 시각(`exp`), 활성 시각(`nbf`)
- DB Audit 엔티티 필드: 생성일시(`createdAt`), 수정일시(`updatedAt`)
- 인프라 및 애플리케이션 로그: 시스템 에러 발생 시각, 사용자의 행위 로그 타임스탬프
- 분산 시스템 간 이벤트 메세지: 메시지 큐(`Kafka`, `RabbitMQ`)에 발행하는 이벤트 생성 시각
💡LocalDateTime 오용 원인
JWT, Audit, Log, Event Message 데이터들은 컨텍스트와 무관하게, 지구상에서 오직 하나로 특정되는 '절대 시점(Point in Time)'을 기록해야 하기 때문이다.
LocalDateTime은 다루기 쉽고 직관적이지만, 타임존(Time Zone)이라는 안전장치가 완전히 배제된 타입이다. 따라서 '인간에게 보여주기 위한 시간'을 처리할 때는 유용하지만, 시스템의 신뢰성과 합의가 중요한 인프라스트럭처 레벨(JWT, Audit, 로그)에는 독이 될 수 있다. 그렇다면 날짜와 시간에 더해 우리가 그토록 필요로 하는 '타임존' 정보까지 꽉 쥐고 있는 타입은 무엇일까? 다음 장에서 LocalDateTime의 직관성에 타임존의 안전함을 더한 `ZonedDateTime`에 대해 알아보자.
5. ZonedDateTime 완벽 이해하기
앞 장에서 타임존 정보가 없는 LocalDateTime이 글로벌 서비스나 분산 시스템 환경에서 얼마나 위험할 수 있는지 살펴보았다. `2026-06-16 14:30:00`이라는 숫자 조합만으로는 그것이 서울의 낮인지, 뉴욕의 새벽인지 컴퓨터가 분간할 수 없기 때문이다.
"그렇다면 날짜와 시간에 타임존(Time Zone) 정보를 세트로 묶어서 들고 다니면 되지 않을까?"
바로 이러한 직관적인 목적을 달성하기 위해 등장한 타입이 `ZonedDateTime`이다.
5-1. ZonedDateTime이란?
ZonedDateTime은 말 그대로 LocalDateTime에 Time Zone 정보가 결합된 타입이다.
ZonedDateTime meetingTime = ZonedDateTime.of(
2026, 6, 16, 14, 30, 0, 0,
ZoneId.of("Asia/Seoul")
);
이 코드가 생성하는 데이터는 단순히 시계 바늘의 위치만 가리키지 않는다. `2026-06-16T14:30:00+09:00[Asia/Seoul]`과 같이 "대한민국 서울의 표준시를 따르는 오후 2시 30분"이라는 물리적 시점 정보를 명확하게 내포한다. 지구상 어느 나라의 서버가 이 데이터를 받더라도 완전히 동일한 순간으로 해석할 수 있게 된다.
1) 타임존(Time Zone)의 표현: 왜 UTC+9 대신 Asia/Seoul일까?
타임존은 세계 각 지역이 사용하는 표준 시간의 기준이다. 자바에서는 이를 `ZoneId` 클래스로 표현한다.
ZoneId seoul = ZoneId.of("Asia/Seoul");
ZoneId newYork = ZoneId.of("America/New_York");
여기서 실무적인 팁이 있다. 시간을 표현할 때 고정된 오프셋 값인 UTC+09:00 대신, 왜 Asia/Seoul 같은 지역 기반 표준 식별자를 써야 할까? 그 이유는 국가별 정치적 규칙 변경과 서머타임(DST, Daylight Saving Time) 때문이다. 단순히 숫자로 고정된 오프셋(+09:00)은 계절에 따라 시간이 시시각각 변하는 국가의 동적 타임존 규칙을 반영하지 못한다.
5-2. 주요 API 사용 방법
// 1. 특정 타임존을 기준으로 현재 시간 생성
ZonedDateTime nowInSeoul = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));
// 2. LocalDateTime에 타임존 옷 입히기
LocalDateTime localDateTime = LocalDateTime.of(2026, 6, 16, 14, 30);
ZonedDateTime seoulTime = localDateTime.atZone(ZoneId.of("Asia/Seoul"));
// 3. 동일한 순간을 다른 타임존 시간으로 변환 (매우 자주 씀)
ZonedDateTime newYorkTime = seoulTime.withZoneSameInstant(ZoneId.of("America/New_York"));
여기서 `withZoneSameInstant` 메서드가 핵심이다. 서울 서버에서 가리키는 '그 순간'을 유지한 채 뉴욕 사람이 시계를 보면 몇 시인지를 정확히 변환해 준다. 화면에 보이는 숫자는 달라지지만, 두 객체가 가리키는 지구상의 절대 시점은 완벽히 동일하다.
5-3. 악명 높은 서머타임(DST) 문제를 해결하는 구원투수
글로벌 서비스를 운영할 때 개발자를 가장 괴롭히는 것 중 하나가 바로 서머타임(DST)이다.
미국 뉴욕(America/New_York)의 경우, 봄이 되면 서머타임이 시작되면서 시계 바늘을 강제로 1시간 앞으로 돌린다.
- 새벽 01:59이 지나면 02:00이 되는 것이 아니라 바로 03:00으로 워프한다.
- 즉, 뉴욕의 그날에는 02:30이라는 시간 자체가 지구상에 존재하지 않는다.
- 반대로 가을에 서머타임이 해제될 때는 시계 바늘을 뒤로 미루기 때문에, 똑같은 시간이 한 번 더 반복되는 현상이 발생한다.
이 복잡하고 변덕스러운 국가별 법적 규칙을 개발자가 코드로 직접 계산하는 것은 자살 행위와 같다. `ZonedDateTime`은 내부적으로 이 복잡한 룰 적용을 자동화해 준다. 우리가 `ZoneId.of("America/New_York")`라고 선언해 두기만 하면, 자바가 알아서 해당 날짜가 서머타임 기간인지 계산하여 정확한 오프셋을 붙여준다.
1) LocalDateTime vs ZonedDateTime
두 타입의 경계선은 '맥락(Context)'의 유무다.
- LocalDateTime (맥락 없음): `2026-06-16 14:30:00` -> "그래서 여기가 어딘데?" (불완전한 시간)
- ZonedDateTime (맥락 있음): `2026-06-16 14:30:00 Asia/Seoul` -> "한국 서울의 14시 30분이구나!" (완전한 시간)
즉, LocalDateTime은 단순한 시계 눈금 정보이고, ZonedDateTime은 어느 지역 사람이 바라보는 시계 눈금인지까지 추적한 정보라고 이해하면 된다.
2) ZonedDateTime 적정 사용 사례 (Best)
"어느 지역의 표준시인가"라는 맥락 자체가 비즈니스적으로 매우 중요할 때 빛을 발한다.
- 사용자의 개인화된 일정 관리 (구글 캘린더 등): 서울 사용자가 등록한 "오후 3시 화상 미팅"은 런던에 있는 협업자에게 런던 시간(오전 7시)으로 정확히 번환되어 보여야 한다.
- 국제 예약 시스템: 다국적 항공권 예약, 다국적 호텔 체크인/체크아웃 시간 관리.
- 사용자의 타임존 선호도 보존: 사용자가 가입할 때 설정한 타임존에 맞추어 마케팅 푸시 알림을 발송해야 할 때.
3) ZonedDateTime이 적합하지 않은 경우 (Worst)
타임존 정보가 포함되어 있어 만능 같아 보이지만, 의외로 다음 영역에서는 투머치(Too Much)이거나 부적절하다.
- JWT Claims (iat, exp): 토큰 만료는 전 우주 공통의 시점이어야 하므로 특정 지역의 타임존 정보가 끼어들 필요가 없다.
- DB Audit 필드 (createdAt, updatedAt): 데이터가 언제 생성되었는지는 시스템 내부 정렬과 추적을 위한 정보일 뿐, '서울'이라는 지역 컨텍스트가 중요하지 않다.
- 인프라 로그 및 이벤트 메시지: 분산 서버의 로그 타임스탬프.
5-4. ZonedDateTime의 치명적인 한계
많은 개발자가 *"LocalDateTime이 위험하면 그냥 다 ZonedDateTime으로 통일하면 되는 거 아냐?"*라고 생각한다.
하지만 이는 저장과 인프라 관점의 복잡성을 간과한 생각이다.
- DB 저장의 파편화: 대부분의 관계형 데이터베이스(RDB)는 Asia/Seoul 같은 구체적인 지역 문자열 이름 자체를 시간 타입 데이터와 함께 통째로 저장하는 데 한계가 있거나 성능 저하를 유발한다. (대부분 UTC 오프셋 숫자로 변환되어 저장된다.)
- 비교 연산의 피로도: 서버 여러 대에서 정렬, 이벤트 시간 비교, Audit 추적을 수행할 때 매번 타임존이 덕덕이 붙어있는 객체들을 비교하는 것은 시스템 리소스를 낭비하게 만든다.
결국 ZonedDateTime은 '사람이 이해하고 인지하기 위한 글로벌 시간'에는 최적이지만, '시스템이 내부적으로 정렬하고, 저장하고, 연산하기 위한 최적의 형태'는 아니다. ZonedDateTime은 LocalDateTime에 타임존이라는 날개를 달아 서머타임과 다국적 시차 문제를 우아하게 해결해 준다. 글로벌 일정이나 예약 시스템에는 필수 불가결한 존재다.
그러나 JWT 발급, 데이터베이스 Audit, 서버 로그처럼 "어느 지역인가"는 궁금하지 않고, 오직 "우주 공통의 절대 시점 기준 언제인가"만 필요한 도메인에서는 여전히 더 가볍고 명확한 타입이 필요하다. 타임존의 거품을 걷어내고 오직 절대적인 순간만을 기록하는 백엔드의 종착지, Instant를 다음 장에서 만나보자.
6. Instant 완벽 이해하기
앞 장에서 살펴본 ZonedDateTime은 날짜, 시간, 타임존을 세트로 묶어 표현하므로 글로벌 일정 관리나 예약 시스템에 매우 유용하다. 하지만 JWT 발급, 데이터베이스 Audit(생성/수정일), 인프라 로그, 분산 이벤트 저장처럼 시스템 내부에서 기계적으로 시간을 처리할 때는 관점을 완전히 바꾸어야 한다. 이때 시스템이 궁금해하는 것은 다음과 같은 직관적인 질문들이다.
- "이 에러 로그는 정확히 언제 찍혔는가?"
- "A 이벤트와 B 이벤트 중 무엇이 먼저 발생했는가? (순서 정렬)"
- "이 JWT 토큰은 지금 이 순간 만료되었는가?"
이 질문들의 공통점은 사람이 보는 달력이나 지역 시계의 숫자가 중요하지 않다는 점이다. 오직 지구상 고유의 '절대적인 시점(Point in Time)'만이 핵심이다. 바로 이 목적을 위해 태어난 자바 백엔드 시간의 종착지가 바로 `Instant`다.
6-1. Instant란?
Instant는 타임존의 맥락을 모두 걷어낸 특정 '찰나의 순간'을 표현하는 타입이다.
여기서 순간이란, 지구상 어디에서 보더라도 완전히 동일한 단 하나의 시점을 의미한다.
2026-06-16T05:13:43Z
위의 Instant 데이터를 각 지역의 시계로 해석하면 다음과 같이 변환된다.
- 서울(Asia/Seoul): 2026-06-16 14:13:43
- 뉴욕(America/New_York): 2026-06-16 01:13:43
보는 사람의 위치에 따라 시계 숫자는 다르게 표현되지만, 실제 우주 공간에서 저 사건이 일어난 '물리적인 순간'은 완벽히 일치한다.
- LocalDateTime = 사람이 보는 시간
- ZonedDateTime = 지역 정보가 포함된 사람 시간
- Instant = 우주 공통의 절대 시점
6-2. UTC 표준과 에포크 타임(Epoch Time)
Instant는 전 세계가 공통으로 사용하는 기준 시간인 UTC(협정 세계시)를 기준으로 동작한다.
2026-06-16T05:13:43Z
출력 포맷 맨 뒤에 붙은 알파벳 Z는 'Zero Zone'을 뜻하며, 타임존 오프셋이 +00:00인 UTC 기준 시각임을 증명하는 전 세계 표준 약속이다. 또한 Instant는 컴퓨터가 가장 연산하기 좋은 에포크 타임(Epoch Time, Unix Timestamp)을 기반으로 내부 데이터 모델을 관리한다. 에포크 타임이란 UTC 기준 1970년 1월 1일 00:00:00을 기점으로 현재까지 흘러온 시간을 초(Second)와 나노초(Nano-second) 단위의 정수로 표현한 것이다.
// 내부적으로 1970년 1월 1일 UTC 이후 흘러온 '초'를 계산하여 인스턴스 생성
Instant instant = Instant.ofEpochSecond(1771046023);
우리가 `Instant.now()`를 호출하면, 자바는 시스템 시계로부터 에포크 타임 정수 값을 받아와 아주 가벼운 객체로 래핑한다. 타임존 텍스트를 파싱하고 문자열을 정렬하는 오버헤드가 없기 때문에 기계적인 연산 속도가 압도적으로 빠르다.
6-3. 주요 API 사용 방법
// 1. 현재 절대 시점 생성 (UTC 기준)
Instant now = Instant.now();
// 2. ISO 8601 표준 문자열 파싱하여 생성
Instant specificInstant = Instant.parse("2026-06-16T05:13:43Z");
// 3. 초 단위 시간 연산 (30분 뒤 계산)
Instant expiresAt = now.plusSeconds(1800);
// 4. 절대 시점 선후 관계 비교 (매우 빠름)
if (now.isAfter(expiresAt)) {
// 토큰 만료 처리
}
타임존에 대한 고려가 아예 배제되어 있으므로, 시간 연산과 비교 알고리즘이 정수 비교 수준으로 단순하고 직관적이다.
1) 타 타입과의 결정적 차이점 비교
1) LocalDateTime과의 차이
- LocalDateTime (2026-06-16 14:13:43): 타임존 맥락이 없어서 물리적 순간을 특정할 수 없다. 서울의 14시인지 뉴욕의 14시인지 알 수 없는 불완전한 상태다.
- Instant (2026-06-16T05:13:43Z): 지구상 어떤 서버, 어떤 국가에서 열어보든 1밀리초의 오차도 없이 완벽히 동일한 우주적 순간을 가리킨다.
2) ZonedDateTime과의 차이
다음 두 코드가 표현하는 '물리적 순간'은 완벽히 같다.
Instant instant = Instant.parse("2026-06-16T05:13:43Z");
ZonedDateTime seoulTime = ZonedDateTime.of(
2026, 6, 16, 14, 13, 43, 0, ZoneId.of("Asia/Seoul")
);
차이점은 의도와 표현 방식이다. ZonedDateTime은 사람(인간 인터페이스, 달력, 일정)이 이해하기 좋은 언어로 서머타임까지 고려해 정교하게 번역된 타입이고, Instant는 시스템(DB, 메모리, 네트워크 패킷)이 저장하고 비교 연산하기 가장 좋게 불순물을 제거한 순수 정수형태의 타입이다.
6-4. 왜 실무 백엔드 아키텍처는 Instant를 사랑할까?
- 인프라 타임존 오염 방지: 서버 가상화 환경(Docker, AWS EC2) 시스템의 타임존 설정이 꼬여있어도 Instant 기반 코드는 철저히 UTC 에포크 타임으로만 움직이므로 비즈니스 로직이 파괴되지 않는다.
- 단순 명쾌한 정렬과 비교: 대규모 트래픽 환경에서 로그나 이벤트 데이터를 시간순으로 정렬할 때, 타임존 오프셋을 역산할 필요가 없어 인덱스 효율과 정렬 연산 속도가 극대화된다.
- 영속성 컨텍스트(DB)와의 궁합: 모던 RDB 및 NoSQL 표준 타임스탬프(`TIMESTAMP WITH TIME ZONE` 등)와 1:1로 가장 완벽하게 매핑된다. 데이터 왜곡 위험이 제로에 가깝다.
1) Instant 적정 사용 사례 (Best)
기계적인 추적, 합의, 인프라스트럭처 레벨의 모든 기록에는 `Instant`가 정답이다.
- JWT(JSON Web Token) Claims: 발급일(`iat`), 만료일(`exp`), 사용제한일(`nbf`) 계산
- 데이터베이스 Audit 데이터: 엔티티의 `createdAt`, `updatedAt` 필드
- 애플리케이션 로그 및 메트릭: 에러 발생 시각, 성능 측정용 타임스탬프
- 이벤트 소싱 및 메시지 큐: 카프카(Kafka)나 래빗MQ(RabbitMQ) 메세지에 실어 보내는 이벤트 발행 시각(`publishedAt`), 발생 시각(`occurredAt`)
2) Instant 오용 사례 (Worst)
인간의 실생활 문맥이 짙게 묻어나는 비즈니스 도메인에는 `Instant`를 쓰면 코드가 지옥으로 변한다.
- 글로벌 사용자의 예약 시스템: "내일 오후 2시 예약"을 Instant로만 덜렁 저장해 버리면, 해당 국가가 갑자기 서머타임 정책을 바꾸거나 사용자가 여행을 떠나 타임존이 바뀔 때 원래 예약하려던 시계 바늘의 의도(오후 2시)가 깨지고 조각나 버린다.
- 반복 스케줄러: "매일 아침 9시 알림 발송".
- 인간용 입력 폼: 생일(1995-01-01), 화면 기입용 날짜.
3) 대원칙: "저장은 Instant, 표현은 ZonedDateTime"
모던 백엔드 개발에서 시니어들이 입을 모아 강조하는 가장 이상적인 시간 데이터 설계 공식은 다음과 같다.
"시스템 내부 골격과 데이터베이스 저장은 가볍고 명확한 Instant로 처리하고,
최종 사용자에게 화면으로 보여주는 프리젠테이션 레이어에서만 ZonedDateTime으로 변환한다."
// 1. 비즈니스 로직 및 DB 저장: 타임존 프리한 절대 시점 유지
Instant createdAt = Instant.now();
// 2. 클라이언트 조회 요청 시: 사용자의 타임존 환경에 맞추어 현지 시각으로 변환하여 전시
ZonedDateTime displayTime = createdAt.atZone(ZoneId.of("Asia/Seoul"));
이 원칙을 아키텍처에 이식하면 데이터의 일관성, 시스템 연산의 단순함, 사용자 친화적인 화면 표현이라는 세 마리 토끼를 완벽하게 잡을 수 있다. 정리하자면, Instant는 타임존의 노이즈를 완전히 제거한 순수한 절대 시점 공급자다. 서버 환경이나 글로벌 시차와 무관하게 시스템의 신뢰성을 지켜주는 든든한 방패 역할을 하므로 JWT나 Audit 설계의 표준 타입으로 낙점하기에 부족함이 없다.
지금까지 날짜와 시점을 표현하는 대표 삼총사(`LocalDateTime`, `ZonedDateTime`, `Instant`)를 모두 학습했다. 다음 장에서는 이 세 가지 타입을 한자리에 모아 철학적인 차이점과 실무에서 바로 써먹을 수 있는 구체적인 의사결정 트리로 한눈에 비교해 보자.
7. 시간 타입 비교와 선택 기준
지금까지 LocalDateTime, ZonedDateTime, Instant를 각각 독립적으로 살펴보았다. 각 타입을 따로 공부할 때는 개념이 명확해 보인다. 하지만 실제 프로젝트 필드에 뛰어들면 곧바로 다음과 같은 치열한 현실 문제에 부딪히게 된다.
- "엔티티의 createdAt 필드는 셋 중 뭘로 선언해야 하지?"
- "글로벌 예약 시스템의 미팅 시각은?"
- "JWT 만료 시간은 어떤 타입으로 리턴해야 안전할까?"
- "결국 데이터베이스(DB) 컬럼에는 어떤 형태의 바이트로 저장해야 하지?"
핵심은 타입 그 자체가 아니다.
"내가 다루고자 하는 비즈니스 도메인이 어떤 성격의 문제를 표현하고 있는가?"
바로 도메인 성격을 꿰뚫어 보는 눈이다.
이번 장에서는 세 타입을 일대일로 직접 비교하며 실무에서 바로 적용 가능한 의사결정 기준을 세워보자.
7-1. 세 타입의 철학적 차이와 표현 방식
세 타입의 본질적인 철학 차이를 한눈에 직관적으로 요약하면 다음과 같다.
- LocalDateTime (사람의 눈): 2026-06-16 14:30:00 -> 오직 시계 바늘의 숫자 정보만 존재 (타임존 프리)
- ZonedDateTime (지역의 눈): 2026-06-16 14:30:00 Asia/Seoul -> 시간 숫자에 구체적인 '행정 구역(지역)' 컨텍스트가 결합됨
- Instant (시스템의 눈): 2026-06-16T05:30:00Z -> 지구상 어디서 보든 완전히 동일한 우주 공통의 순수한 '찰나의 순간'
📌 같은 순간, 다른 옷
만약 대한민국 서울에 있는 사람이 카페에 앉아 커피를 주문한 물리적 순간이 딱 한 번 있었다면, 그 순간은 아래의 세 타입으로 모두 동일하게 표현할 수 있다. 다만 '표현하는 방식'과 '품고 있는 데이터'의 깊이가 다를 뿐이다.
| 타입 | 실제 코드 표현 | 내부적인 비즈니스 의미 |
| `LocalDateTime` | `2026-06-16 14:30:00` | 타임존 컨텍스트가 배제된, 단순히 사람이 읽기 좋은 눈금 정보 |
| `ZonedDateTime` | `2026-06-16 14:30:00+09:00[Asia/Seoul]` | 대한민국 서울이라는 구체적인 지역 표준시를 따르는 현지 시간 |
| `Instant` | `2026-06-16T05:30:00Z` | 타임존 거품을 다 걷어내고 `UTC(+00:00)` 기준으로 표준화한 절대 시점 |
7-2. 영속성(Storage) 관점 비교: DB에는 무엇을 넣어야 할까?
백엔드 아키텍처를 설계할 때 가장 심사숙고해야 하는 영역이 바로 DB 저장 타입이다.
// 1. LocalDateTime으로 저장할 때
private LocalDateTime createdAt;
⚠️ LocalDateTime 문제점: 데이터베이스 파일 자체만 열어보았을 때, 이 데이터가 당초 어떤 타임존(Asia/Seoul인지 UTC인지)을 기준으로 적힌 숫자인지 메타데이터가 유실된다. 추후 서버 인프라 이전이나 글로벌 리전 확장 시 데이터 왜곡 리스크가 매우 크다.
// 2. ZonedDateTime으로 저장할 때
private ZonedDateTime createdAt;
⚠️ ZonedDateTime 문제점: Asia/Seoul 같은 문자열 정보까지 저장하려다 보니 DB 컬럼 포맷에 따라 오프셋 숫자로 강제 치환되거나 불필요하게 스토리지 공간을 많이 차지할 수 있다. 시스템 내부 기록용 데이터로 쓰기에는 정보가 다소 과하다.
// 3. Instant로 저장할 때 (실무 권장 표준)
private Instant createdAt;
👍 장점: 무조건 UTC 기준의 정수(에포크 타임) 형태로 깔끔하게 저장된다. 글로벌 서비스든 단일 서비스든 데이터의 오염 여지가 원천 차단되며, 전 세계 모든 서버가 동일한 기준으로 데이터를 조회하고 정렬할 수 있다.
7-3. 💥 시니어 백엔드도 놓치기 쉬운 DB 저장 주의점
"우리 팀은 엔티티 필드를 Instant로 선언했으니 타임존 장애로부터 백프로 안전하겠지?"
DB에 저장할 데이터 타입이 Instant라고 방심하면 안 된다. 자바의 인메모리 타입이 Instant일지라도, 실제 물리 DB의 컬럼 타입과 JDBC 드라이버 설정에 따라 데이터가 꼬이는 엣지 케이스가 존재하기 때문이다.
- PostgreSQL 계열: 반드시 `TIMESTAMP WITH TIME ZONE` (TZ 정보 포함 타입)으로 컬럼을 생성해야 안전하다. 타임존이 없는 `TIMESTAMP` 타입에 `Instant`를 밀어 넣으면, JDBC 드라이버나 DB 세션의 타임존 설정에 따라 시간이 UTC가 아닌 로컬 시간으로 변강되어 강제 정착하는 불상사가 생길 수 있다.
- MySQL 계열: `DATETIME`과 `TIMESTAMP` 타입의 특성을 명확히 알고 매핑해야 한다. 특히 `TIMESTAMP`는 DB 서버의 타임존 세션에 영향을 받으므로, 어플리케이션과 DB 간의 글로벌 타임존 설정(`time_zone='+00:00'`)을 일치시키는 작업이 선행되어야 한다.
1) 비교 연산 및 분산 시스템 관점 비교
서버 여러 대가 각기 다른 지역에 분산 배치되어 작동하는 환경에서 시간 비교 연산(`isAfter`, `isBefore`)을 수행할 때의 안정성을 비교해 보자.
// JWT 만료 검증 로직 예시
if (now.isAfter(expiredAt)) { ... }
- LocalDateTime: 만약 토큰 발급 서버(서울)와 검증 서버(런던)의 시스템 시계 타임존이 다르면, 같은 LocalDateTime 객체끼리 비교하더라도 물리적인 시차가 계산되지 않아 보안 필터가 뚫리거나 오작동한다.
- ZonedDateTime: 정상적으로 시차를 계산하여 비교해 준다. 다만 내부적으로 타임존 오프셋을 계산하는 로직이 추가로 돌기 때문에 연산 횟수가 많아질수록 성능상 오버헤드가 미세하게 발생한다.
- Instant: 내부적으로 들고 있는 롱(long) 타입의 정수 에포크 타임값을 다이렉트로 비교한다. 가장 가볍고, 가장 빠르며, 타임존 개입이 없어 분산 환경의 표준 연산 타입으로 완벽하다.
2) 실무 마스터 의사결정 트리
도메인 모델링을 할 때 어떤 타입을 선택해야 할지 명쾌하게 정리해 주는 가이드라인 일람표다.
| 비즈니스 요구사항 및 상황 | 추천 타입 | 이유 |
| 사용자의 생일, 결혼 기념일 | `LocalDate` | 타임존과 무관하게 그 날짜 숫자 자체가 보존되어야 함 |
| 오프라인 매장의 오픈/마감 시간 | `LocalTime` | 특정 지역의 시계 눈금 규칙이 중요함 |
| 인간이 화면(UI)에 기입하는 예약 일시 | `LocalDateTime` | 타임존이 확정되기 전, 입력 폼의 원본 데이터를 임시로 받을 때 |
| 글로벌 캘린더, 다국적 항공/호텔 예약 | `ZonedDateTime` | 서머타임(DST) 변동 규칙과 해당 지역의 컨텍스트를 완벽히 묶어야 함 |
| JWT Claims (iat, exp, nbf) | `Instant` | 전 우주 공통의 절대 시점 기준 만료 처리가 생명임 |
| DB Audit (createdAt, updatedAt) | `Instant` | 데이터 정렬, 추적, 인프라 이식성을 위한 백엔드 글로벌 표준 |
| 시스템 로그, 분산 메시지 큐 타임스탬프 | `Instant` | 여러 대의 분산 서버 간 데이터 생성 순서를 명확히 나열해야 함 |
3) 절대 잊지 말아야 할 시간 설계 대원칙
이 장의 모든 내용을 단 한 문장으로 압축하면 다음과 같은 마법의 공식을 도출할 수 있다.
- 저장(Storage)은 언제나 Instant , 표현(Presentation)은 필요할 때 ZonedDateTime
이 명확한 가이드라인을 팀 내 그라운드 룰로 선언해 두면, "시간 타입을 뭘 써야 하느냐"를 두고 벌어지는 무의미한 아키텍처 토론 시간을 획기적으로 줄일 수 있으며, 비즈니스 로직에만 온전히 집중할 수 있게 된다.
자, 이제 시간을 '표현'하는 핵심 타입 삼총사의 완벽한 선택 기준을 세웠다. 그렇다면 이 타입들에 들어갈 '현재 시각 데이터'는 과연 어디서, 어떻게 안전하게 가져와야 할까? 다음 장에서는 시간 설계의 핵심 인프라이자 테스트 가능성을 지켜주는 수호신인 Clock에 대해 깊게 파고들어 보자.
8. Clock 완벽 이해하기
지금까지 우리는 LocalDateTime, ZonedDateTime, Instant를 통해 자바에서 시간을 표현하는 다양한 데이터 타입을 살펴보았다. 하지만 이 시점에서 가장 본질적이고 중요한 질문이 하나 남아 있다.
"그렇다면 우리가 매일 쓰는 '현재 시각' 데이터는 도대체 어디서 오는 것일까?"
학습을 잘 했다면, 대부분의 프로젝트 코드를 열어봤을 때 아마 다음과 같이 작성되어 있을 것이다.
Instant now = Instant.now();
LocalDateTime localNow = LocalDateTime.now();
너무나 익숙하고 자연스러운 코드다. 하지만 아키텍처 관점에서 조금만 깊게 생각해보면 한 가지 가려진 진실을 마주하게 된다. 현재 시각은 우리 애플리케이션(코드)이 직접 계산해서 만들어내는 값이 아니다. 바로 서버가 돌아가고 있는 운영체제(OS)의 시스템 시계(Hardware Clock)가 실시간으로 공급해 주는 값이다. 즉, 현재 시각의 본질은 다음과 같다.
- 현재 시각 = 애플리케이션 내부 데이터 ❌ -> 외부 시스템이 주입하는 의존성 ⭕
이 위대한 패러다임의 전환을 코드로 구현해 놓은 추상 레이어가 바로 `Clock`이다.
8-1. Clock이란?
Clock은 현재 시각을 제공하는 시간 공급자(Time Source) 객체다. 자바에서 `Instant.now()`를 아무런 인자 없이 호출하면, 눈에 보이지 않지만 내부적으로 OS의 기본 시계를 찾아가 현재 시간을 받아온다. Clock을 사용하면 이 과정을 코드로 명시적으로 드러낼 수 있다.
// 1. UTC 기준의 시간 공급자(시계)를 확보한다.
Clock clock = Clock.systemUTC();
// 2. 그 시계에게 "지금 몇 시야?"라고 물어본다.
Instant now = Instant.now(clock);
`Instant.now(clock)`의 진짜 의미는 "내가 건네준 clock이라는 시계 바늘이 가리키는 현재 시점을 가져와라"는 뜻이다.
1) 왜 인자 없는 Instant.now()를 직접 호출하면 안 될까?
프로젝트 곳곳에 Instant.now()를 하드코딩하는 방식은, 객체지향 설계 관점에서 보면 "숨겨진 외부 의존성(Hidden Dependency)"을 코드 깊숙이 심어두는 것과 같다. 앞서 언급했던 JWT 만료 검증 컴포넌트를 예로 들어보자.
public class JwtValidator {
public boolean isExpired(Instant expiredAt) {
// 코드 내부에 OS 시스템 시계 의존성이 꽁꽁 숨어있다.
return Instant.now().isAfter(expiredAt);
}
}
문법적으로는 아무런 하자가 없다. 하지만 이 코드는 단위 테스트(Unit Test) 환경을 구축하려는 순간 개발자에게 커다란 시련을 안겨준다.
2) 간헐적 테스트 실패(Flaky Test)의 주범
만약 우리가 다음과 같은 시나리오를 검증하는 테스트 코드를 짰다고 가정하자.
- 가정된 현재 시각: 2026-06-16 10:00:00
- 토큰의 만료 시각: 2026-06-16 10:30:00
- 기대 결과: 아직 30분의 여유가 있으므로 isExpired()는 false(만료 안 됨)를 반환해야 한다.
그러나 `Instant.now()`를 직접 호출하는 코드는 실행하는 컴퓨터의 '실제 현재 시각'을 그대로 반영한다. 따라서 이 테스트는 오늘 실행하면 성공하지만, 토큰 만료일이 지난 1년 뒤에 실행하면 실패한다. 이처럼 소스 코드는 전혀 바뀌지 않았는데 실행하는 시점이나 환경에 따라 성공과 실패를 오가는 불안정한 테스트를 플래키 테스트(Flaky Test)라고 부른다. 빌드 파이프라인의 신뢰도를 떨어뜨리는 주범이다.
8-2. Clock 주입을 통한 제어의 역전(IoC)
시간이라는 외부 의존성을 제어하기 위해, Clock을 컴포넌트 내부에서 직접 생성하지 않고 외부로부터 주입(Dependency Injection)받도록 코드를 리팩토링해 보자.
public class JwtValidator {
private final Clock clock; // 시간 공급자를 추상화하여 필드로 보유
public JwtValidator(Clock clock) {
this.clock = clock;
}
public boolean isExpired(Instant expiredAt) {
// 주입받은 시계에게 현재 시각을 물어본다.
return Instant.now(clock).isAfter(expiredAt);
}
}
이제 JwtValidator는 시스템 시계와 느슨한 결합 상태가 되었다. 프로덕션 환경에서는 진짜 시계를 넣어주고, 테스트 환경에서는 우리가 마음대로 주무를 수 있는 가짜 시계를 갈아 끼울 수 있는 완벽한 구조가 완성된다.
1) 가짜 시계의 마법: Fixed Clock
자바의 Clock 클래스는 테스트 코드를 위해 시간을 특정 순간으로 완전히 박제해 버리는 스태틱 메서드인 `Clock.fixed()`를 제공한다.
// 전 우주가 멈춘 것과 다름없는, 특정 시점에 고정된 시계를 생성한다.
Clock fixedClock = Clock.fixed(
Instant.parse("2026-06-16T10:00:00Z"),
ZoneOffset.UTC
);
// 이 고정된 시계에게 현재 시간을 백만 번 물어본다.
Instant time1 = Instant.now(fixedClock);
Instant time2 = Instant.now(fixedClock);
// 결과는 100년 뒤에 실행해도 언제나 동일한 2026-06-16T10:00:00Z 다.
2) 완벽하게 격리된 단위 테스트 코드 작성
이제 Fixed Clock을 활용하여 10년 뒤에 실행해도 무조건 100% 성공을 보장하는 견고한 단위 테스트를 작성할 수 있다.
@Test
void 토큰_만료_전이라면_false를_반환한다() {
// 1. 현재 시각을 오전 10시로 꽉 묶어둔 가짜 시계 생성
Clock fixedClock = Clock.fixed(
Instant.parse("2026-06-16T10:00:00Z"),
ZoneOffset.UTC
);
// 2. 검증 컴포넌트에 가짜 시계 주입
JwtValidator validator = new JwtValidator(fixedClock);
// 3. 만료 시간을 오전 10시 30분으로 설정한 토큰 준비
Instant expiredAt = Instant.parse("2026-06-16T10:30:00Z");
// 4. 검증 결과가 무조건 false(만료 안 됨)인지 단언
assertFalse(validator.isExpired(expiredAt));
}
3) 자바가 제공하는 주요 Clock 종류
자바 표준 라이브러리는 상황에 맞게 골라 쓸 수 있는 다양한 Clock 구현체를 팩토리 메서드로 지원한다.
- `Clock.systemUTC()` (실무 강력 권장): UTC 기준의 시스템 시계를 반환한다. 전 세계 서버의 기준시를 하나로 통일하여 인프라 시차 문제를 원천 차단하므로 백엔드 진영의 표준으로 쓰인다.
- `Clock.systemDefaultZone()`: 서버 운영체제(OS)에 설정된 기본 타임존을 따르는 시계다. 로컬 개발 PC와 운영 리눅스 서버의 기본 타임존이 다를 경우 시간 왜곡 버그가 터질 수 있어 주의가 필요하다.
- `Clock.fixed(Instant, ZoneId)`: 특정 시점에 멈춰 서서 오직 그 시간만 무한 반복 공급하는 시계다. 오직 테스트 코드를 격리할 목적으로 사용된다.
- `Clock.offset(Clock, Duration)`: 기준이 되는 시계보다 일정 시간만큼 빠르거나 느리게 흘러가는 변위 시계를 만든다. "만약 지금보다 1시간 뒤라면 시스템이 어떻게 반응할까?" 같은 특수한 비즈니스 시나리오를 시뮬레이션할 때 유용하다.
8-3. Spring Boot 실무 적용 레이아웃
실무 스프링 프레임워크 환경에서는 Clock을 싱글톤 빈(Bean)으로 등록하여 애플리케이션 전역에 주입하는 방식을 취한다.
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
// 프로덕션 환경의 기본 시계는 언제나 전 세계 표준인 systemUTC로 설정
return Clock.systemUTC();
}
}
시간 공급이 필요한 여러 계층인 서블릿, 서비스, 컴포넌트 층에서는 다음과 같이 생성자 주입으로 받아 사용하면 된다.
@Service
public class JwtService {
private final Clock clock;
// 스프링이 컨테이너에 등록된 systemUTC 빈을 자동으로 주입해 준다.
public JwtService(Clock clock) {
this.clock = clock;
}
public String createToken() {
// 현재 시각을 구하는 표준적인 실무 가이드라인
Instant now = Instant.now(clock);
// 또는 clock.instant(); 를 직접 호출해도 무방하다.
...
}
}
Clock은 날짜나 시점을 담아두는 단순한 데이터 그릇이 아니다. 시스템에 현재 시간을 안전하게 배달해 주는 인프라스트럭처 컴포넌트다. 현재 시각을 다루는 영역과 시점 자체를 다루는 영역을 코드로 완벽히 분리하면 다음과 같은 아키텍처적 이점을 얻는다.
- 현재 시각 공급 -> Clock (외부 환경, 모킹 가능)
- 특정 순간 기록 -> Instant (불변 데이터)
시간을 안전하게 공급받는 인프라를 구축했으니, 이제 다음 장에서는 두 시점 사이의 거리를 측정하는 시간의 길이 타입, Duration에 대해 깊이 있게 정복해 보자. Instant가 "언제"에 대한 대답이라면, Duration은 "얼마나"에 대한 명쾌한 해답을 내려줄 것이다.
9. Duration 완벽 이해하기
지금까지 우리는 현재 시각을 안전하게 공급해 주는 Clock과 타임존 노이즈가 제거된 절대 시점 표현 타입인 Instant를 살펴보았다. 시스템의 시간 뼈대를 세우기 위한 거대한 두 축이 완성된 셈이다. 하지만 실제 프로덕션 비즈니스 로직(예: JWT 발급)을 구현하려다 보면 여전히 무언가 하나가 이 빠진 것처럼 부족하다는 느낌을 받게 된다.
- 현재 시각 (Clock 기준): 오전 10시 00분
- 만료 시각 (Instant 지정): 오전 10시 30분
여기서 우리가 진짜 코드로 표현하고 싶었던 핵심 비즈니스 규칙은 '10시 30분'이라는 고정된 시각이 아니다. 사실은 "이 토큰은 발급된 후 '30분 동안'만 효력을 유지한다"라는 동적인 규칙이다. 즉, "언제(When)"가 아니라 "얼마나 오래(How long, 거리)"를 시스템 언어로 표현해야 한다. 이 막중한 역할을 전담하기 위해 탄생한 타입이 바로 `Duration`이다.
9-1. Duration이란?
Duration은 초(Second)와 나노초(Nano-second) 단위로 정밀하게 측정되는 연속적인 시간의 길이(Time Amount)를 표현하는 타입이다.
10초, 5분, 30분, 2시간, 24시간...
여기서 가장 중요한 개념적 포인트는 Duration 단독으로는 특정 날짜나 시각을 가리킬 수 없다는 점이다. '30분'이라는 데이터 그 자체는 허공에 붕 떠 있는 추상적인 수치일 뿐이다. 이 수치가 물리적인 시간으로 작동하려면 반드시 시작 기준점이 결합되어야 한다.
앞서 배운 개념들을 조합하면 다음과 같은 백엔드 시간 설계의 대원칙 수식이 성립한다.
- Instant(기준 시점) + Duration(시간 길이) = Instant(새로운 만료 시점)
9-2. 주요 API 생성 및 계산 방법
자바의 Duration은 빌더나 생성자 대신, 가독성이 극대화된 정적 팩토리 메서드를 제공한다.
// 1. 다양한 단위의 시간 길이 생성
Duration tenSeconds = Duration.ofSeconds(10);
Duration fiveMinutes = Duration.ofMinutes(5);
Duration twoHours = Duration.ofHours(2);
Duration oneDay = Duration.ofDays(1); // 내부적으로 24시간으로 환산됨
// 2. 실무에서 가장 자주 쓰이는 30분 유효기간 선언
Duration jwtTtl = Duration.ofMinutes(30);
✍️ 자연어에 수렴하는 시점 가산 연산
이렇게 선언한 `Duration`은 `Instant`와 결합할 때 코드를 소리 내어 읽기만 해도 의도가 파악될 정도로 강력한 가독성을 자랑한다.
Instant now = Instant.now(clock); // 현재 시각 (기준 시점)
Duration ttl = Duration.ofMinutes(30); // 30분 (시간 길이)
Instant expiresAt = now.plus(ttl); // 만료 시각 = 현재 시각 + 30분
1) Duration의 내부 구조: 마술 숫자의 종말
Duration은 내부적으로 단 두 개의 필드로 모든 시간 길이를 관리한다.
- `private final long seconds;` (초)
- `private final int nanos;` (나노초)
예를 들어 우리가 `Duration.ofMinutes(30)`을 호출하면 자바는 내부적으로 이를 `1800`초로 환산하여 롱(`long`) 타입 정수로 저장한다. 이 설계가 위대한 이유는 분, 시간, 일 단위로 파편화되어 있던 시간 길이 개념을 시스템 내부적으로 동일한 단일 포맷으로 표준화했다는 점이다. 덕분에 개발자는 단위 변환 중에 발생할 수 있는 소수점 누수나 오버플로우 규칙을 더 이상 신경 쓰지 않아도 된다.
9-3. 아직도 수많은 레거시가 고수하는 long Milliseconds 방식의 폐해
의외로 많은 프로젝트의 설정 클래스나 유틸 코드를 보면 여전히 다음과 같은 날것의 정수 계산식이 지뢰처럼 매설되어 있다.
// 30분을 밀리초로 환산한 수식
long expirationMillis = 30 * 60 * 1000;
이 방식은 당장 컴파일하고 실행하는 데는 아무런 문제가 없지만, 프로젝트 규모가 커지고 유지보수 기간이 늘어날수록 다음과 같은 심각한 안티 패턴으로 돌변한다.
- 가독성의 전멸: 시간이 흘러 다른 개발자가 이 코드를 보았을 때, 하드코딩된 숫자 결과물인 1800000만 덜렁 보고 이것이 30분인지, 300초인지, 혹시 18시간을 뜻하는지 뇌 시뮬레이션을 돌려야만 한다.
- 단위(Unit) 착각 사고: 만약 외부 오픈소스 라이브러리나 스프링 메서드 인자가 '초(Seconds)' 단위를 받는데, 버릇처럼 expirationMillis 값을 그대로 밀어 넣으면 유효기간이 1,000배로 뻥튀기되는 대형 대형 보안 장애가 발생한다. 개발자는 오직 변수명(...Millis)에만 의존해야 하는 위험한 외줄 타기를 하게 된다.
// long millis 방식 (위험)
long retryDelay = 60 * 1000;
// Duration 방식 (안전 및 명확)
Duration retryDelay = Duration.ofMinutes(1);
1) Spring Boot 외부 설정(YAML)과의 환상적인 시너지
Duration의 진가는 스프링 부트의 `@ConfigurationProperties`와 바인딩될 때 폭발한다.
스프링은 문자열로 적힌 시간 단위를 `Duration` 객체로 자동 컨버팅해 주는 강력한 내장 변환기를 지원한다.
# application.yml
jwt:
expiration: 30m # 30분 (m = minutes)
refresh-ttl: 7d # 7일 (d = days)
@ConfigurationProperties("jwt")
public class JwtProperties {
// 스프링 부트가 YAML의 "30m" 문자열을 분석하여 자동으로 Duration 객체로 조립해 주입한다.
private Duration expiration;
public Duration getExpiration() { return expiration; }
public void setExpiration(Duration expiration) { this.expiration = expiration; }
}
이 기능을 활용하면 개발자나 인프라 담당자가 환경 설정을 바꿀 때 `30 * 60 * 1000` 같은 복잡한 숫자를 계산할 필요 없이, 30s(30초), 5m(5분), 2h(2시간), 7d(7일) 처럼 직관적인 기호를 사용하여 실수를 완벽하게 방지할 수 있다.
2) Duration 실무 적정 사용 사례 (Best)
연속적이고 정밀한 서버 인프라스트럭처 레벨의 만료 시간 및 지연 정책에는 무조건 Duration이 표준이다.
- JWT 및 OAuth2 토큰 유효 기간 (TTL): 토큰의 생명 주기 관리.
- 인메모리 캐시 및 Redis 데이터 만료 시간: `opsForValue().set(key, value, duration)` 형태의 만료 정책 설정.
- HTTP / 데이터베이스 타임아웃 규칙: Connection Timeout, Read Timeout 설정.
- 회복 탄력성 정책 (Resilience4j 등): API 호출 실패 시 재시도 간격(Retry Interval), 서킷 브레이커 유지 시간.
3) Duration이 적합하지 않은 경우: Duration vs Period
Duration은 물리적인 초 단위를 기준으로 움직이기 때문에, 인간의 달력 체계가 개입되는 '개월(Month)'이나 '개년(Year)' 단위의 날짜 계산에는 부적합하다.
- "1개월 뒤에 정기 결제를 시도한다" 라는 비즈니스 규칙이 있다고 하자.
- 2월은 28일이고, 3월은 31일이다. 달마다 총 초(Seconds)의 길이가 동적으로 달라진다.
- Duration은 고정된 정수 기반이므로 이 유연한 달력 규칙을 알지 못한다.
💡 해결책: 이처럼 달력 기준의 불연속적인 날짜 거리를 계산할 때는 Duration 대신 Period 타입을 사용해야 한다. `(Period.ofMonths(1), Period.ofYears(2))`
단, 백엔드 서버 개발에서 다루는 대부분의 인프라 만료 시간(TTL, Timeout)은 초 단위 이하의 정밀함이 생명이므로 Duration 하나면 거의 모든 도메인을 커버할 수 있다.
Duration은 타입 안정성이 제거된 `long millis` 방식을 완벽히 대체하기 위해 등장한 시간 길이의 구원투수다. 타입 그 자체로 "이 데이터는 시간 거리를 뜻한다"라는 강력한 힌트를 주기 때문에 코드의 유지보수성이 비약적으로 상승한다. 자, 이제 우리는 실무 시간 설계의 핵심 무기 삼총사를 모두 손에 넣었다.
- 현재 시각 공급소: `Clock`
- 타임존 독립 절대 시점: `Instant`
- 직관적인 시간의 길이: `Duration`
이 개별 무기들을 한데 모아 유기적으로 결합하면 어떤 시너지가 날까? 다음 장에서는 이 삼총사(Clock + Instant + Duration)의 환상적인 콜라보레이션을 통해, 우리를 이 고민으로 이끌었던 JWT 발급 및 만료 시스템을 완벽하고 견고하게 리팩토링하는 실전 코드를 구현해 보자.
10. Clock + Instant + Duration 조합 이해하기
지금까지 우리는 자바 백엔드가 시간을 다루기 위해 필요한 3대 핵심 요소를 학습했다.
- Clock: 현재 시각 공급자
- Instant: 타임존 독립적인 절대 시점 (Point in Time)
- Duration: 정밀한 시간의 길이 (Time Amount)
각 타입을 개별적으로 이해하는 것도 중요하지만, 실무 프로독션 코드가 가치를 발휘하는 순간은 이 세 가지가 하나로 묶여 유기적으로 돌아갈 때다. 백엔드가 풀어야 하는 수많은 시간 유즈케이스(JWT 만료, 세션 아웃, Redis TTL, API 재시도 간격 등)는 사실 본질적으로 모두 동일한 수학적 아키텍처 구조를 공유하고 있기 때문이다.
- 현재 시각(Clock) + 시간 길이(Duration) = 미래 시점(Instant)
이번 장에서는 이 삼총사 조합이 어떻게 코드를 예술적으로 바꾸고, 시스템의 신뢰성을 극한으로 끌어올리는지 실제 예제를 통해 살펴보자.
1) 1단계: 현재 시각 가져오기 (Clock -> Instant)
우선 안전하게 추상화된 시계 공급자(Clock)로부터 순수한 현재의 절대 시점(Instant)을 뽑아낸다.
Clock clock = Clock.systemUTC();
// 아래의 두 코드는 완벽히 동일하게 동작한다.
Instant now = clock.instant();
Instant now2 = Instant.now(clock);
이 단계에서 가장 중요한 의의는 현재 시각을 구하는 행위의 주도권이 시스템 OS가 아니라 우리가 주입한 clock 객체로 넘어왔다는 점이다.
2) 2단계: 유효기간 연산 및 비교 (Duration & Instant)
여기에 비즈니스 정책인 Duration을 더해 새로운 만료 시점을 도출하고 선후 관계를 비교한다.
// 1. 30분이라는 시간 정책 선언
Duration expiration = Duration.ofMinutes(30);
// 2. 미래의 만료 시점 계산 (수식과 코드가 1:1 일치)
Instant expiresAt = now.plus(expiration);
// 3. 직관적인 시간 비교 검증
if (clock.instant().isAfter(expiresAt)) {
// 토큰 만료 처리 로직 수행
}
🔍 꿀팁: 남은 시간(Remaining Time) 역산하기
만약 토큰이나 캐시가 만료되기까지 정확히 얼마의 시간이 남았는지 클라이언트에게 전달해야 한다면
`Duration.between`을 활용하면 된다.
Duration remaining = Duration.between(clock.instant(), expiresAt);
System.out.println(remaining); // 출력 예시: PT15M (15분 남았음을 의미하는 ISO-8601 포맷)
10.1 실무 예제로 보는 디자인 패턴
- 현재 시각(Clock) + 시간 길이(Duration) = 미래 시점(Instant)
이 조합이 실제 컴포넌트 설계에 투입되었을 때, 각 타입이 어떻게 자신의 책임(Responsibility)을 명확히 나누어 갖는지 눈여겨보자.
1) JWT 만료 시간 생성기
@Component
public class JwtTokenProvider {
private final Clock clock; // 책임: 현재 시각 공급 (인프라)
private final Duration expiration; // 책임: 유효 기간 정책 (비즈니스 룰)
public JwtTokenProvider(Clock clock, @Value("${jwt.expiration}") Duration expiration) {
this.clock = clock;
this.expiration = expiration;
}
public Instant createExpirationTime() {
// 현재 시각 + 유효 기간 = 정확한 만료 시점 도출
return clock.instant().plus(expiration);
}
}
2) Redis 분산 캐시 TTL 설정
public void cacheUserSession(String key, UserSession session) {
Instant now = clock.instant();
Duration ttl = Duration.ofHours(1); // 1시간 캐싱 정책
// 스프링의 RedisTemplate API는 Duration 타입을 직접 수용한다.
redisTemplate.opsForValue().set(key, session, ttl);
}
3) 탄력적 재시도(Retry) 스케줄링
// API 호출 실패 시, 30초 뒤로 다음 재시도 절대 시각을 정밀하게 예약
Instant nextRetryTime = clock.instant().plus(Duration.ofSeconds(30));
4) 인프라 통제의 짜릿함: 테스트 코드에서의 대활약
이 조합의 진정한 가치는 시스템 전체의 테스트이다. 가혹한 환경을 우리가 손가락 하나로 통제할 수 있게 된다는 점이다.
@Test
void 만료일_계산_및_비교_통합_테스트() {
// 1. 시간을 2026년 6월 16일 오전 10시로 꽁꽁 묶은 시계 준비
Clock fixedClock = Clock.fixed(
Instant.parse("2026-06-16T10:00:00Z"), ZoneOffset.UTC
);
// 2. 30분 유효기간 정책 설정
Duration duration = Duration.ofMinutes(30);
// 3. 고정 시계 기반으로 만료 시각 계산
Instant expiresAt = fixedClock.instant().plus(duration);
// 단언(Assert): 이 계산 결과는 지구 멸망의 날에 실행해도 무조건 10시 30분이다.
assertEquals(Instant.parse("2026-06-16T10:30:00Z"), expiresAt);
}
5) 왜 이 조합을 써야만 하는가? (Before vs After)
아직도 수많은 자바 레거시 시스템에 남아있는 날것의 프리미티브 타입 연산과 가독성을 직접 대조해 보자.
❌ 레거시 방식 (밀리초 정수 연산 + 시스템 시계 직접 의존)
// 문제점: 단위 착각 위험, 테스트 시 시간 고정 불가능, 비즈니스 의미가 숫자에 가려짐
long expirationMillis = 30 * 60 * 1000;
long expiresAt = System.currentTimeMillis() + expirationMillis;
⭕ 모던 아키텍처 방식 (Clock + Instant + Duration)
// 장점: 자연어 수준의 가독성, 타입 안정성 확보, 테스트 환경에서 100% 모킹 가능
Instant expiresAt = clock.instant().plus(Duration.ofMinutes(30));
이 두 코드가 가져오는 시스템 유지보수성의 격차는 하늘과 땅 차이다. 두 번째 방식은 정책이 30분에서 1시간으로 바뀌더라도 `Duration.ofHours(1)`로 교체하면 끝이며, 사이드 이펙트가 전혀 없다.
6) 요약 및 핵심 도메인 맵
자바 백엔드 엔지니어가 비즈니스 시간에 대처하는 가장 완벽한 마인드셋 구조는 다음과 같다.
- Clock: "언제나 현재 시각의 공급을 담당하는 인프라스트럭처"
- Duration: "비즈니스의 만료, 지연 규칙을 명시하는 도메인 정책"
- Instant: "그 둘이 결합되어 나온 가볍고 명확한 연산/저장의 최종 결과물"
이 강력한 패러다임을 완전히 이해했다면, 드디어 본 연재의 서두를 열었던 진짜 주인공을 마주할 준비가 끝난 것이다. 다음 장에서는 이 완벽한 삼총사 조합을 실제 JWT(JSON Web Token) 도메인에 이식하여, 보안성과 확장성을 모두 챙기는 실전 인증 리팩토링 과정을 함께 완수해 보자.
11. JWT에서 시간 타입 선택하기
본 연재를 관통하는 모든 고민의 시작점은 바로 JWT(JSON Web Token) 모듈의 리팩토링이었다.
기존에 관성적으로 작성되어 있던 레거시 코드를 검토하면서 다음과 같은 근본적인 의문이 싹텄다.
LocalDateTime issuedAt = LocalDateTime.now();
LocalDateTime expiresAt = issuedAt.plusMinutes(30);
"과연 이 코드가 글로벌 환경이나 분산 아키텍처에서도 안전한 최선일까?"
처음 로컬 개발 환경에서 테스트할 때는 아무런 문제가 없어 보인다. 하지만 JWT라는 기술 표준이 내포한 본질적인 시간의 의미를 추적해 보면, LocalDateTime은 완전히 잘못된 선택지라는 결론에 도달하게 된다.
11-1. JWT가 다루는 시간 관련 Claims의 본질
JWT 명세에는 토큰의 생명 주기를 제어하기 위한 3대 핵심 시간 표준 Claim이 정의되어 있다.
- iat (Issued At): 토큰이 발급된 시각
- exp (Expiration Time): 토큰이 기술적으로 효력을 상실하는 만료 시각
- nbf (Not Before): 토큰의 활성화 시각 (지정한 시각 전에는 토큰 사용 불가)
이 세 가지 값의 공통점은 "어느 특정 지역의 시계 바늘이 어디를 가리키는가"가 아니다. 오직 "우주 공통의 타임라인 위에서 정확히 언제인가?"라는 절대 시점(Point in Time)을 표현한다는 점이다.
1) JWT에서 중요한 것은 '지구상 단 하나의 순간'
인프라가 확장되어 토큰을 발급하는 인증 서버는 서울 리전에 있고, 발급된 토큰을 가져와 검증하는 API 게이트웨이나 리소스 서버는 도쿄나 뉴욕 리전에 분산 배치되어 있다고 가정해 보자. JWT는 암호화 알고리즘 기반의 '자가 수용적(Self-contained)' 토큰이다. 즉, 어느 국가의 어떤 서버에서 검증되더라도 완전히 동일한 기준선 위에서 만료 여부가 판가름 나야 한다. 여기서 "서울 기준 오전 9시" 같은 지역 컨텍스트(LocalDateTime)는 검증 서버의 타임존에 따라 오염될 위험이 매우 크다. 필요한 것은 전 세계 어디에서 보든 1밀리초의 오차도 없이 일치하는 절대 시점이다.
- JWT 시간 모델 = LocalDateTime❌ -> ZonedDateTime❌ -> Instant⭕
11-2. JWT 공식 스펙(RFC 7519)의 구현 증명
사실 개발자가 임의로 고민할 필요도 없이, JWT 공식 표준 명세서(RFC 7519)가 이를 명확히 규정하고 있다.
명세서에 따르면 exp, iat, nbf는 모두 `NumericDate`라는 타입으로 기술되어야 한다.
- NumericDate: 1970년 1월 1일 00:00:00 UTC(에포크 타임) 이후 경과한 시간의 총량(초 단위 정수).
RFC 7519 표준 정의는 우리가 앞서 공부한 자바의 Instant의 내부 메커니즘과 정확히 1:1로 일치한다.
{
"exp": 1771047600
}
위와 같은 실제 JWT 페이로드는 자바 코드 관점에서 `Instant.ofEpochSecond(1771047600)`과 완벽히 매핑되는 개념이다.
11-3. 점진적 리팩토링으로 완성하는 JWT 시간 아키텍처
1) 1단계: 초기의 LocalDateTime 기반 구현 (위험)
LocalDateTime issuedAt = LocalDateTime.now();
LocalDateTime expiresAt = issuedAt.plusMinutes(30);
- 한계: OS 시스템의 로컬 타임존 환경 변수가 바뀌면 만료 연산이 오염된다. 또한, 정수형태의 Epoch Time을 다루는 JWT 표준 라이브러리에 밀어 넣을 때 내부적인 타입 컨버팅 오버헤드와 시차 왜곡 지뢰가 도사리게 된다.
2) 2단계: Instant 도입 (물리적 안전 확보)
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plusSeconds(1800);
- 한계: 타임존 노이즈는 완벽히 제거했으나, 하드코딩된 마술 숫자 1800이 정확히 몇 분을 뜻하는지 코드 가독성 측면에서 모호하다. 여전히 테스트 코드에서 `Instant.now()`를 고정(Mocking)할 방법이 없다.
3) 3단계: Duration 정책 이식 (가독성 확보)
Duration expiration = Duration.ofMinutes(30); // 정책 분리
Instant expiresAt = issuedAt.plus(expiration); // 직관적인 시점 계산
- 장점: "토큰은 30분간 유효하다"라는 비즈니스 만료 정책이 정밀한 타입(Duration)으로 승격되어 코드의 가독성과 안전성이 비약적으로 상승했다.
4) 4단계: Clock 주입 (완벽한 테스트 가능성 확보)
마지막으로 꽁꽁 숨어있던 외부 의존성인 '현재 시각'을 Clock 인터페이스로 완벽히 격리해 주입한다.
@Service
public class JwtTokenProvider {
private final Clock clock; // 현재 시각 공급 (인프라 추상화)
private final Duration expiration; // JWT 유효 기간 정책 (비즈니스 룰)
public JwtTokenProvider(Clock clock, Duration expiration) {
this.clock = clock;
this.expiration = expiration;
}
public Instant createExpirationTime() {
// 주입된 시계로부터 기준 시점을 구하고 정책을 가산하여 최종 만료 시점 도출
Instant issuedAt = clock.instant();
return issuedAt.plus(expiration);
}
}
이 최종 아키텍처 레이아웃에는 각 Java Time API 핵심 클래스들의 책임이 한 치의 흐트러짐도 없이 명확하게 분배되어 있다.
5) 100년 뒤에도 실패하지 않는 만료 테스트 코드
이렇게 리팩토링이 완료된 `JwtTokenProvider`는 테스트 환경에서 완벽하게 시뮬레이션할 수 있다.
@Test
void 정확한_시점에_JWT_만료시간이_계산된다() {
// 1. 테스트 실행 시점과 무관하게 오전 10시로 고정된 시계 준비
Clock fixedClock = Clock.fixed(
Instant.parse("2026-06-16T10:00:00Z"), ZoneOffset.UTC
);
Duration expiration = Duration.ofMinutes(30);
JwtTokenProvider provider = new JwtTokenProvider(fixedClock, expiration);
// 2. 만료 시각 계산 위임
Instant expiresAt = provider.createExpirationTime();
// 3. 단언(Assert): 결과는 언제나 정확히 10시 30분이어야 한다.
assertEquals(Instant.parse("2026-06-16T10:30:00Z"), expiresAt);
}
JWT가 관리하는 모든 시간 메트릭은 사용자의 지역 시계 바늘 위치가 아니라, 분산 인프라 간의 명확한 합의가 생명인 '절대 시점(Point in Time)'의 영역이다. 따라서 JWT 모듈을 설계할 때 우리가 고수해야 할 최적의 정답 조합은 다음과 같다.
- Clock(시계 주입) -> Duration(정책 설정) -> Instant(최종 결과 도출)
위 조합을 통해 인프라 타임존 오염 리스크를 제로로 만들고, 플래키 테스트의 위협으로부터 시스템을 완벽하게 구출해 낼 수 있었다. 이것이 바로 이번 JWT 리팩토링 여정에서 얻은 가장 값진 기술적 인사이트다.
한 마디로, JWT는 LocalDateTime이 아니라 Instant의 문제다.
JWT라는 거대한 산을 정복했으니, 이제 백엔드 개발에서 이것만큼이나 빈번하게 상용되는 또 다른 필수 관문으로 넘어가 보자. 데이터베이스 엔티티를 설계할 때마다 버릇처럼 집어넣는 createdAt, updatedAt 같은 Audit Timestamp 영역이다. 과연 이 필드들도 Instant로 통일해야 할까? 다음 장에서 명쾌하게 파헤쳐 보자.
12. Audit에서는 무엇을 사용할까?
JWT의 시간 모델을 마스터하고 나면, 백엔드 개발자들의 시선은 자연스럽게 매일 마주치는 또 다른 시간 데이터로 향하게 된다. 바로 데이터베이스 엔티티를 설계할 때마다 관성적으로 집어넣는 '생성일시(createdAt)'와 '수정일시(updatedAt)', 즉 Audit(감사) 타임스탬프 영역이다. 실제로 수많은 국내 프로젝트와 튜토리얼을 열어보면 십중팔구 다음과 같이 설계되어 있다.
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
이 방식은 너무나 흔해서 거의 업계 표준(De facto standard)처럼 보이기까지 한다. 하지만 시스템 아키텍처 관점에서 조금만 삐딱하게 바라보면 강한 의문이 생긴다.
"JWT에서는 글로벌 정합성과 절대 시점이 중요해서 Instant를 썼는데,
시스템의 변경 이력을 추적하는 Audit 데이터 역시 본질은 똑같지 않을까?"
이 질문에 대한 명쾌한 해답과 함께, 실무에서 마주하는 인프라적 트레이드오프를 파헤쳐 보자.
12-1. Audit의 본질: Audit은 사람의 시간일까?
Audit 데이터의 존재 목적을 다시 한번 상기해 보자. 시스템 내부에서 이 필드들을 활용해 던지는 질문은 다음과 같다.
- "이 회원은 정확히 언제 가입했는가?"
- "결제 직후 데이터가 변조되었는데, 수정일시가 결제 시점보다 이전인가 이후인가?"
- "장애가 발생한 서버의 로그 시각과 DB에 데이터가 밀려 들어온 시각의 선후 관계는 어떻게 되는가?"
- "최근에 가입한 순서대로 10명만 정렬(`ORDER BY created_at DESC`)해서 가져와라."
여기에 나열된 질문들의 핵심은 '사용자가 보기 좋은 달력의 숫자'가 아니다. 오직 우주 공통의 타임라인 위에서 "이 이벤트가 정확히 어떤 순간에 일어났는가"라는 절대 시점(Point in Time)을 기록하고 정렬하는 것이다. 이 관점에서 보면 Audit 데이터는 앞서 다룬 JWT 표준과 성격이 완전히 일치한다.
1) LocalDateTime 기반 Audit의 달콤함과 숨겨진 독
가장 대중적인 LocalDateTime 기반 설계는 확실히 매력적인 장점을 가지고 있다.
@Entity
public class Member {
@CreatedDate
private LocalDateTime createdAt;
}
- 압도적인 가독성: DB 툴(DBeaver, DataGrip 등)로 `SELECT * FROM member`를 쳤을 때 2026-06-16 14:30:00이라고 인간의 언어로 정직하게 찍힌다. 개발자가 뇌 내 변환을 거치지 않아도 되므로 직관적이다.
- 낮은 진입 장벽: 거의 모든 국내 레거시 시스템이 이 방식을 고수하므로 이질감이 없다.
2)⚠️ 하지만 인프라가 꼬이기 시작한다면?
어느 날 트래픽 증가로 인해 컨테이너 인프라를 확장하거나 AWS 리전을 다중화했다고 가정하자.
- 서울 리전 서버가 기록한 createdAt: 2026-06-16 14:30:00 (KST 기준)
- 버지니아 리전 서버가 기록한 createdAt: 2026-06-16 14:30:00 (EST 기준)
데이터베이스 파일에 적힌 문자열 숫자는 완벽하게 일치하지만, 두 회원의 실제 가입 시점은 13시간의 시차가 있는 전혀 다른 순간이다. 이 상태에서 `ORDER BY created_at DESC`로 글로벌 통합 정렬을 때리면 순서가 완전히 뒤죽박죽 꼬이는 대참사가 일어난다. LocalDateTime에는 타임존 컨텍스트가 없기 때문이다.
3) Instant 기반 Audit: 글로벌 아키텍처의 표준
반면 Audit 필드를 Instant로 선언하면 시간 왜곡 리스크가 원천 차단된다.
@Entity
public class Member {
@CreatedDate
private Instant createdAt; // UTC 에포크 타임 기반 작동
@LastModifiedDate
private Instant updatedAt;
}
- 타임존 독립성 보장: 물리 서버가 서울에 있든, 런던에 있든 상관없이 무조건 UTC 0시 기준의 동일한 타임라인 위에서 정밀한 타임스탬프를 찍는다. 인프라의 위치가 비즈니스 데이터의 정합성을 해치지 못한다.
- 단순 명쾌한 연산: 데이터의 선후 비교(createdAt.isBefore(updatedAt)) 및 DB 인덱스를 활용한 정렬 연산이 롱(long) 타입 정수 비교 수준으로 가벼워져 대규모 트래픽 정렬 효율이 극대화된다.
12-2. "저장은 Instant, 표현은 ZonedDateTime"의 마법
많은 개발자들이 Instant 도입을 꺼리는 진짜 이유는 딱 하나다. DB를 직접 조회했을 때 `2026-06-16T05:30:00Z` 처럼 UTC 시간으로 찍히니, 한국 시간(+9시간)을 머릿속으로 더해가며 서비스 문의를 확인하기가 너무 피곤하다는 점이다. 이 불편함은 앞서 정립한 백엔드 시간 설계의 대원칙으로 우아하게 해결할 수 있다.
- DB 저장은 철저히 시스템 중심의 `Instant` -> 화면 전시는 사용자 중심의 `ZonedDateTime`
// 1. 엔티티에는 순수한 절대 시점만 저장 (오염 위험 제로)
Instant databaseTime = member.getCreatedAt();
// 2. 어드민 페이지나 API 응답으로 나갈 때만 한국 시간으로 옷을 입혀서 제공
ZonedDateTime displayTime = databaseTime.atZone(ZoneId.of("Asia/Seoul"));
// 결과: 2026-06-16 14:30:00 (KST)
인프라의 정합성과 개발자/사용자의 가독성을 모두 챙기는 가장 모던하고 이상적인 레이아웃이다.
1)❓ ZonedDateTime을 직접 Audit 필드로 쓰면 안 되나요?
종종 다음과 같은 엔티티 설계를 마주하기도 한다.
private ZonedDateTime createdAt;
작동은 하지만, Audit 도메인의 성격상 과한 정보(Too Much Information)다. ZonedDateTime은 서머타임 규칙이 동적으로 요동치는 '글로벌 항공권 예약'이나 '캘린더 일정 관리'처럼 해당 지역 표준시의 맥락 자체가 비즈니스적으로 유지되어야 할 때 쓰는 물건이다. 반면 Audit은 "언제 이 데이터가 생성/수정되었는가"라는 찰나의 팩트만 중요할 뿐, 이 데이터가 생성될 당시 서버가 어느 행정 구역 타임존에 귀속되어 있었는가라는 메타데이터까지 보존할 가치는 전혀 없다.
12-3. Spring Data JPA Auditing 적용 실전 템플릿
프로젝트 전역에 Instant 기반 Audit 메커니즘을 이식하는 표준 가이드라인이다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(name = "created_at", updatable = false, nullable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
// Getters...
}
@Configuration
@EnableJpaAuditing // 스프링 데이터 JPA의 Auditing 트리거 활성화
public class JpaAuditConfig {
// 이제 엔티티가 영속화될 때 자바가 현재 시점의 Instant를 자동으로 계산해 채워준다.
}
12-4.⚠️ 최종 관문: 물리 DB 컬럼 타입과의 궁합 체크
엔티티 필드를 Instant로 안전하게 바꿨다면, 반드시 실제 사용하는 관계형 데이터베이스(RDB)의 컬럼 타입 명세서까지 대조해 보아야 뒤통수를 맞지 않는다. 자바 메모리 속의 Instant가 DB 드라이버를 거치며 데이터가 왜곡될 수 있기 때문이다.
1) PostgreSQL 환경
- ❌ 위험: `TIMESTAMP` (타임존 정보가 없는 일반 타임스탬프)
- ⭕ 안전: `TIMESTAMP WITH TIME ZONE` (`TIMESTAMPTZ`)
- 이유: PostgreSQL의 TIMESTAMP 타입에 Instant를 넘기면, JDBC 드라이버나 DB 세션의 타임존 설정에 의해 UTC 시간이 현지 로컬 시간 숫자로 변형되어 강제 정착해 버리는 대참사가 날 수 있다. 반드시 `WITH TIME ZONE` 계열로 컬럼을 생성해야 완벽히 UTC로 수렴한다.
2) MySQL / MariaDB 환경
- 추천: `DATETIME` 또는 `TIMESTAMP`
- 주의점: 어플리케이션과 DB 간의 글로벌 커넥션 타임존 설정을 명시적으로 일치시켜야 한다. 스프링의 JDBC URL 프로퍼티에 `serverTimezone=UTC` 설정을 부여하여 드라이버 단에서 혼선이 생기지 않도록 못을 박아두는 것이 실무 표준이다.
3) 한눈에 보는 실무 선택 가이드라인
내 프로젝트의 상황에 따라 어떤 타입을 선택해야 할지 명확한 나침반을 제안한다.
- `LocalDateTime` Audit이 충분한 상황:
- 단일 리전, 단일 서버로 작동하는 소규모 국내 전용 프로젝트
- 레거시 DB 시스템의 스키마 구조를 변경할 수 없는 제약이 있을 때
- `Instant` Audit이 강력히 권장되는 상황:
- 서버 컨테이너가 분산 배치되거나 다중 리전(Multi-region) 확장을 고려하는 글로벌 서비스
- MSA(Microservice Architecture) 환경이라 서버 간 로그 및 이벤트 타임스탬프 정렬 정합성이 생명일 때
- 5년, 10년 이상 장기적으로 안정되게 빌드업해야 하는 엔터프라이즈 아키텍처
Audit 데이터가 지닌 비즈니스적 본질은 사람이 보는 시계 눈금이 아니라, 지구상 단 하나로 특정되는 '절대 시점(Point in Time)'의 기록이다. 따라서 현대 백엔드 설계에서는 `Instant`로 정밀하고 일관성 있게 영속성 레이어에 저장하고, 어드민이나 클라이언트 등 인간의 눈에 노출되어야 하는 최전선 프리젠테이션 레이어에서만 `ZonedDateTime`을 활용해 현지 시각으로 디코딩하는 전략이 가장 영리하고 견고한 정답이다. Audit의 시간 축을 완벽히 정립했으니, 이제 다음 장으로 넘어가 보자. 앞 장에서 맛보기로 살펴보았던 Duration과 과거의 유물인 long milliseconds 방식을 정면으로 대조해보며, 현대 자바 코드에서 왜 정수형 시간 계산식을 몰아내고 객체지향적인 시간 길이 타입을 고수해야 하는지 그 필연적인 패러다임을 정복해 보자.
13. Duration vs long Milliseconds
앞서 Duration을 시간의 길이(Time Amount)를 표현하는 타입이라고 설명했다.
하지만 실제 실무 프로젝트를 살펴보면 여전히 다음과 같은 코드가 자주 보인다.
long expirationMillis = 30 * 60 * 1000;
long retryDelay = 5000;
코드 자체는 문제없이 동작한다. 그러나 중요한 것은 동작 여부가 아니라, "코드가 무엇을 표현하는가?"이다.
두 방식의 차이는 단순히 문법의 차이가 아니다. 본질적인 개념의 차이다.
- long = 숫자 (단순한 데이터)
- Duration = 시간의 길이 (의미를 가진 타입)
1) 가독성과 의미론적 차이
다음 두 코드를 몇 달 뒤에 다시 바라본다고 가정해 보자.
// 1) long 밀리초 방식
long expirationMillis = 30 * 60 * 1000;
// 2) Duration 방식
Duration expiration = Duration.ofMinutes(30);
1번 코드는 순간적으로 계산이 필요하다. '30분인가? 30초인가? 30시간인가?' 머릿속에서 마술 숫자(Magic Number)를 해석하는 연산 과정을 거쳐야 한다. 반면 2번 코드는 보는 즉시 30분임을 이해할 수 있다. 코드 자체가 스스로를 설명(Self-Describing)하고 있는 것이다.
2) 타입 안정성과 단위(Unit) 실수 방지
메서드 시그니처에 long을 사용하면 심각한 정보 손실이 발생한다.
// 무엇을 뜻하는지 알 수 없다 (초? 밀리초? 분?)
public void setTimeout(long timeout) { ... }
// 의미가 명확하다 (시간의 길이를 받는다)
public void setTimeout(Duration timeout) { ... }
`long timeout = 30;`이라고 작성했을 때, 이 값이 30초인지, 30밀리초인지, 혹은 30분인지는 코드를 작성한 본인만 알 수 있다. 반면 `Duration.ofSeconds(30);`은 오해의 여지가 전혀 없다. 잘못된 단위를 전달하는 휴먼 에러를 컴파일 시점에 차단한다.
3) 변경 취약성과 유지보수성
비즈니스 요구사항이 바뀌어 토큰 만료 시간이 30분에서 1시간으로 변경되었다고 가정해 보자.
- 기존 long 방식: `60 * 60 * 1000`으로 코드를 수정해야 한다.
- 새로운 개발자는 이 곱셈 계산이 정말 맞게 검증되었는지 다시 확인해야 하는 피로감을 느낀다.
- Duration 방식: `Duration.ofHours(1);`로 직관적으로 변경된다. 비즈니스 정책의 변경이 코드에 그대로 투영된다.
13-4. Spring Boot 및 Java Time API와의 호환성
1) Spring Boot 설정 파일(@ConfigurationProperties) 연동
Spring Boot는 Duration 타입을 기본적으로 강력하게 지원한다.
jwt:
expiration: 30m # 설정을 보는 것만으로도 의미가 명확하다
@ConfigurationProperties("jwt")
public class JwtProperties {
private Duration expiration; // Spring이 문자열(30m)을 Duration 객체로 자동 변환한다
}
- 지원 형식 예시: 30s (30초), 5m (5분), 2h (2시간), 7d (7일)
2) Java Time API와의 자연스러운 결합
Duration은 Java의 다른 시간 객체들과 결합할 때 가독성이 극대화된다. 거의 자연어와 유사하게 읽힌다.
// 추천: 자연어처럼 읽히는 구조 (현재 시각 + 30분 = 만료 시각)
Instant expiresAt = clock.instant().plus(Duration.ofMinutes(30));
// 비추천: 의도를 파악하기 위해 해석이 필요한 구조
long expirationMillis = 30 * 60 * 1000;
long expiresAt = System.currentTimeMillis() + expirationMillis;
3) 언제 long을 사용해도 될까?
물론 모든 곳에서 Duration만 고집할 필요는 없다. 다음과 같은 예외적인 상황에서는 long이 더 적합하거나 필수적이다.
- 저수준 표준 API 사용 시: `Thread.sleep(1000);`처럼 API 자체가 밀리초 구조를 강제할 때
- 외부 라이브러리 호환성: `System.currentTimeMillis();`를 사용하는 레거시 라이브러리와 연동할 때
하지만 이를 제외한 애플리케이션 내부의 핵심 도메인 모델, 비즈니스 로직, 정책 표현에는 항상 `Duration`을 우선시해야 한다.
4) 요약 및 실무 추천 기준
| 구분 | long 방식 | Duration 방식 |
| 본질 | 단순 숫자 (30000) | 시간의 길이 (Duration.ofSeconds(30)) |
| 의도 표현 | 모호함 (해석 프로세스 필요) | 명확함 (코드 자체가 문서가 됨) |
| 타입 안정성 | 낮음 (단위 실수 유발 가능) | 높음 (컴파일러가 단위 검증) |
💡 최종 행동 강령 (Action Item)
- 좋지 않은 예: `private long expirationMillis;`, `private long retryDelay;`
- 추천하는 예: `private Duration expiration;`, `private Duration retryDelay;`
핵심 원칙: "시간 정책은 무조건 Duration으로 표현한다."
JWT 유효 시간, Redis TTL, 캐시 만료 시간, 재시도(Retry) 간격, 타임아웃 등 시스템의 '시간 정책'을 다룰 때는 long이 아닌 Duration을 선택하는 것이 유지보수하기 훨씬 쉽다. 결국 좋은 설계란 단순히 '동작하는 코드'를 넘어 '개발자의 의도가 명확히 드러나는 코드'를 만드는 것이다. Duration은 시간 정책의 의도를 가장 우아하게 표현하는 도구다. 다음 장에서는 지금까지 살펴본 개념들을 바탕으로 JWT, Audit, 로그, Redis TTL, 예약 시스템 등 실제 실무 서비스에서 어떤 시간 타입을 조합하여 선택해야 하는지 최종 정리해 보겠다.
14. 실무에서 추천하는 시간 설계
LocalDateTime, ZonedDateTime, Instant, Duration, Clock까지, 자바 8+ 타임 API의 모든 유닛을 깊이 있게 정복했다. 하지만 아키텍트와 시니어 백엔드 엔지니어에게 정작 중요한 것은 개별 클래스의 문법이 아니다. "내 눈앞에 놓인 실무 비즈니스 유즈케이스에 정확히 어떤 타입을 커스텀하게 박아 넣어야 하는가?"이다. 이번 최종장에서는 우리가 지금까지 쌓아 올린 모든 지식을 총동원하여, 실무 현업 개발에서 매일같이 마주치는 대표 시나리오별 베스트 추천 가이드라인을 완전하게 집대성해 보자.
14-1. 인프라 및 인증 시스템 레이어 (System Core)
1) JWT (JSON Web Token)
- 추천 조합: Clock + Instant + Duration
- 한 줄 정리: 현재 시각은 Clock으로 공급받고, 만료 정책은 Duration으로 설정하며, 최종 발급(iat) 및 만료(exp) 시점은 Instant로 마킹한다. 타임존 개입이 불허되는 대표적인 절대 시점 도메인이다.
- 코드 예시: `Instant expiresAt = clock.instant().plus(Duration.ofMinutes(30));`
2) Audit (데이터베이스 변경 감사 트래킹)
- 추천 타입: Instant
- 한 줄 정리: createdAt, updatedAt은 사람이 읽는 눈금이 아니라 이벤트가 발생한 글로벌 팩트(Fact)다. 엔티티 내부에는 Instant로 저장하고, 관리자 화면에 뿌려줄 때만 서비스 타임존(ZonedDateTime)으로 변환(Decoding)하는 것이 장애 없는 글로벌 표준 아키텍처다.
- 코드 예시: `@CreatedDate private Instant createdAt;`
3) 로그 및 도메인 이벤트 (Log & Domain Event)
- 추천 타입: Instant
- 한 줄 정리: 분산 시스템 환경에서 주문 생성, 결제 완료, 배송 시작 같은 이벤트 시각(occurredAt)은 서버 리전 위치와 무관하게 일렬로 완벽히 정렬(Ordering)되고 재생(Replay)될 수 있어야 하므로 Instant가 강제된다.
- 코드 예시: `Instant loggedAt = clock.instant();`
14-2. 네트워크 및 미들웨어 정책 레이어 (Policy & Timeout)
4) Redis TTL & 인메모리 캐시 만료 시간
- 추천 타입: Duration
- 한 줄 정리: "이 캐시 데이터를 얼마나 오래 유지할 것인가?"에 대한 시간의 물리적 거리 규격이므로 타입 안정성이 완벽한 Duration을 쓴다. 스프링의 RedisTemplate 등의 API와도 찰떡궁합을 자랑한다.
- 코드 예시: `Duration cacheTtl = Duration.ofHours(1);`
5) Retry 정책 및 타임아웃 (Retry & Timeout)
- 추천 타입: Duration
- 한 줄 정리: HTTP 커넥션 타임아웃, DB 쿼리 타임아웃, 락(Lock) 대기 타임아웃, 혹은 API 호출 실패 후 재시도 간격 규칙은 0.001초의 단위 실수도 용납되지 않는 영역이므로 무조건 Duration으로 명시한다.
- 코드 예시: `Duration retryDelay = Duration.ofSeconds(5);`
14-3. 도메인 및 글로벌 예약 비즈니스 레이어 (Business Domain)
6) 글로벌 사용자 일정 및 국제 예약 시스템 (Calendar & Booking)
- 추천 타입: ZonedDateTime
- 한 줄 정리: "서울 오전 9시 화의", "시드니발 런던행 항공권", "해외 호텔 예약" 등은 행정 구역의 타임존 맥락과 서머타임(DST) 변동 규칙이 시간 숫자와 한몸으로 묶여서 보존되어야만 비즈니스 버그가 터지지 않는 전형적인 ZonedDateTime 도메인이다.
- 코드 예시: ZonedDateTime departureTime = ZonedDateTime.of(localTime, ZoneId.of("Europe/London"));
7) 웹/앱 사용자 입력 최전선 화면 (UI Presentation)
- 추천 타입: LocalDateTime
- 한 줄 정리: 사용자가 날짜/시간 선택 컴포넌트(Picker)를 통해 아직 타임존이 확정되지 않은 눈금 정보(2026-06-16 14:30)를 날것으로 기입할 때는 LocalDateTime 인터페이스로 바인딩하여 수용하는 것이 가장 직관적이다.
- 코드 예시: `LocalDateTime reservationInput = form.getReservationTime();`
8) 순수 달력 속성: 생일 및 정기 영업 시간 (Pure Calendar & Time)
- 추천 타입: LocalDate 또는 LocalTime
- 한 줄 정리: 생일(birthday)은 지구 어디서 보든 그냥 날짜 숫자 자체가 본질이므로 시간이 필요 없는 LocalDate가 맞다. 매장 문을 여는 "오전 9시 오픈" 같은 규칙은 날짜 정보가 사족이므로 LocalTime이 가장 명확한 표현이다.
- 코드 예시: `LocalDate birthday = LocalDate.of(1995, 5, 23);`
실무 완벽 가이드 치트 시트 (Decision Table)
| 비즈니스 시나리오 분류 | 실제 구현 유즈케이스 | 최적의 추천 타입 | 아키텍처 관점의 핵심 이유 |
| 인프라 / 시스템 인프라 | JWT 발급 및 만료 시각 (iat, exp) | Instant | 글로벌 분산 서버 간 1밀리초의 왜곡도 없는 절대 시점 합의 필요 |
| DB Audit 엔티티 변경 기록 (createdAt) | Instant | 데이터의 정확한 인서트 선후 정렬 및 다중 리전 인프라 확장 대비 |
|
| 분산 메시지 큐 / 서버 로그 타임스탬프 | Instant | 시스템 장애 추적 시 여러 서버의 타임라인 동기화 일치 |
|
| 정책 / 인프라 설정 | JWT 유효 시간 토큰 TTL 정책 | Duration | long millis 방식의 단위 착각 사고 및 마술 숫자 전면 배제 |
| Redis / Cache 만료 시간 설정 | Duration | 스프링 부트 외부 설정 파일(yaml) 바인딩과의 압도적 가독성 연동 |
|
| API 타임아웃 / 재시도 주기 정책 | Duration | 메서드 시그니처만 보고도 시간의 길이임을 알 수 있는 타입 안정성 |
|
| 비즈니스 / 글로벌 서비스 | 국제 항공권 / 글로벌 호텔 및 회의 예약 | ZonedDateTime | 현지 행정 구역 타임존 정보 및 글로벌 서머타임(DST) 변동 추적 |
| 사용자가 기입하는 임시 예약 일시 입력 폼 | LocalDateTime | 타임존이 확정되기 전, 순수한 인간 중심 시계 바늘 눈금 저장 |
|
| 단순 날짜 / 시간 메타 | 회원의 생일 / 결혼 기념일 | LocalDate | 시간과 시차가 배제된 순수한 달력 날짜 정보 보존 |
| 매장의 일일 정기 영업 시작/종료 시간 | LocalTime | 날짜가 배제된 순수한 일일 시계 눈금 규칙 표현 |
백엔드 시간 설계의 거대한 요약
시간을 관통하며 배운 타임 아키텍처의 철학을 단 한 줄의 수식과 원칙으로 마음속에 명확히 아로새겨 두자.


그리고 수많은 아키텍처적 갈등의 순간을 단칼에 정리해 줄 실무 제1수칙은 이것이다.

이 강력한 패러다임 분리와 그라운드 룰을 장착하는 순간, 여러분의 백엔드 시스템은 타임존 변동으로 인한 야간 긴급 장애 호출로부터 완벽하게 해방될 것이며, 어떤 글로벌 분산 환경 요구사항이 밀려와도 끄떡없는 견고한 무기가 될 것이다.
💡 가슴에 새길 최종 대원칙: 저장(Storage)은 시스템 중심의 Instant로 안전하게 통일하고, 표현(Presentation)은 사용자 중심의 ZonedDateTime으로 유연하게 옷을 입혀라!
15. 결론: 시간을 지배하는 백엔드 아키텍처를 마치며
이 기나긴 연재는 단순히 자바가 제공하는 java.time 패키지의 문법과 사용법을 나열하기 위해 시작된 글이 아니다. 처음에는 실무 프로젝트의 JWT(JSON Web Token) 검증 모듈을 리팩토링하던 도중 마주친, 어쩌면 아주 사소해 보이는 몇 가지 의문에서 출발했다.
- "왜 다들 관성적으로 쓰는 LocalDateTime 대신 Instant를 써야 할까?"
- "왜 날것의 정수형인 long milliseconds 대신 객체인 Duration을 고수해야 할까?"
- "왜 Instant.now()를 직호출하지 않고 굳이 Clock을 스프링 빈으로 주입받아야 할까?"
처음에는 그저 개발자 개인의 취향이나 구현 스타일의 차이 정도로 치부하기 쉽다. 하지만 자바 타임 API의 깊숙한 내부 메커니즘을 파헤치고, 아키텍처 관점에서 시간을 재정립해 보면서 한 가지 거대한 진실을 깨닫게 되었다. 이것은 단순한 코딩 스타일의 문제가 아니라, "소프트웨어 시스템 안에서 '시간'이라는 도메인을 아키텍처적으로 어떻게 모델링할 것인가"에 대한 본질적인 패러다임의 문제였다.
시간이라는 단어에 갇히면 버그가 시작된다. 우리는 일상생활에서 "시간"이라는 단어를 단 하나의 개념처럼 뭉뚱그려 사용한다. 하지만 백엔드 엔지니어가 다루는 시스템 속 시간은 전혀 다른 책임과 성격을 가진 파편들의 집합이다.
- 현재 시각 (계속 흘러가는 외부 환경의 공급 데이터)
- 이벤트 발생 시각 (이미 우주 어딘가에서 터져버린 변하지 않는 절대적 사실)
- 토큰 및 캐시 유효 시간 (비즈니스 룰을 나타내는 연속적인 물리적 거리)
- 사용자의 예약 일정 (정치·행정적 규칙과 시차가 결합된 지역 컨텍스트)
이를 하나의 프리미티브 타입(long)이나 잘못된 타입(LocalDateTime)으로 전부 처리하려고 드는 순간, 인프라 확장이나 글로벌 타임존, 그리고 단위 테스트 환경에서 설계가 스파게티처럼 꼬이고 시차 왜곡 버그가 터지기 시작한다. 자바 타임 API가 여러 분화된 타입을 제공하는 진짜 이유도 바로 여기에 있다.
1) 시간은 역할별로 분리해야 한다.
이 글에서 우리가 완벽하게 정립한 핵심 개념의 역할 분담은 다음과 같다.
각각은 서로 대체할 수 있는 관계가 아니며, 표현하는 비즈니스적 의미가 완전히 다르다.
- Clock = 현재 시각 공급 (인프라 추상화)
- Instant = 특정 시점 (타임존 독립적인 절대 시점)
- Duration = 시간의 길이 (타임아웃, TTL 등의 정책)
- LocalDateTime = 사람이 보는 시간 (타임존 없는 날것의 입력)
- ZonedDateTime = 시간대가 포함된 사람의 시간 (지역적 맥락이 포함된 표현)

이 원칙을 레이어에 이식하는 순간 다음과 같은 거대한 기술적 이점을 얻을 수 있다.
- Time Zone 문제의 근본적인 감소
- 데이터베이스 인덱스를 활용한 선후 비교 및 정렬 연산의 단순화
- MSA 등 분산 시스템 환경에서 서버 간 이벤트 정합성 대응 용이
- 글로벌 다국적 서비스로의 유연한 확장성 확보
물론 모든 상황에 통용되는 절대적인 은탄환(Silver bullet)은 아니다.
하지만 대부분의 모던 백엔드 시스템에서는 시스템의 신뢰성을 보장하는 가장 강력한 기본 전략이 될 것이다.
2) JWT 리팩토링을 통해 얻은 진짜 인사이트
프로젝트의 핵심 인증 메커니즘인 JWT를 다시 설계하면서 가장 크게 깨달은 점은, JWT가 필요로 하는 것은 '사람이 시계를 보았을 때 눈에 들어오는 시간 눈금'이 아니라는 사실이었다. JWT의 생명 주기를 제어하는 본질은 전 세계 어디서나 동일한 타임라인으로 통제되어야 하는 '절대 시점'이다. 그렇기에 JWT 도메인에서는 다음 트리니티 조합이 가장 자연스럽고 견고한 정답이 된다.

이 조합은 우리에게 테스트 가능성(Testability), 타임존 독립성, 그리고 깔끔한 책임 분리라는 아키텍처적 선물을 동시에 안겨준다.
마치며 시간 관련 장애는 대개 개발자가 Java Time API의 사용법을 몰라서 발생하지 않는다. 대부분은 내가 지금 만지는 데이터가 현재 시각인지, 특정 시점인지, 시간의 길이인지, 아니면 사람이 인지하는 로컬 시간인지를 개념적으로 명확하게 모델링하지 못해서 발생한다. 그래서 시간 설계의 진짜 핵심은 API 클래스 선택의 기술이 아니라 '개념의 분리'이다. 시간을 올바르게 추상화하고 격리하면 코드가 명확해지고, 테스트가 쉬워지며, 타임존이라는 복잡한 인프라 문제도 훨씬 단순하게 다룰 수 있다. 결국 우리가 이 긴 여정을 통해 가져가야 할 최종 핵심 인사이트는 다음 한 문장으로 요약된다.
"시간 문제는 단순한 API 선택의 문제가 아니다.
시간을 어떤 비즈니스 개념으로 모델링할 것인가의 문제다."
앞으로 프로덕션 코드가 시차와 플래키 테스트의 위협으로부터 언제나 안전하기를 응원하는 마음이다.
