Notice
Recent Posts
Recent Comments
Link
«   2025/09   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

코딩 이래요래

TimeZone😤 본문

Trouble Shooting

TimeZone😤

강범호 2025. 6. 23. 15:07

단일 테스트는 통과, 전체 테스트는 실패... 타임존의 늪에 빠지다

오늘은 테스트 코드를 작성하던 중 예상치 못한 Time-zone 이슈를 마주했다.

분명히 PostgreSQL에선 잘 작동하던 로직인데, H2로 테스트하면 실패하는 상황이다.

 

이건 단순한 코드 실수가 아니라고 생각했다.

한줄 한줄 의심하며 로그를 까보던 내 하루를 이곳에 기록해보려 한다.

 

테스트 시나리오

  • @BeforeEach로 조회용 메세지 3개를 Thread.sleep을 이용하여 시간차를 두고 저장한다.
  • 각 메세지에는 @CreatedDate Instant createdAt이 자동으로 생성된다.

  • 그 중 가장 최근 메세지의 createdAt을 커서로 지정하고,
  • createdAt < :cursor 조건으로 이전 메세지를 페이징 조회한다.
List<Message> result = messageRepository
    .findByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc(channelId, cursor, pageable);
  • 기대한 결과
no 내용 시간
2 내용 2 2025-06-23T04:22:13.693226Z
1 내용 1 2025-06-23T04:22:12.186757Z
0 내용 0 2025-06-23T04:22:10.636840Z

→ cursor = 04:22:13.693226Z 라면, "내용 1", "내용 0" 두 개가 조회돼야 한다.


그런데... 테스트는 실패했다

  • 예상
assertThat(result.getContent()).hasSize(2);
assertThat(result.getContent().get(0).getContent()).isEqualTo("내용 1");

 

  • 테스트 실행결과

SQL Exception이 발생한 것도 아니다. 내가 예상한대로라면 2개의 메세지를 조회해야하는데.. 이상하다.

우선 각 Message의 createAt, cursor를 확인 해보았다.

이건 무슨 상황인가? consol에는 각 메세지의 createdAt이 약 2초 간격으로 잘 저장되어있다.

cursor도 가장 최근 메세지의 createdAt으로 잘 지정되어 있었다.

→ 내용 2는 커서와 동일하므로 제외, 내용1, 내용 0은 커서보다 이전이니 당연히 조회돼야 한다. 하지만, 돌아오는 건 빈 리스트일 뿐..

 

심지어 이 로직은 이미 프론트엔드 연동까지 마친 기능이다.

 

그래서 혹시나 해서 Postman으로 실제 API를 호출하여 커서 기반 페이지네이션 테스트를 해보기로했다.

아래는 실제 DB에 저장되어있는 메세지 데이터다.

 

이를 Postman으로 메세지 조회 요청을 보내보자.

가장 최근 메세지의 createdAt을 cursor로 지정하여 요청을 보내면 위 사진처럼 이전 2개의 메세지를 조회한다.

정리해보면 로직이 아니라, 테스트 환경의 문제라고 생각했다.

그럼 도대체 뭐가 문제일까...

 

이때 문득 든 생각이 운영용은 PostgreSQL, Test는 H2 데이터베이스를 사용하고있다.

지금 상황을 GPT에게 질문을 하니 2가지의 가능성을 알려주었다.

 

1. H2는 Instant -> TIMESTAMP로 매핑 시 정밀도 손실을 유발할 수 있다는 점

즉, 로그에 createdAt = 2025-06-23T10:12:34.123456Z 와 같이 출력되어도, H2에서는 2025-06-23 10:12:34.123 처럼 잘려서 저장될 수 있다.

 

하지만 콘솔에 출력된 시간처럼 나노초 차이가 아닌 약 2초 가량 차이가 나는데 이번 문제는 정밀도 손실의 문제는 아닌것 같다.

 

2. H2의 Instant 저장 시 UTC <-> 로컬 시간대 처리 문제

  • Instant는 UTC 기준 시간이다.
  • 하지만, H2는 TIMESTAMP를 JVM의 로컬 시간대로 해석한다.

좀 더 쉽게 생각해보자.

  • Instant로 저장한 값은 2025-06-23T04:00:00Z -> (UTC 기준)
  • 커서도 Instant로 2025-06-23T04:02:00Z로 지정했다.
  • 그런데 JVM이 한국(+09:00)이라면, H2는 DB값을 13:00이라 보고 커서 시간도 13:02로 해석하지 않을 수도 있다는 것이다.

어떨 땐 커서만 한국 시간으로 보고, DB 값은 UTC 그대로 쓰기도 한다.

그럼 04:00 < 13:02가 아니라,

04:00 > 13:02 처럼 말도 안되는 WHERE절이 생성되는 것.

 

따라서 application.yaml에 아래 설정을 추가해서 타임존을 아예 UTC로 고정해주었다.

spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;TIMEZONE=UTC
  jpa:
    properties:
      hibernate:
        jdbc:
          time_zone: UTC

 

설정 이후 다시 테스트를 돌리니 Pass가 떴다!


끝난 줄 알았다.

단일 테스트는 통과하는데,

프로젝트 전체 테스트(All tests)를 실행하니 다시 조회를 못하는 문제가 똑같이 발생했다.. 😤

다시 GPT에게 질문을 했더니..

 

GPT 왈:

"Hibernate 시간대만 고정해선 안 됩니다.
H2는 JVM 시스템 타임존(user.timezone)도 참고합니다."

 

즉, 프로젝트 전체 테스트를 돌리면 어떤 테스트는 UTC 기준으로, 어떤 테스트는 +09:00으로 시간을 해석해버리면서 이런 문제가 발생한 것이다...

 

다시말해 JDBC 시간대만 UTC로 설정하는 걸로는 부족하다. H2는 비교 시 JVM의 시스템 시간대도 참고하기 때문에, 진짜 확실하게 하려면 JVM 레벨에서 고정해야 한다.

IntelliJ에서 JVM 옵션에 '-Duser.timezone=UTC' 설정을 추가해주었다.

 

설정 후 다시 테스트를 실행하니...

드디어 모든 테스트가 pass되었다..


문제는 내 코드가 아니었다. 이번 이슈를 겪으면서, 테스트가 실패한다고 해서 항상 내 코드가 틀렸다고 단정지을 순 없다는 걸 다시 한 번 배웠다.

처음엔 테스트 코드를 잘못 작성한 줄 알았다. 콘솔을 열고, DB를 까보고, SQL 쿼리를 직접 써가며 로직을 의심했다.

테스트 데이터를 일부러 2초씩 나눠 저장해가며 재현해봤고, Postman으로 API 호출해가며 "도대체 왜 안되는거지" 라는 생각을 몇 번이나 반복했다. 그러다 결국 도달한 건, "시간"이 문제였다.

단순히 createdAt 비교의 문제 같지만, 그 이면에는 H2가 해석하는 시간, 그리고 JVM이 해석하는 시간이 있었다.

 

결국 알게 된건

  1. 테스트는 정확한 환경 설정 위에서만 신뢰할 수 있다.
  2. 테스트가 실패했을 땐, 내 코드뿐만 아니라 실행 환경 자체도 의심할 줄 알아야 한다.

개발이란 결국 시간과의 싸움이라고 생각한다.

로직을 짜는 시간, 테스트를 고치는 시간, 배우고 깨닫는 시간까지.

 

그리고... 로컬과 서버의 시간이 안 맞는 걸 눈치채는 데 걸리는 시간.

이번 타임존 이슈로 하루를 통째로 쓰고 나서야, 깨달았다.