PostgreSQL에서 UUID를 CHAR(36)으로 저장하고 있었습니다
PostgreSQL에서 UUID를 CHAR(36)으로 저장하면 공간은 1.74배, 조인은 1.76배 손해입니다.
[배경 - 스키마를 다시 읽다가]
메일 서비스의 스키마를 훑어보다가 눈에 걸리는 게 있었어요.
CREATE TABLE IF NOT EXISTS threads (
id CHAR(36) NOT NULL,
mail_account_id CHAR(36) NOT NULL,
...
PRIMARY KEY (id)
);
모든 기본 키와 외래 키가 CHAR(36) 입니다. 엔티티 쪽도 마찬가지예요.
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "CHAR(36)")
@JdbcTypeCode(SqlTypes.CHAR)
private UUID id;
Java에서는 UUID 타입인데 DB에는 36자 문자열로 저장하고 있었습니다. 그런데 이 프로젝트는 PostgreSQL을 씁니다. PostgreSQL에는 uuid 라는 네이티브 타입이 있어요.
왜 이렇게 됐는지는 짐작이 갑니다. MySQL에는 UUID 전용 타입이 없어서 CHAR(36) 이나 BINARY(16) 을 쓰는 게 관례거든요. 그 습관이 그대로 넘어온 것 같았어요.
그래서 얼마나 차이가 나는지 재보기로 했습니다.
[문제 상황 분석 - 36바이트와 16바이트]
저장 크기부터 다릅니다
UUID는 128비트, 즉 16바이트입니다. 이걸 사람이 읽는 형식으로 쓰면 하이픈 포함 36글자예요.
550e8400-e29b-41d4-a716-446655440000
└──────────── 36 characters ────────────┘
uuid 타입은 16바이트를 그대로 저장합니다. CHAR(36) 은 36바이트를 저장해요. PostgreSQL에서 char(n) 은 내부적으로 bpchar 이고 가변 길이 헤더가 붙기 때문에 실제로는 조금 더 듭니다.
단순 계산으로도 2.25배지만, 진짜 문제는 다른 데 있어요.
인덱스가 같이 커집니다
기본 키에는 인덱스가 붙습니다. B-Tree 인덱스는 페이지 단위로 키를 담는데, 키가 크면 한 페이지에 들어가는 개수가 줄어요.
[8KB 페이지에 들어가는 키 개수]
uuid (16 bytes) ██████████████████████ 많이
CHAR(36) ██████████ 적게
페이지당 키가 적어지면 같은 행 수를 담는 데 페이지가 더 필요하고, 트리 깊이가 깊어질 수 있습니다. 조회할 때 읽어야 할 블록이 늘어나요.
여기에 외래 키까지 전부 CHAR(36) 이니, 조인용 인덱스도 같이 부풀어 오릅니다.
비교 연산의 성격이 다릅니다
uuid 비교는 고정 길이 128비트 비교예요. CHAR(36) 비교는 문자열 비교이고 콜레이션 규칙을 탑니다. 조인 조건이 걸릴 때마다 이 비용이 붙어요.
[해결 방법 - 실제로 재보기]
두 방식으로 같은 구조를 만들고 200,000행을 넣었습니다. PostgreSQL 15이고, 스레드 테이블과 메시지 테이블을 외래 키로 연결한 형태예요. 양쪽에 완전히 같은 UUID 값을 넣어서 비교했습니다.
CREATE TABLE thread_char (
id CHAR(36) PRIMARY KEY,
mail_account_id CHAR(36) NOT NULL,
subject TEXT
);
CREATE TABLE msg_char (
id CHAR(36) PRIMARY KEY,
thread_id CHAR(36) NOT NULL REFERENCES thread_char(id),
body TEXT
);
CREATE INDEX idx_msg_char_thread ON msg_char(thread_id);
-- uuid 버전은 CHAR(36) 자리에 UUID 만 넣은 동일 구조
[성과 - 개선 전후 비교]
저장 크기
| 방식 | 테이블 | 인덱스 | 합계 |
|---|---|---|---|
| CHAR(36) | 43 MB | 45 MB | 88 MB |
| uuid | 26 MB | 25 MB | 51 MB |
바이트로는 92,332,032 대 53,207,040 입니다. 1.74배 차이예요.
눈여겨볼 건 인덱스입니다. 45 MB에서 25 MB로 줄었어요. 테이블보다 인덱스 쪽 절감이 더 컸습니다. 인덱스는 키만 담기 때문에 키 크기 차이가 그대로 드러나요.
조인 성능
두 테이블을 조인하는 쿼리를 EXPLAIN (ANALYZE, BUFFERS) 로 돌렸습니다.
| 방식 | 실행 시간 | Hash Batches | temp 블록 |
|---|---|---|---|
| CHAR(36) | 116.874 ms | 4 | read 2,766 / written 2,766 |
| uuid | 66.518 ms | 2 | read 1,078 / written 1,078 |
1.76배 빨라졌습니다. 그런데 실행 시간보다 중요한 건 옆의 두 열이에요.
Hash Batches 가 4에서 2로 줄었습니다. 해시 조인은 작은 쪽 테이블을 메모리에 올려두고 큰 쪽을 훑는 방식인데, work_mem 에 다 안 들어가면 배치로 쪼개서 디스크를 오갑니다. CHAR(36) 쪽은 키가 커서 메모리에 안 들어갔고, 그래서 배치가 두 배로 늘었어요.
temp 블록도 2,766에서 1,078로 줄었습니다. 디스크에 쏟아낸 양이 2.5배 차이예요.
즉 느려진 이유는 문자열 비교 비용이 아니라 데이터가 커서 메모리에 안 들어간 것이었습니다. 이게 측정하지 않았으면 몰랐을 부분이에요. 저는 콜레이션 비교 비용이 주범일 거라고 예상했는데 실제로는 I/O 문제였습니다.
[결론]
PostgreSQL을 쓰면서 UUID를 CHAR(36) 으로 저장하면, 같은 데이터에 1.74배의 저장 공간을 쓰고 조인이 1.76배 느려집니다.
MySQL에서 넘어온 습관이 원인이었어요. MySQL에는 UUID 타입이 없으니 CHAR(36) 이 자연스러운 선택이지만, PostgreSQL에는 16바이트 네이티브 타입이 있습니다. DB를 바꾸면서 같이 봤어야 할 부분이었어요.
다만 지금 당장 바꿀지는 다른 문제입니다. 한계와 조건을 적어둘게요.
첫째, 마이그레이션 비용이 작지 않습니다. 모든 테이블의 기본 키와 외래 키를 동시에 바꿔야 해요. 외래 키 제약을 풀고, 타입을 바꾸고, 다시 걸어야 합니다. 실사용자가 있는 서비스라 다운타임 계획이 필요합니다.
둘째, 지금 규모에서는 체감되지 않습니다. 가입자 322명 규모에서 88 MB와 51 MB의 차이는 문제가 아니에요. 이 측정은 200,000행 기준이고, 실제 데이터는 그보다 작습니다. 당장 급한 개선은 아닙니다.
셋째, 측정 환경이 실제와 다릅니다. work_mem 기본값에서 돌린 결과라, 값을 올리면 배치 분할이 사라져 차이가 줄어들 수 있어요. 반대로 데이터가 더 커지면 차이는 벌어집니다.
넷째, BINARY(16) 이라는 중간 선택지도 있습니다. MySQL에서 흔히 쓰는 방식인데, PostgreSQL에서는 네이티브 uuid 가 있으니 굳이 고를 이유가 없어요.
결론적으로 다음에 새로 만드는 테이블부터 uuid 를 쓰기로 했습니다. 기존 테이블은 데이터가 더 쌓이거나 조인 성능이 실제로 문제가 될 때 옮기려고 해요.
DB를 바꿀 때 애플리케이션 코드만 보고 스키마 관례는 그대로 가져왔다는 걸 이번에 알았습니다. 타입 하나가 이렇게 티가 날 줄은 몰랐어요.