PostgreSQL에서 UUID 대 bigint: 확장 가능한 ID 선택하기
PostgreSQL에서 UUID와 bigint를 비교: 인덱스 크기, 정렬 순서, 샤딩 준비성, 그리고 API·웹·모바일 앱을 통해 ID가 어떻게 흐르는지 살펴봅니다.

ID 선택이 겉보기보다 중요한 이유
PostgreSQL 테이블의 모든 행은 나중에 다시 찾을 수 있는 안정적인 식별자가 필요합니다. ID는 레코드를 고유하게 식별하고 보통 기본 키가 되며 관계의 접착제가 됩니다. 다른 테이블이 외래 키로 저장하고, 쿼리가 조인에 쓰며, 앱은 "그 고객", "그 송장", "그 지원 티켓"을 가리키는 핸들로 ID를 주고받습니다.
ID가 모든 곳에 등장하기 때문에 선택은 단순한 DB 세부사항을 넘습니다. 나중에 인덱스 크기, 쓰기 패턴, 쿼리 속도, 캐시 적중률, 분석, 데이터 수입, 디버깅 같은 제품 작업에 영향을 줍니다. 또한 URL과 API에 무엇을 노출할지, 모바일 앱이 데이터를 안전하게 저장하고 동기화하기 쉬운지에도 영향을 미칩니다.
대부분 팀은 PostgreSQL에서 UUID 대 bigint를 저울질합니다. 간단히 말하면 다음 중 하나를 고르는 것입니다:
- bigint: 64비트 숫자, 보통 시퀀스(1, 2, 3...)로 생성됩니다.
- UUID: 128비트 식별자, 무작위처럼 보이거나 시간 순서를 따르는 방식으로 생성됩니다.
어떤 옵션도 모든 상황에서 항상 승리하지 않습니다. bigint는 인덱스와 정렬에서 компакт하고 유리한 경향이 있습니다. UUID는 여러 시스템 전반에서 전역적으로 고유한 ID가 필요하거나 공개 ID를 덜 추측 가능하게 하거나 데이터가 여러 장소에서 생성될 것으로 예상될 때 적합합니다.
실용적인 규칙: 오늘 어떻게 저장되는지만 보지 말고 데이터가 어떻게 생성되고 공유될지를 기준으로 결정하세요.
bigint와 UUID 기초를 쉽게 설명하면
사람들이 PostgreSQL에서 UUID 대 bigint를 비교할 때는 두 가지 방식으로 행에 이름을 붙이는 것 중 하나를 고르는 겁니다: 카운터 같은 작은 숫자 또는 더 긴 전역 고유값.
bigint ID는 64비트 정수입니다. PostgreSQL에서는 보통 identity 컬럼(또는 오래된 serial)으로 생성합니다. DB는 내부적으로 시퀀스를 유지하고 행을 삽입할 때마다 다음 숫자를 줍니다. 그래서 ID는 대개 1, 2, 3, 4...처럼 증가합니다. 단순하고 읽기 쉬우며 도구와 리포트에 친숙합니다.
UUID(범용 고유 식별자)는 128비트입니다. 보통 하이픈을 포함해 36자 형태로 보입니다: 550e8400-e29b-41d4-a716-446655440000. 일반적인 유형은:
- v4: 무작위 UUID. 어디서나 생성하기 쉽지만 생성 순서로 정렬되지 않습니다.
- v7: 시간 순서 UUID. 여전히 고유하지만 대체로 시간이 지나며 증가하도록 설계되었습니다.
저장은 첫 번째 실용적 차이점 중 하나입니다: bigint는 8바이트, UUID는 16바이트를 사용합니다. 이 크기 차이는 인덱스에 반영되어 캐시 적중률에 영향을 줄 수 있습니다(데이터베이스가 메모리에 적은 수의 인덱스 항목을 담을 수 있음).
또한 ID가 DB 외부 어디에 나타나는지도 생각하세요. bigint ID는 URL에서 짧고 로그나 지원 티켓에서 읽기 쉽습니다. UUID는 길고 입력하기 번거롭지만 추측하기 어렵고 클라이언트에서 안전하게 생성할 수 있습니다.
인덱스 크기와 테이블 부풀기: 무엇이 바뀌나
bigint와 UUID의 가장 큰 실용적 차이는 크기입니다. bigint는 8바이트, UUID는 16바이트입니다. 이는 인덱스가 ID를 여러 번 반복해서 저장한다는 점을 생각하면 사소해 보이지 않습니다.
기본 키 인덱스는 빠르게 유지되어야 성능이 느껴집니다. 인덱스가 작을수록 shared buffers와 CPU 캐시에 더 많이 들어가 조회와 조인이 디스크 읽기를 덜 하게 됩니다. UUID 기본 키를 쓰면 같은 행 수에서 인덱스가 눈에 띄게 커지는 경우가 많습니다.
곱셈 효과는 보조 인덱스에서 더 두드러집니다. PostgreSQL B-트리 인덱스에서 모든 보조 인덱스 항목은 기본 키 값도 저장합니다(그래서 DB가 행을 찾을 수 있음). 따라서 넓은 ID는 기본 키 인덱스뿐 아니라 모든 다른 인덱스도 팽창시킵니다. 세 개의 보조 인덱스가 있으면 UUID로 인한 추가 8바이트가 사실상 네 군데에 나타납니다.
외래 키와 조인 테이블도 영향을 받습니다. ID를 참조하는 모든 테이블은 자체 행과 인덱스에 해당 값을 저장합니다. 다대다 조인 테이블은 주로 두 개의 외래 키와 약간의 오버헤드로 구성되므로 키 폭이 두 배가 되면 공간 차이가 크게 납니다.
실무적으로:
- UUID는 보통 기본 및 보조 인덱스를 더 크게 만듭니다. 차이는 인덱스가 많아질수록 확대됩니다.
- 큰 인덱스는 메모리 압박을 증가시키고 부하 시 페이지 읽기가 늘어납니다.
- ID를 참조하는 테이블이 많을수록 크기 차이는 더 중요해집니다.
사용자 ID가 users, orders, order_items, audit_log에 등장하면 동일한 값이 모든 테이블에 저장되고 인덱싱됩니다. 따라서 넓은 ID를 선택하는 것은 저장 공간에 대한 결정이기도 합니다.
정렬 순서와 쓰기 패턴: 순차 vs 무작위 ID
대부분 PostgreSQL 기본 키는 B-트리 인덱스에 놓입니다. B-트리는 새 행이 인덱스의 끝 근처에 위치할 때 가장 효율적입니다. 데이터베이스가 거의 연속으로 추가할 수 있어 재배열이 적기 때문입니다.
순차적 ID: 예측 가능하고 저장에 친화적
bigint identity나 시퀀스를 쓰면 새 ID는 시간이 지남에 따라 증가합니다. 삽입은 보통 인덱스의 오른쪽 끝에서 발생하므로 페이지가 꽉 차 있고 캐시가 따뜻하게 유지되며 PostgreSQL이 추가 작업을 덜 하게 됩니다.
이는 ORDER BY id를 전혀 사용하지 않더라도 중요합니다. 쓰기 경로는 여전히 각 새 키를 정렬된 순서로 인덱스에 배치해야 합니다.
무작위 UUID: 더 많은 분산과 교란
무작위 UUID(일반적인 v4)는 삽입을 인덱스 전체에 분산시킵니다. 이는 페이지 분할 가능성을 높여 PostgreSQL이 새 인덱스 페이지를 할당하고 항목을 이동시켜야 할 일이 늘어납니다. 결과적으로 더 많은 쓰기 증폭이 발생합니다: 더 많은 인덱스 바이트가 기록되고 WAL이 증가하며 나중에(autovacuum과 부풀기 관리) 더 많은 백그라운드 작업이 발생합니다.
시간 순서 UUID는 이야기를 바꿉니다. 시간이 지남에 따라 대체로 증가하는 UUID(v7 스타일 등)는 지역성(locality)을 많이 회복시켜 주면서도 API에서는 여전히 UUID처럼 보입니다.
이 차이는 높은 삽입률, 메모리에 들어가지 않는 큰 테이블, 다수의 보조 인덱스가 있을 때 가장 크게 느껴집니다. 페이지 분할로 인한 쓰기 지연에 민감하다면 핫한 쓰기 테이블에 완전 무작위 ID는 피하세요.
예: 모바일 앱 로그를 하루 종일 받는 바쁜 이벤트 테이블은 완전 무작위 UUID보다 순차 키나 시간 순서 UUID로 운영하는 편이 대체로 더 부드럽게 동작합니다.
체감 가능한 성능 영향
실제 성능 저하는 대개 “UUID가 느리다” 또는 “bigint가 빠르다” 같은 단순한 결론이 아닙니다. 쿼리에 답하기 위해 DB가 건드려야 하는 양과 성격이 문제입니다.
쿼리 계획은 주로 필터에 인덱스 스캔을 사용할 수 있는지, 키로 빠른 조인을 하는지, 테이블이 물리적으로 정렬되어 있는지(또는 충분히 가까운지)에 따라 달라집니다. bigint 기본 키를 쓰면 새 행이 대체로 증가 순으로 들어와 기본 키 인덱스가 조밀하고 지역성 친화적으로 유지됩니다. 반면 무작위 UUID는 인덱스 전반에 삽입을 분산시켜 페이지 분할과 디스크 상의 혼란을 초래할 수 있습니다.
읽기 성능 저하는 많은 팀이 먼저 느끼는 부분입니다. 키가 커지면 인덱스가 커지고, 인덱스가 크면 RAM에 유용한 페이지가 적게 들어가서 캐시 적중률이 떨어지고 IO가 늘어납니다. 특히 조인이 많은 화면(예: "고객 정보와 함께 주문 목록" 등)에서 영향이 큽니다. 작업 집합이 메모리에 맞지 않으면 UUID 중심 스키마가 더 빨리 한계를 드러낼 수 있습니다.
쓰기에도 변화가 옵니다. 무작위 UUID 삽입은 인덱스 교란을 늘려 autovacuum에 부담을 주고 바쁜 시간대의 지연(p95 스파이크)으로 나타날 수 있습니다.
UUID vs bigint를 벤치마크할 때는 공정하게 하세요: 같은 스키마, 같은 인덱스, 같은 fillfactor로, RAM을 초과하는 충분한 행 수(10k가 아님)로 테스트하세요. p95 지연과 IO를 측정하고 웜/콜드 캐시 모두를 테스트하세요.
AppMaster로 PostgreSQL 기반 앱을 만들면 목록 페이지가 느려지거나 DB 부하가 커지는 현상을 CPU 문제로 보기 훨씬 전에 발견하는 경우가 많습니다.
공개 시스템에서의 보안성과 사용성
ID가 데이터베이스를 떠나 URL, API 응답, 지원 티켓, 모바일 화면에 표시되면 선택이 안전성과 일상적 사용성 모두에 영향을 미칩니다.
bigint ID는 사람에게 친숙합니다. 짧고 전화로 읽기 쉬우며 지원팀이 "실패한 주문이 9,200,000 근처에 집중되어 있다" 같은 패턴을 빠르게 파악할 수 있어 디버깅이 빨라집니다. UUID는 더 복사/붙여넣기 위주가 되지만 추측하기 어렵고 공개 API에서 안전하게 보일 수 있습니다.
다만 "추측 불가능"이 곧 "안전"이라는 함정을 경계하세요. 권한 검사가 약하면 bigint ID는 빠르게 남용될 수 있지만, UUID조차 공유 링크나 유출된 로그에서 탈취될 수 있습니다. 보안은 ID를 숨기는 것에서 오는 것이 아니라 권한 점검에서 옵니다.
실용적인 접근:
- 모든 읽기/쓰기에서 소유권 또는 역할 검사를 강제하세요.
- ID를 공개 API에 노출한다면 UUID나 별도의 공개 토큰을 사용하세요.
- 사람 친화적 참조가 필요하면 내부적으로 bigint를 유지하세요.
- ID 자체에 민감한 의미(예: 사용자 유형)를 담지 마세요.
예: 고객 포털에서 송장 ID를 표시한다고 합시다. 송장이 bigint이고 API가 단지 "송장이 존재한다"만 체크하면 누군가 숫자를 증가시키며 다른 사람의 송장을 다운로드할 수 있습니다. 먼저 권한 체크를 고치고, 그다음 공개 송장 ID를 UUID로 바꿀지 결정하세요.
AppMaster 같은 플랫폼에서는 ID가 생성된 API와 모바일 앱을 통해 흐르므로 안정적 권한 검사와 클라이언트가 잘 처리할 수 있는 ID 형식이 가장 안전한 기본값입니다.
ID가 API와 모바일 앱을 통해 흐르는 방식
DB에서 선택한 타입은 데이터베이스에만 머무르지 않습니다. URL, JSON 페이로드, 클라이언트 저장소, 로그, 분석 등 모든 경계로 새어 나갑니다.
나중에 ID 타입을 변경하면 대부분의 경우 단순한 마이그레이션이 아닙니다. 외래 키는 메인 테이블뿐 아니라 모든 곳에서 바뀌어야 합니다. ORM과 코드 생성기는 모델을 갱신하겠지만 통합 지점은 여전히 이전 형식을 기대할 수 있습니다. GET /users/123 같은 단순한 엔드포인트도 ID가 36자 UUID로 바뀌면 복잡해집니다. 캐시, 메시지 큐, ID를 정수로 저장한 곳도 업데이트해야 합니다.
API에서는 표현과 검증이 중요합니다. bigint는 숫자로 전송되지만 일부 시스템(특히 일부 언어)은 아주 큰 값을 부동소수점으로 파싱해 정밀도 문제가 생길 수 있습니다. UUID는 문자열로 전송되어 파싱이 안전하지만 "거의 UUID" 같은 잘못된 값이 로그와 DB에 들어가지 않도록 엄격한 검증이 필요합니다.
모바일에서는 ID가 지속적으로 직렬화되고 저장됩니다: JSON 응답, 로컬 SQLite 테이블, 네트워크 복귀 시까지 동작을 저장하는 오프라인 큐 등. 숫자 ID는 더 작지만 문자열 UUID는 불투명 토큰으로 다루기 쉽습니다. 실제 문제는 불일치입니다: 한 레이어는 정수로, 다른 레이어는 텍스트로 저장하면 비교나 조인이 취약해집니다.
몇 가지 규칙:
- API의 정규 표현 하나를 선택(종종 문자열)하고 지키세요.
- 경계에서 ID를 검증하고 명확한 400 오류를 반환하세요.
- 로컬 캐시와 오프라인 큐에서도 같은 표현을 저장하세요.
- 서비스 전반에서 일관된 필드 이름과 형식으로 로그를 남기세요.
AppMaster처럼 생성된 스택으로 웹과 모바일 클라이언트를 만들면 안정된 ID 계약이 더 중요합니다. 그 계약은 모든 생성된 모델과 요청의 일부가 됩니다.
샤딩 준비성과 분산 시스템
"샤딩 준비"는 주로 여러 장소에서 ID를 생성해도 고유성이 깨지지 않고, 나중에 데이터를 노드 간에 옮겨도 모든 외래 키를 다시 쓰지 않아도 되는 상태를 의미합니다.
UUID는 각 노드가 중앙 시퀀스를 묻지 않고도 고유한 ID를 생성할 수 있어 다중 리전·다중 라이터 환경에서 인기입니다. 이는 조정을 줄이고 서로 다른 리전에서 쓰기를 허용한 뒤 데이터를 병합하기 쉽게 만듭니다.
bigint도 작동할 수 있지만 계획이 필요합니다. 일반적인 옵션은 샤드별 숫자 범위 할당(샤드1: 11B, 샤드2: 1B2B), 샤드 접두사를 포함한 별도 시퀀스, 또는 Snowflake 스타일 ID(시간 기반 비트 + 머신/샤드 비트) 사용입니다. 이런 방식은 UUID보다 작은 인덱스를 유지하고 어느 정도 정렬을 보존할 수 있지만 운영 규칙을 계속 지켜야 합니다.
일상에서 중요한 트레이드오프:
- 조정 필요성: UUID는 거의 필요 없음; bigint는 범위 계획이나 생성 서비스가 필요할 수 있음.
- 충돌 가능성: UUID 충돌은 극히 드물다; bigint는 할당 규칙이 겹치지 않아야 안전함.
- 정렬성: 많은 bigint 방식은 시간 순서에 가깝게 만들 수 있음; UUID는 시간 순서 변형을 쓰지 않으면 무작위임.
- 복잡성: 샤드된 bigint는 팀의 규율이 있을 때만 단순함을 유지함.
대부분 팀에게 "샤딩 준비"란 실제로는 "마이그레이션 준비"입니다. 지금 단일 DB라면 현재 작업을 쉽게 해주는 ID를 선택하세요. 이미 여러 라이터(예: AppMaster로 생성된 API와 오프라인 모바일)를 구축 중이라면 서비스 전반에서 ID가 어떻게 생성되고 검증될지 초기에 결정하세요.
단계별: 올바른 ID 전략 선택하기
먼저 애플리케이션의 실제 형태를 규정하세요. 단일 리전의 단일 PostgreSQL DB인지, 멀티 테넌트인지, 나중에 리전에 따라 분리될 수 있는지, 또는 오프라인에서 생성 후 동기화해야 하는 모바일 앱인지에 따라 요구가 다릅니다.
다음으로 ID가 어디에 나타날지 솔직히 평가하세요. 식별자가 백엔드 내부(작업, 내부 도구, 관리자 패널)에만 머무르면 단순함이 이깁니다. 식별자가 URL, 고객과 공유되는 로그, 지원 티켓, 모바일 딥 링크에 나타나면 예측 가능성과 개인정보 보호가 더 중요해집니다.
정렬은 사후 고려가 아닌 결정 요소로 삼으세요. "최신 항목 우선" 피드, 안정적 페이지네이션, 쉽게 훑어볼 수 있는 감사 로그가 필요하면 순차 ID(또는 시간 순서 ID)를 사용하세요. 정렬이 기본 키와 무관하면 PK 선택을 분리하고 타임스탬프로 정렬할 수도 있습니다.
실용적 의사결정 흐름:
- 아키텍처 분류(단일 DB, 멀티 테넌트, 다중 리전, 오프라인 우선) 및 여러 소스에서 데이터를 병합할 가능성 판단.
- ID가 공개 식별자인지 내부 전용인지 결정.
- 정렬과 페이지네이션 요구 확인. 삽입 순서가 필요하면 완전 무작위 ID는 피하세요.
- UUID를 택하면 버전을 목적에 맞게 고르세요: 무작위(v4)는 예측 불가능, 시간 순서형은 인덱스 지역성에 유리.
- 규칙을 초기에 고정하세요: 표준 텍스트 형식, 대소문자 규칙, 검증, API가 ID를 반환하고 받는 방식.
예: 모바일 앱이 오프라인에서 "임시 주문"을 생성하고 나중에 동기화해야 한다면 UUID를 사용하면 디바이스에서 서버를 거치지 않고도 안전하게 ID를 생성할 수 있어 편합니다. AppMaster 같은 도구에서는 동일한 ID 형식이 DB에서 API, 웹, 네이티브 앱으로 특별 처리 없이 흐를 수 있어 편리합니다.
흔한 실수와 피해야 할 함정
대부분의 ID 논쟁은 한 가지 이유로 타입을 고르고 나중에 부작용에 깜짝 놀라면서 잘못됩니다.
흔한 실수 중 하나는 핫 쓰기 테이블에 완전 무작위 UUID를 쓰고 삽입이 불규칙해져 왜 삽입이 스파이크를 보이는지 의아해하는 경우입니다. 무작위 값은 인덱스 전체에 삽입을 분산시켜 페이지 분할과 DB 작업을 늘립니다. 쓰기량이 많은 테이블이라면 삽입 지역성을 고려하세요.
또 다른 문제는 서비스와 클라이언트 간에 ID 타입을 섞는 것입니다. 예: 한 서비스는 bigint, 다른 서비스는 UUID를 쓰면 API가 숫자와 문자열 ID를 모두 다루게 됩니다. 이로 인해 JSON 파서의 정밀도 손실, 모바일에서 화면마다 ID를 숫자/문자열로 다르게 취급하는 문제, 캐시 키 불일치 같은 미묘한 버그가 생깁니다.
세 번째 함정은 "추측 불가능한 ID"를 곧바로 보안으로 보는 것입니다. UUID를 써도 권한 검사를 반드시 해야 합니다.
마지막으로 팀이 계획 없이 늦게 ID 타입을 바꾸는 경우가 있습니다. 가장 힘든 부분은 기본 키 자체가 아니라 그에 붙은 모든 것들입니다: 외래 키, 조인 테이블, URL, 분석 이벤트, 모바일 딥 링크, 클라이언트에 저장된 상태 등.
피하는 법:
- 공개 API에는 하나의 ID 타입을 정하고 지키세요.
- 클라이언트에서는 ID를 불투명 문자열로 다루어 숫자 경계 이슈를 피하세요.
- ID 무작위성을 접근 제어로 사용하지 마세요.
- 마이그레이션이 필요하면 API 버전 관리를 하고 장기 지원 계획을 세우세요.
AppMaster 같은 코드 생성 플랫폼을 쓰면 일관성이 더 중요합니다. 같은 ID 타입이 DB 스키마에서 생성된 백엔드, 웹, 네이티브 앱까지 흐르기 때문입니다.
결정 전에 확인할 빠른 체크리스트
막혀 있다면 이론부터 시작하지 말고 1년 후 제품이 어떻게 보일지, ID가 몇 곳을 거칠지부터 시작하세요.
자문:
- 12~24개월 후 가장 큰 테이블은 얼마나 커질 것인가? 오랫동안 기록을 보관할 건가?
- 생성 시간 순으로 대략 정렬되는 ID가 필요해 페이지네이션과 디버깅이 쉬운가?
- 여러 시스템이 동시에(오프라인 모바일 포함) 레코드를 생성할 가능성이 있는가?
- ID가 URL, 지원 티켓, 내보내기, 고객과 공유되는 스크린샷에 나타나는가?
- 모든 클라이언트(웹, iOS, Android, 스크립트)가 같은 방식으로 ID를 처리하고 검증하고 저장할 수 있는가?
답을 얻었다면 배관도 점검하세요. bigint를 쓰면 모든 환경(특히 로컬 개발과 임포트)에 대한 ID 생성 계획이 있어야 합니다. UUID를 쓰면 API 계약과 클라이언트 모델이 문자열 ID를 일관되게 처리하도록 하고 팀이 긴 UUID를 읽고 비교하는 데 익숙한지 확인하세요.
간단한 현실 테스트: 모바일 앱이 오프라인에서 주문을 생성하고 나중에 동기화해야 하면 UUID가 조정 작업을 줄이는 경우가 많습니다. 대부분 온라인이고 작고 컴팩트한 인덱스가 우선이면 bigint가 더 쉽습니다.
AppMaster로 앱을 만든다면 초기에 결정하세요. ID 규약은 PostgreSQL 모델, 생성된 API, 웹 및 네이티브 모바일 앱 전체에 흐르기 때문입니다.
현실적인 예시 시나리오
작은 회사가 내부 운영 도구, 고객 포털, 현장 직원용 모바일 앱을 운영합니다. 세 가지 모두 하나의 PostgreSQL DB를 같은 API로 접근합니다. 티켓, 사진, 상태 업데이트, 송장 같은 새 레코드가 하루 종일 생성됩니다.
bigint ID를 쓰면 API 페이로드가 간결하고 읽기 쉽습니다:
{ "ticket_id": 4821931, "customer_id": 91244 }
페이지네이션도 자연스럽습니다: ?after_id=4821931&limit=50. id로 정렬하면 보통 생성 시간과 일치해 "최신 티켓"이 빠르고 예측 가능합니다. 지원팀도 "티켓 4821931"이라고 말하면 대부분 입력 오류 없이 찾을 수 있습니다.
UUID를 쓰면 페이로드가 길어집니다:
{ "ticket_id": "3f9b3c0a-7b9c-4bf0-9f9b-2a1b3c5d1d2e" }
무작위 v4 UUID를 쓰면 삽입이 인덱스 전체에 흩어집니다. 그 결과 인덱스 교란이 늘고 일상적 디버깅은 복사/붙여넣기가 기본이 됩니다. 페이지네이션은 종종 "after id" 대신 커서 기반 토큰으로 바뀝니다.
시간 순서 UUID를 사용하면 대부분의 "최신 먼저" 동작을 유지하면서 공개 URL에서 추측을 어렵게 할 수 있습니다.
실무에서 팀은 보통 다음 네 가지를 관찰합니다:
- 사람이 얼마나 자주 ID를 직접 입력하는지 vs 복사하는지
- "id로 정렬"이 "생성 시간으로 정렬"과 얼마나 일치하는지
- 커서 페이지네이션이 얼마나 깔끔하고 안정적으로 느껴지는지
- 로그, API 호출, 모바일 화면에서 한 레코드를 추적하기 얼마나 쉬운지
다음 단계: 기본을 정하고 테스트하며 표준화하세요
완벽을 원하기 때문에 망설이는 팀이 많습니다. 완벽할 필요는 없습니다. 오늘 제품에 맞는 기본값 하나와 나중에 문제 없는지 입증할 수 있는 간단한 검증이 있으면 충분합니다.
표준화할 규칙:
- 인덱스가 가장 작고 예측 가능한 정렬과 쉬운 디버깅을 원하면 bigint를 사용하세요.
- URL에서 추측을 어렵게 하거나 오프라인 생성(모바일)이 필요하거나 시스템 간 충돌을 줄이고 싶으면 UUID를 사용하세요.
- 나중에 테넌트나 리전별로 데이터를 분리할 가능성이 있다면 노드 간에 작동하는 ID 계획(UUID 또는 조정된 bigint)을 선택하세요.
- 하나를 기본값으로 정하고 예외는 드물게 만드세요. 일관성이 테이블별 미세 최적화보다 중요할 때가 많습니다.
잠금 전에 작은 스파이크를 실행하세요. 현실적인 행 크기로 테이블을 만들고 100만~500만 행을 삽입해 (1) 인덱스 크기, (2) 삽입 시간, (3) 몇 가지 흔한 쿼리(기본 키와 보조 인덱스 포함)를 비교하세요. 실제 하드웨어와 실제 데이터 형태로 테스트하세요.
나중에 바꿀 가능성이 걱정될 경우 마이그레이션을 지루하게 만들 계획을 세우세요:
- 새 ID 컬럼과 고유 인덱스 추가.
- 듀얼 라이트: 새 행은 두 ID를 모두 채움.
- 배치로 기존 행 백필.
- API와 클라이언트가 새 ID를 받도록 업데이트(전환 기간 동안 기존 것도 유지).
- 읽기 전환 후 로그와 지표가 안정되면 오래된 키 삭제.
AppMaster(appmaster.io)에서 빌드한다면 초기에 결정해 두는 것이 좋습니다. ID 규약은 PostgreSQL 모델, 생성된 API, 웹 및 네이티브 모바일 앱 전체에 영향을 미치기 때문입니다.
자주 묻는 질문
서버에서 대부분 쓰기가 이루어지고 인덱스가 작고 예측 가능한 삽입 동작을 원한다면 기본값으로 bigint를 사용하세요. 여러 서비스, 오프라인 모바일, 혹은 향후 샤딩처럼 여러 장소에서 ID를 생성해야 하거나 공개 ID가 추측되기 어려워야 한다면 UUID를 선택하세요.
ID 값은 기본 키 인덱스, 모든 보조 인덱스(행을 찾기 위한 기본 키 값으로 저장됨), 다른 테이블의 외래 키 열, 조인 테이블 등 여러 곳에 복사됩니다. UUID는 16바이트, bigint는 8바이트라서 이 크기 차이는 스키마 전체에 곱해져 캐시 적중률을 낮출 수 있습니다.
핫한 쓰기 테이블에서는 그렇습니다. 랜덤 UUID(v4 등)는 B-트리 인덱스 전체에 삽입을 분산시키므로 페이지 분할과 인덱스 교란이 늘어나 부하 시 성능 저하를 유발할 수 있습니다. UUID를 원하지만 쓰기 성능도 중요하면, 시간 순서에 가까운 UUID 전략을 사용해 새 키가 주로 끝에 쌓이도록 하세요.
보통은 CPU가 느려지는 것이 아니라 IO가 늘어나는 형태로 나타납니다. 키가 커지면 인덱스가 커지고 메모리에 들어가는 페이지 수가 줄어들어 조인과 조회 시 더 많은 디스크 읽기가 필요해집니다. 이 차이는 대형 테이블, 조인 위주의 쿼리, 작업 집합이 RAM에 맞지 않을 때 가장 뚜렷합니다.
UUID는 /users/1처럼 쉽게 추측하는 것을 줄여주지만, 권한 검사를 대체하지는 못합니다. 권한 검사에 문제가 있으면 UUID도 공유 링크나 로그 유출로 재사용될 수 있습니다. 공개 식별자는 편의성과 약간의 난독화를 제공할 뿐이며, 실제 보안은 엄격한 접근 제어에서 나옵니다.
하나의 표준 표현을 정하고 일관되게 사용하세요. 실무에서 편한 기본값은 API에서 ID를 문자열로 처리하는 것입니다(데이터베이스가 bigint여도). 이렇게 하면 클라이언트 쪽 숫자 정밀도 문제를 피할 수 있고 검증이 단순해집니다. 무엇을 선택하든 웹, 모바일, 로그, 캐시 전반에 일관되게 유지하세요.
클라이언트에 따라 bigint는 큰 숫자를 부동소수점으로 파싱할 때 정밀도 손실을 일으킬 수 있습니다. UUID는 문자열이므로 그런 문제에서 자유롭지만 길이가 길어 관리 실수가 생길 수 있습니다. 가장 안전한 방법은 일관성입니다: 모든 곳에서 같은 타입으로 다루고 API 경계에서 엄격히 검증하세요.
UUID는 중앙 시퀀스 없이 각 노드에서 독립적으로 생성할 수 있어 다중 리전·다중 라이터 환경에서 간단합니다. bigint도 가능하지만 샤드별 범위 할당, 샤드 접두사, Snowflake 스타일 ID 생성기 같은 규칙을 꾸준히 지켜야 합니다. 분산 환경에서 단순함을 원하면(특히 시간 기반 버전 사용 시) UUID가 실용적입니다.
기본 키 타입 변경은 단일 컬럼 변경 이상을 의미합니다. 외래 키, 조인 테이블, API 계약, 클라이언트 저장 방식, 캐시, 분석 이벤트 등 ID를 저장하거나 사용한 모든 곳을 건드립니다. 변경이 필요하다면 단계적 마이그레이션(새 ID 컬럼 추가 → 듀얼 라이트 → 배치 백필 → 클라이언트 업데이트 → 점진적 전환) 계획을 세우세요.
내부적으로는 compact한 인덱스를 사용하고 외부에는 추측하기 어려운 UUID(또는 토큰)를 노출하는 패턴이 효과적입니다. 즉, 데이터베이스에는 bigint 기본 키를 유지하고 공개용으로 별도의 UUID를 두면 내부 디버깅은 쉬우면서 공개 식별자는 안전하게 노출할 수 있습니다. 중요한 건 어느 하나를 '공개 ID'로 정하고 임의로 섞지 않는 것입니다.


