BUG · 버그 도감 #1
카운트다운이 3.00에서 멈췄다: PostgreSQL now()의 함정
스택 타워 1:1 대결의 시작 카운트다운이 3.00에서 움직이지 않았습니다. 원인은 PostgreSQL의 now()가 '지금'이 아니라 트랜잭션 시작 시각이라는 데 있었습니다. 증상, 원인, 해결과 함께 시간 함수 세 가지를 정리했습니다.
버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록을 근거로 합니다.
프로젝트 스택 타워 · 시기 2026년 8월 30일 · 관련 기술 PostgreSQL, Supabase
증상
스택 타워에는 친구와 같은 블록 순서로 점수를 겨루는 1:1 대결이 있습니다. 한 사람이 방을 만들고 다른 사람이 들어오면, 3초 카운트다운 뒤에 두 사람이 동시에 0점부터 시작하도록 만들고 있었습니다.
그런데 서버에서 계산한 "남은 시간"이 줄어들지 않았습니다. 커밋 기록의 표현을 그대로 옮기면 이렇습니다. "now()가 트랜잭션 시작 시각이라 흐르지 않는 것을 확인했다 (3.00에서 멈춤)." 카운트다운 숫자가 3.00에 붙박여 움직이지 않은 것입니다.
배경: 왜 서버가 시작 시각을 정하나
이 버그를 이해하려면 먼저 왜 카운트다운을 서버에서 계산하게 됐는지부터 봐야 합니다. 처음 생각은 간단했습니다. 상대가 들어오면 각자의 기기에서 "지금부터 3초"를 세면 됩니다.
문제는 두 사람이 상대의 입장을 아는 시점이 다르다는 것입니다. 들어오는 사람(손님)은 참가 버튼을 누르는 순간 바로 압니다. 반면 방을 만든 사람(방장)은 서버에 주기적으로 물어보는(폴링) 방식이라, 커밋 기록에 따르면 "최대 0.9초 뒤에" 압니다. 각자 3초를 세면 방장만 최대 0.9초 늦게 출발하는 셈입니다. 점수를 겨루는 게임에서 이건 불공정합니다.
그래서 시작 시각을 서버가 정하기로 했습니다. 손님이 입장하는 순간의 시각을 started_at에 기록하고, 두 사람이 서버에 물어볼 때마다 "시작까지 남은 시간"을 계산해서 내려 줍니다. 그러면 언제 물어보든 두 사람은 같은 시각을 향해 카운트다운하게 됩니다. 남은 시간은 대략 이런 식으로 계산합니다(실제 SQL이 아니라 원리를 보여 주는 예시입니다).
-- 남은 시간(초) = 3초 - (현재 시각 - 시작 기준 시각)
greatest(0, 3 - extract(epoch from (now() - started_at)))
이 "현재 시각" 자리에 쓴 now()가 문제였습니다.
원인: now()는 '지금'이 아니다
PostgreSQL의 now()는 이름과 달리 함수를 호출한 순간의 시각을 돌려주지 않습니다. 현재 트랜잭션이 시작된 시각을 돌려주고, 그 트랜잭션 안에서는 몇 번을 불러도 같은 값입니다. 문서상으로 now()는 transaction_timestamp()와 같은 함수입니다.
이 동작은 버그가 아니라 의도된 설계입니다. 한 트랜잭션 안에서 여러 행을 넣을 때 모두 같은 시각이 찍혀야 일관성이 맞기 때문입니다. 하지만 "흐르는 시간"을 재야 하는 계산에서는 문제가 됩니다. 시작 기준 시각을 찍은 것과 같은 트랜잭션 안에서 남은 시간을 계산하면 현재 시각이 앞으로 가지 않아 경과 시간이 0이 되고, 결과는 늘 처음 값인 3.00이 됩니다. 서버 함수의 SQL은 저장소에 남아 있지 않아 정확히 어느 경로에서 이 일이 일어났는지까지는 기록으로 확인할 수 없습니다. 확인된 사실은 now()로 잰 남은 시간이 3.00에서 움직이지 않았다는 것, 그리고 아래처럼 바꾸자 해결됐다는 것입니다.
PostgreSQL에는 시각을 돌려주는 함수가 여러 개 있고, 기준이 서로 다릅니다.
| 함수 | 돌려주는 시각 | 같은 트랜잭션 안에서 |
|---|---|---|
now()transaction_timestamp() | 트랜잭션 시작 시각 | 항상 같은 값 |
statement_timestamp() | 현재 SQL 문이 시작된 시각 | 문마다 다름 |
clock_timestamp() | 실제 현재 벽시계 시각 | 호출할 때마다 다름 |
해결
남은 시간을 재는 부분을 실제 현재 시각을 돌려주는 clock_timestamp()로 바꿨습니다. 커밋 메시지는 "남은 시간은 실제 벽시계 clock_timestamp()로 잰다"입니다.
greatest(0, 3 - extract(epoch from (clock_timestamp() - started_at)))
고친 뒤에는 데이터베이스에서 두 사람의 카운트다운이 같은 시각을 가리키는지 직접 확인했습니다. 기록에 남은 측정값은 이렇습니다. 손님 쪽에서 2.99초가 남았을 때, 1.2초 뒤 방장이 받은 남은 시간은 1.79초였고, 다시 3.4초 뒤에는 0이었습니다. 2.99에서 1.2초를 빼면 1.79이니, 두 사람이 서로 다른 순간에 물어도 같은 시작 시각을 향해 세고 있다는 뜻입니다.
함께 고친 것
같은 커밋에서 대결을 기다리는 경험도 바꿨습니다. 방을 만들고 상대를 기다리는 동안 화면이 비어 있던 것을, 바로 연습판이 돌도록 했습니다. 연습 점수는 서버로 보내지 않아 대결 점수와 섞이지 않고, 상대가 들어오면 3초 카운트다운 뒤 두 사람 모두 0점부터 새로 시작합니다. 화면 크기가 414×896일 때만 상대 점수 줄이 피버 게이지와 2px 겹치던 문제도 위치 지정 방식을 바꿔 고쳤습니다.
교훈
- 시간 함수는 이름이 아니라 기준을 확인한다. "지금"을 뜻하는 것 같은 함수도 실제로는 트랜잭션, 문장, 벽시계 중 어느 하나를 기준으로 합니다.
- 기록용 시각과 측정용 시각을 구분한다. 행에 찍는 생성 시각처럼 일관성이 중요한 곳에는
now()가 맞고, 경과 시간을 재는 곳에는clock_timestamp()가 맞습니다. - 여러 기기가 함께 시작해야 하면 기준은 서버가 쥔다. 각 기기가 따로 세면 알림을 받는 시점 차이가 그대로 불공정으로 바뀝니다.
- 고쳤다면 숫자로 확인한다. 두 사람이 받은 남은 시간을 실제로 대조해 봐야 같은 시각을 향하고 있는지 알 수 있습니다.
스택 타워를 만든 전체 과정은 스택 타워 제작기에, 모두가 같은 블록 순서를 받는 원리는 시드 난수 이야기에 정리했습니다.