실험과 해결

개발서버에선 멀쩡했던 UUID_SHORT(), 서버 이전 후 깨진 이유

이요셉 (@kebab-lee) 2026. 10. 1. 23:30

무슨 일이 있었나

회사 서비스의 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 구성, 노드 설정)에서 안전한지부터 확인하자.