# Redis Lua 스크립트를 활용한 동시성 제어와 좌석 선점 로직 개선기
모의 수능 신청 플랫폼을 개발하면서 가장 까다로웠던 부분은 좌석 선점 로직이었다. 한정된 좌석(200석)을 여러 명이 동시에 클릭해도, 중복 없이 정확하게 신청되도록 보장해야 했다. 이는 단순한 기능이 아니라, 시스템 신뢰성과 사용자 경험을 좌우하는 핵심 문제였다.
# 1️⃣ 초창기 접근 — SELECT FOR UPDATE 기반의 비관적 락
초기에는 데이터베이스 수준에서 문제를 해결하기 위해 SELECT FOR UPDATE를 사용했다.
즉, 신청이 들어올 때마다 해당 학교의 시험 행(row)에 락을 걸고 좌석 수를 갱신하는 방식이었다.
이 구조는 정합성을 완벽하게 보장하지만, 트랜잭션 구간이 길어질수록 병목이 심해진다.
당시 제휴된 학교는 총 6곳, 학교당 좌석은 200석이었다. 사전 리서치 결과, 한 학교에 400명 이상이 몰릴 가능성이 높았다. 즉, 락 경쟁이 피할 수 없는 상황이었다.
이를 검증하기 위해 Little’s Law를 적용해봤다.
평균 트랜잭션 시간이 약 100ms였으므로, 한 행(row) 기준으로 초당 약 10건의 요청밖에 처리할 수 없었다.
6개 학교 기준으로 약 60TPS가 한계였다.
실제 테스트에서도 유사한 수치가 나왔고, 동시에 200명 이상이 접속하면
락 대기열이 길어지면서 응답 지연이 눈에 띄게 늘어났다.
이 방식은 안정적이지만, 처리량과 확장성 측면에서는 한계가 명확했다.
# 2️⃣ Redis를 활용한 선점 구조로의 전환
이후 접근 방식을 완전히 바꿨다. Redis는 기본적으로 싱글 스레드 기반의 이벤트 루프 아키텍처를 사용하기 때문에, 명령이 동시에 들어와도 내부적으로 순차적으로 처리된다. 즉, 락 없이도 명령 간 원자성(Atomicity) 을 보장할 수 있다.
여기에 Lua 스크립트를 결합하면 여러 명령을 하나의 트랜잭션처럼 묶어 실행할 수 있다. 이 덕분에 신청 요청이 아무리 동시에 들어와도 “중간에 끼어드는 연산”이 발생하지 않는다.
실제 평균 실행 속도는 약 0.7ms, 빠르면 0.5ms 수준이었다. 이를 기준으로 계산하면 초당 약 2000건의 요청을 안정적으로 처리할 수 있다. 즉, 기존의 60TPS 구조 대비 약 33배 향상된 성능이었다. 동시 접속자 수가 400명을 넘어도 병목이 발생하지 않았다.
# 3️⃣ TTL을 통한 유효기간 관리와 캐시 스탬피드 방지
Redis에 좌석 정보를 저장한 뒤에는, 시험 종료 시점 이후에도 일정 시간 동안은 보상 트랜잭션을 처리할 수 있어야 했다. 이를 위해 각 학교의 좌석 키에 TTL(Time To Live) 을 설정했다. 시험 종료 후 2시간까지 데이터를 유지하도록 하여, 결제 취소나 보상 로직이 정상적으로 작동할 수 있도록 했다.
또한 TTL 만료 시점이 모두 동일할 경우, 한순간에 캐시가 동시에 삭제되는 “캐시 스탬피드(Cache Stampede)” 현상이 발생할 수 있다. 이를 방지하기 위해 TTL에 무작위 지연 값(jitter) 을 더해 만료 시점을 분산시켰다. 결과적으로 Redis가 안정적인 캐시 레이어로 동작할 수 있었다.
# 4️⃣ Eventually Consistent한 Redis → DB 동기화
Redis는 인메모리 저장소이기 때문에, 결국 MySQL과의 최종 일관성(Eventual Consistency) 을 맞춰주는 과정이 필요했다.
이를 위해 스케줄러를 두고, 30분 간격으로 Redis의 데이터를 DB와 동기화했다.
DB에서는 GROUP BY를 통해 학교별 신청자 수를 집계한 뒤,
이를 Redis에 다시 반영하는 식으로 “진실의 원본(Source of Truth)”을 주기적으로 복원했다.
이 방식은 완벽한 실시간 일관성을 제공하진 않지만, 시험 신청이라는 도메인 특성상 수 초 단위의 정확성보다 처리 속도와 경험이 더 중요했다.
단, 마감 직전에 신청만 완료되고 실제 결제나 동기화가 되지 않은 케이스가 생길 수 있었다. 이 문제는 시험 종료 이후 “좌석 보정 스케줄러”를 통해 Redis와 DB의 데이터 차이를 재조정하는 방식으로 해결했다.
# 5️⃣ 트레이드오프 분석
이 시점에서 정리해보면 다음과 같다.
| 항목 | 비관적 락 기반 구조 | Redis + Lua 구조 |
|---|---|---|
| 정합성 | 강한 일관성 (Strong Consistency) | 최종적 일관성 (Eventually Consistent) |
| 처리량 | 약 60 TPS | 약 2000 TPS |
| 평균 응답 시간 | 100ms | 0.5~0.7ms |
| 장애 복구 | 단순 (롤백) | 보상 트랜잭션 필요 |
| 운영 난이도 | 낮음 | 중간 (TTL 관리 및 동기화 필요) |
즉, 비관적 락은 안정적이지만 느리고, Redis는 빠르지만 보상 로직이 필요하다. 결국 “어떤 일관성 수준을 받아들일 것인가”의 문제였다. 이번 프로젝트에서는 속도와 가용성을 우선시하는 AP 중심 설계를 택했다.
# 6️⃣ 회고
이 방식이 완벽하다고 말할 수는 없다. 보상 로직이 추가되면서 운영 복잡도가 늘었고, 마감 직전 동기화 타이밍에 따라 일부 좌석 불일치가 발생할 가능성도 있었다.
하지만 제한된 시간과 리소스 안에서, 사용자 경험을 해치지 않으면서 시스템 안정성을 확보한 현실적인 선택이었다고 생각한다.
무엇보다 이번 경험을 통해 깨달은 점은 단순하다. “모든 시스템은 트레이드오프 위에 세워진다.” 정합성, 성능, 복구성 중 무엇을 우선시할지 명확히 정의하지 않으면 결국 어느 것도 제대로 만족시키지 못한다는 것이다.
# 💡 배운 점 요약
- 비관적 락은 안정적이지만, 대규모 동시 요청에서는 병목이 빠르게 발생한다.
- Redis + Lua는 원자적 연산으로 높은 처리량을 확보할 수 있지만, 보상 설계가 필수적이다.
- TTL과 jitter는 캐시 스탬피드와 데이터 유실을 예방하는 강력한 도구다.
- 완벽한 정합성보다는 도메인에 맞는 일관성 수준을 설계하는 것이 중요하다.