병목
고친 사람 github-actions[bot]
병목은 여러 단계를 거치는 일에서 전체가 얼마나 처리될지를 혼자 정하는 한 단계입니다. 앞뒤 단계를 아무리 키워도 일은 이 단계가 받아 주는 만큼만 빠져나갑니다. 그래서 어디를 손봐야 전체가 달라지는지를 병목이 가려 줍니다.
쉽고 빠른 이해
병목은 이어 붙은 여러 단계 가운데 같은 시간에 가장 적게 처리하는 단계입니다. 요청이 웹 서버와 앱 서버를 지나 데이터베이스까지 간다면, 그 셋 중 하나가 전체의 상한을 쥡니다.
이걸 가려내지 않으면 어디를 고칠지 못 고릅니다. 병목이 아닌 단계를 키우는 데에도 돈과 시간이 들지만 전체가 처리하는 양은 그대로입니다.
어떻게 도는가:
- 단계마다 같은 시간에 몇 건을 처리하는지 잽니다
- 그중 가장 적은 단계가 전체의 상한이 됩니다
- 그 단계 앞에는 처리되기를 기다리는 줄이 쌓입니다
대가도 있습니다. 병목을 넓히면 상한이 그다음으로 적게 처리하던 단계로 넘어갑니다. 한 번 찾아 고치고 끝나는 일이 아니라 넘어간 곳을 다시 재야 합니다.
상세
병을 거꾸로 들면 물은 몸통의 굵기가 아니라 목의 굵기만큼만 나옵니다. 몸통을 키워도 쏟아지는 양은 달라지지 않습니다.
여기서 말하는 단계는 일 하나가 지나가는 구간입니다. 웹 요청 하나가 웹 서버를 거쳐 앱 서버로, 다시 데이터베이스로 갔다가 돌아온다면 그 구간 하나하나가 단계입니다.
병목은 그 단계들 가운데 같은 시간에 처리할 수 있는 건수가 가장 적은 단계입니다. 전체가 처리하는 양이 그 단계에 맞춰지기 때문에 붙은 이름입니다.
전체 처리량을 정하는 단계
처리량은 같은 시간에 끝내는 일의 건수입니다. 단계마다 처리량이 다르고, 그중 제일 작은 값이 전체의 처리량이 됩니다.
flowchart TD
시작["들어오는 요청"] --> A["웹 서버<br/>초당 2,000건"]
A --> B["앱 서버<br/>초당 800건"]
B --> C["데이터베이스<br/>초당 150건 · 병목"]
C --> 끝["빠져나가는 요청<br/>초당 150건"]
그림에서 웹 서버는 초당 2,000건을 받을 수 있지만 데이터베이스가 초당 150건만 처리합니다. 그래서 이 흐름이 밖으로 내보내는 것도 초당 150건입니다. 웹 서버를 두 배로 키워도 이 숫자는 그대로입니다.
이것이 병목을 다루는 첫 번째 이유입니다. 손볼 곳이 여럿일 때 병목이 아닌 곳을 고치면 들인 품에 비해 전체가 달라지지 않습니다.
병목 앞에 쌓이는 줄
앞 단계가 병목이 처리하는 것보다 많이 밀어 넣으면 처리되지 못한 일이 남습니다. 이렇게 순서를 기다리며 쌓이는 줄을 큐라고 부릅니다.
응답 시간은 요청 하나가 들어와서 답을 받기까지 걸린 시간입니다. 이 시간은 각 단계가 일을 처리한 시간에 더해, 병목 앞에서 줄을 서느라 기다린 시간까지 합친 값입니다.
그래서 들어오는 양이 병목의 상한에 가까워질수록 응답 시간이 가파르게 늘어납니다. 처리량은 상한에서 멈춰 더 늘지 않는데 줄만 길어지기 때문입니다. 이 관계를 다루는 갈래가 큐잉 이론입니다.
줄이 끝없이 길어지면 기다리던 요청이 타임아웃으로 끊기고, 그것이 앞 단계로 번져 연쇄 장애가 됩니다. 앞 단계가 스스로 밀어 넣는 양을 줄이게 하는 백프레셔가 이때 쓰입니다.
병목의 이동
병목을 넓히면 그 단계는 더 이상 상한이 아니게 됩니다. 대신 그다음으로 적게 처리하던 단계가 상한을 넘겨받습니다. 앞의 그림에서 데이터베이스만 손봤다고 해 봅시다.
| 단계 | 손보기 전 | 데이터베이스를 넓힌 뒤 |
|---|---|---|
| 웹 서버 | 초당 2,000건 | 초당 2,000건 |
| 앱 서버 | 초당 800건 | 초당 800건 |
| 데이터베이스 | 초당 150건 | 초당 1,200건 |
| 전체 | 초당 150건 | 초당 800건 |
전체는 초당 150건에서 800건이 됐고, 병목은 데이터베이스에서 앱 서버로 옮겨 갔습니다. 병목은 없앨 수 있는 것이 아니라 언제나 어딘가 하나는 있습니다.
그래서 성능을 손보는 일은 한 번으로 끝나지 않습니다. 넓히고 나면 다시 재서 다음 병목을 찾는 과정이 되풀이됩니다.
병목이 되는 자원의 종류
단계가 일을 처리하려면 자원을 씁니다. 자원이 다 차면 그 단계는 더 받지 못하고 병목이 됩니다. 자원마다 다 찼다는 신호가 달라서, 무엇을 봐야 하는지 미리 알아 두면 찾는 시간이 줄어듭니다.
| 자원 | 다 찼을 때 보이는 것 | 흔한 원인 |
|---|---|---|
| CPU(Central Processing Unit, 중앙처리장치) | 실행을 기다리는 작업이 쌓인다 | 계산 · 압축 · 암호화 |
| 메모리 | 디스크로 내보냈다 다시 읽어 오는 일이 늘어난다 | 캐시 과다 · 메모리 누수 |
| 디스크 | 입출력 요청이 끝나기까지 걸리는 시간이 늘어난다 | 흩어진 데이터 읽기 · 기록을 기다리는 쓰기 |
| 네트워크 | 같은 시간에 보낼 수 있는 양이 다 차고 재전송이 늘어난다 | 큰 응답 · 잦은 왕복 |
| 잠금 | 같은 잠금을 기다리는 작업이 늘어난다 | 잠금을 오래 쥐는 처리 |
표의 마지막 줄이 나머지 넷과 성격이 다릅니다. 잠금은 여러 작업이 같은 데이터를 동시에 건드리지 못하게 막는 표식입니다. 한 작업이 잠금을 쥐고 있으면 나머지는 기다립니다.
이 기다림을 락 경합이라고 부릅니다. 앞의 넷은 장비를 더 붙이면 늘어나지만 잠금은 그렇지 않습니다. 하나뿐인 것을 나눠 가질 수 없기 때문입니다.
그래서 서버를 여러 대로 늘리는 수평 확장은 병목이 나눠 가질 수 있는 자원일 때만 듣습니다. 서버를 열 대로 늘려도 모두가 같은 데이터베이스 하나를 부르면 그 단계의 상한은 그대로입니다.
병목을 찾는 방법
병목은 짐작으로 고르면 자주 틀립니다. 코드를 읽어서 가장 복잡해 보이는 곳이 실제로 시간을 가장 많이 쓰는 곳이 아닌 경우가 흔하기 때문입니다.
그래서 재는 것부터 합니다. 단계마다 자원이 얼마나 찼는지와 대기 중인 일이 얼마나 쌓였는지를 보는 일이 모니터링입니다. 자원이 다 차서 더 받지 못하는 상태를 포화라고 부릅니다.
한 요청이 각 단계에서 얼마씩 썼는지 따라가는 방법도 있습니다. 요청 하나가 지나간 구간마다 걸린 시간을 이어 붙인 기록을 트레이스라고 합니다. 여기서 시간을 제일 많이 먹은 구간이 병목 후보입니다.
한 단계 안에서 어느 함수가 시간을 썼는지까지 쪼개 보는 것은 프로파일링입니다. 어느 단계가 병목인지 정한 뒤에 그 안을 들여다볼 때 씁니다.
찾은 병목을 넓히는 방법은 여럿입니다. 같은 결과를 다시 계산하지 않게 두는 캐싱, 요청을 여러 서버로 나누는 부하 분산, 미리 만들어 둔 연결을 돌려 쓰는 커넥션 풀이 자주 쓰입니다. 어느 것이 듣는지는 병목이 무엇인가에 달려 있습니다.
관련 항목
병목이 좌우하는 성능 지표
처리량 · 응답 시간 · 지연 · 포화 · 꼬리 지연 · 사용률
병목 앞에서 일이 쌓이는 구조
큐 · 큐 대기 · 백프레셔 · 큐잉 이론 · 리틀의 법칙 · 버퍼
병목으로 바뀌는 자원
CPU · 메모리 · 디스크 · 네트워크 · 대역폭 · 락 경합 · 커넥션 풀 · 파일 서술자
병목을 찾을 때 쓰는 방법
프로파일링 · 성능 분석 · USE 방법 · 트레이스 · 부하 테스트 · 모니터링 · 플레임 그래프
병목을 넓히거나 우회하는 방법
수평 확장 · 수직 확장 · 캐싱 · 부하 분산 · 배치 처리 · 샤딩 · 비동기 처리
병목이 커져 장애로 번지는 방식
연쇄 장애 · 썬더링 허드 · 커넥션 풀 고갈 · 타임아웃 · 캐시 스탬피드 · 메모리 누수
병목과 헷갈리는 이웃
핫스팟 · 한계 · 암달의 법칙 · 임계 경로
다른 이름: bottleneck · 보틀넥 · 병목 지점 · 병목 구간