Redis를 활용한 좌석 예약 시스템: SET NX PX로 좌석 잠금 및 자동 만료
1. 개요
영화표, 비행기표, 또는 식당 테이블 예약 시스템에서 사용자는 보통 결제를 위해 몇 분 동안 좌석을 “보유”합니다. 시간이 만료되고 결제가 완료되지 않으면 좌석은 자동으로 다른 사람에게 반환됩니다. 이 글에서는 Redis의 SET key value NX PX 명령을 사용하여 원자적으로 좌석을 잠그고, 잠금을 자동으로 만료시키며, Lua 스크립트로 안전하게 해제하거나 연장하는 방법을 안내합니다. 이 글을 읽은 후, Spring Boot 3.x에서 동작하는 SeatHoldService와 Testcontainers를 사용한 테스트를 갖게 될 것입니다.
2. 좌석 보유 문제
두 고객이 동시에 상영 42의 좌석 E7을 선택하려고 합니다. 시스템은 세 가지를 보장해야 합니다:
- 정확히 한 명만 좌석을 보유할 수 있습니다.
- 그 사람이 떠나면 좌석은 자동으로 일정 시간(예: 5분) 후에 해제됩니다.
- 좌석을 보유한 본인만 해제하거나 연장할 수 있습니다.
데이터베이스에서 SELECT ... FOR UPDATE와 held_until 컬럼으로 이를 구현할 수 있습니다. 이 방법도 작동하지만 만료된 기록을 정리하는 작업이 필요합니다. 판매 시작 시간에 대량의 쓰기 작업이 데이터베이스를 막을 수 있습니다. Redis는 두 가지를 기본으로 제공하므로 더 간단하게 해결합니다: ’존재하지 않으면 설정’이라는 원자적 연산과 자동 만료되는 TTL이 있는 key입니다.
3. SET NX PX 작동 방식
redis-cli로 직접 시도해봅시다:
redis-cli SET seat-hold:{show:42}:E7 "token-alice" NX PX 300000
redis-cli SET seat-hold:{show:42}:E7 "token-bob" NX PX 300000
redis-cli PTTL seat-hold:{show:42}:E7
OK
(nil)
(integer) 299412
각 부분의 의미:
NX: key가 아직 존재하지 않을 때만 설정합니다. Bob의 두 번째 명령은 Alice가 이미 좌석을 보유했으므로nil을 반환합니다.PX 300000: TTL을 300,000밀리초, 즉 5분으로 설정합니다. 시간이 지나면 Redis가 자동으로 key를 삭제합니다.token-alice값은 누가 좌석을 보유하고 있는지 식별하는 hold token입니다. 나중에 이 token을 사용하여 잠금을 안전하게 해제합니다.
중요한 점은 NX와 PX가 하나의 명령에 포함되어 있어서 원자적으로 실행된다는 것입니다. 이전 방식인 SETNX 후 PEXPIRE는 위험합니다: 두 명령 사이에 애플리케이션이 종료되면 key가 영구적으로 남아 좌석이 무한정 잠긴 상태가 됩니다.
key의 {show:42} 부분은 hash tag입니다. Redis Cluster에서 같은 hash tag를 가진 모든 key는 같은 slot에 있습니다. 덕분에 섹션 7에서처럼 동일한 상영의 여러 좌석에 대해 Lua 스크립트를 사용할 수 있습니다.
4. Spring Boot 설정
Spring Data Redis 의존성을 추가합니다. Lettuce가 기본 클라이언트입니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
연결 설정:
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=2s
Spring Boot가 StringRedisTemplate bean을 자동으로 생성합니다. Key와 value 모두 문자열이므로 이 문제에 적합합니다.
5. setIfAbsent로 좌석 보유하기
@Service
public class SeatHoldService {
private final StringRedisTemplate redis;
public SeatHoldService(StringRedisTemplate redis) {
this.redis = redis;
}
static String key(long showId, String seatId) {
return "seat-hold:{show:%d}:%s".formatted(showId, seatId);
}
public Optional<String> hold(long showId, String seatId, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue().setIfAbsent(key(showId, seatId), token, ttl);
return Boolean.TRUE.equals(ok) ? Optional.of(token) : Optional.empty();
}
public boolean isHeld(long showId, String seatId) {
return Boolean.TRUE.equals(redis.hasKey(key(showId, seatId)));
}
}
설명:
setIfAbsent(key, value, Duration)는SET key value NX PX <ms>명령을 생성합니다.ttl이 정수 초인 경우 Spring은PX대신EX를 사용할 수 있지만 의미는 동일합니다.- 매번 좌석을 보유할 때 새로운
UUID를 token으로 생성합니다. Token은POST /holdsAPI의 응답으로 클라이언트에게 반환됩니다. 클라이언트는 결제 또는 취소 시 token을 다시 보냅니다. setIfAbsent는 pipeline이나 transaction 내에서 실행될 때null이 될 수 있는Boolean을 반환합니다. 따라서 코드는 직접 언박싱하지 않고Boolean.TRUE.equals(ok)로 비교합니다.
컨트롤러에서 사용 예:
@PostMapping("/shows/{showId}/seats/{seatId}/hold")
public ResponseEntity<Map<String, String>> hold(@PathVariable long showId,
@PathVariable String seatId) {
return seatHoldService.hold(showId, seatId, Duration.ofMinutes(5))
.map(token -> ResponseEntity.ok(Map.of("holdToken", token)))
.orElseGet(() -> ResponseEntity.status(HttpStatus.CONFLICT).build());
}
나중에 온 사람은 409 Conflict를 받습니다. 프론트엔드는 이를 기반으로 ’좌석이 이미 선택되었습니다’라는 메시지를 표시합니다.
6. Lua로 안전하게 잠금 해제 및 연장하기
6.1. DEL을 직접 사용하지 않는 이유
다음 시나리오를 봅시다:
- Alice가 TTL 5분으로 좌석을 보유합니다.
- Alice가 결제 페이지에 5분 이상 머물러 있어 key가 자동으로 만료됩니다.
- Bob이 좌석
E7을 보유하게 됩니다. - Alice가 ’취소’를 클릭하면 서버가
DEL을 호출합니다. Bob의 좌석이 삭제됩니다.
삭제하기 전에 token을 확인해야 합니다. GET 후 DEL을 두 개의 별도 명령으로 실행하면 두 명령 사이에 key가 만료되고 소유자가 바뀔 수 있습니다. Lua 스크립트는 Redis에서 원자적으로 실행되므로 이러한 간격을 제거합니다.
6.2. Release(해제) 및 Extend(연장)
@Service
public class SeatHoldService {
private static final RedisScript<Long> RELEASE = new DefaultRedisScript<>("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""", Long.class);
private static final RedisScript<Long> EXTEND = new DefaultRedisScript<>("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
""", Long.class);
// ... constructor, key(), hold(), isHeld()는 위와 동일합니다
public boolean release(long showId, String seatId, String token) {
Long n = redis.execute(RELEASE, List.of(key(showId, seatId)), token);
return n != null && n == 1L;
}
public boolean extend(long showId, String seatId, String token, Duration ttl) {
Long n = redis.execute(EXTEND, List.of(key(showId, seatId)),
token, String.valueOf(ttl.toMillis()));
return n != null && n == 1L;
}
}
설명:
KEYS[1]은 좌석 key이고,ARGV[1]은 클라이언트가 보낸 token입니다. 현재 값이 token과 일치할 때만 script가DEL또는PEXPIRE를 실행합니다.- Script은 성공 시
1을, token이 일치하지 않거나 key가 만료되었을 때0을 반환합니다. DefaultRedisScript는 SHA1을 자동으로 계산하고EVALSHA를 호출합니다. Redis가 아직 스크립트를 캐시하지 않았으면 Spring이 자동으로EVAL로 폴백합니다. 매번 호출할 때마다 다시 생성하지 않도록 script를static final로 선언해야 합니다.
extend는 사용자가 결제 게이트웨이로 이동할 때 추가로 2분을 연장하려는 경우에 유용합니다. extend가 false를 반환하면 좌석이 이미 손실되었음을 알 수 있으며 사용자에게 다시 선택하도록 알려야 합니다.
7. 여러 좌석을 동시에 보유하기
친구 그룹이 세 개의 좌석 E7, E8, E9를 예약합니다. 요구사항은 세 좌석을 모두 보유하거나 아무것도 보유하지 않는 것입니다. hold를 세 번 호출한 후 실패 시 롤백하면 작동하지만 복잡하고 다른 사람들이 중간 상태를 볼 수 있는 순간이 있습니다. Lua 스크립트가 이를 더 간단하게 처리합니다:
private static final RedisScript<Long> HOLD_ALL = new DefaultRedisScript<>("""
for _, k in ipairs(KEYS) do
if redis.call('EXISTS', k) == 1 then return 0 end
end
for _, k in ipairs(KEYS) do
redis.call('SET', k, ARGV[1], 'PX', ARGV[2])
end
return 1
""", Long.class);
public Optional<String> holdAll(long showId, List<String> seatIds, Duration ttl) {
String token = UUID.randomUUID().toString();
List<String> keys = seatIds.stream().map(s -> key(showId, s)).toList();
Long n = redis.execute(HOLD_ALL, keys, token, String.valueOf(ttl.toMillis()));
return n != null && n == 1L ? Optional.of(token) : Optional.empty();
}
첫 번째 루프는 이미 보유된 좌석이 있는지 확인합니다. 없으면 두 번째 루프가 모두 같은 token과 TTL로 설정합니다. 전체 스크립트가 원자적으로 실행되므로 요청이 그 사이에 끼어들 수 없습니다.
hash tag {show:42} 덕분에 같은 상영의 모든 key가 동일한 slot에 있습니다. hash tag가 없으면 script가 여러 key에 접근할 때 Redis Cluster가 CROSSSLOT 오류를 반환합니다.
8. Testcontainers로 테스트 작성하기
테스트 의존성 추가:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
주요 동작에 대한 테스트:
@SpringBootTest
@Testcontainers
class SeatHoldServiceTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired SeatHoldService service;
@Autowired StringRedisTemplate template;
@BeforeEach
void clean() {
template.getConnectionFactory().getConnection().serverCommands().flushAll();
}
@Test
void onlyOneCustomerCanHoldTheSameSeat() {
Optional<String> alice = service.hold(42, "E7", Duration.ofMinutes(5));
Optional<String> bob = service.hold(42, "E7", Duration.ofMinutes(5));
assertThat(alice).isPresent();
assertThat(bob).isEmpty();
}
@Test
void holdExpiresAutomatically() throws InterruptedException {
service.hold(42, "E7", Duration.ofMillis(200));
Thread.sleep(400);
assertThat(service.isHeld(42, "E7")).isFalse();
assertThat(service.hold(42, "E7", Duration.ofMinutes(5))).isPresent();
}
@Test
void staleTokenCannotReleaseSomeoneElsesHold() {
String aliceToken = service.hold(42, "E7", Duration.ofMillis(100)).orElseThrow();
await(200);
String bobToken = service.hold(42, "E7", Duration.ofMinutes(5)).orElseThrow();
assertThat(service.release(42, "E7", aliceToken)).isFalse();
assertThat(service.isHeld(42, "E7")).isTrue();
assertThat(service.release(42, "E7", bobToken)).isTrue();
}
@Test
void holdAllIsAllOrNothing() {
service.hold(42, "E8", Duration.ofMinutes(5));
assertThat(service.holdAll(42, List.of("E7", "E8", "E9"), Duration.ofMinutes(5))).isEmpty();
assertThat(service.isHeld(42, "E7")).isFalse();
assertThat(service.isHeld(42, "E9")).isFalse();
}
private static void await(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
@ServiceConnection(name = "redis")는 이 컨테이너가 Redis라고 Spring Boot에 알립니다. Spring Boot가 자동으로 컨테이너를 가리키는 호스트 및 포트를 설정하므로 @DynamicPropertySource가 필요하지 않습니다. staleTokenCannotReleaseSomeoneElsesHold 테스트는 섹션 6.1의 정확한 시나리오를 재현합니다. Alice의 이전 token은 Bob의 잠금을 해제할 수 없습니다.
여러 스레드를 사용하여 실제 경합을 테스트할 수도 있습니다:
@Test
void hundredConcurrentRequestsYieldExactlyOneWinner() throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Optional<String>>> results = IntStream.range(0, 100)
.mapToObj(i -> executor.submit(() -> service.hold(42, "E7", Duration.ofMinutes(5))))
.toList();
long winners = 0;
for (var f : results) if (f.get().isPresent()) winners++;
assertThat(winners).isEqualTo(1);
}
}
테스트는 Java 21의 virtual thread를 사용하여 100개의 요청을 동시에 발사합니다. 결과는 항상 정확히 한 명의 승자가 있습니다.
9. 일반적인 오류 및 제한사항
주의: Redis 잠금은 “소프트” 보호 계층일 뿐입니다. 예약을 확인할 때
booking테이블의(show_id, seat_id)에 unique constraint가 있어야 합니다. Redis는 failover 시 데이터를 잃을 수 있으므로 replica가 비동기적으로 데이터를 수신합니다. constraint가 없으면 두 사람이 같은 좌석에 대해 승인될 수 있습니다.
다른 일반적인 오류:
SETNX후EXPIRE를 별도로 사용합니다. 두 명령 사이에 애플리케이션이 종료되면 잠금이 절대 만료되지 않습니다. 항상 한 명령에서SET ... NX PX를 사용하세요.- token을 확인하지 않고
DEL로 잠금을 해제합니다. 섹션 6.1의 시나리오처럼 실수로 다른 사람의 잠금을 삭제할 수 있습니다. - 잠금을 확인하지 않고 예약을 확인합니다. 예약을 작성하기 전에 token이 여전히 일치하는지 확인하는 스크립트를 호출하거나
release를 사용하여 결과가true일 때만 작성하세요. 결제가 성공했지만 잠금이 손실된 경우 환불 프로세스가 필요합니다. - TTL이 너무 짧거나 너무 깁니다. 너무 짧으면 카드를 입력하는 동안 사용자가 좌석을 잃습니다. 너무 길면 피크 시간에 좌석이 ‘어리석게’ 점유됩니다. 각 단계에서
extend와 함께 짧은 TTL을 유지하세요. - Cluster 실행 시 hash tag를 잊어버립니다. 여러 key의 스크립트가
CROSSSLOT오류가 발생합니다. - 애플리케이션 시계에 의존하여 보유 기간을 계산합니다. Redis가 TTL을 관리하도록 하세요. UI에서 카운트다운을 표시해야 하면 직접 계산하지 말고
PTTL을 읽으세요.
이 방법을 사용하면 안 되는 경우:
- 잠금 계층에서 절대적인 보장이 필요한 경우, 예를 들어 뒤에 database 제약 계층이 없는 재정 리소스. 비동기 복제를 가진 단일 Redis는 그러한 보장을 제공하지 않습니다.
- 트래픽이 적고 데이터베이스가 충분할 때. unique constraint와 함께
held_until컬럼이 추가 Redis 운영보다 간단할 수 있습니다. - 보유 상태를 대사를 위해 오랫동안 보관해야 할 때. Redis key는 자동으로 사라지므로 별도의 이벤트 로그를 작성해야 합니다.
10. 결론
SET key value NX PX 명령은 단 하나의 호출로 원자적이고 자동으로 만료되는 좌석 잠금을 제공합니다. 임의의 token과 Lua 스크립트와 결합하면 다른 사람의 잠금을 실수로 삭제할 염려 없이 잠금을 해제하거나 연장할 수 있습니다. 여러 좌석을 all-or-nothing 방식으로 보유할 수도 있습니다. Redis Cluster 실행 시 hash tag를 사용하세요. 마지막으로 예약을 확인할 때 unique constraint가 있는 데이터베이스를 최종 보호 계층으로 사용하세요.