Performance · Scaling · Backend

무엇이 먼저 무너지나

부하가 늘면 전부가 고르게 느려지는 게 아니라 가장 좁은 한 곳이 먼저 막힌다. 도구를 고르기 전에 그 자리를 찾는 이야기.

·부하를 견디는 법 1편
목차
  1. 서버를 늘렸는데 더 느려졌다
  2. 시스템은 가장 좁은 곳만큼만 흐른다
  3. 병목이 되는 자원은 넷이다
  4. 상한은 자원에만 있는 게 아니다
  5. 처리량과 응답 시간은 다른 말이다
  6. 여유가 줄면 대기는 급격히 나빠진다
  7. 평균은 거짓말을 한다
  8. 추측하지 말고 재는 순서
  9. 병목은 옮겨 다닌다
  10. 정리

트래픽이 늘어 응답이 느려졌다. 서버를 두 배로 늘렸는데 더 느려졌다.

서버를 늘렸는데 더 느려졌다

이런 일이 실제로 일어난다. 이유는 대개 간단하다. 느린 원인이 서버가 아니었기 때문이다.

앱 서버가 한 대일 때 DB에 연결 20개를 열고 있었다면, 두 대가 되면 40개를 연다. DB 입장에서는 갑자기 두 배가 밀려든 것이다. 앱은 여유로워졌는데 DB가 더 바빠졌고, 전체 응답은 DB가 정한다.

그래서 확장은 도구를 고르는 일이 아니라 자리를 찾는 일부터 시작한다. 어디가 먼저 막히는지 모르는 채로 캐시를 붙이면 안 빨라지고, 서버를 늘리면 뒤에 있는 것이 죽는다.

시스템은 가장 좁은 곳만큼만 흐른다

요청은 여러 단계를 지난다. 네트워크를 타고 들어와서, 로드 밸런서를 지나, 앱 서버에서 처리되고, DB를 다녀오고, 응답으로 나간다.

이 중 하나라도 좁으면 전체가 그 폭에 묶인다. 나머지가 아무리 넓어도 소용이 없다.

초당 1000이
들어온다
실제로 흐르는 양 감당할 수 있는 양 나가는 건
이만큼
요청 로드 밸런서벽이 높다 앱 서버벽이 높다 커넥션 풀여기가 목 DB벽이 높다 응답
점선까지 올 수 있었던 양

벽이 높아도 한 칸이 낮으면 그 뒤로는 그만큼만 흐른다. 넓힐 곳은 높은 벽이 아니라 낮은 벽이다.

물이 흐르는 파이프를 떠올리면 된다. 파이프 하나가 가늘면 앞뒤를 굵게 바꿔도 흐르는 양은 그대로다. 이 가장 가는 자리를 **병목(bottleneck)**이라고 부른다.

여기서 확장의 첫 번째 규칙이 나온다. 병목이 아닌 곳을 넓히면 아무 일도 안 일어난다. 돈과 시간만 쓴다.

병목이 되는 자원은 넷이다

그러면 어디가 좁아지나. 한 대의 서버 안에서 바닥나는 것은 대체로 넷 중 하나다.

자원바닥나면대표적인 원인
CPU계산이 밀린다암호화, 직렬화, 이미지 처리, 비효율 루프
메모리스왑하거나 죽는다큰 응답을 통째로 올림, 캐시 과다, 누수
디스크 I/O읽기·쓰기가 기다린다인덱스 없는 조회, 로그 과다 기록
네트워크대역폭이 찬다큰 파일 전송, 응답이 불필요하게 큼

증상이 서로 다르다. CPU가 포화면 서버가 바쁘고, I/O가 포화면 서버는 한가한데 응답이 안 온다. 한가한데 느리면 기다리고 있는 것이고, 기다리는 대상을 찾아야 한다.

“CPU 사용률이 낮은데 느리다”는 아주 흔한 상황이고, 대부분 뒤에 있는 무언가(DB, 외부 API, 디스크)를 기다리는 중이다. CPU만 보고 “여유 있다”고 결론 내리면 병목을 놓친다.

상한은 자원에만 있는 게 아니다

물리 자원이 멀쩡한데도 막히는 자리가 있다. 우리가 직접 그어놓은 상한이다.

  • 스레드 풀 크기
  • 커넥션 풀 크기
  • 큐의 최대 길이
  • 외부 API의 호출 제한

Connection Pooling에서 본 게 정확히 이것이다. 커넥션 풀이 마르면 DB도 앱도 멀쩡한데 요청이 전부 대기하다 타임아웃된다. CPU 그래프에는 아무것도 안 나온다.

이런 상한은 일부러 그어둔 것이라 나쁜 게 아니다. 뒤에 있는 것을 보호하려고 만든 문이다. 다만 그 문이 병목이 되면, 문을 넓힐지 뒤를 넓힐지를 판단해야 한다. 문만 넓히면 보호받던 쪽이 무너진다. 첫 장면의 DB가 그 경우다.

처리량과 응답 시간은 다른 말이다

“느리다”는 말이 두 가지를 섞고 있다. 나눠야 한다.

  • 응답 시간(latency) - 요청 하나가 끝나기까지 걸리는 시간
  • 처리량(throughput) - 단위 시간에 끝내는 요청 수

둘은 같이 움직이지 않는다. 계산대가 하나인 가게를 생각하면 쉽다. 계산 자체는 빠른데(응답 시간 짧음) 줄이 길면 손님이 느끼는 시간은 길다. 계산대를 늘리면 처리량이 오르지만, 계산 한 건에 걸리는 시간은 그대로다.

요청 하나앞에 5건이 밀려 있었다
0 60ms 120ms 180ms
앞 1앞 2앞 3앞 4앞 5 내 차례
앱 로그가 재는 것 · 30ms
사용자가 겪는 것 · 180ms

같은 요청인데 앱이 재는 자사람이 겪는 자의 길이가 다르다. 빠진 것은 앞사람이 끝나기를 기다린 시간이다.

사용자가 느끼는 시간은 대기 시간 + 처리 시간이다. 그런데 애플리케이션 로그는 대개 처리 시간만 찍는다. 그래서 “서버 로그상 응답은 빠른데 사용자는 느리다고 한다”가 생긴다. 답은 큐에 있다.

여유가 줄면 대기는 급격히 나빠진다

여기가 직관을 배신하는 지점이다.

자원 사용률이 절반에서 조금 오르는 것과, 이미 높은 상태에서 조금 더 오르는 것은 결과가 전혀 다르다. 여유가 있을 때는 요청이 도착하는 즉시 처리되지만, 여유가 거의 없으면 앞 요청이 끝나기를 기다려야 하고, 그 기다림이 다음 요청의 기다림을 또 늘린다.

여유가 있을 때요청 하나가 붙잡는 시간이 도착 간격의 40%

도착 여기서 몰려 들어온다
요청 1
2
3
4
5
6
7
8

여유가 거의 없을 때같은 도착인데 하나가 붙잡는 시간이 89%

도착 몰림도 똑같다
요청 1
2
3
4
5
6
7
8

도착은 두 판이 똑같다. 여유가 줄자 기다린 시간이 뒤로 갈수록 불어난다. 일한 시간이 조금 길어진 것뿐인데 결과는 이렇게 갈린다.

그래서 자원을 100% 쓰는 게 효율의 정점이 아니다. 꽉 채운 시스템은 작은 변동에도 무너진다. 트래픽은 고르게 오지 않고 몰려서 오는데, 여유가 없으면 그 몰림을 흡수할 곳이 없다.

주의

“평소 사용률이 높으니 잘 쓰고 있다”는 위험한 해석이다. 평소가 이미 한계 근처면 평소보다 조금 많은 날 한 번에 무너진다. 확장 판단은 평균이 아니라 피크에 얼마나 여유가 남는가로 한다.

참고

그래서 몰리는 것을 흡수할 자리를 따로 두기도 한다. 요청을 바로 처리하는 대신 쌓아두고 뒤에서 꺼내 쓰는 방식인데, 그 구조와 대가는 메시지 큐 글이 다룬다.

평균은 거짓말을 한다

응답 시간을 평균으로 보면 병목이 안 보인다.

요청 100건 중 95건이 10ms고 5건이 10초라면 평균은 510ms다. 이 숫자는 아무도 겪지 않은 값이다. 510ms를 본 사람은 전원이 조금 느렸는지, 5%가 완전히 멈췄는지 구별할 수 없다. 그리고 그 5건을 겪은 사용자에게 서비스는 망가진 것이다. 게다가 그 느린 5건이 스레드를 오래 붙잡고 있어서, 나머지 95건의 대기까지 만들고 있을 수 있다.

그래서 응답 시간은 분포로 본다.

지표읽는 법
평균전체 감각. 튀는 값에 쉽게 가려진다
p50(중앙값)절반의 사용자가 이보다 빠르다
p95 · p99느린 쪽 꼬리. 여기가 실제 사고가 사는 곳
최대한 건이라도 있으면 원인이 있다

p99가 유난히 나쁘면 “가끔 일어나는 무언가”가 있다는 신호다. 특정 사용자만 데이터가 많거나, 캐시 미스일 때만 느리거나, GC가 도는 순간이거나. 평균만 보면 그 무언가를 영영 못 찾는다.

추측하지 말고 재는 순서

병목 찾기는 순서가 있다. 위에서 아래로 좁혀 간다.

어느 요청이 느린가 · 엔드포인트별 응답 시간 분포
/list /search /detail /upload /pay /me
그 요청 안에서 시간이 어디로 갔나
앱 처리 DB 조회 외부 호출 기다림
그 구간의 자원은 포화인가
CPU 메모리 디스크 I/O 네트워크
넷 다 여유가 있다 - 남는 게 없다
그러면 상한을 본다
스레드 풀 커넥션 풀 큐 길이
가장 좁은 한 곳커넥션 풀

후보를 한 단에 하나씩 줄여 내려간다. 자원이 멀쩡하면 다음 단은 우리가 직접 그어둔 상한이다.

핵심은 한 번에 하나씩 좁힌다는 것이다. “느리다”에서 곧장 “캐시를 붙이자”로 뛰면, 캐시를 붙였는데 안 빨라지는 결과를 만난다. 시간이 DB 조회가 아니라 외부 API 호출에 가고 있었다면 캐시 위치가 틀린 것이다.

그리고 고칠 때도 하나씩 고친다. 두 군데를 동시에 바꾸면 무엇이 효과였는지 알 수 없고, 다음번에 같은 판단을 못 한다.

병목은 옮겨 다닌다

가장 중요한 성질이 남았다. 병목을 하나 없애면 사라지는 게 아니라 다음 자리로 옮겨 간다.

앱 CPU DB 커넥션 풀 네트워크
외부 API
캐시를 붙였다
서버를 늘렸다
앞단에서 나눴다
지금 병목 넓힌 자리 아직 안 건드린 자리

낮은 칸이 없어지는 게 아니라 오른쪽으로 걸어간다. 그래서 손대기 전에 다음에 어디가 낮아질지를 먼저 본다.

이게 좌절할 일은 아니다. 한 칸씩 넓혀 가는 게 확장이고, 어느 시점에는 “여기까지면 충분하다”에서 멈춘다. 다만 다음 병목이 어디일지를 미리 생각하고 손대야 첫 장면처럼 옮겨간 자리에서 사고가 나지 않는다.

이 시리즈의 나머지는 각각 이 그림의 한 칸을 맡는다.

도구겨냥하는 병목
캐시같은 답을 다시 만드는 앱 CPU와 DB 조회
원격 캐시서버가 여러 대일 때 갈라지는 캐시
수평 확장한 대의 CPU·메모리 상한
로드 밸런서여러 대에 요청을 고르게 나누는 문제
CDN거리에서 오는 지연과 오리진 대역폭
Rate Limiting아무리 늘려도 감당 못 하는 유입량

각 편은 이 표의 한 줄이 무엇을 사고 무엇을 파는지를 본다.

정리

병목가장 좁은 한 곳이 전체 속도를 정한다
병목 아닌 곳넓혀도 아무 일도 안 일어난다
자원 넷CPU · 메모리 · 디스크 I/O · 네트워크
인위적 상한스레드 풀 · 커넥션 풀 · 큐. 자원은 멀쩡한데 막힌다
두 지표응답 시간과 처리량은 같이 움직이지 않는다
여유꽉 채우면 작은 변동에 무너진다. 피크 기준으로 본다
분포평균 말고 p95·p99. 꼬리에 원인이 산다
순서어느 요청 → 어느 구간 → 자원 → 상한
이동하나 풀면 다음 자리로 옮겨 간다

가장 흔한 요청은 “이미 만들어본 답을 또 만들어 달라”는 것이다. 다음 글은 그 반복을 없애는 캐시를 본다. 다만 알맹이는 “붙이면 빨라진다”가 아니라 무엇에는 붙이면 안 되는가다.