이번 시간에는 서버마다 시계가 다를 때 타임스탬프로 순서를 정하면 무엇이 틀어지는지, Docker 컨테이너와 libfaketime, Redis로 직접 재현해 보며 배워가는 시간입니다. libfaketime은 컨테이너마다 시간을 다르게 보이게 해 주는 라이브러리예요.
먼저 이런 상황을 하나 생각해 볼게요. 회원이 이메일을 두 번 바꿨어요. 첫 번째 요청은 서버 B가, 1.5초 뒤의 두 번째 요청은 서버 A가 처리했습니다.
두 서버는 회원 정보를 저장할 때 자기 시계로 찍은 타임스탬프를 함께 저장하고, 저장된 값보다 타임스탬프가 큰 쓰기만 받아들이게 되어 있었어요. 그런데 최종 이메일은 첫 번째 값이었습니다. 서버 A의 시계가 3초 늦게 가고 있었기 때문이에요.
실습 준비
이 글의 명령은 모두 실습 코드의 distributed-clock-ordering/ 디렉터리에서 실행합니다.
Dockerfile: libfaketime과redis패키지를 넣은 노드 이미지compose.yaml: Redis와 노드 두 개(node-a,node-b) 정의common.py: 노드 이름, Redis 클라이언트, 시간 표시 함수lww.py: last-write-wins 쓰기ver.py: 버전 번호 CAS 쓰기
Docker 컨테이너는 호스트(macOS라면 Docker Desktop의 리눅스 VM)의 커널 시계를 함께 써요. 그래서 컨테이너마다 시간을 다르게 보이게 하려면 커널이 아니라 프로그램 쪽에서 시간을 바꿔야 해요.
이때 쓰는 게 libfaketime입니다. libfaketime은 LD_PRELOAD로 프로그램보다 먼저 올라가서 clock_gettime() 같은 시간 함수를 가로채고, 진짜 시간에 지정한 값을 더해서 돌려주는 라이브러리예요.
노드 이미지는 python:3.12-slim에 libfaketime과 redis 패키지를 넣어 만들게요. CPU 종류에 따라 라이브러리 경로가 달라서, 고정된 경로로 링크를 하나 걸어 둡니다. Dockerfile은 이렇습니다.
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends faketime \
&& rm -rf /var/lib/apt/lists/* \
&& ln -s "$(dpkg -L libfaketime | grep 'faketime/libfaketime.so.1$')" /usr/local/lib/libfaketime.so.1 \
&& pip install --no-cache-dir --root-user-action=ignore redis==6.4.0
WORKDIR /app
compose.yaml은 redis, node-a, node-b 세 서비스를 정의하고, 노드는 위 Dockerfile로 만든 이미지를 씁니다. Redis는 두 노드가 함께 쓰는 저장소예요.
libfaketime은 node-a에만 걸었어요. 환경 변수의 뜻은 다음과 같습니다.
LD_PRELOAD: 이 컨테이너에서 실행하는 모든 프로그램에 libfaketime을 먼저 올립니다.FAKETIME_TIMESTAMP_FILE: 시간을 얼마나 바꿀지 이 파일에서 읽어요. 컨테이너가 시작할 때-3을 적어 두었으므로 node-a는 진짜 시간보다 3초 늦은 시간을 봅니다.
FAKETIME_NO_CACHE, FAKETIME_DONT_FAKE_MONOTONIC도 걸려 있지만 이 글에서는 쓰지 않아요.
이제 compose 프로젝트 이름을 clock으로 두고, 자주 쓰는 명령을 셸 함수로 만들어 놓을게요. on a ...는 node-a에서, on b ...는 node-b에서 명령을 실행합니다.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export COMPOSE_PROJECT_NAME=clock
docker compose up -d --build
rcli() { docker compose exec -T redis redis-cli --no-raw "$@"; }
on() { local node=$1; shift; docker compose exec -T "node-$node" "$@"; }
실습 1: last-write-wins는 나중 쓰기를 버릴 수 있습니다
같은 데이터에 쓰기가 여러 번 들어왔을 때 타임스탬프가 가장 큰 쓰기를 남기는 규칙을 last-write-wins(LWW)라고 해요. 이 방식은 "타임스탬프가 크면 나중에 쓴 것"이라고 가정하고 있어요.
lww.py는 쓰는 쪽 노드의 time.time()을 ts로 붙여 Lua 스크립트를 실행합니다. Lua 스크립트는 Redis 안에서 한 번에 실행되고, 실행되는 동안 다른 명령이 끼어들지 않아요.
스크립트는 반영했으면 1, 거절했으면 0을 돌려줍니다. 쓰기가 실제로 Redis에 도착한 시간을 남기려고 Redis TIME도 함께 돌려줘요. Redis TIME은 Redis 서버의 현재 시간을 돌려주는 명령이고, 시간은 모두 UTC예요.
node-b가 먼저 쓰고, 1초 쉰 뒤 node-a가 쓰게 해 볼게요.
rcli DEL user:1 > /dev/null
on b python lww.py b@example.com
sleep 1
on a python lww.py a@example.com
rcli HGETALL user:1
node-b email=b@example.com ts=08:26:54.439 Redis 도착=08:26:54.441 결과=1 (반영)
node-a email=a@example.com ts=08:26:52.949 Redis 도착=08:26:55.952 결과=0 (거절)
1) "email"
2) "b@example.com"
3) "ts"
4) "1790843214.439"
Redis 도착 시간을 보면 node-a의 쓰기는 node-b보다 1.5초 늦게 들어왔어요. 그런데 node-a의 시계가 3초 늦어서, node-a가 붙인 ts는 node-b의 ts보다 1.5초 작았습니다. 그래서 스크립트는 node-a의 쓰기를 거절했어요.
여기서 눈여겨볼 건 node-a가 아무 오류도 받지 않았다는 점이에요. 스크립트 반환값을 확인하지 않는 코드라면 쓰기에 성공했다고 믿고 넘어가게 됩니다.
시계 오차보다 짧은 간격으로 들어온 쓰기들의 순서는 LWW가 보장하지 못합니다. 오차가 수 밀리초라도 같은 키에 수 밀리초 간격으로 쓰는 시스템이라면 같은 문제가 생겨요.
실습 2: 시계를 하나로 모으면
노드마다 시계가 다른 것이 문제라면, 타임스탬프를 한 곳에서 붙이면 되겠죠.
--server-ts를 붙이면 노드는 ts를 보내지 않고, Lua 스크립트가 Redis TIME으로 ts를 붙여요.
rcli DEL user:1 > /dev/null
on b python lww.py b@example.com --server-ts
sleep 1
on a python lww.py a@example.com --server-ts
rcli HGETALL user:1
node-b email=b@example.com ts=08:27:03.131 Redis 도착=08:27:03.131 결과=1 (반영)
node-a email=a@example.com ts=08:27:04.577 Redis 도착=08:27:04.577 결과=1 (반영)
1) "email"
2) "a@example.com"
3) "ts"
4) "1790843224.577"
이번에는 나중에 도착한 node-a의 쓰기가 남았어요. node-a의 시계는 여전히 3초 늦지만 ts를 붙이는 데 쓰이지 않았기 때문입니다.
다만 이 방법은 시계를 Redis 서버 하나로 모은 것일 뿐이라서, 다음 한계가 있어요.
- Redis 서버의 시계도 벽시계(
time.time()처럼 현재 날짜와 시각을 돌려주는 시계)예요. NTP가 step(조금씩 빠르게 하거나 늦추지 않고 한 번에 건너뛰는 보정)으로 시간을 뒤로 맞추면, 그 뒤에 들어온 쓰기가 더 작은 ts를 받습니다. - 페일오버로 replica가 새 primary가 되면 이후 ts는 다른 서버의 시계로 붙어요. 두 서버의 시계가 어긋나 있으면 페일오버 직후의 쓰기가 거절될 수 있습니다.
- 두 요청이 거의 동시에 도착하면 결과는 Redis에 도착한 순서로 정해져요. 사용자가 실제로 누른 순서와는 다를 수 있습니다.
실습 3: 시간 대신 버전으로 순서를 정합니다
같은 데이터에 대한 쓰기 순서를 정하는 데 시간이 꼭 필요하지는 않아요. 데이터에 버전 번호를 두고, 쓸 때마다 1씩 올리면 됩니다.
쓰는 쪽은 읽은 버전을 기억해 두었다가, 저장된 버전이 그대로일 때만 써요. 이렇게 "기대한 값과 같을 때만 바꾸는" 방식을 compare-and-set(CAS)이라고 합니다.
ver.py는 버전을 읽고, 호스트가 신호를 줄 때까지 기다렸다가 써요. 충돌하면 다시 읽어서 최대 세 번까지 시도합니다.
두 노드가 같은 버전 0을 읽은 상태에서 node-b가 먼저 쓰고, 그다음 node-a가 쓰게 해 볼게요. ready, go:a, go:b, done은 순서를 맞추려고 쓰는 Redis List예요. node-a의 시계는 여전히 3초 늦은 상태입니다.
rcli DEL user:1 ready done go:a go:b > /dev/null
rcli HSET user:1 email old@example.com ver 0 > /dev/null
on a python ver.py a@example.com &
on b python ver.py b@example.com &
rcli BLPOP ready 10 > /dev/null; rcli BLPOP ready 10 > /dev/null
rcli RPUSH go:b 1 > /dev/null; rcli BLPOP done 10 > /dev/null
rcli RPUSH go:a 1 > /dev/null; rcli BLPOP done 10 > /dev/null
wait
rcli HGETALL user:1
node-a 읽음: ver=0
node-b 읽음: ver=0
node-b 쓰기 1회차: 기대 ver=0 -> 반영, ver=1
node-a 쓰기 1회차: 기대 ver=0, 저장된 ver=1 -> 충돌, 다시 읽음
node-a 쓰기 2회차: 기대 ver=1 -> 반영, ver=2
1) "email"
2) "a@example.com"
3) "ver"
4) "2"
node-b의 쓰기로 버전이 1이 됐고, 버전 0을 기대한 node-a의 첫 쓰기는 충돌로 거절됐어요.
여기서 LWW와 다른 점은 node-a가 거절됐다는 사실을 안다는 거예요. 다시 읽으면 node-b가 바꾼 값과 버전 1을 보게 되고, 그 값을 보고 무엇을 쓸지 다시 정할 수 있습니다. 이 실습에서는 같은 이메일로 다시 썼고, 버전 2로 반영됐어요.
버전 번호는 데이터를 저장하는 한 곳이 매기기 때문에, 어느 서버의 시계가 틀려도 순서가 흔들리지 않아요. 관계형 DB라면 UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?처럼 쓰고 바뀐 행 수가 0인지 확인합니다.
정리
범위: Redis 8.10.2, Python 3.12와 redis 6.4.0, libfaketime으로 시계를 바꾼 Docker 컨테이너 두 개(node-a는 3초 늦음).
- 3초 늦은 node-a의 쓰기는 node-b보다 1.5초 늦게 도착했는데도 LWW에서 거절됐고, node-a는 아무 오류도 받지 않았습니다. 서로 다른 서버의 벽시계 타임스탬프로 최신 값을 고르지 마세요.
- ts를 Redis
TIME으로 붙이자 나중에 도착한 node-a의 쓰기가 남았지만, Redis 서버의 시계도 step 보정과 페일오버에 따라 흔들리는 벽시계입니다. 시계를 한 서버로 모은 것을 순서 보장으로 믿지 마세요. - 버전 조건 쓰기에서 node-a의 첫 쓰기는 충돌로 거절됐다가 다시 읽고 버전 2로 반영됐습니다. 같은 데이터의 쓰기 순서는 버전 번호와 조건부 쓰기로 정하세요.