Redis Array의 진화: 대규모 데이터 처리를 위한 아키텍처 분석
안녕하세요! 최근 Hacker News를 통해 흥미로운 기사 하나를 접하게 되었습니다. 바로 Redis의 핵심 개발자 중 한 명인 Oran Agra가 작성한 **“Redis array: short story of a long development process”**입니다. 단순히 기능 하나가 추가된 이야기가 아니었습니다. 이는 25년 된 레거시 코드를 건드리면서 성능을 유지하고, 안정성을 확보하며, 거대한 코드베이스를 밤새 포맷팅했던 개발자들의 집념의 기록이었습니다.
오늘은 이 기사를 바탕으로, Redis 내부에서 Array(배열) 자료구조가 어떻게 진화해왔는지, 그리고 우리가 대규모 시스템을 설계할 때 배울 수 있는 교훈은 무엇인지 깊이 있게 분석해보겠습니다.
1. 문제 제기: 25년 된 레거시 코드의 굴레
Redis의 LIST 자료구조는 내부적으로 QuickList를 사용합니다. QuickList는 양방향 연결 리스트인 ziplist와 linkedlist의 장점을 결합한 구조입니다. 하지만 수천만 개의 요소를 가진 거대한 리스트를 다룰 때, 메모리 파편화(memory fragmentation)와 캐시 미스(cache miss)가 심각한 성능 저하를 일으키는 문제가 있었습니다.
특히, 배열(Array) 타입의 데이터를 처리할 때 기존의 구조는 다음과 같은 병목이 있었습니다.
- 메모리 오버헤드: 포인터 연결로 인한 추가 메모리 사용.
- 순차 접근 비용: 캐시 라인을 효율적으로 사용하지 못해 발생하는 지연.
개발팀은 이를 해결하기 위해 C 언어 수준에서 내부 구조를 뜯어고치기로 결심합니다. 여기서 가장 큰 난관은 바로 **“변경하지 않으면 안 되는 레거시 코드”**였습니다.
2. 해결 과정: Formatting a 25M-line Codebase
기사에서 가장 인상 깊었던 부분은 **“Formatting a 25M-line codebase overnight”**입니다. 2,500만 라인에 달하는 코드를 포맷팅하고 리팩토링하는 과정은 단순한 기술적 도전을 넘어 체스와 같은 전략이 필요했습니다.
2.1. 리팩토링을 위한 사전 준비
대규모 리팩토링 시 가장 두려운 것은 **“회귀(Regression)”**입니다. 배열 구조를 변경하는 과정에서 수백 개의 Redis 명령어(LPUSH, RPUSH, LINDEX 등)가 영향을 받을 수 있기 때문입니다.
이를 해결하기 위해 팀은 다음과 같은 접근 방식을 취했습니다.
- 테스트 커버리지 확대: 기존 명령어에 대한 단위 테스트(Unit Test)를 통과하는지 확인.
- CI/CD 파이프라인 강화: 코드 변경 시 즉시 성능 저하가 발생하는지 감시하는 벤치마킹 스크립트 배치.
2.2. Redis Array의 새로운 구조
개선된 Array 구조는 단순히 메모리를 할당하는 방식에서 벗어나, 데이터 지역성(Locality)을 극대화하는 방향으로 변경되었습니다. 핵심은 **“연속된 메모리 블록을 최대한 활용하되, 필요시 분할하여 관리한다”**는 것입니다.
이를 통해 다음과 같은 이점을 얻었습니다.
- CPU 캐시 히트율 향상: 연속된 메모리 접근으로 인해 L1/L2 캐시 적중률이 크게 향상되었습니다.
- 메모리 절약: 불필요한 포인터 연결을 줄여 실제 데이터 저장 공간을 확보했습니다.
3. 실전 가이드: Redis에서 효율적인 배열 사용하기
이론적인 배경은 충분하니, 이제 실제로 어떻게 적용할 수 있는지 코드로 살펴보겠습니다.
3.1. 기존 리스트 사용의 문제점
먼저, 수천만 개의 아이템을 리스트에 넣는 기존 방식을 생각해봅시다. 이는 QuickList 기반으로 동작하며, 아이템 수가 늘어날수록 점프 횟수가 늘어납니다.
# 기존 방식 (QuickList based)
# 10,000,000개의 아이템 추가 (메모리 및 속도 저하 발생 가능)
for i in {1..10000000}; do
redis-cli LPUSH my_huge_list "item:$i"
done
3.2. Stream과 Hash를 활용한 최적화
Redis Array의 내부 개선은 사용자에게 투명하게 적용되지만, 우리가 설계를 할 때는 **“데이터의 크기”**와 **“접근 패턴”**을 고려해야 합니다. 단순히 순서대로 저장만 하면 된다면 최신 버전의 Redis를 쓰는 것만으로도 이득을 볼 수 있습니다.
하지만 만약 배열 안의 데이터를 검색하거나 수정해야 한다면 LIST 대신 HASH를 사용하는 것이 좋습니다.
import redis
import time
r = redis.Redis(host='localhost', port=6379, db=0)
# 시나리오: 로그 데이터 저장 (대규모)
# 1. List 사용 (순차 보관용)
def push_to_list(count):
start = time.time()
for i in range(count):
r.lpush("logs:timeline", f"log_entry_{i}")
print(f"List pushed {count} items in {time.time() - start:.4f}s")
# 2. Hash 사용 (검색 및 수정용)
def push_to_hash(count):
start = time.time()
pipe = r.pipeline()
for i in range(count):
pipe.hset("logs:details", f"entry_{i}", f"log_content_{i}")
pipe.execute()
print(f"Hash pushed {count} items in {time.time() - start:.4f}s")
if __name__ == "__main__":
# 10만 개 데이터 삽입 테스트
push_to_list(100000)
push_to_hash(100000)
실행 결과 분석:
최신 Redis 버전(7.x 이상)에서는 내부적으로 Array 구조가 최적화되어 있어 LPUSH 속도가 매우 빠릅니다. 하지만 특정 인덱스의 데이터를 자주 조회해야 한다면 LINDEX는 O(N)의 복잡도를 가지므로, HGET을 쓰는 O(1) 방식이 훨씬 유리합니다.
4. 결론: 개발 문화와 기술의 조화
Redis Array의 개발 과정은 우리에게 중요한 교훈을 줍니다.
- 성능은 무료가 아니다: 25년 된 코드를 개선하기 위해서는 그에 상응하는 리팩토링과 테스트 비용이 따른다.
- 도구의 투자: 2,500만 라인의 코드를 포맷팅할 수 있는 자동화 도구와 CI/CD 환경이 있었기에 가능한 작업이었다.
우리가 시스템을 설계할 때, 단순히 “빠르다"는 것만 넘어서 “어떻게 유지보수 가능한 성능을 낼 것인가"를 고민해야 합니다. Redis 팀이 보여준 것처럼, 때로는 아키텍처의 근간을 흔드는 대규모 개선을 두려워하지 말아야 할 때입니다.
5. 참고 자료
감사합니다!