본문 바로가기
시스템 디자인

[시스템 설계 - 고유 ID] Ep.4 시계가 거꾸로 갑니다: 고유 ID 만들기가 어려운 진짜 이유

by Renechoi 2026. 7. 31.

분산 환경으로 시스템을 확장할 때, 예상치 못한 곳에서 발목을 잡혔다면 무엇 때문일까요?

 

트래픽을 견디려 서버를 늘립니다. 병목을 줄이려 데이터베이스를 쪼갭니다. 그러자 단일 서버 시절 공기처럼 당연하게 쓰던 기초적인 기능들이 시스템의 발목을 잡기 시작합니다.

 

그중 하나가 바로 식별자 발급입니다.

 

단일 데이터베이스 환경에서는 너무나 당연하고 쉬웠던 일이, 서버가 늘어나고 데이터베이스가 쪼개지는 순간 거대한 아키텍처적 고민거리로 둔갑합니다.

 

이 글에서는 분산 환경에서 고유한 ID를 생성하기 위해 업계가 어떤 대가를 치르며 발전해 왔는지, 그리고 우리가 마주한 시스템에는 어떤 방식이 가장 적합할지 트레이드오프의 관점에서 살펴보겠습니다.

0. 겹치거나 멈추거나: 분산 환경의 딜레마

그런데 이것은 왜 필요한 걸까요? 도대체 왜 분산 환경에서 '고유한 숫자 하나'를 만드는 게 이토록 골치 아픈 문제가 되는지 짚고 넘어가면 좋겠습니다.

 

우리가 게시판에 글을 쓰거나 쇼핑몰에서 주문을 할 때, 시스템은 반드시 '절대 겹치지 않는 고유한 식별자'를 발급해야 합니다. 그래야 나중에 그 데이터를 정확히 찾고, 수정하고, 지울 수 있으니까요. 단일 데이터베이스 서버 한 대만 쓸 때는 아무 문제가 없습니다. 서버가 혼자서 장부를 쥐고 있으니 "방금 99번이 나갔으니, 다음 번호는 100번이야"라고 통제하면 끝납니다.

 

하지만 서비스가 성장해서 데이터베이스를 여러 대로 쪼개는 순간 상황은 180도 달라집니다. 1번 데이터베이스와 2번 데이터베이스가 서로 소통하지 않은 채 각자 "다음 번호는 100번이야"라고 발급해버린다면 어떻게 될까요? 나중에 두 데이터를 모아서 볼 때 식별자가 충돌하는 대형 사고가 터집니다.

 

그렇다고 번호가 겹치는 걸 막겠답시고 수십 대의 분산 서버가 매번 하나의 중앙 통제소에 모여 "이번엔 몇 번 쓸 차례인가요?"라고 묻는다면 어떻게 될까요? 그 중앙 통제소는 엄청난 트래픽 병목을 겪게 됩니다. 심지어 그 통제소가 죽기라도 하면 전체 시스템의 글쓰기와 주문이 일제히 멈춰버리는 단일 장애점이 되고 말죠.

 

 

결국 분산 시스템에서의 ID 설계란, '서버들끼리 매번 대화(조율)하지 않으면서도, 각자 알아서 절대 겹치지 않는 유일한 번호를 빠르게 찍어내야 한다'는 거의 모순에 가까운 미션을 해결하는 과정입니다.

 

이 딜레마를 머릿속에 담아두고, 우리가 잃어버린 과거의 평화로웠던 시절부터 다시 출발해 보겠습니다.

1. 가장 쉽고 확실한 정답, AUTO_INCREMENT

관계형 데이터베이스의 AUTO_INCREMENT는 그 자체로 훌륭한 기본값입니다. 장점은 세 가지로 뚜렷합니다. 첫째, 8바이트(BIGINT) 크기면 충분할 만큼 길이가 짧습니다. 둘째, 항상 나중에 만든 것이 더 크도록 완벽하게 정렬됩니다. 셋째, 데이터베이스 인덱스 구조와 찰떡궁합을 자랑합니다.

 

세 번째 장점이 이 글 내내 따라다니니 약간의 용어를 짚고 가겠습니다. 

 

먼저 페이지입니다. 데이터베이스가 디스크를 읽고 쓰는 최소 단위이고, InnoDB의 기본 크기는 16KB입니다. 여기서 중요한 것은 데이터베이스가 행 하나만 콕 집어 읽는 법이 없다는 사실입니다. 그 행이 들어 있는 페이지를 통째로 집어 옵니다. 그러니 페이지 한 장에 쓸모 있는 행이 몇 개나 담겨 있느냐가 그대로 성능이 됩니다.

 

다음은 클러스터드 인덱스입니다. 보통 인덱스라고 하면 책 뒤의 찾아보기를 떠올리게 됩니다. "그 단어는 몇 쪽에 있다"고 알려주는 이정표죠. 그런데 InnoDB의 기본키 인덱스는 이정표가 아니라 본문 그 자체입니다. 행 데이터가 기본키 순서대로 인덱스 안에 직접 들어앉아 있습니다.

 

둘을 합치면 결론이 나옵니다. 기본키를 무엇으로 정하느냐가 곧 그 행이 디스크의 어느 페이지에 적히느냐를 정합니다. 조회가 빠르냐 느리냐의 문제가 아니라 데이터가 어떻게 쌓이느냐의 문제라는 뜻입니다.

 

이제 순차 키의 이점이 선명해집니다. 계속 커지기만 하는 키를 차곡차곡 밀어 넣으면, 새 데이터는 언제나 인덱스 트리의 맨 오른쪽 끝 페이지에 붙습니다. 앞쪽 페이지들은 건드릴 일이 없죠. MySQL 매뉴얼은 이 경우 페이지가 약 15/16까지 찬다고 적었습니다. 16KB 중 15KB가 실제 데이터라는 뜻입니다. 그리고 성능을 깎아먹는 '페이지 분할'이 거의 일어나지 않습니다.

 

반대로 무작위 키를 삽입하면 어떻게 될까요? 새 데이터가 이미 꽉 찬 트리의 중간을 억지로 비집고 들어가야 합니다. 이 과정에서 페이지가 강제로 쪼개집니다. 매뉴얼은 이때 페이지가 1/2에서 15/16 사이로만 찬다고 적었습니다. 디스크에 반쯤 텅 빈 페이지들이 흉물스럽게 쌓이게 되죠.

 

순차 키와 무작위 키의 페이지 채움률

 

이 강력한 이점을 분산 환경에서도 유지해 보기 위해, 가장 직관적인 아이디어를 적용해 봅니다. MySQL은 auto_increment_incrementauto_increment_offset이라는 설정을 제공하거든요. 만약 서버가 두 대라면 증가 폭을 2로 설정합니다. 한 대는 시작점을 1로 주어 홀수(1, 3, 5...)만 발급하게 하고, 다른 한 대는 시작점을 2로 주어 짝수(2, 4, 6...)만 발급하게 만드는 식입니다. 이렇게 하면 번호는 절대 겹치지 않습니다.

 

이 우회법은 꽤 유효한 해법 같습니다. 하지만 이내 치명적인 한계를 마주하죠. 바로 서버 대수를 바꾸는 순간 시스템의 전제가 무너진다는 사실입니다. 트래픽이 몰려 서버를 두 대에서 세 대로 늘리려면 증가 폭을 3으로 바꿔야 합니다. 그러려면 기존에 발급된 수많은 홀수와 짝수 틈새로 새로운 수열을 안전하게 밀어 넣기 위해 모든 관계를 처음부터 다시 계산해야 합니다. 무중단 운영 중에 데이터베이스 대수를 늘리는 작업이 사실상 불가능해지는 것이죠!

 

어쩔 수 없네요. 그런데 이 방식은 여기서 한계를 맞고 버려지는 듯하지만, 이 아이디어 자체가 완전히 끝난 것은 아닙니다. 3장에서 다룰 '티켓 서버' 모델에서 이 단순한 설정을 얼마나 영리하게 다시 꺼내 쓰는지 확인해 보겠습니다.

 

우선은 데이터베이스 바깥으로 시선을 돌려보죠. 중앙의 개입 없이 각자 완벽하게 고유한 ID를 만들 방법은 없을까요?

2. 장치 하나, UUID: 좌표를 버리고 공간을 산다

번호를 순서대로 예쁘게 나눠주려면 누군가는 전체 상황을 지켜보며 조율해야 합니다. 앞서 본 데이터베이스의 카운터가 그 역할을 했죠. 그렇다면 이 조율을 아예 포기하면 어떻게 될까요. 순서를 잃는 대신, 겹칠 확률이 무시할 만큼 거대한 공간에서 무작위로 값을 뽑아내는 방식을 택할 수 있습니다. 이것이 바로 UUID, 범용 고유 식별자가 취하는 전략입니다. 복잡한 조율 과정을 팔고, 광활한 공간을 샀습니다.

 

가장 널리 쓰이는 UUIDv4는 128비트로 이루어져 있습니다. 이 중 6비트는 버전과 변형을 표시하는 데 쓰고, 나머지 122비트가 순수한 난수입니다. 생일 문제로 계산해 보면, 충돌 확률이 50퍼센트가 되는 지점은 $\sqrt{2\ln 2 \cdot 2^{122}} \approx 2.71 \times 10^{18}$개입니다. 우리말로 약 271경 개입니다!

 

이 숫자를 체감할 수 있게 시간 기준으로 치환해 봅시다. 만약 시스템이 초당 100만 개의 ID를 끊임없이 생성한다고 가정한다면, 50퍼센트 충돌 확률에 도달하는 데 무려 86,032년이 걸립니다. 처리량을 초당 10억 개로 극한까지 끌어올려도 약 86년이 필요합니다. 실무적인 관점에서 이 정도면 충돌 확률은 기우에 불과합니다.

 

하지만 세상에 공짜는 없습니다. 거대한 공간을 얻은 대가로 세 가지 무거운 청구서가 날아옵니다.

 

첫째, 길이가 너무 깁니다. 문자열로 표현하면 하이픈을 포함해 36자입니다. 텍스트 그대로 저장하면 36바이트라 8바이트 정수의 4.5배죠. RFC 9562는 데이터베이스에는 128비트 이진값으로 저장하라고 권고하는데, 그렇게 줄여도 16바이트라 여전히 두 배입니다.


둘째, 정렬되지 않습니다. 나중에 만든 ID가 먼저 만든 ID보다 작을 수 있어 시간순으로 데이터를 정렬하기 까다로워집니다.

 

가장 치명적인 세 번째 대가는 바로 1장에서 설명한 인덱스 성능의 파괴입니다. 순서가 없는 난수가 기본키로 들어오면, 새 데이터는 인덱스 트리의 맨 끝이 아니라 트리의 중간 어딘가로 무작위로 파고들어야 합니다. 이 과정에서 꽉 찬 디스크 페이지를 강제로 쪼개는 '페이지 분할'이 빈번하게 일어납니다. 2024년에 나온 최신 표준 문서조차 이 문제를 명확하게 경고합니다.

 

"UUID versions that are not time ordered, such as UUIDv4 (described in Section 5.4), have poor database-index locality. This means that new values created in succession are not close to each other in the index; thus, they require inserts to be performed at random locations. The resulting negative performance effects on the common structures used for this (B-tree and its variants) can be dramatic."

"UUIDv4처럼 시간순으로 정렬되지 않는 UUID 버전은 데이터베이스 인덱스 지역성이 떨어집니다. 연속으로 생성된 새 값들이 인덱스 상에서 서로 가까이 위치하지 않는다는 뜻입니다. 따라서 무작위 위치에 삽입을 수행해야 합니다. 그로 인해 이런 용도에 흔히 쓰이는 자료구조(B-트리와 그 변형)에 미치는 성능 악화는 극적일 수 있습니다."

- RFC 9562, Universally Unique IDentifiers, 2024년 5월, 2.1절 Update Motivation

 

 

이처럼 데이터가 순차적으로 모여 있는 인덱스 지역성을 잃어버리면 트리가 망가지고 반쯤 빈 페이지가 늘어나며 데이터베이스 쓰기 성능이 곤두박질칩니다. 표준 문서의 덧붙임은 꽤나 서늘합니다.

"The real-world differences in this approach of index locality versus random data inserts can be one order of magnitude or more."

"인덱스 지역성을 갖춘 접근과 무작위 데이터 삽입 사이의 실제 성능 차이는 10배 이상일 수 있습니다."

- 같은 문서, 6.11절 Sorting

 

결국 조율을 포기한 무작위 추출은 성능이라는 너무 큰 희생을 요구합니다.

 

여기서 우리는 깊은 딜레마에 빠집니다. 분산 환경의 병목을 없애려고 중앙 통제를 포기했더니, 이번엔 데이터베이스 쓰기 성능이 박살 나버렸습니다. 인덱스 성능을 살리려면 결국 1장처럼 '순차적으로 증가하는 짧은 숫자'로 다시 돌아가야만 합니다.

 

하지만 1장에서 이미 확인했듯, 여러 대로 쪼개진 샤딩 환경에서는 순차적인 숫자를 안전하게 발급하기가 너무 까다롭습니다.

 

이 모순을 깨기 위해 발상의 전환이 필요합니다. "데이터를 저장하는 역할"과 "번호표를 뽑아주는 역할"을 완전히 분리해 버리면 어떨까요?

 

서비스의 메인 데이터베이스들은 마음껏 여러 대로 쪼개어 확장하되, 오직 '번호표만 전문으로 뽑아주는 아주 가벼운 기계'를 따로 두는 겁니다.

3. 장치 둘, 티켓 서버: 중앙을 두되 아주 얇게

2010년 2월, 세계적인 사진 공유 서비스였던 플리커의 엔지니어링 팀이 자기네 ID 발급 구조를 기술 블로그에 공개했습니다. 이름이 '티켓 서버'입니다.

 

이름부터 오해를 부르는데, 여기서 티켓 서버는 애플리케이션이 아닙니다. 보통 서버라고 하면 API를 열어둔 애플리케이션을 떠올리는데, 여기서는 사실 AP 서버가 아닌 DB를 의미합니다. 

 

"A Flickr ticket server is a dedicated database server, with a single database on it, and in that database there are tables like Tickets32 for 32-bit IDs, and Tickets64 for 64-bit IDs."

"플리커 티켓 서버는 전용 데이터베이스 서버이며, 그 위에 데이터베이스 하나가 올라가 있고, 그 안에 32비트 ID용 Tickets32와 64비트 ID용 Tickets64 같은 테이블이 있습니다."

- 플리커 엔지니어링 블로그, Ticket Servers, 2010년 2월

 

MySQL 한 대입니다. 그 위에 도는 코드도, 서비스 프로세스도 없습니다. 애플리케이션은 그 MySQL에 직접 접속해서 SQL 두 줄을 날리고 숫자 하나를 받아 옵니다. 중간에 아무것도 없습니다.

왜 애플리케이션이 아니라 데이터베이스인가

번호 하나 세는 일인데 애플리케이션에서 하면 안 될까요? 플리커가 이 구조에 도달한 순서를 따라가면 그 이유를 알게 됩니다. 

 

데이터베이스를 여러 대로 쪼개니 AUTO_INCREMENT가 전역 유일성을 보장하지 못한다. 그럼 UUID는 어떤가. 원문 표현으로 "크고, MySQL에서 인덱스가 나쁘다"는 이유로 접는다. 그렇다면 데이터베이스를 딱 한 대만 쓰면 되지 않을까. 사진이 올라올 때마다 그 한 대에 행을 하나 넣고 거기서 나온 AUTO_INCREMENT 값을 전체의 기본키로 쓰자. 그런데 초당 60장씩 쌓이면 그 테이블이 감당이 안 된다. 그래서 행을 쌓지 않고 한 행만 갈아 끼우는 방법을 찾은 것이 REPLACE INTO입니다.

 

즉 이건 "MySQL을 도구로 골랐다"가 아니라 "AUTO_INCREMENT를 포기하기 싫어서 그것만 한 대로 몰았다"는 이야기입니다. 티켓 서버는 새로 발명한 물건이 아니라 1장의 그 카운터를 따로 떼어낸 것입니다.

 

그러면 애플리케이션으로는 왜 안 될까요? 두 가지가 걸립니다. 첫째, 애플리케이션 서버는 여러 대입니다. 각자 메모리에 카운터를 하나씩 들면 0장에서 본 그 문제로 곧장 돌아갑니다. 겹치지 않으려면 결국 한 대에 물어봐야 하고, 그 한 대가 곧 티켓 서버입니다. 둘째, 번호는 재시작해도 잊으면 안 됩니다. 메모리 카운터는 프로세스가 죽으면 어디까지 줬는지 잊습니다. 그러니 어딘가에 적어야 하고, 그 적기가 동시 요청 속에서도 안전해야 합니다.

 

원자적으로 1을 올리고, 그 값을 안 잃어버리는 것. 이게 정확히 데이터베이스가 이미 하는 일입니다. 그러니 애플리케이션으로 못 한다기보다는, 제대로 하려고 들면 결국 데이터베이스를 다시 만들게 됩니다. 플리커는 이미 잘 운영하던 MySQL을 그대로 썼을 뿐입니다. 요즘 같은 용도에 자주 쓰이는 Redis의 INCR도 모양이 똑같습니다. 원자적 증가와 영속성을 남이 만든 물건에서 빌려 오는 것이죠.

테이블 하나에 행 하나

플리커가 이 서버에 만든 테이블은 이게 전부입니다.

CREATE TABLE `Tickets64` (
  `id` bigint(20) unsigned NOT NULL auto_increment,
  `stub` char(1) NOT NULL default '',
  PRIMARY KEY  (`id`),
  UNIQUE KEY `stub` (`stub`)
) ENGINE=InnoDB

 

(원문은 2010년 발행 당시 ENGINE=MyISAM으로 적혀 있었고 이후 InnoDB로 수정되었습니다. 지금 링크를 열면 후자가 보입니다.)

 

핵심은 stub에 걸린 고유 인덱스입니다. 애플리케이션이 번호를 받고 싶을 때 날리는 쿼리는 단 두 줄입니다.

 

REPLACE INTO Tickets64 (stub) VALUES ('a');
SELECT LAST_INSERT_ID();

 

INSERT 대신 REPLACE INTO를 쓰는 근거로 플리커는 MySQL 매뉴얼의 정의를 그대로 인용합니다.

 

"REPLACE works exactly like INSERT, except that if an old row in the table has the same value as a new row for a PRIMARY KEY or a UNIQUE index, the old row is deleted before the new row is inserted."

"REPLACE는 기본키나 고유 인덱스에 대해 새 행과 동일한 값을 가진 이전 행이 테이블에 있을 경우, 새 행을 삽입하기 전에 이전 행을 삭제한다는 점만 제외하면 INSERT와 정확히 동일하게 동작합니다."

- MySQL 레퍼런스 매뉴얼, REPLACE Statement. 플리커 원문이 링크로 인용한 문장입니다.

 

동작은 이렇습니다. 언제나 stub에 'a'를 넣으려 시도하는데, stub은 중복을 허용하지 않으니 MySQL은 기존 'a' 행을 지우고 새 'a' 행을 넣습니다. id는 AUTO_INCREMENT이므로 그때마다 1 올라갑니다. 그 값을 LAST_INSERT_ID()로 꺼내 오는 것이죠.

 

 

그래서 이 테이블은 커지지 않습니다. 행이 쌓일 틈이 없이 한 행만 갈아 끼워지니 디스크도 거의 안 쓰고 카운터만 올라갑니다. 아주 얇은 중앙 통제소를 얻은 셈입니다.

 UPDATE가 아니라 REPLACE인가

여기서 "그냥 UPDATE 테이블 SET id = id + 1을 쓰면 안 되나"라는 의문이 들 수 있습니다. 문제는 그다음에 SELECT id FROM 테이블로 값을 다시 읽어야 한다는 데 있습니다. 이 두 번째 조회는 모두가 공유하는 그 한 행을 다시 읽습니다. 수십 대가 동시에 찔러대면 내가 올린 값을 읽기도 전에 다른 서버가 한 번 더 올려버립니다. 두 서버가 같은 번호를 들고 돌아가는 것이죠.

 

비밀은 LAST_INSERT_ID()에 있습니다. 이 함수는 현재 접속한 내 커넥션에서 방금 생성된 값만 기억합니다. 1,000대가 같은 0.001초에 저 두 줄을 실행해도 각자 자기 번호만 안전하게 쥐고 돌아갑니다. 락을 걸어 줄을 세울 필요가 없으니 빠르기까지 하죠.

 

다만 정확히 하자면, 안전을 만드는 것은 UPDATE냐 REPLACE냐가 아닙니다. MySQL 매뉴얼은 LAST_INSERT_ID(expr)를 쓰면 UPDATE로도 같은 안전성을 얻을 수 있다고 안내합니다. 값을 커넥션별로 기억하느냐, 공유된 행에서 다시 읽느냐. 갈림길은 거기입니다.

앱에서 보면 어떻게 흘러가는가

한 장으로 보면 이렇습니다.

 

 

애플리케이션은 데이터베이스를 두 종류 씁니다. 하나는 티켓 서버입니다. 번호만 받아 오고 데이터는 넣지 않습니다. 다른 하나는 실제 사진이 들어가는 샤드입니다. 받아 온 번호를 기본키로 삼아 여기에 저장합니다.

 

티켓 서버는 사진에 손도 대지 않습니다. 샤드에 쓰는 일은 애플리케이션이 직접 합니다. 번호를 만드는 곳과 데이터를 넣는 곳을 갈라놓은 것, 이게 이 구조의 전부입니다.

한 대가 죽으면 멈추지 않나

중앙에 한 대를 두면 늘 따라오는 걱정입니다. 그 MySQL이 죽으면 사진 업로드도 댓글도 전부 번호를 못 받아 멈추니까요.

 

플리커는 1장의 그 우회법을 여기서 다시 꺼냅니다. 번호 발급 전용 MySQL을 두 대 두고, 한 대는 auto-increment-increment = 2에 auto-increment-offset = 1로 홀수를, 다른 한 대는 offset = 2로 짝수를 뱉게 합니다.

 

여기서 오해하기 쉽습니다. 이 두 대는 복제 쌍이 아닙니다. 서로의 존재를 모르는 독립된 MySQL 두 대이고, 각자 자기 Tickets64와 자기 카운터를 따로 굴립니다. 복제를 안 한 이유도 원문에서 보이네요.

 

"At this write/update volume replicating between the boxes would be problematic, and locking would kill the performance of the site."

"이 정도의 쓰기와 갱신 볼륨에서 두 장비 사이의 복제는 문제를 일으킬 것이고, 락은 사이트의 성능을 죽일 것입니다."

- 같은 글

 

즉 두 대가 겹치지 않는 근거는 통신이 아니라 산수입니다. 처음부터 수열을 홀짝으로 갈라놓았으니 서로 물어볼 일이 없습니다. 조율을 없애려고 조율 담당을 두는 자기모순을 이렇게 피한 것입니다.

 

 

애플리케이션은 두 대에 번갈아 요청을 보냅니다. 홀수 서버가 멈춰도 짝수 서버가 살아 있으니 발급은 이어집니다.

 

대가는 연속성입니다. 번호가 1, 2, 3, 4로 예쁘게 떨어지지 않고 순서가 조금 뒤섞이거나 중간이 비기도 합니다. 원문도 두 줄기가 어긋난다는 사실을 인정하면서 "이것은 누구에게도 해가 되지 않는다"고 적었습니다. 겹치지만 않으면 되는 열쇠로 쓸 것이라면 정말로 상관이 없거다는 것입니다. 

 

그런데 정말 그럴까요? 그럴 수도 있고 아닐 수도 있겠죠. 이 부분은 4장에서 또 나올 예정입니다!

왜 하필 두 대인가

세 대도 되지 않나, 하는 의문이 남습니다. increment를 3으로 두고 offset을 1, 2, 3으로 주면 세 서버가 각각 1·4·7, 2·5·8, 3·6·9를 뱉습니다. N대로 일반화되는 방식이죠. 원문은 왜 둘이었는지 밝히지 않았으니, 아래는 제 추론입니다.

 

먼저 2가 목적을 달성하는 최소값입니다. 한 대에서 두 대로 가는 이유는 딱 하나, 한 대가 죽어도 멈추지 않게 하는 것입니다. 두 번째 장비에서 이미 해결됩니다. 세 대는 "두 대가 동시에 죽어도 버틴다"를 사는 셈인데, 카운터만 올리는 전용 장비 두 대가 동시에 죽을 확률에 2010년 물리 장비 한 대 값을 쓰기는 아깝습니다.

 

그리고 더 중요한 이유가 있습니다. 나중에 늘리는 것이 위험합니다. 1장에서 "서버 대수를 바꾸면 전제가 무너진다"고 했던 그 문제가 여기서도 그대로 나옵니다.

 

홀수 서버가 1000001까지, 짝수 서버가 600000까지 발급한 상태를 생각해 보죠. 여기서 세 대로 늘리려고 increment를 3으로 바꾸면, 짝수였던 서버는 600000 다음에 600003을 뱉습니다. 그런데 600003은 홀수입니다. 홀수 서버가 이미 오래전에 발급한 번호죠. 충돌입니다.

 

안전하게 늘리려면 세 서버의 카운터를 전부 현재 전체 최댓값 위로 먼저 올려야 합니다. 그러려면 서로 몇 번까지 갔는지 비교해야 하고, 그게 바로 이 구조가 없애려던 조율입니다. 결국 이 방식에서 대수는 처음에 정하면 사실상 끝입니다. 그러니 평생 이고 갈 최소값을 고르는 것이 합리적이고, 그 값이 둘입니다.

플리커가 새로 발명한 것은 무엇인가

여기까지 보고 나면 한 가지가 걸립니다. 그래서 플리커가 발명한 대단한 기술이 뭐냐는 것이죠.

 

없습니다. REPLACE INTO도, LAST_INSERT_ID()도, auto_increment_increment도 MySQL에 오래전부터 있던 평범한 기능입니다. 다들 중복 에러를 피하거나 다중 마스터를 세팅하는 정해진 용도로만 쓰던 기능이죠.

 

플리커의 진짜 성과는 기존 도구의 재배치입니다. 특히 1장에서 "메인 데이터베이스 수십 대에 적용하면 망한다"며 버렸던 그 낡은 홀짝 설정을, 번호만 발급하는 가벼운 전용 장비 두 대로 범위를 좁히는 순간 훌륭한 해답으로 바꿔놓았습니다. 같은 도구가 어디에 놓이느냐로 막다른 길이 되기도 하고 답이 되기도 한다는 뜻입니다.

 

평범한 도구를 어디에 놓느냐가 더 강한 무기가 될 수 있다는 사실을 가르쳐주는 듯 합니다. 

 

4. 장치 셋, 스노우플레이크: 시간을 ID 안에 넣는다

트윗 세 개가 올라옵니다. 9시 정각에 하나, 9시 1분에 하나, 9시 2분에 하나.

 

번호는 이렇게 나갑니다. 600002번, 1000003번, 600004번. 짝수 서버가 60만 번대를 지나는 동안 홀수 서버는 이미 100만 번대를 달리고 있었거든요. 이제 이 셋을 번호순으로 세우면 600002, 600004, 1000003입니다. 9시 1분 트윗이 맨 뒤로 갑니다.

 

3장의 그 홀짝 티켓 서버 이야기입니다. 두 카운터가 각자의 속도로 올라가니 어긋나는 것이 당연하고, 플리커는 그 어긋남을 "누구에게도 해가 되지 않는다"고 넘겼습니다. 트위터는 넘길 수 없었습니다.

 

"These ids need to be roughly sortable, meaning that if tweets A and B are posted around the same time, they should have ids in close proximity to one another since this is how we and most Twitter clients sort tweets."

"이 ID들은 대략적으로 정렬 가능해야 합니다. 트윗 A와 B가 비슷한 시각에 올라왔다면 두 ID가 서로 가까운 값이어야 한다는 뜻입니다. 우리와 대부분의 트위터 클라이언트가 트윗을 그렇게 정렬하기 때문입니다."

- 트위터 엔지니어링 블로그, Announcing Snowflake, 2010년

 

플리커에게 ID는 사진을 찾는 열쇠였습니다. 겹치지만 않으면 그만이고, 정렬은 촬영 시각 컬럼으로 따로 하면 됩니다. 트위터에게 ID는 열쇠인 동시에 타임라인 그 자체였습니다. 트윗을 시간순으로 보여주는 일을 번호 크기 비교로 처리하고 있었으니까요. 그래서 트위터는 티켓 서버가 "재동기화 루틴 같은 것을 따로 만들지 않고서는 우리가 필요한 정렬 보장을 주지 못했다"고 적었습니다.

 

물론 재동기화 루틴을 만들 수는 있습니다. 두 서버가 서로 몇 번까지 갔는지 물어보면 되니까요. 그런데 트위터에게는 조건이 하나 더 있었습니다. 초당 수만 개를 높은 가용성으로 찍어내야 했고, 그래서 "자연스럽게 조율 없는 방식을 택하게 되었다"고 밝혔습니다. 서로 물어보는 순간 그 처리량이 무너집니다.

 

두 요구가 서로를 밀어냅니다. 번호가 시간순으로 커지려면 보통 누군가 한 명이 순서대로 나눠줘야 하는데, 처리량 때문에 없애고 싶은 것이 바로 그 한 명입니다.

 

빠져나갈 구멍은 하나뿐입니다. 모두가 이미 각자 들고 있으면서 서로 대체로 같은 값을 가리키는 무언가. 그것을 번호 앞에 붙이면 아무에게도 묻지 않고도 여러 서버의 번호가 한 줄로 섭니다. 그런 물건이 서버마다 하나씩 있습니다. 시계입니다.

 

2010년 트위터가 공개한 스노우플레이크가 그 답입니다. 64비트를 이렇게 나눕니다.

  • 1비트: 부호. ID가 음수가 되면 곤란하므로 늘 0으로 비워 둡니다.
  • 41비트: 타임스탬프. 밀리초 단위이되 1970년이 아니라 트위터가 따로 정한 기준 시각부터 셉니다.
  • 10비트: 노드 식별자. 원 구현체는 데이터센터와 워커에 5비트씩 쪼갰습니다.
  • 12비트: 시퀀스. 같은 밀리초 안의 충돌을 막는 카운터입니다.

 

 

숫자를 직접 내 보겠습니다. 41비트가 담을 수 있는 최댓값은 $2^{41}-1$, 곧 2,199,023,255,551밀리초이고 연으로 환산하면 약 69.7년입니다. 트위터가 실제로 쓴 기준 시각은 소스 코드에 1288834974657로 남아 있는데 2010년 11월 4일입니다. 그러니 이 공간은 2080년 7월에 꽉 찹니다. 처리량 쪽은 더 넉넉합니다. 12비트 시퀀스가 밀리초당 4,096개니 노드 한 대가 초당 409만 6천 개, 1,024대면 이론상 초당 41억 9천만 개입니다.

 

얻은 것은 분명합니다. 조율 없는 정렬, 그리고 64비트라는 짧은 길이. 시간이 맨 앞에 있으니 값이 저절로 커지고 정렬이 공짜로 따라옵니다.

 

그럼 앞에서 마주쳤던 골치 아픈 문제들이 이제 다 해결된 걸까요? 그동안 겪은 고난의 역사를 체크리스트로 한 번 지워 보겠습니다.

  • 중앙 통제소의 병목? 해결됐네요! 번호 하나 얻겠다고 모두가 한 곳에 줄 서지 않으니까요.
  • 노드 간 조율? 해결됐네요! 각 노드가 자기 시계와 자기 번호만 보고 찍어내니까요.
  • 인덱스 성능 붕괴? 해결됐네요! 시간이 맨 앞에 있어서 알아서 예쁘게 정렬되니까요.
  • 너무 긴 용량? 해결됐네요! 1장과 똑같은 8바이트 정수 안에 기가 막히게 구겨 넣었으니까요.

이쯤 되면 분산 환경의 ID 생성이라는 난제에 대한 완벽한 마스터키를 마침내 찾아낸 기분입니다.

 

그런데... 저 목록에는 거짓말이 한 줄 있고, 빠진 줄이 하나 있습니다.

 

거짓말은 두 번째 줄입니다. 배분표에서 제일 조용해 보이던 저 10비트 때문이죠. README는 그 칸을 "configured machine id", 곧 설정된 머신 번호라고 부릅니다. 설정한다는 말은 누군가 배정한다는 뜻입니다. 라이브러리는 비트를 조립해 주지만 이 번호만은 만들어 주지 못합니다. 게다가 장비마다가 아니라 ID를 만드는 프로세스마다 달라야 합니다. 같은 서비스를 세 벌 띄우면 세 개가 필요하죠.

 

요즘 그 프로세스는 오토스케일링으로 떴다 사라집니다. 새로 뜨는 인스턴스에게 누가 번호를 주고, 죽은 인스턴스의 번호는 누가 회수할까요? 여러 방식을 생각해볼 수 있겠지만 확실한 건 전부 대가가 따릅니다. 배포 설정에 고정하면 오토스케일링이 깨지고, IP나 호스트명에서 유도하면 재사용되는 순간 겹칩니다. 주키퍼나 etcd에서 순번을 받으면 그 물건을 운영해야 하고, 실제로 2010년 트위터가 그렇게 했습니다. 레디스에서 번호를 빌려 오면 피하려던 중앙 카운터가 다시 들어옵니다.

 

그러니 조율은 사라진 것이 아니라 옮겨 간 것입니다. 번호를 뽑을 때마다 묻지는 않지만, 프로세스가 뜰 때 한 번은 합의해야 합니다. 10비트라 그 번호는 1,024개뿐이고, 회수하지 않으면 언젠가 동납니다.

 

그리고 빠진 줄은, 시계입니다. 저 체크리스트 어디에도 시계 항목이 없는 이유는 지웠기 때문이 아니라, 지우기는커녕 여기에 전부를 걸었기 때문입니다. 분산된 모든 노드의 시계가 서로 비슷하고 무조건 앞으로만 흐른다는 믿음, 그 하나가 시스템 전체를 간신히 떠받치고 있습니다. README도 알고 있어서 NTP를 정확히 유지하라 당부하고 "ntp가 시계를 뒤로 옮기지 않는 모드로 실행하라"고까지 권합니다. 라이브러리가 대신 해 줄 수 없는 서버 설정이죠. 

 

이게 중요한가요? 네, 그 이유가 있습니다.

 

그런데 그 전에, 참고로 모두가 표준처럼 인용하는 트위터의 그 저장소는 지금 멈춰 있습니다. twitter-archive/snowflake는 아카이브 상태로 동결되었고 마지막 푸시가 2020년 7월 22일이며 7,769개의 스타를 받은 채 굳어 있습니다. 시계에 전부를 건 설계가, 정작 자기 시계는 5년 전에 멈춘 채로 업계의 교과서처럼 여겨지고 있는 것은, 왜 일까요?

 

5. 그런데 시계는 거꾸로 간다

시계. 스노우플레이크가 치러야 할 청구서가 도착할 시간입니다. 이 견고해 보이는 시스템은 오직 한 가지 맹목적인 전제 위에서만 돌아갑니다. 바로 '시간은 언제나 앞으로만 흐른다'는 믿음입니다. 하지만 현실 세계의 서버 시계는 종종 뒤로, 거꾸로 되돌아갑니다.

 

"아니, 이게 무슨 말이죠? 시계가 어떻게 거꾸로 갈 수가 있죠?"

 

당연히 가질 수 있는 의문입니다. 타임머신을 탄 것도 아닌데 물리적인 시간이 역행할 리는 없습니다. 엄밀히 말하자면 진짜 시간이 거꾸로 흐르는 것이 아니라, 서버가 바라보고 있는 시스템 시계의 값이 과거의 숫자로 덮어씌워지는 현상을 의미합니다.

 

실제 운영 환경에서 이런 시계의 역행이 발생하는 경로는 크게 세 가지입니다.

 

첫째, NTP 보정입니다. 물리적인 하드웨어 시계는 완벽하지 않아서 서버마다 시간이 조금씩 어긋납니다. 그래서 서버들은 주기적으로 외부의 타임 서버와 통신하며 자신의 시계를 표준 시간에 맞춥니다. 이것이 Network Time Protocol, 줄여서 NTP의 역할입니다.

 

여기서 오해를 하나 걷어내야 합니다. 오차가 작을 때 NTP는 시계를 되돌리지 않습니다. 진행 속도를 아주 조금 늦추거나 당겨서 오차를 서서히 흡수합니다. 시계값이 실제로 역행하는 점프는 오차가 임계값을 넘을 때만 일어납니다. ntpd의 기본 임계값은 128밀리초이고, 그마저 그 상태가 일정 시간 지속돼야 적용됩니다. 즉 시계 역행은 아무 때나 일어나는 미세한 흔들림이 아닙니다. 서버가 한참 어긋난 뒤에 한 번에 되돌아오는 사건이고, 그 크기도 몇 밀리초가 아니라 최소 100밀리초대입니다.

 

둘째, 윤초입니다. 지구의 자전 속도는 완벽하게 일정하지 않아서, 인간이 만든 정밀한 원자시계와의 오차가 조금씩 누적됩니다. 이 오차를 맞추기 위해 협정 세계시 UTC에 가끔 1초를 끼워 넣습니다. 운영체제나 시스템이 이 추가된 1초를 다루는 방식에 따라, 동일한 1초가 두 번 반복되거나 시계가 순간적으로 뒤로 되돌아가는 현상이 발생합니다.

 

셋째, 가상 머신의 마이그레이션과 일시 정지입니다. 클라우드 환경에서는 물리 서버를 점검하기 위해 실행 중인 가상 머신을 잠시 멈추고 다른 호스트 서버로 옮기거나, 스냅샷에서 인스턴스를 복원하곤 합니다. 이 멈춤과 재개의 틈새에서 서버가 인식하는 내부 시계는 크게 튀어버립니다.

 

중요한 것은 이런 일들이 어쩌다 한 번 일어나는 극단적인 예외 상황이 아니라는 점입니다. 분산 시스템을 설계할 때 반드시 고려해야 할 '정상적인' 환경이라는 점에 대해서, 2024년에 제정된 식별자 최신 표준 문서인 RFC 9562는 아예 이 상황을 명시해 두었습니다.

"Implementations acquire the current timestamp from a reliable source to provide values that are time ordered and continually increasing. Care must be taken to ensure that timestamp changes from the environment or operating system are handled in a way that is consistent with implementation requirements. For example, if it is possible for the system clock to move backward due to either manual adjustment or corrections from a time synchronization protocol, implementations need to determine how to handle such cases."

"구현체는 신뢰할 수 있는 소스로부터 현재 타임스탬프를 획득하여 시간순으로 정렬되고 지속적으로 증가하는 값을 제공합니다. 환경이나 운영 체제로부터의 타임스탬프 변경이 구현 요구사항과 일관된 방식으로 처리되도록 주의를 기울여야 합니다. 예를 들어 수동 조정이나 시간 동기화 프로토콜의 보정으로 인해 시스템 시계가 뒤로 갈 가능성이 있다면, 구현체는 그런 경우를 어떻게 처리할지 결정해야 합니다."

- RFC 9562, 6.1절 Timestamp Considerations

 

표준 문서의 입장은 단호합니다. 시계는 당연히 뒤로 갈 수 있고, 그 예외 상황을 어떻게 다룰지는 ID 발급기를 구현하는 쪽에서 책임지고 결정해야 한다는 뜻입니다.

 

시간에 의존하기로 결정한 이상, 우리는 반드시 선택을 내려야 합니다.

6. 두 갈래 선택: 멈추기와 뭉개기

시계가 뒤로 돌아가는 물리적 현상을 막을 수 없다면 시스템은 어떻게 대응해야 할까요? 여기서 두 갈래의 선택지가 있어 보입니다. 이 선택은 결국 유일성과 가용성 중 무엇을 포기할 것인가를 묻는 고전적인 질문입니다.

 

첫 번째 선택은 단호하게 '멈추기'입니다. 트위터의 스노우플레이크 원본 저장소는 이런 상황을 어떻게 다루는지 분명한 지침을 남겼습니다.

"You should use NTP to keep your system clock accurate. Snowflake protects from non-monotonic clocks, i.e. clocks that run backwards. If your clock is running fast and NTP tells it to repeat a few milliseconds, snowflake will refuse to generate ids until a time that is after the last time we generated an id. Even better, run in a mode where ntp won't move the clock backwards."

"시스템 시계를 정확하게 유지하려면 NTP를 사용해야 합니다. 스노우플레이크는 단조 증가하지 않는 시계, 즉 뒤로 가는 시계로부터 시스템을 보호합니다. 시계가 너무 빨라 NTP가 몇 밀리초를 반복하라고 지시하면, 스노우플레이크는 우리가 마지막으로 ID를 생성했던 시간 이후의 시간이 될 때까지 ID 생성을 거부합니다. 더 좋은 것은 ntp가 시계를 뒤로 옮기지 않는 모드로 실행하는 것입니다."

- 트위터 스노우플레이크 저장소 README.mkd (twitter-archive/snowflake, snowflake-2010 태그), System Clock Dependency 절

 

명확합니다. 스노우플레이크는 시계가 뒤로 가는 것을 감지하면 ID 발급을 거부합니다. 언제까지일까요? 시계가 다시 정상적으로 흘러, 자신이 마지막으로 발급했던 시각을 무사히 지나갈 때까지 숨죽여 기다립니다.

 

중복된 ID가 만들어지는 것을 막기 위해 가용성을 미련 없이 던져버린 것입니다. 그 짧은 순간, 고유 ID 발급에 의존하는 애플리케이션의 쓰기 요청은 줄줄이 실패하게 됩니다.

뭉개기: 역행하는 순간을 만들지 않는다

두 번째 선택은 시간을 뭉개는 것입니다. 구글은 이를 leap smear라고 부르며 2008년부터 써 왔습니다. 1초를 시스템에 억지로 끼워 넣어 시계가 순간적으로 튀게 두지 않습니다. 대신 윤초를 중심으로 정오부터 다음 정오까지 24시간에 걸쳐 서버 시계를 아주 미세하게 늦추거나 빠르게 조작합니다. 1초를 24시간에 균등하게 녹여 흡수시키는 방식이죠. 이렇게 하면 시계가 역행하는 찰나의 순간 자체를 물리적으로 지워버릴 수 있습니다.

 

하지만 이 방식도 대가를 치릅니다. 시간을 뭉개는 기간 동안 서버의 시계는 현실 세계의 정확한 UTC와 미세하게 다르게 흐릅니다. 극도의 정밀한 시각 일치가 생명인 금융 거래나 과학 계산 시스템에서는 이 오차를 감당하기 어렵습니다. 시각 정확도를 포기하고 시스템의 가용성을 지킨 셈입니다.

세 번째 방어선: 카운터

최근의 RFC 9562 표준은 여기에 세 번째 방어선을 권고합니다.

"Monotonicity (each subsequent value being greater than the last) is the backbone of time-based sortable UUIDs. Normally, time-based UUIDs from this document will be monotonic due to an embedded timestamp; however, implementations can guarantee additional monotonicity via the concepts covered in this section. Take care to ensure UUIDs generated in batches are also monotonic."

"단조성(연속된 각 값이 이전 값보다 큰 것)은 시간 기반 정렬 가능 UUID의 중추입니다. 보통 이 문서의 시간 기반 UUID는 내장된 타임스탬프 덕분에 단조성을 가집니다. 하지만 구현체는 이 절에서 다루는 개념들을 통해 추가적인 단조성을 보장할 수 있습니다. 일괄 생성된 UUID들도 단조성을 갖도록 주의하십시오."

출처: RFC 9562, 6.2절 Monotonicity and Counters

 

표준 문서는 타임스탬프만 맹신하지 말고 카운터를 곁들여 단조성을 방어하라고 지시합니다. 앞서 4장에서 본 스노우플레이크의 '12비트 시퀀스'가 정확히 이 역할을 수행합니다. 동일한 시간에 여러 요청이 오더라도 카운터를 1씩 올려 충돌을 막아냅니다. 다만 이 카운터 상태를 메모리에 안전하게 유지하고 관리해야 하므로, 구현 복잡도를 높이는 대가를 감수해야 합니다.

 

멈추기와 뭉개기

 

결국 공짜는 없습니다. 역시 또, 엔지니어링에서 완벽한 마법은 존재하지 않습니다. 스노우플레이크의 멈추기는 가용성을 내어주었고, 구글의 뭉개기는 시각 정확도를 팔았으며, 카운터 방식은 구현의 복잡도를 지불했습니다. 각자의 비즈니스가 감당할 수 있는 대가를 선택했을 뿐입니다.

 

그런데 여전히 저는 이 부분에서 의문이 들었습니다. 

 

"물리적인 시간과 달리 서버의 시계는 뒤로 갈 수도 있다는 원리는 알겠습니다. 그런데 그게 현실에서 흔하게 일어나는 일인가요? 몹시 희박한 확률 아닌가요? 그냥 시계가 정상적으로 흐른다고 가정하고 편하게 쓰면 큰일이 나나요?"

 

평범한 규모의 서비스나 단일 서버를 운영하는 개발자라면 평생 한 번도 시계 역행 버그를 마주치지 않을 확률이 높겠죠.

 

하지만 수천, 수만 대의 서버가 초당 수백만 건의 트래픽을 처리하는 거대한 분산 시스템의 세계로 넘어오면 확률의 법칙이 달라집니다.

 

한 대의 서버에서는 '10년에 한 번 일어날까 말까 한 희박한 일'이, 1만 대의 클러스터에서는 '오늘 오후에 당장 터질 수 있는 명백한 위협'으로 충분히 바뀔 수 있죠.

 

정말 그럴까요?

7. 2017년 1월 1일 0시

이론적인 시계 역행이 현실의 거대한 시스템을 어떻게 무너뜨리는지 보여주는 가장 처절한 사례가 있습니다.

"At midnight UTC on New Year’s Day, deep inside Cloudflare’s custom RRDNS software, a number went negative when it should always have been, at worst, zero."

"새해 첫날 UTC 자정, 클라우드플레어의 맞춤형 RRDNS 소프트웨어 깊은 곳에서, 최악의 경우라도 항상 0이어야만 했던 숫자가 음수가 되었습니다."

- Cloudflare 블로그, How and why the leap second affected Cloudflare DNS, 2017년

 

사건의 전말은 이렇습니다. 2016년 12월 31일의 마지막 1분은 61초짜리였습니다. 23시 59분 60초라는 윤초가 삽입되었고, 그 1초가 끝나는 지점이 바로 2017년 1월 1일 0시 UTC입니다. 글로벌 CDN 및 DNS 제공업체인 클라우드플레어는 자체 개발한 DNS 소프트웨어를 운영하고 있었죠. 이 소프트웨어 내부에는 자기네가 직접 돌리는 상위 DNS 리졸버들의 응답 시간을 재는 아주 평범한 로직이 하나 있었습니다. 현재 시각에서 질의를 시작한 시각을 빼서 걸린 시간을 계산하는 코드였습니다. 이 리졸버들은 몇 밀리초 만에 답하도록 다듬어져 있었기 때문에, 시계가 1초 밀리는 순간 측정값은 곧바로 뒤집혔습니다.

 

코드를 작성한 엔지니어는 아주 자연스러운 가정을 했습니다. '나중 시각은 항상 시작 시각보다 크거나 같다. 그러니 두 시간의 차이를 계산하면 최악의 경우라도 0이다.'

 

하지만 윤초 삽입으로 인해 시스템 시계가 순간적으로 뒤로 되돌아가면서, 이 당연한 명제가 산산조각 났습니다. 나중 시각이 시작 시각보다 더 작아진 것입니다.

 

시간의 차이를 계산한 결괏값은 '음수'가 되었습니다.

 

그런데 여기서 곧바로 터지지는 않았습니다. 클라우드플레어는 리졸버마다 측정치를 여러 번 모아 평활화해서 씁니다. 음수 측정이 몇 번 섞이자 평활화된 값 자체가 음수로 내려앉았습니다. 그리고 CNAME을 풀어줄 리졸버를 고르는 가중치 선택 코드가 그 음수를 Go의 난수 함수 rand.Int63n()에 인자로 넘겼습니다. 이 함수는 인자가 음수면 그 즉시 패닉을 일으킵니다.

 

다만 프로세스가 죽지는 않았습니다. Go의 recover가 패닉을 잡아냈기 때문입니다. 대신 패닉이 난 CNAME 조회만 하나씩 조용히 실패했습니다. 오히려 이쪽이 더 고약합니다. 서버는 멀쩡히 살아서 오염된 음수 평활값을 계속 들고 있었고, 그래서 수습 방법이 전 서버 재시작이었습니다.

 

결과적으로 최대 시점 기준으로 클라우드플레어를 거치는 DNS 질의의 약 0.2퍼센트가 실패했습니다. HTTP 요청으로 따지면 1퍼센트 미만입니다. 숫자만 보면 작아 보이지만, 세계에서 가장 많은 도메인을 받아내는 인프라에서의 0.2퍼센트라는 점을 생각하면 이야기가 달라집니다.

 

윤초에서 조회 실패까지

 

 

저는 이 장애 부검 결과가 던지는 함의가 아주 무겁다고 봅니다. 문제가 된 저 코드는 복잡한 분산 ID 발급 로직조차 아니었습니다. 그저 응답 시간을 재는, 우리가 실무에서 매일 작성하는 아주 단순하고 흔한 코드였습니다.

 

시간을 근거로 계산을 수행하는 모든 코드는 이처럼 "시계는 무조건 앞으로 단조 증가한다"는 숨은 전제를 밑바탕에 깔고 있습니다. 고유 ID 생성기는 단지 그 위험한 전제에 시스템 전체의 목숨을 통째로 걸어둔 가장 극단적인 사례일 뿐입니다.

 

한 가지 덧붙일 것이 있습니다. 이때의 윤초가 마지막이었습니다. 국제 지구 자전 좌표국이 발행하는 공보를 보면 UTC와 국제원자시의 차이는 2017년 1월 1일 0시 이후로 계속 -37초에 머물러 있습니다. 그리고 2022년 국제도량형총회는 결의 4호로 "UT1과 UTC의 최대 허용 차이를 2035년 또는 그 이전에 늘린다"고 결정했습니다. 사실상 윤초를 은퇴시키겠다는 뜻입니다.

 

그렇다면 이 이야기는 이제 흘러간 옛일일까요. 저는 반대로 봅니다. 윤초가 사라져도 NTP 보정과 가상 머신 이동은 그대로 남습니다. 시계 역행의 세 경로 중 하나가 없어질 뿐입니다. 그리고 우리가 방금 본 것은 윤초가 부순 코드가 아니라, 시간이 앞으로만 간다고 믿은 코드였습니다.

 

우리가 시스템을 지탱하는 기반 지식이 얼마나 쉽게 흔들릴 수 있는지 확인할 수 있는 사례입니다.

 

그렇다면 이 거대한 문제를 업계의 선배들은 어떻게 바라보고 있었을까요? 다음 장에서는 시간이 지닌 모순을 해결하기 위해 2024년에야 비로소 도달한 최신 표준, 그리고 그 속에 숨겨진 충격적인 진실을 들여다보겠습니다.

 

8. 2024년, 표준이 된 것

수많은 기업이 트위터의 스노우플레이크를 모방하며 각자의 방식대로 커스텀 ID 생성기를 깎던 파편화의 시대를 지나, 2024년 5월 마침내 RFC 9562 표준이 세상에 나왔습니다. 바로 UUIDv7의 등장입니다.

 

"UUIDv7 features a time-ordered value field derived from the widely implemented and well-known Unix Epoch timestamp source, the number of milliseconds since midnight 1 Jan 1970 UTC, leap seconds excluded. (...) UUIDv7 values are created by allocating a Unix timestamp in milliseconds in the most significant 48 bits and filling the remaining 74 bits, excluding the required version and variant bits, with random bits for each new UUIDv7 generated to provide uniqueness as per Section 6.9."

"UUIDv7은 널리 구현되고 잘 알려진 유닉스 에포크 타임스탬프 소스(UTC 1970년 1월 1일 자정 이후의 밀리초 수, 윤초 제외)에서 파생된 시간 정렬 값 필드를 특징으로 합니다. (...) UUIDv7 값은 최상위 48비트에 밀리초 단위 유닉스 타임스탬프를 할당하고, 필수 버전과 변형 비트를 제외한 나머지 74비트를 새로 생성되는 각 UUIDv7마다 무작위 비트로 채워 만들어지며, 이는 6.9절에 따라 유일성을 제공하기 위함입니다."

- RFC 9562, 5.7절 UUID Version 7

 

동작 원리를 가만히 살펴보면 4장에서 본 스노우플레이크와 발상이 정확히 같습니다. 최상단 앞부분에 밀리초 단위의 타임스탬프를 두어 공짜로 정렬 속성을 얻고, 뒷부분에는 거대한 난수를 두어 충돌을 막아냅니다.

 

이 규격이 감당할 수 있는 수명을 직접 계산해 보겠습니다. $2^{48}$밀리초, 무려 8,919년에 달합니다. 유닉스 에포크인 1970년을 기준으로 서기 10889년경까지 고갈 걱정 없이 쓸 수 있습니다. 41비트를 할당해 고작 69.7년의 수명을 가졌던 스노우플레이크와 비교하면 아키텍처의 유통기한이 비교조차 할 수 없을 만큼 늘어났습니다.

 

 

그럼 UUIDv7은 무엇을 얻고 무엇을 잃었을까요?

 

얻은 것부터 봅시다. 

 

앞 48비트가 시간이니 새 값이 인덱스 오른쪽에 모여 붙습니다. 2장에서 UUIDv4가 치른 페이지 분할 대가를 물리친 것이죠. 아무에게도 묻지 않고 각자 만드니 3장의 중앙 의존도 없습니다. 그리고 4장에서 스노우플레이크가 팔았던 그 10비트, 노드 번호 배정이 아예 사라졌습니다. UUIDv7에는 노드 식별자 칸이 없거든요. 겹치지 않는 근거를 "프로세스마다 다른 번호를 배정한다"에서 "74비트짜리 주사위를 굴린다"로 갈아 끼운 것입니다. 표준도 노드 번호를 굳이 넣고 싶으면 v7이 아니라 UUIDv8을 쓰라고 하고, 분산 환경이라고 그 방식이 필요한 것도 아니라고 못을 칩니다. 기준 시각이 유닉스 에포크로 고정돼 있으니 커스텀 에포크를 관리할 일도 없습니다.

 

잃은 것은 셋입니다.

 

첫째, 길이입니다. 64비트로 되던 일에 128비트를 씁니다. 스노우플레이크가 그토록 아끼려 했던 그 비트를 두 배로 쓰는 것이죠. 둘째, 같은 밀리초 안의 순서입니다. 1밀리초 안에 만들어진 두 값은 뒤쪽 난수로만 갈리니 사실상 무작위 순서입니다. 표준이 카운터를 곁들이는 방법을 두 가지 제시하지만 의무는 아닙니다. 그래서 UUIDv7의 정렬은 트위터가 쓴 표현 그대로 "대략적으로" 정렬입니다. 셋째, 만들어진 시각이 드러납니다. ID를 가진 사람은 그것이 언제 만들어졌는지 압니다. 그래서 표준은 여기에 못을 하나 칩니다.

 

"If UUIDs are required for use with any security operation within an application context in any shape or form, then UUIDv4 (Section 5.4) SHOULD be utilized."

"애플리케이션 맥락에서 어떤 형태로든 보안 관련 동작에 UUID가 필요하다면, UUIDv4를 사용해야 합니다."
- RFC 9562, 8절 Security Considerations

 

 

즉 UUIDv7은 UUIDv4를 밀어낸 것이 아닙니다. 정렬이 필요하면 v7, 예측 불가능성이 필요하면 v4. 용도가 갈릴 뿐입니다.

 

그런데 목록에서 빠진 것이 하나 있습니다. 시계입니다. UUIDv7도 시스템 시계를 읽습니다. 5장에서 본 세 경로가 그대로 살아 있죠. 표준은 이 문제를 풀지 않았습니다. 6.1절이 "시스템 시계가 뒤로 갈 가능성이 있다면 구현체가 그런 경우를 어떻게 처리할지 결정해야 한다"고 적고, 참고할 방법으로 6장에서 본 뭉개기를 가리킬 뿐입니다. 결정을 아래로 미루었습니다.

 

30년이 흐르는 동안 다른 빚은 다 갚혔는데 이 빚만 그대로 넘어왔습니다.

 

"그럼 스노우플레이크보다 도대체 발전이 있긴 한 건가요?"

 

있습니다. 다만 발전을 재는 화폐는 성능이나 우아함 보다는 내가 직접 해야 하는 일의 개수로 볼 수 있겠습니다.

 

AUTO_INCREMENT는 서버 대수를 늘리지 못하게 묶었습니다. 티켓 서버는 전용 장비 두 대를 운영하게 했죠. 스노우플레이크는 프로세스마다 번호를 배정하고 시계까지 관리하게 만들었습니다. UUIDv7은 라이브러리를 부르면 끝입니다. 단계마다 내 손에서 일이 하나씩 빠져나간 것이, 실무 개발과 운영 관점에서 매우 큰 성취로 보입니다. 

 

바뀌지 않은 것은 밑바닥입니다. 시계는 그대로고, 달라진 것은 그 시계를 누가 책임지느냐입니다. 2010년에는 README 한 귀퉁이의 경고였던 것이 2024년에는 국제 표준의 절 하나가 되었습니다. 문제가 사라진 것이 아니라 문제를 다루는 책임이 아래로 내려간 것이죠.

 

"그럼 스노우플레이크를 쓰나 UUIDv7을 쓰나 결국 같은 것 아닌가요?"

 

제 생각에는, 시계라는 빚이 같을 뿐, 나머지는 꽤 다른 것 같습니다.

 

가장 큰 차이는 길이입니다. 스노우플레이크는 64비트, UUIDv7은 128비트. 두 배죠. 트위터가 이 조건을 얼마나 무겁게 여겼는지는 원문에 남아 있습니다. 숫자가 64비트에 들어가야 한다고 못을 하나 치면서, 예전에 ID 비트 수를 늘려 본 적이 있는데 "10만 개가 넘는 코드베이스가 얽혀 있으면 놀랍지 않게도 어려운 일"이었다고 적었거든요. 지금도 ID를 8바이트 정수 컬럼에 담아야 하는 시스템이라면 UUIDv7은 선택지에 오르지도 못합니다.

 

정렬은 오히려 스노우플레이크 쪽이 셉니다. 12비트 시퀀스가 같은 밀리초 안에서도 순서를 보장하니까요. UUIDv7은 그 구간이 난수라 구현체가 카운터를 따로 넣어야 순서가 생깁니다. 충돌을 막는 방식도 다릅니다. 스노우플레이크는 노드 번호로 구조적으로 막고, UUIDv7은 74비트 주사위로 확률적으로 막습니다.

 

그러니 UUIDv7이 스노우플레이크를 이긴 것은 아닙니다. 64비트라는 압축과 구조적인 유일성을 내주고 노드 번호 배정을 지웠을 뿐입니다. 이번에도 교환입니다.

 

그런데 왜 요즘은 UUIDv7이 정답처럼 들릴까요? 물건이 바뀐 것이 아니라 가격표가 뒤집혔기 때문입니다. 2010년에는 비트가 비싸고 노드 번호가 쌌습니다. 장비 목록이 고정되어 있었으니 번호 하나 붙이는 것은 설정 파일 한 줄이었죠. 2024년에는 반대입니다. 저장 공간도 대역폭도 흔해졌지만, 컨테이너가 초 단위로 떴다 사라지는 환경에서 프로세스마다 고유 번호를 붙이는 일은 만만치 않은 작업이 되었습니다.

 

같은 두 물건을 놓고 14년 사이에 값이 뒤집힌 것입니다. 스노우플레이크가 틀린 답이 된 것이 아니라, 그 답이 싸게 먹히던 환경이 사라졌을 뿐입니다.

반전 하나: 시간은 처음부터 있었다

"와, 드디어 2024년이 되어서야 UUID에 시간을 넣는 혁신이 일어났구나!"

 

아마 많은 분이 이렇게 생각하실 겁니다. 철저한 오해입니다. 시간은 1994년 규격부터 이미 그곳에 있었습니다.

 

"UUIDv6 is a field-compatible version of UUIDv1 (Section 5.1), reordered for improved DB locality. (...) Instead of splitting the timestamp into the low, mid, and high sections from UUIDv1, UUIDv6 changes this sequence so timestamp bytes are stored from most to least significant."

"UUIDv6은 데이터베이스 지역성을 개선하기 위해 재배열된 UUIDv1(5.1절)의 필드 호환 버전입니다. (...) 타임스탬프를 하위, 중위, 상위 섹션으로 쪼개던 UUIDv1과 달리, UUIDv6은 타임스탬프 바이트가 최상위부터 최하위 순으로 저장되도록 이 순서를 바꿉니다."

- RFC 9562, 5.6절 UUID Version 6

 

RFC 9562가 스스로 밝히는 자기 출처는 1994년 8월에 나온 OSF DCE 규격입니다. 거기 실린 최초의 UUIDv1은 처음부터 시간을 품고 있었습니다. 치명적인 실책은 60비트 타임스탬프를 하위 32비트, 중위 16비트, 상위 12비트 세 조각으로 쪼갠 뒤 하위 조각을 맨 앞에 두었다는 것 하나뿐입니다. 이 기묘한 배치 탓에 문자열로 저장하고 정렬해도 도무지 시간 순서대로 정렬이 되지 않았습니다.

 

즉, 우리가 새롭다고 믿었던 UUIDv6은 혁신적인 발명품이 아닙니다. 30년 전 규격에서 타임스탬프 조각을 상위부터 오도록 다시 자른 것입니다. 시계 시퀀스와 노드 필드는 위치가 그대로입니다.

 

 

반전 둘: 1994년 규격서를 열어보면

놀랍게도, 이보다 더 뼈아픈 진짜 반전이 또 있습니다.

 

"UUIDv1 is a time-based UUID featuring a 60-bit timestamp represented by Coordinated Universal Time (UTC) as a count of 100-nanosecond intervals since 00:00:00.00, 15 October 1582 (the date of Gregorian reform to the Christian calendar). UUIDv1 also features a clock sequence field that is used to help avoid duplicates that could arise when the clock is set backwards in time or if the Node ID changes."

"UUIDv1은 1582년 10월 15일(기독교 달력의 그레고리력 개혁일) 00:00:00.00 이후의 100나노초 간격 수로 협정 세계시(UTC)를 표현한 60비트 타임스탬프를 특징으로 하는 시간 기반 UUID입니다. UUIDv1은 또한 시계가 뒤로 설정되거나 노드 ID가 바뀔 때 발생할 수 있는 중복을 피하는 데 도움을 주기 위해 사용되는 시계 시퀀스 필드를 특징으로 합니다."

- RFC 9562, 5.1절 UUID Version 1

 

 

이 규격은 유닉스 시간이 아니라 1582년 그레고리력 개혁일을 기점으로 삼는 100나노초 단위의 아주 정밀한 시계를 썼습니다. RFC 문서조차 다루기 까다롭다고 인정할 정도의 기이한 포맷이죠.

 

하지만 이 인용구의 핵심은 마지막 문장에 있습니다. 시계 시퀀스 필드 말입니다.

 

이게 정말 1994년 문서에 있었는지 궁금해서 원문을 열어봤습니다. 674쪽짜리 규격서의 부록 A에 이렇게 적혀 있더군요.

 

"The clock sequence value must be changed whenever: • The UUID generator detects that the local value of UTC has gone backward; this may be due to normal functioning of the DCE Time Service. (...)"

"시계 시퀀스 값은 다음의 경우 반드시 변경되어야 합니다. UUID 생성기가 로컬 UTC 값이 뒤로 갔음을 감지했을 때. 이는 DCE 시간 서비스의 정상적인 동작 때문일 수 있습니다. (...)"

- X/Open CAE Specification C309, X/Open DCE: Remote Procedure Call, 1994년 8월, A.2.1절 Clock Sequence

 

30년 전입니다. 그리고 "정상적인 동작 때문일 수 있다"는 대목에 눈길이 갑니다. 이 사람들에게 시계 역행은 어쩌다 터지는 예외가 아니었습니다. 시간 서비스가 제대로 돌아갈 때도 벌어지는 일이었죠. 같은 절은 대응까지 적어뒀습니다. 마지막으로 쓴 시각을 저장해 두고, 새 UUID를 만들 때마다 현재 시각과 비교해서 더 작으면 시계 시퀀스를 1 올린다고요.

 

한 가지가 더 눈에 들어옵니다. 같은 부록의 다른 절에는 시계 해상도보다 빠르게 ID를 요청받았을 때 고를 수 있는 선택지 세 가지를 제시합니다. 요청자를 종료시키거나, 성공할 때까지 재시도하게 하거나, 시스템 시계가 따라잡을 때까지 생성기를 멈춰 세우거나.

 

마지막 항목이 낯익지 않으신가요? 물론 문제 자체는 다릅니다. 저건 시계가 너무 느린 경우고, 6장에서 본 트위터는 시계가 되돌아간 경우였으니까요. 그런데 처방이 똑같습니다. 마지막으로 발급한 시각을 시계가 지나갈 때까지 멈춰 세운다는 것. 16년 뒤에 트위터가 고른 그 동작이 1994년 규격의 선택지 목록에 이미 세 번째 항목으로 적혀 있었습니다.

악당은 시계가 아니었다

우리를 괴롭힌 악당은 시계가 아니었습니다. 1990년대의 선배 엔지니어들은 서버의 시계가 역행한다는 사실을 이미 알고 있었고, 대비책을 고민해 문서에 남겼던 것입니다. 그 조항은 1994년 DCE 규격에서 시작해 2005년 RFC 4122로 IETF 표준에 실렸고, 2024년 RFC 9562까지 그대로 이어집니다. 그러나 이후의 수많은 기업과 개발자들은 그 세부 사항을 유심히 읽지 않았습니다.

 

2010년 트위터는 조율 없는 정렬을 얻고자 커스텀 시계를 깎다가 시간 역행의 한계에 부딪혔습니다. 2017년 클라우드플레어는 시계가 뒤로 갈 수 있다는 변수를 놓쳐 뼈아픈 장애를 겪었죠. 내로라하는 기술 기업들조차 앞선 엔지니어들이 남긴 오답 노트를 꼼꼼히 살피지 못해 비슷한 함정을 밟곤 했습니다.

 

이전 다이나모 편에서 저는 "복잡성은 사라지지 않고 계층 사이를 이동할 뿐이다"라는 발견을 했고 그렇게 썼습니다. 이번 고유 ID 설계의 흐름이 우리에게 주는 교훈은 또 다른 결 같습니다.

 

시스템을 멈추는 치명적인 장애는 어쩌면 대단히 새로운 기술적 난해함에서 비롯되는 게 아닐 수도 있겠습니다. 오히려 과거의 뻔한 교훈을 잊어버린 그 틈새에서 조용히 반복되는 것이죠.

9. 그래서 무엇을 골라야 하나

어떤 게 더 우월하다고 말하기는 힘들다고 생각합니다. 내 시스템의 현재 상황과 요구사항을 파악하기 위해, 다음 네 가지 기준을 점검해 볼 수 있습니다.

 

첫째, 현재 시스템이 정말로 거대한 분산 환경을 요구하는지 확인해야 합니다. 단일 데이터베이스의 처리량으로 충분한 규모라면, 1장에서 살펴본 AUTO_INCREMENT가 여전히 훌륭한 정답이라는 생각입니다. 시스템 규모를 과대평가하여 불필요하게 복잡한 분산 ID 생성기를 성급히 도입할 필요는 없습니다.

 

둘째, 데이터 정렬과 페이지네이션 구조입니다. 게시글 목록을 시간순으로 빠르게 가져오거나 Cursor 기반 페이지네이션을 구현해야 한다면 앞부분에 시간이 배치된 UUIDv7이나 스노우플레이크 계열이 유리합니다. 인덱스 성능을 방어하면서 자연스러운 정렬 효과까지 얻을 수 있죠.

 

셋째, 식별자가 외부에 노출되었을 때의 비즈니스 정보 유출 가능성입니다. 순차적으로 증가하는 정수 ID를 URL이나 API 응답에 그대로 노출하면 경쟁사가 비즈니스 규모를 쉽게 유추할 수 있습니다. 며칠 간격으로 주문 번호 두 개를 수집해 차이를 계산하면 그 기간의 총주문량이 고스란히 노출되죠. 2차 세계대전 당시 연합군이 독일 전차의 일련번호만으로 생산량을 실제에 근접하게 추정해 낸 '독일 전차 문제'와 같은 맥락입니다. 첩보 기관의 추정이 실제의 네 배에서 여덟 배까지 빗나가던 시기에, 일련번호 분석은 오차를 4퍼센트에서 38퍼센트 범위로 좁혔습니다.

 

넷째, 새로운 장치를 유지하기 위한 운영 부담입니다. 스노우플레이크를 자체 구축하려면 ZooKeeper 등을 활용해 각 워커 노드에 고유 식별자를 할당하고, 서버 간의 시계를 정밀하게 관리하는 인프라가 필요합니다. 반면 UUIDv7은 잘 만들어진 오픈소스 라이브러리 함수 호출 하나로 끝납니다.

한 장으로 비교하면

지금까지 살펴본 네 가지 해법의 특징을 표로 비교해 두었습니다.

생성 방식 정렬 가능 여부 생성 위치 인프라 운영 부담 추천하는 상황
AUTO_INCREMENT O 데이터베이스 매우 낮음 단일 DB로 충분한 대부분의 서비스
UUIDv4 X 애플리케이션 낮음 정렬이 필요 없고 외부 노출 시 보안이 중요할 때
티켓 서버 O 데이터베이스 중간, 발급 서버 이중화 필요 앱 서버는 늘리되 짧은 숫자형 ID가 필요할 때
스노우플레이크 O 애플리케이션 높음 (노드/시계 관리) 초당 수백만 이상의 극단적인 쓰기 처리량이 필요할 때
UUIDv7 O 애플리케이션 낮음 (라이브러리) 분산 환경에서 시간 정렬이 필요하며 운영 부담을 낮추고 싶을 때


마지막으로, 난수를 쓴다면

외부 노출 문제를 막기 위해 난수를 활용하려 할 때, 반드시 새겨야 할 표준 문서의 경고 하나로 이 글을 마무리하겠습니다.

"Implementations SHOULD utilize a cryptographically secure pseudorandom number generator (CSPRNG) to provide values that are both difficult to predict ("unguessable") and have a low likelihood of collision ("unique")."

"구현체는 예측하기 어렵고("추측 불가") 충돌 가능성이 낮은("유일함") 값을 제공하기 위해 암호학적으로 안전한 의사 난수 생성기(CSPRNG)를 사용해야(SHOULD) 합니다."

- RFC 9562, 6.9절 Unguessability

 

난수부를 채우기 위해 Math.random() 같은 단순 난수 생성기를 쓰면 쉽게 예측 가능한 패턴이 만들어집니다. 예측 가능한 고유 ID는 악의적인 공격자에게 시스템을 열어주는 취약점이 될 수 있습니다. 기술을 선택할 때는 이런 숨겨진 비용과 안전성까지 함께 고려하는 것이 좋습니다.

 


참고 자료

규격 문서

원전 자료

장애 부검

공식 문서


 

 

 

A Note on Graphics

이 글에 사용된 모든 다이어그램과 일러스트 이미지는 개념의 시각화를 위해 생성형 AI 도구를 활용해 제작되었습니다.

 
반응형