우동체 제작기 #2 — 좋아요 1개 눌렀는데 11이 됐다 (postgres.js와 bigint)
2026-11-02 11:10:00
우동체에서 게시글에 좋아요를 누르면 숫자가 아주 잠깐 11로 보였다가 2로 돌아왔다. 좋아요가 1개였던 글에 하나를 더 눌렀을 뿐이다. 0.3초쯤 보였다 사라지니 사용자는 "어? 방금 뭐였지" 하고 넘어가는 종류의 버그였다.
증상
- 좋아요가
1인 글에서 하트를 누르면 순간11이 보인다. - 서버 응답이 오고 캐시가 다시 불러와지면 정상값
2로 바뀐다. - 관장 대시보드의 "이번 달 매출"은 천 단위 콤마가 안 찍혔다.
150000처럼 나왔다.
두 증상이 같은 원인일 거라고는 처음엔 생각 못 했다.
원인: COUNT()는 bigint고, postgres.js는 bigint를 문자열로 준다
Postgres에서 COUNT()의 반환 타입은 bigint(int8)다. SUM(int4)도 오버플로를 막으려고 bigint로 승격된다.
그런데 우리가 쓰는 드라이버 postgres.js는 기본 숫자 파서를 OID 21, 23, 26, 700, 701(int2, int4, oid, float4, float8)에만 붙인다. int8(OID 20)은 문자열로 돌려준다. JavaScript number는 2^53까지만 정확해서 큰 정수를 조용히 깨뜨릴 수 있으니, 안전하게 문자열로 넘기는 것이다. 설계 자체는 맞다.
문제는 Drizzle 쪽 선언이었다.
likesCount: sql<number>`COUNT(DISTINCT ${postLikes.id})`,
sql<number>는 타입스크립트에게 하는 약속일 뿐, 런타임 변환은 하지 않는다. 그래서 타입은 number인데 실제 값은 "1"이었다. 컴파일러가 잡을 방법이 없다.
왜 하필 11인가
좋아요 버튼은 옵티미스틱 업데이트로 화면을 먼저 바꾼다.
likesCount: Math.max(0, old.likesCount + 1)
old.likesCount가 문자열 "1"이면 "1" + 1은 문자열 연결이라 "11"이 된다. Math.max(0, "11")은 이걸 숫자로 바꿔 11을 돌려준다. 그리고 서버 재조회가 끝나면 다시 정상값으로 덮인다. 그래서 "잠깐 11"이었다.
매출 콤마도 같은 이유다. 숫자의 toLocaleString()은 150,000을 주지만, 문자열의 toLocaleString()은 문자열을 그대로 돌려준다.
고친 방법
-
클라이언트로 나가는 COUNT에는
::int캐스팅. 개수는 int4 범위(약 21억)를 넘을 일이 없다.likesCount: sql<number>`COUNT(DISTINCT ${postLikes.id})::int`,FILTER절이 붙은 집계는(COUNT(...) FILTER (WHERE ...))::int처럼 통째로 괄호로 감싼 뒤 캐스팅했다. 어디에 붙는 캐스팅인지 읽는 사람이 헷갈리지 않게. -
금액 SUM은 SQL에서 캐스팅하지 않고
Number()로. 합계가 int4를 넘으면 Postgres가 캐스팅에서 에러를 던진다. 금액은 JS 쪽에서 변환하는 편이 안전하다. -
옵티미스틱 증감은 방어적으로. 서버가 언젠가 또 문자열을 주더라도 화면이 깨지지 않게 했다.
likesCount: Number(old.likesCount ?? 0) + 1
HAVING이나 ORDER BY에서만 쓰이고 클라이언트로 안 나가는 COUNT는 건드리지 않았다. 문제는 값이 JS로 넘어오는 순간에만 생긴다.
남긴 교훈
sql<T>의 제네릭은 캐스트가 아니라 주석이다. 원시 SQL을 쓸 때는 DB 타입과 드라이버 파서를 같이 확인해야 한다.- 타입이 맞는데 값이 이상하면 런타임에
typeof부터 찍어보자. "1" + 1 같은 버그는 거기서 바로 드러난다. - 대안으로 드라이버에 int8 파서를 전역으로 등록하는 방법도 있지만, 언젠가 진짜 큰 정수(ID 시퀀스 등)를 다룰 때 정밀도를 잃을 수 있어서 쓰지 않았다.
← #1 복싱하는 개발자가 동네 복싱 커뮤니티를 만든 1년 · #3 서울 서버인데 요청이 태평양을 두 번 건넜다 →