HotSpot의 실시간 사용량 집계는 단순히 숫자 하나를 증가시키는 문제가 아니었다.
Kafka로 유입되는 사용량 이벤트 한 건마다 중복 여부를 확인해야 했고 선물 데이터와 개인 데이터, 가족 공유 데이터를 정해진 순서대로 차감해야 했다. 여기에 월·일·앱별·3시간 버킷 사용량을 함께 반영하고 임계치 crossing 여부를 판단한 뒤 이미 발송한 알림인지까지 확인해야 했다.
즉, 우리 프로젝트의 사용량 집계는 단순 카운터 증가가 아니라 여러 상태를 동시에 읽고 계산하고 반영해야 하는 복합 처리에 가까웠다.
처음에는 Redis를 사용하면 충분할 것처럼 보였다. Redis는 빠르고 상태를 다루기에 적합한 저장소이기 때문이다. 하지만 실제로 구조를 설계해보니 Redis를 사용한다는 사실만으로는 정합성이 자동으로 보장되지 않았다. 애플리케이션에서 여러 Redis 명령을 순서대로 호출하는 방식으로는 고동시성 환경에서 중간 상태와 경쟁 조건을 피하기 어려웠다.
그래서 우리에게 필요했던 것은 단순히 빠른 저장소가 아니라 실시간 상태 전이를 한 번에 원자적으로 처리할 수 있는 수단이었다. 그리고 그 해법으로 Redis Lua 스크립트를 선택하게 되었다.
실시간 사용량 이벤트를 처리하는 과정은 생각보다 훨씬 많은 판단을 포함하고 있었다.
이 이벤트가 이미 처리된 것인지 먼저 확인해야 했고 사용량 차감도 항상 같은 방식으로 이뤄지는 것이 아니라 선물 데이터가 있으면 우선 소진하고 이후 개인 데이터와 가족 데이터를 반영하는 식으로 진행되어야 했다.
여기에 사용량은 단일 지표만 갱신하는 것이 아니라 월별, 일별, 앱별, 시간대별 버킷까지 함께 움직였다. 그리고 단순 누적에서 끝나는 것이 아니라 이번 이벤트로 50%, 30%, 10%, 소진 구간 중 어느 임계치를 넘었는지 판단하고 이미 같은 알림을 발송한 적이 있다면 중복 발송도 막아야 했다.
즉, 사용량 집계는 “값 하나를 더한다”는 문제가 아니라 하나의 이벤트를 기준으로 여러 상태를 동시에 일관되게 전이시키는 문제였다. 이 특성 때문에 처리 과정이 조금만 분리되어도 정합성이 깨질 가능성이 높았다.
Redis는 매우 빠르지만 여러 명령을 따로 호출하는 구조에서는 여전히 중간 상태가 생긴다.
예를 들어 애플리케이션이 먼저 현재 사용량을 읽고 그 값을 바탕으로 임계치를 계산한 뒤 다시 사용량을 저장하고 마지막으로 알림 발송 여부를 기록한다고 가정해보자.
이 과정에서 다른 consumer가 같은 subId 나 familyId 에 대한 이벤트를 거의 동시에 처리하면 서로 같은 이전 상태를 읽고 다른 결론을 내릴 수 있다. 그러면 사용량이 중복 반영되거나 누락될 수 있고 같은 임계치 알림이 여러 번 발송되거나 반대로 필요한 알림이 빠질 수도 있다. 선물 데이터처럼 순서가 중요한 자원은 더 민감했다. 차감 순서가 꼬이면 실제 잔여량과 저장된 상태가 달라질 수 있기 때문이다.
즉, Redis를 사용하고 있다는 사실보다 더 중요한 것은 그 안의 상태를 어떻게 다루느냐였다.
우리에게 필요한 것은 여러 Redis 명령의 빠른 조합이 아니라 복수 키와 복수 조건이 얽힌 비즈니스 로직을 한 번에 실행할 수 있는 원자적 처리 경계였다.
Lua를 선택한 가장 큰 이유는 여러 단계의 상태 변경을 Redis 내부에서 하나의 실행 단위로 묶을 수 있기 때문이었다.
Lua 스크립트는 Redis 서버 안에서 하나의 명령처럼 실행되므로 스크립트가 수행되는 동안 다른 요청이 그 중간에 끼어들 수 없다.
이 특성 덕분에 우리는 이벤트 한 건에 대해 dedup 확인, 사용량 차감, 잔여량 계산, 임계치 판단, 알림 중복 방지, 필요한 결과 반환까지를 한 번의 흐름 안에서 처리할 수 있었다. 만약 이 과정을 애플리케이션에서 여러 번 나눠 호출했다면 각 단계 사이에 다른 이벤트가 끼어들며 상태가 달라질 수 있었을 것이다. 하지만 Lua 안에서는 그 경계를 하나로 묶을 수 있었다.
그래서 Lua는 단순한 성능 최적화 수단이 아니고 실시간 정합성을 지키기 위한 핵심 수단에 가까웠다.
Lua가 유효했던 이유는 원자성 하나 때문만은 아니었다.
실시간 집계에서 가장 위험한 패턴은 애플리케이션이 값을 읽고 계산하고 다시 저장하는 방식이다. 이 구조는 네트워크 왕복 횟수도 많고 계산과 저장 사이에 상태가 바뀔 여지도 크다. 반면 Lua는 Redis 안에서 현재 상태를 바로 읽고 그 자리에서 계산한 뒤 즉시 반영할 수 있다. 즉, 조회와 갱신 사이의 틈 자체를 거의 없애버린다.
또한 대량 이벤트 처리에서는 로직 자체보다 Redis round-trip 횟수가 비용이 되는 경우가 많다. dedup 확인, 사용량 조회, 잔여량 계산, 임계치 체크, 상태 갱신, 알림 방지 기록 등을 모두 개별로 호출하면 이벤트당 여러 번 Redis를 오가야 한다. 하지만 Lua를 사용하면 애플리케이션 입장에서는 스크립트 한 번 호출로 핵심 처리가 끝난다. 이 방식은 네트워크 비용을 줄이고 consumer 스레드의 체류 시간을 낮추며 전체 처리량을 더 예측 가능하게 만드는 데 도움이 됐다.
임계치 알림 로직과의 결합도 중요한 이유였다.
우리 서비스에서 알림은 집계 이후 별도로 판단되는 기능이 아니었다. 사용량이 반영된 바로 그 순간의 상태를 기준으로 crossing 여부를 계산해야 했고 같은 임계치 알림이 중복 발송되지 않도록 이전 발송 상태까지 함께 확인해야 했다. Lua 안에서 사용량 반영과 threshold 판단, 중복 방지를 함께 처리하면 이 흐름을 하나의 원자적 결정으로 묶을 수 있었다. 결과적으로 알림 로직도 훨씬 안정적으로 설계할 수 있었다.
중복 이벤트 처리 측면에서도 Lua는 중요했다.
Kafka 기반 구조에서는 같은 이벤트가 다시 전달되는 상황을 항상 고려해야 한다. 이때 dedup 여부 판단과 실제 상태 반영이 같은 실행 경계 안에 있어야만 멱등성 보조 장치로서 의미가 생긴다. Lua는 dedup 확인과 실제 집계를 한 번에 수행할 수 있었기 때문에 중복 이벤트를 잘못 다시 반영하거나 반대로 dedup 흔적만 남기고 실제 반영은 빠지는 식의 어긋남을 줄이는 데 적합했다.
HotSpot은 특정 시점에 특정 사용자나 특정 가족 그룹에 이벤트가 몰릴 수 있는 구조였다.
즉, subId 나 familyId 기준으로 hot key 가능성이 충분히 있는 환경이었다. 이런 구조에서 애플리케이션이 같은 키를 대상으로 여러 번 읽고 쓰는 방식은 경합을 더 키우기 쉽다. Lua는 같은 키 범위에서 필요한 작업을 한 번에 처리함으로써 적어도 이벤트 한 건이 남기는 명령 수를 줄이는 방향으로 작동했다.
또 하나는 Redis를 단순 캐시가 아니라 실시간 상태 레이어로 사용하고 있었다는 점이다.
우리 구조에서 Redis는 단순 조회 캐시가 아니라 사용량 상태 저장소이자 dedup 판단 저장소였고 threshold 상태와 gift 소진 순서까지 함께 관리하는 핵심 계층이었다. 즉, Redis는 이미 실시간 상태 머신의 일부가 되어 있었다. 그렇다면 그 상태를 가장 가까운 곳에서 원자적으로 다룰 수 있는 Lua를 사용하는 것이 자연스러운 선택이었다.
그리고 우리 구조는 앞단과 뒷단 모두 Lua가 쓰이는 2단계 흐름을 가지고 있었다.
앞단의 usage_valid.lua 는 정책과 한도 기준으로 이벤트를 사전 검증해 Kafka로 보낼 이벤트를 선별했고 뒷단의 usage_atomic.lua 는 실제 사용량 반영과 임계치 판정을 원자적으로 처리했다. 이 구조는 이벤트 유입 품질을 관리하는 단계와 실시간 상태 전이의 정합성을 보장하는 단계를 분리하면서도 핵심 경로에서는 일관된 원자성을 확보할 수 있게 해주었다.
중요한 것은 Lua의 장점을 말할 때 한계도 함께 보는 것이다.
Lua는 강력하지만 비즈니스 규칙이 계속 늘어나면 스크립트가 비대해지고 유지보수 난이도도 빠르게 올라간다. 즉, Lua는 꼭 필요한 핵심 경로에 써야지 모든 로직을 몰아넣기 좋은 공간은 아니다.
또한 Redis는 단일 스레드 기반이기 때문에 스크립트 실행 시간이 길어지면 전체 처리량에도 영향을 준다. 그래서 Lua는 원자적이기만 하면 되는 것이 아니라 짧고 예측 가능해야 한다는 제약이 있다. 이 부분은 성능 테스트와 운영 지표를 통해 계속 검증해야 한다.
그리고 무엇보다 중요한 한계는 Redis 내부 원자 처리와 이후 DB 영속화가 하나의 트랜잭션이 아니라는 점이었다.
즉, Lua가 성공해서 Redis 상태는 반영되었지만 그 뒤 Java 계층에서 결과를 DB에 영속화하거나 후속 처리 근거를 남기는 단계가 실패할 수 있다. 이 경우 실시간 상태는 바뀌었지만 durable한 기록은 누락되는 위험이 생긴다.
이것은 Lua가 부족하다는 뜻이 아니라 Lua가 보장하는 경계와 시스템 전체가 최종적으로 책임져야 하는 경계가 다르다는 뜻이다.
다시 말해 Lua는 실시간 상태 전이를 안전하게 묶어주지만 Redis와 RDB 사이의 완료 보장까지 자동으로 해결해주지는 않는다. 그래서 이후 구조에서는 DB 영속화와 ACK 시점을 분리하고 APPLIED와 DURABLE 상태를 나누는 방식으로 이 경계를 별도로 보완하게 되었다.
우리 프로젝트의 실시간 사용량 집계는 단순 카운터 증가가 아니었다.
중복 이벤트 방지, 선물·개인·가족 데이터의 복합 차감, 다중 버킷 반영, 임계치 판단, 중복 알림 방지까지 한 번에 맞물리는 복합 처리였다.
이 흐름을 애플리케이션에서 여러 Redis 명령으로 나누어 처리하면 고동시성 환경에서 상태 불일치와 경쟁 조건이 발생할 가능성이 높았다. 그래서 우리는 이 핵심 경로를 usage_atomic.lua 로 묶어 Redis 내부에서 원자적으로 실행하도록 설계했다.
결국 우리에게 Lua는 단순히 “더 빠른 방법”이 아니었다.
그보다는 실시간 상태 전이를 일관되게 처리하고 동시성 환경에서도 집계와 임계치 판단의 정합성을 지키기 위해 선택한 핵심 기술에 가까웠다.
다만 동시에 Redis 내부 원자 처리와 이후 DB 영속화는 다른 경계라는 점도 분명히 인식하게 되었다. 그래서 Lua 도입의 의미를 이야기할 때도 “모든 문제가 해결되었다”가 아니라 실시간 상태 전이는 Lua로 강하게 묶고 그 이후의 durable 보장은 별도 구조로 보완해야 한다고 설명하는 것이 더 정확하다고 생각한다.
이 경험을 통해 얻은 결론은 분명했다.
실시간 시스템에서 중요한 것은 단순히 빠르게 처리하는 것이 아니라
동시성 상황에서도 같은 결과를 일관되게 만들어내는 것이다.