[파이썬 100강] 60강. uuid로 충돌 걱정 없는 고유 ID 설계하기

[파이썬 100강] 60강. uuid로 충돌 걱정 없는 고유 ID 설계하기

사용자 초대 링크, 주문 번호, 파일 업로드 키, 이벤트 트래킹 키를 만들 때 id = len(rows) + 1 같은 방식은 금방 한계를 드러냅니다. 오늘은 서론 길게 끌지 않고, 바로 uuid를 써서 고유 ID를 안전하게 만들고 저장·노출·검증까지 연결하는 실전 흐름으로 들어가겠습니다.


핵심 개념

  • uuid전역에서 사실상 충돌 가능성이 매우 낮은 식별자를 만들기 위한 표준 포맷입니다.
  • uuid4()는 난수 기반 UUID를 생성하며, 분산 환경(여러 서버/프로세스)에서 특히 유용합니다.
  • UUID는 "생성"만이 아니라 어디에 저장하고, 어떤 형태로 외부에 노출할지(문자열/바이너리) 까지 같이 설계해야 합니다.

정수 증가 ID(auto increment)는 단일 DB에서 단순할 때는 편하지만, 여러 시스템이 동시에 ID를 만들기 시작하면 중앙 조정 없이 일관성을 유지하기 어렵습니다. 반면 UUID는 각 노드가 독립적으로 생성해도 충돌 확률이 극도로 낮아 메시지 큐, 로그 파이프라인, 마이크로서비스 같은 구조에서 다루기 좋습니다. 또 보안 관점에서도 순차 ID보다 예측이 어려워 URL 추측 공격 난이도를 높일 수 있습니다. 다만 문자열 길이가 길고 인덱스 비용이 늘 수 있으므로, "무조건 UUID"가 아니라 용도에 맞춘 선택이 중요합니다. 핵심은 고유성·예측 가능성·성능·가독성 네 축을 함께 보는 것입니다.

기본 사용

예제 1) uuid4로 고유 ID 생성하기

>>> import uuid
>>> uid = uuid.uuid4()
>>> uid
UUID('a6bb7fb4-4bcb-4d11-98a4-b5e7a4f8575a')
>>> str(uid)
'a6bb7fb4-4bcb-4d11-98a4-b5e7a4f8575a'
>>> uid.version
4

해설:

  • uuid.uuid4()는 난수 기반 UUID를 생성합니다.
  • DB/JSON/API로 보낼 때는 보통 str(uid) 형태를 씁니다.
  • version == 4를 확인하면 기대한 방식으로 생성됐는지 빠르게 검증할 수 있습니다.

예제 2) 안전한 리소스 키 만들기

>>> import uuid
>>> def new_order_id(prefix: str = "ord") -> str:
...     return f"{prefix}_{uuid.uuid4().hex}"
...
>>> oid = new_order_id()
>>> oid.startswith("ord_"), len(oid)
(True, 36)
>>> oid
'ord_9adf61f21fdb4b86b9f9534ff1e20b33'

해설:

  • hex를 쓰면 하이픈 없는 32자리 문자열이라 URL/로그에서 다루기 편합니다.
  • 접두사(ord_, usr_, evt_)를 두면 운영 중 로그 검색과 디버깅이 쉬워집니다.
  • "UUID 자체"와 "서비스에서 쓰는 키 포맷"을 분리하면 나중에 정책 변경이 쉬워집니다.

예제 3) 문자열 UUID 파싱/검증

>>> import uuid
>>> def parse_uuid(value: str) -> uuid.UUID | None:
...     try:
...         return uuid.UUID(value)
...     except ValueError:
...         return None
...
>>> good = "a6bb7fb4-4bcb-4d11-98a4-b5e7a4f8575a"
>>> bad = "not-a-uuid"
>>> parse_uuid(good) is not None, parse_uuid(bad) is None
(True, True)

해설:

  • 외부 입력은 무조건 파싱 단계에서 거릅니다.
  • API 핸들러 초반에 UUID 형식 검증을 하면 불필요한 DB 조회를 줄일 수 있습니다.
  • 실패를 None으로 통일하면 상위 로직에서 400/404 처리 분기가 단순해집니다.

예제 4) 중복 방지 관점에서 UUID 집합 테스트

>>> import uuid
>>> n = 50_000
>>> ids = {uuid.uuid4().hex for _ in range(n)}
>>> len(ids) == n
True

해설:

  • 교육용으로 "충돌이 현실적으로 거의 없다"는 감각을 익히는 예제입니다.
  • 물론 이 테스트가 절대 안전성을 증명하진 않지만, 난수 기반 UUID의 실무 적합성을 이해하는 데 도움이 됩니다.
  • 실제 시스템 안전성은 고유 인덱스(UNIQUE) 제약으로 최종 보장해야 합니다.

자주 하는 실수

실수 1) uuid.uuid4 함수 객체 자체를 저장

>>> import uuid
>>> wrong = {"id": uuid.uuid4}   # 괄호 누락
>>> callable(wrong["id"])
True
>>> str(wrong["id"]).startswith("<function")
True

원인:

  • uuid.uuid4uuid.uuid4()의 차이를 놓치면 함수 참조가 저장됩니다.

해결:

>>> correct = {"id": str(uuid.uuid4())}
>>> len(correct["id"]), "-" in correct["id"]
(36, True)

실수 2) 입력 문자열을 검증 없이 신뢰

  • 증상: 잘못된 ID가 DB 쿼리 단계까지 들어가 불필요한 에러 로그가 쌓입니다.
  • 원인: "어차피 프론트가 보내는 값"이라고 가정하고 서버 검증을 생략.
  • 해결: 요청 초반 uuid.UUID(value) 파싱으로 형식을 먼저 검증하고, 실패 시 즉시 400을 반환합니다.

실수 3) UUID를 정렬 키로 오해

>>> import uuid
>>> xs = [uuid.uuid4().hex for _ in range(3)]
>>> xs
['b0b8ce97aa94493dbe359564f890d611', '0d841eeeb7db4400932d996bd8c6e25e', 'f7293a9be0f44095a8de38dcf4bdcc38']
>>> sorted(xs) == xs
False

원인:

  • uuid4는 시간순 정렬용이 아니라 고유성 중심입니다.

해결:

>>> # 생성 시각 정렬이 필요하면 created_at 컬럼을 별도로 둡니다.
>>> # 정렬 = created_at, 식별 = uuid 라는 역할 분리를 유지하세요.

실수 4) 문자열 길이만 검사하고 포맷은 무시

  • 증상: 길이 36만 맞춘 엉터리 값이 유입되어 추후 로직에서 실패합니다.
  • 원인: 단순 len(value) == 36 검사를 "유효성 검증"으로 착각.
  • 해결: 길이 체크는 보조로만 쓰고, 최종 검증은 uuid.UUID(value) 파싱으로 처리합니다.

실무 패턴

  • 입력 검증 규칙: API 레이어에서 UUID 파싱 검증을 먼저 수행하고, 실패 요청은 DB 접근 전에 차단합니다.
  • 로그/예외 처리 규칙: ID 원문은 남기되 개인정보와 직접 결합하지 말고, request_id/entity_id를 분리해 추적성을 유지합니다.
  • 재사용 함수/구조화 팁: new_id(), parse_id(), is_valid_id() 같은 유틸로 팀 전역 규칙을 통일하면 버그가 크게 줄어듭니다.
  • 성능/메모리 체크 포인트: DB에서 UUID를 문자열로 저장할지, 네이티브 UUID 타입/바이너리로 저장할지 초기 설계 때 결정해야 인덱스 비용을 줄일 수 있습니다.

운영에서 자주 쓰는 패턴은 "외부 노출 ID(UUID)"와 "내부 조인용 정수 PK"를 분리하는 방식입니다. 예를 들어 내부 테이블 조인은 정수 PK로 빠르게 처리하고, API 응답과 URL에는 UUID를 노출하면 보안성과 운영 성능을 동시에 챙길 수 있습니다. 또 서비스 간 이벤트 전달에서는 이벤트마다 event_id(UUID)를 붙여 중복 수신을 멱등 처리하는 것이 중요합니다. 메시지 재전송이 생겨도 같은 event_id면 한 번만 반영하게 설계하면 장애 복구 시 데이터 꼬임을 크게 줄일 수 있습니다.

오늘의 결론

한 줄 요약: UUID는 분산 환경에서 충돌 위험을 크게 줄이는 실전 식별자이며, 생성·검증·저장 정책을 함께 설계해야 진짜로 안전합니다.

기억할 것:

  • uuid4()는 고유성 중심, 정렬/시계열 의미는 created_at으로 분리합니다.
  • 외부 입력 UUID는 길이 검사가 아니라 파싱 검증으로 처리합니다.
  • DB 레벨 UNIQUE 제약을 마지막 안전장치로 반드시 둡니다.

연습문제

  1. new_user_public_id() 함수를 작성해 usr_ 접두사 + UUID hex 형식 문자열을 반환하세요.
  2. validate_public_id(value) 함수를 작성해 usr_ 접두사 여부와 UUID 형식 유효성을 함께 검증하세요.
  3. 주문 생성 API를 가정하고, idempotency_key(UUID)를 이용해 중복 주문을 막는 간단한 의사코드를 작성하세요.

이전 강의 정답

  1. issue_reset_token(user_id) 구현
>>> import secrets, hashlib, time
>>> def issue_reset_token(user_id: int, ttl_sec: int = 600):
...     raw_token = secrets.token_urlsafe(32)
...     token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
...     expires_at = int(time.time()) + ttl_sec
...     return raw_token, token_hash, expires_at
...
>>> raw, h, exp = issue_reset_token(101)
>>> isinstance(raw, str), len(h) == 64, exp > 0
(True, True, True)
  1. verify_token(raw_token, token_hash, expires_at, now) 구현
>>> import hashlib, secrets, time
>>> def verify_token(raw_token: str, token_hash: str, expires_at: int, now: int) -> bool:
...     if now > expires_at:
...         return False
...     candidate = hashlib.sha256(raw_token.encode()).hexdigest()
...     return secrets.compare_digest(candidate, token_hash)
...
>>> now = int(time.time())
>>> raw, h, exp = "abc", hashlib.sha256(b"abc").hexdigest(), now + 60
>>> verify_token(raw, h, exp, now), verify_token("zzz", h, exp, now)
(True, False)
  1. consume_token(record, raw_token, now) 설계
>>> import hashlib, secrets, time
>>> def consume_token(record: dict, raw_token: str, now: int) -> bool:
...     if record.get("used"):
...         return False
...     if now > record["expires_at"]:
...         return False
...     digest = hashlib.sha256(raw_token.encode()).hexdigest()
...     if not secrets.compare_digest(digest, record["token_hash"]):
...         return False
...     record["used"] = True
...     return True
...
>>> now = int(time.time())
>>> rec = {"token_hash": hashlib.sha256(b"abc").hexdigest(), "expires_at": now+120, "used": False}
>>> consume_token(rec, "abc", now), consume_token(rec, "abc", now)
(True, False)

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: uuid, time, hashlib, secrets
  • 재현 절차:
    1. 기본 사용 예제 1~2로 UUID 생성과 서비스 키 포맷을 확인합니다.
    2. 예제 3으로 외부 입력 검증 함수를 만들어 잘못된 값을 차단합니다.
    3. 예제 4로 대량 생성 시 중복이 관찰되지 않는지 감각적으로 확인합니다.
    4. 실수 섹션을 따라가며 함수 객체 저장/검증 생략/정렬 오해를 각각 재현해 봅니다.
  • 검증 포인트:
    • UUID 생성 시 uuid4() 호출 괄호 누락이 없는지
    • 입력 검증이 단순 길이 체크가 아니라 파싱 기반인지
    • 저장소에 UNIQUE 제약과 멱등 처리 전략이 준비되어 있는지