로컬에서 테스트를 모두 통과하여 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 기준이며, 같은 버전의 도커와 테스트 컨테이너를 활용하고 있다.
동일 환경에서 동일 코드로 테스트를 하는데 왜 결과가 다를까?
원인을 알아내기 전에 일단 문제를 해결하는 방법부터 살펴보자. 사실 이 문제를 해결하는 법은 너무 간단했다.
actual은 이미 마이크로초로 맞춰져 있으니, expected도 마이크로초로 표현될 수 있도록 맞춰주면 된다.
따라서, 코드에 Java의 LocalDateTime에서 지원하는 .truncatedTo(ChronoUnit.MICROS)
메서드를 적용해 마이크로초 미만의 단위는 절삭했다.
이후 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 값이라는 것이다.
Java 21의 공식문서를 살펴보면, LocalDateTime은 나노초
까지 표현한다고 나와있다.
하지만 실제 로그를 살펴보면 actual은 나노초가 아닌 마이크로초로 표현된다.
actual은 분명히 LocalDateTime.now()
값을 그대로 저장했다가 꺼냈는데, 왜 데이터가 변했을까?
원인은 DB에 있었다. 현재 DB로 PostgreSQL을 사용하고 있는데, 공식문서를 살펴보면, 아래와 같은 표가 등장한다.
여기서 빨간색 네모로 표시한 Resolution
은 해상도
라는 뜻으로, 해당 타입의 최소 단위를 의미한다. 즉, 시간을 어디까지 정밀하게 나타낼 수 있는가에 대한 것이다. 이를 통해, 날짜와 시간을 모두 표현하는 타입인 Timestamp의 최소 단위가 마이크로초
임을 알 수 있다.
그렇다. LocalDateTime.now()
값은 처음에는 나노초였지만 DB에 들어가면서 해상도의 한계로 인해 마이크로초 단위로 절삭되어 저장된다.
이후 다시 꺼내 actual에 할당할 때는 이미 절삭된 값이 담기므로 나노초가 아닌 마이크로초로 담긴다.
로컬과 Github Actions 모두 PostgreSQL을 사용하므로 actual 값이 동일하게 표현되는 것이다.
expected는 actual과 달리 순수 Java LocalDateTime 값이다. 따라서, 공식문서에 나온대로 나노초로 표현된다.
하지만 실제로는 로컬에서는 마이크로초로, Github Actions에서는 나노초로 표현되었다.
사실 이 부분은 원인을 찾기 너무 힘들었다. 동일 환경에 동일 코드이고 DB와 같이 영향을 줄 외부 요인도 없기 때문이다.
하지만 그건 내 착각이었다. 애초에 동일 환경
이 아니었던 것이다.(?)
나는 MacOS이고 Github Actions는 Ubuntu이기 때문이다. 즉, OS가 다르다...! 😇😇
OS가 다르면 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());
}
System.currentTimeMillis()
를 사용하거나, 가능하다면 더 높은 해상도(resolution)의 Clock을 사용한다고 나와있다.자, 그럼 현재 시각은 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);
}
System.currentTimeMillis() / 1000
으로 현재 시각을 초 단위로 구하고 1024초
를 빼서 일종의 오차범위를 고려한 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()
의 해상도가 달라진다. ⭐️⬇️ Java 21의 /src/hotspot/share/prims/jvm.cpp에 VM.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로 나누어 저장한다.자, 이때 중요한 게 os::javaTimeSystemUTC(seconds, nanos)
를 통해 계산되는 nanos
값이다.
이 부분이 메서드 마지막에 더해져서 반환되므로, LocalDateTime.now()
의 나노초 부분이 될 값이다.
⬇️ Java 21의 /src/hotspot/os/posix/os_posix.cpp에 os::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.c에 clock_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;
}
...(생략)
}
os::javaTimeSystemUTC()
에서 이 clock id로 CLOCK_REALTIME
을 넣은 것을 알 수 있다. 이는 현재 시각(wall-time)을 구할 때 사용하는 clock id라고 한다.gettimeofday()
메서드를 통해 현재 시각을 가져온다.man gettimeofday
를 입력하면 해당 메서드의 documentation에 대해 나온다.The time is expressed in seconds and microseconds since midnight (0 hour), January 1, 1970.
마이크로초
다!⬇️ 다음으로, Ubuntu를 살펴보자. (24.04 LTS 기준)
Ubuntu는 git clone https://sourceware.org/git/glibc.git
을 통해 Ubuntu의 C 표준 라이브러리를 직접 가져올 수 있다.
Ubuntu의 glibc의 /sysdeps/unix/sysv/linux/clock_gettime.c
에 clock_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_REALTIME
를 넣었다.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.
나노초
단위로 가져온다!로컬에서는 통과하는 테스트가 Github Actions Workflow에서는 통과하지 못하는 이슈가 있었다.
알고보니 로컬과 Github Actions에서 Java의 LocalDateTime의 해상도가 다르게 나타났기 때문에 발생하는 이슈였다.
LocalDateTime의 해상도는 나노초로써, 원래라면 두 환경에서 모두 나노초 단위까지 표현되어야 한다.
하지만 actual 값은 두 환경에서 모두 마이크로초로 표현되었고, expected 값은 로컬에서는 마이크로초로 Github Actions에서는 나노초로 표현되었다.
이로 인해 나는 두 가지의 궁금증을 가졌고, 이에 대한 답을 찾아나갔다.
gettimeofday()
메서드를 통해 마이크로초 단위로 가져오고,__clock_gettime()64
메서드를 통해 나노초 단위로 가져온다.이러한 이유로 이슈가 발생하였고, .truncatedTo(ChronoUnit.MICROS)
를 코드에 추가해 마이크로초 단위로 통일하여 해당 이슈를 해결하였다.
추가적으로 "왜 MacOS는 나노초가 아니라 마이크로초를 고수하는가?"에 대한 궁금증이 생겼다.
gettimeofday()
메서드의 경우 MacOS 뿐만 아니라, 원래 POSIX 표준을 따르는 OS에 굉장히 옛날부터 있던 메서드이다.
그래서 Ubuntu의 경우도 gettimeofday()
메서드가 있고, 심지어 5년 전까지만 해도 현재 시각을 반환할 때 해당 메서드를 썼다.
그런데, 5년 전에 어떤 사람이 리눅스 환경에서 나노초를 반환할 수 있도록 JDK의 코드를 변경했다.
여기를 가보면 2020년 5월 29일의 커밋을 볼 수 있다. 아래에 사진으로도 첨부한다.
더 재밌는건 이 당시 커밋을 반영하기 위한 메일도 있다. 뭐 이런저런 이유를 대면서 바꾸자고 한다.
아무튼 리눅스도 원래는 마이크로초 단위로 현재 시각을 반환했다는 걸 말하고 싶었다.
즉, 원래 주류는 나노초가 아니라 마이크로초였다는 것이다.
결국 MacOS가 여전히 현재 시각(wall-time)을 마이크로초로 반환하는 이유는 "하위호환성" 때문이 아닐까싶다.
gettimeofday()
메서드 자체의 역사가 엄청 오래되기도 했고 그만큼 엮여있는 것이 많아서 그러지 않을까라고 추측된다.