FairLock은 왜 필요했을까?
FCFS 예약 결제 과제를 구현하며 일반 분산락만으로는 보장하기 어려웠던 락 획득 순서와 Redisson FairLock을 검토하게 된 과정을 정리했습니다.
서론
최근 선착순 예약/결제 시스템 과제를 진행해보면서 가장 오래 고민했던 지점은 단순히 "재고보다 많이 팔리지 않게 막는 것"이 아니었습니다.
물론 오버셀링을 막는 것은 기본입니다. 하지만 선착순이라는 요구사항이 붙는 순간 질문이 조금 달라졌습니다.
재고는 정확히 막았는데, 이 요청들이 정말 납득 가능한 순서로 처리됐다고 말할 수 있을까?
처음에는 일반적인 Redis 분산락을 떠올렸습니다. 과제 요구사항으로는 2대 이상의 서버 인스턴스가 동시에 같은 상품 옵션에 대한 예약 요청을 처리할 수 있어야 했고, 그 과정에서 재고 차감과 DB 저장이 원자적으로 묶여야 했습니다. 같은 상품 옵션에 대해 한 번에 하나의 요청만 DB 예약 구간에 들어가게 하면 충분하다고 생각했습니다. 그런데 구현 방향을 잡는 과정에서 AI를 활용해 Redisson 분산락까지 고려하며 동시성 제어 방식에 대해 방향성을 수립하다가 FairLock이라는 선택지를 처음 접했습니다.
그때부터 질문이 바뀌었습니다.
분산락은 동시에 하나만 처리하게 해주는데, 선착순에서는 "누가 먼저 처리되는가"도 설계의 일부가 아닐까?
이 글은 FairLock 자체를 소개하기 위한 글이라기보다, 제가 선착순 예약 시스템을 구현하면서 정합성과 공정성을 분리해서 생각하게 된 과정을 정리한 글입니다.
글에서 이야기하는 과제 코드는 fcfs-reservation-system에 정리해두었습니다. 한정 수량 숙소 상품을 대상으로 00시 오픈 직후의 예약과 결제를 처리하는 Kotlin/Spring Boot 기반 시스템이고, Redis counter, Redisson FairLock, MySQL 조건부 차감, 결제 실패 보상 흐름을 함께 다뤘습니다.
처음에 흔히들 아는 분산락이면 충분하다고 생각했습니다
일반적인 Redis 기반 분산락의 목적은 명확합니다. 여러 서버 인스턴스가 같은 자원에 동시에 접근하지 못하게 막는 것입니다.
많은 서버 개발자분들이 예를 들어 같은 상품 옵션의 남은 수량을 차감하는 구간이 있다면, 분산락은 아래 문제를 줄이는 데 도움이 됩니다.
- 여러 요청이 같은 재고 값을 동시에 읽는 문제
- 동시에 차감하면서 재고 정합성이 깨지는 문제
- 멀티 인스턴스 환경이라면 JVM 내부 lock이 통하지 않는 문제
Redis 공식 문서에서도 분산락의 중요한 safety property를 mutual exclusion, 즉 특정 시점에 하나의 client만 lock을 잡는 것으로 설명합니다. [E2]
여기까지만 보면 선착순 예약도 일반 분산락으로 충분해 보입니다.
요청 A, B, C가 동시에 들어옴
-> lock을 하나만 획득
-> DB 재고 차감
-> lock 해제
-> 다음 요청 처리그런데 선착순 요구사항을 놓고 다시 보면 애매한 지점이 생깁니다.
일반 lock은 "동시에 하나만 처리한다"는 설명에는 강하지만, "먼저 기다린 요청이 먼저 처리된다"는 점을 보장하기에는 상대적으로 약한 느낌이 듭니다. lock이 풀리는 순간 여러 요청이 다시 경쟁한다면, 어떤 요청이 다음으로 lock을 잡을지는 구현과 타이밍에 의존할 수 있습니다.
정합성만 보면 괜찮을 수 있습니다. 하지만 선착순에서는 기대하는 순서가 있는데, 일반적인 분산락만으로는 그 순서를 보장하기에 부족한 느낌이 들었습니다. 그래서 이번 과제에서 단순히 분산락을 도입하여 해결하는 것이 맞을 수는 있으나, 선착순 시스템에서 공정성 문제를 더 명확히 다루려면 FairLock과 같은 추가적인 도구를 활용하는 것도 고려해볼 만하다고 생각했습니다.
선착순에서 공정성은 정합성과 다른 문제였습니다
이 과제에서 제가 구분하고 싶었던 것은 두 가지였습니다.
| 구분 | 질문 | 최종 책임 |
|---|---|---|
| 정합성 | 재고보다 많이 팔리지 않았는가? | DB 조건부 update, 제약 |
| 공정성 | 먼저 대기한 요청이 먼저 처리되는 순서를 보장할 수 있는가? | Redis gate, FairLock, queue 설계 |
처음에는 이 둘을 하나의 문제처럼 생각했습니다. "락을 걸면 정합성도 맞고 선착순도 되는 것 아닌가?"라고 본 것입니다.
하지만 조금 더 생각해보니 락은 기본적으로 임계 구간 보호 도구입니다. 공정성은 별도의 문제입니다. 특히 여러 서버 인스턴스가 있고, 동시에 많은 요청이 몰리는 상황에서는 lock 획득 순서를 보장하기 어려울 수 있습니다.
이 지점에서 FairLock이 눈에 들어왔습니다.
FairLock은 무엇을 다르게 보게 만들었나
Redisson FairLock은 대기 중인 thread를 queue로 관리하고, 요청한 순서대로 lock을 획득하도록 돕는 lock입니다. Redisson 문서에서는 FairLock이 요청 순서대로 lock을 획득하게 하며, 대기 thread가 죽은 경우에는 복귀를 기다리는 시간이 생길 수 있다고 설명합니다. [E1]
제가 여기서 중요하게 본 것은 "완벽한 선착순 보장"이 아니었습니다.
FairLock이 사용자 클릭 시각 기준의 전역 순서를 보장하는 것은 아닙니다. 네트워크 지연, 로드밸런서, 서버 큐, Redis 도착 순서가 모두 영향을 줍니다.
대신 FairLock은 적어도 락 대기열 안에 들어온 요청들 사이에서 획득 순서를 보장하는 장치로 볼 수 있었습니다.
그래서 저는 FairLock을 최종 정합성 도구가 아니라, 선착순 예약 구간의 보조 장치로 이해했습니다.
정합성 도구: DB conditional update
순서 보조 도구: FairLock
입장 제한 도구: Redis Lua counter일반 분산락과 FairLock은 어떤 차이가 있었나
제가 처음 헷갈렸던 부분은 일반 분산락과 FairLock이 모두 "한 번에 하나만 들어가게 한다"는 점이었습니다. 겉으로 보면 둘 다 임계 구간을 보호하는 도구처럼 보입니다.
하지만 선착순 관점에서 보면 차이가 있습니다.
| 구분 | 일반 분산락 | FairLock |
|---|---|---|
| 핵심 목적 | 같은 자원에 동시에 한 요청만 접근하게 한다 | 동시에 한 요청만 접근하게 하면서 대기 순서까지 고려한다 |
| 획득 순서 보장 | lock이 풀리는 순간 경쟁한 요청 중 하나가 획득한다 | lock 대기열 기준으로 먼저 기다린 요청이 먼저 획득하는 방향이다 |
| 적합한 상황 | 순서보다 정합성과 처리량이 중요한 짧은 임계 구간 | 선착순 예약, 티켓팅처럼 락 대기열 기준 순서 보장이 중요한 짧은 임계 구간 |
| 비용 | 구조가 단순하고 상대적으로 가볍다 | 대기열 관리 비용과 죽은 대기자 확인 비용이 추가될 수 있다 |
| 주의점 | "먼저 요청한 사용자"의 처리 순서를 보장하기 어렵다 | 사용자 클릭 시각 기준의 전역 선착순을 보장하지는 않는다 |
| 최종 정합성 | DB 조건부 update나 제약이 필요하다 | DB 조건부 update나 제약이 필요하다 |
성능 수치를 넣으려면 같은 코드 경로에서 RLock과 FairLock을 각각 적용하고, 같은 Redis/DB/애플리케이션 환경에서 p95/p99 latency, lock wait time, throughput, sold-out 이후 응답 시간을 비교해야 합니다.
대신 설계 판단은 이렇게 잡았습니다. FairLock을 모든 요청에 적용하지 않고, Redis Lua gate를 통과한 소수 요청만 FairLock 대기열에 들어가게 한다면 비용을 제한하면서 락 획득 순서를 보장할 수 있다고 봤습니다.
제가 잡은 구조는 Redis -> FairLock -> MySQL이었습니다
최종적으로 생각한 구조는 아래와 같습니다.
1. Redis Lua counter
- 남은 수량이 있을 때만 통과
- 매진이면 빠르게 거절 (DB까지 닿지 않음)
2. Redisson FairLock
- Redis gate를 통과한 요청만 대기
- 짧은 DB 예약 구간의 진입 순서 보강
3. MySQL conditional update
- remaining_quantity > 0 조건으로 차감
- 최종 오버셀링 방지
4. PG 결제
- lock 밖에서 처리 (Transaction NOT_SUPPORTED)
- 실패 시 보상 흐름으로 복구여기서 중요한 점은 모든 요청을 FairLock에 넣지 않았다는 것입니다.
만약 1,000명이 동시에 들어왔고 남은 좌석이 10개라면, 1,000명을 모두 락 대기열에 세우는 것은 비효율적입니다. 먼저 Redis Lua counter로 실제 재고 수량만큼만 통과시키고, 그 요청들만 FairLock 대기열에 들어가게 해야 합니다.
이렇게 하면 FairLock의 비용을 전체 트래픽에 지불하지 않고, 락 획득 순서를 보장해야 하는 짧은 구간에만 적용할 수 있습니다.
실제 과제 repo에서는 이 구조를 README와 설계 문서로 나눠 정리했습니다. 전체 시스템 맥락은 README에, Redis counter와 FairLock 경계는 동시성/락 전략 문서에, 왜 다층 방어를 선택했는지는 주요 의사결정 문서에 남겨두었습니다.
코드 기준으로 따라가면 흐름이 더 분명합니다. 예약 요청의 전체 흐름은 BookingFacade가 조율하고, Redis counter와 FairLock을 묶은 재고 예약 경계는 StockService에 있습니다. Redis Lua 조건부 차감은 StockRedisCounterRepository에, Redisson FairLock 획득은 RedisLockClient와 DistributedLockProcessor에 있습니다. 마지막 방어선인 DB 조건부 차감은 ProductStockJpaRepository, 락 안에서 PENDING 주문을 만드는 구간은 BookingReservationProcessor를 보면 됩니다.
왜 결제는 lock 밖으로 뺐을까
선착순 시스템에서 lock 안에 들어가는 코드는 짧아야 합니다.
특히 PG 결제처럼 외부 I/O가 있는 작업은 lock 안에 넣으면 위험합니다. PG 응답이 2초 늦어지면 lock도 2초 더 오래 잡히고, 뒤의 요청들도 모두 밀립니다.
그래서 제가 잡은 기준은 단순했습니다.
| 구간 | lock 안에 둘지 | 이유 |
|---|---|---|
| DB 재고 차감 | 유지 | 예약 상태를 바꾸는 핵심 임계 구간 |
| PENDING 주문 생성 | 유지 | 재고 차감과 함께 묶어야 함 |
| PG 결제 요청 | 분리 | 외부 I/O라 지연 편차가 큼 |
| 결제 실패 보상 | 별도 처리 | lock 장기 점유를 피해야 함 |
FairLock을 쓴다고 해서 긴 작업을 공정하게 줄 세우는 것이 좋은 설계는 아닙니다. 공정하게 오래 기다리게 만드는 것보다, lock 안의 일을 줄이는 것이 먼저였습니다.
다른 팀의 락 설계 글을 보며 다시 생각한 지점
FairLock 자체를 전면에 둔 공개 글은 많지 않았지만, Redisson 분산락과 Redis lock을 실제 서비스 동시성 문제에 적용한 글들은 참고할 만했습니다. 제가 보기에 공통적으로 중요한 지점은 "락을 썼는가"보다 락이 어느 경계를 맡고, 어디서부터 다른 장치가 책임을 나눠 갖는가였습니다.
컬리 풀필먼트 입고서비스팀은 멀티 인스턴스 환경에서 공통 lock이 필요해 Redisson을 선택했고, Lettuce 기반 직접 구현보다 Redisson의 Lock interface, timeout 설정, Pub/Sub 기반 획득 방식을 장점으로 설명합니다. 특히 쿠폰 차감과 중복 발주 같은 테스트 시나리오로 분산락 유무에 따른 차이를 보여줍니다. [E3]
우아한형제들 WMS 재고 이관 사례에서는 분산락에 waitTime을 설정해 순차 처리를 시도했지만, 그로 인한 지연과 락 범위 문제를 다시 마주합니다. 이후 상태 키를 함께 사용해 락 안에서 하는 일을 줄이는 방향으로 개선합니다. [E4]
또 다른 우아한형제들 RDB 기반 Task Queue 사례에서는 ORDER BY id ASC로 대기 작업을 조회하고, Redis 분산락으로 worker 간 중복 선점을 막습니다. 여기서도 순서와 선점, 중복 방지는 하나의 도구로 끝나는 문제가 아니라 각 계층이 나눠 맡는 문제였습니다. [E5]
이 사례들을 보면서 제가 얻은 결론도 비슷했습니다.
락은 동시성 문제를 해결하는 도구지만, 락 하나로 정합성, 순서, 처리량, 장애 대응을 모두 해결하려고 하면 설계가 오히려 흐려진다.
FairLock을 선택할 때 조심해야 할 점
FairLock은 항상 일반 lock보다 좋은 선택지가 아닙니다.
Redisson 문서 기준으로 FairLock은 대기 thread를 queue로 관리합니다. 이 말은 곧 관리 비용이 있다는 뜻입니다. 또 대기 중인 thread가 죽으면 Redisson이 일정 시간 복귀를 기다리기 때문에 지연이 누적될 수 있습니다.
그래서 저는 FairLock을 아래 조건에서만 고려하는 것이 좋다고 봅니다.
- 선착순, 티켓팅, 예약처럼 락 획득 순서 보장이 중요한가?
- lock 대기열에 들어가는 요청 수를 Redis counter나 queue로 제한할 수 있는가?
- lock 안의 임계 구간이 충분히 짧은가?
- DB 조건부 update나 unique/check 제약 같은 최종 방어선이 있는가?
- Redis 장애 시 공정성보다 정합성을 우선하는 fallback을 납득시킬 수 있는가?
반대로 단순히 재고만 정확히 차감하면 되는 문제라면 DB 조건부 update만으로 충분할 수 있습니다. 엄밀한 전역 선착순이 필요하다면 FairLock보다 queue와 sequence 기반 접수가 더 적합할 수 있습니다.
결론
이번 과제를 하면서 FairLock을 처음 접했고, 이 과정에서 제가 가장 크게 배운 것은 "분산락을 어떤 종류로 쓸까"가 아니었습니다.
더 중요한 것은 문제를 나누는 일이었습니다.
- Redis Lua counter는 많은 요청 중 실제로 진입 가능한 요청을 빠르게 제한합니다.
- FairLock은 그 요청들이 짧은 예약 구간에 들어가는 락 대기열 기준 획득 순서를 보장합니다.
- MySQL 조건부 update와 제약은 최종 정합성을 닫습니다.
- PG 결제는 lock 밖으로 빼서 지연과 실패를 별도 흐름으로 다룹니다.
FairLock은 멋져 보여서 넣는 기술이 아닙니다. 그리고 FairLock을 썼다고 해서 선착순 문제가 완전히 해결되는 것도 아닙니다.
제가 이 글에서 말하고 싶은 것은 오히려 반대에 가깝습니다.
선착순 시스템에서 공정성을 고민한다면, 먼저 "어떤 순서를 보장한다고 말할 수 있는가"를 좁혀야 합니다. 그다음에야 일반 분산락, FairLock, Redis Lua, DB 조건부 update, queue 중 어떤 도구가 어느 경계를 맡아야 하는지 보입니다.
이번 과제에서 FairLock은 최종 정답이라기보다, 제가 정합성과 공정성을 분리해서 보기 시작한 계기였습니다.
References
Project References
- [P1] fcfs-reservation-system GitHub Repository
- [P2] fcfs-reservation-system README
- [P3] Concurrency Control & Locking Strategy
- [P4] DECISIONS