Lua Script, Transaction, Redisson MultiLock. 세 가지 방식을 직접 구현하고 JMeter 부하 테스트로 비교했다. 결론은 Lua Script였지만, 진짜 수확은 부하 테스트 중 발견한 Transaction의 중간 상태 노출 버그였다.
구체적인 구현 코드는 다루지 않는다. 코드가 궁금하다면 👉 PR
크레용 서비스에는 메일 발송 한도가 두 겹으로 있다. 하루 총 한도 50,000건, 그리고 동아리별 한도 300건. AWS SES 발신량과 비용을 통제하기 위한 제약이라, 이 한도는 절대 초과되면 안 된다.
처음 구현은 단순했다. Redis에서 현재 소비량을 조회하고(Check), 한도가 남아 있으면 소비하는(Act) 두 단계였다. 그런데 요청이 집중되자 한도가 초과되는 일이 발생했다.
원인은 전형적인 Check-Then-Act 경쟁 상태였다. "조회"와 "소비" 사이의 짧은 틈에 다른 요청이 끼어든다. 예를 들어 남은 한도가 1건일 때 두 요청이 동시에 조회하면, 둘 다 "1건 남았네"라고 판단하고 둘 다 소비한다. 결과는 한도 초과다.
현재 서버는 단일 인스턴스다. 따라서 ReentrantLock 같은 JVM 락으로 Check-Then-Act 구간을 감싸도 지금 당장은 문제가 해결된다. 그럼에도 Redis에서 제어하기로 한 이유는 상태가 이미 Redis에 있기 때문이다.
하루 단위 발송 한도는 서버가 재배포·재시작되어도 유지되어야 하는 값이라, 소비량 카운터를 JVM 메모리가 아닌 Redis에 두고 있었다. 그런데 상태는 Redis에 있는데 그 상태를 지키는 락은 JVM에 있는 구조는 정합성의 책임이 두 곳으로 쪼개진다. 상태가 있는 곳에서 원자성을 보장하는 쪽이 설계상 단순하고 안전하다.
| 방식 | 동작 원리 | 특징 |
|---|---|---|
| Lua Script | EVAL로 스크립트 전체를 Redis에서 원자적으로 실행 | 조회·검증·소비가 한 덩어리. 재시도 없음 |
| Transaction | WATCH-MULTI-EXEC 기반 낙관적 락. 충돌 시 재시도 (최대 3회) | 명령을 큐에 모았다가 일괄 실행. 중간 분기 불가 |
| Redisson MultiLock | 분산 락 획득 후 처리 (wait 1초, lease 3초) | 락 대기 시간이 응답 시간에 포함됨 |
목표
시나리오
환경
[채우기: 인스턴스 사양, 개수]| 방식 | 평균 | P95 | P99 |
|---|---|---|---|
| Lua Script | 2ms | 4ms | 4ms |
| Transaction | 6ms | 20ms | 36ms |
| MultiLock | 8ms | 38ms | 80ms |
평균 응답 시간은 Lua Script가 가장 빨랐다. 그렇다면 이걸로 결론일까?
평균만으로 판단하면 안 되는 이유가 있다. tail latency(꼬리 지연 시간) 때문이다.

tail latency는 전체 응답 중 가장 느린 쪽의 성능을 백분위로 나타낸 지표다.
| 구분 | 의미 |
|---|---|
| P95 | 전체 요청 중 95%가 이 시간보다 빠르게 응답됨 |
| P99 | 99%가 이 시간보다 빠르게 응답됨 |
평균은 "대부분의 요청"이 얼마나 빠른지를 보여주지만, tail latency는 특정 순간에 갑자기 느려지는 현상을 드러낸다. 100건 중 1건이 80ms씩 걸린다면 평균에는 거의 안 잡히지만, 그 1건을 맞은 사용자에게는 그게 서비스의 전부다. 이런 경험이 쌓이면 서비스 신뢰 저하로 이어진다.
P99 기준으로 다시 보면 차이가 선명하다. Lua Script는 4ms로 평균과의 격차가 작아 안정적인 반면, Transaction은 36ms, MultiLock은 80ms까지 벌어진다. 최악의 순간에도 일정한 성능을 내는 건 Lua Script뿐이었다.
원인은 각 방식의 구조에 있다.
WATCH 충돌 시 재시도한다. 요청이 몰릴수록 충돌이 늘고, 재시도 횟수만큼 지연이 누적된다.성능 비교보다 더 큰 수확은 부하 테스트 결과에서 발견한 이상 패턴이었다.
Lock과 Lua Script는 한도가 소진되는 시점을 경계로 성공 → 실패로 깔끔하게 갈렸다. 그런데 Transaction만 성공 → 실패 → 성공 → 실패 → ...가 교차로 나타났다.

한도는 단조 감소하는 값인데, 실패했다가 다시 성공한다? 뭔가 잘못됐다는 신호였다.
원인을 추적해보니 구조적인 문제였다.
1. MULTI-EXEC은 분기 처리가 불가능하다. 명령을 큐에 모았다가 일괄 실행하는 방식이라, "검증 결과에 따라 소비할지 말지"를 트랜잭션 안에서 결정할 수 없다. 그래서 순서를 뒤집어야 한다: 검증 → 소비가 아니라 소비 → 검증(소비 가능했는지).
2. 소비했는데 소비하면 안 되는 상태였다면? 소비한 만큼 다시 채워줘야 한다. 즉 롤백이 필요하다.
3. 그런데 이 롤백은 원자적으로 처리할 수 없다. '검증'이라는 분기를 거쳐야 롤백 여부가 정해지는데, 분기는 트랜잭션 밖에서만 가능하다. 결국 소비 → 검증과 롤백 사이에 틈이 생긴다.
4. 그 틈에 다른 요청이 끼어들면, 롤백 전의 '중간 상태'를 본다. 실제보다 한도가 더 소진된 것처럼 보이는 상태다. 그래서 성공할 수 있었던 요청이 실패한다. 이게 성공/실패가 교차하던 패턴의 정체였다.

정리하면: 원자성은 "명령이 한 번에 실행된다"로 끝나는 게 아니라, 롤백 경로까지 보장되어야 완성된다. Transaction 방식은 성능에서 밀리기 전에 정합성에서 이미 탈락이었다.
공짜는 아니다. Redis는 싱글 스레드로 명령을 처리하기 때문에, Lua 스크립트가 실행되는 동안 다른 모든 명령이 대기한다. 스크립트가 길거나 무거우면 Redis 전체가 그 시간만큼 멈추는 것과 같다.
이번 케이스는 조회·검증·소비로 이루어진 짧은 스크립트라 이 비용이 문제가 되지 않았다고 생각한다. 하지만 복잡한 로직을 Lua로 밀어 넣기 시작하면 이 트레이드오프가 역전될 수 있다. "앞으로 무조건 Lua를 써야겠군"이 아니라, "짧고 빈번한 원자 연산이라는 이 워크로드에서는 Lua의 비용보다 이득이 컸다"가 정확한 결론이다.
수치로 검증된 결과를 근거로 팀을 설득해 Lua Script 방식을 적용했고, 실험 과정과 결과는 문서로 남겨 팀의 Redis 이해도를 높이는 데 활용했다.
동시성 제어를 할 때 레디스를 사용하신 이유가 궁금해요!
메일 발송 제한을 이미 레디스로 처리하고 있어서.?
아님 서버를 여러개 띄우고 있어서.?
Java에서 동기화를 위한 ReentrantLock을 제공하는데 이것도 고민해보셨는지 궁금해서요!