본문으로 건너뛰기
개발 머꼬
개발 노트API 설계
hohyeon.dev248

64비트 주문 ID가 화면에서만 끝자리가 바뀌어 있던 이유

  • #API 설계
  • #Engineering Note
  • #JavaScript

문제 발생

주문 ID를 DB의 auto increment에서 시간 기반 64비트 ID로 바꿨습니다. 서버는 Java라 Long 그대로 쓰면 됐고, API 응답도 손대지 않았습니다.

{ "id": 1843920145377763329, "total": 12000 }

배포하고 며칠 뒤 운영팀에서 연락이 왔습니다. 관리자 화면에서 주문을 누르면 가끔 "없는 주문"이라고 나오고, 거의 동시에 들어온 주문 두 건이 목록에 같은 번호로 찍혀 있고 둘 중 뭘 눌러도 같은 화면이 열린다고요. 네트워크 탭에서 응답 원문을 보면 ID는 멀쩡했습니다. 그런데 화면이 보낸 상세 조회 요청은 /orders/1843920145377763300이었습니다. 끝 세 자리가 329에서 300으로 바뀌어 있었습니다. Postman으로 같은 API를 부르면 아무 문제가 없어서, 서버 쪽에서는 한동안 "API는 정상"이라는 말만 오갔습니다.

원인 분석

JavaScript의 숫자는 2⁵³ − 1까지만 정확합니다. MDN의 Number.MAX_SAFE_INTEGER 문서에 따르면 이 값은 9007199254740991이고, 배정밀도 부동소수점은 가수부가 52비트뿐이라 이 범위 안의 정수만 안전하게 나타낼 수 있습니다. 여기서 안전하다는 건 정수를 정확히 나타내고 올바르게 비교할 수 있다는 뜻이라고 적어 둡니다. 문서의 예제도 MAX_SAFE_INTEGER + 1 === MAX_SAFE_INTEGER + 2가 true라는 겁니다.

JSON.parse는 숫자를 읽는 순간 그 숫자로 바꿉니다. 우리 ID는 약 1.8 × 10¹⁸이라 안전 범위보다 200배쯤 큽니다. 이 크기에서는 표현할 수 있는 값이 256 간격으로만 있어서, 연속된 ID 256개 정도가 같은 숫자 하나로 뭉칩니다. 직접 찍어 보면 바로 보입니다.

const orders = JSON.parse(
  '[{"id":1843920145377763329},{"id":1843920145377763330}]'
);
orders.map(o => o.id);           // [1843920145377763300, 1843920145377763300]
orders[0].id === orders[1].id;   // true

ID가 1 차이 나는 두 주문이 같은 값이 됐습니다. 거의 동시에 생긴 주문이 같은 번호로 보인 이유고, 그 값으로 다시 요청하니 없는 주문이 나온 이유입니다.

JSON 표준이 큰 숫자를 금지하지는 않지만, 정확하게 오갈 거라고 보장하지도 않습니다. RFC 8259의 숫자 절은 구현이 숫자의 범위와 정밀도에 제한을 둘 수 있다고 하고, IEEE 754 배정밀도를 쓰는 소프트웨어끼리는 [-(2⁵³)+1, 2⁵³−1] 범위의 정수만 값이 정확히 일치한다고 적어 둡니다. JSON을 더 엄격하게 쓰자는 I-JSON(RFC 7493)은 한 발 더 나갑니다. 보내는 쪽은 절댓값이 9007199254740991보다 큰 정수를 받는 쪽이 정확한 값으로 다룰 거라고 기대할 수 없고, 그런 숫자는 JSON 문자열로 보내기를 권장한다고요. 그리고 그 예로 64비트 정수를 들면서 이유를 JavaScript 숫자의 한계라고 짚습니다.

Java의 Long도, DB도, Postman도 64비트 정수를 정확히 다룹니다. 깨지는 건 JavaScript가 그 JSON을 읽는 순간뿐이라 서버 쪽에서는 아무리 봐도 정상이었던 겁니다.

해결 방안

  1. 64비트 ID는 문자열로 내려 줍니다. I-JSON이 권장하는 방법이고, 실제로 Discord API 문서도 Snowflake ID를 일부 언어의 정수 오버플로를 막으려고 HTTP API에서 항상 문자열로 돌려준다고 적어 두었습니다. Jackson이라면 ID 필드에만 ToStringSerializer를 붙입니다. 문서 설명대로 toString() 결과를 그대로 JSON 값으로 쓰는 직렬화기입니다.
public record OrderResponse(
    @JsonSerialize(using = ToStringSerializer.class) Long id,
    int total
) {}
{ "id": "1843920145377763329", "total": 12000 }
  1. 전역 옵션으로 한 번에 끄는 건 신중하게 합니다. Jackson의 WRITE_NUMBERS_AS_STRINGS는 모든 숫자를 문자열로 쓰는 기능이고, javadoc도 JavaScript의 64비트 정수 한계를 쓰임새로 듭니다. 다만 금액이나 개수까지 전부 문자열이 되니, 프론트에서 그 값을 쓰는 코드가 다 같이 바뀌어야 합니다. 저는 ID 필드에만 붙였습니다.

  2. 프론트에서는 ID를 끝까지 문자열로 둡니다. 타입을 id: string으로 두고 비교도 문자열로 합니다. Number(id)나 parseInt(id, 10)을 거치는 순간 똑같이 1843920145377763300이 됩니다. 크기 비교나 계산이 꼭 필요할 때만 BigInt(id)로 바꿉니다. BigInt를 그대로 JSON.stringify에 넣으면 TypeError: Do not know how to serialize a BigInt가 나니, 보낼 때는 다시 문자열로 돌립니다.

  3. API를 당장 못 바꾸면 reviver의 context.source를 씁니다. MDN은 reviver가 파싱이 끝난 뒤에 불려서 숫자는 이미 정밀도를 잃었을 수 있다고 경고하면서, 세 번째 인자 context의 source로 원래 JSON 텍스트를 꺼낼 수 있다고 안내합니다. fetch의 response.json()은 reviver를 받지 않으니 텍스트로 받아 직접 파싱합니다.

const text = await response.text();
const data = JSON.parse(text, (key, value, context) =>
  key === 'id' ? context.source : value
);

Node 22에서 돌려 보니 '1843920145377763329'가 문자열로 그대로 남았습니다. 반대로 서버에 숫자 그대로 돌려보내야 한다면 같은 제안으로 들어온 JSON.rawJSON('1843920145377763329')를 쓰면 따옴표 없이 정확한 숫자로 직렬화됩니다. MDN은 JSON.rawJSON을 2025년 3월부터 최신 브라우저에서 쓸 수 있는 기능(Baseline 2025)으로 표시하고 있어서, 오래된 브라우저까지 지원해야 하면 MDN의 JSON.parse 문서가 안내하는 core-js 폴리필을 봅니다.

  1. 테스트 데이터에 2⁵³을 넘는 ID를 하나 넣어 둡니다. auto increment 시절의 작은 ID로는 이 문제가 절대 드러나지 않습니다. 이번에도 개발 DB의 ID가 전부 다섯 자리여서 배포 전까지 아무도 몰랐습니다. 9007199254740993 같은 값을 넣고, 화면이 보내는 요청 URL에 그 숫자가 그대로 들어가는지까지 확인합니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.