Redis 동시성 제어 3종 부하 테스트 — P99 80ms에서 4ms까지

아연·2025년 4월 26일

project

목록 보기
6/10
post-thumbnail

Lua Script, Transaction, Redisson MultiLock. 세 가지 방식을 직접 구현하고 JMeter 부하 테스트로 비교했다. 결론은 Lua Script였지만, 진짜 수확은 부하 테스트 중 발견한 Transaction의 중간 상태 노출 버그였다.

구체적인 구현 코드는 다루지 않는다. 코드가 궁금하다면 👉 PR


문제 상황: 한도를 넘겨버리는 메일 발송

크레용 서비스에는 메일 발송 한도가 두 겹으로 있다. 하루 총 한도 50,000건, 그리고 동아리별 한도 300건. AWS SES 발신량과 비용을 통제하기 위한 제약이라, 이 한도는 절대 초과되면 안 된다.

처음 구현은 단순했다. Redis에서 현재 소비량을 조회하고(Check), 한도가 남아 있으면 소비하는(Act) 두 단계였다. 그런데 요청이 집중되자 한도가 초과되는 일이 발생했다.

원인은 전형적인 Check-Then-Act 경쟁 상태였다. "조회"와 "소비" 사이의 짧은 틈에 다른 요청이 끼어든다. 예를 들어 남은 한도가 1건일 때 두 요청이 동시에 조회하면, 둘 다 "1건 남았네"라고 판단하고 둘 다 소비한다. 결과는 한도 초과다.

왜 Redis로 제어하는가? (ReentrantLock은 안 되나?)

현재 서버는 단일 인스턴스다. 따라서 ReentrantLock 같은 JVM 락으로 Check-Then-Act 구간을 감싸도 지금 당장은 문제가 해결된다. 그럼에도 Redis에서 제어하기로 한 이유는 상태가 이미 Redis에 있기 때문이다.

하루 단위 발송 한도는 서버가 재배포·재시작되어도 유지되어야 하는 값이라, 소비량 카운터를 JVM 메모리가 아닌 Redis에 두고 있었다. 그런데 상태는 Redis에 있는데 그 상태를 지키는 락은 JVM에 있는 구조는 정합성의 책임이 두 곳으로 쪼개진다. 상태가 있는 곳에서 원자성을 보장하는 쪽이 설계상 단순하고 안전하다.


후보 3가지와 동작 원리

방식동작 원리특징
Lua ScriptEVAL로 스크립트 전체를 Redis에서 원자적으로 실행조회·검증·소비가 한 덩어리. 재시도 없음
TransactionWATCH-MULTI-EXEC 기반 낙관적 락. 충돌 시 재시도 (최대 3회)명령을 큐에 모았다가 일괄 실행. 중간 분기 불가
Redisson MultiLock분산 락 획득 후 처리 (wait 1초, lease 3초)락 대기 시간이 응답 시간에 포함됨

실험 설계

목표

  • 세 방식의 평균 응답시간, P95/P99 tail latency 비교
  • 성공뿐 아니라 실패(한도 초과) 경로의 동작까지 관찰

시나리오

  • 300개 동아리가 각 200건씩 소비 요청 (총 한도 50,000건 / 동아리별 한도 300건)
  • 마지막 100개 동아리는 의도적으로 소비 실패 유도 → 성공 50% + 실패 50%
  • 실패 케이스를 절반 섞은 이유: 한도 제어 로직에서 중요한 건 "성공할 때 빠른가"만이 아니라 "거절할 때도 올바르고 빠르게 거절하는가"이기 때문

환경

  • Redis: 단일 인스턴스
  • 서버: [채우기: 인스턴스 사양, 개수]
  • JMeter: 스레드 300개, 램프업 5초
  • 측정 반복: 3회 반복 후 중앙값 사용

응답시간

결과

방식평균P95P99
Lua Script2ms4ms4ms
Transaction6ms20ms36ms
MultiLock8ms38ms80ms

평균 응답 시간은 Lua Script가 가장 빨랐다. 그렇다면 이걸로 결론일까?

평균만으로 판단하면 안 되는 이유가 있다. tail latency(꼬리 지연 시간) 때문이다.

  • 실험 중 일부

왜 tail latency를 봐야 하는가

tail latency는 전체 응답 중 가장 느린 쪽의 성능을 백분위로 나타낸 지표다.

구분의미
P95전체 요청 중 95%가 이 시간보다 빠르게 응답됨
P9999%가 이 시간보다 빠르게 응답됨

평균은 "대부분의 요청"이 얼마나 빠른지를 보여주지만, tail latency는 특정 순간에 갑자기 느려지는 현상을 드러낸다. 100건 중 1건이 80ms씩 걸린다면 평균에는 거의 안 잡히지만, 그 1건을 맞은 사용자에게는 그게 서비스의 전부다. 이런 경험이 쌓이면 서비스 신뢰 저하로 이어진다.

P99 기준으로 다시 보면 차이가 선명하다. Lua Script는 4ms로 평균과의 격차가 작아 안정적인 반면, Transaction은 36ms, MultiLock은 80ms까지 벌어진다. 최악의 순간에도 일정한 성능을 내는 건 Lua Script뿐이었다.

원인은 각 방식의 구조에 있다.

  • Transaction — 낙관적 락이라 WATCH 충돌 시 재시도한다. 요청이 몰릴수록 충돌이 늘고, 재시도 횟수만큼 지연이 누적된다.
  • MultiLock — 동시 요청이 많으면 락 획득 대기가 길어진다. wait를 1초로 설정했기 때문에 경합이 심한 요청은 대기 시간이 그대로 tail에 반영된다. P99 80ms는 처리 시간이 아니라 대기 시간이 만든 숫자다.
  • Lua Script — 원자적으로 한 번에 실행되고 재시도도 대기도 없다. 그래서 응답 시간의 분산 자체가 작다.

진짜 수확: Transaction의 중간 상태 노출

성능 비교보다 더 큰 수확은 부하 테스트 결과에서 발견한 이상 패턴이었다.

Lock과 Lua Script는 한도가 소진되는 시점을 경계로 성공 → 실패로 깔끔하게 갈렸다. 그런데 Transaction만 성공 → 실패 → 성공 → 실패 → ...가 교차로 나타났다.

한도는 단조 감소하는 값인데, 실패했다가 다시 성공한다? 뭔가 잘못됐다는 신호였다.

원인을 추적해보니 구조적인 문제였다.

1. MULTI-EXEC은 분기 처리가 불가능하다. 명령을 큐에 모았다가 일괄 실행하는 방식이라, "검증 결과에 따라 소비할지 말지"를 트랜잭션 안에서 결정할 수 없다. 그래서 순서를 뒤집어야 한다: 검증 → 소비가 아니라 소비 → 검증(소비 가능했는지).

2. 소비했는데 소비하면 안 되는 상태였다면? 소비한 만큼 다시 채워줘야 한다. 즉 롤백이 필요하다.

3. 그런데 이 롤백은 원자적으로 처리할 수 없다. '검증'이라는 분기를 거쳐야 롤백 여부가 정해지는데, 분기는 트랜잭션 밖에서만 가능하다. 결국 소비 → 검증과 롤백 사이에 틈이 생긴다.

4. 그 틈에 다른 요청이 끼어들면, 롤백 전의 '중간 상태'를 본다. 실제보다 한도가 더 소진된 것처럼 보이는 상태다. 그래서 성공할 수 있었던 요청이 실패한다. 이게 성공/실패가 교차하던 패턴의 정체였다.

정리하면: 원자성은 "명령이 한 번에 실행된다"로 끝나는 게 아니라, 롤백 경로까지 보장되어야 완성된다. Transaction 방식은 성능에서 밀리기 전에 정합성에서 이미 탈락이었다.


Lua Script의 비용은 없나?

공짜는 아니다. Redis는 싱글 스레드로 명령을 처리하기 때문에, Lua 스크립트가 실행되는 동안 다른 모든 명령이 대기한다. 스크립트가 길거나 무거우면 Redis 전체가 그 시간만큼 멈추는 것과 같다.

이번 케이스는 조회·검증·소비로 이루어진 짧은 스크립트라 이 비용이 문제가 되지 않았다고 생각한다. 하지만 복잡한 로직을 Lua로 밀어 넣기 시작하면 이 트레이드오프가 역전될 수 있다. "앞으로 무조건 Lua를 써야겠군"이 아니라, "짧고 빈번한 원자 연산이라는 이 워크로드에서는 Lua의 비용보다 이득이 컸다"가 정확한 결론이다.


결론

  • 실시간 한도 제어처럼 짧고 빈번한 원자 연산 워크로드에서는 Lua Script가 가장 안정적이고 빨랐다 — 평균뿐 아니라 P99에서 격차가 가장 컸다 (4ms vs 36ms vs 80ms)
  • Transaction은 성능 이전에 롤백 비원자성으로 인한 중간 상태 노출로 탈락
  • MultiLock은 락 대기 시간이 tail latency를 지배 (P99 80ms)
  • 실시간 제어 로직에선 평균만큼이나 tail latency가 중요하다. 그리고 부하 테스트는 성능 숫자만 주는 게 아니라 정합성 버그를 드러내는 도구이기도 했다

수치로 검증된 결과를 근거로 팀을 설득해 Lua Script 방식을 적용했고, 실험 과정과 결과는 문서로 남겨 팀의 Redis 이해도를 높이는 데 활용했다.

한계와 남은 질문

  • 단일 Redis 인스턴스 기준의 실험이다. Redis Cluster 환경에서는 Lua Script의 키 제약(같은 슬롯에 있어야 함)과 MultiLock의 동작이 달라지므로 결과가 그대로 유효하지 않다.
  • MultiLock의 wait/lease 파라미터(1초/3초)를 튜닝하면 tail이 얼마나 개선되는지는 확인하지 않았다. 락 방식 자체의 한계인지, 설정의 한계인지 분리해볼 여지가 있다.
  • 스크립트가 길어지는 시나리오에서 Lua의 블로킹 비용이 언제 역전되는지도 측정해보고 싶다.

2개의 댓글

comment-user-thumbnail
2025년 5월 17일

동시성 제어를 할 때 레디스를 사용하신 이유가 궁금해요!
메일 발송 제한을 이미 레디스로 처리하고 있어서.?
아님 서버를 여러개 띄우고 있어서.?

Java에서 동기화를 위한 ReentrantLock을 제공하는데 이것도 고민해보셨는지 궁금해서요!

1개의 답글