Redis 좌석 예약 락의 TTL이 만료되면: 안전한 해제와 DB 중복 방지
Redis 락의 안전한 해제와 예약의 중복 확정 방지는 서로 다른 문제다. 해제할 때는 현재 소유자를 확인하고, 예약을 확정할 때는 DB가 중복을 거절하도록 설계해야 한다.
이 글은 2026-09-11 공식 문서를 확인해 작성한 설계 예제다. 실제 예약 서비스에서 발생한 장애 기록이 아니며 Redis·PostgreSQL 통합 실행 결과도 아니다. 아래 토큰·좌석·시간과 상황은 설명용이다. 적용 전에 격리된 환경에서 검증해야 한다.
기존 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이 끝나도 이전 작업이 멈추지는 않는다
다음은 장애를 설명하기 위한 가정 순서다. 실제 측정 시각이나 로그가 아니다.
| 순서 | 요청 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 일관성 주의사항
적용 전에 실제로 확인할 실패 경로
아래 표는 아직 실행하지 않은 검증 계획이다. 기대 동작을 성공 로그로 읽으면 안 된다.
| 검증 상황 | 확인할 결과 |
|---|---|
| 두 요청이 동시에 같은 좌석 요청 | DB 현재 점유 행이 두 개 생기지 않는지 |
| A 만료 후 B 획득, A의 뒤늦은 해제 | A의 조건부 해제가 B의 키를 지우지 않는지 |
| 비교 후 삭제를 두 명령으로 나눈 구형 코드 | 소유권 교체 사이의 오삭제를 재현할 수 있는지 |
| Redis 응답 유실·접속 실패 | 락을 얻었다고 추정하지 않는지 |
| DB INSERT 후 커밋 전 실패 | 롤백된 예약에 성공 응답을 주지 않는지 |
| DB 커밋 후 응답 유실 | 재시도 시 같은 요청 결과를 안전하게 찾는지 |
| 취소된 A가 재개 | 현재 요청 상태 검증이 오래된 확정을 거절하는지 |
기록할 항목은 테스트 버전, 자원 식별자, 요청 상관관계 ID, 락 획득 결과, DB 결과, 해제 결과, 기대와 실제의 차이다. 원본 토큰·인증정보·고객 데이터는 로그와 공개 글에 노출하지 않는다.
DB 부하가 문제라면 pg_stat_statements 구간 비교로 후보 쿼리를 찾을 수 있다. 그러나 평균 실행 시간이 낮다는 결과는 예약 정합성 테스트를 통과했다는 뜻이 아니다.
정리하면, 락은 경합을 조정하고 조건부 해제는 다른 소유자를 보호한다. 예약의 정확성은 DB 제약·요청 상태·재시도 처리까지 함께 검증해야 한다.