← 공연 예매 서비스 프로젝트로 돌아가기

문제 해결

Redis 분산 락을 활용한 예매 시스템 성능 개선

문제 정의

예매 시스템은 동일 좌석에 대한 중복 예약을 막기 위해 DB 비관적 락으로 동시성을 제어하고 있었습니다. 이 방식은 선점 트랜잭션이 커밋될 때까지 나머지 요청이 DB 커넥션을 점유한 채 대기하는 구조적 한계를 가집니다. 동시 요청이 늘어날수록 락 대기 요청이 커넥션 풀에 누적됩니다. 커넥션 풀이 고갈되면 신규 요청은 커넥션 자체를 확보하지 못해 대기 스레드가 쌓입니다.

이를 확인하기 위해 nGrinder로 가상 유저 1,000명이 좌석 10개를 동시에 예약 요청하는 시나리오를 배포된 개발 서버에서 부하테스트했습니다. 테스트 도중 HikariCP 커넥션 풀 상태를 조회한 결과, 활성 커넥션 30개(풀 전체)가 모두 사용 중이고 유휴 커넥션은 0개, 커넥션을 받기 위해 대기 중인 스레드는 162개로 관찰됐습니다. 이 상태에서 평균 응답 시간은 526ms였습니다.

해결 방안 비교 및 선택 이유

비관적 락의 문제는 “커넥션을 점유한 채 대기한다”는 데 있으므로, 대안은 DB 트랜잭션에 진입하기 전에 경합을 걸러내는 방향이어야 했습니다. 두 가지 후보를 비교했습니다.

후보 A: 낙관적 락 — 트랜잭션 진입 시점에 다른 트랜잭션의 락을 기다리며 블로킹되지 않고, 각자 진행한 뒤 커밋 시점에 버전 충돌을 검사해 실패한 트랜잭션만 처음부터 재시도하는 방식입니다. 하지만 1,000명이 좌석 10개에 동시 경합하는 시나리오에서는 대부분의 요청이 첫 시도에서 충돌해 재시도를 반복하게 되고, 재시도마다 트랜잭션을 다시 열고 닫아야 합니다. 결국 트랜잭션 자체를 반복 실행하는 비용이 발생합니다. 커넥션을 오래 붙잡는 비관적 락과는 다른 방식이지만, 결국 같은 종류의 리소스 낭비(불필요한 트랜잭션 실행)라고 판단해 배제했습니다.

후보 B: Redis 분산 락 — 좌석 선점 여부를 DB 트랜잭션 밖의 Redis에서 먼저 확인하고, 선점에 성공한 요청만 DB 트랜잭션을 실행하는 방식입니다. 경합에서 밀린 요청은 DB에 도달하기 전에 걸러지므로, 낙관적 락처럼 실패한 트랜잭션을 반복 실행하는 비용 자체가 발생하지 않습니다. 이 차이를 근거로 Redis 분산 락을 선택했습니다.

분산 락 구현 라이브러리로는 Lettuce 대신 Redisson을 선택했습니다. Lettuce는 저수준 Redis 커맨드 클라이언트라 락의 재시도 로직, TTL 관리, 원자적 해제를 직접 구현해야 합니다. 반면 Redisson은 이런 기능과, 여러 키를 하나의 원자적 단위로 묶어 락을 거는 기능까지 라이브러리 레벨에서 제공해, 여러 좌석을 동시에 예약해야 하는 이번 요구사항을 직접 구현하지 않고 처리할 수 있었습니다.

문제 해결 과정

락 키는 회차와 좌석 조합 단위로 설계했습니다. 같은 좌석이라도 회차가 다르면 별도의 좌석으로 취급해야 하는 도메인 특성을 반영한 것으로, 좌석만으로 락을 걸면 같은 좌석에 대한 다른 회차의 예약 요청까지 불필요하게 서로 대기하게 됩니다.

여러 좌석을 한 번에 예약하는 요청은 좌석을 정렬한 뒤 Redisson이 제공하는 다중 락 기능으로 묶어서 락을 겁니다. 정렬 없이 각 요청이 좌석을 서로 다른 순서로 락을 걸면, 두 요청이 서로 상대방이 쥔 락을 기다리는 순환 대기가 발생해 데드락으로 이어질 수 있기 때문입니다.

락은 최대 3초까지 대기해 획득을 시도하고, 성공하면 최대 10초 뒤 자동 해제되도록 처리합니다. 대기 시간을 0으로 두어 완전히 즉시 실패시키는 대신 3초를 준 이유는, 순간적으로 겹치는 락 경합은 짧게 흡수하고 그 이상 지연되는 요청만 DB 접근 전에 차단하려는 의도입니다. 3초 내에 락을 획득하지 못하면 좌석이 이미 잠겨 있다는 에러를 반환하고, 이 경우 예약 실행 로직 자체가 호출되지 않아 DB 조회와 트랜잭션이 전혀 시작되지 않습니다.

락 획득에 성공한 요청만 DB 트랜잭션을 실행하며, 트랜잭션 커밋이 완료된 뒤에 락을 해제합니다. 커밋 전에 락을 풀면 아직 반영되지 않은 좌석 상태를 보고 다른 요청이 같은 좌석을 다시 선점할 수 있는 레이스 컨디션이 발생하기 때문에, 락 해제를 커밋 이후로 명시적으로 미뤘습니다.

결과

동일 조건(nGrinder, 가상 유저 1,000명, 좌석 10개, 동일하게 배포된 개발 서버)에서 DB 비관적 락 버전과 Redis 분산 락 버전을 각각 실행해 비교했습니다.

  • 평균 응답 시간: 526ms → 6.61ms
  • 경합에서 밀린 요청은 락 획득에 실패해 DB 조회/트랜잭션이 시작되지 않으므로, 커넥션을 점유한 채 대기하던 비용이 제거됐습니다.
  • 뒤늦게 락을 얻고도 이미 선점된 좌석임을 확인한 뒤 롤백되던 비용도 제거됐습니다.