목차
트래픽이 늘어 응답이 느려졌다. 서버를 두 배로 늘렸는데 더 느려졌다.
서버를 늘렸는데 더 느려졌다
이런 일이 실제로 일어난다. 이유는 대개 간단하다. 느린 원인이 서버가 아니었기 때문이다.
앱 서버가 한 대일 때 DB에 연결 20개를 열고 있었다면, 두 대가 되면 40개를 연다. DB 입장에서는 갑자기 두 배가 밀려든 것이다. 앱은 여유로워졌는데 DB가 더 바빠졌고, 전체 응답은 DB가 정한다.
그래서 확장은 도구를 고르는 일이 아니라 자리를 찾는 일부터 시작한다. 어디가 먼저 막히는지 모르는 채로 캐시를 붙이면 안 빨라지고, 서버를 늘리면 뒤에 있는 것이 죽는다.
시스템은 가장 좁은 곳만큼만 흐른다
요청은 여러 단계를 지난다. 네트워크를 타고 들어와서, 로드 밸런서를 지나, 앱 서버에서 처리되고, DB를 다녀오고, 응답으로 나간다.
이 중 하나라도 좁으면 전체가 그 폭에 묶인다. 나머지가 아무리 넓어도 소용이 없다.
물이 흐르는 파이프를 떠올리면 된다. 파이프 하나가 가늘면 앞뒤를 굵게 바꿔도 흐르는 양은 그대로다. 이 가장 가는 자리를 **병목(bottleneck)**이라고 부른다.
여기서 확장의 첫 번째 규칙이 나온다. 병목이 아닌 곳을 넓히면 아무 일도 안 일어난다. 돈과 시간만 쓴다.
병목이 되는 자원은 넷이다
그러면 어디가 좁아지나. 한 대의 서버 안에서 바닥나는 것은 대체로 넷 중 하나다.
| 자원 | 바닥나면 | 대표적인 원인 |
|---|---|---|
| CPU | 계산이 밀린다 | 암호화, 직렬화, 이미지 처리, 비효율 루프 |
| 메모리 | 스왑하거나 죽는다 | 큰 응답을 통째로 올림, 캐시 과다, 누수 |
| 디스크 I/O | 읽기·쓰기가 기다린다 | 인덱스 없는 조회, 로그 과다 기록 |
| 네트워크 | 대역폭이 찬다 | 큰 파일 전송, 응답이 불필요하게 큼 |
증상이 서로 다르다. CPU가 포화면 서버가 바쁘고, I/O가 포화면 서버는 한가한데 응답이 안 온다. 한가한데 느리면 기다리고 있는 것이고, 기다리는 대상을 찾아야 한다.
“CPU 사용률이 낮은데 느리다”는 아주 흔한 상황이고, 대부분 뒤에 있는 무언가(DB, 외부 API, 디스크)를 기다리는 중이다. CPU만 보고 “여유 있다”고 결론 내리면 병목을 놓친다.
상한은 자원에만 있는 게 아니다
물리 자원이 멀쩡한데도 막히는 자리가 있다. 우리가 직접 그어놓은 상한이다.
- 스레드 풀 크기
- 커넥션 풀 크기
- 큐의 최대 길이
- 외부 API의 호출 제한
Connection Pooling에서 본 게 정확히 이것이다. 커넥션 풀이 마르면 DB도 앱도 멀쩡한데 요청이 전부 대기하다 타임아웃된다. CPU 그래프에는 아무것도 안 나온다.
이런 상한은 일부러 그어둔 것이라 나쁜 게 아니다. 뒤에 있는 것을 보호하려고 만든 문이다. 다만 그 문이 병목이 되면, 문을 넓힐지 뒤를 넓힐지를 판단해야 한다. 문만 넓히면 보호받던 쪽이 무너진다. 첫 장면의 DB가 그 경우다.
처리량과 응답 시간은 다른 말이다
“느리다”는 말이 두 가지를 섞고 있다. 나눠야 한다.
- 응답 시간(latency) - 요청 하나가 끝나기까지 걸리는 시간
- 처리량(throughput) - 단위 시간에 끝내는 요청 수
둘은 같이 움직이지 않는다. 계산대가 하나인 가게를 생각하면 쉽다. 계산 자체는 빠른데(응답 시간 짧음) 줄이 길면 손님이 느끼는 시간은 길다. 계산대를 늘리면 처리량이 오르지만, 계산 한 건에 걸리는 시간은 그대로다.
사용자가 느끼는 시간은 대기 시간 + 처리 시간이다. 그런데 애플리케이션 로그는 대개 처리 시간만 찍는다. 그래서 “서버 로그상 응답은 빠른데 사용자는 느리다고 한다”가 생긴다. 답은 큐에 있다.
여유가 줄면 대기는 급격히 나빠진다
여기가 직관을 배신하는 지점이다.
자원 사용률이 절반에서 조금 오르는 것과, 이미 높은 상태에서 조금 더 오르는 것은 결과가 전혀 다르다. 여유가 있을 때는 요청이 도착하는 즉시 처리되지만, 여유가 거의 없으면 앞 요청이 끝나기를 기다려야 하고, 그 기다림이 다음 요청의 기다림을 또 늘린다.
그래서 자원을 100% 쓰는 게 효율의 정점이 아니다. 꽉 채운 시스템은 작은 변동에도 무너진다. 트래픽은 고르게 오지 않고 몰려서 오는데, 여유가 없으면 그 몰림을 흡수할 곳이 없다.
“평소 사용률이 높으니 잘 쓰고 있다”는 위험한 해석이다. 평소가 이미 한계 근처면 평소보다 조금 많은 날 한 번에 무너진다. 확장 판단은 평균이 아니라 피크에 얼마나 여유가 남는가로 한다.
그래서 몰리는 것을 흡수할 자리를 따로 두기도 한다. 요청을 바로 처리하는 대신 쌓아두고 뒤에서 꺼내 쓰는 방식인데, 그 구조와 대가는 메시지 큐 글이 다룬다.
평균은 거짓말을 한다
응답 시간을 평균으로 보면 병목이 안 보인다.
요청 100건 중 95건이 10ms고 5건이 10초라면 평균은 510ms다. 이 숫자는 아무도 겪지 않은 값이다. 510ms를 본 사람은 전원이 조금 느렸는지, 5%가 완전히 멈췄는지 구별할 수 없다. 그리고 그 5건을 겪은 사용자에게 서비스는 망가진 것이다. 게다가 그 느린 5건이 스레드를 오래 붙잡고 있어서, 나머지 95건의 대기까지 만들고 있을 수 있다.
그래서 응답 시간은 분포로 본다.
| 지표 | 읽는 법 |
|---|---|
| 평균 | 전체 감각. 튀는 값에 쉽게 가려진다 |
| p50(중앙값) | 절반의 사용자가 이보다 빠르다 |
| p95 · p99 | 느린 쪽 꼬리. 여기가 실제 사고가 사는 곳 |
| 최대 | 한 건이라도 있으면 원인이 있다 |
p99가 유난히 나쁘면 “가끔 일어나는 무언가”가 있다는 신호다. 특정 사용자만 데이터가 많거나, 캐시 미스일 때만 느리거나, GC가 도는 순간이거나. 평균만 보면 그 무언가를 영영 못 찾는다.
추측하지 말고 재는 순서
병목 찾기는 순서가 있다. 위에서 아래로 좁혀 간다.
핵심은 한 번에 하나씩 좁힌다는 것이다. “느리다”에서 곧장 “캐시를 붙이자”로 뛰면, 캐시를 붙였는데 안 빨라지는 결과를 만난다. 시간이 DB 조회가 아니라 외부 API 호출에 가고 있었다면 캐시 위치가 틀린 것이다.
그리고 고칠 때도 하나씩 고친다. 두 군데를 동시에 바꾸면 무엇이 효과였는지 알 수 없고, 다음번에 같은 판단을 못 한다.
병목은 옮겨 다닌다
가장 중요한 성질이 남았다. 병목을 하나 없애면 사라지는 게 아니라 다음 자리로 옮겨 간다.
이게 좌절할 일은 아니다. 한 칸씩 넓혀 가는 게 확장이고, 어느 시점에는 “여기까지면 충분하다”에서 멈춘다. 다만 다음 병목이 어디일지를 미리 생각하고 손대야 첫 장면처럼 옮겨간 자리에서 사고가 나지 않는다.
이 시리즈의 나머지는 각각 이 그림의 한 칸을 맡는다.
| 도구 | 겨냥하는 병목 |
|---|---|
| 캐시 | 같은 답을 다시 만드는 앱 CPU와 DB 조회 |
| 원격 캐시 | 서버가 여러 대일 때 갈라지는 캐시 |
| 수평 확장 | 한 대의 CPU·메모리 상한 |
| 로드 밸런서 | 여러 대에 요청을 고르게 나누는 문제 |
| CDN | 거리에서 오는 지연과 오리진 대역폭 |
| Rate Limiting | 아무리 늘려도 감당 못 하는 유입량 |
각 편은 이 표의 한 줄이 무엇을 사고 무엇을 파는지를 본다.
정리
| 병목 | 가장 좁은 한 곳이 전체 속도를 정한다 |
| 병목 아닌 곳 | 넓혀도 아무 일도 안 일어난다 |
| 자원 넷 | CPU · 메모리 · 디스크 I/O · 네트워크 |
| 인위적 상한 | 스레드 풀 · 커넥션 풀 · 큐. 자원은 멀쩡한데 막힌다 |
| 두 지표 | 응답 시간과 처리량은 같이 움직이지 않는다 |
| 여유 | 꽉 채우면 작은 변동에 무너진다. 피크 기준으로 본다 |
| 분포 | 평균 말고 p95·p99. 꼬리에 원인이 산다 |
| 순서 | 어느 요청 → 어느 구간 → 자원 → 상한 |
| 이동 | 하나 풀면 다음 자리로 옮겨 간다 |
가장 흔한 요청은 “이미 만들어본 답을 또 만들어 달라”는 것이다. 다음 글은 그 반복을 없애는 캐시를 본다. 다만 알맹이는 “붙이면 빨라진다”가 아니라 무엇에는 붙이면 안 되는가다.