당신의 컴퓨터의 now는 내 컴퓨터와 다를 수 있다 - timestamp precision

개요

로컬에서 통과하던 테스트가 GitHub Action에서는 실패했다

제가 근무하는 팀에서는 GitHub에 PR을 생성하면 GitHub Action Trigger에 의해 모든 테스트 케이스를 문제 없이 통과하는지 검증하는 workflow가 실행됩니다.

저는 여느 때와 같이 로컬에서 모든 테스트 케이스가 All pass임을 확인하고, PR을 작성했습니다. 그런데, GitHub Action Runner(이하 runner)에서 수행된 테스트는 일부 테스트의 실패로 인해 workflow run fail 이라는 결과를 맞았습니다.

runner 실행 환경에서 테스트가 실패하면 artifact로 test report를 업로드해 실패한 테스트를 확인할 수 있는데요. report를 확인해보니, 두 개의 LocalDateTime을 비교하는 assertEquals 문에서 아래와 같은 이유로 테스트가 실패했다고 출력되고 있었습니다.

<2024-03-25T06:49:53.923859886> but was: <2024-03-25T06:49:53.923860>

결과로부터 원인을 추측해보자면, 반올림에 의해 두 데이터 간 오차가 생기며 테스트가 실패했을 것이라 추측됩니다. 기대했던 데이터는 소수점 이하 자릿수가 9자리였으나 실제 데이터는 7자리에서 반올림이 일어난 6자리까지만 저장하고 있었기 때문입니다.

의문점

위의 결과를 보고, 2가지 의문점이 생겼습니다.

  • 왜 반올림이 일어났을까?
  • 왜 로컬에서는 반올림에 의해 테스트 코드가 실패하지 않았을까?

실제로 이 2가지 의문을 해결해나가는 과정에서 테스트가 실패한 이유와 로컬에서는 실패가 재현되지 않은 이유에 대해 알게 되었습니다.

아래 내용부터는 이러한 의문점들을 해결해나간 과정을 소개해 드리겠습니다.

 

 

문제 원인 파악하기

코드 분석

일단 실패한 테스트에서 비교하는 두 날짜 데이터가 어떤 데이터인지 확인하기 위해 코드를 분석해보았습니다.

해당 테스트 코드에서 두 날짜 데이터에 값을 설정하는 부분만 간단하게 정리하자면 아래와 같습니다.

LocalDateTime d1 = LocalDateTime.now();
LocalDateTime d2 = {d1과 같은 날짜 데이터를 DB에 저장한 후 꺼낸 값}

assertEquals(d1, d2);

즉, DB라는 외부 시스템으로의 직렬화-역직렬화가 이루어지는 과정에서 일부 자릿수의 손실이 발생했고, 이게 반올림 형태로 나타난 것이라는 사실을 깨달았습니다.

그렇다면 로컬 환경에서는 왜 테스트가 실패하지 않았을까요? 로컬 PC에서 d1의 값을 확인해본 결과, d2와 동일하게 소수점 6자리까지 출력되었습니다. 즉, 두 값의 자릿수가 같아 손실이 발생하지 않았기 때문에 테스트를 통과한 것입니다.

원인은 “OS 차이”

결국 LocalDateTime.now() 값의 자릿수가 달라 테스트의 pass/fail 여부가 갈렸음을 알 수 있습니다. 가장 유력한 범인 후보는 두 실행 환경 간 OS 차이라고 생각했는데요. 따라서, java, LocalDateTime, different, platform 4가지 키워드로 검색하여 아래와 같은 스택오버플로우 글을 발굴해냈습니다.

 

LocalDateTime.now() has different levels of precision on Windows and Mac machine

When creating a new LocalDateTime using LocalDateTime.now() on my Mac and Windows machine i get a nano precision of 6 on my Mac and a nano precision of 3 on my Windows machine. Both are running jdk...

stackoverflow.com

이 글을 보고, 아래 2가지 사실을 알게 되었습니다.

  1. LocalDateTime의 now 메서드를 호출하면 시스템 클록을 기반으로 정밀도(precision)를 결정한다.
  2. 시스템 클록(Clock)의 최대 정밀도는 OS 별로 상이하다.

로컬 PC의 OS인 mac에서의 클록 정밀도는 마이크로초(6자리)였고, workflow를 실행하는 Runner의 OS인 ubuntu에서의 클록 정밀도는 나노초(9자리)였습니다.

그렇다면 직렬화-역직렬화 과정에서 일어난 소수점 손실도 DB의 정밀도 때문이 아니었을까 하는 생각이 드는데요. 테스트 환경에서 사용한 DBMS인 PostgreSQL가 지원하는 날짜 타입의 최대 정밀도를 확인해보니, 마이크로초(6자리)라고 합니다. (공식 문서 참조)

즉, 나노초까지 명시된 데이터가 마이크로초까지의 정밀도만 지원하는 저장 공간을 거치며 자릿수 손실이 발생한 것이었습니다.

 

 

해결

부동소수점 연산에서 오차가 절대 없는 결과를 기대하기는 어렵습니다. 결국 해당 날짜 비교 이슈 또한 부동소수점 연산에서 OS 간 지원하는 최대 클록 정밀도가 달라 오차가 발생한 사례였습니다.

이 문제를 해결하기 위해서는 아래와 같은 2가지 해결책을 선택할 수 있을 듯합니다.

  1. 동등 연산(==)이 아닌 범위 연산으로 변경
  2. 모든 시스템에서 대응이 가능하도록 정밀도를 낮춰(truncate) 비교

사실 해당 테스트에서는 2개의 날짜 데이터가 같은지를 알기 위해 초(second) 이후의 데이터까지 검증할 필요는 없었습니다. 따라서, 두 날짜 데이터의 정밀도를 second까지만 표시되도록 truncate하여 비교하도록 코드를 수정함으로써 OS에 상관 없이 테스트가 통과할 수 있도록 변경할 수 있었습니다.

 

 

요약

문제 정리

  • 테스트는 2개의 날짜 데이터의 동등성 비교 결과가 참이면 성공한다.
  • 2개 날짜 데이터는 각각 LocalDateTime.now()와 이것을 PostgreSQL에 저장한 후 다시 역직렬화한 값이다.
  • 전자는 OS 클록의 정밀도에 의해 결정되며, 후자는 postgres의 precision에 의해 마이크로초(6자리)로 결정된다.
  • 로컬 PC의 OS는 mac이어서 precision이 마이크로초(6자리). 역직렬화된 날짜 데이터와 동일한 값이므로 테스트가 통과한다.
  • CI 환경인 GitHub Action Runner의 OS는 ubuntu여서 precision이 나노초(9자리)이다. 이 값의 직렬화 과정에서 정밀도 하락에 의해 일부 자릿수의 손실이 일어났고, 그 결과 두 개의 날짜가 다른 값으로 취급되어 테스트가 실패했다.

깨달은 것

  • LocalDateTime 자료형의 현재 시각 데이터를 얻기 위한 정적 팩토리 메서드인 now의 소수부 자릿수는 시스템 클록의 최대 정밀도를 기반으로 결정된다.
  • 부동 소수점 연산에는 “100% 정확성”을 기대할 수 없으며, 날짜 데이터 또한 second 이후의 데이터는 소수점으로 취급되기 때문에 소수값을 포함하는 데이터라는 사실을 다시금 깨달았다. 부동 소수점 연산의 검증이 필요하다면, 오차가 특정 값 이하인지 확인하는 방식으로 우회하기 위해 범위 연산으로 바꾸거나 정밀도를 낮추는 방향으로 풀어 나갈 수 있겠다.