배경)
서버 개발 시 가장 중요한 부분은 하위 호환입니다. 이번 포스팅에서는 java version에 따라 Instant.now()의 출력값이 달라지는 이유와 문제를 해결했던 방법을 공유하려고 합니다.
JDK8 - Instant.now()
JDK17 - Instant.now()
내용)
Instant.now() 의 데이터가 다르니 해당 부분부터 탐색 시작! 먼저 JDK8 부터 확인해보겠습니다. (OpenJDK8)
public static Instant now() {
return Clock.systemUTC().instant();
}
Clock 클래스에 systemUTC()의 instant()값을 사용합니다. Clock으로 이동하겠습니다.
Clock.java
public static Clock systemUTC() {
return new SystemClock(ZoneOffset.UTC);
}
Clock의 구현체인 SystemClock을 사용합니다.
Clock.SystemClock class
static final class SystemClock extends Clock implements Serializable {
private static final long serialVersionUID = 6740630888130243051L;
private final ZoneId zone;
SystemClock(ZoneId zone) {
this.zone = zone;
}
@Override
public ZoneId getZone() {
return zone;
}
@Override
public Clock withZone(ZoneId zone) {
if (zone.equals(this.zone)) { // intentional NPE
return this;
}
return new SystemClock(zone);
}
@Override
public long millis() {
return System.currentTimeMillis();
}
@Override
public Instant instant() {
return Instant.ofEpochMilli(millis());
}
@Override
public boolean equals(Object obj) {
if (obj instanceof SystemClock) {
return zone.equals(((SystemClock) obj).zone);
}
return false;
}
@Override
public int hashCode() {
return zone.hashCode() + 1;
}
@Override
public String toString() {
return "SystemClock[" + zone + "]";
}
}
해당 클래스에서 instatnt()는 System.currentMillis()로 전달 받은 milli seconds 값을 Instant로 만들어 리턴합니다. 때문에 위에 스크린샷처럼 2023-08-22T10:02:44.126Z 정밀도가 milli second까지 표현됩니다.
같은 방법으로 JDK17을 확인 해보겠습니다.
public static Instant now() {
return Clock.currentInstant();
}
static Instant currentInstant() {
// Take a local copy of offset. offset can be updated concurrently
// by other threads (even if we haven't made it volatile) so we will
// work with a local copy.
long localOffset = offset;
long adjustment = VM.getNanoTimeAdjustment(localOffset);
if (adjustment == -1) {
// -1 is a sentinel value returned by VM.getNanoTimeAdjustment
// when the offset it is given is too far off the current UTC
// time. In principle, this should not happen unless the
// JVM has run for more than ~136 years (not likely) or
// someone is fiddling with the system time, or the offset is
// by chance at 1ns in the future (very unlikely).
// We can easily recover from all these conditions by bringing
// back the offset in range and retry.
// bring back the offset in range. We use -1024 to make
// it more unlikely to hit the 1ns in the future condition.
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);
}
JDK17에서는 JDK8과 다르게 adjustment 값을 추가로 전달합니다. 마지막에 Instant.ofEpochSecond로 리턴하는 부분을 보겠습니다.
/**
* Obtains an instance of {@code Instant} using seconds from the
* epoch of 1970-01-01T00:00:00Z and nanosecond fraction of second.
* <p>
* This method allows an arbitrary number of nanoseconds to be passed in.
* The factory will alter the values of the second and nanosecond in order
* to ensure that the stored nanosecond is in the range 0 to 999,999,999.
* For example, the following will result in exactly the same instant:
* <pre>
* Instant.ofEpochSecond(3, 1);
* Instant.ofEpochSecond(4, -999_999_999);
* Instant.ofEpochSecond(2, 1000_000_001);
* </pre>
*
* @param epochSecond the number of seconds from 1970-01-01T00:00:00Z
* @param nanoAdjustment the nanosecond adjustment to the number of seconds, positive or negative
* @return an instant, not null
* @throws DateTimeException if the instant exceeds the maximum or minimum instant
* @throws ArithmeticException if numeric overflow occurs
*/
public static Instant ofEpochSecond(long epochSecond, long nanoAdjustment) {
long secs = Math.addExact(epochSecond, Math.floorDiv(nanoAdjustment, NANOS_PER_SECOND));
int nos = (int)Math.floorMod(nanoAdjustment, NANOS_PER_SECOND);
return create(secs, nos);
}
ofEpochSecond 메소드의 두번째 인자값이 nano second 값이란걸 확인 할 수 있습니다.
결국, currentInstant 메소드에서 VM.getNanoTimeAdjustment(localOffset) 통해 nano second 값을 가져오게 되는데요, 이 값은 system os 에 따라 다른 정밀도로 가져오게 됩니다(micro, nano). (해당 게시글을 작성한 환경은 MacOS)
해결)
공통으로 처리하기 위해 Custom Serializer를 구현해 ObjectMapper에 추가하는 JavaTimeModule에 추가했습니다. 기존에 동작하는 InstantSerializer를 그대로 사용하기 위해 Instant 클래스에 truncatedTo 메소드로 Milli 단위까지 truncate를 하고 InstantSerializer에 serialize를 호출 할 수 있게 했습니다.
public static class CustomInstantSerializer extends JsonSerializer<Instant> {
@Override
public void serialize(Instant value, JsonGenerator generator, SerializerProvider provider) throws IOException {
InstantSerializer.INSTANCE.serialize(value.truncatedTo(ChronoUnit.MILLIS), generator, provider);
}
}
참고 자료)