프로젝트 개요
2025.06 — 2025.07
들어가며
성능 최적화는 때로 예상치 못한 곳에 병목을 만듭니다. 이전 글에서 소개한 Kafka 파티션 불균형 해결과 컨슈머 TPS 개선 이후, 이번에는 그 여파로 Redis에서 CPU 100% 장애가 발생했습니다. 한 컴포넌트의 성능을 끌어올리자 다음 병목이 드러난 사례를 SBI(Situation → Behavior → Impact) 구조로 정리합니다.
Situation — 너무 빨라진 전송 속도가 불러온 나비효과
Kafka 최적화 이후 찾아온 예상치 못한 장애
Kafka 컨슈머 TPS를 ~400에서 파티션당 4,000~5,000 수준으로 끌어올린 직후, 알림 발송 파이프라인의 처리량이 급격하게 증가했습니다. 이전에는 초당 400건 수준에서 아무런 문제 없이 돌아가던 후속 로직들이 폭발적으로 쏟아지는 트래픽을 견디지 못하면서 Redis CPU가 100%를 기록하는 장애가 발생했습니다.
Redis는 단일 스레드로 명령을 처리하는 구조입니다. CPU가 포화 상태에 이르면 모든 명령 처리가 지연되고, 연결된 애플리케이션 전체로 타임아웃이 전파됩니다. 알림 발송 파이프라인의 장애로 그치지 않고 Redis를 공유하는 다른 서비스로 장애가 번질 수 있는 상황이었습니다.
문제가 된 구조: transaction pool
시스템은 원자적 처리 보장을 위해 transaction pool이라는 이름의 Redis List를 사용하고 있었습니다. 메시지 처리가 시작될
때 해당 트랜잭션 ID를 리스트에 추가하고, 처리가 완료되면 LREM 명령으로 리스트에서 해당 값을 찾아 제거하는 흐름이었습니다.
# 처리 시작 시
LPUSH transaction_pool {tx_id}
# 처리 완료 후
LREM transaction_pool 1 {tx_id}
초당 수백 건 수준에서는 전혀 문제가 없던 구조였습니다. 그러나 처리량이 수십 배로 늘어나자 리스트의 크기도 빠르게 불어났고, 이 구조의 숨겨진 취약점이 드러나기 시작했습니다.
Behavior — 즉각 대응과 근본 해결, 두 단계의 접근
1단계: 원인 파악 — LREM의 O(N+M) 복잡도
Redis 명령어 통계를 살펴보니 LREM이 압도적인 실행 시간을 차지하고 있었습니다. LREM의 시간 복잡도는
O(N+M)입니다. 리스트 전체를 처음부터 끝까지 순회하며 일치하는 값을 찾기 때문에, 리스트의 길이 N이 커질수록 탐색 비용이 선형으로 증가합니다.
"처리량이 20배 늘면서 리스트에 쌓이는 데이터도 20배 늘었고, LREM의 탐색 비용도 20배가 되었다."
리스트에 쌓인 데이터가 10만 건을 넘어가자 단 하나의 LREM 명령이 Redis 전체를 멈추는 수준의 CPU 점유를 만들어냈습니다. Redis는 단일 스레드이기 때문에
하나의 느린 명령이 뒤에 오는 모든 명령을 블로킹합니다.
2단계: 즉각 대응 — 탐색 방향 변경으로 CPU 절반 낮추기
장애 상황인 만큼 먼저 빠른 완화 조치가 필요했습니다. LREM의 count 인자에 주목했습니다.
# 변경 전: 앞(Head)에서부터 순방향 탐색
LREM transaction_pool 1 {tx_id}
# 변경 후: 뒤(Tail)에서부터 역방향 탐색
LREM transaction_pool -1 {tx_id}
count 값이 양수이면 리스트의 앞(Head)부터, 음수이면 뒤(Tail)부터 탐색을 시작합니다. LPUSH로 새 데이터를 리스트 앞에 추가하고
있었기 때문에, 삭제 대상인 최신 트랜잭션 ID는 항상 리스트의 앞쪽에 위치했습니다.
그런데 LREM 1(순방향)과 LREM -1(역방향)의 탐색 방향이 서로 반대임을 활용했습니다. LPUSH로 Head에 쌓이는
구조에서 LREM -1(Tail부터 탐색)은 리스트 전체를 훑어야 했습니다. 반면 LREM 1(Head부터 탐색)로 변경하자 최신 데이터가 Head
근처에 있어 빠르게 찾을 수 있었습니다. 이 한 줄의 변경만으로 Redis CPU 부하를 즉시 약 50% 수준으로 낮추는 효과를 얻었습니다.
3단계: 근본 해결 — 자료구조 교체 (List → Set)
그러나 O(N) 연산은 데이터가 다시 쌓이면 언제든 재발할 수 있는 구조적 시한폭탄이었습니다. 항구적인 해결이 필요했습니다.
transaction pool이 실제로 어떤 요구사항을 만족해야 하는지 다시 검토했습니다.
- 특정 트랜잭션 ID의 존재 여부 확인
- 처리 완료 후 해당 ID 제거
- 데이터의 순서 보장은 불필요
순서 보장이 필요 없다면 List를 고집할 이유가 없었습니다. Redis Set으로 자료구조를 교체했습니다.
# 처리 시작 시
SADD transaction_pool {tx_id}
# 처리 완료 후 (O(1) 삭제)
SREM transaction_pool {tx_id}
| 구분 | Redis List (LREM) | Redis Set (SREM) |
|---|---|---|
| 삭제 시간 복잡도 | O(N+M) — 전체 탐색 | O(1) — 해시 직접 접근 |
| 데이터 10만 건 시 | 탐색 비용 선형 증가 | 데이터 양과 무관하게 일정 |
| 순서 보장 | O | X (불필요한 요구사항) |
Set의 SREM은 내부적으로 해시 테이블을 사용하므로 데이터 양과 무관하게 O(1)에 삭제가 완료됩니다. 리스트 크기가 100만 건이 되어도 삭제 비용은 변하지 않습니다.
Impact — CPU 100%에서 5% 이내로
수치로 본 결과
자료구조를 Set으로 전환한 이후, Redis CPU 점유율은 100%에서 5% 내외로 안정화되었습니다. 트래픽이 더 증가해도 삭제 비용은 O(1)로 고정되기 때문에, 데이터 규모에 따른 성능 저하 가능성을 구조적으로 차단했습니다.
두 가지 대응 전략의 교훈
이 장애는 두 단계의 대응이 필요했습니다. 먼저 즉각적인 완화 조치(탐색 방향 변경)로 장애를 진정시키고, 이후 근본적인 구조 개선(자료구조 교체)으로 재발 가능성을 원천 차단했습니다. 빠른 임시방편과 올바른 영구 해결책을 구분해 순서대로 적용한 것이 핵심이었습니다.
정리하며
분산 시스템에서 성능 개선은 단일 컴포넌트의 승리로 끝나지 않습니다. 하나의 병목을 해결하면 반드시 그다음 병목이 드러납니다. 이 경험에서 배운 것은 두 가지입니다.
- 자료구조의 시간 복잡도는 트래픽이 낮을 때는 보이지 않습니다. O(N) 연산이 초당 수백 건에서는 무해해도, 수만 건이 되는 순간 시스템을 멈춥니다.
- 요구사항을 다시 읽어야 올바른 자료구조가 보입니다. "순서가 필요한가?"라는 질문 하나가 List와 Set의 차이, 즉 O(N)과 O(1)의 차이를 만들었습니다.
대규모 트래픽을 견디는 시스템은 복잡한 알고리즘보다 적절한 자료구조 선택에서 시작한다는 것을, 직접 장애를 맞고서야 체감했습니다.