UUID, ULID, 시리얼 ID: 인덱싱, 정렬, 샤딩, 실제 프로젝트에서의 안전한 데이터 내보내기·가져오기에서 각 방식이 어떤 영향을 주는지 알아보세요.

첫 주에는 ID 선택이 지루하게 느껴질 수 있습니다. 그런데 출시하고 데이터가 늘어나면 그 "간단한" 결정이 곳곳에 나타납니다: 인덱스, URL, 로그, 내보내기, 통합 등.
진짜 질문은 "무엇이 최고인가?"가 아니라 "나중에 어떤 고통을 피하고 싶은가?"입니다. ID는 다른 테이블에 복사되고, 클라이언트에 캐시되며, 다른 시스템이 의존하게 되기 때문에 바꾸기 어렵습니다.
ID가 제품 진화와 맞지 않으면 보통 몇 군데에서 문제로 드러납니다:
지금의 편의성과 나중의 유연성 사이엔 항상 트레이드오프가 있습니다. 시리얼 정수는 읽기 쉽고 보통 빠르지만 레코드 수가 노출될 수 있고 데이터 병합을 어렵게 만듭니다. 무작위 UUID는 시스템 간 고유성에는 좋지만 인덱스에 부담을 줘서 사람이 로그를 볼 때 불편합니다. ULID는 전역 고유성과 시간에 따른 대략적 정렬을 목표로 하지만 저장 및 도구 측면에서의 트레이드오프가 남습니다.
한 가지 유용한 관점: 이 ID는 누구를 위한 것인가?
ID가 주로 사람(서포트, 디버깅, 운영)을 위한 것이라면 더 짧고 스캔하기 쉬운 것이 낫습니다. 기계(분산 쓰기, 오프라인 클라이언트, 다중 리전)를 위한 것이라면 전역 고유성과 충돌 회피가 더 중요합니다.
사람들이 "UUID vs ULID vs serial IDs"를 논할 때 사실은 각 행에 어떤 고유 레이블을 붙일지 선택하는 것입니다. 그 레이블은 나중에 삽입, 정렬, 병합, 이동이 얼마나 쉬운지에 영향을 줍니다.
시리얼 ID는 카운터입니다. 데이터베이스가 1, 2, 3을 차례로 발급합니다(보통 integer나 bigint로 저장). 읽기 쉽고 저장 비용이 적으며 새 행이 인덱스 끝에 추가되므로 일반적으로 빠릅니다.
UUID는 128비트 식별자로 겉모습이 랜덤합니다(예: 3f8a...). 대부분의 환경에서 데이터베이스에 다음 번호를 요청하지 않고도 생성할 수 있어 서로 다른 시스템에서 독립적으로 ID를 만들 수 있습니다. 대신 무작위 같은 삽입은 인덱스에 부담을 주고 bigint보다 공간을 더 차지할 수 있습니다.
ULID도 128비트지만 대체로 시간 순서를 따르도록 설계되었습니다. 최신 ULID는 보통 오래된 것보다 뒤에 정렬되며 전역 고유성을 유지합니다. UUID의 "어디서나 생성" 장점을 가지면서 정렬 행동이 더 친화적입니다.
간단 요약:
시리얼 ID는 단일 데이터베이스 앱과 내부 도구에서 흔합니다. UUID는 여러 서비스, 디바이스, 리전에서 데이터가 생성될 때 사용됩니다. ULID는 분산 ID 생성이 필요하면서도 정렬, 페이지네이션, "최신 순" 쿼리를 신경 쓸 때 인기 있습니다.
기본키는 보통 인덱스로 지원됩니다(대개 B-tree). 그 인덱스를 정렬된 전화번호부처럼 생각하세요: 새 행은 빠른 조회를 위해 올바른 위치에 삽입돼야 합니다.
무작위 ID(전형적인 UUIDv4)에서는 새 엔트리가 인덱스 곳곳에 위치합니다. 이는 데이터베이스가 많은 인덱스 페이지를 건드리고, 페이지를 더 자주 분할하며, 추가 쓰기가 발생함을 의미합니다. 시간이 지나면 인덱스 변동이 심해져 삽입당 더 많은 작업, 캐시 미스, 예상보다 큰 인덱스가 생깁니다.
대부분 증가하는 ID(시리얼/bigint 또는 많은 ULID의 시간 정렬)에서는 데이터베이스가 새 항목을 인덱스 끝 근처에 덧붙일 수 있습니다. 이는 최근 페이지가 뜨거운 상태로 남아있어 캐시 친화적이고 높은 쓰기율에서도 삽입이 부드럽습니다.
키 크기는 중요합니다. 인덱스 엔트리는 공짜가 아닙니다:
키가 크면 인덱스 페이지당 들어가는 항목 수가 줄어듭니다. 이는 대개 더 깊은 인덱스, 쿼리당 더 많은 페이지 읽기, 빠르게 유지하려면 더 많은 RAM이 필요함을 의미합니다.
만약 계속 삽입되는 'events' 테이블이 있다면 무작위 UUID 기본키는 bigint 키보다 빨리 느려지기 시작할 수 있습니다. 단건 조회는 여전히 괜찮아 보여도, 쓰기 집중적인 경우 인덱싱 비용이 첫 번째로 체감되는 차이입니다.
"더 보기"나 무한 스크롤을 만들어 본 적이 있다면 정렬이 잘 안 되는 ID의 고통을 이미 느껴봤을 것입니다. ID가 "정렬이 잘 된다"는 것은 그것으로 정렬했을 때 안정적이고 의미 있는 순서(주로 생성 시간)를 제공해 페이지네이션이 예측 가능하다는 뜻입니다.
무작위 ID(UUIDv4 등)에서는 새 행이 흩어집니다. id로 정렬하면 시간이 맞지 않아 "이 ID 이후" 같은 커서 페이지네이션이 신뢰할 수 없게 됩니다. 보통은 created_at으로 대체하지만, 신중히 사용해야 합니다.
ULID는 대략 시간 순서를 따르도록 설계되어 있어 ULID로 정렬하면 새 항목이 뒤로 오는 경향이 있습니다. 이는 마지막으로 본 ULID를 커서로 삼는 등 커서 페이지네이션을 단순하게 만듭니다.
ULID는 피드, 단순한 커서, UUIDv4보다 덜 무작위적인 삽입 동작에 도움을 줍니다.
하지만 여러 머신이 같은 밀리초에 많은 ID를 생성하면 ULID는 완벽한 시간 순서를 보장하지 않습니다. 정확한 순서가 필요하면 실제 타임스탬프가 여전히 필요합니다.
created_at이 더 나은가데이터를 백필하거나 과거 레코드를 가져오거나 명확한 타이브레이킹이 필요할 때 created_at으로 정렬하는 것이 더 안전합니다.
실무 패턴은 (created_at, id)처럼 id를 타이브레이커로 사용하는 것입니다.
샤딩은 하나의 데이터베이스를 여러 개로 나누어 각 샤드가 데이터 일부를 가지도록 하는 것입니다. 보통 단일 DB가 확장하기 어렵거나 단일 실패 지점 위험이 커질 때 나중에 합니다.
ID 선택은 샤딩을 관리하기 쉽게 하거나 골칫거리로 만들 수 있습니다.
시퀀스(오토인크리먼트) ID의 경우 각 샤드는 기쁘게도 1, 2, 3...을 생성합니다. 여러 샤드에 같은 ID가 존재할 수 있습니다. 데이터를 합치거나 행을 이동하거나 크로스-샤드 기능을 만들 때 충돌에 부딪힙니다.
조율(중앙 ID 서비스, 샤드별 범위)로 충돌을 피할 수 있지만 운영 구성요소가 늘고 병목이 될 수 있습니다.
UUID와 ULID는 각 샤드가 독립적으로 ID를 생성할 수 있어 중복 위험을 극히 낮추므로 조율 필요성을 줄여줍니다. 샤딩을 고려한다면 이는 순수 시퀀스에 반대하는 강력한 이유 중 하나입니다.
일반적인 타협은 샤드 접두사를 추가하고 각 샤드에서 로컬 시퀀스를 사용하는 것입니다. 두 컬럼으로 저장하거나 하나의 값에 패킹할 수 있습니다.
작동은 하지만 커스텀 ID 형식을 만들게 됩니다. 모든 통합이 이를 이해해야 하고, 정렬은 전역 시간 순서가 아니게 되며, 샤드 간 데이터를 옮기려면 ID를 다시 써야 합니다(공유된 참조가 깨짐).
초기에 한 가지 질문을 하세요: 여러 데이터베이스의 데이터를 합쳐 참조를 유지할 일이 있나? 있다면 처음부터 전역 고유 ID를 계획하거나 나중에 마이그레이션 비용을 예산에 포함하세요.
내보내기·가져오기에서 ID 선택은 이론이 아니라 현실이 됩니다. 운영을 스테이징으로 복제하거나 백업을 복원하거나 두 시스템의 데이터를 합칠 때 ID가 안정적이고 이식 가능한지 바로 드러납니다.
시리얼(오토인크리먼트) ID로는 원래 번호를 보존하지 않으면 다른 DB에 삽입을 안전하게 재생할 수 없습니다. 일부 행(예: 200명의 고객과 그 주문)만 가져오려면 테이블을 올바른 순서로 로드하고 정확한 기본키를 유지해야 합니다. 아무것도 다시 번호가 매겨지면 외래 키가 깨집니다.
UUID와 ULID는 데이터베이스 시퀀스 밖에서 생성되므로 환경 간 이동이 쉽습니다. 행을 복사하고 ID를 유지하면 관계는 그대로입니다. 이는 백업 복원, 부분 내보내기, 데이터 병합에서 유리합니다.
예: 운영에서 계정 50개를 내보내 스테이징에서 문제를 디버그하려고 할 때, UUID/ULID 기본키가 있으면 계정과 연관된 프로젝트·송장·로그를 가져와도 모두 올바른 부모를 가리킵니다. 시리얼 ID이면 보통 변환 테이블(old_id -> new_id)을 만들어 외래 키를 다시 써야 합니다.
대량 가져오기에서는 ID 타입보다 기본 원칙이 더 중요합니다:
미래에 아플 가능성이 높은 지점을 중심으로 빠르게 판단할 수 있습니다.
미래 위험을 적어보세요. 구체적 사건이 도움이 됩니다: 여러 DB로 분할, 다른 시스템에서 고객 데이터 병합, 오프라인 쓰기, 환경 간 잦은 복사 등.
ID 정렬이 시간과 일치해야 하는지 결정하세요. "최신 순"을 추가 컬럼 없이 원하면 ULID(또는 다른 시간 정렬 ID)가 적합합니다. created_at으로 정렬해도 괜찮다면 UUID나 시리얼도 무방합니다.
쓰기량과 인덱스 민감도를 추정하세요. 쓰기가 많고 기본키 인덱스가 집중되는 상황이라면 시리얼 BIGINT가 B-tree에 가장 부담이 적습니다. 무작위 UUID는 더 많은 변동을 유발합니다.
기본값을 정하고 예외를 문서화하세요. 단순하게 유지하세요: 대부분 테이블에 하나의 기본값을 쓰고 벗어날 때의 규칙을 명확히 하세요(대개: 공개용 ID vs 내부 ID).
변경 여지를 남겨두세요. ID에 의미를 담지 말고 어디서 ID를 생성할지(DB vs 앱)를 정하며 제약을 명확히 하세요.
가장 큰 함정은 인기 있어서 선택한 ID가 쿼리, 확장, 데이터 공유 방식과 충돌하는 것을 나중에 발견하는 것입니다. 대부분 문제는 몇 달 후에 나타납니다.
흔한 실패 사례:
123, 124, 125가 있으면 사람들은 근처 레코드를 추측하고 시스템을 스캔할 수 있습니다.초기에 해결해야 할 경고 신호:
대다수 테이블에 하나의 기본키 타입을 정하고 따르세요. 타입을 섞으면(join에 bigint, 다른 곳에 UUID) 조인, API, 마이그레이션이 복잡해집니다.
예상 규모에서 인덱스 크기를 추정하세요. 키가 넓으면 기본 인덱스가 커지고 메모리·IO가 늘어납니다.
어떻게 페이지네이션할지 결정하세요. ID로 페이지네이션할 계획이면 ID가 예측 가능한 정렬을 갖는지 확인하세요(또는 정렬되지 않음을 수용). 타임스탬프 기반이라면 created_at을 인덱스하고 일관되게 사용하세요.
프로덕션과 유사한 데이터로 가져오기 계획을 테스트하세요. 외래 키를 깨뜨리지 않고 레코드를 재생성할 수 있는지, 재가져오기가 새 ID를 조용히 생성하지 않는지 확인하세요.
충돌 전략을 문서화하세요. 누가 ID를 생성하는가(DB 또는 앱), 두 시스템이 오프라인에서 생성 후 동기화하면 어떻게 되는가?
공개 URL과 로그가 민감한 패턴(레코드 수, 생성 속도, 내부 샤드 힌트)을 유출하지 않는지 확인하세요. 시리얼 ID를 쓰면 근처 ID를 사람들이 추측할 수 있다고 가정하세요.
솔로 창업자가 간단한 CRM을 출시합니다: contacts, deals, notes. 한 대의 Postgres와 한 개의 웹앱, 목표는 출시입니다.
처음에는 시리얼 bigint 기본키가 완벽해 보입니다. 삽입이 빠르고 인덱스가 깔끔하며 로그에서 읽기 쉽습니다.
1년 뒤 고객이 감사를 위해 분기별 내보내기를 요청하고 창업자는 마케팅 툴에서 리드를 가져오기 시작합니다. 내부용이던 ID가 CSV, 이메일, 서포트 티켓에 등장합니다. 두 시스템이 둘 다 1, 2, 3...을 쓴다면 병합이 엉망이 됩니다. 소스 컬럼, 매핑 테이블을 추가하거나 가져오기 시 ID를 다시 쓰는 일이 생깁니다.
2년 차에는 모바일 앱이 생깁니다. 오프라인에서 레코드를 만들고 나중에 동기화해야 합니다. 이제 데이터베이스와 통신하지 않고도 클라이언트에서 ID를 생성할 수 있어야 하고, 서로 다른 환경에서 데이터가 섞일 때 충돌 위험이 낮아야 합니다.
오래 가는 타협안:
UUID, ULID, 시리얼 ID 사이에서 고민된다면 데이터가 어떻게 이동하고 성장할지 기준으로 결정하세요.
한 문장 추천:
bigint 시리얼 기본키를 사용하세요.혼합이 대개 최선의 답입니다. 내부적으로 절대 DB를 벗어나지 않을 테이블(조인 테이블, 백그라운드 작업 등)은 시리얼 bigint를, 사용자·조직·송장처럼 내보내거나 동기화할 가능성이 있는 공개 엔터티는 UUID/ULID를 사용하세요.
Koder.ai에서 빌드 중이라면 (koder.ai) 많은 테이블과 API를 생성하기 전에 ID 패턴을 정하는 것이 좋습니다. 플랫폼의 계획 모드와 스냅샷/롤백 기능은 스키마 변경을 초기에 적용하고 검증하기 쉽게 만들어 줍니다.
나중에 피하고 싶은 고통을 먼저 정해보세요: 무작위 인덱스 쓰기로 인한 느린 삽입, 어색한 페이지네이션, 위험한 마이그레이션, 또는 가져오기·병합 시 ID 충돌 등입니다. 데이터가 여러 곳에서 생성되거나 시스템 간 이동이 예상된다면 전역 고유 ID(UUID/ULID)를 기본으로 삼고 시간 정렬 문제는 따로 다루는 것이 안전합니다.
하나의 데이터베이스에서 쓰기가 많고 ID가 내부에서만 쓰인다면 시리얼 bigint가 강한 기본값입니다. 저장비가 작고 B-tree 인덱스에 유리하며 로그에서 읽기 쉽습니다. 단점은 나중에 다른 시스템과 병합할 때 충돌이 생기고, 공개하면 레코드 수가 유추될 수 있다는 점입니다.
레코드가 여러 서비스, 리전, 디바이스 또는 오프라인 클라이언트에서 생성될 가능성이 있고 조율 없이도 중복 위험을 극히 낮게 유지하려면 UUID를 선택하세요. 공개용 ID로도 적합해 추측이 어렵습니다. 대신 인덱스가 커지고 삽입 패턴이 랜덤해져 쓰기 성능에 비용이 발생할 수 있습니다.
ULID는 어디서나 생성할 수 있으면서도 일반적으로 생성 시간 순으로 정렬되는 ID가 필요할 때 유용합니다. 피드나 커서 페이지네이션이 단순해지고 UUIDv4의 무작위 삽입 문제를 줄여줍니다. 다만 동일 밀리초 안에 여러 머신이 생성하면 완벽한 시간 보장은 되지 않으니, 엄밀한 순서가 필요하면 created_at을 사용하세요.
예, 특히 쓰기가 많은 테이블에서 UUIDv4 스타일의 무작위 키는 성능에 영향을 줍니다. 무작위 삽입은 주요 인덱스 페이지를 여기저기 건드려서 페이지 스플릿, 캐시 치환, 인덱스 크기 증가를 초래합니다. 보통 단건 조회보다 지속적인 삽입 처리율 저하나 메모리/IO 부담으로 먼저 체감합니다.
무작위 ID(UUIDv4 등)로 정렬하면 생성 시간과 일치하지 않기 때문에 ‘이 ID 이후 항목’ 같은 커서가 안정적이지 않습니다. 해결책은 created_at으로 페이지네이션하고 ID를 타이브레이커로 추가하는 것입니다. 예: (created_at, id). ID만으로 페이지네이션하려면 시간 정렬 가능한 ID(예: ULID)가 더 쉽습니다.
시퀀셜 ID는 샤드마다 1, 2, 3...을 생성하므로 샤딩 후 병합 시 충돌이 발생합니다. 샤드별 범위나 중앙 ID 서비스 같은 조율로 해결할 수 있지만 운영 복잡도가 늘고 병목이 될 수 있습니다. UUID/ULID는 각 샤드가 독립적으로 안전하게 ID를 생성할 수 있어 조율 필요성을 줄여줍니다.
내보내기·가져오기·병합 측면에서는 UUID/ULID가 더 안전합니다. 행을 내보내 다른 곳에 그대로 가져와도 참조가 유지되기 때문입니다. 시리얼 ID의 부분적 가져오기는 종종 old_id -> new_id 같은 변환 테이블을 만들고 외래 키를 다시 써야 해서 실수하기 쉽습니다. 자주 환경을 복제하거나 데이터셋을 병합한다면 전역 고유 ID가 시간을 절약합니다.
일반적인 패턴은 내부용 컴팩트 기본키(시리얼 bigint)와 공개용 불변 ID(ULID 또는 UUID)를 함께 쓰는 것입니다. 내부 조인과 저장 효율은 유지하면서, URL·API·내보내기 등 외부 참조는 공개 ID로 처리하면 통합과 마이그레이션이 쉬워집니다. 공개 ID는 안정적으로 취급하고 재사용하거나 의미를 부여하지 마세요.
일찍 계획하고 테이블과 API 전반에 일관되게 적용하세요. Koder.ai에서는 계획 모드에서 기본 ID 전략을 정한 뒤 스키마와 엔드포인트를 많이 생성하기 전에 스냅샷/롤백으로 변경을 검증하는 것이 좋습니다. 새로운 ID를 만드는 것보다 어려운 일은 기존 외래 키, 캐시된 페이로드, 로그, 외부 통합이 오래된 ID를 계속 참조하는 상황입니다.