내가 개인 프로젝트에서 Redis를 더 이상 찾지 않는 이유
요약
개인 프로젝트에서 관습적으로 사용하던 Redis를 SQLite, Postgres, Edge KV 등으로 대체하여 비용과 운영 부담을 줄인 사례를 소개합니다. 트래픽 규모에 맞지 않는 과도한 인프라 대신, 필요할 때만 최적화 도구로서 Redis를 도입해야 한다는 철학을 강조합니다.
핵심 포인트
- Redis를 기본값이 아닌 트래픽 증가에 따른 최적화 수단으로 취급해야 함
- SQLite와 Postgres를 활용해 Rate limiting 및 Pub/Sub 구현 가능
- 불필요한 상태 저장 서비스(Stateful service) 운영으로 인한 비용 및 관리 부하 감소
- 고처리량(High-throughput) 공유 큐가 필요한 경우에만 Redis 사용 권장
-
SQLite, Postgres, Edge KV로 대체된 6가지 Redis 사용 사례
-
40줄의 코드로 해결 가능한 SQLite 테이블 기반의 Rate limiting (속도 제한)
-
별도의 서비스 없이 Postgres LISTEN으로 처리하는 pubsub (발행/구독)
-
Redis가 여전히 승리하는 단 한 가지 영역: 고처리량 (high-throughput) 공유 큐
나는 습관적으로 3년 동안 모든 개인 프로젝트에 Redis를 실행했습니다. 그러다 한 앱에서 Redis가 실제로 수행하는 역할을 감사(audit)해 보았고, 6가지 작업 중 5가지가 이미 비용을 지불하고 있던 도구들로 옮겨갔습니다. 각 작업이 어디로 이동했는지, 그리고 내가 Redis를 유지한 단 하나의 사례는 무엇인지 소개합니다.
내가 의심하지 않았던 습관
Redis는 새로운 프로젝트에 내가 가장 먼저 추가하는 것이었습니다. 사용자가 생기기 전에도, 스키마 (schema)가 생기기 전에도, 튜토리얼에서 그렇게 하니까 Redis 인스턴스를 띄웠습니다. 그것이 인프라 관리의 기본(hygiene)처럼 느껴졌습니다. 하지만 사실 그것은 카고 컬트 (cargo culting, 근거 없는 모방)였습니다.
문제는 비용 청구서와 운영 (ops) 부하에서 나타났습니다. 관리형 Redis 인스턴스는 프로젝트당 한 달에 약 15 EUR가 들었고, 나는 6개의 프로젝트를 운영하고 있었습니다. 이는 대부분의 앱이 거의 건드리지도 않는 기능에 매달 90 EUR를 지불하고 있다는 뜻이었습니다. 더 나쁜 것은, Redis가 모니터링하고, 백업하고, 구조를 파악해야 할 두 번째 상태 저장 서비스 (stateful service)가 되었다는 점입니다. 새벽 2시에 Redis가 다운되면, 나는 단 200밀리초 만에 재구축될 수 있는 캐시 (cache) 때문에 호출을 받았습니다.
그래서 내가 Redis를 찾았던 모든 이유를 목록으로 만들었습니다. 6가지가 나왔습니다: rate limiting (속도 제한), sessions (세션), background queues (백그라운드 큐), caching (캐싱), presence tracking (접속 상태 추적), 그리고 pubsub (발행/구독)입니다. 그런 다음 각 항목에 대해 더 직설적인 질문을 던졌습니다. "이 앱이 전용 인메모리 저장소 (in-memory store)를 정당화할 만큼 충분한 트래픽을 가지고 있는가, 아니면 액자 하나를 걸기 위해 잭해머 (jackhammer)를 사용하고 있는 것인가?"
내가 운영하는 모든 개인 프로젝트에 대해 솔직한 답변은 '잭해머'였습니다. 내 앱 중 가장 바쁜 앱도 초당 요청 수가 기껏해야 40건 정도입니다. Redis는 수만 건을 처리하도록 설계되었습니다. 나는 내가 도달하기를 축하할 규모를 위해 미리 자원을 할당하고 있었던 것입니다.
이러한 사고방식의 전환은 Redis를 기본값이 아닌 최적화 (Optimization) 수단으로 취급하는 것이었습니다. 최적화부터 시작해서는 안 됩니다. 이미 스택에 포함되어 있는 지루한 도구 (Boring tool)로 시작하여, 측정하고, 실제 수치가 필요하다고 말할 때만 Redis를 추가해야 합니다. 나는 프런트엔드 스토어를 위해 Shopify를 사용하고 나머지는 작은 Node 앱으로 실행하고 있는데, 그중 어느 것도 한계치에 근접하지 않았습니다. 지루한 도구를 먼저 선택하는 것에 대한 더 자세한 철학을 알고 싶다면, Claude Blueprint에서 내가 코드 한 줄을 쓰기 전에 어떻게 이러한 결정을 내리는지 다루고 있습니다.
SQLite에서의 속도 제한 (Rate Limiting) 및 세션 (Sessions)
속도 제한 (Rate limiting)은 모두가 Redis가 필요하다고 말하기 때문에 옮기기에 가장 무서운 부분이었습니다. 전형적인 패턴은 만료 시간과 함께 원자적 (Atomic)이고 빠른 INCR 방식입니다. 나는 SQLite가 이를 따라갈 수 없을 것이라고 가정했습니다.
하지만 가능합니다. 키 (Key), 카운트 (Count), 그리고 윈도우 시작 타임스탬프 (Window-start timestamp)를 가진 단일 테이블만으로도 내 트래픽 수준에서는 충분히 처리할 수 있습니다. 각 요청마다 해당 행을 업서트 (Upsert)하고, 윈도우가 만료되었는지 확인한 뒤, 초기화하거나 증가시킵니다. 전체 코드는 약 40줄 정도입니다. SQLite는 쓰기 작업이 어차피 직렬화 (Serialized)되기 때문에 원자적 쓰기 (Atomic write)를 처리합니다. 초당 수십 건의 요청을 처리하는 단독 앱에서 쓰기 잠금 (Write lock)은 결코 병목 현상 (Bottleneck)이 되지 않습니다.
그 구조는 다음과 같습니다. identifier (IP 또는 사용자 ID)와 endpoint를 키로 사용하는 rate_limits 테이블입니다. hits를 위한 컬럼 하나와 window_start를 위한 컬럼 하나가 있습니다. 요청이 들어오면 해당 행을 읽고, 만약 now - window_start가 60초를 초과하면 두 필드를 모두 초기화하고, 그렇지 않으면 hits를 증가시키며 임계값을 넘으면 거부합니다. 만료 데몬 (Expiry daemon), 제거 정책 (Eviction policy), 별도의 서비스가 필요 없습니다.
세션 (Sessions)도 같은 방식으로 옮겼습니다. 예전에는 세션 저장소는 빠르고 휘발성 (Ephemeral)이어야 한다는 문서의 내용 때문에 세션을 Redis에 저장했습니다. 하지만 내 세션은 밀리초 단위가 아니라 며칠 동안 유지됩니다. 그것은 휘발성이 아니라 지속 가능한 상태 (Durable state)이며, 이것이 바로 데이터베이스가 존재하는 이유입니다. 토큰, 사용자 참조, 그리고 만료 타임스탬프를 가진 sessions 테이블은 Redis가 했던 모든 일을 수행하며, 영속성 설정 (Persistence config)을 고민하지 않아도 재시작 시 데이터가 유지됩니다.
예상치 못했던 보너스는 SQL로 세션(Session)을 쿼리할 수 있다는 점입니다. "이 사용자의 모든 활성 세션을 보여줘"라는 요청이 단 한 줄이면 끝납니다. Redis에서는 이를 위해 별도의 인덱스 세트(Index set)를 유지하고 동기화 상태를 관리해야 했습니다. 서로 동기화가 어긋날 수 있었던 두 가지 요소가, 이제는 어긋날 수 없는 하나의 요소가 되었습니다.
더 무거운 도구가 필요하기 전까지 SQLite가 얼마나 버틸 수 있는지에 대해서는 SQLite Is Enough에서 더 자세히 다루었습니다. 이 글에서는 제가 더 무거운 데이터베이스에서 완전히 벗어난 서비스들을 살펴봅니다. 요약하자면, 대부분의 1인 개발 앱은 SQLite의 실제 한계에 도달할 만큼 충분한 동시 쓰기(Concurrent writes)를 발생시키지 않으며, 한계에 도달하는 경우라도 일반적인 확장성 문제라기보다는 특정 핫 테이블(Hot table)을 분리해낼 수 있는 문제인 경우가 많습니다.
큐(Queues), 캐싱(Caching), 그리고 에지 KV(Edge KV)
백그라운드 작업(Background jobs)은 제가 Redis를 사용했던 두 번째로 흔한 이유였습니다. 이메일 발송, 이미지 크기 조정, 웹훅(Webhook) 호출 등 말이죠. 이에 대한 반사적인 대응은 작업(Task)을 가져가는 워커(Worker)들이 있는 Redis 기반의 큐(Queue)를 구축하는 것이었습니다.
1인용 앱의 경우, Postgres의 jobs 테이블이 이 작업을 깔끔하게 처리합니다. 상태가 pending인 행을 삽입합니다. 워커는 SELECT FOR UPDATE SKIP LOCKED를 사용하여 대기 중인 행을 폴링(Polling)하고, 이를 running으로 표시한 뒤 작업을 수행하고, 다시 done으로 표시합니다. SKIP LOCKED 절은 여러 워커가 서로 충돌하지 않고 작업을 가져올 수 있게 해주는 핵심 기술이며, Postgres는 9.5 버전부터 이 기능을 지원해 왔습니다. 저는 앱당 하나의 워커를 실행하며, 이는 아무런 무리 없이 분당 수백 개의 작업을 처리합니다.
장애 상황(Failure story)에서도 SQLite가 Redis보다 낫습니다. 작업이 충돌하여 중단되더라도 에러 로그와 함께 테이블에 그대로 남아 있습니다. 저는 실패한 작업을 쿼리하거나, 재시도하거나, 몇 주 후에 검사할 수도 있습니다. 반면, 확인 응답(Acknowledgment) 레이어를 직접 구축하지 않는 한, 충돌로 인해 작업을 놓쳐버리는 Redis 큐는 그냥 작업을 잃어버리게 됩니다. 그런데 이 확인 응답 레이어를 만드는 것은 Postgres 방식을 사용하는 것보다 더 많은 코드가 필요합니다.
캐싱 (Caching)은 두 가지 답변으로 나뉩니다. 인스턴스별 캐싱 (per-instance caching)의 경우, Node 메모리 내의 인프로세스 캐시 (in-process cache)가 매번 Redis로의 네트워크 라운드 트립 (network round trip)보다 훨씬 빠릅니다. 로컬 Map에서 값을 읽는 데는 나노초 (nanoseconds)가 걸립니다. Redis에서 읽는 것은 1밀리초 (millisecond) 이상의 네트워크 홉 (network hop)이 발생합니다. 데이터가 메모리에 들어갈 수 있고 하나의 인스턴스가 이를 처리할 수 있다면, 인스턴스 내에 유지하세요.
에지 로케이션 (edge locations) 간에 공유되어야 하는 캐싱의 경우, 저는 Edge KV (Cloudflare의 키-값 저장소 또는 호스트의 동등한 서비스)를 사용합니다. 여기서 이야기가 반전됩니다. 전 세계적으로 분산된 읽기 (globally distributed reads)의 경우, 데이터가 사용자 근처에 위치하기 때문에 Edge KV가 단일 Redis 박스보다 진정으로 우세합니다. 에지 (edge)의 KV에서 읽어오는 설정 블롭 (config blob)이나 피처 플래그 (feature flag)는 중앙 Redis로 다시 돌아가는 요청보다 빠릅니다. 저는 이런 방식으로 렌더링된 프래그먼트 (rendered fragments)와 API 응답을 TTL과 함께 캐싱하며, 캐시 미스 (cache miss)가 발생할 때만 오리진 (origin)에 요청이 전달됩니다.
Edge KV의 솔직한 트레이드오프 (tradeoff)는 쓰기 지연 시간 (write latency)과 최종 일관성 (eventual consistency)입니다. 쓰기 작업은 즉시가 아니라 몇 초에 걸쳐 전파됩니다. 캐시나 플래그의 경우에는 괜찮습니다. 하지만 '자신이 쓴 내용을 읽기 (read-your-own-write)' 보장이 필요한 경우에는 적합하지 않으며, 이 점이 저를 그러한 케이스에서 Postgres를 사용하도록 유도했습니다.
프레즌스 (Presence), Pubsub, 그리고 여전히 Redis가 승리하는 한 가지
프레즌스 트래킹 (Presence tracking, 누가 온라인인지, 누가 타이핑 중인지)은 제가 Redis를 가장 화려하게 사용했던 사례였습니다. 타임스탬프를 활용한 정렬된 집합 (Sorted sets), 오래된 항목 만료 등 모든 것을 활용했죠. 수백 명의 동시 접속자가 있는 솔로 앱에서는 last_seen 타임스탬프가 포함된 presence 테이블과 지난 30초 동안 업데이트된 행을 조회하는 쿼리만으로도 동일한 작업을 수행할 수 있습니다. 저는 하트비트 (heartbeat) 시점에 last_seen을 업데이트하고, 오래된 데이터는 오프라인으로 처리합니다. 정렬된 집합 (sorted set)을 이용한 복잡한 기교는 필요 없습니다.
Pubsub (발행/구독)은 제가 Redis가 반드시 필요하다고 확신했던 유일한 부분이었습니다. 하지만 알고 보니 Postgres에는 LISTEN과 NOTIFY가 내장되어 있었습니다. 한 연결에서 LISTEN channel_name을 실행하고, 다른 연결에서 NOTIFY channel_name, 'payload'를 실행하면 메시지가 도착합니다. 저는 이를 서버 전송 이벤트 (Server-Sent Events, SSE)를 통해 연결된 클라이언트에게 업데이트를 푸시하는 데 사용합니다. 작업이 완료되면 워커 (worker)가 NOTIFY를 발생시키고, 제 웹 프로세스가 이를 리스닝 (listening)하고 있다가 브라우저로 이벤트를 전달합니다. 추가 인프라가 전혀 필요 없습니다.
NOTIFY의 페이로드 (payload) 크기 제한은 8000바이트인데, 클라이언트가 전체 레코드를 가져오는 데 사용하는 이벤트 ID 용도로는 충분합니다. 어차피 저는 pubsub을 통해 큰 페이로드를 푸시하지 않으므로, 이로 인해 문제가 생긴 적은 한 번도 없습니다.
이제 솔직한 이야기를 해보겠습니다. 제 프로젝트에서 여전히 Redis가 승리하는 영역이 하나 있습니다. 바로 많은 프로듀서 (producers)와 많은 컨슈머 (consumers)가 동일한 스트림 (stream)을 몰아치는 고처리량 공유 큐 (high-throughput shared queue)이며, 이 경우 Postgres 폴링 (polling)이 병목 현상이 됩니다. 저는 몇 초 사이에 수천 개의 웹훅 (webhook) 이벤트를 폭발적으로 수집하는 한 앱에서 이 문제를 겪었습니다. Postgres의 SKIP LOCKED 폴링은 락 경합 (lock contention)을 보이기 시작했고, 폴링 간격은 지연 시간 (latency)을 추가했습니다. Redis Streams는 컨슈머가 반복적으로 요청하는 대신 새로운 항목에서 블로킹 (blocking)되기 때문에, 폴링 없이 더 낮은 지연 시간으로 동일한 폭발적 부하를 처리했습니다.
그것이 제가 내린 결론입니다. 데이터베이스 폴링이 따라갈 수 없는 공유 구조에서 진정한 고빈도 경합 (high-frequency contention)이 발생할 때, Redis는 그 자리를 얻습니다. 이것은 막연한 예감이 아니라 측정 가능한 조건입니다. 그 하나의 앱을 위해서만 저는 단일 소형 Redis 인스턴스를 실행하며, 다른 어떤 것도 그것을 건드리지 않습니다. 나머지 다섯 개의 프로젝트는 Redis를 전혀 사용하지 않습니다. 저는 SQLite Is Enough에서
저는 6개의 프로젝트 중 5개에서 Redis를 제거했고, 아무런 문제도 발생하지 않았습니다. Rate limiting (속도 제한)과 세션 (sessions)은 SQLite 테이블로 옮겼습니다. 큐 (Queues)와 Presence (접속 상태 확인)는 Postgres로 옮겼습니다. Pubsub (발행/구독)은 Postgres의 LISTEN/NOTIFY를 사용합니다. 공유 캐싱 (Shared caching)은 Edge KV로 옮겼는데, 이는 중앙의 Redis 서버보다 에지 (edge)에서 실제로 더 빠릅니다. Redis는 폴링 (polling)이 병목 현상을 일으킬 정도로 실제 버스트 트래픽 (burst traffic)이 발생하는 단 하나의 앱에만 남겨두었습니다.
이를 통해 한 달에 약 75 EUR를 절약했고, 밤중에 저를 깨울 상태 저장 서비스 (stateful service)가 하나 줄었습니다. 하지만 더 큰 승리는 정신적인 측면이었습니다. 서비스를 추가할 때마다 모니터링하고, 백업하고, 장애 발생 시 원인을 파악해야 할 대상이 늘어납니다. 움직이는 부품(moving parts)이 적다는 것은 사람들이 실제로 비용을 지불하는 제품을 만드는 데 더 많은 시간을 쓸 수 있음을 의미합니다.
이것이 Redis가 나쁘다는 뜻은 아닙니다. Redis는 최적화 (optimization) 도구이며, 최적화에는 그것을 정당화할 수 있는 수치가 필요하다는 뜻입니다. 스택에서 지루한 도구부터 시작하여 실제 트래픽을 측정하고, 지표가 필요하다고 말할 때만 빠른 인메모리 저장소 (in-memory store)를 추가하세요. 코드를 작성하기 전에 이러한 '구축할 것인가, 추가할 것인가'에 대한 결정을 내리기 위해 제가 사용하는 프레임워크가 궁금하다면, Claude Blueprint에서 전체 프로세스를 설명하고 있습니다. 이번 주에 여러분의 스택을 점검해 보세요. 액자 하나를 걸기 위해 잭해머 (jackhammer)를 사용하는 상황을 발견하게 될지도 모릅니다.
이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면, 귀하에게 추가 비용 없이 저에게 소정의 수수료가 지급될 수 있습니다. (광고)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기