Ch.19 사례: Scale-Out 했는데 왜 안 빨라지죠?¶
< 환경 세팅 | Bottleneck과 Amdahl의 법칙 >
앞에서 캐시 전략(Ch.17)과 계층 캐시 설계(Ch.18)를 다뤘다. DB 최적화도 했고, 캐시도 붙였다. 이번에는 "그래도 느린데, 서버를 늘리면 되지 않느냐"라는 질문에 답한다.
19-1. 사례 설명¶
3년차 백엔드 개발자가 상품 검색 API를 운영하고 있다. API 한 건의 응답 시간이 평균 2초다. 사용자가 늘면서 동시 요청이 증가하자 응답 시간이 3~4초까지 치솟았다.
팀 회의에서 이런 이야기가 나왔다.
팀장: "API가 너무 느리다. 서버를 늘려서 해결하자."
개발자: "지금 서버 1대인데, 4대로 늘리면 4배 빨라지지 않을까요?"
팀장: "그렇겠지. 앞에 Load Balancer 붙이고 서버 4대로 올려."
서버를 1대에서 4대로 늘렸다. Load Balancer가 요청을 4대에 분산한다. 결과는 이랬다.
| 구성 | 서버 수 | 평균 응답 시간 | 개선율 |
|---|---|---|---|
| 변경 전 | 1대 | 2.0초 | - |
| 변경 후 | 4대 | 1.8초 | 10% |
2초가 1.8초가 됐다. 4배를 기대했는데 10% 개선이다.
"서버가 부족한가 보다." 8대로 늘렸다.
| 구성 | 서버 수 | 평균 응답 시간 | 개선율 |
|---|---|---|---|
| 변경 전 | 1대 | 2.0초 | - |
| 4대 | 4대 | 1.8초 | 10% |
| 8대 | 8대 | 1.75초 | 12.5% |
8대로 늘려도 거의 차이가 없다. 서버 비용은 8배가 됐는데 응답 시간은 0.25초밖에 안 줄었다. 이 시나리오에서 서버를 100대로, 1,000대로 늘려도 2배 이상 빨라지지 않는다.
왜?
API의 처리 과정을 뜯어보면 답이 나온다.
상품 검색 API (총 2.0초)
├── 1. 요청 파싱 + 비즈니스 로직 0.1초 (5%)
├── 2. DB 쿼리 실행 1.6초 (80%)
├── 3. 결과 가공 + JSON 직렬화 0.2초 (10%)
└── 4. 응답 전송 0.1초 (5%)
2초 중 1.6초가 DB 쿼리다. 서버를 4대로 늘리면 1번, 3번, 4번 단계는 분산된다. 동시에 4개의 요청을 4대가 나눠서 처리하니까 대기 시간이 줄어든다.
그런데 2번은? DB는 1대다. 서버가 4대가 되면 4대가 전부 같은 DB 1대에 쿼리를 보낸다. DB의 처리 능력은 변하지 않았다. 오히려 DB에 4배 많은 쿼리가 몰릴 수 있다.
이게 핵심이다. 시스템에서 가장 느린 구간이 바뀌지 않으면, 나머지를 아무리 빠르게 해도 전체 성능은 거의 개선되지 않는다. 이 "가장 느린 구간"을 Bottleneck이라고 한다.
Bottleneck (병목)
시스템 전체 성능을 제한하는 가장 느린 구간이다. 병목이라는 이름은 병(bottle)의 목(neck)에서 왔다. 병에 물을 넣으면 병 몸통이 아무리 넓어도 목이 좁으면 물이 천천히 나온다. 시스템도 마찬가지다. 서버가 아무리 빨라도 DB가 느리면 전체가 느리다. Bottleneck을 찾아서 해결하지 않으면, 다른 곳에 돈을 쓰는 건 효과가 없다.
(Python에서 cProfile로 함수별 실행 시간을 측정하는 것도 같은 원리다. "가장 오래 걸리는 함수"를 먼저 최적화해야 전체가 빨라진다.)
19-2. 결과 예측¶
여기서 질문이다.
- 서버를 4배 늘리면 응답 시간이 4배 줄어드는가?
- 위 사례에서 서버를 무한히 늘리면 최대 몇 배까지 빨라질 수 있는가?
- DB가 아닌 서버 로직(파싱, 가공, 전송)이 전체의 20%를 차지한다. 이 20%를 0초로 만들면 전체는 얼마나 빨라지는가?
19-3. 결과 분석¶
Amdahl's Law¶
1967년 Gene Amdahl이 제시한 법칙이다. 프로그램의 일부분만 병렬화할 수 있을 때, 전체 성능 향상의 상한이 얼마인지를 수식으로 보여준다.
(출처: Amdahl, Gene M. "Validity of the single processor approach to achieving large scale computing capabilities." AFIPS '67, 1967)
수식:
위 사례에 대입해보자.
- 전체 처리 시간: 2.0초
- DB 쿼리 (병렬화 불가): 1.6초 = 80% → (1 - P) = 0.8
- 나머지 (병렬화 가능): 0.4초 = 20% → P = 0.2
서버 1대: Speedup = 1 / (0.8 + 0.2/1) = 1 / 1.0 = 1.00배
서버 4대: Speedup = 1 / (0.8 + 0.2/4) = 1 / 0.85 = 1.18배
서버 8대: Speedup = 1 / (0.8 + 0.2/8) = 1 / 0.825 = 1.21배
서버 100대: Speedup = 1 / (0.8 + 0.2/100) = 1 / 0.802 = 1.25배
서버 무한대: Speedup = 1 / (0.8 + 0) = 1 / 0.8 = 1.25배
서버를 무한히 늘려도 1.25배까지만 빨라진다. 2초가 1.6초가 되는 게 이론적 한계다. 왜? 병렬화 불가능한 80%(DB 쿼리)가 남아있으니까.
결과를 표로 정리한다.
| 서버 수 | Speedup | 응답 시간 | 서버 비용 (상대) |
|---|---|---|---|
| 1대 | 1.00x | 2.00초 | 1x |
| 2대 | 1.11x | 1.80초 | 2x |
| 4대 | 1.18x | 1.70초 | 4x |
| 8대 | 1.21x | 1.65초 | 8x |
| 16대 | 1.23x | 1.63초 | 16x |
| 무한대 | 1.25x | 1.60초 | 무한대 |
(수치는 Amdahl's Law 공식에 P=0.2를 대입한 이론값이다.)
8대까지는 그나마 미세한 개선이 있다. 16대부터는 사실상 의미 없다. 서버 비용만 16배가 됐다.
이걸 텍스트 기반 그래프로 보면:
Speedup
|
1.25 | ............................ ← 이론적 상한 (P=0.2)
| .....
1.20 | ...
| ..
1.15 | .
| .
1.10 | .
| .
1.05 | .
|.
1.00 +---+---+---+---+---+---+---+---+---+---→ 서버 수
1 2 4 8 16 32 64 128 256 512
곡선이 금방 수평에 가까워진다. 서버를 아무리 늘려도 1.25배를 넘을 수 없다.
반대로, P가 크면 어떤가? 병렬화 가능한 비율이 95%라면:
P=0.95일 때:
서버 1대: Speedup = 1 / (0.05 + 0.95/1) = 1.00배
서버 4대: Speedup = 1 / (0.05 + 0.95/4) = 3.48배
서버 8대: Speedup = 1 / (0.05 + 0.95/8) = 5.93배
서버 16대: Speedup = 1 / (0.05 + 0.95/16) = 9.14배
서버 무한대: Speedup = 1 / 0.05 = 20.00배
P=0.95면 서버를 늘리는 효과가 크다. 핵심은 P값이다. 병렬화 불가능한 부분(1-P)이 클수록 서버를 늘리는 효과가 줄어든다.
실무에서의 의미는 명확하다. DB 쿼리가 전체의 80%를 차지하는 상태에서 서버를 늘리는 건, "병목의 목을 넓히지 않고 병의 몸통만 넓히는 것"과 같다. DB 쿼리를 0.4초로 줄이면(인덱스, 쿼리 최적화, 캐시) 전체가 0.8초가 된다. 그게 서버 100대를 추가하는 것보다 효과가 크다.
Amdahl's Law (암달의 법칙)
프로그램의 병렬화 가능 비율이 P일 때, N개의 프로세서로 얻을 수 있는 최대 성능 향상을 Speedup = 1 / ((1-P) + P/N)으로 계산하는 법칙이다. 핵심은 "순차 실행 부분(1-P)이 전체 성능의 상한을 결정한다"는 것이다. P=0.5이면(절반이 순차 실행) 프로세서를 무한히 늘려도 2배까지만 빨라진다. 이 법칙이 "Bottleneck을 먼저 찾아라"의 이론적 근거다.
(Java에서 synchronized 블록이 코드의 30%를 차지한다고 하자. 그 synchronized 블록은 한 번에 하나의 스레드만 실행할 수 있다. 스레드를 100개로 늘려도 Amdahl's Law에 의해 최대 1/(0.3) = 3.33배까지만 빨라진다. Python에서 GIL이 CPU Bound 작업의 병렬화를 제한하는 것도 같은 원리다.)
19-4. 코드 설명¶
사례의 핵심을 재현하기 위해 단순한 시나리오를 구성한다. DB 쿼리가 느린 API를 만들고, 서버를 늘려도 응답 시간이 개선되지 않는 것을 확인한다.
서버 코드¶
DB에서 1.6초 걸리는 쿼리를 시뮬레이션한다. 실제 DB 쿼리 대신 time.sleep()으로 DB 지연을 재현한다. (실제 DB를 쓰면 여러 변수가 섞여서 Bottleneck 효과가 깨끗하게 드러나지 않는다. 여기서 증명하고 싶은 건 "순차 실행 부분이 있으면 서버를 늘려도 소용없다"는 것이니까, 순수하게 그것만 보여주는 게 맞다.)
# csbe-study/csbe_study/routers/ch19_scale.py
import time
from fastapi import APIRouter
router = APIRouter(prefix="/ch19", tags=["ch19"])
@router.get("/search")
def search_products():
"""상품 검색 API - DB 쿼리가 Bottleneck인 시나리오"""
# 1. 요청 파싱 + 비즈니스 로직 (0.1초)
time.sleep(0.1)
# 2. DB 쿼리 실행 (1.6초) - 이게 Bottleneck
time.sleep(1.6)
# 3. 결과 가공 (0.2초)
time.sleep(0.2)
# 4. 응답 전송 (0.1초)
time.sleep(0.1)
return {"products": [{"id": i, "name": f"상품 {i}"} for i in range(20)]}
k6 테스트 스크립트¶
// csbe-study/k6/ch19_scale_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
constant_load: {
executor: 'constant-vus',
vus: 10, // 동시 사용자 10명
duration: '30s', // 30초간 요청
},
},
};
export default function () {
const res = http.get('http://localhost:8765/ch19/search');
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(0.5);
}
서버 1대 vs 4대 결과¶
서버 1대로 테스트하면:
| 지표 | 서버 1대 |
|---|---|
| 평균 응답 시간 | 2.0초 |
| p95 응답 시간 | 2.1초 |
| Throughput | ~4.5 req/s |
(10 VU가 2초짜리 요청을 보내면, 한 VU가 초당 ~0.45회 요청 가능. 10 VU x 0.45 = ~4.5 req/s. sleep(0.5)도 있으니 실제로는 약간 다를 수 있다.)
서버를 4대로 늘리고 Load Balancer로 분산하면? 각 서버가 요청을 나눠 받으니까 동시 처리 능력(Throughput)은 올라간다. 하지만 개별 요청의 응답 시간(Latency)은 여전히 2초다. 왜? 한 건의 요청이 한 대의 서버에서 처리되는 과정은 변하지 않으니까. DB 쿼리 1.6초는 서버를 몇 대로 늘려도 1.6초다.
| 지표 | 서버 1대 | 서버 4대 |
|---|---|---|
| 평균 응답 시간 | 2.0초 | 2.0초 |
| p95 응답 시간 | 2.1초 | 2.05초 |
| Throughput | ~4.5 req/s | ~18 req/s |
Throughput은 약 4배 늘었다. 동시에 처리할 수 있는 요청 수가 늘어났으니까. 하지만 Latency(응답 시간)는 변하지 않았다.
여기서 중요한 구분이 있다. Ch.2에서 Throughput과 Latency를 다뤘다.
- Throughput (처리량): 단위 시간당 처리할 수 있는 요청 수. 서버를 늘리면 올라간다.
- Latency (응답 시간): 한 건의 요청이 처리되는 데 걸리는 시간. Bottleneck이 해결되지 않으면 변하지 않는다.
"느리다"는 게 Throughput 문제인지 Latency 문제인지를 먼저 구분해야 한다. Throughput이 부족한 거라면 서버를 늘리는 게 맞다. Latency가 문제라면 서버를 늘려도 소용없다. Bottleneck을 찾아서 없애야 한다.
위 사례에서 "API 응답이 2초"는 Latency 문제다. 서버를 100대로 늘려도 한 건의 요청은 여전히 2초가 걸린다. DB 쿼리 1.6초를 줄이지 않는 한.
반면, "초당 100건 처리해야 하는데 4.5건밖에 못 한다"는 Throughput 문제다. 이 경우에는 서버를 늘리면 해결된다. 서버 25대면 ~112 req/s가 되니까.
그런데 여기서도 함정이 있다. 서버 25대가 같은 DB 1대에 쿼리를 보내면? DB에 25배 많은 쿼리가 몰린다. DB가 이걸 감당할 수 있는가? DB가 못 버티면 DB가 새로운 Bottleneck이 된다. Connection Pool 고갈이 발생할 수도 있다. Ch.6에서 다뤘던 그 문제다.
서버를 늘렸더니 DB Connection이 터진다. Ch.16에서 봤던 시나리오가 다시 발생한다.
이 에피소드에서 중요한 건, "서버를 늘리기 전에 무엇이 Bottleneck인지를 먼저 찾아야 한다"는 것이다. Bottleneck을 모르면 돈만 날리고, 경우에 따라 상황을 더 악화시킬 수도 있다.