Redis 클러스터를 모니터링하다가 이상한 점을 발견했다. 같은 클러스터의 노드들인데, 트래픽은 비슷한데 CPU 사용률이 달랐다.
노드 1: CPU 70%
노드 2: CPU 45%
노드 3: CPU 48%
노드 4: CPU 72%
트래픽이나 key 분포 문제인가 싶어서 확인해봤지만 특별한 차이가 없었다. 그럼 뭐가 문제일까?
원인을 찾다가 물리 서버 스펙을 확인했고, 하나의 단서를 발견했다.
서버 그룹 A: Xeon Silver 4116 (1세대) → 노드 1, 4 (CPU 높음)
서버 그룹 B: Xeon Silver 4216 (2세대) → 노드 2, 3 (CPU 낮음)
같은 8코어 VM인데 물리 서버가 다르다는 것만으로 CPU 사용률이 이렇게 차이가 날까?
먼저 기본부터 이해해보자.
CPU 코어는 "일꾼"이라고 생각하면 된다. 8코어 VM은 8명의 일꾼이 있다는 뜻이다.
그런데 "일꾼의 숫자"가 같다고 해서 "일의 처리 속도"가 같은 건 아니다.
왜? 각 일꾼의 "능력"이 다르기 때문이다.
비유하자면
결과적으로 같은 8명이 일해도, 신형 일꾼들이 20% 더 빠르게 일을 끝낸다.
그럼 이제 "왜 신형이 더 효율적인가?"를 구체적으로 알아보자.
두 CPU의 스펙을 비교해보자.
| 구분 | Xeon 4116 | Xeon 4216 |
|---|---|---|
| CPU 세대 | Skylake-SP | Cascade Lake-SP |
| 기본 클럭 | 2.1GHz | 2.1GHz |
| L3 캐시 | 16.5MB | 22MB (+33%) |
| 메모리 속도 | DDR4-2400 | DDR4-2933 (+22%) |
| IPC | 기준 | 약 10~15% 개선 |
클럭은 같은데 뭐가 다른 걸까? 핵심은 3가지다.
IPC (Instructions Per Cycle)는 "CPU가 1초에 똑딱똑딱 하는 그 한 번(클럭)에 몇 개의 일을 처리하는가"를 나타낸다.
클럭 = 시계 초침이 한 번 가는 것
IPC = 그 한 번에 몇 개의 작업을 끝내는가
비유:
실제 처리량 = 클럭 속도 × IPC
Xeon 4116: 2.1GHz × 1.0 IPC = 2.1 "단위 일"
Xeon 4216: 2.1GHz × 1.15 IPC = 2.4 "단위 일" (+14%)
CPU는 명령을 처리할 때 이런 과정을 거친다:
1. 명령 읽기 (Fetch)
2. 해석하기 (Decode)
3. 실행하기 (Execute)
4. 결과 저장 (Write back)
구형 CPU는 이 과정을 하나씩 순서대로 처리한다. 하지만 신형 CPU는:
1. Branch Predictor 개선 (분기 예측)
Redis 코드를 보면 if-else가 많다:
if (key_exists) {
return value;
} else {
return NULL;
}
CPU는 if의 결과가 나오기 전에 미리 "아마 true겠지?" 하고 예측해서 다음 작업을 시작한다. 예측이 맞으면 시간을 벌지만, 틀리면 다시 해야 한다.
신형 CPU는 예측 정확도가 95% → 98%로 향상되어, 다시 하는 경우가 줄었다.
2. Out-of-Order Execution (순서 바꿔 실행)
명령 A가 메모리를 기다리는 동안, 뒤에 있는 명령 B, C를 먼저 실행할 수 있다.
구형: A(메모리 대기 100ns) → B → C
신형: A(메모리 대기 시작) → B, C 먼저 실행 → A 완료
신형 CPU는 이 "재배치 버퍼"가 더 커서, 더 많은 명령을 동시에 처리할 수 있다.
Redis는 단일 스레드다. 즉, 1개 코어의 성능이 전체 성능이다.
IPC가 15% 높으면:
CPU가 데이터를 쓸 때마다 RAM(메인 메모리)에 가서 가져오면 너무 느리다.
비유: 창고에서 물건 가져오기
자주 쓰는 물건은 책상 서랍에 두면 훨씬 빠르다.
CPU에는 3단계 캐시가 있다:
L1 캐시 (64KB) → 가장 빠름 (1~2 사이클, 약 1ns)
L2 캐시 (1MB) → 빠름 (10~20 사이클, 약 10ns)
L3 캐시 (16~22MB) → 보통 (40~60 사이클, 약 50ns)
RAM → 느림 (100~300 사이클, 약 100ns)
"사이클"이란? CPU가 똑딱 한 번 하는 것. 2.1GHz CPU는 1초에 21억 번 똑딱한다.
Redis GET 요청이 들어오면:
1. Key의 해시값 계산
2. 해시 테이블에서 위치 찾기 → 메모리 접근 1회
3. 실제 value 읽기 → 메모리 접근 1회
만약 둘 다 L3 캐시에 있으면:
50ns + 50ns = 100ns
만약 하나가 RAM에 있으면:
50ns + 100ns = 150ns (50% 느림)
L3 캐시가 크면 → 더 많은 key를 캐시에 보관 → 캐시 히트율 증가
| CPU | L3 캐시 | 캐시 히트율 (예상) |
|---|---|---|
| Xeon 4116 | 16.5MB | 90% |
| Xeon 4216 | 22MB | 93% |
캐시 히트율 3%p 증가의 효과:
차이가 작아 보이지만, 초당 10만 요청이면:
구형: 105ns × 100,000 = 10.5ms
신형: 103.5ns × 100,000 = 10.35ms
→ 약 1.5% CPU 시간 절약
Redis는 메모리 DB다. 모든 작업이 메모리 접근이다.
캐시가 크면:
"2400"은 "초당 2400메가 전송 (2400 MT/s)"을 의미한다.
비유: 수도관 굵기
Redis는 메모리 접근이 매우 많다:
메모리 속도가 22% 빠르면:
메모리 읽기 시간: 100ns → 82ns (-18%)
Redis가 10GB 데이터를 다루고, 초당 10만 QPS를 처리한다고 가정하자.
평균 요청당 메모리 접근: 1KB
초당 메모리 전송량: 100,000 × 1KB = 100MB/s
구형 (DDR4-2400):
- 대역폭: 19.2GB/s
- 사용률: 100MB / 19,200MB = 0.5%
- 메모리 대기: 100ns
신형 (DDR4-2933):
- 대역폭: 23.5GB/s
- 사용률: 100MB / 23,500MB = 0.4%
- 메모리 대기: 82ns (-18%)
메모리 대기 시간이 18% 줄면, CPU는 그만큼 더 빨리 다음 요청을 처리할 수 있다.
결과: CPU 사용률이 낮아진다
| 요소 | 개선 효과 | 의미 |
|---|---|---|
| IPC 개선 | +15% | 한 클럭에 더 많은 일 처리 |
| L3 캐시 증가 | +3% | 캐시 히트율 향상, RAM 접근 감소 |
| 메모리 속도 증가 | +18% | 메모리 대기 시간 감소 |
| 총합 | 약 20~30% | 같은 작업을 더 빠르게 완료 |
즉, 4216은 같은 workload를 20~30% 빠르게 처리한다.
Redis가 100개 요청을 처리할 때:
같은 일을 하는데, 신형은 빨리 끝내고 쉬는 시간이 생긴다.
문제가 된 Redis 노드들을 1세대 서버에서 2세대 서버로 이동시켰다.
이동 전: CPU 70%
이동 후: CPU 48%
결과는 명확했다. 이후 운영 정책도 변경되었다:
"CPU 연산 성능의 차이로 서비스에 영향이 있을 수 있어, 1세대 서버는 추가적으로 사용하지 않는다"
여기까지는 하드웨어 차이로 설명이 된다. 그런데 이상한 케이스를 발견했다.
같은 2세대 CPU, 같은 8코어 VM인데:
뭐가 다른 걸까?
OS 설정을 확인해보니, 여러 차이점이 있었다.
Linux는 메모리를 "페이지"라는 단위로 관리한다. 기본 크기는 4KB다.
비유: 물건을 상자에 담기
큰 상자를 쓰면 관리가 편하다. 1,000개 물건을 담을 때:
이론적으로는 THP가 좋아 보인다. 실제로도 일부 애플리케이션에서는 성능이 향상된다.
그런데 왜 Redis는 THP를 싫어할까?
Redis는 이런 경고를 자주 띄운다:
WARNING you have Transparent Huge Pages (THP) support enabled in your kernel.
This will create latency and memory usage issues with Redis.
이유는 3가지다.
Redis는 BGSAVE나 AOF rewrite 시 fork()를 한다. 이게 뭔가?
fork() = 프로세스 복사하기
Redis 부모가 10GB 메모리를 쓰고 있다면, 자식도 10GB를 써야 한다. 하지만 처음부터 10GB를 복사하면 너무 느리다.
그래서 Linux는 "COW (Copy-on-Write)"를 쓴다:
1. 처음엔 복사 안 함 (페이지 테이블만 복사)
2. 부모가 메모리를 수정하려고 할 때
3. 그때 해당 페이지만 복사
작은 상자(4KB)일 때:
부모가 1개 key 수정
→ 그 key가 들어있는 4KB 페이지만 복사
→ 4KB 복사 (아주 빠름)
큰 상자(2MB)일 때:
부모가 1개 key 수정
→ 그 key가 들어있는 2MB 페이지 전체를 복사
→ 2MB 복사 (512배 느림!)
Redis는 작은 key-value를 수없이 다룬다. 평균 100바이트라면:
1개 key를 수정해도 20,480개가 들어있는 2MB 전체를 복사해야 한다.
실제 측정:
THP 켜짐: BGSAVE 시 5초 이상 멈춤, CPU 100% spike
THP 꺼짐: BGSAVE 시 0.1초 미만, CPU 정상
시간이 지나면서 메모리에 빈 공간이 조각조각 생긴다.
작은 상자(4KB):
빈 공간이 8KB 있으면
→ 4KB 상자 2개를 넣을 수 있음
큰 상자(2MB):
빈 공간이 1.5MB씩 여러 곳에 흩어져 있으면
→ 2MB 상자를 넣을 수 없음
→ 커널이 "메모리 정리(compaction)" 작업을 함
→ 프로세스가 블록됨
→ Redis 응답 지연 발생
Redis에 1KB 객체 100만 개를 저장한다면:
작은 상자(4KB):
1KB 객체 4개가 4KB 페이지 1개에 들어감
→ 낭비 없음
→ 필요한 메모리: 약 1GB
큰 상자(2MB):
1KB 객체를 2MB 페이지에 넣으면
→ 2MB 중 1KB만 사용
→ 1.999MB 낭비
→ 실제 사용 메모리: 1.5GB (50% 낭비!)
| OS | THP 기본값 |
|---|---|
| Ubuntu 18.04/20.04 | always (항상 켜짐) |
| CentOS 7/8 | always (항상 켜짐) |
| Amazon Linux 2 | madvise (선택적) |
즉, 대부분의 OS는 THP를 켜놓고 있다. Redis에는 독이 되는데.
실제로 여러 Redis에서 THP 경고가 발생했고, 비활성화 작업을 진행했다.
Linux는 메모리가 부족하면 디스크로 페이지를 옮긴다 (swap).
비유: 책상과 서랍
책상이 가득 차면 자주 안 쓰는 것을 서랍에 넣는다.
swappiness는 "얼마나 적극적으로 서랍에 넣을 것인가"를 결정한다 (0~100).
대부분 OS 기본값: 60
Redis는 "모든 데이터가 메모리(책상)에 있다"는 전제로 설계되었다.
메모리 접근: 100ns (0.0001ms)
SSD 접근: 100,000ns (0.1ms, 1,000배 느림)
HDD 접근: 10,000,000ns (10ms, 100,000배 느림)
Redis 데이터가 일부라도 swap(서랍)으로 가면:
1. GET 요청이 들어옴
2. 해당 key가 swap에 있음을 발견
3. 커널이 디스크에서 읽어옴 (수십 ms)
4. 이 동안 Redis 메인 스레드가 멈춤
5. 다른 모든 요청도 대기
실제 측정:
메모리 사용률 70% 도달 시:
swappiness=60 (기본값):
→ Redis 메모리 일부 swap out
→ GET 응답: 0.5ms → 20ms (40배!)
→ CPU 사용률: 50% → 70%
swappiness=1:
→ Redis 메모리 swap out 안 됨
→ GET 응답: 일정하게 0.5ms
→ CPU 사용률: 안정적으로 50%
Swap이 발생하면 CPU는 이런 일을 한다:
1. Page fault 처리 (CPU busy)
2. 디스크 I/O 대기 (CPU idle)
3. Swap in/out 작업 (CPU busy)
4. Context switch 빈발 (CPU busy)
실제 연산은 적게 하면서, "대기"와 "관리" 작업으로 CPU가 바쁘게 보인다.
Redis는 네트워크로 요청을 받는다. OS는 "연결 대기열"을 관리한다.
비유: 식당 대기줄
sysctl net.core.somaxconn
| OS | somaxconn |
|---|---|
| Ubuntu 20.04 | 4096 |
| CentOS 7/8 | 128 |
| Amazon Linux 2 | 4096 |
CentOS의 128은 현대 서비스에는 너무 작다.
고부하 상황:
초당 10,000개 연결 요청
평균 처리 시간: 1ms
→ 보통은 동시 연결 10개 정도
하지만 순간적으로 spike 발생:
→ 0.1초 동안 1,000개 요청 도착
→ somaxconn=128이면?
→ 128개만 대기열에 들어감
→ 나머지 872개는 거부됨
→ 클라이언트는 connection timeout 발생
Redis 입장에서는 요청을 받지도 못했다. 당연히 CPU를 쓸 일이 없다.
즉, 같은 workload인데 OS 설정에 따라 "실제로 처리되는 요청 수"가 다르고, CPU 사용률도 다르게 나타난다.
CPU는 전력을 아끼기 위해 클럭 속도를 조절할 수 있다.
비유: 자동차 기어
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
주요 모드:
powersave: 항상 최저 클럭 (연비 우선)ondemand: 부하에 따라 조절 (기본값)performance: 항상 최고 클럭 (성능 우선)powersave 모드 (1.0 GHz):
Redis GET 명령: 1000 사이클 필요
1.0GHz에서 1000 사이클 = 1.0μs
초당 100만 요청 처리:
→ 100만μs = 1초 전부
→ CPU 사용률 100%
performance 모드 (3.0 GHz):
Redis GET 명령: 1000 사이클 필요
3.0GHz에서 1000 사이클 = 0.33μs
초당 100만 요청 처리:
→ 333,000μs = 0.33초
→ CPU 사용률 33%
같은 workload인데, 클럭이 낮으면 CPU가 "더 오래 바쁜 상태"로 보인다.
ondemand는 부하를 감지해서 클럭을 올린다. 하지만:
1. 요청 도착 → CPU 부하 감지
2. 클럭 상승 명령 (수십~수백 μs 소요)
3. 클럭이 올라감
4. 요청 처리
5. 부하가 낮아짐 → 클럭 하락
6. 다음 요청 → 다시 2번부터
Redis처럼 latency-sensitive한 서비스에는 "클럭 올리는 시간" 동안 지연이 발생한다.
실측 결과:
powersave: 평균 0.8ms, 최대 5ms spike
ondemand: 평균 0.5ms, 가끔 2ms spike
performance: 평균 0.2ms, spike 거의 없음
여기까지 분석하고 나니 대부분의 CPU 사용률 차이는 설명이 되었다. 그런데 한 가지 더 이상한 케이스를 발견했다.
같은 2세대 CPU, 같은 OS, 같은 커널 설정인데:
왜?
NUMA (Non-Uniform Memory Access)는 멀티소켓 서버의 메모리 구조다.
비유로 이해하기
단일소켓 서버 (NUMA 없음):
[CPU] ←→ [메모리]
↓
모든 CPU가 같은 거리에서 메모리 접근
멀티소켓 서버 (NUMA 있음):
[CPU 0] ←빠름→ [메모리 A]
↓
느림
↓
[CPU 1] ←빠름→ [메모리 B]
각 CPU 소켓마다 "전용 메모리"가 있다.
단일 메모리 컨트롤러의 문제:
CPU 0 ┐
CPU 1 ├→ [메모리 컨트롤러] → [메모리]
CPU 2 ┘
→ 모든 CPU가 하나의 메모리 컨트롤러를 경쟁
→ 병목 발생
NUMA의 장점:
CPU 0 → [메모리 컨트롤러 0] → [메모리 A]
CPU 1 → [메모리 컨트롤러 1] → [메모리 B]
→ 각 CPU가 전용 메모리 컨트롤러 사용
→ 메모리 대역폭 2배
하지만 단점도 있다:
Redis는 이렇게 작동한다:
while (true) {
request = readFromSocket();
key = parseKey(request);
value = hashtable.get(key); // 어디 있을지 모름
writeToSocket(value);
}
Key가 어디 있을지 예측할 수 없다. 메모리 A에 있을 수도, 메모리 B에 있을 수도 있다.
만약 40%가 "remote NUMA 노드"에 있다면:
평균 지연 = 100ns × 0.6 + 250ns × 0.4 = 160ns
vs
전부 local = 100ns
→ 60% 느려짐
Redis가 오래 실행되면서 메모리를 할당/해제하면:
처음: 모든 메모리가 Node 0에 할당됨
시간 경과 후: Node 0 메모리 부족
→ 새로운 key는 Node 1에 할당됨
→ 메모리가 Node 0, Node 1에 섞임
실제 측정:
numastat -p $(pgrep redis-server)
Node 0 Node 1 Total
Numa_Hit 180000 120000 300000
Numa_Miss 40000 80000 120000 # 40% remote!
40%의 접근이 remote면 성능 저하가 크다.
단독 실행 시:
Redis 프로세스가 CPU 0에 고정됨
→ 대부분 메모리 A 사용
→ Remote access 최소
여러 VM 동시 실행 시:
Redis 프로세스가 CPU 0 ↔ CPU 1 왔다갔다
→ 메모리 A, B를 번갈아 접근
→ Remote access 급증
→ 메모리 대역폭 경쟁 심화
실제 측정:
# 단독 실행
node-load-misses: 5%
# 자원 경합
node-load-misses: 40%
NUMA 이슈가 있을 때 IPC를 측정하면:
# NUMA miss 많음 (멀티소켓, 미튜닝)
IPC: 0.6
CPU utilization: 70%
# NUMA miss 없음 (단일소켓 or vCPU pinning)
IPC: 1.8
CPU utilization: 45%
CPU 사용률은 70%로 높지만, 실제로 하는 일의 양은 비슷하다.
IPC가 뭘 의미하는지 다시 보자:
IPC = 0.6 의미:
→ 클럭 1회당 0.6개 명령 실행
→ 클럭 1.7회당 1개 명령 실행
→ 나머지 0.7 클럭은 메모리 대기
IPC = 1.8 의미:
→ 클럭 1회당 1.8개 명령 실행
→ 거의 대기 없이 실행
CPU가 70% 바쁜데 IPC가 0.6이면 → 실제로는 "메모리 기다림"으로 시간을 낭비하고 있다.
VM의 vCPU를 특정 물리 CPU와 NUMA 노드에 고정하면:
Before (자유 스케줄링):
시간 Redis vCPU → 물리 CPU 메모리 위치 지연
0ms → 4 (Node 0) Node 0 100ns
1ms → 16 (Node 1) Node 0 250ns (remote!)
2ms → 2 (Node 0) Node 0 100ns
3ms → 18 (Node 1) Node 0 250ns (remote!)
After (Node 0에 pinning):
시간 Redis vCPU → 물리 CPU 메모리 위치 지연
0ms → 4 (Node 0) Node 0 100ns
1ms → 6 (Node 0) Node 0 100ns
2ms → 2 (Node 0) Node 0 100ns
3ms → 4 (Node 0) Node 0 100ns
Remote access가 사라지고 성능이 안정화된다.
가상화 환경 전환 시 성능 검증을 진행했다:
시나리오:
결과:
단독 실행: 큰 차이 없음
자원 경합 상황: CPU 성능 2배 이상 차이!
왜 자원 경합 시에만 차이가 날까?
단독 실행 시에는 프로세스가 한 곳에 머물 가능성이 높다. 하지만 여러 VM이 동시에 돌면 서로 밀어내면서 NUMA 노드를 넘나든다.
즉, 실제 운영 환경(멀티테넌시)에서 NUMA 설정이 왜 중요한지를 보여준다.
같은 8코어 VM에서 같은 Redis를 실행해도 CPU 사용률이 다른 이유:
핵심:
비유: 신형 일꾼은 같은 시간에 20% 더 많은 일을 한다.
결과: 같은 workload를 빠르게 끝내고, CPU 사용률이 낮게 측정됨.
THP (Transparent Huge Pages):
Swappiness:
TCP 스택:
CPU Governor:
핵심:
비유: 창고가 멀리 있으면 왕복 시간이 오래 걸린다.
실제 측정:
NUMA miss 40%: IPC 0.6, CPU 70%
NUMA miss 0%: IPC 1.8, CPU 45%
핵심 인사이트: CPU 사용률은 높은데 실제 일의 양은 비슷함 (대기 시간이 많음)
CPU 사용률이 높다고 해서 실제로 많은 일을 하고 있는 건 아니다.
Case 1: IPC 1.8, CPU 45%
→ 효율적으로 일하고, 빨리 끝내고 쉼
Case 2: IPC 0.6, CPU 70%
→ 메모리 기다리며 시간 낭비
→ 실제 일의 양은 Case 1과 비슷
IPC를 함께 봐야 정확한 판단이 가능하다.
단독 실행: NUMA 설정 차이가 적음
자원 경합: NUMA 설정에 따라 2배 차이
벤치마크(단독 실행)에서는 문제가 없어 보여도, 실제 운영(멀티테넌시)에서 문제가 터질 수 있다.
대부분의 OS는 "일반적인 워크로드"에 최적화되어 있다. Redis 같은 특수한 워크로드에는 맞지 않을 수 있다.
Ubuntu/CentOS: THP=always → Redis에 독
CentOS: somaxconn=128 → 고부하 처리 불가
CentOS: zone_reclaim_mode=1 → NUMA 문제 심화
같은 하드웨어, 같은 애플리케이션인데 OS만 다르면 성능이 2배 차이날 수 있다.
내 환경에서 어떤 요인이 영향을 주는지 확인:
# 1. 하드웨어 정보
lscpu
cat /proc/cpuinfo | grep "model name"
# 2. THP 상태 (never여야 함)
cat /sys/kernel/mm/transparent_hugepage/enabled
# 3. Swappiness (1 이하가 좋음)
cat /proc/sys/vm/swappiness
# 4. TCP 설정 (최소 4096 이상)
sysctl net.core.somaxconn
# 5. CPU Governor (performance가 좋음)
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
# 6. NUMA 구성 (VM은 1개 노드가 이상적)
numactl --hardware
lscpu | grep NUMA
# 7. Redis NUMA 분포 (Numa_Miss가 적어야 함)
numastat -p $(pgrep redis-server)
# 8. IPC 측정 (1.5 이상이 좋음)
perf stat -p $(pgrep redis-server) sleep 10
# 9. NUMA miss 비율 (10% 이하가 좋음)
perf stat -e 'node-load-misses,node-loads' -p $(pgrep redis-server) sleep 10