RFC3339Nano로 저장한 시간이 역순으로 보이는 이유
목차
이벤트를 최신순으로 정렬했는데 같은 초에 발생한 이벤트의 순서가 뒤집힌다. 저장한 값은 모두 올바른 RFC 3339 형식이다. DB의 컬럼이 문자열이라면 올바른 시간 표현이라는 사실만으로 시간순 비교까지 보장되지는 않는다.
Go의 time.RFC3339Nano는 소수 초 뒤의 0을 생략한다. 0.1초는 .1Z, 정수 초는 소수점 없이 Z로 끝난다. Go 문서도 이 형식의 문자열이 올바른 시간순으로 정렬되지 않을 수 있다고 명시한다. Go 1.24.5 시간 형식 상수
Z와 소수점이 순서를 바꾼다
같은 날 정오를 기준으로 다음 세 이벤트를 만들었다고 하자. ID는 설명용이며 발생 시각과 별개다.
| ID | 정오에서 지난 시간 | RFC3339Nano 출력 |
|---|---|---|
| a | 0초 | 2026-10-23T12:00:00Z |
| c | 0.1초 | 2026-10-23T12:00:00.1Z |
| e | 0.11초 | 2026-10-23T12:00:00.11Z |
최신순은 e → c → a다. 하지만 바이트 단위 문자열 비교에서는 공통 접두사 뒤의 첫 다른 문자를 비교한다. a와 c를 비교하면 Z와 .에서 갈리고 Z가 더 크므로 내림차순의 앞에 a가 온다. c와 e도 .1까지 같다가 Z와 1에서 갈려 c가 앞선다.
SQLite에서 별도 collation을 지정하지 않은 문자열 비교는 기본적으로 BINARY를 사용한다. 이 비교 규칙은 문자열의 바이트를 비교하며 값이 날짜처럼 생겼다고 시간으로 해석하지 않는다. 이 글의 실험도 이 기본 규칙을 사용한다. SQLite 문자열 비교 규칙
RFC 3339에서 문자열 정렬을 설명하는 부분에는 조건이 있다. 시간대와 그 표기가 같고 소수 초의 자릿수도 같아야 한다. RFC3339Nano의 가변 길이 소수 초는 이 조건을 만족하지 않는다. 서로 다른 UTC offset까지 섞여 있다면 자릿수만 맞춰도 부족하다. RFC 3339 §5.1
정렬뿐 아니라 WHERE도 같은 문제를 갖는다
이벤트 목록에는 보통 비교가 여러 군데 있다. 화면의 최신순 정렬, 다음 페이지를 고르는 커서 조건, “이 시각까지”라는 조회 조건이다. 같은 시간을 한 곳에서는 시간으로, 다른 곳에서는 원래 문자열로 비교하면 결과가 서로 어긋난다.
예를 들어 c 시점까지의 이벤트만 읽으려 한다.
SELECT id FROM events
WHERE raw <= '2026-10-23T12:00:00.1Z';시간상으로는 a가 포함되고 e는 제외되어야 한다. 문자열 비교에서는 반대다. .11Z가 .1Z보다 작으므로 미래의 e가 조건을 통과한다. 소수점 없는 a는 오히려 조건을 통과하지 못한다. 조회한 결과를 애플리케이션에서 다시 정렬해도 이미 잘못 골라 온 행은 복구되지 않는다.
시간으로 해석한 뒤 비교용 표현을 정한다
시간을 비교할 때 쓸 표현은 저장소의 타입과 필요한 정밀도를 보고 정한다. 시간 타입을 제공하는 DB라면 그 타입의 범위·정밀도·시간대 처리 방식을 확인한다. 문자열로 보관해야 한다면 모든 값을 UTC로 변환하고 소수 초의 길이를 고정하는 방법이 있다.
이 예제에서는 이미 파싱한 time.Time을 다음처럼 표현한다.
const sortLayout = "2006-01-02T15:04:05.000000000Z"
sortKey := t.UTC().Format(sortLayout)0 아홉 개는 소수 초를 아홉 자리로 유지한다. 정수 초도 .000000000Z가 되고 .1Z는 .100000000Z가 된다. UTC로 먼저 변환하므로 끝의 Z도 실제 시간대와 맞는다. 본문의 범위는 RFC 3339의 네 자리 연도와 Go가 표현하는 나노초 정밀도다. 더 넓은 연도나 다른 정밀도를 지원해야 한다면 별도 표현을 정해야 한다. Go의 시간 포맷 규칙
외부 문자열을 받는 코드라면 파싱 오류를 먼저 처리해야 한다. 위 두 줄은 입력 검증을 대신하는 코드가 아니다. 화면에 표시할 형식은 별도로 두어도 되지만 저장한 비교 키와 조회 경계값은 같은 규칙으로 만들어야 한다.
1나노초 차이와 같은 시각의 두 행으로 확인하기
실행 가능한 예제는 Go 표준 라이브러리로 시간 문자열을 만들고 Python의 내장 SQLite에 넣는다. 외부 패키지는 필요 없다. Go가 설치된 환경에서 파일을 내려받아 실행한다. 자체 검사가 assert를 사용하므로 Python의 -O 옵션은 붙이지 않는다.
python3 timestamp-order.py이벤트는 일곱 개다. 정오에서 지난 시간을 나노초로 쓰면 a=0, b=1, c=100000000, d=100000001, e=110000000, f=g=900000000이다. c와 d는 1나노초 차이이고 f와 g는 시각이 같다. 같은 시각은 ID 내림차순으로 정한다.
Go 1.24.5, Python 3.14.5, SQLite 3.43.2에서 실행한 결과 중 비교 부분은 다음과 같다. raw는 RFC3339Nano 원문이고 fixed는 UTC·소수 초 아홉 자리 표현이다.
raw DESC: agfcedb
fixed DESC: gfedcba
cutoff at c: raw=bcde; fixed=abc
cursor pages: gf / ed / cb / a
strftime c == d: 2026-10-23T12:00:00.100Z
PASS: ordering, cutoff, tied cursor, nanoseconds, UTC conversion, cleanup첫 두 줄은 최신순 결과다. 세 번째 줄은 c 시점까지의 행을 골라 비교하기 쉽도록 정상 시간 오름차순으로 표시한 결과다. 원문 비교는 a를 빠뜨리고 미래의 d, e를 포함했다. 고정 길이 표현으로 비교하면 a, b, c만 남는다.
스크립트는 이 기대값뿐 아니라 고정 길이 문자열을 다시 파싱했을 때 원래 시각이 보존되는지도 검사한다. 같은 시각을 UTC+9로 표현한 뒤 UTC로 되돌려도 같은 키가 되는지 확인한다. DB는 메모리에서 닫고 Go 임시 소스는 실행 뒤 제거한다.
커서에는 시각과 고유 ID를 함께 넣는다
다음 페이지를 읽을 때도 정렬과 같은 비교 기준을 사용한다. 이 예제의 최신순 정렬 키는 (fixed, id)다. 첫 페이지 뒤부터는 직전 페이지의 마지막 행을 커서로 사용한다.
SELECT id, fixed FROM events
WHERE (fixed, id) < (?, ?)
ORDER BY fixed DESC, id DESC
LIMIT 2;첫 번째 매개변수는 마지막 행의 정규화된 시각, 두 번째는 그 행의 ID다. 첫 페이지에는 WHERE 조건이 없다. fixed만 커서로 쓰면 같은 시각의 f, g가 페이지 경계에서 나뉠 때 한 행을 건너뛸 수 있다. 고유하고 변하지 않는 ID를 두 번째 비교 키로 넣으면 같은 시각에도 순서를 정할 수 있다.
실험 결과 gf / ed / cb / a를 이어 붙이면 전체 정렬 결과와 같다. 누락과 중복이 없다는 검사를 함께 둔다. 정렬 키를 바꿀 때는 ORDER BY뿐 아니라 커서 생성·복원, 커서의 비교 조건, 특정 시점 필터를 같이 확인해야 한다. 기존 클라이언트가 원문 시각을 커서로 가지고 있다면 전환 중에 그 값을 어떻게 해석할지도 정해야 한다.
이 검사는 고정된 데이터 집합의 순서를 확인한다. 페이지를 넘기는 동안 과거 시각의 이벤트가 추가되거나 기존 행이 바뀌는 경우까지 같은 조회 스냅샷을 보장하지는 않는다. 그런 보장이 필요하면 저장소의 스냅샷이나 별도의 조회 경계가 필요하다. 또한 이벤트 시각이 업무상 처리 순서나 데이터 버전을 대신하지도 않는다.
날짜 함수로 바꾸면 정밀도가 유지될까
SQL의 날짜 함수로 감싸면 간단해 보이지만 함수가 어느 정밀도까지 보존하는지 확인해야 한다. 실험에서 SQLite strftime('%Y-%m-%dT%H:%M:%fZ', raw)는 c와 d를 둘 다 .100Z로 표현했다. 1나노초 차이가 사라진다. SQLite 문서의 %f 출력은 SS.SSS다. SQLite 날짜 함수
따라서 이 예제의 요구사항에서는 해당 변환을 정렬 키로 쓰지 않았다. 밀리초까지면 충분한 시스템과 나노초 차이를 보존해야 하는 시스템은 같은 선택을 할 필요가 없다. 비교할 정밀도를 먼저 정하고 변환 전후에 서로 다른 시각이 같은 값으로 합쳐지지 않는지 확인하면 된다.
기존 데이터가 있다면 새 쓰기 경로만 바꾸어서는 끝나지 않는다. 옛 값과 새 값이 같은 비교 표현을 쓰는지 확인하고 기존 행의 변환과 인덱스 준비를 마쳐야 한다. 검증 입력에도 정수 초, 길이가 다른 소수 초, 최소 정밀도 차이, 같은 시각의 서로 다른 ID를 넣어 보자. 각각 정렬·필터·페이지 경계의 다른 오류를 드러낸다.