View

728x90
반응형

요즘 Cursor나 Claude Code를 켜놓고 "Postgres로 유저 테이블 짜줘"라고 지시해보면 백이면 백 id UUID PRIMARY KEY DEFAULT gen_random_uuid()를 박고 시작한다. 그런데 MySQL을 시키면 또 얌전하게 BIGINT AUTO_INCREMENT로 응답하는 것을 보면 무언가 이상하다. 작년 말 우리 팀도 신규 서비스에 AI가 뱉은 스키마를 그대로 머지했다가, 6개월 만에 인덱스 체크포인트 IO가 튀기 시작해 주말 내내 기본키 마이그레이션 삽질을 한 적이 있어, 이번에 아예 작정하고 파고들었다.

 

요약하면 AI의 기본값은 틀린 답이 아니라 절반만 맞는 답이다. 최근 등장한 UUIDv7이면 대부분 괜찮지만, 아직도 수많은 샘플 코드는 UUIDv4 랜덤값을 그대로 박아놓은 상태다. 이 글에서는 MySQL의 Auto Increment와 Postgres의 UUID 처리 방식을 내부 구조 레벨에서 비교하고, 실제로 인덱스가 얼마나 비대해지는지, 페이지 분할이 얼마나 자주 일어나는지, 2024년 RFC 9562로 표준화된 UUIDv7이 무엇을 바꿨는지까지 정리한다.

출처: velog.io

Cursor에게 테이블 설계를 맡기면 왜 UUID가 튀어나오는가

실제로 해보면 패턴이 한눈에 보인다. Cursor에게 "Postgres로 블로그 테이블을 만들어달라"고 하면 다음과 같이 응답한다.

CREATE TABLE posts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

그러나 "MySQL로 동일하게 해달라"고 지시하면 이렇게 나온다.

CREATE TABLE posts (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  title TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

동일한 AI가 DB 이름 하나 바뀌었다고 기본키 전략을 싹 바꿔버린다. 이는 우연이 아니라 학습 데이터 편향이다. GitHub와 Stack Overflow에서 긁어모은 Postgres 예제 대부분이 UUID를 사용하고 있고, MySQL 예제는 Auto Increment가 압도적으로 많다. AI가 이 분포를 "관행"으로 외워버린 것이고, 왜 그렇게 사용하는지 설명도 없이 그대로 뱉는다.

MySQL의 BIGINT AUTO_INCREMENT는 내부에서 어떻게 작동하는가

MySQL InnoDB는 클러스터드 인덱스 구조다. 데이터 자체가 기본키 순서대로 디스크에 정렬되어 박혀 있다. 기본키 B-tree의 리프 노드가 곧 데이터 페이지라는 뜻이다.

그래서 BIGINT AUTO_INCREMENT는 InnoDB와 궁합이 좋다. 새 row가 들어올 때마다 항상 맨 끝에 붙으므로 B-tree 오른쪽 끝 페이지만 건드리게 되고, 페이지 분할은 거의 일어나지 않는다. 8바이트짜리 숫자이기 때문에 인덱스도 작다. 세컨더리 인덱스도 기본키 값을 포인터로 복사해 저장하기 때문에, 기본키가 커지면 모든 세컨더리 인덱스가 함께 비대해지는 구조다. 바로 여기서 UUID 16바이트가 부담으로 돌아온다.

Postgres는 SERIAL, IDENTITY, UUID 중 무엇을 쓰는 것이 정석인가

Postgres는 구조가 다르다. 힙(heap) 저장 + 세컨더리 B-tree 인덱스 구조다. 데이터는 들어온 순서대로 힙에 쌓이고, 기본키 인덱스는 별도의 B-tree로 따로 존재한다. 즉 기본키 순서로 데이터가 물리 정렬되어 있지 않다.

전통적으로 Postgres는 다음과 같이 쓴다.

-- 옛날 방식 (여전히 많이 보임)
CREATE TABLE posts (id BIGSERIAL PRIMARY KEY, ...);

-- Postgres 10 이후 SQL 표준 방식
CREATE TABLE posts (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  ...
);

BIGSERIAL은 내부적으로 sequence를 만들어 DEFAULT nextval('...')을 걸어주는 문법설탕(syntactic sugar)이다. 반면 GENERATED AS IDENTITY는 SQL:2003 표준이라 다른 DB와의 호환성이 좋다. 지금 신규 프로젝트라면 IDENTITY를 쓰는 것이 맞지만, 레거시 코드에 BIGSERIAL이 박혀 있다고 굳이 마이그레이션할 이유는 없다. 동작은 동일하다.

그렇다면 왜 AI는 Postgres에만 UUID를 박아주는가? 여기에는 구조적 이유가 있다. Postgres는 클러스터드 인덱스가 없어 UUID를 사용할 때 MySQL보다 페널티가 작다. InnoDB처럼 데이터 자체를 기본키 순서로 재배치하는 비용이 없고, 세컨더리 인덱스도 물리 주소(TID)로 포인팅하지 기본키 값을 복사하지 않는다. 그래서 Postgres 커뮤니티는 역사적으로 UUID에 관대했고, Supabase, Prisma, Hasura 같은 프레임워크가 UUID를 밀면서 "Postgres = UUID"라는 공식이 굳어진 것이다.

AI가 Postgres에 UUID를 디폴트로 박는 진짜 이유들

가장 큰 이유는 역시 학습 데이터 편향이다. ORM 튜토리얼, Supabase 공식 문서, Prisma 스키마 예제 대부분이 UUID를 사용한다. AI가 학습한 코드 분포 자체가 그러하니 그대로 뱉는 것이고, 거기엔 "왜 UUID를 쓰는지"에 대한 설명조차 없는 경우가 태반이다. 여기에 더해 AI는 "확장성 높은 선택이 안전하다"는 편향이 있어, 분산 시스템이나 멀티 리전 배포 같은 시나리오를 막연히 가정하고 UUID를 기본값으로 밀어붙이는 경향이 있다. 다만 실제로는 대부분의 프로젝트가 단일 Postgres 인스턴스에서 굴러가는데도 그렇다.

그래도 한 가지는 제대로 맞는 이유가 있다. 바로 외부 노출되는 ID의 순번 유출 문제다. /api/posts/123 같은 URL은 "우리 게시글이 총 123개구나"라는 정보를 바로 흘린다. 경쟁사 성장률 추정도 가능하고, 순차 스크래핑도 쉽다. 이전 회사에서 API 응답에 auto increment ID를 그대로 노출했다가, 경쟁사가 매주 월요일마다 id=1부터 1000 단위로 긁어가면서 우리 측 신규 가입자 증가율을 거의 실시간으로 추적하는 것을 접속 로그 파싱 중 발견한 적이 있다. UUID를 쓰면 이런 enumerable ID 문제가 사라진다.

UUID를 Postgres 기본키로 쓰면 실제로 무엇이 문제가 되는가

인덱스 키가 2배 비대해진다

native uuid 타입 기준 16바이트, BIGINT는 8바이트다. B-tree는 리프 노드뿐 아니라 중간 노드에도 키를 저장하고, 페이지 내에 페이지 헤더와 line pointer 오버헤드도 존재한다. 실측상 UUID 인덱스는 BIGINT 대비 약 2~2.5배 크기가 된다. 1000만 row 테이블이면 인덱스만 수백 MB 차이가 나고, 그것은 곧 버퍼 캐시 효율 저하로 이어진다.

참고로 UUID를 VARCHAR(36) 문자열로 저장하는 것은 가장 피해야 할 조합이다. 36바이트 + varlena 헤더 = 37바이트를 넘어간다. 반드시 native uuid 타입을 써야 한다.

랜덤 인서트로 인한 B-tree 페이지 분할

UUIDv4는 완전 랜덤이라 새 값이 들어올 때마다 B-tree의 아무 페이지에나 끼어들어간다. 이것이 페이지 분할(page split)을 유발한다. fillfactor를 90으로 낮춰도 시간이 지나면 분할 빈도가 수배로 늘어난다. Percona가 2020년에 공개한 벤치마크에서 UUIDv4 인서트 TPS가 BIGINT 대비 20~40% 낮게 나왔고, 테이블이 버퍼 풀을 넘어서는 순간부터 격차가 더 벌어졌다.

WAL 쓰기 증폭

페이지 분할이 일어나면 해당 변경 내역이 전부 WAL(Write-Ahead Log)에 기록된다. 한 row 인서트인데 실제로는 인덱스 페이지 여러 개가 쓰이고, 그것이 또 WAL에 그대로 박힌다. 레플리케이션 대역폭, 백업 크기, 디스크 IO가 전부 함께 밀려 올라간다. 우리 팀 운영 DB에서 UUIDv4 → UUIDv7 교체 후 하루 WAL 생성량이 눈에 띄게 줄어 백업 시간이 짧아진 것이 가장 직관적인 개선이었다.

"컬럼 재정렬" 이슈의 실체: 8바이트 얼라인먼트

원래 질문에 있던 "sorting(컬럼 재정렬)"은 정확히는 Postgres의 물리적 컬럼 저장 순서와 pad alignment 이야기다. Postgres는 pg_attribute.attnum 순서대로 row 안에 컬럼을 물리적으로 배치한다. 다만 컬럼마다 정렬 요구사항(alignment)이 다르다.

BIGINT, uuid, timestamptz는 8바이트 정렬이 필요하고, int4나 float4는 4바이트, bool이나 char는 1바이트로 끝난다. 컬럼 순서를 bool → bigint → int4 → uuid 이런 식으로 섞어놓으면, 각 타입 앞에 padding 바이트가 들어가 row 크기가 1~7바이트씩 커진다. 100만 row면 수 MB, 1억 row면 수백 MB 차이가 난다. Postgres DBA들이 큰 타입 → 중간 → 작은 타입 순으로 컬럼을 배치하는 것을 습관화하는 이유가 바로 이것이다. UUID를 기본키로 쓴다고 해서 재정렬 이슈가 발생하는 것은 아니지만, UUID가 8바이트 정렬 그룹이라 맨 앞에 오는 것이 자연스럽다는 점은 기억해둘 만하다.

AI가 자동 생성한 스키마는 이 최적화를 거의 하지 않는다. 그저 논리적 순서대로 박을 뿐이다. 지금 당장 문제는 없어도 row 수가 늘어나면 누적 비용으로 돌아온다.

UUIDv7은 실제로 무엇을 바꾸었는가

UUIDv4는 128비트 중 122비트가 랜덤이라 B-tree 삽입 패턴이 최악이라고 위에서 언급했다. UUIDv7은 앞 48비트가 Unix 타임스탬프(밀리초), 그 뒤가 랜덤이다. 시간순으로 정렬되므로 B-tree 오른쪽 끝에 몰려 들어가고, 결과적으로 BIGINT IDENTITY와 거의 동일한 삽입 패턴을 보인다.

UUIDv4 예시: a8f3e2c1-4b5d-4a6e-8f7c-1d2e3f4a5b6c (완전 랜덤)
UUIDv7 예시: 018e7d8f-3b2c-7a6e-8f7c-1d2e3f4a5b6c (앞쪽 타임스탬프)

2024년 9월에 릴리스된 Postgres 17부터 uuidv7() 함수가 내장됐다. 이전 버전이라면 pg_uuidv7 익스텐션을 쓰거나, 앱 레벨에서 생성해 넣어야 한다.

-- Postgres 17
CREATE TABLE posts (
  id UUID PRIMARY KEY DEFAULT uuidv7(),
  ...
);

-- Postgres 14~16 (익스텐션 필요)
CREATE EXTENSION pg_uuidv7;
CREATE TABLE posts (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
  ...
);

Supabase가 2024년에 공개한 벤치마크에 따르면 UUIDv7 인서트 TPS는 BIGINT 대비 5~10% 저하선에 머무른다. UUIDv4가 20~40% 저하였던 것을 감안하면 체감 차이가 확연히 다른 수준이다. 인덱스 비대는 여전히 16바이트라 BIGINT보다 크지만, 페이지 분할과 WAL 증폭 문제는 거의 해결됐다고 봐도 무방하다.

출처: hoing.io

ULID, Snowflake, NanoID는 언제 쓰는 것인가

UUIDv7이 나왔다고 다른 ID 전략이 의미를 잃은 것은 아니다. 상황별로 적합한 것이 따로 있다.

ULID는 26자 base32 문자열로 시간순 정렬된다. URL에 그대로 박아도 읽을 만하고, UUIDv7보다 짧게 보여 로그 디버깅 시 눈이 편하다. 다만 Postgres native 타입이 없어 TEXT 또는 커스텀 타입으로 처리해야 한다. Snowflake는 트위터가 만든 64비트 시간순 ID인데, 타임스탬프 + 머신 ID + 시퀀스 구조다. BIGINT 크기라 인덱스가 작고 분산 생성도 되지만 머신 ID 할당 관리를 직접 해야 해 운영 부담이 있다. 자체 인프라 대규모 플랫폼이 아니면 오버엔지니어링이다. NanoID는 URL 친화적인 짧은 ID(기본 21자)이며, 랜덤 기반이라 정렬은 되지 않는다. 공개 식별자나 short URL 용도로 UUID 대신 쓰는 정도다.

평범한 SaaS 백엔드라면 솔직히 UUIDv7 하나로 거의 다 해결된다. 나머지는 특수 상황용이라고 보면 된다.

MySQL 진영에도 할 말이 있다: UUID_TO_BIN

MySQL InnoDB도 UUID를 쓸 수 있다. BINARY(16) 컬럼에 UUID_TO_BIN(uuid, swap_flag=1)을 써주면 시간순 정렬되는 16바이트 바이너리로 저장된다. swap_flag=1이 핵심인데, UUIDv1의 타임스탬프 필드 순서를 뒤집어 정렬 가능한 prefix로 만들어준다. 이렇게 하면 클러스터드 인덱스 페널티가 크게 완화된다.

다만 문자열로 검색할 때마다 BIN_TO_UUID 변환이 들어가고, 개발자가 이 변환을 놓치면 인덱스를 타지 못하는 쿼리가 나오기 쉽다. Postgres의 native uuid 타입이 이런 면에선 훨씬 편하다. 공정하게 말하면 MySQL도 의지만 있다면 시간순 UUID를 잘 쓸 수 있지만, 기본값이 친화적이지 않아 모르고 쓰면 독이 된다.

그래서 무엇을 써야 하는가: 상황별 의사결정

각설하고, 외부 노출이 없는 모놀리스 내부 테이블이라면 BIGINT GENERATED ALWAYS AS IDENTITY가 여전히 정답이다. 인덱스가 작고, 삽입이 빠르며, 조인도 빠르다. AI가 무어라 하든 이것이 최적일 때가 많다. 우리 팀 내부 작업 큐 테이블은 지금도 IDENTITY로 굴러가고 있지만 아무 문제가 없다.

외부 노출되는 API나 멀티 테넌트 SaaS에서는 UUID로 가는 것이 맞다. 단, v4가 아니라 v7이다. URL에 ID가 박혀도 순번 추론이 불가능하고, 테넌트별 백업/이관 시 ID 충돌도 없다. Postgres 17이면 uuidv7() 네이티브 함수를 바로 쓰면 되고, 하위 버전이면 pg_uuidv7 익스텐션을 설치하면 된다. 분산 시스템이나 이벤트 소싱처럼 ID를 앱에서 생성해야 하는 환경도 UUIDv7이 표준이라 포팅이 쉽다. 자체 플랫폼 대규모 트래픽이라면 Snowflake도 고려할 만하지만 대부분은 오버킬이다.

절대 피해야 할 조합을 짚자면, UUIDv4를 대용량 테이블 PK로 박는 것은 인서트 성능이 망가지고, UUID를 VARCHAR(36)으로 저장하는 것은 스토리지를 2배 이상 낭비한다. 특히 InnoDB 클러스터드 인덱스에 UUIDv4를 그대로 박는 것이 가장 심한 페널티를 받는다.

최근 대형 서비스에서 자주 보이는 하이브리드 패턴도 있다. 내부용 PK는 BIGINT IDENTITY로 두고, 외부 노출용 public_id UUID 컬럼을 따로 두는 방식이다. 인덱스 성능과 URL 안전성을 모두 챙길 수 있으나, 조인 키가 두 개가 되어 애플리케이션 코드가 다소 복잡해진다. 우리 팀도 신규 서비스에는 이 구조를 기본으로 깔고 있다.

AI 답변을 그대로 받지 말고 한 번 더 생각해야 한다

맨 앞에서 언급한 주말 마이그레이션 삽질을 다시 꺼내보면, 당시 AI가 제안한 UUIDv4 스키마를 코드 리뷰에서 그대로 통과시킨 것이 시작이었다. 로컬 10만 row 테스트에서는 티가 나지 않았다. 운영에 올라가 2000만 row를 넘어가고 나서야 체크포인트 IO가 튀기 시작했고, 결국 금요일 밤에 UUIDv7 마이그레이션을 걸어 토요일 오전까지 인덱스 리빌드를 돌렸다. 리뷰 단계에서 5분만 더 썼으면 깨지지 않을 주말이었던 것이라, 그 이후로 AI 스키마를 받으면 UUID 버전, 컬럼 얼라인먼트, 진짜로 UUID가 필요한 상황인지까지 습관적으로 확인하고 있다.

다음에 Cursor가 DEFAULT gen_random_uuid()를 박으면 UUIDv7 버전으로 고쳐달라고 한 번 찔러보는 것도 방법이다. 왜일까? 은근히 AI들도 최신 RFC를 모른 채 관행만 뱉는 경우가 많기 때문이다.


참고 자료

728x90
반응형
Share Link
reply
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31