목차
무슨 일이 있었나
회사 서비스의 DB 서버를 IDC에서 NCP(Naver Cloud Platform)로 이전했다. 이전 직후, 외부 API로 받은 판매 데이터를 적재하는 프로시저가 keyId 생성 단계에서 오류를 내기 시작했다.
코드는 한 줄도 바뀌지 않았다. 개발서버에서는 지금도 정상이다. 그렇다면 남는 건 환경 차이뿐이었다.
원인: UUID_SHORT()는 server_id를 쓴다
keyId는 UUID_SHORT()로 만들고 있었다. 이름 때문에 단순한 랜덤 함수라고 생각했는데, 실제로는 아래 공식으로 64bit 정수를 만든다.
(server_id & 255) << 56
+ (서버 시작 시각(초) << 24)
+ 호출마다 1씩 증가하는 카운터
상위 8비트를 server_id가 결정한다. 그리고 두 서버의 server_id가 달랐다.
| 기존 서버 | NCP 서버 | |
|---|---|---|
| server_id | 1 | 400번대 |
| server_id & 255 | 1 | 144 (400 기준) |
| 생성값 자릿수 | 약 18자리 | 약 20자리 |
NCP 관리형 MySQL은 Primary / Standby / Read Replica 구조라 인스턴스마다 server_id가 다르고 고정값이라는 보장이 없다. UUID_SHORT()는 "server_id가 서로 다르고 서버 시각이 되돌아가지 않는다"는 전제에서만 고유성을 보장하기 때문에, 이런 환경에서 키 생성기로 쓰기엔 애초에 위험한 선택이었다.
해결: 환경에 의존하지 않는 방식으로 교체
UUID_SHORT()를 걷어내고 keyId를 직접 조합하도록 바꿨다.
FLOOR(DATE_FORMAT(NOW(), '%Y%m%d%H%i%s') + RAND() * 899999999999999)
+ t.RowSeq * 100
+ CASE secb.Type
WHEN 'owner' THEN 21
WHEN 'develop' THEN 22
WHEN 'hub' THEN 23
WHEN 'manage' THEN 24
ELSE 29
END
- 랜덤 베이스: 약 15자리 범위에서 값을 분산
RowSeq * 100: 배치 안의 행 순번Type코드: 한 행에서 여러 서브 레코드를 만들 때 구분
값이 15~16자리라 BIGINT 범위 안에 여유 있게 들어오고, server_id와 무관하게 어느 서버에서든 같은 형태로 만들어진다. 같은 패턴을 이력 테이블과 서브 테이블 INSERT에도 동일하게 적용했다.
다만 한계도 분명하다. RAND()가 행마다 새로 계산되므로 이 방식은 고유성을 보장하지 않고, 충돌 확률을 아주 낮게 만들 뿐이다. 운영 장애를 빠르게 막는 데는 충분했지만, 장기적으로는 아래처럼 목적에 맞는 방식으로 옮기는 게 맞다.
그래서 keyId는 어떻게 만들어야 할까
먼저 keyId에 무엇이 필요한지부터 정해야 한다.
1. 단순히 행을 구분하는 식별자라면 → AUTO_INCREMENT
외부에 노출되지 않고 내부에서 행을 구분하기만 하면 되는 키라면 고민할 필요 없이 DB에 맡기면 된다.
CREATE TABLE sellinfo (
SellInfoId BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
...
PRIMARY KEY (SellInfoId)
);
INSERT INTO sellinfo (...) VALUES (...);
SELECT LAST_INSERT_ID(); -- 방금 생성된 키 조회
- 고유성을 DB가 보장한다.
- 서버를 옮기거나 failover가 일어나도 동작이 같다.
- 값이 순차적이라 인덱스(클러스터드 인덱스) 효율도 가장 좋다.
이번 케이스처럼 한 번에 여러 테이블에 INSERT하며 부모 키를 자식에게 넘겨야 할 때도, 부모를 먼저 INSERT하고 LAST_INSERT_ID()로 받아서 쓰면 된다.
2. 추측하기 어려워야 하는 키라면 → 인프라에 맞는 방법을 골라야 한다
URL이나 API로 외부에 노출되는 키는 1, 2, 3...처럼 순차적이면 다른 데이터의 ID를 쉽게 추측할 수 있다. 이럴 땐 랜덤성이 있는 키가 필요한데, 이때부터는 지금 서버와 인프라 구성에서 그 방식이 안전한지를 반드시 확인해야 한다. 이번 사고가 정확히 이 지점에서 났다.
| 방식 | 특징 | 인프라 관점에서 확인할 점 |
|---|---|---|
애플리케이션에서 UUID v4 생성 (Java UUID.randomUUID()) |
완전 랜덤, 추측 불가 | DB 설정과 무관해서 가장 안전. 단 랜덤이라 PK로 쓰면 인덱스 단편화가 생김 |
| UUID v7 (애플리케이션 라이브러리) | 앞부분이 시간순이라 정렬·인덱스에 유리 | 서버 간 시각 차이가 크면 순서가 어긋날 수 있음 |
MySQL UUID() + UUID_TO_BIN(UUID(), 1) 저장 |
DB에서 생성, BINARY(16)로 저장해 용량 절약 |
UUID()는 v1(시간 + MAC 기반)이라 랜덤이 아님. 추측 방지 용도로는 부적합 |
| Snowflake 같은 분산 ID | 64bit 정수, 시간순 정렬 | 서버마다 고유한 worker id 설정이 필요. server_id와 똑같은 함정이 있음 |
실무에서 많이 쓰는 절충안은 내부 PK는 AUTO_INCREMENT, 외부 노출용은 별도 컬럼에 UUID를 두는 방식이다. 조인과 인덱스는 순차 PK로 빠르게 하고, 밖으로는 UUID만 보여주면 된다.
CREATE TABLE sellinfo (
SellInfoId BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, -- 내부용
PublicId CHAR(36) NOT NULL, -- 외부 노출용 (애플리케이션에서 UUID 생성)
...
PRIMARY KEY (SellInfoId),
UNIQUE KEY uk_public_id (PublicId)
);
배운 점
- DB 함수도 서버 설정에 의존할 수 있다.
UUID_SHORT()의 결과는server_id에 따라 자릿수부터 달라진다. 이름만 보고 동작을 짐작하지 말고 공식 문서를 확인하자. - "개발에선 됐는데"는 코드보다 환경을 먼저 의심하자. 이번에도 코드는 그대로였고 바뀐 건 서버 설정 하나였다.
- 서버 이전 체크리스트에 DB 설정값을 넣자.
server_id, 타임존,sql_mode, 문자셋처럼 쿼리 결과에 영향을 주는 값은 이전 전후로 비교해봐야 한다. - 키 생성은 인프라가 바뀌어도 똑같이 동작해야 한다. 단순 식별자라면 AUTO_INCREMENT처럼 DB가 책임지는 방식을 쓰고, 랜덤성이 필요하다면 그 방식이 지금 인프라(서버 수, HA 구성, 노드 설정)에서 안전한지부터 확인하자.