시간은 타입보다 계약이 더 중요하다
시간 관련 버그는 보통 타입 선택 실수로 보이지만, 실제로는 저장 형식, 파서, 타임존 기준이 흐린 계약에서 시작되는 경우가 많다. 운영에서만 드러난 datetime 버그를 통해 그 이유를 정리한 글이다.
이번엔 시간 관련 버그가 두 번 다른 얼굴로 나타났다.
한 번은 운영에서만 DateTimeParseException이 터졌고, 다른 한 번은 새벽 시간대에만 “오늘” 데이터가 “내일”로 밀리거나 반대로 보였다.
겉으로 보면 전혀 다른 문제처럼 보인다.
하나는 파서 예외고, 다른 하나는 날짜 비교 실수처럼 보이기 때문이다.
그런데 둘 다 파고 들어가 보면 결국 같은 데서 시작됐다. 이 값이 어떤 시간 형식이고, 어떤 timezone 의미를 가지며, 다음 단계가 어떤 파서를 기대하는지 계약이 고정돼 있지 않았다.
첫 번째 문제는 이런 흐름이었다.
- 외부 API는
2026-02-05T12:00:00+00:00같은 offset 포함 문자열을 내려준다 - 중간 저장 계층은 그 값을
LocalDateTime으로 바꾸며 offset을 버린다 - 다시 도메인으로 되돌린 값은
2026-02-05T12:00:00처럼 offset 없는 문자열이 된다 - 마지막 소비자는 여전히
OffsetDateTime.parse(...)로 읽으려다 운영에서만 깨진다
두 번째 문제는 이렇게 보였다.
- DB에는 UTC 기준 시간이 저장돼 있다
- 조회 코드는 그 값을 KST로 바꾸지 않고 바로
toLocalDate()로 비교한다 - 그래서 UTC 15:00 이후 데이터가 사용자 기준으로는 이미 다음 날인데도, 시스템은 여전히 같은 날로 분류한다
공통점은 분명했다.
둘 다 시간 타입 하나를 잘못 골랐다기보다, 저장 형식, 파서, timezone 기준이 서로 다른데도 하나의 계약처럼 취급한 상태에서 생긴 문제였다.
처음에는 타입을 잘못 고른 문제처럼 보인다.
String 대신 Instant를 썼어야 했나, LocalDateTime 대신 OffsetDateTime이 맞았나 같은 질문으로 시작하게 된다.
그런데 실제로 더 자주 문제를 만든 건 타입 그 자체보다 시간 계약이 흐린 상태였다.
저장 형식이 무엇인지, offset이 있는지 없는지, 의미상 UTC인지 KST인지, 어떤 파서로 읽어야 하는지가 계약으로 고정돼 있지 않으면 시간 버그는 운영에서만 드러난다.
이 글에서 말하고 싶은 핵심은 단순하다.
시간 버그의 많은 부분은 타입 선택 실수보다 계약 누락에서 시작된다.
“ISO 8601”이라고만 적어두면 아무 설명도 안 한 것과 비슷하다
시간 필드를 설명할 때 흔히 이런 주석을 본다.
1
val datetime: String // UTC, ISO 8601 형식
이 문장은 얼핏 충분해 보인다.
하지만 실제로는 거의 아무것도 고정하지 못한다.
ISO 8601 안에는 여러 형식이 있다.
2026-02-05T12:00:002026-02-05T12:00:00+00:002026-02-05T12:00:00Z
이 셋은 겉보기엔 비슷하지만 파서는 다르게 동작할 수 있다.
한쪽은 LocalDateTime.parse가 맞고, 다른 쪽은 OffsetDateTime.parse가 맞다.
한쪽은 offset 정보가 없고, 다른 쪽은 포함돼 있다.
문제는 시스템 안의 여러 지점이 이걸 서로 다르게 해석할 수 있다는 점이다.
- Provider는 offset 포함 문자열을 만든다
- Entity는
LocalDateTime으로 파싱하며 offset을 버린다 - DB에는 offset 없는 값이 저장된다
- Consumer는 다시
OffsetDateTime.parse로 읽는다
이 순간 버그는 이미 심어져 있다.
코드는 “각자 그럴듯하게” 보이지만, 전체 라운드트립 계약은 깨져 있다.
운영에서만 터졌던 이유는 라운드트립 전체를 아무도 테스트하지 않았기 때문이다
이번 케이스에서 전형적인 손실 흐름은 이랬다.
1
2
3
4
5
6
7
8
9
Provider
→ "2026-02-05T12:00:00+00:00" (offset 포함)
Entity
→ LocalDateTime 으로 파싱 (offset 손실)
DB 저장
Entity.toDomain()
→ "2026-02-05T12:00:00" (offset 없음)
Consumer
→ OffsetDateTime.parse(...) (예외)
각 단계만 보면 이상해 보이지 않는다.
- Provider는 정상 문자열을 만들었다
- Entity는 잘 저장했다
- DB도 값을 들고 있었다
- Consumer도 자기 기대 타입에 맞는 파서를 썼다
그런데 전체로 보면 한쪽은 offset 포함 문자열을 만들고, 중간 저장 계층은 그 offset을 버리고, 마지막 소비자는 여전히 offset이 있을 거라고 기대하고 있었다.
이런 버그가 단위 테스트에서 잘 안 잡히는 이유도 명확하다.
단위 테스트는 대개 도메인 객체를 직접 픽스처로 만든다.
즉 문제의 핵심인 “Provider → Entity → DB → Entity → Consumer” 라운드트립 전체를 재현하지 않는다.
그래서 실제로는 이렇게 된다.
- 단위 테스트는 통과한다
- 코드 리뷰도 각 파일 단위로 보면 지나가기 쉽다
- 운영 배포 후 특정 데이터가 들어온 순간만 깨진다
시간 버그가 특히 짜증나는 이유가 여기에 있다.
로컬에서는 조용하고, 운영에서만 계약 위반이 현실이 된다.
UTC/KST 문제도 결국 타입 문제가 아니라 비교 기준 계약 문제다
다른 종류의 시간 버그도 비슷했다.
DB에 UTC 기준으로 저장된 값을 조회해 놓고, 변환 없이 toLocalDate()로 날짜 비교를 해버리는 경우다.
예를 들어 UTC 기준으로 2026-04-01T15:00:00가 저장돼 있다고 해보자.
- UTC로 보면 4월 1일 15시
- KST로 보면 4월 2일 0시
그런데 변환 없이 날짜만 뽑아 비교하면 4월 1일로 분류된다.
실제 사용자 기준으로는 이미 “내일”이어야 하는데, 시스템은 “오늘”로 보는 셈이다.
특히 이런 코드가 위험하다.
1
item.dateTime().toLocalDate().isEqual(curdate)
혹은 현재 시각 비교에서도 마찬가지다.
1
data.dateTime().isBefore(LocalDateTime.now())
겉보기엔 큰 문제 없어 보이지만, 이 코드는 이미 중요한 계약을 숨기고 있다.
- 저장값은 UTC인가
- 비교 기준은 UTC인가 KST인가
- “오늘/내일”은 사용자 기준인가 저장 기준인가
이 질문에 답하지 않은 채 날짜만 비교하면, 9시간 경계에 걸린 데이터가 조용히 잘못 분류된다.
그래서 올바른 코드는 보통 더 장황하다.
1
2
3
4
5
item.dateTime()
.atZone(ZoneOffset.UTC)
.withZoneSameInstant(kst)
.toLocalDate()
.isEqual(curdate)
장황해 보이지만, 사실 이건 복잡한 게 아니라 숨겨져 있던 계약을 코드에 다시 드러낸 것이다.
시간 타입보다 먼저 고정해야 할 건 저장 형식과 파서다
시간 타입을 고를 때 흔히 이런 얘기를 한다.
- 무조건
Instant를 써라 - offset이 있으면
OffsetDateTime을 써라 LocalDateTime은 위험하다
대체로 맞는 말이다.
하지만 실무에서는 외부 API 계약, DB 제약, 라이브러리 호환성 때문에 String을 유지해야 할 때도 있다.
특히 DynamoDB 같은 곳에서는 attribute converter나 기존 스키마 때문에 당장 타입을 바꾸기 어려울 수 있다.
그럴수록 더 중요한 건 타입 교체 구호가 아니라 계약 고정이다.
내가 먼저 고정하려는 건 이 네 가지다.
- 저장 문자열 형식은 정확히 무엇인가
- offset 정보가 있는가 없는가
- offset이 없다면 의미상 어느 timezone인가
- 소비자는 어떤 파서를 써야 하는가
예를 들어 이렇게 적어야 한다.
1
2
val datetime: String
// ISO_LOCAL_DATE_TIME (offset 없음, 의미상 UTC)
혹은 이렇게.
1
2
val datetime: String
// ISO_OFFSET_DATE_TIME (offset 포함, 항상 +00:00)
이 정도로 적어야 Provider, Entity, Consumer가 같은 계약을 본다.
그냥 “ISO 8601”이라고 적으면 각자가 자기 해석을 붙이게 된다.
시간 버그를 막는 테스트는 단위 테스트가 아니라 계약 테스트에 가깝다
이런 문제를 막으려면 테스트도 바뀌어야 한다.
단순 파서 테스트나 단일 함수 테스트만으로는 부족하다.
필요한 건 라운드트립 전체를 보는 테스트다.
예를 들면 이런 흐름이다.
- Provider가 만든 문자열을 넣는다
- Entity로 변환한다
- DB 저장/복원 과정을 거친다
- 다시 도메인으로 되돌린다
- Consumer가 기대한 파서로 읽는다
즉 테스트의 질문도 달라져야 한다.
- “이 함수가 파싱되나?”가 아니라
- “이 값이 시스템 전체를 한 바퀴 돌고 나와도 같은 의미를 유지하나?”
UTC/KST 비교도 마찬가지다.
- 14:59 UTC
- 15:00 UTC
- 23:59 UTC
- 00:00 UTC
이런 경계 시각을 고정된 fixture로 넣고, KST 기준 오늘/내일 분류가 기대대로 나오는지 보는 테스트가 더 중요하다.
시간 버그는 보통 평범한 시각이 아니라 경계에서 터진다.
그래서 테스트도 경계를 향해야 한다.
시간 관련 코드를 볼 때 먼저 던질 질문
지금은 시간 코드를 보면 타입부터 보지 않는다.
먼저 계약부터 묻는다.
- 이 값은 문자열인가, 객체인가
- 문자열이라면 정확히 어떤 포맷인가
- offset이 있는가 없는가
- offset이 없다면 의미상 어떤 timezone인가
- 저장 기준과 조회 기준이 같은가
- 날짜 비교 전에 사용자 기준 timezone으로 변환했는가
- 소비자 파서가 생산자 형식과 정확히 일치하는가
이 질문에 답이 없으면, 타입이 무엇이든 결국 버그가 난다.
반대로 계약이 분명하면, 심지어 임시로 String을 유지하더라도 사고 반경을 꽤 줄일 수 있다.
마무리
시간 버그는 종종 타입 선택 문제처럼 보인다.
그래서 Instant를 쓸까, OffsetDateTime을 쓸까 같은 논쟁으로 바로 들어가기 쉽다.
하지만 운영에서 더 비싸게 터지는 건 그 전 단계다.
저장 형식, 파서, timezone 기준, 날짜 비교 기준이 계약으로 명시되지 않은 상태가 더 위험하다.
타입은 그 계약을 표현하는 수단이다.
계약이 흐리면 좋은 타입도 서로 다른 의미로 소비된다.
그래서 지금은 이렇게 정리하고 있다.
ISO 8601같은 넓은 표현으로는 계약을 적지 않는다- offset 포함 여부와 의미상 timezone을 같이 적는다
- 문자열을 유지하더라도 producer와 consumer의 파서를 같이 고정한다
- 시간 테스트는 단일 함수보다 라운드트립과 경계 시각 중심으로 짠다
- 날짜 비교는
toLocalDate()전에 어떤 timezone 기준인지 먼저 드러낸다
시간은 값처럼 보이지만, 실제로는 해석 규칙까지 포함한 데이터다.
그 규칙이 코드와 주석과 테스트에 함께 고정돼 있지 않으면, 버그는 로컬이 아니라 운영에서 터진다.