Redis 내부 원리와 ElastiCache 운영의 기준
Redis의 자료구조 인코딩, 단일 스레드 이벤트 루프, 영속화, 클러스터 운영 기준을 내부 메커니즘 중심으로 정리한다. ElastiCache 운영 지표와 장애 진단 포인트까지 연결해, 어떤 원리가 어떤 실무 판단으로 이어지는지 설명한다.
Script Companion
오디오와 함께 스크립트 보기
- 01
Redis를 운영 관점에서 이해하려면 먼저 겉으로 보이는 명령어보다 내부 선택 기준을 봐야 한다. Redis는 자료구조마다 데이터 크기에 따라 압축 인코딩을 자동으로 고르고, 임계값을 넘으면 연산 효율을 우선하는 구조로 바꾼다. 그래서 같은 Hash나 List라도 작은 데이터일 때와 커진 뒤의 메모리 사용량, 조회 비용, 장애 양상이 달라진다. 이 문서의 핵심은 바로 그 전환점을 알고, 운영 중 관찰되는 느려짐이나 메모리 증가를 내부 원리로 설명하는 것이다.
- 02
String은 Redis의 기본 문자열 타입이지만, C의 char 포인터를 그대로 쓰지 않고 SDS, Simple Dynamic String 구조를 쓴다. SDS는 길이를 필드에 저장하므로 길이 조회가 O(1)이고, null 문자를 포함할 수 있는 이진 안전 구조이며, append 성능을 위해 사전 할당도 사용한다. 인코딩도 값에 따라 달라진다. 정수 범위의 값은 int로 포인터 자체에 저장되고, 44 bytes 이하 문자열은 헤더와 데이터를 단일 메모리 블록에 둔 embstr을 쓰며, 그보다 크면 일반 SDS인 raw가 된다.
- 03
Hash는 작은 데이터에서 압축 리스트를 쓰다가 임계값을 넘으면 hashtable로 전환된다. 항목 수가 hash-max-ziplist-entries 128 이하이고 값이 hash-max-ziplist-value 64 bytes 이하일 때는 연속된 메모리 블록을 사용해 캐시 효율을 높인다. 작은 해시 수백만 개를 저장하는 상황에서는 이 임계값을 늘려 메모리를 30~50% 절약할 수 있지만, 조회 시간이 O(N)으로 증가한다. 즉 메모리 절약을 얻는 대신 항목 수가 커질수록 탐색 비용을 감수해야 한다.
- 04
List, Set, Sorted Set도 같은 원리를 따른다. Redis 7.0 이후 List는 작은 구간에서 listpack을 쓰고, list-max-ziplist-size 128을 넘으면 여러 listpack을 이중 연결 리스트로 묶은 quicklist로 전환된다. 그래서 LPUSH와 RPUSH는 O(1)이지만, 중간 접근인 LINDEX와 전체 조회에 가까운 LRANGE 0 -1은 O(N) 비용을 가진다. Set은 모든 요소가 정수이고 64개 이하이면 intset을 쓰고, 작은 문자열 셋은 Redis 7.2 이후 listpack을 쓰며, 커지면 hashtable로 바뀐다.
- 05
Sorted Set은 Redis 자료구조 중 가장 복잡한 축에 있다. zset-max-listpack-entries 128, zset-max-listpack-value 64 임계값을 넘으면 listpack에서 Skip List와 Hash Table 조합으로 전환된다. Skip List는 삽입과 삭제가 평균 O(log N)이고, 범위 조회는 O(log N + M)이며, Score 기준 탐색도 O(log N)이다. 동시에 멤버에서 Score로 가는 해시 테이블을 따로 유지하므로 ZSCORE가 O(1)이 된다. 이 이중 구조를 알아야 랭킹이나 범위 쿼리 성능을 올바르게 예측할 수 있다.
- 06
Redis가 빠른 또 하나의 이유는 단일 스레드 이벤트 루프와 I/O Multiplexing이다. Redis는 모든 커맨드를 단일 스레드에서 처리하지만, epoll이나 kqueue로 수천 개의 소켓을 동시에 감시하고 데이터가 준비된 소켓만 처리한다. 실제 연산은 디스크가 아니라 RAM에서 이루어지므로 응답이 마이크로초 단위로 나온다. 단일 스레드는 락이 필요 없고 컨텍스트 스위칭 오버헤드도 줄이며, 모든 커맨드가 원자적으로 처리되는 장점도 만든다.
- 07
단일 스레드는 장점이면서 분명한 한계이기도 하다. CPU 코어 하나만 쓰기 때문에 여러 Redis 인스턴스를 포트별로 실행하거나, 처리량이 더 필요하면 클러스터로 수평 분산해야 한다. KEYS *로 키 100만 개를 전체 스캔하거나 SMEMBERS로 Set 요소 100만 개를 한 번에 가져오면 약 100~300ms 동안 단일 스레드 전체가 멈출 수 있다. 메모리 8GB에서 BGSAVE나 BGREWRITEAOF의 fork()가 일어날 때도 약 200~500ms의 짧은 블로킹이 생긴다. Redis 6.0 이후 I/O 스레드는 네트워크 읽기와 쓰기를 멀티스레드로 처리해 처리량을 20~30% 높일 수 있지만, 커맨드 실행 자체는 여전히 단일 스레드다.
- 08
영속화는 Redis가 메모리 저장소라는 사실에서 출발한다. RDB는 특정 시점의 전체 데이터를 .rdb 바이너리 파일로 덤프하는 스냅샷 방식이고, fork와 Copy-On-Write를 사용한다. fork 시점에 메모리 전체를 즉시 복사하지 않기 때문에 효율적이며, 자식 프로세스가 저장하는 동안 부모는 계속 요청을 처리한다. 대신 fork 자체는 데이터 크기에 비례해 짧은 블로킹을 만든다. RDB는 파일 크기가 작고 복구가 빠르며 성능 영향이 적지만, 마지막 스냅샷 이후 최대 수분의 데이터 손실을 감수해야 한다.
- 09
AOF는 모든 쓰기 커맨드를 순서대로 파일에 기록하고, 재시작 때 replay해서 복구하는 로그 방식이다. 중요한 함정은 기본 설정 aof-load-truncated yes에서 AOF tail이 잘렸을 때 Redis가 에러 없이 마지막 미완성 커맨드 이후를 잘라낸 채 정상 시작할 수 있다는 점이다. 클라이언트 응답, PING 헬스체크, CloudWatch 지표가 모두 정상이어도 appendfsync everysec 기준으로 비정상 종료 직전 최대 1초 분량의 쓰기가 조용히 사라질 수 있다. 결제 큐나 재고 차감처럼 정합성이 우선인 워크로드는 aof-load-truncated no로 부팅 실패와 운영자 개입을 강제하고, 가용성이 우선인 캐시는 기본값을 쓰되 모니터링에 포함하는 판단이 필요하다.
- 10
Redis 4.0 이후 권장되는 RDB와 AOF 혼합 모드는 빠른 복구와 낮은 데이터 손실을 함께 노린다. 재시작할 때 RDB로 빠르게 로드한 뒤 이후 AOF 로그만 replay하는 방식이다. 순수 캐시처럼 손실을 허용하는 경우에는 RDB만으로 충분하고, 세션 저장처럼 최대 1초 손실을 허용하는 경우에는 AOF everysec가 맞는다. BullMQ 큐처럼 손실을 허용하지 않는 경우에는 AOF always 또는 혼합 모드를 고려해야 하며, 실시간 랭킹처럼 재연산이 가능하면 RDB만 선택할 수 있다. 결국 영속화 선택은 데이터 손실 허용도와 복구 시간의 균형 문제다.
- 11
수평 확장과 고가용성에서는 Cluster와 Sentinel을 구분해야 한다. Redis Cluster는 Hash Slot과 CRC16 기반으로 키를 슬롯에 매핑하고, ioredis는 클러스터 슬롯 맵을 캐시해 MOVED와 ASK 리다이렉션을 처리한다. 마스터 장애 시 슬레이브가 마스터로 승격되며, 과반수 마스터가 살아 있어야 정상 동작하므로 최소 3 마스터 구성이 권장된다. Sentinel은 클러스터 모드 없이 단일 Primary와 Replica 구성에서 Monitoring, Notification, Automatic Failover, Configuration Provider 역할을 제공한다. 데이터 분산이 필요 없고 단일 노드 메모리 안에 들어가면 Sentinel이 단순하고, 수 TB나 초고처리량처럼 수평 확장이 필요하면 Cluster가 맞다.
- 12
Sentinel과 Cluster의 분기점은 정성적 선호가 아니라 수치로도 볼 수 있다. 단일 인스턴스 maxmemory는 시스템 메모리의 80~90%가 권고되지만, RDB나 AOF rewrite가 동시에 일어나면 최대 2배 메모리가 필요할 수 있어 안전한 실제 데이터 크기는 인스턴스 메모리의 약 0.45로 잡는다. 처리량은 단일 인스턴스 100~180k ops/sec 부근에서 CPU 1코어가 포화되고, io-threads 4로 20~30%를 더 얻을 수 있다. 동시 연결은 약 30,000 연결에서 처리량이 약 50% 하락한다. 평균 70k ops/sec, 데이터 60GB, 동시 연결 5천이라면 처리량과 연결보다 메모리 한계가 먼저 깨지므로 Cluster 3샤드가 선택되고, 데이터가 20GB라면 Sentinel과 r6g.2xlarge가 답이 된다.
- 13
ElastiCache 운영에서는 내부 원리를 CloudWatch 지표와 파라미터 그룹 판단으로 번역해야 한다. EngineCPUUtilization은 50% 아래를 정상 범위로 보고 80% 지속 시 알람이 필요하며, DatabaseMemoryUsagePercentage는 75% 아래가 정상이고 80%를 넘으면 즉시 대응한다. CacheHitRate는 90% 이상을 기대하고 80% 아래가 지속되면 캐시 전략을 점검한다. CurrConnections 급증은 연결 누수를, 캐시 외 용도의 Evictions 증가는 메모리 부족을, ReplicationLag가 1초 이상 지속 증가하는 현상은 복제 지연을 의심하게 한다. 노드 타입도 개발과 소규모는 cache.t3.micro, 일반 캐시는 cache.r6g.large, 고처리량은 cache.r6g.xlarge 이상, BullMQ나 세션처럼 영속화가 필요한 워크로드는 cache.r6g 계열과 AOF를 함께 본다.
- 14
장애 진단도 내부 원리와 직접 이어진다. AOF 재시작 후 데이터가 맞지 않으면 비정상 종료 등으로 AOF 파일이 손상된 상황을 의심하고, 복구 도구로 손상 구간을 정리한 뒤 재시작하는 흐름을 잡는다. CROSSSLOT 에러는 멀티 키가 서로 다른 슬롯에 배치된 것이 원인이므로 Hash Tag 패턴으로 같은 슬롯에 묶는 리팩토링이 필요하다. 메모리 급증과 Eviction이 함께 보이면 TTL 없는 키 누적이나 단편화를 의심하고, TTL 설정, active-defrag 활성화, maxmemory 증가를 검토한다. 특정 요청 레이턴시가 튀면 블로킹 커맨드가 실행된 것일 수 있으므로 전체 스캔은 SCAN 계열로, 대용량 Set 조회는 SSCAN 계열로 나누는 방향을 택한다.
- 15
Redis 내부 원리는 다른 시스템을 읽는 렌즈로도 전이된다. Skip List의 O(log N) 탐색은 Redis Sorted Set 범위 쿼리뿐 아니라 LevelDB와 RocksDB의 MemTable 인덱스, Cassandra의 파티션 내 정렬, PostgreSQL brin index의 범위 탐색 보완과 연결된다. Copy-On-Write fork()는 RDB 스냅샷뿐 아니라 PostgreSQL MVCC, Docker 이미지 레이어, Linux fork() 기반 프로세스 복제와 맞닿아 있다. I/O Multiplexing은 Node.js 이벤트 루프, Nginx Worker 프로세스, Go의 netpoller를 이해할 때 재사용된다. Hash Slot은 Cassandra Consistent Hashing, DynamoDB 파티션 키 해싱, Kafka의 murmur2 기반 파티션 키 해싱과 유사한 원리를 보인다.
- 16
새 기술을 만났을 때는 네 가지 질문으로 Redis에서 배운 구조를 다시 꺼낼 수 있다. 정렬과 범위 쿼리를 어떤 인덱스 자료구조로 처리하는지, 키를 노드에 어떻게 결정론적으로 매핑하는지, 락 없이 읽기-수정-쓰기 원자성을 어떻게 보장하는지, 변경이 즉시 영속되는지 지연되는지를 묻는 것이다. Kafka에 적용하면 파티션 내 offset은 정렬된 append-only 로그이고, murmur2(key) % numPartitions는 Redis Cluster의 CRC16(key) % 16384와 같은 구조다. Cassandra에 적용하면 MemTable은 Skip List이고, 키 분산은 Consistent Hashing이며, MemTable flush는 Redis BGSAVE의 fork와 Copy-On-Write가 아니라 LSM-Tree의 immutable file 전환을 사용한다. Redis를 안다는 것은 이 비교 축을 손에 쥐는 것이다.
같은 레이어