들어가며
안녕하세요! 요즘 플러피(Fluffy)라는 온라인 시험 제작 및 관리 서비스를 개발하고 있습니다. 플러피에서 사용자는 하나의 시험을 여러 번 응시할 수 있고, 마지막 제출 시간을 기준으로 정렬된 제출 목록을 볼 수 있습니다.
이 기능이 정확하게 동작하는지 확인하기 위해 제출 시간과 마지막 제출 시간이 같은지 비교하는 테스트를 작성했습니다. 로컬 환경(MacOS)에서는 테스트가 잘 통과했지만, CI 환경(Ubuntu)에서는 테스트가 실패하는 문제가 발생했습니다.
이 문제를 해결하는 과정에서 다음과 같은 사실을 알게 되었습니다.
- 운영체제마다 System Clock의 해상도가 다르기 때문에 LocalDateTime.now()의 정밀도가 달라질 수 있다.
- PostgreSQL에서는 마이크로초까지 지원하기 때문에 나노초에서 반올림한다.
이러한 내용을 공유해보려고 합니다.
로컬 환경과 CI 환경 비교
기술 스택
우선 플러피에서 사용한 기술 스택은 다음과 같습니다.
- Kotlin
- Spring Data JPA
- Spring Boot 3.4.4
- PostgreSQL 16
- Spring Data JPA
- Testcontainers, Kotest, Mockk
- Github Actions
참고로, Testcontainers는 테스트 시, Docker를 사용해서 PostgreSQL 컨테이너를 띄우고, 통합 테스트를 할 수 있게 합니다. 플러피 서비스에서는 Github Actions를 이용해 CI/CD를 진행하고 있습니다. Github Actions에서는 Docker를 사용할 수 있어, Testcontainers를 사용할 수 있습니다.
실행 환경
환경 | 운영체제 |
로컬 개발 환경 | macOS Sequoia 15.3.2 |
CI 환경 | Ubuntu 24.04 LTS (GitHub Actions) |
문제 상황 및 테스트 코드
문제가 되는 부분을 간단히 해서 설명하겠습니다.
테스트 대상이 되는 엔티티입니다. AuditingEntityListener와 CreatedDate를 통해서 엔티티가 저장될 때 자동으로 createdAt 필드에 현재 시간이 저장됩니다.
@Entity
@EntityListeners(AuditingEntityListener::class)
class Submission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0
@CreatedDate
@Column(columnDefinition = "TIMESTAMP(6)")
var createdAt: LocalDateTime = LocalDateTime.MIN
private set
}
save를 통해 저장한 값과 조회를 통해 가져온 값의 createdAt을 비교하는 테스트입니다.
fun '생성 시간 비교 테스트'() {
val savedSubmission = submissionRepository.save(Submission())
val foundSubmission = submissionRepository.findByIdOrThrow(submission.id)
println(savedSubmission.createdAt)
println(foundSubmission.createdAt)
savedSubmission.createdAt shouldBe foundSubmission.createdAt
}
테스트 결과는 다음과 같습니다
환경 | 저장 직후 createdAt 값 | DB에서 조회한 createdAt 값 | 테스트 결과 |
MacOS (로컬) |
2025-04-01T12:00:00.512332 (마이크로초) |
2025-04-01T12:00:00.512332 (마이크로초) |
✅ 통과 |
Ubuntu (CI) |
2025-04-01T12:00:00.512332129 (나노초) |
2025-04-01T12:00:00.512332 (마이크로초) |
❌ 실패 |
문제 원인 분석
@CreatedDate에 값이 채워지는 과정
우선, 엔티티를 영속화할 때 어떻게 createdAt에 현재 시간이 채워지는지 알아보겠습니다.
엔티티를 생성하고, 저장할 때 persist 메서드가 호출됩니다. JPA가 persist 이벤트를 감지하고, AuditingEntityListener의 @PrePersist 메서드를 호출합니다. 이 메서드는 @CreatedDate가 붙은 필드에 영속화 전에 현재 시간을 채워줍니다. 현재 시간을 채우기 위해서 아래의 코드와 같이 LocalDateTime.now()가 호출됩니다.
package org.springframework.data.auditing;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAccessor;
import java.util.Optional;
public enum CurrentDateTimeProvider implements DateTimeProvider {
INSTANCE;
private CurrentDateTimeProvider() {
}
public Optional<TemporalAccessor> getNow() {
return Optional.of(LocalDateTime.now()); // 이 부분
}
}
LocalDateTime.now()의 동작 원리
LocalDateTime.now()는 시스템 시간(System Clock)을 기준으로 현재 시간을 얻습니다. 공식 문서에는 나노초 단위까지 표현한다고 되어 있습니다. 하지만, 운영체제마다 시스템 시간의 해상도가 다릅니다.
macOS는 마이크로초 단위까지 지원하지만, Ubuntu는 나노초 단위까지 지원합니다. 이로 인해, LocalDateTime.now()의 값이 다르게 나올 수 있습니다.
macOS | 마이크로초(μs) (최대 6자리, 0.000001s 단위) |
Ubuntu | 나노초(ns) (최대 9자리, 0.000000001s 단위) |
PostgreSQL의 시간 정밀도
위의 과정을 통해, 로컬 환경에서 LocalDateTime.now()는 마이크로초까지 나온다는 것을 알 수 있습니다.
PostgreSQL의 TIMESTAMP은 마이크로초(6자리, microseconds)까지 지원합니다. TIMESTAMP 컬럼을 생성할 때 정밀도를 지정하지 않으면 기본값은 최대 6자리(마이크로초)입니다. 즉, TIMESTAMP = TIMESTAMP(6)와 동일하게 동작합니다.
이를 통해, 로컬 환경에서는 LocalDateTime.now()를 통해 마이크로초까지 시간을 얻고, 데이터베이스(PostgreSQL)에 저장할 때도, 마이크로초까지 저장할 수 있다는 사실을 알 수 있었습니다. 그래서 로컬에서의 테스트가 정상적으로 통과가 된 것입니다.
CI 환경(Github Actions, Ubuntu)에서는 LocalDateTime.now()가 나노초까지의 시간 정밀도를 가지기 때문에 "2025-04-01T12:00:00.512332129"와 같은 결과를 얻을 수 있었고, 데이터베이스는 마이크로초까지만 사용하기 때문에 "2025-04-01T12:00:00.512332"와 같은 결과를 얻을 수 있었습니다. 두 값은 같지 않기 때문에 테스트에 실패한 것입니다.
문제 해결 방법
테스트 시 응답 값을 마이크로초로 잘라서 비교 - 실패
그렇다면, 이 문제를 해결하기 위해 시간을 비교할 때, 마이크로초까지만 잘라서 비교하면 될까요? 이렇게 할 경우, 테스트가 성공할 수도, 실패할 수도 있습니다. 다음과 같은 경우에는 테스트가 성공하게 됩니다.
val now = LocalDateTime.now() | 2025-04-01T12:00:00.512_332_129 |
now를 마이크로초로 자른 값 | 2025-04-01T12:00:00.512_332 |
데이터베이스에서 조회한 값 | 2025-04-01T12:00:00.512_332 |
하지만 다음과 같은 경우에서는 테스트가 실패합니다.
val now = LocalDateTime.now() | 2025-04-01T12:00:00.333_333_777 |
now를 마이크로초로 자른 값 | 2025-04-01T12:00:00.333_333 |
데이터베이스에서 조회한 값 | 2025-04-01T12:00:00.333_334 |
PostgreSQL의 경우, 마이크로초까지 반올림해서 저장하기 때문에 7이 반올림되어 333_334가 됩니다.
PostgreSQL JDBC 드라이버 내부 코드를 확인하면 다음과 같이 나노초를 반올림해서 마이크로초로 저장함을 알 수 있습니다.
참고로, MySQL JDBC 드라이버도 동일하게 나노초를 마이크로초로 반올림하여 저장합니다.
package org.postgresql.jdbc;
public class TimestampUtils {
...
public String toString(...) {
...
if (nanos >= 999999500) {
nanos = 0;
++timeMillis;
}
...
}
...
}
생성 시간에 값을 채우는 과정에서 현재 시간을 마이크로초로 잘라서 저장
Ubuntu 환경에서 다음과 같은 코드는 값의 불일치 문제가 발생합니다.
val submission = submissionRepository.save(Submission())
submisison.createdAt은 영속화되기 전에 LocalDateTime.now()로 생성된 나노초 정밀도의 시간을 가지고 있습니다. 하지만 실제 데이터베이스에서 createdAt은 마이크로초로 반올림된 값을 가지게 됩니다.
이러한 문제를 해결하기 위해, 영속화되기 전에 미리 마이크로초로 절삭하는 방법을 선택했습니다.
@Entity
@EntityListeners(AuditingEntityListener::class)
class Submission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0
@CreatedDate
@Column(columnDefinition = "TIMESTAMP(6)")
var createdAt: LocalDateTime = LocalDateTime.MIN
private set
// 이 부분
@PrePersist
fun prePersist() {
createdAt = createdAt.truncatedTo(ChronoUnit.MICROS)
}
}
LocalDateTime.now()를 통해 생성된 값을 다시 마이크로초로 절삭해서 createdAt에 저장하게 됩니다. 데이터베이스에서는 이미 마이크로초이기 때문에 추가 과정없이 저장할 수 있습니다.
이제 영속화되기 전에 부여된 생성 시간과 데이터베이스에서 저장된 값이 일치하여 테스트가 성공적으로 통과하게 됩니다.
알아두면 좋을 것
시간이 "2025-04-01T23:59:59.999_999_999"인 경우를 데이터베이스에 저장할 경우 "2025-04-02T00:00:00.0"와 같이 1초가 올라가서 다음 날로 넘어갑니다. 이를 막기 위해 나노초를 999_999_000로 지정하는 것을 추천합니다.
마무리
이번 글에서는 운영체제에 따라 LocalDateTime.now()의 시간 정밀도가 달라지는 문제로 인해, 로컬에서는 통과하던 테스트가 CI 환경에서 실패한 사례를 공유했습니다. 이를 해결하기 위해 영속화하기 전에 LocalDateTime.now() 값을 마이크로초(6자리) 단위로 절삭(truncate)하여 저장하는 방법을 적용했습니다.
혹시 더 좋은 해결 방법이나 보완할 점이 있다면 댓글로 의견을 남겨주시면 감사하겠습니다. 읽어주셔서 감사합니다!
fluffy/server/src/main/kotlin/com/fluffy/infra/persistence/AuditableEntity.kt at 81526d1f1a6cae61e10d092bb1e97fc49518498b · als
온라인 시험 문제 제작 및 관리 서비스, 플러피. Contribute to alstn113/fluffy development by creating an account on GitHub.
github.com
참고
'서버' 카테고리의 다른 글
Presigned URL과 CDN으로 이미지 업로드 & 조회 개선 (0) | 2025.03.07 |
---|---|
테스트 후 데이터 정리를 통해 테스트 간 데이터 독립성 보장 (0) | 2025.03.03 |
Testcontainers로 실제 서비스와 유사한 환경에서 테스트하기 (1) | 2025.03.02 |
분산락을 이용한 중복 생성 문제 해결 (2) | 2025.02.23 |
로컬에서 AWS Private Subnet에 있는 인스턴스에 접속하는 방법 (0) | 2025.02.13 |