타임스탬프만으론 안 된다? Cursor bugbot이 알려준 jobId 설계 실수

Nova | 김인후·2025년 6월 22일
2

들어가며: 숫자로 된 ID, 정말 안전할까요?

백엔드 시스템에서 각종 작업(Job), 메시지, 트랜잭션 등을 식별하기 위한 고유 ID 생성은 굉장히 기본적인 기능이지만, 동시에 많은 문제가 숨어 있는 지점이기도 합니다. 특히 부하가 큰 상황에서는 "시간 기반 ID" 하나만으로는 유니크함을 보장하기 어렵지요.

이번 글에서는 실제 서비스 배포 이전 새로운 기능 구현에서 jobId를 밀리초 타임스탬프 기반으로 만들었다가 Cursor AI의 버그 봇한테 혼나고(!)
이를 계기로 더 안전한 방식으로 고친 이야기를 풀어보려 합니다. 실무에서도 유사한 이슈는 충분히 발생할 수 있으니, 참고가 되셨으면 좋겠습니다.

1. 문제 상황 : jobId가 중복되는 이유

저같은 경우에는, 스케줄링 시스템에서 메시지 배치 작업의 식별을 위해 jobId를 다음과 같이 생성하고 있었습니다:

const jobId = DateTime.utc().toMillis();

UTC 기준 밀리초 단위의 타임스탬프를 사용하는 이 방식은 겉보기에 유니크한 값을 보장할 것처럼 보입니다. 그러나 동일한 밀리초 내에 여러 요청이 들어오는 경우, 똑같은 jobId가 생성되는 심각한 문제가 발생할 수 있습니다.

실제 문제 사례 (거의 발생할 뻔한 문제)

풀리퀘를 열고 Cursor Bug Bot으로 코드 리뷰를 받았는데,
(아니 마침 코드래빗 프리티어 끝난 시점에 이녀석들이 시용기간을 주지 뭡니까?! 개꿀)

바로 이 부분이 지적됐습니다:

"타임스탬프 기반 ID는 충돌 가능성이 있어요. 부하가 큰 상황에선 동일한 밀리초 안에 여러 job이 생성될 수 있습니다."

처음엔 "그렇게까지 동시 요청이 많을까...?" 싶었지만, 정말 중요한 포인트를 짚어준 리뷰였기에, 더 안전한 방식으로 바꾸기로 했습니다.

2. 흔히 저지르는 실수: 왜 타임스탬프만 썼을까?

단순한 타임스탬프 기반 ID 생성 방식은 다음과 같은 장점 때문에 널리 사용됩니다:

  • 구현이 간단하고,
  • 시간 순서에 따라 정렬이 가능하며,
  • 외부 라이브러리 없이 숫자형 ID를 쉽게 만들 수 있기 때문이죠.

그치만 이 방식의 근본적인 한계는 다음과 같습니다:

  • 단일 서버 또는 싱글스레드 상황에서는 괜찮지만,
  • 부하가 큰, 혹은 멀티스레드 환경에서는 동시성 문제가 발생하기 쉽습니다.

3. 해결 방법 : 진짜 유니크한 jobId 만들기

이 문제를 해결하기 위해 timestamp + counter + random 조합 방식을 도입했습니다.

개선된 코드 예시

private static jobIdCounter = 0;

private generateUniqueJobId(): number {
  const timestamp = DateTime.utc().toMillis();
  SchedulingService.jobIdCounter = (SchedulingService.jobIdCounter + 1) % 1000;
  const randomComponent = Math.floor(Math.random() * 1000);
  return timestamp * 1000000 + SchedulingService.jobIdCounter * 1000 + randomComponent;
}

개선 포인트 요약

요소설명효과
timestampUTC 기준 밀리초기본적인 시간 순서 보장
counter0~999 순환 카운터동일 밀리초 내 요청 구분
random0~999 랜덤 값충돌 가능성 최소화

4. 다른 ID 생성 방식과 비교

방식장점단점
UUID충돌 거의 없음, 전역 유니크길고 복잡한 문자열, 숫자 ID 필요시 부적합
Snowflake ID시간 정렬 + 유니크, 대규모 분산 환경에 적합구현 복잡, 시스템 시간 오류에 취약
Timestamp + Counter + Random (현 방식)시간 정렬, 숫자 ID, 간단한 구현슬프게도 분산 환경에서는 global counter 관리 필요

5. 이렇게 하면 이런 장점이 있답니다?

  • 충돌 없음 : 동일 밀리초에도 counter와 random이 보완
  • 정렬 가능 : 시간순 정렬 유지
  • 숫자 포맷 유지 : 기존 시스템과 호환성 유지
  • 부하가 클 때 대응 가능 : 1ms 내 최대 1,000건까지 안전 처리 가능

예시 Job ID

  • 1672531200000000123456 → (timestamp: 1672531200000, counter: 123, random: 456)
  • 1672531200000001234567 → 같은 timestamp라도 counter와 random 값으로 구분

6. (잠깐만) 그런데 JavaScript에서 이 방식 써도 괜찮은 걸까?


Cursor 리뷰를 반영해서 개선한 이 방식, 얼핏 보면 완벽해 보입니다. (저도 그런 줄 알았는데 말이죠?) 그런데 한 가지 치명적인 문제가 더 있었으니...

바로 JavaScript의 (그리고 TypeScript의 number 타입의) Number.MAX_SAFE_INTEGER 한계 초과 이슈입니다.

현재 방식은 timestamp * 1_000_000 계산을 기반으로 합니다. 그런데 이때 timestamp는 보통 1.7e12 수준 (밀리초)이기 때문에, 전체 값은 1.7e18 근처가 됩니다. 문제는 이게 JS의 안전한 정수 표현 범위(~9e15)를 야무지게 넘는다는 점입니다.

🤯 왜 이게 문제일까요?

  • TypeScript의 number는 JavaScript와 동일한 Number 타입을 따릅니다. 이 타입은 정밀도를 보장할 수 있는 최대 정수가 약 9조(2^53 - 1)까지예요.
  • 이 범위를 넘는 숫자는 계산이나 비교, 정렬 등의 연산에서 정밀도가 깨지거나 이상한 결과를 만들 수 있습니다.
  • 즉, jobId가 유일하더라도 정렬이 깨지거나, 숫자끼리 비교 시 서로 다른 값인데도 같다고 인식될 수도 있는 거죠.

해결책: 밀리초 → 초 단위로 바꾸자!💡

다행히도 해결책은 단순합니다:

const timestamp = Math.floor(DateTime.utc().toSeconds());

즉, 초 단위로 timestamp를 줄이면:

  • timestamp ≈ 1.7e9
  • 최종 jobId ≈ 1.7e15 수준 → Number.MAX_SAFE_INTEGER 이내!

나머지 구조(counter, random)는 그대로 유지할 수 있어 안정성 + 정렬성 + 유니크함까지 모두 잡을 수 있습니다.


7. 결론 : 절대 (과거의 저처럼ㅎ) 타임스탬프에만 의존하지 마세요

밀리초 단위의 타임스탬프만으로 유니크 ID를 생성하는 것은 부하가 큰 시스템에서 너무 위험해버리는 전략입니다.

복합 요소를 조합하고, 정수 범위도 체크하는 신중한 ID 설계가 더더욱 필요합니다. (그리고 가능하다면... Cursor bugbot 리뷰 한 번 받아보는 것도 추천드려요 😎)

profile
SoftwareEngineer

1개의 댓글

comment-user-thumbnail
2025년 6월 22일

타임스탬프만 가지고 id를 만드는자 죽음을 면치못할것.

답글 달기