TT Lab
시작하기
배우기 러닝패스 코스

부하 테스트

거절은 실패가 아니라 보호다

TT Lab 에서 이어서 보기

한 줄 요약

설계 문제는 '큐를 둘 것인가'가 아니라 어느 큐가 한계에 먼저 도달하고 그때 무엇을 할 것인가다.

왜 이게 필요했나

요청 하나가 처리되기까지 지나는 길에는 큐가 줄지어 있다. 소켓 버퍼 → 스레드 풀 큐 → 커넥션 풀 대기 → DB 락 대기 → 처리. 어느 하나가 먼저 포화되면 그 뒤는 전부 대기로 바뀐다. 그래서 병목 조사는 이 사다리를 순서대로 훑는 일이다.

큐 길이의 상한은 계산할 수 있다. 허용 대기 시간을 평균 처리 시간으로 나눈 값이다. 목표 200ms, 처리 20ms, 워커 10개라면 상한은 대략 100 이다. 이 상한을 넘겨 무한 큐를 두면 장애를 지연 시간으로 바꿀 뿐이다. 거절은 실패가 아니라 보호다. 빨리 503 을 내는 편이 낫다.

어떻게 동작하나

가장 유명한 전투 사례는 기본값 두 개의 충돌이다. HikariCP maximum-pool-size 기본값 10 과 Tomcat threads.max 기본값 200. 트래픽이 늘면 대부분의 스레드가 커넥션 대기에 빠지고 Connection is not available, request timed out after 30000ms 가 쏟아진다. 해결은 세 가지를 함께 바꾸는 것이다. 풀을 30으로 올리고, connection-timeout 을 30초에서 5초로 줄여 빠르게 실패시키고, Tomcat threads.max 는 오히려 50으로 축소한다. 재발 방지 알림은 hikaricp.connections.pending > 0 이 30초 이상 지속될 때 건다.

포화 지점을 찾는 절차는 다섯 단계다.

  1. 공식으로 초기값을 잡는다. 풀 크기 = (코어 수 × 2) + 디스크 축 수. 8코어면 20 근처다.
  2. 목표 부하에서 풀 대기 p99 와 처리량을 기록한다.
  3. 절반으로 줄여 본다. 처리량이 유지되면 원래가 컸다는 뜻이다.
  4. 처리량이 늘지 않는 지점까지 늘려 보고, 평평해지기 시작한 값보다 조금 작은 곳이 적정이다.
  5. 그 값에서도 대기가 길면 풀이 아니라 쿼리를 고쳐야 한다.

커넥션 대기가 길다는 것은 점유 시간이 길다는 뜻이고 그것은 대개 느린 쿼리이거나 긴 트랜잭션이다. 풀 크기는 그 증상을 잠시 가릴 뿐이다. 그래서 원칙은 이렇다. 풀 크기는 데이터베이스가 동시에 잘 처리할 수 있는 건수여야 하고, 애플리케이션이 보내고 싶은 건수여서는 안 된다.

현장에서 만나는 모습

한계에 도달한 뒤 동시성을 더 늘리면 처리량 X 는 늘지 않고 응답 시간 W 만 비례해 늘어난다. 곧 컨텍스트 스위칭, 동시성의 제곱에 가까운 잠금 경합, 캐시 적중률 하락, 재시도 부하가 겹쳐 오히려 줄어든다. 이 식이 주는 메시지는 숫자 자체가 아니라 자릿수다. 정답은 수백이 아니라 수십이다.

측정 도구가 병목을 숨긴다

병목을 못 찾는 가장 흔한 이유는 서버가 아니라 측정 쪽에 있다. 부하 도구가 응답을 받은 뒤에야 다음 요청을 보내면, 서버가 멈춰 있는 동안에는 요청이 아예 발사되지 않는다. 그 멈춤은 어떤 요청의 응답 시간으로도 기록되지 않으므로, 서버가 1초를 통째로 굳어 있어도 지표에는 나타나지 않는다. 이것을 협조적 누락(coordinated omission)이라 부른다. 실제 사용자는 서버 사정을 봐 가며 요청을 미루지 않기 때문에, 이렇게 잰 p99 는 현실보다 훨씬 낙관적이다.

피하는 방법은 두 가지다. 첫째, 목표 도착률을 고정하는 방식을 지원하는 도구를 쓴다. 응답을 기다리지 않고 정해진 간격으로 요청을 발사하고, 늦어진 만큼을 응답 시간에 더해 기록한다. 둘째, 도구가 그것을 못 하면 서버 쪽에서 큐 대기 시간을 따로 계측한다. 요청이 소켓에 도착한 순간과 워커가 집어 든 순간의 차이가 그 값이고, 이 값이 커지는 시점이 진짜 포화 지점이다.

평균값도 비슷한 방식으로 사람을 속인다. 응답 시간 분포는 대칭이 아니라 오른쪽으로 길게 늘어져 있어서, 평균은 대부분의 사용자가 겪지 않는 값이 된다. 그래서 백분위로 본다. 그런데 여러 인스턴스의 p99 를 평균 내면 그 값은 아무 의미가 없다. 백분위는 더하거나 나눌 수 있는 값이 아니기 때문이다. 히스토그램 버킷을 모아서 전체 분포를 다시 만든 뒤 백분위를 구해야 한다.

한 가지 더 짚어 둘 것이 있다. 화면 하나가 내부 호출 열 번으로 이루어져 있고 각 호출의 p99 가 100ms 라면, 화면 전체의 p99 는 100ms 가 아니다. 열 번 중 한 번이라도 느린 쪽에 걸릴 확률이 훨씬 크기 때문에, 사용자가 실제로 겪는 지연은 그보다 한참 위로 올라간다. 내부 호출을 하나 늘리는 결정은 그 호출의 평균이 아니라 꼬리를 하나 더 얹는 결정이라고 보아야 합니다. 그래서 성능을 지키는 가장 효과적인 방법은 각 호출을 조금씩 빠르게 만드는 것이 아니라, 호출 개수 자체를 줄이거나 병렬로 겹치게 만드는 것이다.

다음 실습에서 할 것

한 번에 하나씩만 처리하는 파이썬 HTTP 서버를 일부러 만들어 띄웁니다. 동시성 1과 10에서 측정해 처리량은 그대로인데 지연만 늘어나는 직렬화 신호를 확인하고, 병목 가설 세 개를 계층별로 세운 뒤, 동시 처리 가능한 서버로 바꿔 처리량이 몇 배로 뛰는지 비교합니다. 마지막에 목표 트래픽을 감당할 인스턴스 수를 계산합니다.