Redis 좌석 예약 락의 TTL이 만료되면: 안전한 해제와 DB 중복 방지
Redis 락의 안전한 해제와 예약의 중복 확정 방지는 서로 다른 문제다. 해제할 때는 현재 소유자를 확인하고, 예약을 확정할 때는 DB가 중복을 거절하도록 설계해야 한다.
이 글은 공식 문서에 근거한 예약 설계 예제에, 2026-09-16 Redis 8.4.0 격리 실험 결과를 더한 기록이다. 실제 예약 서비스의 장애 사례는 아니다. 9절은 단일 Redis에서 직접 실행한 결과이며, PostgreSQL 동시성·Spring Boot 통합·네트워크 장애 검증은 아직 수행하지 않았다. 실습용 토큰과 TTL을 운영값으로 그대로 사용하지 않는다.
2026-10-01에는 macOS의 Redis 8.8.0에서도 같은 소유권 교체 순서를 별도로 재현했다. 추가 실험의 입력·응답·측정 한계는 9절 마지막에 구분해 기록했다.
기존 Spring Boot 다중 서버 스케줄러와 ShedLock이 예약 작업의 중복 실행을 다뤘다면, 이번에는 여러 사용자가 같은 좌석을 요청할 때의 처리 경계를 살펴본다.
1. 잠글 대상부터 정확히 정한다
같은 노선이라도 출발 날짜나 운행 회차가 다르면 다른 좌석 자원이다. 반대로 사용자마다 락 키를 다르게 만들면 같은 좌석을 요청한 두 사용자가 서로 다른 락을 얻는다.
여기서는 하나의 운행 회차 전체를 한 사람이 점유하는 좌석만 예로 든다. run_id는 날짜·방향·회차까지 구분되는 안정적인 식별자라고 가정한다.
mlog:lab:lock:run-example:seat-12
이는 예제 키다. 실제 키에는 환경·서비스 구분을 넣고, 식별자 정규화와 충돌 방지 규칙을 정한다. 탑승 구간에 따라 같은 좌석을 재판매하는 서비스라면 이 모델만으로 충분하지 않다. 구간 겹침 조건을 별도로 설계해야 한다.
또한 짧은 코드 실행을 보호하는 락과 결제를 기다리는 좌석 홀드는 구분한다. 수분 동안 유지되는 업무 상태를 단순 실행 락의 TTL 하나에 맡기지 않는다.
2. 획득과 만료 설정을 한 명령으로 묶는다
다음은 이미 연결된 격리 실습 Redis에서 사용하는 명령 예다. 운영 서버에 그대로 실행하지 않는다.
SET mlog:lab:lock:run-example:seat-12 token-A NX PX 30000
NX는 키가 없을 때만 설정하고 PX는 밀리초 만료 시간을 지정한다. 여기서는 GET 옵션 없이 사용하므로 성공 여부를 해당 클라이언트의 OK·null 매핑으로 구분한다. 타임아웃을 성공이나 일반적인 락 경합으로 처리하면 안 된다. Redis SET
30000은 문법 설명용 값이지 운영 권장 TTL이 아니다. token-A 역시 고정 문자열을 사용하라는 뜻이 아니다. 실제로는 매 획득 시도마다 충분히 충돌 가능성이 낮은 새 소유권 토큰을 생성한다.
3. TTL이 끝나도 이전 작업이 멈추지는 않는다
다음 표는 장애를 설명하기 위한 순서다. 실제 측정 시각을 나타내지는 않으며, 같은 소유권 교체 순서를 Redis에서 실행한 결과는 9절에 정리했다.
| 순서 | 요청 A | 요청 B | Redis 키의 소유자 |
|---|---|---|---|
| 처음 | 락 획득 후 작업 시작 | 대기 | A |
| 다음 | 일시 정지·지연 | 대기 | A |
| 만료 뒤 | 아직 작업이 끝나지 않음 | 새 락 획득 | B |
| 마지막 | 뒤늦게 작업 재개·해제 시도 | 작업 중 | B여야 함 |
마지막에 A가 키 이름만 보고 삭제하면 B의 락까지 없앨 수 있다. TTL은 무기한 점유를 줄이는 장치이지 애플리케이션 작업을 취소하는 기능이 아니다. 단일 Redis의 비동기 복제·승격도 별도의 소유권 겹침 위험을 만들 수 있다. Redis 분산 락 설명
4. Redis 8.4 이상은 DELEX IFEQ를 확인한다
DELEX는 Redis Open Source 8.4.0에서 추가됐다. 현재 저장된 문자열이 자신의 토큰과 같은 경우에만 삭제하도록 사용할 수 있다.
DELEX mlog:lab:lock:run-example:seat-12 IFEQ token-A
삭제했으면 1, 키가 없거나 조건이 맞지 않아 삭제하지 않았으면 0이다. 0만으로 “이미 만료됨”과 “다른 소유자”를 구분할 수 없다. 조건을 생략한 DELEX key를 토큰 확인 해제로 착각하지 않는다. 키가 다른 자료형이면 조건부 명령이 오류를 반환할 수도 있다. Redis DELEX
도입 전 지원 여부를 확인하는 조회 예는 다음과 같다.
COMMAND INFO DELEX
명령 정보 조회 자체가 ACL로 제한될 수 있다. 조회 실패를 바로 구버전으로 판정하지 말고 관리형 서비스 문서·실제 서버 버전·클라이언트 지원도 대조한다. 권한을 풀거나 운영 Redis를 업데이트하는 절차는 이 글에 포함하지 않는다. COMMAND INFO
5. 이전 버전에서는 비교와 삭제를 Lua 안에 넣는다
DELEX를 사용할 수 없는 환경에서는 다음 조건부 해제를 검토할 수 있다.
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
return 0
end
return redis.call('DEL', KEYS[1])
키는 KEYS[1], 그 획득 시도의 토큰은 ARGV[1]로 전달한다. 실제 클라이언트의 스크립트 실행·직렬화 방식에 맞춰야 한다. 애플리케이션에서 GET을 먼저 호출한 뒤 별도 DEL을 호출하면 두 명령 사이에 소유자가 바뀔 수 있다.
Redis는 Lua 스크립트를 원자적으로 실행한다. 다만 이 보장은 Redis 안의 비교·삭제에 관한 것이며, PostgreSQL INSERT까지 같은 트랜잭션으로 만드는 것이 아니다. 스크립트는 짧게 유지한다. Redis Lua 실행
GETDEL도 비교할 예상 토큰을 받는 명령이 아니므로 조건부 소유권 확인을 대체하지 못한다. GETDEL 명령
6. DB가 중복 확정을 거절하도록 한다
이 예제의 최종 불변 조건은 “한 운행 회차의 한 좌석에는 현재 점유 행이 하나만 존재한다”다. 이를 DB에 표현한다.
다음은 별도 실습 PostgreSQL 데이터베이스에서 새 테이블을 만드는 예다. 운영 테이블 변경 스크립트가 아니다.
CREATE TABLE mlog_seat_claim_demo (
run_id text NOT NULL,
seat_no integer NOT NULL CHECK (seat_no > 0),
reservation_ref text NOT NULL,
PRIMARY KEY (run_id, seat_no)
);
복합 기본키로 두 컬럼의 조합이 중복되지 않게 한다. 이 테이블은 예약 이력 전체가 아니라 현재 점유만 나타내는 단순 모델이다. 취소 이력·홀드 만료·좌석 존재 여부·탑승 구간 검사는 별도로 필요하다. PostgreSQL 제약 조건
확정 시도는 다음처럼 충돌을 명시적으로 처리할 수 있다.
INSERT INTO mlog_seat_claim_demo
(run_id, seat_no, reservation_ref)
VALUES
('run-example', 12, 'reservation-B')
ON CONFLICT (run_id, seat_no) DO NOTHING
RETURNING run_id, seat_no, reservation_ref;
반환 행이 없으면 이번 INSERT가 점유를 만들지 못한 것이다. 행이 반환되어도 명시적 트랜잭션이라면 커밋까지 성공해야 확정으로 응답한다. 충돌했다고 기존 예약을 무조건 DO UPDATE로 덮어쓰면 안 된다. PostgreSQL INSERT
동시 INSERT는 다른 트랜잭션의 결정을 기다릴 수 있다. DO NOTHING은 대기 없는 명령이라는 뜻이 아니다. DB 타임아웃·롤백·응답 처리까지 테스트한다. PostgreSQL 트랜잭션 격리
이 제약은 중복 행을 막는다. 만료된 A가 먼저 INSERT해 이기는 것, 이미 취소된 요청이 재개되는 것까지 막지는 않는다. 그런 업무 규칙에는 요청 상태·세대 번호를 DB에서 검증하는 조건부 쓰기가 더 필요하다. 외부 결제나 알림 중복도 이 기본키만으로 해결되지 않는다.
7. 락 해제와 사용자 성공 응답의 경계를 정한다
권장 처리 순서는 이 예제의 설계 제안이다.
- 같은 좌석 자원에 같은 키를 계산한다.
- 새 토큰으로 락을 시도한다. 성공이 확인되지 않으면 보호 구간에 진입하지 않는다.
- 쓰기 DB에서 요청 유효성·현재 점유를 검증하고 트랜잭션을 마친다.
- 자신의 토큰과 일치할 때만 락을 해제한다.
- DB 결과를 기준으로 응답한다. 락 해제 성공을 예약 성공으로 사용하지 않는다.
DB 커밋은 성공했지만 락 해제 요청만 실패했다면 예약까지 실패한 것으로 되돌리지 않는다. DB 처리 결과와 해제 오류를 분리해 기록하고, 응답 유실 여부는 멱등 키로 확인한다.
DB 커밋 뒤 응답이 유실되면 사용자가 같은 요청을 다시 보낼 수 있다. 소유권 토큰은 매 락 시도마다 새로 만들되, 같은 업무 요청을 찾는 멱등 키는 재시도 사이에 유지한다. 두 식별자를 하나로 사용하지 않는다. 별도 요청 기록에 처리 결과를 보존하고, 조회 권한과 요청 내용의 일치 여부를 확인하는 방식이 필요하다.
재조회가 지연된 Replica로 향하면 성공한 예약을 못 찾는 것처럼 보일 수 있다. 이 부분은 Spring Boot PostgreSQL 읽기·쓰기 분리의 정합성 문제와 함께 설계한다.
8. PTTL은 진단값이지 다음 쓰기의 허가증이 아니다
PTTL mlog:lab:lock:run-example:seat-12
Redis 2.8 이상에서 -2는 키 없음, -1은 만료 설정 없음이다. 나머지는 남은 밀리초를 보여준다. 조회 직후에도 상태가 바뀔 수 있으므로 PTTL이 양수였다는 이유로 이어지는 DB 쓰기를 안전하다고 판정하지 않는다. Redis PTTL
TTL을 늘리거나 자동 갱신을 붙이는 것만으로 오래 멈춘 작업을 무효화할 수는 없다. 더 강한 순서 보장이 필요하면 보호 대상 저장소가 오래된 세대의 쓰기를 거절하는 fencing 같은 설계를 검토한다. 단순 소유권 문자열은 단조 증가하는 fencing 번호가 아니다. Redis 일관성 주의사항
9. 격리 실험: 만료된 A가 B의 락을 지우는 순간
2026년 9월 16일 Redis 8.4.0을 별도 Linux x86_64 환경에서 빌드하고, 로컬 주소 127.0.0.1에만 연결해 확인했다. 운영 Redis와 고객 데이터는 사용하지 않았다. Python 3.12.14의 두 Redis 연결로 명령 순서를 통제했으며, 각 경우를 새 실습 키로 10회씩 실행했다. 처리량이나 실제 장애 발생률을 측정한 부하 테스트는 아니다.
A의 TTL은 재현 시간을 줄이려고 200ms, B는 결과 확인 중 만료되지 않도록 5,000ms로 설정했다. 둘 다 운영 권장값이 아니다. 단순히 일정 시간 잔 뒤 만료를 가정하지 않고, PTTL이 -2가 된 것을 확인한 다음 B가 SET ... NX PX 5000에 성공하도록 진행했다.
실험 1: DEL은 B의 락을 실제로 삭제했다
A가 token-A로 락을 얻고 만료된 뒤, B가 같은 키를 token-B로 획득했다. 이때 A의 늦은 해제를 DEL로 실행하자 반환값은 1, 직후 GET은 null이었다. 10회 모두 B의 키가 사라졌다. DEL의 성공은 올바른 소유자가 해제했다는 증거가 아니었다.
실험 2: Lua 조건부 해제는 B의 값을 유지했다
같은 순서에서 5절의 Lua에 token-A를 전달하자 결과는 0이었고, 직후 값은 token-B로 남았다. 이어 B의 토큰으로 실행했을 때만 1을 반환하고 키가 없어졌다. 10회 모두 이전 소유자의 삭제는 거절되고 현재 소유자의 삭제는 성공했다.
실험 3: GET을 먼저 해도 별도 DEL이면 문제가 남았다
이번에는 A가 만료 전에 GET으로 token-A를 확인했다. 그 뒤 만료를 기다려 B가 획득하게 하고, A가 앞서 읽은 결과를 근거로 DEL을 실행했다. 10회 모두 B의 키를 삭제했다. 소유권 확인 코드를 넣는 것만으로는 부족했고, 비교와 삭제가 하나의 원자적 연산이어야 했다.
실험 4: DELEX IFEQ도 현재 소유자만 해제했다
Redis 8.4.0에서 COMMAND INFO DELEX로 지원을 확인한 뒤 같은 순서를 실행했다. A의 DELEX ... IFEQ token-A는 0, 직후 GET은 token-B였다. B의 토큰으로 실행했을 때만 1과 키 삭제를 확인했다. 10회 모두 Lua와 같은 소유권 보호 결과였다. 이는 DELEX 공식 문서의 조건부 삭제 동작과 일치했다.
다음은 첫 실행의 응답을 읽기 쉽게 줄인 것이다. K는 실제 실습 키 mlog:lab:lock:run-example:seat-12:delex:0를 뜻한다. token-A와 token-B는 실습용 값이다.
A: SET K token-A NX PX 200 -> OK
PTTL K -> -2
B: SET K token-B NX PX 5000 -> OK
A: DELEX K IFEQ token-A -> 0
GET K -> token-B
B: DELEX K IFEQ token-B -> 1
GET K -> null
이번 결과로 확인한 범위
확인한 것은 단일 Redis에서 소유자가 바뀐 뒤의 해제 동작이다. Lua와 DELEX IFEQ는 이 실험에서 B의 키를 보호했지만, 이미 만료된 A가 애플리케이션이나 DB에서 계속 실행되는 것까지 중단하지는 않는다. 해제 결과 0을 예약 실패나 성공으로 바로 해석해서도 안 된다. 이러한 구분은 Redis의 단일 인스턴스 락 설명과 함께 읽는 것이 좋다.
Spring Boot·Lettuce 또는 Redisson 통합, Redis 복제·승격, 네트워크 응답 유실, PostgreSQL의 실제 다중 세션 경합은 이번 실행 범위에 포함하지 않았다. 따라서 전체 예약 서비스의 중복 확정 방지까지 검증을 마쳤다는 의미는 아니다.
2026-10-01 추가 재현: Redis 8.8.0에서 해제 방식 비교
이번에는 macOS 27.0.1, Apple Silicon arm64에 이미 설치된 Redis 8.8.0과 Python 3.9.6으로 다시 확인했다. 운영 장애 사례가 아니라 두 클라이언트의 실행 순서를 통제한 로컬 실험이다. 기존 Redis에 접속하지 않고 별도 프로세스를 생성했다. port 0으로 TCP 연결을 끄고 임시 Unix 소켓만 사용했으며, RDB 저장과 AOF도 껐다. 새 프로세스의 PID·설정·빈 DB를 확인한 뒤 시작했다.
Python 표준 라이브러리의 Unix 소켓 연결 두 개를 A와 B로 구분했다. Redis 클라이언트 라이브러리는 사용하지 않고 RESP2 명령과 응답을 기록했다. 입력은 다음과 같다.
| 항목 | 입력 |
|---|---|
| 키 | mlog:lab:20261001:lock:run-example:seat-12:<scenario>:<iteration> |
| A의 획득 | SET K token-A NX PX 200 |
| B의 획득 | SET K token-B NX PX 5000 |
| 만료 확인 | 약 5ms 간격으로 PTTL K를 조회하고 -2 관측 후 진행 |
| 반복 | 해제 방식별 새 키로 10회, 총 50회 |
token-A와 token-B는 이 실험만의 고정 표식이다. 200ms와 5,000ms는 순서를 빠르게 재현하고 결과를 확인하기 위한 값이며, 운영 토큰 생성 방식이나 권장 TTL을 뜻하지 않는다. PTTL=-2는 키가 없음을 나타낸다. 정확히 200ms를 잤다고 만료를 가정하지 않았다. Redis PTTL 공식 문서
먼저 A가 획득한 상태에서는 B의 SET ... NX가 null을 반환하는지 확인했다. 그다음 A의 키가 만료된 것을 관측하고 B가 새로 획득하도록 진행했다. B가 GET으로 자기 값을 확인한 뒤, 이전 소유자 A의 늦은 해제를 실행했다.
| A의 늦은 해제 방식 | A의 반환값 | 해제 직후 값 | 10회 결과 |
|---|---|---|---|
DEL K |
1 |
null |
10회 모두 B의 키 삭제 |
| Lua 비교·삭제 | 0 |
token-B |
10회 모두 B의 키 유지 |
만료 전 GET, 만료·B 획득 후 별도 DEL |
1 |
null |
10회 모두 B의 키 삭제 |
DELEX K IFEQ token-A |
0 |
token-B |
10회 모두 B의 키 유지 |
GETDEL K |
token-B |
null |
10회 모두 B의 키 삭제 |
Lua와 DELEX IFEQ로 A의 삭제를 거절한 경우에는 이어서 B의 토큰으로 해제했다. 각 10회 모두 1을 반환했고, 다음 GET은 null이었다. Lua 코드는 다음과 같다.
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
return 0
end
return redis.call('DEL', KEYS[1])
이번 Redis에서 COMMAND INFO DELEX가 명령 정보를 반환하는 것을 먼저 확인했다. DELEX의 도입 버전은 Redis Open Source 8.4.0이지만 이번 추가 실행은 8.8.0 한 버전에서 수행했다. 다른 버전이나 관리형 서비스의 지원 여부까지 시험했다는 뜻은 아니다. Redis DELEX 공식 문서
다음은 delex_ifeq 첫 번째 반복에서 실제 응답을 간추린 기록이다. K는 mlog:lab:20261001:lock:run-example:seat-12:delex_ifeq:00이고, 시각은 A의 첫 SET 응답을 받은 시점을 0으로 한 클라이언트 관측값이다. 반복되는 중간 PTTL 조회는 생략했다.
0.000 ms A: SET K token-A NX PX 200 -> OK
0.109 ms B: SET K token-B NX PX 5000 -> null
205.007 ms A: PTTL K -> -2
205.156 ms B: SET K token-B NX PX 5000 -> OK
205.240 ms B: GET K -> token-B
205.335 ms A: DELEX K IFEQ token-A -> 0
205.419 ms A: GET K -> token-B
205.549 ms B: DELEX K IFEQ token-B -> 1
205.632 ms A: GET K -> null
분리된 GET과 DEL의 반례에서는 A가 0.090ms에 token-A를 읽었다. 201.631ms에 만료를 관측한 다음 B가 201.755ms에 획득했다. 그 뒤 A가 이전 읽기를 근거로 201.859ms에 DEL을 실행하자 1이 반환됐고 B의 키가 사라졌다. 비교와 삭제를 두 명령으로 나누면 중간에 소유자가 바뀔 수 있다는 점을 직접 확인했다. Lua의 비교·삭제는 Redis 안에서 하나의 원자적 실행으로 묶인다. Redis Lua 실행 보장
GETDEL도 이 문제를 해결하지 않았다. 새 소유자인 B의 문자열을 반환하면서 키를 삭제했기 때문이다. 읽기와 삭제를 한 명령으로 합치는 것과, 예상 소유권 토큰이 일치할 때만 삭제하는 것은 조건이 다르다. Redis GETDEL 공식 문서
최종 50회에서 A의 획득 응답 뒤 만료를 관측하기까지의 시간은 200.219~212.080ms였다. 이 값에는 클라이언트 폴링 간격, 로그 기록, OS 스케줄링이 포함된다. Redis 내부의 정확한 만료 시각이나 성능 수치를 측정한 결과로 해석하지 않는다. 순서를 의도적으로 만든 작은 재현 실험이므로 실제 서비스에서의 경합 확률도 추정할 수 없다.
이번 결과가 확인하는 범위는 단일 Redis에서 이전 소유자가 새 소유자의 락을 잘못 해제하는지까지다. Lua나 DELEX IFEQ의 반환값이 0이었다고 해서 만료된 A의 애플리케이션 작업이 중단되는 것은 아니다. Redis 안의 원자적 해제는 DB 쓰기와 같은 트랜잭션이 아니다. Redis 단일 인스턴스 락 설명
PostgreSQL의 동시 INSERT, DB 요청 상태·세대 번호 검증, 트랜잭션 커밋·롤백 경합, Spring Boot 통합, Redis 복제·승격, 네트워크 응답 유실은 이번 실험에서 검증하지 않았다. DB 기본키·고유 제약으로 중복 점유 행을 막는 것과 만료된 A의 늦은 쓰기를 거절하는 것은 별도 설계다. 전자가 후자를 자동으로 보장하지는 않는다.
10. 서비스 통합에서 추가로 검증할 실패 경로
다음 항목은 아직 실행 결과가 아닌 검증 계획이다. 9절에서 확인한 Redis 해제 테스트와 구분한다.
- 두 요청의 동시 DB INSERT: 같은 운행·좌석의 점유 행이 최종적으로 하나만 남는지 확인한다. 앞선 트랜잭션이 커밋한 경우와 롤백한 경우를 나눠, 대기 중인 요청의 반환 행도 기록한다.
- Redis 응답 유실·접속 실패: 락 획득을 추정해 보호 구간에 진입하지 않는지 확인한다.
- DB INSERT 뒤 커밋 전 실패: 트랜잭션이 롤백되고 사용자에게 성공을 반환하지 않는지 확인한다.
- DB 커밋 뒤 응답 유실: 같은 멱등 키로 재시도했을 때 이미 처리한 예약을 찾아 중복 처리를 막는지 확인한다.
- 취소된 A의 늦은 재개: DB에서 현재 요청 상태를 다시 검증해 오래된 확정을 거절하는지 확인한다.
기록에는 테스트 버전, 자원 식별자, 요청 상관관계 ID, 락 획득 결과, DB 커밋·롤백 결과, 해제 반환값을 함께 남긴다. 운영 소유권 토큰·인증정보·고객 데이터는 공개 로그에 넣지 않는다.
DB 부하가 문제라면 pg_stat_statements 구간 비교로 후보 쿼리를 찾을 수 있다. 그러나 평균 실행 시간이 낮다는 결과는 예약 정합성 테스트를 통과했다는 뜻이 아니다.
정리하면, 락은 경합을 조정하고 조건부 해제는 다른 소유자를 보호한다. 예약의 정확성은 DB 제약·요청 상태·재시도 처리까지 함께 검증해야 한다.