자바 애플리케이션 개발을 하다 보면 종종 문자열 타입의 시간 정보를 자바의 LocalDateTime 혹은 LocalDate, LocalTime과 같은 인스턴스로 변환을 하거나 역으로 원하는 시간 형식으로 문자열로 변환을 해야 하는 경우가 있다. 또한 지역 시간(timezone)에 맞도록 시간을 변경해야 하는 경우도 있을 것이다. 이번 포스팅에서는 java time과 java time convert(시간 변환)에 대해서 정리해 보고자 한다.
기본으로 제공되는 시간 관련 클래스
java에서는 기본적으로 제공하는 몇 가지 시간 관련 클래스가 있다. 해당 클래스의 목록은 아래와 같다.
| 클래스 | now() 호출 형식 | 기본 timezone |
|---|---|---|
| LocalDateTime | 2023-09-17T13:22:51.003196 | 시스템에 적용된 timezone의 날짜와 시간 표시 |
| LocalDate | 2023-09-17 | 시스템에 적용된 timezone의 날짜 표시 |
| LocalTime | 13:26:02.674767 | 시스템에 적용된 timezone의 시간 표시 |
| OffsetDateTime | 2023-09-17T13:26:42.987772+09:00 | 시스템에 적용된 timezone을 UTC 기준으로 timezone offset 표시 (날짜 + 시간 + timezone offset) |
| OffsetTime | 13:29:37.716176+09:00 | 시스템에 적용된 timezone을 UTC 기준으로 timezone offset 표시 (시간 + timezon offset) |
| ZonedDateTime | 2023-09-17T13:30:21.927436+09:00[Asia/Seoul] | 시스템에 적용된 timezone을 UTC 기준으로 timezone offset과 지역 정보 표시 (날짜 + 시간 + timezone offset + 지역) |
| Instant | 2023-09-17T05:00:09.049776Z | UTC timezone (날짜 + 시간 ) Z는 UTC를 표현하는 suffix 정보 |
클래스 이름에서 알 수 있듯이 XXXLocalTime은 날짜와 시간을 모두 다루고 XXXDate는 날짜만, XXXTime은 시간만 다룬다.
DateTimeFormatter
DateTimeFormatter는 java 시간 관련 인스턴스를 출력하고자 하는 시간 포맷으로 변환할 수 있도록 지원하거나 String 타입의 시간 정보 형식을 파싱 하기 위해서 제공되는 시간 형식 분석기이다.
몇 가지 대표 심볼
시간 정보 파싱을 위한 몇 가지 대표 형식은 아래와 같다. 더 자세한 형식은 DateTimeFormatter 클래스의 주석을 확인해 보면 된다.
| symbol | meaning | example |
|---|---|---|
| y | year-of-era (서기년도) | 2004; 04 |
| M | month-of-year | 7; 07 |
| D | day-of-year | 189 |
| d | day-of-month | 09 |
| H | hour-of-day (0-23) | 14 |
| m | minute-of-hour | 30 |
| s | second-of-minute | 55 |
| S | fraction-of-second | 978 |
| n | nano-of-second | 987654321 |
| V | time-zone ID | Asia/Seoul |
| v | generic time-zone name | Pacific Time; PT |
| z | time-zone name | Pacific Standard Time; PST |
| O | localized zone-offset | GMT+8; UTC-08:00; GMT+08:00 |
| X | zone-offset (‘Z’ for zero) | Z;-08; -0830;-08:30;-083015;-08:30:15 |
| x | zone-offset | +0000; -08; -0830; -08:30; -083015; -08:30:15 |
| Z | zone-offset | +0000; -0800; -08:00 |
DateTimeFormatter example
String localDateTimeStr = "2023-09-17 14:08:23";
//datetime foramt
//y-M-d H:m:s 와 yyyy-MM-dd HH:mm:ss 는 동일하다
DateTimeFormatter dateTimeFormatter1 =
DateTimeFormatter.ofPattern("y-M-d H:m:s");
DateTimeFormatter dateTimeFormatter2 =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime localDateTime1 = LocalDateTime.parse(localDateTimeStr, dateTimeFormatter1);
LocalDateTime localDateTime2 = LocalDateTime.parse(localDateTimeStr, dateTimeFormatter2);
System.out.println("instance time1: " + localDateTime1);
System.out.println("instance time2: " + localDateTime2);
-----------------------------------------------------------------
결과
instance time1: 2023-09-17T14:08:23
instance time2: 2023-09-17T14:08:23JavaDateTimeFormatter에서 제공하는 기본 형식
DateTimeFormatter클래스에서 기본적으로 몇가지 시간 형식을 미리 정의하여 제공한다.

위 시간 형식들은 static으로 미리 정의되어 있는 시간 형식들이다. 아래 코드는 ISO_LOCAL_DATE_TIME형식의 코드이다.
public static final DateTimeFormatter ISO_LOCAL_DATE_TIME;
static {
ISO_LOCAL_DATE_TIME = new DateTimeFormatterBuilder()
.parseCaseInsensitive()
.append(ISO_LOCAL_DATE)
.appendLiteral('T')
.append(ISO_LOCAL_TIME)
.toFormatter(ResolverStyle.STRICT, IsoChronology.INSTANCE);
}
...
public static final DateTimeFormatter ISO_LOCAL_DATE;
static {
ISO_LOCAL_DATE = new DateTimeFormatterBuilder()
.appendValue(YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
.appendLiteral('-')
.appendValue(MONTH_OF_YEAR, 2)
.appendLiteral('-')
.appendValue(DAY_OF_MONTH, 2)
.toFormatter(ResolverStyle.STRICT, IsoChronology.INSTANCE);
}
...
public static final DateTimeFormatter ISO_LOCAL_TIME;
static {
ISO_LOCAL_TIME = new DateTimeFormatterBuilder()
.appendValue(HOUR_OF_DAY, 2)
.appendLiteral(':')
.appendValue(MINUTE_OF_HOUR, 2)
.optionalStart()
.appendLiteral(':')
.appendValue(SECOND_OF_MINUTE, 2)
.optionalStart()
.appendFraction(NANO_OF_SECOND, 0, 9, true)
.toFormatter(ResolverStyle.STRICT, null);
}JavaISO_LOCAL_DATE_TIME은 ISO_LOCAL_DATE와 ISO_LOCAL_TIME을 조합하여 시간 형식을 미리 정의한다.
ISO_LOCAL_TIME에 정의된 형식을 보면. optionalStart()가 호출되는 부분을 볼 수 있는데 .optionalStart()가 호출된 다음 형식은 생략이 가능하다는 의미로 해석하면 된다. 즉 SECOND_OF_MINUTE(초), NANO_OF_SECOND(나노초) 형식은 생략
가능하다는 것이다.
ChronoField enum 클래스에 정의된 몇 가지 타입들을 살펴보면 아래와 같다.
MILLI_OF_SECOND("MilliOfSecond", MILLIS, SECONDS, ValueRange.of(0, 999)),
...
SECOND_OF_MINUTE("SecondOfMinute", SECONDS, MINUTES, ValueRange.of(0, 59), "second"),
...
MINUTE_OF_HOUR("MinuteOfHour", MINUTES, HOURS, ValueRange.of(0, 59), "minute"),Java타입 이름에서 알 수 있듯이 MILLI_OF_SECOND는 0 – 999 범위의 밀리초 형식을 의미하고 SECOND_OF_MINUTE는 0 – 59의 초 형식을 의미한다. 이 처럼 각 정의된 필드를 따라가 보면서 그 의미를 확인하는 것도 DateTimeFormatter를 이해하는데 도움이 될 것이다.
DateTimeFormatter.ISO_LOCAL_DATE_TIME을 이용하여 String 타입의 시간 형식을 파싱 하는 코드를 살펴보자.
//T의 의미는 날짜 뒤에 시간이 표시된다는 의미의 리터럴 문자이다.
String localDateTimeStr = "2023-09-17T14:08:23.123";
LocalDateTime localDateTime = LocalDateTime.parse(localDateTimeStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
System.out.println(localDateTime);
//ISO_LOCAL_DATE_TIME은 y-M-dTH:m[:s][.n] ([]는 생략가능 하다는 의미)
//형식이므로 아래 시간 형식은 y-M-dTH:m 형식이므로 ISO_LOCAL_DATE_TIME으로 파싱이 가능하다.
localDateTimeStr = "2023-09-17T14:08";
localDateTime = LocalDateTime.parse(localDateTimeStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
System.out.println(localDateTime);JavaDateTimeFormatterBuilder를 이용한 커스텀 DateTimeFormatter 생성
주어진 시간 형식에 맞게 formatter를 커스텀하게 생성하여 시간 정보를 파싱 할 수 있다.
예를 들어 날짜와 시간 사이를 구분 짓는 리터럴 문자 ‘T’가 있지만 DateTimeFormatter에서 기본적으로 제공하는 형식이 아닌 경우 에는 ofPattern 메서드를 이용하여 파싱 하기가 어렵다. 아래의 코드를 보면
String zonedTimeStr = "2023-09-14T02:06:03.988+0000";
LocalDateTime localDateTime =
LocalDateTime.parse(zonedTimeStr, DateTimeFormatter.ofPattern("y-M-dTH:m:s.SZ"));
System.out.println(localDateTime);
--------------------------------------------------------
Unknown pattern letter: T 오류가 발생한다.Java보통은 ofPattern으로 시간 형식에 맞게 각 symbol을 지정하게 되는데 리터럴 문자 ‘T’ 역시 symbol로 인식하게 되어 오류가 발생한다. 이러한 경우에는 DateTimeFormatterBuilder를 사용하여 직접 DateTimeFormatter를 생성하여 처리할 수 있다.
String zonedTimeStr = "2023-09-14T02:06:03.988+0000";
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.parseCaseInsensitive()
.appendPattern("yyyy-MM-dd")
.appendLiteral('T')
.appendPattern("HH:mm:ss.SSS")
.parseLenient()
.appendOffset("+HHmm", "Z")
.parseStrict()
.toFormatter();
ZonedDateTime zonedDateTime = ZonedDateTime.parse(zonedTimeStr, formatter);
System.out.println("time: " + zonedDateTime);
System.out.println("zone: " + zonedDateTime.getZone());
String format = zonedDateTime.format(formatter);
System.out.println(format);
-------------------------------------------------------------------------
결과
time: 2023-09-14T02:06:03.988Z
zone: Z
2023-09-14T02:06:03.988Z
-------------------------------------------------------------------------
timezone 'Z'는 UTC를 의미한다.Java시간 변환 케이스
timezone 목록은
List Of DataBase Timezones, List of Timezone Abbreviations을 참고하기 바란다.
로컬 시간을 UTC 시간으로 변경 (LocalDateTime -> Instant)
테스트 환경의 timezone은 아래와 같다. (Asiz/Seoul +0900)
$>ll /etc/localtime
lrwxr-xr-x 1 root wheel 36 9 10 20:12 /etc/localtime -> /var/db/timezone/zoneinfo/Asia/SeoulBashLocalDateTime localDateTime = LocalDateTime.now();
System.out.println("localDateTime: " + localDateTime);
//내부적으로 ZoneOffset으로 지정된 시간초만큼을 뺀 epoch second로 Inatant를 생성하기 때문에
//ZoneOffset.UTC는 offset 초가 0 이므로 시간은 그대로이고 timezone만 UTC로 변경한 것과 같다.
Instant instant = localDateTime.toInstant(ZoneOffset.UTC);
System.out.println("instant: " + instant);
//ZoneOffset을 바로 지정하여 Instant를 생성할 수도 있다.
//9시간만큼의 초(3600 * 9) 만큼을 현재시간에 뺀 시간을 생성한다.
instant = localDateTime.toInstant(ZoneOffset.of("+0900"));
System.out.println("instant: " + instant);
//시스템에 적용된 timezone이 적용된 ZonedDateTime 인스턴스를 이용한다.
ZonedDateTime zonedDateTime = localDateTime.atZone(ZoneId.systemDefault());
instant = zonedDateTime.toInstant();
System.out.println("instant: " + instant);
ZoneOffset offset = zonedDateTime.getOffset();
instant = localDateTime.toInstant(offset);
System.out.println("instant: " + instant);
--------------------------------------------------------------------
결과
localDateTime: 2023-09-17T16:48:08.397653
instant: 2023-09-17T16:48:08.397653Z
instant: 2023-09-17T07:48:08.397653Z
instant: 2023-09-17T07:48:08.397653Z
instant: 2023-09-17T07:48:08.397653Z
--------------------------------------------------------------------
'Z'는 UTC timezone을 의미한다.JavaUTC 시간을 로컬 시간으로 변경 (Instant -> LocalDateTime)
Instant instant = Instant.now();
System.out.println("instant now: " + instant);
//변환할 timezone을 설정한다.
ZonedDateTime zonedDateTime = instant.atZone(ZoneId.systemDefault());
System.out.println("time zone: " + zonedDateTime.getZone());
LocalDateTime localDateTime = zonedDateTime.toLocalDateTime();
System.out.println("localDateTime: " + localDateTime);
----------------------------------------------------------
결과
instant now: 2023-09-17T08:11:04.676517Z
time zone: Asia/Seoul
localDateTime: 2023-09-17T17:11:04.676517Java
로컬 시간을 지정된 timezone 시간으로 변경 (LocalDateTime ->
ZonedDateTime)
LocalDateTime localDateTime = LocalDateTime.now();
ZoneId systemZone = ZoneId.systemDefault();
//PST (America/Los_Angeles)
ZoneId pstZone = ZoneId.of(ZoneId.SHORT_IDS.get("PST"));
//현재 시간을 ZonedDateTime 인스턴스로 변환
ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, systemZone);
//ZonedDateTime을 Instant 시간 기준으로 PST Zone 시간으로 변경
ZonedDateTime pstZoneTime = zonedDateTime.withZoneSameInstant(pstZone);
System.out.println("local time now: " + localDateTime);
System.out.println("local zoned time now: " + zonedDateTime);
System.out.println("PST time now: " + pstZoneTime);
//ZonedDateTime.toLocalDateTime()은 시스템 timezone에 맞춰서 변환해 주는 것이 아니라
//timezone 정보를 없애는 효과다.
//주어진 timezone에 맞춰서 시간을 변환하는 것은 withZoneSameInstant(toTimeZone)을 사용한다.
System.out.println(localDateTime + "은 미국 LA 시간으로 " + pstZoneTime.toLocalDateTime() + "입니다.");
---------------------------------------------------------
결과
local time now: 2023-09-17T18:50:58.739906
local zoned time now: 2023-09-17T18:50:58.739906+09:00[Asia/Seoul]
PST time now: 2023-09-17T02:50:58.739906-07:00[America/Los_Angeles]
2023-09-17T18:50:58.739906은 미국 LA 시간으로 2023-09-17T02:50:58.739906입니다.JavaUTC 시간을 지정된 timezone 시간으로 변경 (Instant ->
ZonedDateTime)
//UTC 시간
Instant instant = Instant.now();
ZoneId pstZone = ZoneId.of(ZoneId.SHORT_IDS.get("PST"));
//PST timezone이 지정된 ZonedDateTime으로 변경
ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, pstZone);
System.out.println("UTC time now: " + instant);
System.out.println("PST time now: " + zonedDateTime);
//PST time to local time (origin zone time -> UTC -> to zone time)
ZonedDateTime localDateTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
System.out.println("local zone time now: " + localDateTime.toLocalDateTime());
-----------------------------------------------------------
결과
UTC time now: 2023-09-17T09:59:43.951825Z
PST time now: 2023-09-17T02:59:43.951825-07:00[America/Los_Angeles]
local zone time now: 2023-09-17T18:59:43.951825Java
지정된 timezone 시간을 로컬 시간으로 변경 (ZonedDateTime ->
LocalDateTime)
ZonedDateTime을 LocalDateTime으로 변환하는 것은 ZonedDateTime.toLocalDateTime()을 호출하면 된다고 생각하기 쉬운데 .toLocalDateTime()은 ZonedDateTime에서 timezone 부분만 제거하는 효과라서 원하는 결과를 얻는 호출이 아니다.
timezone이 지정된 시간을 로컬 시간으로 변경하기 위해서는 이미 위 샘플코드에서 사용된 .withZoneSameInstant()를 사용한다.
ZoneId pstZone = ZoneId.of(ZoneId.SHORT_IDS.get("PST"));
ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(Instant.now(), pstZone);
System.out.println("pst zone time: " + zonedDateTime);
ZonedDateTime localZoneTime = zonedDateTime.withZoneSameInstant(ZoneId.systemDefault());
System.out.println("local zone time: " + localZoneTime);
LocalDateTime localDateTime = localZoneTime.toLocalDateTime();
System.out.println("local date time: " + localDateTime);
---------------------------------------------------------------------
결과
pst zone time: 2023-09-17T03:07:18.737404-07:00[America/Los_Angeles]
local zone time: 2023-09-17T19:07:18.737404+09:00[Asia/Seoul]
local date time: 2023-09-17T19:07:18.737404Java
지정된 timezone 시간을 다른 timezone 시간으로 변경(ZonedDateTime ->
ZonedDateTime)
//방콕 시간
String zonedTimeStr = "2023-09-14T02:06:03.988+07:00";
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.parseCaseInsensitive()
.appendPattern("y-M-d")
.appendLiteral('T')
.appendPattern("H:m:s")
.appendFraction(MILLI_OF_SECOND, 0, 3, true)
.parseLenient()
.appendOffsetId()
.parseStrict()
.toFormatter();
ZonedDateTime bangkokTime = ZonedDateTime.parse(zonedTimeStr, formatter);
ZoneId toTimeZone = ZoneId.of("America/Los_Angeles");
//LA 시간
ZonedDateTime losAngelesZoneTime = bangkokTime.withZoneSameInstant(toTimeZone);
System.out.println("방콕 시간 " + bangkokTime.toLocalDateTime() +
"은 LA 시간으로 " + losAngelesZoneTime.toLocalDateTime() + " 입니다.");
-------------------------------------------------------------------------
결과
방콕 시간 2023-09-14T02:06:03.988은 LA 시간으로 2023-09-13T12:06:03.988 입니다.Javatimestamp를 로컬 시간으로 변경 (timestamp -> LocalDateTime)
Instant instant = Instant.ofEpochSecond(1695368422);
System.out.println("instant: " + instant);
//방법1
ZoneOffset offset = ZoneId.systemDefault().getRules().getOffset(instant);
System.out.println("zone offset: " + offset);
LocalDateTime localDateTime2 = LocalDateTime.ofEpochSecond(1695368422, 0, offset);
System.out.println("local date time1: " + localDateTime2);
//방법2
LocalDateTime localDateTime3 = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
System.out.println("local date time2: " + localDateTime3);
--------------------------------------------------------------
결과
instant: 2023-09-22T07:40:22Z
zone offset: +09:00
local date time1: 2023-09-22T16:40:22
local date time2: 2023-09-22T16:40:22Javatimestamp를 지정된 timezone 시간으로 변경
아래 코드는 주어진 timestamp 시간을 미국 LA 현지 시간으로 변경하는 예시 코드다.
//1695368422: 2023-09-22T07:40:22Z (UTC 시간)
Instant instant = Instant.ofEpochSecond(1695368422);
System.out.println("instant: " + instant);
//UTC 시간을 PST(미국 LA) 시간으로 변경
ZoneId pstZone = ZoneId.of(ZoneId.SHORT_IDS.get("PST"));
ZoneOffset pstOffset = pstZone.getRules().getOffset(instant);
System.out.println("PST time offset: " + pstOffset);
ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, pstZone);
System.out.println("zoned date time: " + zonedDateTime);
//Timezone을 제거 하여 zoned time을 현지 시간화
LocalDateTime localDateTime = zonedDateTime.toLocalDateTime();
System.out.println(instant + " UTC 시간은 미국 LA 현지 시간 으로 " + localDateTime + " 입니다.");
----------------------------------------------------
결과
instant: 2023-09-22T07:40:22Z
PST time offset: -07:00
zoned date time: 2023-09-22T00:40:22-07:00[America/Los_Angeles]
2023-09-22T07:40:22Z UTC 시간은 미국 LA 현지 시간 으로 2023-09-22T00:40:22 입니다.Java실무에서 자주 빠지는 타임존 함정 3가지
java.time을 쓰면 타임존 문제가 사라질 거라고 생각하기 쉽다. 실제로는 그렇지 않다. “동작은 하는데 시간이 안 맞아요”라는 버그 리포트는 java.time을 도입한 프로젝트에서도 꾸준히 올라온다. 발견이 늦고 원인 추적이 까다로운 타임존 함정 세 가지를 짚어본다.
함정 1: LocalDateTime에는 타임존이 없다
LocalDateTime은 이름 그대로 “로컬” 시간이다. 타임존 정보가 없다. 같은 2026-04-11T14:30:00이라는 값이 서울 시간인지 UTC인지 알 수 없고, 이 값을 Instant로 변환하거나 다른 시스템에 전달하는 순간 문제가 드러난다.
LocalDateTime ldt = LocalDateTime.of(2026, 4, 11, 14, 30, 0);
// 서울 기준으로 해석하면 UTC 05:30
ZonedDateTime seoulTime = ldt.atZone(ZoneId.of("Asia/Seoul"));
Instant seoulInstant = seoulTime.toInstant();
// UTC 기준으로 해석하면 UTC 14:30
ZonedDateTime utcTime = ldt.atZone(ZoneOffset.UTC);
Instant utcInstant = utcTime.toInstant();
System.out.println("서울 기준: " + seoulInstant); // 2026-04-11T05:30:00Z
System.out.println("UTC 기준: " + utcInstant); // 2026-04-11T14:30:00ZJava같은 LocalDateTime인데 atZone()에 넘기는 ZoneId만 다르면 9시간 차이가 난다. 코드 리뷰에서 놓치기 쉬운 부분이 바로 이것이다. LocalDateTime을 Instant로 변환하는 코드가 보이면, “이 시간이 어느 타임존 기준인지” 명시되어 있는지부터 확인하자.
함정 2: 서버 타임존과 DB 타임존이 다를 때
애플리케이션 서버는 Asia/Seoul, DB는 UTC. 이 조합이 생각보다 흔하다. JDBC 드라이버가 타임존 변환을 암묵적으로 처리하기 때문에, 저장한 시간과 조회한 시간이 다른데도 한참 동안 눈치채지 못하는 경우가 있다.
우선 현재 JVM 타임존부터 확인해 보자.
System.out.println("JVM 타임존: " + ZoneId.systemDefault());JavaAsia/Seoul이 나오는데 DB는 UTC라면, 서버 시작 시 타임존을 UTC로 맞추는 것이 가장 깔끔하다.
@SpringBootApplication
public class Application {
public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
SpringApplication.run(Application.class, args);
}
}JavaTimeZone.setDefault()는 JVM 전체에 영향을 준다. 같은 JVM 위의 모든 스레드, 모든 라이브러리가 UTC를 기본으로 쓰게 된다는 뜻이다. MySQL이라면 JDBC URL에 serverTimezone=UTC도 같이 넣어줘야 한다. 운영 환경에서는 코드를 건드리지 않고 JVM 옵션 -Duser.timezone=UTC로 지정하는 편이 더 안전하다.
함정 3: 멀티 리전 서비스에서 UTC 미통일
서울 리전과 도쿄 리전에서 각각 로컬 시간으로 이벤트를 기록하면 어떻게 될까. 두 리전의 로그를 합쳐볼 때 시간 순서가 뒤섞인다. 답은 간단하다. 저장할 때는 UTC, 사용자에게 보여줄 때만 로컬 타임존으로 변환한다.
// 저장할 때: 항상 UTC Instant로 변환
Instant now = Instant.now(); // UTC 기준 현재 시각
long epochMilli = now.toEpochMilli(); // DB에 밀리초 단위로 저장
// 조회할 때: 사용자 타임존으로 변환
ZoneId userZone = ZoneId.of("Asia/Seoul");
ZonedDateTime userTime = Instant.ofEpochMilli(epochMilli).atZone(userZone);
System.out.println("사용자 화면: " + userTime.format(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
)); // 2026-04-11 23:30:00 (서울 시간)Java“저장은 UTC, 표시는 로컬.” Instant.now()는 타임존에 의존하지 않으므로 어떤 리전 서버에서 호출해도 같은 시점을 가리킨다. epochMilli로 저장하면 타임존 변환 자체가 끼어들 여지가 없고, 화면에 뿌릴 때만 atZone()을 쓰면 된다.
java.util.Date, 이제는 졸업할 시간
레거시 프로젝트를 유지보수하다 보면 java.util.Date와 Calendar를 아직도 마주친다. 한 번에 걷어내기 어려운 상황이라면, 경계 지점에서 java.time으로 변환하는 방법만이라도 알아두면 쓸모가 많다.
Date를 Instant로 변환
Date legacyDate = new Date(); // 기존 코드에서 넘어온 Date 객체
// Date → Instant (가장 깔끔한 변환)
Instant instant = legacyDate.toInstant();
// Instant → Date (역변환이 필요한 경우)
Date backToDate = Date.from(instant);Javajava.util.Date는 내부적으로 epoch 밀리초를 저장하고 있어서 toInstant() 호출 시 정보 손실이 없다. 실무에서 쓸 만한 전략은, 신규 코드는 Instant로 작성하고 레거시 인터페이스에 넘겨야 할 때만 Date.from()으로 감싸는 것이다.
Date를 LocalDateTime으로 변환
Date legacyDate = new Date();
// Date → LocalDateTime (시스템 타임존 기준)
LocalDateTime ldt = legacyDate.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
// 특정 타임존 기준으로 변환
LocalDateTime utcLdt = legacyDate.toInstant()
.atZone(ZoneOffset.UTC)
.toLocalDateTime();JavaDate에서 LocalDateTime으로 갈 때는 반드시 중간에 ZoneId를 거쳐야 한다. Date가 UTC 기반 epoch 값이라서, 어떤 타임존 기준으로 “로컬 시간”을 뽑을지 빠뜨리면 의도와 다른 시간이 나온다. ZoneId.systemDefault()는 서버 타임존에 의존하게 되니, 가능하면 타임존을 직접 지정하자.
Calendar을 ZonedDateTime으로 변환
Calendar calendar = Calendar.getInstance();
calendar.set(2026, Calendar.APRIL, 11, 14, 30, 0);
calendar.set(Calendar.MILLISECOND, 0); // 밀리초 초기화
// Calendar → ZonedDateTime
ZonedDateTime zdt = ZonedDateTime.ofInstant(
calendar.toInstant(),
calendar.getTimeZone().toZoneId()
);
// Calendar → LocalDate (날짜만 필요한 경우)
LocalDate localDate = zdt.toLocalDate();JavaCalendar는 자체적으로 타임존 정보를 갖고 있다. getTimeZone().toZoneId()로 그대로 가져오면 된다. 참고로 Calendar.APRIL은 상수인데, java.time에서는 4월이 그냥 숫자 4다. 사소해 보이지만 마이그레이션할 때 이런 데서 버그가 나온다.
마이그레이션 변환 빠른 참조
| 변환 전 | 변환 후 | 핵심 메서드 |
|---|---|---|
Date | Instant | date.toInstant() |
Date | LocalDateTime | date.toInstant().atZone(zone).toLocalDateTime() |
Calendar | ZonedDateTime | ZonedDateTime.ofInstant(cal.toInstant(), cal.getTimeZone().toZoneId()) |
Timestamp | LocalDateTime | timestamp.toLocalDateTime() |
LocalDateTime | Date | Date.from(ldt.atZone(zone).toInstant()) |
Spring Boot에서 java.time, 제대로 직렬화하기
java.time 클래스를 도메인에서 잘 쓰고 있어도, JSON 응답이나 DB 저장 시점에 직렬화가 깨지면 의미가 없다. Spring Boot 프로젝트에서 java.time을 쓸 때 필요한 설정을 정리한다.
Jackson의 JavaTimeModule 등록
Spring Boot 2.x 이상이면 jackson-datatype-jsr310이 이미 포함되어 있다. 문제는 기본 직렬화 형식이 배열([2026, 4, 11, 14, 30])이라는 점이다. API 응답으로 이런 형태가 내려가면 프론트엔드에서 당황할 수 있다.
# application.yml
spring:
jackson:
serialization:
write-dates-as-timestamps: false
date-format: "yyyy-MM-dd HH:mm:ss"
time-zone: UTCYAMLwrite-dates-as-timestamps: false를 넣으면 LocalDateTime이 “2026-04-11T14:30:00” 같은 ISO 8601 문자열로 바뀐다. 한 가지 주의할 것은 date-format이 java.util.Date에만 먹힌다는 점이다. java.time 클래스의 포맷은 아래 @JsonFormat으로 따로 잡아줘야 한다.
@JsonFormat으로 필드별 포맷 지정
public class OrderResponse {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private ZonedDateTime orderedAt;
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate deliveryDate;
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
private Instant createdAt; // epoch 밀리초로 직렬화
}Java@JsonFormat은 필드 단위로 직렬화 형식을 잡을 때 쓴다. ZonedDateTime에 timezone을 지정하면 해당 타임존으로 변환된 값이 JSON에 찍힌다. Instant에 Shape.NUMBER를 쓰면 epoch 밀리초로 내려가는데, 프론트엔드에서 타임존 변환을 직접 해야 하는 구조라면 이쪽이 편하다.
JPA/Hibernate에서 java.time 매핑
Hibernate 5.4 이상(Spring Boot 2.1+)이면 java.time을 별도 설정 없이 바로 쓸 수 있다.
@Entity
@Table(name = "orders")
public class Order {
@Column(name = "ordered_at", nullable = false)
private LocalDateTime orderedAt; // TIMESTAMP 컬럼과 매핑
@Column(name = "delivery_date")
private LocalDate deliveryDate; // DATE 컬럼과 매핑
@Column(name = "created_at", nullable = false)
private Instant createdAt; // TIMESTAMP WITH TIME ZONE과 매핑
@PrePersist
protected void onCreate() {
this.createdAt = Instant.now();
}
}JavaHibernate 5.4부터 java.time 클래스가 컬럼 타입에 자동 매핑되므로 @Temporal은 더 이상 필요 없다. LocalDateTime은 TIMESTAMP, LocalDate는 DATE, Instant는 TIMESTAMP WITH TIME ZONE에 대응한다. @PrePersist로 생성 시각을 자동 기록하면 서비스 레이어에서 시간 세팅 로직을 빼낼 수 있어서 코드가 깔끔해진다.
Spring Boot java.time 직렬화 빠른 참조
| 상황 | 설정 위치 | 핵심 설정 |
|---|---|---|
| 전체 JSON 응답 포맷 | application.yml | write-dates-as-timestamps: false |
| 특정 필드 포맷 | DTO 필드 | @JsonFormat(pattern = "...") |
| JPA 엔티티 매핑 | Entity 클래스 | 별도 설정 불필요 (Hibernate 5.4+) |
| 요청 파라미터 바인딩 | Controller 메서드 | @DateTimeFormat(pattern = "...") |
함께 읽으면 좋은 포스팅
- Jackson boolean 필드 is 접두사가 사라지는 이유와 해결법 — JSON 직렬화 시 시간 필드 처리와도 연관되는 Jackson 동작 원리
- Spring Boot JSON 설정 가이드 — Java time 타입의 JSON 직렬화 자동 구성 (jackson-datatype-jsr310)
- Java Collection Framework 정리 — Java 기본 API를 함께 복습하기
시간 변환을 위한 다양한 케이스가 있겠지만 자주 있을 법한 변환 예시를 적어 보았다.
시간 변환에 관련한 내용은 java.time 패키지에 있는 DateTimeFormatter, LocalDateTime, ZonedDateTime, OffsetDateTime, ZoneId, ZoneOffset 등 직접 코드를 확인해 보면 많은 도움이 될 것이다.
