Skip to content

LocalDateTime과 Instant

Local Up을 개발하면서 날짜와 시간 처리를 고민하다 보니, 자바의 LocalDateTime과 Instant 선택이 생각보다 복잡한 문제였다는 걸 깨달았다. 컨테이너 환경과 외부 API 연동이 당연해진 요즘에는 단순히 "날짜만 저장하면 되지"라는 생각으로는 부족하다. 특히 도커로 배포하고 서드파티 API를 쓰는 환경에서는 타임존 문제가 예상치 못한 버그로 이어질 수 있다. 예를 들어 로컬에서는 정상 동작하던 일일 배치 작업이 UTC 환경의 서버에서는 9시간 일찍 실행되거나, 외부 API 호출 시 "오늘" 데이터를 요청했는데 "어제" 데이터가 반환되는 상황이 발생한다.

Java 8 이후 날짜/시간 API의 변화

자바 8이 가져온 가장 큰 변화 중 하나는 새로운 날짜/시간 API였다.

기존 DateCalendar의 문제들을 해결하기 위해 LocalDate, LocalTime, LocalDateTime, Instant 등이 새롭게 도입되었다. 기존 Date 클래스는 변경 가능한(mutable) 객체로 인한 스레드 안전성 문제가 있었고, 월(month)이 0부터 시작하는 비직관적 설계와 타임존 처리의 모호함 때문에 어려움이 있었다.

새로운 API는 이 문제들을 명확히 분리해서 해결했다. LocalDateTime은 타임존 정보 없이 "현지 시간"을 표현하고, Instant는 UTC 기준 절댓값을 나타낸다. 언뜻 간단해 보이지만, 실제로는 이 선택이 애플리케이션 전체 아키텍처에 미치는 영향이 크다.

실제 환경에서의 차이점 확인

궁금증을 해결하기 위해 직접 테스트해봤다. PostgreSQL 환경에서 JPA가 두 타입을 어떻게 다르게 처리하는지, 그리고 실행 환경에 따라 값이 어떻게 달라지는지 확인해보자.

kt
@Entity
@Table(name = "domain")
class DomainEntity(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    val localDateTime: LocalDateTime,
    val instant: Instant,
)

JPA가 이 엔티티를 기반으로 생성하는 DDL을 보면 차이가 명확해진다. (PostgreSQL 15, 16 기준)

     Column      |              Type              | Collation | Nullable |             Default
-----------------+--------------------------------+-----------+----------+----------------------------------
 id              | bigint                         |           | not null | generated by default as identity
 instant         | timestamp(6) with time zone    |           |          |
 local_date_time | timestamp(6) without time zone |           |          |
Indexes:
    "domain_pkey" PRIMARY KEY, btree (id)

PostgreSQL에서 LocalDateTime은 timestamp(6) without time zone으로, Instant는 timestamp(6) with time zone으로 생성된다. 하지만 실제 데이터 저장과 조회 시점에서 차이가 나타난다.

로컬 환경 테스트

kt
@Test
fun `LocalDateTime과 Instant 저장 테스트`() {
    val now = LocalDateTime.of(2025, 9, 1, 0, 0, 0)
    val instantNow = now.toInstant(ZoneOffset.ofHours(9)) // KST

    val entity = DomainEntity(
        localDateTime = now,
        instant = instantNow
    )

    val saved = repository.save(entity)

    println("LocalDateTime: ${saved.localDateTime}")  // 2025-09-01T00:00:00
    println("Instant: ${saved.instant}")              // 2025-08-31T15:00:00Z
}

결과를 보면 LocalDateTime은 입력한 그대로 저장되지만, Instant는 UTC로 변환되어 저장된다. KST(UTC+9)에서 UTC로 변환되면서 9시간이 빠진 값이 데이터베이스에 들어간 것이다.

도커 환경에서의 차이

더 재미있는 건 도커 컨테이너에서 실행했을 때다.

dockerfile
FROM openjdk:17-jre-slim
ENV TZ=UTC
COPY app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

동일한 코드를 UTC 타임존으로 설정된 도커 컨테이너에서 실행하면

kt
// 컨테이너 내부에서 실행
val now = LocalDateTime.now()  // 컨테이너 시간 기준
val instant = Instant.now()    // 항상 UTC

println("Container LocalDateTime: $now")  // UTC 기준 현재 시간
println("Container Instant: $instant")    // UTC 기준 현재 시간

LocalDateTime은 컨테이너의 시스템 시간을 따라가지만, Instant는 항상 UTC를 기준으로 한다. 이 차이가 외부 API와 연동할 때 예상치 못한 문제를 일으킨다.

성능과 편의성 측면에서의 고려사항

그렇다면 모든 상황에서 Instant가 정답일까? 실제로는 각각의 장단점이 명확하다.

LocalDateTime의 장점

kt
// 직관적인 날짜 연산
val tomorrow = LocalDateTime.now().plusDays(1)
val lastWeek = LocalDateTime.now().minusWeeks(1)

// 비교 연산
val isAfter = dateTime1.isAfter(dateTime2)

LocalDateTime은 날짜 연산이 직관적이다. 때문에 빠른 프로토타이핑이나 내부 로직에서는 여전히 유용하다. 단순 날짜 연산의 경우 Instant보다 코드가 간결하고 가독성이 좋다.

Instant의 제약사항

kt
// 복잡한 날짜 연산
val instant = Instant.now()
val tomorrow = instant
    .atZone(ZoneId.systemDefault())
    .plusDays(1)
    .toInstant()

// DST(Daylight Saving Time) 고려하지 않은 단순 시간 계산
val zone = ZoneId.of("America/New_York")
val baseTime = instant.atZone(zone)
val sixMonthsLater = instant.plus(180, ChronoUnit.DAYS).atZone(zone)
// Instant는 단순히 초를 더할 뿐, DST 전환을 자동으로 처리하지 않음

Instant는 날짜 연산이 번거롭고, DST 같은 복잡한 시간 규칙을 자동으로 처리하지 않는다. 단순히 "초 단위 절댓값"이기 때문에 비즈니스 로직에서 직접 사용하기에는 불편한 면이 있다.

선택 기준

이런 정리를 바탕으로 상황별 가이드라인을 정리해봤다.

LocalDateTime을 쓰는 경우

  1. 내부 시스템 간 통신: 동일한 타임존에서 동작하는 마이크로서비스 간 데이터 교환
  2. 로그 기록: 서버 로컬 시간 기준으로 기록하고 분석하는 경우
  3. 빠른 프로토타이핑: 초기 개발 단계에서 시간대 복잡성을 피하고 싶을 때
kt
@Component
class InternalEventLogger {
    fun log(event: String) {
        val timestamp = LocalDateTime.now()
        println("[$timestamp] $event")
    }
}

Instant를 쓰는 경우

  1. 외부 API 연동: 타임존이 다른 시스템과 데이터를 주고받을 때
  2. 글로벌 서비스: 여러 지역에서 접근하는 서비스
  3. 이벤트 순서 보장: 분산 시스템에서 정확한 시간 순서가 중요한 경우
kt
@Entity
class UserActivity(
    val userId: String,
    val activity: String,
    val timestamp: Instant = Instant.now()  // 전세계 어디서든 일관된 기준
)

LocalDateTime에서 Instant로 마이그레이션 고민

기존에 LocalDateTime으로 저장된 데이터를 Instant로 전환할 때 문제와 대응 방안이다.

생각했을 때 가장 안전한 접근은 기존 LocalDateTime 컬럼을 유지하면서 새 Instant 컬럼을 추가하는 방식인 것 같다.

sql
-- 기존 테이블에 새 컬럼 추가
ALTER TABLE domain ADD COLUMN created_at_instant TIMESTAMP WITH TIME ZONE;

-- 기존 데이터를 KST로 해석하여 변환 (한국 서비스 기준)
UPDATE domain
SET created_at_instant = created_at AT TIME ZONE 'Asia/Seoul'
WHERE created_at_instant IS NULL;

또 다른 방법으로는 점진적 전환을 고려해볼 수 있을 것 같다. 새로운 데이터는 Instant로 저장하고, 기존 데이터는 읽기 시점에 변환하는 방식 말이다.

kt
@Entity
class DomainEntity(
    @Id val id: Long = 0,

    // 기존 필드 (deprecated)
    @Deprecated("Use createdAtInstant instead")
    val createdAt: LocalDateTime? = null,

    // 새 필드
    val createdAtInstant: Instant? = null,
) {
    // 호환성을 위한 프로퍼티
    val effectiveCreatedAt: Instant
        get() = createdAtInstant ?: createdAt?.toInstant(ZoneOffset.ofHours(9))
            ?: throw IllegalStateException("Both createdAtInstant and createdAt are null")
}

팀 내에서 일부는 LocalDateTime을, 일부는 Instant를 사용하는 혼용 상황에서는 표준 변환 유틸리티를 만들어두면 도움이 될 것 같다.

또한 마이그레이션 후에는 데이터 일관성을 꼭 확인해보는 것이 좋을 것 같다.

sql
-- 변환된 데이터 검증 (9시간 차이 확인)
SELECT
    created_at,
    created_at_instant,
    EXTRACT(EPOCH FROM (created_at_instant - (created_at AT TIME ZONE 'Asia/Seoul'))) as diff_seconds
FROM domain
WHERE ABS(EXTRACT(EPOCH FROM (created_at_instant - (created_at AT TIME ZONE 'Asia/Seoul')))) > 1;

운영 환경에서의 실제 고려사항

개발할 때는 생각하지 못했던 운영 이슈들도 있다.

데이터베이스 타임존 설정

PostgreSQL에서는 타임존 설정이 중요하다.

sql
-- 데이터베이스 타임존 확인
SHOW timezone;

-- Instant 저장 시 실제 변환 과정
SELECT
    '2025-09-01 00:00:00'::timestamp AS local_time,
    '2025-09-01 00:00:00'::timestamp AT TIME ZONE 'UTC' AS utc_time;

JPA는 Instant를 저장할 때 자동으로 UTC 변환을 수행하는데, 경험상 데이터베이스 설정과 애플리케이션 설정이 일치하지 않으면 예상치 못한 값이 저장되는 경우가 있다.

배포 환경별 일관성

yaml
# docker-compose.yaml
services:
  app:
    environment:
      - TZ=UTC
      - SPRING_JPA_PROPERTIES_HIBERNATE_JDBC_TIME_ZONE=UTC

  postgres:
    environment:
      - TZ=UTC

컨테이너, 애플리케이션, 데이터베이스의 타임존을 모두 맞추는 것이 일관된 동작을 보장하는 데 중요하다고 생각한다.

추가로 application.yaml에서도 시간 관련 설정을 명시적으로 지정할 수 있다.

yaml
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          time_zone: UTC
  jackson:
    time-zone: UTC
    serialization:
      write-dates-as-timestamps: false
  • hibernate.jdbc.time_zone: JPA가 데이터베이스와 통신할 때 사용할 타임존
  • jackson.time-zone: JSON 직렬화/역직렬화 시 사용할 타임존
  • write-dates-as-timestamps: false: 날짜를 ISO-8601 문자열로 출력 (timestamp 숫자 대신)

정리

LocalDateTime과 Instant 선택은 단순한 기술적 문제가 아니라 서비스 아키텍처와 운영 정책을 반영하는 의사결정이다.

이번 탐색을 통해 느낀 점을 정리하면, 개인적으로 가장 중요하다고 생각하는 것은 팀 내에서 명확한 기준을 정하고 일관되게 적용하는 것이다. 로컬, 컨테이너, 클라우드 환경에서의 동작 차이를 미리 테스트해보고, API 연동 시에는 상대방의 시간 기준과 형식을 정확히 파악하는 것이 중요하다고 생각한다. 또한 시간 의존적 로직은 반드시 테스트 가능하게 설계하는 것이 중요하다고 본다.

완벽한 정답은 없지만, 각 선택의 결과를 이해하고 상황에 맞는 결정을 내리는 것이 중요하다고 생각한다. 무엇보다 개발 단계에서부터 운영 환경을 고려한 설계를 하는 것이, 나중에 발생할 수 있는 시간 관련 버그를 예방하는 가장 확실한 방법이라고 느꼈다.

Released under the MIT License.