> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# Redis 좌석 예약 락의 TTL이 만료되면: 안전한 해제와 DB 중복 방지
- URL: https://mlog.me/redis-lock-ttl-expiry-safe-release-seat-reservation/
- Published: 2026-09-11T05:08:51.000Z
- Updated: 2026-09-11T05:08:51.000Z
- Description: Redis 락의 소유권 확인과 DB 중복 예약 방지는 다른 문제입니다. TTL 만료 뒤의 안전한 해제, DB 제약, 커밋·재시도 경계를 구분합니다.
- Author: mLog
- Tags: 데이터베이스, Redis, PostgreSQL, #예약-동시성

**Redis 락의 안전한 해제와 예약의 중복 확정 방지는 서로 다른 문제다.** 해제할 때는 현재 소유자를 확인하고, 예약을 확정할 때는 DB가 중복을 거절하도록 설계해야 한다.

이 글은 2026-09-11 공식 문서를 확인해 작성한 설계 예제다. 실제 예약 서비스에서 발생한 장애 기록이 아니며 Redis·PostgreSQL 통합 실행 결과도 아니다. 아래 토큰·좌석·시간과 상황은 설명용이다. 적용 전에 격리된 환경에서 검증해야 한다.

기존 [Spring Boot 다중 서버 스케줄러와 ShedLock](https://mlog.me/spring-boot-scheduled-shedlock-postgresql/)이 예약 작업의 중복 실행을 다뤘다면, 이번에는 여러 사용자가 같은 좌석을 요청할 때의 처리 경계를 살펴본다.

## 1\. 잠글 대상부터 정확히 정한다

같은 노선이라도 출발 날짜나 운행 회차가 다르면 다른 좌석 자원이다. 반대로 사용자마다 락 키를 다르게 만들면 같은 좌석을 요청한 두 사용자가 서로 다른 락을 얻는다.

여기서는 **하나의 운행 회차 전체를 한 사람이 점유하는 좌석**만 예로 든다. `run_id`는 날짜·방향·회차까지 구분되는 안정적인 식별자라고 가정한다.

```text
mlog:lab:lock:run-example:seat-12

```

이는 예제 키다. 실제 키에는 환경·서비스 구분을 넣고, 식별자 정규화와 충돌 방지 규칙을 정한다. 탑승 구간에 따라 같은 좌석을 재판매하는 서비스라면 이 모델만으로 충분하지 않다. 구간 겹침 조건을 별도로 설계해야 한다.

또한 짧은 코드 실행을 보호하는 락과 결제를 기다리는 좌석 홀드는 구분한다. 수분 동안 유지되는 업무 상태를 단순 실행 락의 TTL 하나에 맡기지 않는다.

## 2\. 획득과 만료 설정을 한 명령으로 묶는다

다음은 이미 연결된 **격리 실습 Redis**에서 사용하는 명령 예다. 운영 서버에 그대로 실행하지 않는다.

```redis
SET mlog:lab:lock:run-example:seat-12 token-A NX PX 30000

```

`NX`는 키가 없을 때만 설정하고 `PX`는 밀리초 만료 시간을 지정한다. 여기서는 `GET` 옵션 없이 사용하므로 성공 여부를 해당 클라이언트의 `OK`·null 매핑으로 구분한다. 타임아웃을 성공이나 일반적인 락 경합으로 처리하면 안 된다. [Redis SET](https://redis.io/docs/latest/commands/set/?ref=mlog.me)

30000은 문법 설명용 값이지 운영 권장 TTL이 아니다. `token-A` 역시 고정 문자열을 사용하라는 뜻이 아니다. 실제로는 매 획득 시도마다 충분히 충돌 가능성이 낮은 새 소유권 토큰을 생성한다.

## 3\. TTL이 끝나도 이전 작업이 멈추지는 않는다

다음은 장애를 설명하기 위한 가정 순서다. 실제 측정 시각이나 로그가 아니다.

| 순서   | 요청 A            | 요청 B   | Redis 키의 소유자 |
| ---- | --------------- | ------ | ------------ |
| 처음   | 락 획득 후 작업 시작    | 대기     | A            |
| 다음   | 일시 정지·지연        | 대기     | A            |
| 만료 뒤 | 아직 작업이 끝나지 않음   | 새 락 획득 | B            |
| 마지막  | 뒤늦게 작업 재개·해제 시도 | 작업 중   | B여야 함        |

마지막에 A가 키 이름만 보고 삭제하면 B의 락까지 없앨 수 있다. TTL은 무기한 점유를 줄이는 장치이지 애플리케이션 작업을 취소하는 기능이 아니다. 단일 Redis의 비동기 복제·승격도 별도의 소유권 겹침 위험을 만들 수 있다. [Redis 분산 락 설명](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/?ref=mlog.me)

## 4\. Redis 8.4 이상은 DELEX IFEQ를 확인한다

`DELEX`는 Redis Open Source 8.4.0에서 추가됐다. 현재 저장된 문자열이 자신의 토큰과 같은 경우에만 삭제하도록 사용할 수 있다.

```redis
DELEX mlog:lab:lock:run-example:seat-12 IFEQ token-A

```

삭제했으면 1, 키가 없거나 조건이 맞지 않아 삭제하지 않았으면 0이다. 0만으로 “이미 만료됨”과 “다른 소유자”를 구분할 수 없다. 조건을 생략한 `DELEX key`를 토큰 확인 해제로 착각하지 않는다. 키가 다른 자료형이면 조건부 명령이 오류를 반환할 수도 있다. [Redis DELEX](https://redis.io/docs/latest/commands/delex/?ref=mlog.me)

도입 전 지원 여부를 확인하는 조회 예는 다음과 같다.

```redis
COMMAND INFO DELEX

```

명령 정보 조회 자체가 ACL로 제한될 수 있다. 조회 실패를 바로 구버전으로 판정하지 말고 관리형 서비스 문서·실제 서버 버전·클라이언트 지원도 대조한다. 권한을 풀거나 운영 Redis를 업데이트하는 절차는 이 글에 포함하지 않는다. [COMMAND INFO](https://redis.io/docs/latest/commands/command-info/?ref=mlog.me)

## 5\. 이전 버전에서는 비교와 삭제를 Lua 안에 넣는다

`DELEX`를 사용할 수 없는 환경에서는 다음 조건부 해제를 검토할 수 있다.

```lua
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 실행](https://redis.io/docs/latest/develop/programmability/eval-intro/?ref=mlog.me)

`GETDEL`도 비교할 예상 토큰을 받는 명령이 아니므로 조건부 소유권 확인을 대체하지 못한다. [GETDEL 명령](https://redis.io/docs/latest/commands/getdel/?ref=mlog.me)

## 6\. DB가 중복 확정을 거절하도록 한다

이 예제의 최종 불변 조건은 “한 운행 회차의 한 좌석에는 현재 점유 행이 하나만 존재한다”다. 이를 DB에 표현한다.

다음은 **별도 실습 PostgreSQL 데이터베이스**에서 새 테이블을 만드는 예다. 운영 테이블 변경 스크립트가 아니다.

```sql
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 제약 조건](https://www.postgresql.org/docs/18/ddl-constraints.html?ref=mlog.me)

확정 시도는 다음처럼 충돌을 명시적으로 처리할 수 있다.

```sql
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](https://www.postgresql.org/docs/18/sql-insert.html?ref=mlog.me)

동시 INSERT는 다른 트랜잭션의 결정을 기다릴 수 있다. `DO NOTHING`은 대기 없는 명령이라는 뜻이 아니다. DB 타임아웃·롤백·응답 처리까지 테스트한다. [PostgreSQL 트랜잭션 격리](https://www.postgresql.org/docs/18/transaction-iso.html?ref=mlog.me)

이 제약은 **중복 행**을 막는다. 만료된 A가 먼저 INSERT해 이기는 것, 이미 취소된 요청이 재개되는 것까지 막지는 않는다. 그런 업무 규칙에는 요청 상태·세대 번호를 DB에서 검증하는 조건부 쓰기가 더 필요하다. 외부 결제나 알림 중복도 이 기본키만으로 해결되지 않는다.

## 7\. 락 해제와 사용자 성공 응답의 경계를 정한다

권장 처리 순서는 이 예제의 설계 제안이다.

1. 같은 좌석 자원에 같은 키를 계산한다.
2. 새 토큰으로 락을 시도한다. 성공이 확인되지 않으면 보호 구간에 진입하지 않는다.
3. 쓰기 DB에서 요청 유효성·현재 점유를 검증하고 트랜잭션을 마친다.
4. 자신의 토큰과 일치할 때만 락을 해제한다.
5. DB 결과를 기준으로 응답한다. 락 해제 성공을 예약 성공으로 사용하지 않는다.

DB 커밋은 성공했지만 락 해제 요청만 실패했다면 예약까지 실패한 것으로 되돌리지 않는다. DB 처리 결과와 해제 오류를 분리해 기록하고, 응답 유실 여부는 멱등 키로 확인한다.

DB 커밋 뒤 응답이 유실되면 사용자가 같은 요청을 다시 보낼 수 있다. 소유권 토큰은 매 락 시도마다 새로 만들되, 같은 업무 요청을 찾는 멱등 키는 재시도 사이에 유지한다. 두 식별자를 하나로 사용하지 않는다. 별도 요청 기록에 처리 결과를 보존하고, 조회 권한과 요청 내용의 일치 여부를 확인하는 방식이 필요하다.

재조회가 지연된 Replica로 향하면 성공한 예약을 못 찾는 것처럼 보일 수 있다. 이 부분은 [Spring Boot PostgreSQL 읽기·쓰기 분리](https://mlog.me/spring-boot-postgresql-read-write-splitting/)의 정합성 문제와 함께 설계한다.

## 8\. PTTL은 진단값이지 다음 쓰기의 허가증이 아니다

```redis
PTTL mlog:lab:lock:run-example:seat-12

```

Redis 2.8 이상에서 -2는 키 없음, -1은 만료 설정 없음이다. 나머지는 남은 밀리초를 보여준다. 조회 직후에도 상태가 바뀔 수 있으므로 PTTL이 양수였다는 이유로 이어지는 DB 쓰기를 안전하다고 판정하지 않는다. [Redis PTTL](https://redis.io/docs/latest/commands/pttl/?ref=mlog.me)

TTL을 늘리거나 자동 갱신을 붙이는 것만으로 오래 멈춘 작업을 무효화할 수는 없다. 더 강한 순서 보장이 필요하면 보호 대상 저장소가 오래된 세대의 쓰기를 거절하는 fencing 같은 설계를 검토한다. 단순 소유권 문자열은 단조 증가하는 fencing 번호가 아니다. [Redis 일관성 주의사항](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/?ref=mlog.me)

## 적용 전에 실제로 확인할 실패 경로

아래 표는 **아직 실행하지 않은 검증 계획**이다. 기대 동작을 성공 로그로 읽으면 안 된다.

| 검증 상황                    | 확인할 결과                     |
| ------------------------ | -------------------------- |
| 두 요청이 동시에 같은 좌석 요청       | DB 현재 점유 행이 두 개 생기지 않는지    |
| A 만료 후 B 획득, A의 뒤늦은 해제   | A의 조건부 해제가 B의 키를 지우지 않는지   |
| 비교 후 삭제를 두 명령으로 나눈 구형 코드 | 소유권 교체 사이의 오삭제를 재현할 수 있는지  |
| Redis 응답 유실·접속 실패        | 락을 얻었다고 추정하지 않는지           |
| DB INSERT 후 커밋 전 실패      | 롤백된 예약에 성공 응답을 주지 않는지      |
| DB 커밋 후 응답 유실            | 재시도 시 같은 요청 결과를 안전하게 찾는지   |
| 취소된 A가 재개                | 현재 요청 상태 검증이 오래된 확정을 거절하는지 |

기록할 항목은 테스트 버전, 자원 식별자, 요청 상관관계 ID, 락 획득 결과, DB 결과, 해제 결과, 기대와 실제의 차이다. 원본 토큰·인증정보·고객 데이터는 로그와 공개 글에 노출하지 않는다.

DB 부하가 문제라면 [pg\_stat\_statements 구간 비교](https://mlog.me/postgresql-pg-stat-statements-snapshot-delta-slow-queries/)로 후보 쿼리를 찾을 수 있다. 그러나 평균 실행 시간이 낮다는 결과는 예약 정합성 테스트를 통과했다는 뜻이 아니다.

정리하면, 락은 경합을 조정하고 조건부 해제는 다른 소유자를 보호한다. 예약의 정확성은 DB 제약·요청 상태·재시도 처리까지 함께 검증해야 한다.