# Redis# 분산 시스템

Redis 분산 락은 정말 안전할까?

@Hong2026-10-01

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Redis 분산 락으로 두 서버가 같은 데이터를 동시에 고치는 일을 막을 수 있을까?
답
한 서버가 3초 멈춘 사이 TTL 2초가 지나 다른 서버가 락을 잡아 최종 잔액이 180이 아닌 130이 됐고, 펜싱 토큰을 붙이자 늦게 온 UPDATE가 rowcount 0으로 거절됐고 그 서버가 락부터 다시 잡아 180이 됐다.
조건
Redis 8.10.2(RDB와 AOF 끔), PostgreSQL 17, Python redis 6.4.0과 psycopg 3.2.10, 노트북 Docker, 타이밍 실습 각 3번

환경

Redis
8.10.2(redis:8.10.2 이미지), RDB와 AOF 끔
PostgreSQL
17
Python 클라이언트
redis 6.4.0, psycopg 3.2.10
실행
노트북의 Docker, 작업자 A와 B는 같은 컨테이너 안의 서로 다른 프로세스
반복
타이밍에 좌우되는 실습은 각 3번, 글에는 그중 한 번
목차
  1. 실습 준비
  2. 실습 1: 작업이 TTL보다 길어지면
  3. 실습 2: 락을 연장해도 프로세스가 멈추면
  4. 실습 3: 펜싱 토큰으로 DB가 늦은 쓰기를 거절하게 하면
  5. 정리

이번 시간에는 Redis 분산 락이 두 서버의 동시 수정을 언제 막지 못하는지, Docker의 Redis와 PostgreSQL, Python 작업자로 하나씩 재현하며 배워가는 시간입니다.

예를 들어서 새벽 정산 배치가 두 서버에서 동시에 돌았다고 해볼게요. 둘 다 Redis 락을 잡고 시작하게 만들어 두었는데도 말이에요.

로그를 보면 첫 번째 서버가 락을 잡고 일하는 도중에 한동안 멈췄고, 그사이 락의 TTL(키가 자동으로 사라지기까지 남은 시간)이 끝났어요.

두 번째 서버는 비어 있는 락을 정상적으로 잡았어요. 첫 번째 서버는 깨어난 뒤 자기가 아직 락을 가지고 있다고 믿고 하던 일을 마저 했습니다.

이런 일이 어떤 순서로 생기는지 보려고, 잔액 100인 계좌에 클라이언트 A가 30을, B가 50을 더해서 최종 잔액 180을 만드는 작업을 Redis 락으로 보호해 볼게요.

실습 준비

이 글의 명령은 모두 실습 코드의 redis-distributed-lock/ 디렉터리에서 실행합니다.

  • compose.yaml: Redis, Redis 복제본, PostgreSQL, Python 컨테이너 정의
  • init.sql: 계좌 하나와 배치 작업 하나
  • lock.py: 연결, 락 해제·연장·펜싱 획득 Lua 스크립트, 로그 함수를 모은 공통 모듈
  • worker.py: 락을 잡고 잔액에 금액을 더하는 작업자
  • reset.py: 잔액과 락 상태를 되돌리고 실험 시작 시간을 기록
  • showlog.py: run.log를 시간순으로 정렬해 출력
  • sig.py: 작업자 프로세스에 SIGSTOP/SIGCONT 전송

compose.yaml로 Redis, PostgreSQL, Python 컨테이너를 띄워요. Redis는 디스크에 아무것도 남기지 않도록 RDB 스냅숏(주기적으로 전체 데이터를 파일로 저장)과 AOF(쓰기 명령을 파일에 이어 적기)를 모두 껐어요. Redis 복제본은 failover 프로필이라 기본 docker compose up에서는 뜨지 않고, 이 글에서는 쓰지 않아요.

init.sql은 계좌 하나와 배치 작업 하나를 만들어요. fence 열은 펜싱 토큰 실습에서 씁니다.

compose 프로젝트 이름을 rlock으로 두고, 자주 쓰는 명령을 셸 함수로 만들어 놓을게요.

worker는 작업자 프로세스를 app 컨테이너 안에서 백그라운드로 띄워요. A와 B는 같은 컨테이너 안의 서로 다른 프로세스라서, 서로 다른 서버에서 도는 두 클라이언트와 같은 상황이라고 보시면 됩니다.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export COMPOSE_PROJECT_NAME=rlock
docker compose up -d
py() { docker compose exec -T app python "$@"; }

worker() { docker compose exec -d app python worker.py "$@"; }

sql() { docker compose exec -T db psql -U postgres -d bank -c "$1"; }

락 키는 lock:account:1이에요. 단일 Redis 락은 명령 하나로 잡아요.

SET lock:account:1 <토큰> NX PX 2000
  • NX: 키가 없을 때만 저장해요. 이미 누가 락을 잡았으면 저장하지 않습니다.
  • PX 2000: 2000밀리초 뒤에 키가 저절로 지워지게 해요. 락을 잡은 프로세스가 죽어도 락이 영원히 남지 않게 하는 장치입니다.

토큰은 락을 잡을 때마다 새로 만드는 고유한 값이에요. 락을 풀 때는 키의 값이 내 토큰과 같을 때만 지워야 해서, 확인과 삭제를 Lua 스크립트 하나로 실행합니다. Redis 8.4부터는 같은 일을 DELEX key IFEQ <토큰> 명령 하나로도 할 수 있어요.

공통 코드는 lock.py에 모아 두었고, 이런 것들이 들어 있어요.

  • Redis와 PostgreSQL 연결
  • 토큰이 같을 때만 지우는 RELEASE
  • 토큰이 같을 때만 TTL을 늘리는 EXTEND
  • 락을 잡으며 펜싱 번호를 올리는 ACQUIRE_FENCED Lua 스크립트
  • 실험 시작 시간 기준으로 run.log에 한 줄씩 남기는 log 함수

worker.py는 락을 잡고, 잔액을 읽고, 금액을 더해 쓰고, 락을 푸는 작업자예요. worker A 30처럼 작업자 이름과 더할 금액을 먼저 받고, 옵션은 이렇습니다.

  • --ttl: 락 TTL(밀리초). 기본 2000이에요.
  • --delay: 실험 시작 후 몇 초 뒤에 락을 잡으러 갈지 정해요. 락을 못 잡으면 0.1초마다 다시 시도합니다.
  • --pause: 잔액을 읽은 뒤 쉬는 시간(초)이에요. 오래 걸리는 작업이나 프로세스가 멈춘 상황을 흉내 냅니다.
  • --watchdog: 락을 쥔 동안 TTL을 주기적으로 늘려요.
  • --fenced: 펜싱 토큰을 받아 조건부로 써요.
  • --retry: 쓰기가 거절되면 락부터 다시 잡고 처음부터 해요.

reset.py는 잔액을 100으로 되돌리고 락 키와 펜싱 카운터를 지운 뒤, 1초 뒤를 실험 시작 시간으로 기록해요. 두 작업자는 이 시작 시간을 기준으로 움직이고, 로그의 초도 이 시간부터 셉니다.

실습 1: 작업이 TTL보다 길어지면

락 TTL은 2초예요. A는 락을 잡고 잔액을 읽은 뒤 3초 동안 멈춥니다.

이 3초는 GC 멈춤을 흉내 낸 거예요. GC 멈춤은 JVM 같은 런타임이 쓰지 않는 메모리를 정리하느라 애플리케이션 스레드를 모두 세우는 시간을 말해요.

B는 1초 뒤부터 0.1초마다 락을 잡으러 가요. 두 작업자는 같은 run.log에 기록하니까, showlog.py로 줄 앞의 초를 기준으로 정렬해 볼게요.

py reset.py
worker A 30 --ttl 2000 --pause 3
worker B 50 --ttl 2000 --delay 1
sleep 6; py showlog.py
sql "SELECT balance FROM accounts WHERE id = 1"
 0.01s  A  락 획득 (TTL 2000ms)
 0.04s  A  잔액 읽음: 100
 1.02s  B  락 획득 실패, 0.1초마다 다시 시도
 2.04s  B  락 획득 (TTL 2000ms)
 2.08s  B  잔액 읽음: 100
 2.11s  B  잔액 씀: 100 + 50 = 150
 2.13s  B  해제 결과: 1
 3.06s  A  잔액 씀: 100 + 30 = 130
 3.20s  A  해제 결과: 0
 balance 
---------
     130
(1 row)

출력을 보면 A가 멈춰 있던 2.04초에 A의 락은 TTL이 끝나 지워졌고, B는 비어 있는 락을 정상적으로 잡았어요. B는 A가 아직 쓰지 않은 잔액 100을 읽어 150을 썼습니다.

3.06초에 깨어난 A는 자기가 3초 전에 읽은 100에 30을 더해 130을 썼어요. 최종 잔액은 기댓값 180이 아니라 130이고, B가 더한 50은 사라졌습니다.

이렇게 한쪽의 쓰기가 다른 쪽 쓰기를 덮어써 없어지는 것을 갱신 손실(lost update)이라고 해요.

A의 해제 결과는 0이에요. 토큰을 비교하는 해제 스크립트 덕분에 B의 락은 지워지지 않았지만, 그 전에 A의 쓰기가 이미 B의 결과를 덮었어요.

그럼 A가 쓰기 직전에 락을 아직 가졌는지 확인하게 바꾸면 될까요? 확인한 바로 다음에 멈추면 같은 일이 생깁니다.

실습 2: 락을 연장해도 프로세스가 멈추면

작업이 TTL보다 길어질 수 있으니, 락을 쥔 동안 TTL을 계속 늘리는 방법이 있어요. 이렇게 TTL을 늘려 주는 백그라운드 스레드를 흔히 watchdog이라고 부르고, Java의 Redisson 같은 라이브러리가 이 방식을 씁니다.

실습에서는 worker.py의 watchdog 스레드가 TTL의 3분의 1마다 lock.py의 EXTEND 스크립트로 "아직 내 토큰이면 PEXPIRE"를 실행하고, 연장에 실패하면 멈춰요. 로그의 PTTL은 연장 직후 남은 TTL(밀리초)이에요.

이번에는 --watchdog을 붙이고, A가 잔액을 읽은 직후 프로세스에 SIGSTOP을 보내 볼게요.

SIGSTOP은 프로세스의 모든 스레드를 그 자리에 세우는 시그널이고, SIGCONT를 받으면 이어서 실행해요. 긴 GC 멈춤이나 가상 머신이 잠시 멈추는 상황과 같다고 보시면 됩니다.

sig.py는 작업자가 시작할 때 적어 둔 PID로 시그널을 보내고 로그에 남겨요. .read_A는 A가 잔액을 읽은 직후 만드는 파일이라, 이 파일이 생기길 기다렸다가 SIGSTOP을 보냅니다.

py reset.py
worker A 30 --ttl 2000 --pause 2 --watchdog
worker B 50 --ttl 2000 --delay 1 --watchdog
until [ -f .read_A ]; do sleep 0.05; done
py sig.py STOP A
sleep 4
py sig.py CONT A
sleep 3; py showlog.py
sql "SELECT balance FROM accounts WHERE id = 1"
 0.12s  A  락 획득 (TTL 2000ms)
 0.16s  A  잔액 읽음: 100
 0.85s  A  watchdog 연장 성공, PTTL 1950
 1.01s  B  락 획득 실패, 0.1초마다 다시 시도
 1.33s  host  A에 SIGSTOP
 2.89s  B  락 획득 (TTL 2000ms)
 2.93s  B  잔액 읽음: 100
 2.94s  B  잔액 씀: 100 + 50 = 150
 2.95s  B  해제 결과: 1
 6.27s  host  A에 SIGCONT
 6.28s  A  watchdog 연장 실패, PTTL -2
 6.28s  A  잔액 씀: 100 + 30 = 130
 6.29s  A  해제 결과: 0
 balance 
---------
     130
(1 row)

0.85초에 마지막으로 연장된 락은 그로부터 2초 뒤에 만료됐고, B가 2.89초에 락을 잡았어요.

왜 연장이 안 됐을까요? watchdog 스레드도 A 프로세스 안에 있으니 A와 함께 멈췄기 때문이에요.

6.28초에 깨어난 A의 watchdog은 연장에 실패해 락을 잃은 걸 알았지만, 같은 순간 메인 스레드는 이미 130을 쓰고 있었어요. 최종 잔액은 다시 130입니다.

watchdog은 작업이 TTL보다 길어지는 경우를 줄여 주지만, 락을 잡은 프로세스가 멈추는 경우에는 도움이 되지 않아요.

실습 3: 펜싱 토큰으로 DB가 늦은 쓰기를 거절하게 하면

락을 가진 클라이언트가 스스로 확인하는 방법은 확인 직후에 멈추면 소용이 없어요. 그래서 여기서는 마지막 확인을 데이터를 저장하는 쪽에 맡기는 방식으로 가볼게요.

락을 잡을 때마다 하나씩 커지는 번호를 함께 발급하고, 쓰기에 그 번호를 붙여 보내요. 저장하는 쪽은 이미 본 번호보다 작은 번호가 붙은 쓰기를 거절합니다. 이 번호를 펜싱 토큰이라고 해요.

락을 잡는 것과 번호를 올리는 것은 한 Lua 스크립트(ACQUIRE_FENCED)에서 함께 해요. SET ... NX PX가 성공하면 같은 스크립트 안에서 INCR로 펜싱 번호를 하나 올려 돌려줍니다. 따로 하면 락을 잡은 순서와 번호 순서가 어긋날 수 있거든요.

쓰기는 fence 열이 내 번호보다 작을 때만 반영해요(UPDATE accounts SET balance = …, fence = 내 번호 WHERE id = 1 AND fence < 내 번호).

실습 1과 같은 흐름에 --fenced를 붙일게요.

이 예는 읽은 잔액에 더하는 구조라서, A의 쓰기를 거절하기만 하면 A가 더하려던 30이 빠져요. 그래서 거절되면 락부터 다시 잡고 처음부터 계산하는 --retry도 함께 붙였습니다.

py reset.py
worker A 30 --ttl 2000 --pause 3 --fenced --retry
worker B 50 --ttl 2000 --delay 1 --fenced --retry
sleep 6; py showlog.py
sql "SELECT balance, fence FROM accounts WHERE id = 1"
 0.00s  A  락 획득 (TTL 2000ms, 펜싱 토큰 1)
 0.05s  A  잔액 읽음: 100
 1.01s  B  락 획득 실패, 0.1초마다 다시 시도
 2.04s  B  락 획득 (TTL 2000ms, 펜싱 토큰 2)
 2.09s  B  잔액 읽음: 100
 2.12s  B  UPDATE balance=150 fence=2 -> rowcount 1
 2.12s  B  해제 결과: 1
 3.06s  A  UPDATE balance=130 fence=1 -> rowcount 0
 3.08s  A  해제 결과: 0
 3.09s  A  쓰기가 거절됨, 락부터 다시 잡는다
 3.09s  A  락 획득 (TTL 2000ms, 펜싱 토큰 3)
 3.12s  A  잔액 읽음: 150
 3.14s  A  UPDATE balance=180 fence=3 -> rowcount 1
 3.14s  A  해제 결과: 1
 balance | fence 
---------+-------
     180 |     3
(1 row)

A는 펜싱 토큰 1, B는 2를 받았어요. B의 UPDATE가 먼저 반영되면서 fence가 2가 됐습니다.

3.06초에 늦게 온 A의 UPDATE는 fence < 1 조건에 맞지 않아 rowcount(UPDATE가 바꾼 행 수) 0으로 거절됐어요. A는 락을 다시 잡아 토큰 3을 받고, B가 쓴 150을 읽어 180을 만들었습니다.

A의 첫 쓰기는 DB에 반영되지 않았어요. 여기서 rowcount 0을 확인하지 않고 넘어가면 A가 더하려던 30은 아무 오류 없이 빠져요.

그러니 펜싱을 쓸 때는 rowcount를 확인하고, 거절되면 다시 할지 실패로 돌려줄지 정해 두세요.

펜싱이 되려면 저장하는 쪽이 번호를 비교할 수 있어야 해요.

이 실습처럼 DB의 조건부 UPDATE로 비교할 수 있으면 되지만, 외부 API 호출이나 파일 쓰기처럼 조건을 붙일 수 없는 대상이라면 펜싱 토큰을 쓸 수 없습니다.

정리

범위: Redis 8.10.2(redis:8.10.2, RDB와 AOF 끔), PostgreSQL 17, Python redis 6.4.0과 psycopg 3.2.10, 잔액 100에 A가 30, B가 50을 더하는 작업, 타이밍에 좌우되는 실습은 각 3번.

  • A가 잔액을 읽고 3초 멈춘 사이 TTL 2초가 지나 B가 락을 잡았고, 깨어난 A가 130을 덮어써 최종 잔액은 180이 아닌 130이었습니다. TTL 락만으로 두 클라이언트가 같은 데이터를 고치는 일을 막을 수 있다고 보지 마세요.
  • watchdog을 붙여도 SIGSTOP으로 A가 멈췄을 때는 연장이 실행되지 못해 잔액이 다시 130이었어요. 락 연장을 프로세스 멈춤 대책으로 쓰지 마세요.
  • 펜싱 토큰을 붙이자 늦게 온 A의 UPDATE가 rowcount 0으로 거절됐고, 락부터 다시 잡은 A가 150을 읽어 180을 만들었습니다. 펜싱을 쓸 때는 rowcount를 확인하고, 거절되면 다시 할지 실패로 돌려줄지 정해 두세요.
  • 이 글에 싣지 않은 추가 측정에서는 영속화를 끈 Redis를 재시작하자 26초 넘게 남았던 락이 사라져 B가 바로 락을 잡았어요. 락 키가 복제본에 닿기 전에 페일오버(원본 Redis가 죽어 복제본을 새 원본으로 올리는 일)했을 때도 새 원본에 락이 없어 B가 락을 잡았습니다. 락용 Redis는 appendfsync always를 쓰거나, 재시작 뒤 최대 락 TTL보다 조금 더 기다린 뒤 락 요청을 받게 하세요.
내 링크 만들기