OS에 따라 Java의 LocalDateTime.now()의 값이 다른 이유는 무엇일까?

kyle·2025년 3월 7일
0

1. 문제 상황

로컬에서 테스트를 모두 통과하여 PR을 올렸으나, Github Actions Workflow에서 테스트가 실패하였다.
Java의 LocalDateTime 타입의 값을 서로 비교하는 테스트였는데, Github Actions의 로그를 보니 아래와 같았다.

actual : 2025-03-06T23:15:33.802807
expected : 2025-03-06T23:15:33.802807022

actual은 마이크로초까지 표현되고, expected는 나노초까지 표현되어 서로 다르다고 평가된 것이다!

하지만 로컬에서는 테스트가 통과하기 때문에, 다시 한 번 로컬에서도 테스트를 하고 로그를 살펴보았다.

actual : 2025-03-06T23:18:21.618237
expected : 2025-03-06T23:18:21.618237

신기하게도 로컬에서는 actual과 expected 모두 마이크로초까지만 표현되어 테스트를 통과한다...!

로컬과 Github Actions 모두 Java 21 기준이며, 같은 버전의 도커와 테스트 컨테이너를 활용하고 있다.
동일 환경에서 동일 코드로 테스트를 하는데 왜 결과가 다를까?


2. 문제 해결

원인을 알아내기 전에 일단 문제를 해결하는 방법부터 살펴보자. 사실 이 문제를 해결하는 법은 너무 간단했다.
actual은 이미 마이크로초로 맞춰져 있으니, expected도 마이크로초로 표현될 수 있도록 맞춰주면 된다.

따라서, 코드에 Java의 LocalDateTime에서 지원하는 .truncatedTo(ChronoUnit.MICROS) 메서드를 적용해 마이크로초 미만의 단위는 절삭했다.

이후 Github Actions에서도 정상적으로 테스트를 통과하였고 작업을 마무리 할 수 있었다.

끝...! 이라고 하면 좋겠지만, 동일한 코드에서 왜 시간이 다르게 표현되는지 너무 궁금해서 한 번 파보기로 결심했다.


3. 원인 파헤쳐보기

내가 궁금한 건 두 가지였다.

  1. actual은 왜 로컬과 Github Actions에서 모두 마이크로초로 표현될까?
  2. actual과 달리 expected는 왜 로컬과 Github Actions에서 서로 다르게 표현될까?

일단 이를 알기 위해서는, actual과 expected 값이 정확히 어떤 값인지 알 필요가 있다. 관련 코드는 대강 아래와 같다.

LocalDateTime now = LocalDateTime.now(); // 현재 시각을 now에 할당

Long savedId = repository.save(now); // DB에 저장

LocalDateTime actual = repository.findById(savedId); // DB에서 조회하여 actual에 할당
LocalDateTime expected = now; // now 값을 expected에 할당

assertThat(actual).isEqualTo(expected); // actual과 expected가 동일한지 검증

이게 무슨 코드인가 싶겠지만, 이해를 돕기 위해 불필요한 맥락은 많이 생략한 것이므로 그러려니 하고 넘어가자.
여기서 중요한건 actual은 한 번 DB에 들어갔다 나온 값이라는 것이고, expected는 순수 LocalDateTime 값이라는 것이다.

3-1. actual은 왜 로컬과 Github Actions에서 모두 마이크로초로 표현될까?

Java 21의 공식문서를 살펴보면, LocalDateTime은 나노초까지 표현한다고 나와있다.

하지만 실제 로그를 살펴보면 actual은 나노초가 아닌 마이크로초로 표현된다.
actual은 분명히 LocalDateTime.now() 값을 그대로 저장했다가 꺼냈는데, 왜 데이터가 변했을까?

원인은 DB에 있었다. 현재 DB로 PostgreSQL을 사용하고 있는데, 공식문서를 살펴보면, 아래와 같은 표가 등장한다.

여기서 빨간색 네모로 표시한 Resolution해상도라는 뜻으로, 해당 타입의 최소 단위를 의미한다. 즉, 시간을 어디까지 정밀하게 나타낼 수 있는가에 대한 것이다. 이를 통해, 날짜와 시간을 모두 표현하는 타입인 Timestamp의 최소 단위가 마이크로초임을 알 수 있다.

그렇다. LocalDateTime.now() 값은 처음에는 나노초였지만 DB에 들어가면서 해상도의 한계로 인해 마이크로초 단위로 절삭되어 저장된다.
이후 다시 꺼내 actual에 할당할 때는 이미 절삭된 값이 담기므로 나노초가 아닌 마이크로초로 담긴다.

로컬과 Github Actions 모두 PostgreSQL을 사용하므로 actual 값이 동일하게 표현되는 것이다.

3-2. actual과 달리 expected는 왜 로컬과 Github Actions에서 서로 다르게 표현될까?

expected는 actual과 달리 순수 Java LocalDateTime 값이다. 따라서, 공식문서에 나온대로 나노초로 표현된다.
하지만 실제로는 로컬에서는 마이크로초로, Github Actions에서는 나노초로 표현되었다.

사실 이 부분은 원인을 찾기 너무 힘들었다. 동일 환경에 동일 코드이고 DB와 같이 영향을 줄 외부 요인도 없기 때문이다.
하지만 그건 내 착각이었다. 애초에 동일 환경이 아니었던 것이다.(?)

나는 MacOS이고 Github Actions는 Ubuntu이기 때문이다. 즉, OS가 다르다...! 😇😇

OS가 다르면 Java의 LocalDateTime.now()의 동작이 달라지는걸까?
이를 알기 위해 코드를 깊게 들어가보기로 했다.

Java의 LocalDateTime.now() 내부 구조

⬇️ 먼저, LocalDateTime.now()는 아래와 같이 구현되어 있다.

/**
 * Obtains the current date-time from the system clock in the default time-zone.
 * ...
 */
public static LocalDateTime now() {
    return now(Clock.systemDefaultZone());
}
  • 주석을 보면 system clock으로 부터 현재 시각을 얻는다고 나와있다.
  • 그러면서 인자로 system clock에 해당하는 값을 넘기며, 오버로딩된 또다른 now() 메서드를 호출한다.

⬇️ Clock.systemDefaultZone()은 아래와 같이 구현되어 있다.

/**
 * Obtains a clock that returns the current instant using the best available
 * system clock, converting to date and time using the default time-zone.
 * This clock is based on the best available system clock.
 * This may use {@link System#currentTimeMillis()}, or a higher resolution
 * clock if one is available.
 * ...(생략)
 */
public static Clock systemDefaultZone() {
    return new SystemClock(ZoneId.systemDefault());
}
  • 현재 시각(wall-time)에 대해 System.currentTimeMillis()를 사용하거나, 가능하다면 더 높은 해상도(resolution)의 Clock을 사용한다고 나와있다.
  • 여기서 Clock의 해상도는 OS 별로 상이하다.

자, 그럼 현재 시각은 system clock으로 부터 얻고, system clock은 OS마다 해상도가 다르다는 것을 알았다.

⬇️ 오버로딩된 또다른 now() 메서드를 살펴보자.

public static LocalDateTime now(Clock clock) {
    Objects.requireNonNull(clock, "clock");
    final Instant now = clock.instant();  // called once
    ZoneOffset offset = clock.getZone().getRules().getOffset(now);
    return ofEpochSecond(now.getEpochSecond(), now.getNano(), offset);
}
  • 현재 시각을 clock.instant()를 통해 얻는다.

⬇️ clock.instant()는 Clock 인터페이스의 instant 추상메서드를 의미하며, 이는 SystemClock 클래스가 구현한다.

@Override
public Instant instant() {
    // inline of SystemInstantSource.INSTANCE.instant()
    return currentInstant();
}
  • 별 내용은 없고 동일 인터페이스에 존재하는 currentInstant() 메서드를 호출한다.

⬇️ currentInstant() 메서드는 아래와 같이 구현되어 있다.

private static final long OFFSET_SEED = System.currentTimeMillis() / 1000 - 1024;
private static long offset = OFFSET_SEED;

static Instant currentInstant() {
    long localOffset = offset;
    long adjustment = VM.getNanoTimeAdjustment(localOffset);

    if (adjustment == -1) {
        localOffset = System.currentTimeMillis() / 1000 - 1024;

        // retry
        adjustment = VM.getNanoTimeAdjustment(localOffset);

        if (adjustment == -1) {
            // Should not happen: we just recomputed a new offset.
            // It should have fixed the issue.
            throw new InternalError("Offset " + localOffset + " is not in range");
        } else {
            // OK - recovery succeeded. Update the offset for the
            // next call...
            offset = localOffset;
        }
    }
    return Instant.ofEpochSecond(localOffset, adjustment);
}
  • 현재 시각을 구하기 위해 offset과 보정값(adjustment)을 통해 계산 및 조합하는 과정이다.
  • 일단 System.currentTimeMillis() / 1000 으로 현재 시각을 초 단위로 구하고 1024초를 빼서 일종의 오차범위를 고려한 offset을 만든다.
  • 그리고 offset을 인자로 넣어 VM.getNanoTimeAdjustment(localOffset)를 호출하면, OS가 나노초 단위로 계산한 현재 시각과 offset의 차이를 계산하여, 오차범위를 넘어서면 adjustment를 -1로 반환한다. (현지 시간대가 UTC와 너무 많이 차이나는 경우에는 에러가 발생한다는 의미)
  • 오차범위를 넘어서지 않으면 정상적인 보정값을 반환하고, Instant.ofEpochSecond()를 호출하여 offset에 보정값을 더해 정확한 현재 시각을 계산한다.

⬇️ OS에 따라 해상도가 달라지는 이유가 바로 VM.getNanoTimeAdjustment(localOffset) 때문이다!!!

public static native long getNanoTimeAdjustment(long offsetInSeconds);
  • 보정값을 반환하는 메서드이므로, 이 보정값이 마이크로초로 절삭되는가 아니면 나노초를 그대로 반환하는가에 따라 LocalDateTime.now()의 해상도가 달라진다. ⭐️
  • 이는 JNI에 해당하므로 OS에 정의된 네이티브 메서드를 호출한다. 따라서, OS가 구현하는 네이티브 메서드의 동작에 따라 결과가 달라지는 것이다.

그래서 OS까지 내려가면 어떻게 다른가? (MacOS vs Ubuntu)

⬇️ Java 21의 /src/hotspot/share/prims/jvm.cppVM.getNanoTimeAdjustment(localOffset)와 연결된 네이티브 메서드가 정의되어 있다.

JVM_LEAF(jlong, JVM_GetNanoTimeAdjustment(JNIEnv *env, jclass ignored, jlong offset_secs))
  jlong seconds;
  jlong nanos;

  os::javaTimeSystemUTC(seconds, nanos);

  jlong diff = seconds - offset_secs;
  if (diff >= MAX_DIFF_SECS || diff <= MIN_DIFF_SECS) {
     return -1; // sentinel value: the offset is too far off the target
  }

  return (diff * (jlong)1000000000) + nanos;
JVM_END
  • os::javaTimeSystemUTC(seconds, nanos)를 호출하여 UTC 기준 시각을 계산하여 seconds, nanos로 나누어 저장한다.
  • 그리고 UTC seconds와 현지 시간대 offset seconds의 차이를 구해 오차범위를 검사한다. (너무 차이나면 -1 반환)
  • 오차범위 이내라면 보정값을 나노초로 변환하여 반환한다.

자, 이때 중요한 게 os::javaTimeSystemUTC(seconds, nanos)를 통해 계산되는 nanos 값이다.
이 부분이 메서드 마지막에 더해져서 반환되므로, LocalDateTime.now()의 나노초 부분이 될 값이다.

⬇️ Java 21의 /src/hotspot/os/posix/os_posix.cppos::javaTimeSystemUTC() 메서드가 정의되어있다.

void os::javaTimeSystemUTC(jlong &seconds, jlong &nanos) {
  struct timespec ts;
  int status = clock_gettime(CLOCK_REALTIME, &ts);
  assert(status == 0, "clock_gettime error: %s", os::strerror(errno));
  seconds = jlong(ts.tv_sec);
  nanos = jlong(ts.tv_nsec);
}
  • clock_gettime(CLOCK_REALTIME, &ts)을 통해 현재 시각을 저장한다.
  • 이 값은 초와 나노초로 나누어져 구조체인 struct timespec에 저장된다.
    struct timespec {
       time_t    tv_sec;   // 초
       long      tv_nsec;  // 나노초
    };

clock_gettime(CLOCK_REALTIME, &ts)에서 MacOS와 Ubuntu의 동작이 달라진다! ⭐️

⬇️ 먼저, MacOS부터 살펴보자. (Sequoia 15 기준)
Apple은 여기에서 일부 소스코드를 공개한다.
이중에서 Libc-1669.60.4에 해당하는 부분이 MacOS의 C 표준 라이브러리와 관련된 부분이다.

MacOS Libc의 /gen/clock_gettime.cclock_gettime() 메서드가 정의되어 있다.

int
clock_gettime(clockid_t clk_id, struct timespec *tp)
{
    switch(clk_id){
    case CLOCK_REALTIME: {
        struct timeval tv;
        int ret = gettimeofday(&tv, NULL);
        TIMEVAL_TO_TIMESPEC(&tv, tp);
        return ret;
    }
    ...(생략)
}
  • 일종의 clock id를 매개변수로 받아서 switch 문으로 나누어져 동작한다.
  • 잠시 위로 가보면 Java 21의 os::javaTimeSystemUTC()에서 이 clock id로 CLOCK_REALTIME을 넣은 것을 알 수 있다. 이는 현재 시각(wall-time)을 구할 때 사용하는 clock id라고 한다.
  • 따라서 해당 case 문에 걸리게 되고, gettimeofday() 메서드를 통해 현재 시각을 가져온다.
    • MacOS Terminal에서 man gettimeofday를 입력하면 해당 메서드의 documentation에 대해 나온다.
      The time is expressed in seconds and microseconds since midnight (0 hour), January 1, 1970.
    • 내부적으로 gettimeofday를 통해 현재 시각을 가져오는데, 그 단위가 초와 마이크로초다!
  • MacOS는 마이크로초 단위로 반환하므로, Java에서는 나노초의 형태로 다루긴 하지만 마이크로초 단위에서 절삭된 값을 사용하게 되는 것이다.

⬇️ 다음으로, Ubuntu를 살펴보자. (24.04 LTS 기준)
Ubuntu는 git clone https://sourceware.org/git/glibc.git을 통해 Ubuntu의 C 표준 라이브러리를 직접 가져올 수 있다.
Ubuntu의 glibc의 /sysdeps/unix/sysv/linux/clock_gettime.cclock_gettime() 메서드가 정의되어 있다.

int
__clock_gettime64 (clockid_t clock_id, struct __timespec64 *tp)
{
  int r;

#ifndef __NR_clock_gettime64
# define __NR_clock_gettime64 __NR_clock_gettime
#endif

#ifdef HAVE_CLOCK_GETTIME64_VSYSCALL
  int (*vdso_time64) (clockid_t clock_id, struct __timespec64 *tp)
    = GLRO(dl_vdso_clock_gettime64);
  if (vdso_time64 != NULL)
    {
      r = INTERNAL_VSYSCALL_CALL (vdso_time64, 2, clock_id, tp);
      if (r == 0)
	return 0;
      return INLINE_SYSCALL_ERROR_RETURN_VALUE (-r);
    }
#endif

...(생략)

  return INLINE_SYSCALL_ERROR_RETURN_VALUE (-r);
}
  • 솔직히 여기는 시스템콜과 같이 커널이나 하드웨어랑 연결된 부분이 많아서 이해하기 쉽지 않다.
  • 분명한 것은 여기도 마찬가지로 clock id를 매개변수로 받는다는 것이다. 그리고 clock id로 CLOCK_REALTIME를 넣었다.
  • Ubuntu 공식문서에서 clock_gettime()를 통해 어떻게 현재 시각을 가져오는지 나온다.
    All implementations support the system-wide real-time clock, which is identified by CLOCK_REALTIME. 
    Its time represents seconds and nanoseconds since the Epoch.
    • 내부적으로 현재 시각을 초와 나노초 단위로 가져온다!
  • Ubuntu에서는 나노초 단위로 반환하므로, Java에서 절삭되지 않은 나노초 값을 그대로 사용하게 되는 것이다.

4. 정리

로컬에서는 통과하는 테스트가 Github Actions Workflow에서는 통과하지 못하는 이슈가 있었다.
알고보니 로컬과 Github Actions에서 Java의 LocalDateTime의 해상도가 다르게 나타났기 때문에 발생하는 이슈였다.

LocalDateTime의 해상도는 나노초로써, 원래라면 두 환경에서 모두 나노초 단위까지 표현되어야 한다.
하지만 actual 값은 두 환경에서 모두 마이크로초로 표현되었고, expected 값은 로컬에서는 마이크로초로 Github Actions에서는 나노초로 표현되었다.

이로 인해 나는 두 가지의 궁금증을 가졌고, 이에 대한 답을 찾아나갔다.

  1. actual은 왜 로컬과 Github Actions에서 모두 마이크로초로 표현될까?
    • DB에 저장한 후 조회하기 때문이다.
    • PostgreSQL의 해상도는 마이크로초 단위이므로, 저장되는 순간 마이크로초 미만의 단위는 절삭된다.
  2. actual과 달리 expected는 왜 로컬과 Github Actions에서 서로 다르게 표현될까?
    • MacOS와 Ubuntu의 차이 때문이다.
    • MacOS는 현재 시각을 gettimeofday() 메서드를 통해 마이크로초 단위로 가져오고,
    • Ubuntu는 현재 시각을 __clock_gettime()64 메서드를 통해 나노초 단위로 가져온다.
    • 그래서 각 OS가 반환한 현재 시각을 Java에서 나노초 단위의 보정값으로 사용하긴 하지만, MacOS와 같이 마이크로초 미만이 절삭된 경우는 그대로 사용하기 때문에 차이가 발생한다.

이러한 이유로 이슈가 발생하였고, .truncatedTo(ChronoUnit.MICROS)를 코드에 추가해 마이크로초 단위로 통일하여 해당 이슈를 해결하였다.

번외

추가적으로 "왜 MacOS는 나노초가 아니라 마이크로초를 고수하는가?"에 대한 궁금증이 생겼다.
gettimeofday() 메서드의 경우 MacOS 뿐만 아니라, 원래 POSIX 표준을 따르는 OS에 굉장히 옛날부터 있던 메서드이다.
그래서 Ubuntu의 경우도 gettimeofday() 메서드가 있고, 심지어 5년 전까지만 해도 현재 시각을 반환할 때 해당 메서드를 썼다.

그런데, 5년 전에 어떤 사람이 리눅스 환경에서 나노초를 반환할 수 있도록 JDK의 코드를 변경했다.
여기를 가보면 2020년 5월 29일의 커밋을 볼 수 있다. 아래에 사진으로도 첨부한다.
더 재밌는건 이 당시 커밋을 반영하기 위한 메일도 있다. 뭐 이런저런 이유를 대면서 바꾸자고 한다.

아무튼 리눅스도 원래는 마이크로초 단위로 현재 시각을 반환했다는 걸 말하고 싶었다.

즉, 원래 주류는 나노초가 아니라 마이크로초였다는 것이다.
결국 MacOS가 여전히 현재 시각(wall-time)을 마이크로초로 반환하는 이유는 "하위호환성" 때문이 아닐까싶다.
gettimeofday() 메서드 자체의 역사가 엄청 오래되기도 했고 그만큼 엮여있는 것이 많아서 그러지 않을까라고 추측된다.

0개의 댓글

관련 채용 정보