← 모든 글

Redis 를 캐시로만 쓰지 않는 6가지 패턴

Redis를 캐시로만 쓰는 팀이 놓치는 6가지 패턴 — 분산 락부터 Rate Limiting·Pub/Sub·카운터까지 HEDVION 결제·정산 현장의 실전 경험과 트레이드오프를 담았다.

Redis를 캐시 이상으로 쓰게 된 계기

HEDVION에서 Redis를 처음 도입한 것은 결제 내역 조회 API의 응답 속도를 개선하기 위해서였다. DB 조회 결과를 5분 TTL로 캐시하자 p99 응답 시간이 340ms에서 42ms로 줄었다. 효과는 명확했지만 곧 다른 생각이 들었다. 월 단위 인프라 비용을 지불하며 띄워 놓은 Redis 인스턴스를 캐시 레이어 하나에만 쓰고 있다는 점이 아까웠다.

결제·정산·자동화를 직접 운영하는 팀에게 Redis의 나머지 기능은 "있으면 좋은 것"이 아니다. 정산 배치가 두 번 실행되면 금액이 중복 차감되고, 외부 PG사 API를 계약 한도 이상으로 호출하면 IP가 차단되어 결제가 전면 중단된다. 이런 환경에서 Redis의 비캐시 기능들은 시스템 안전성을 떠받치는 핵심 레이어가 된다. 이 글은 우리 팀이 실제로 문제를 겪고 나서 도입한 순서대로 여섯 가지를 정리한다.

패턴 1·2: 분산 락과 Sorted Set — 정산 배치의 두 기둥

분산 락을 도입한 계기는 사고였다. 월말 정산 배치가 두 개의 서버 인스턴스에서 동시에 실행되어 같은 가맹점에 정산금이 이중 지급됐다. SET settlement:batch:202505 1 NX EX 3600 한 줄이 그 문제를 막아줬다. NX 옵션은 키가 없을 때만 저장하므로 첫 번째 인스턴스만 락을 획득하고, EX 3600은 배치가 비정상 종료되더라도 1시간 뒤 락이 자동 해제되도록 보장한다.

다만 락 TTL 설정에는 놓치기 쉬운 트레이드오프가 있다. 초기에 배치 예상 소요 시간과 TTL을 동일하게 잡았다가, 외부 API 지연으로 배치가 예상보다 길어지며 락이 먼저 풀려 이중 실행이 재발했다. 지금은 예상 소요 시간의 3배를 TTL로 설정하고, 정상 완료 시 명시적으로 DEL을 호출한다. 이때 주의할 점은 단순 DEL을 쓰면 다른 인스턴스가 재획득한 락을 실수로 삭제할 수 있다는 것이다. 락 값에 {hostname}:{pid}:{uuid}를 넣고, Lua 스크립트로 "내가 잡은 락인지 확인 후 삭제"하는 원자적 패턴이 필수다.

Sorted Set은 가맹점별 미정산 잔액 우선순위 큐에 활용 중이다. ZADD settlements:pending {amount} {merchant_id}로 저장하면 ZREVRANGE settlements:pending 0 9 WITHSCORES로 미정산 금액 상위 10개 가맹점을 즉시 조회할 수 있다. PostgreSQL에서 동일 쿼리는 인덱스를 타도 테이블 규모에 따라 20~80ms가 걸렸지만, Redis Sorted Set은 O(log N + M)으로 수십만 건도 1ms 이내다. 실시간 정산 대시보드처럼 새로고침이 잦은 화면에 특히 효과적이다.

패턴 3·4: Rate Limiting과 Pub/Sub — API 보호와 내부 이벤트 전파

결제 시스템에서 Rate Limiting은 선택이 아니다. 우리가 연동하는 PG사 중 한 곳은 초당 50건·분당 1,000건 제한이 있고, 초과 시 해당 IP를 30분간 차단한다. 차단 상태에서는 결제 처리가 전면 중단된다. INCREXPIRE를 조합한 고정 윈도우 방식은 코드 10줄 이내지만, 윈도우 경계에서 버스트가 가능하다는 구조적 약점이 있다. 59초에 1,000건, 61초에 1,000건을 보내면 2초 사이에 2,000건이 나가고 IP가 차단된다.

이 문제 때문에 PG사 API 호출에 한해서는 Sorted Set 기반 슬라이딩 윈도우를 쓴다. 요청마다 현재 타임스탬프를 score로 ZADD, 1분 이전 항목을 ZREMRANGEBYSCORE로 제거, 남은 항목 수를 ZCARD로 판단하는 세 단계를 Lua 스크립트로 묶어 원자적으로 실행한다. 코드 복잡도는 올라가지만 고정 윈도우의 버스트로 IP가 차단되어 결제가 멈추는 것보다 낫다. 내부 API에는 고정 윈도우, 외부 PG사 호출에는 슬라이딩 윈도우를 쓰는 것이 현재 우리 팀의 기준이다.

Pub/Sub는 정산 상태 변경 이벤트를 내부 서비스들에 전파하는 데 쓴다. 정산 완료 이벤트가 발행되면 알림 서버, 리포팅 서버, 대시보드 서버가 각각 수신해 자신의 상태를 갱신한다. Kafka 대비 운영 비용이 거의 없다는 장점은 실제로 크다. 단, 메시지 유실은 직접 경험했다. 롤링 배포 중 구독자가 잠깐 끊긴 사이 발행된 이벤트가 유실되어 대시보드 정산 상태가 수 분간 틀리게 표시됐다. 지금은 팀 내부에 "유실되어도 나중에 재조회로 복구 가능한 이벤트만 Pub/Sub, 유실 불가 이벤트는 Redis Streams 또는 DB 폴링"이라는 규칙을 명문화했다.

패턴 5·6: 임시 토큰과 카운터 — 작지만 결정적인 세부사항

임시 토큰 저장은 코드 복잡도를 가장 많이 줄여준 패턴이다. 이전에는 이메일 인증 코드와 결제 링크 토큰을 DB 테이블에 저장하고 배치로 만료 항목을 주기적으로 삭제했다. 테이블이 커질수록 삭제 배치의 락 경합이 생겼고 인덱스 유지 비용도 무시하기 어려웠다. Redis TTL로 전환하자 이 모든 관리 코드가 사라졌다. SET payment:token:{uuid} {payload} EX 1800으로 30분 유효 토큰을 만들고, GETDEL payment:token:{uuid}로 조회와 동시에 삭제해 1회용 토큰을 원자적으로 처리한다. GETDEL은 Redis 6.2부터 지원하므로 도입 전 버전 확인은 필수다.

카운터 패턴은 트랜잭션 볼륨 추적에 사용한다. 매 결제 요청마다 DB UPDATE를 치면 고빈도 시간대에 row lock 경합이 심해진다. 현재는 INCR stats:txn:{date}:{merchant}로 Redis에 누적하고 5분마다 배치로 DB에 UPSERT한다. 점심·저녁 결제 집중 구간에 DB 쓰기 부하가 기존 대비 약 85% 감소했다. 단, Redis 재시작 시 최대 5분치 카운터가 유실될 수 있다. 이를 감안해 AOF를 활성화하되 appendfsync everysec으로 성능과 내구성의 균형을 잡는다. 통계 데이터 특성상 1초 이내 유실은 비즈니스 팀과 합의한 허용 범위다.

우리가 겪은 실패와 패턴 선택의 세 가지 기준

여섯 패턴을 모두 도입한 지금도 실수는 있었다. 분산 락 TTL을 배치 소요 시간과 동일하게 잡았다가 외부 API 지연으로 이중 실행이 재발했고, Pub/Sub 메시지 유실로 정산 대시보드가 수 분간 틀리게 표시됐으며, AOF 설정을 빠뜨린 인스턴스 재시작으로 당일 통계가 통째로 날아간 적도 있다. 공통점은 "설마 그럴 일이 있겠어"라는 판단이 틀렸던 케이스들이다.

이런 경험에서 나온 패턴 선택 기준은 세 가지다. 첫째, 데이터 유실 시 비즈니스 임팩트. 결제 금액·정산 상태처럼 유실이 불가한 데이터는 Redis만으로 처리하면 안 된다. 둘째, 원자성 필요 여부. Read-then-Write를 분리하면 레이스 컨디션이 생긴다. GETDEL, Lua 스크립트, NX 옵션은 원자성이 필요한 곳에 반드시 써야 한다. 셋째, 운영 복잡도. 슬라이딩 윈도우는 고정 윈도우보다 정확하지만 디버깅 난이도가 높다. 팀 규모가 작을수록 트레이드오프를 코드 주석이나 내부 문서에 명시적으로 남겨야 한다.

지금 당장 써먹을 수 있는 시사점

① 배치 코드를 열어 중복 실행 방지 락이 있는지 먼저 확인하라. 없다면 SET {job}:lock 1 NX EX {ttl}를 지금 붙여라. 락 값에 {hostname}:{pid}:{uuid}를 넣으면 만료 시 어느 인스턴스가 잡고 있었는지 로그로 추적 가능하다. 슬랙 알림을 달아 놓으면 비정상 종료도 즉시 감지된다.

② Rate Limiting은 정밀도보다 존재 여부가 먼저다. 고정 윈도우로 시작해도 된다. 없는 것과 있는 것의 차이가 고정·슬라이딩 간 정밀도 차이보다 크다. PG사 API처럼 차단 시 전면 장애가 되는 엔드포인트에만 슬라이딩 윈도우로 업그레이드하면 충분하다.

③ 임시 토큰은 지금 당장 DB에서 Redis로 옮겨라. 만료 배치·삭제 로직·인덱스가 모두 사라진다. Redis 버전 6.2 이상인지 확인하고 반드시 GETDEL로 조회와 삭제를 원자적으로 처리하라.

④ Pub/Sub를 쓴다면 "유실 허용/불허 목록"을 팀 문서에 만들어라. 없으면 배포할 때마다 "이 이벤트 유실되어도 되나?"를 매번 논의하게 된다. 정산 완료처럼 유실이 불허인 이벤트는 처음부터 Redis Streams나 트랜잭셔널 아웃박스 패턴을 써라.

⑤ 카운터 플러시 주기와 AOF 설정을 함께 결정하고 비즈니스 팀에 명시적으로 고지하라. 플러시 주기 5분 + appendfsync everysec 조합이면 최악의 경우 약 5분치 데이터가 유실될 수 있다. 이를 사전에 합의해 두지 않으면 "왜 통계가 안 맞아?"라는 질문이 반드시 온다.

Redis를 이미 운영 중인 팀이라면 추가 인프라 없이 다섯 가지를 바로 적용할 수 있다. 가장 먼저 할 일은 오늘 배치 코드를 열어 중복 실행 방지 락이 붙어 있는지 확인하는 것이다.

* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

📚 추천 강의
한 입 크기로 잘라먹는 바이브코딩 (with Claude Code)
Claude Code로 바이브코딩, 개발자라면 꼭 들어야 할 필수 강의
강의 보러가기 →

* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.