생성형 AI가 GPU 서버 위에서 돈다는 건 알고 있었다. 그런데 "이 GPU 한 장이 동시에 몇 명까지 받아주느냐"고 물으면 아무도 숫자로 답을 못 했다. 나도 못 했고.
그래서 직접 재봤다. 엘리스 클라우드에서 GPU를 시간당 빌려 모델을 하나 띄우고, 파이썬으로 요청을 한꺼번에 때려봤다. 결과가 생각과 많이 달라서 정리해 둔다.
먼저 테스트 환경부터
엘리스 클라우드의 ML API에서 엔드포인트를 하나 만들었다. 모델을 고르고 GPU 사양을 고르면 호출할 수 있는 주소를 주는 방식이다. 톰캣에 WAR 올려서 서비스 URL 받던 것과 구조가 비슷하다.
- GPU: A100 80GB PCIe (시간당 2,500원)
- 모델: Gemma 4 E4B (구글 공개 모델, 메모리 약 18GB 사용)
- 호출 방식: OpenAI 호환 API (
/v1/chat/completions) - 조건: 출력 300토큰 고정, temperature 0
temperature를 0으로 고정한 게 중요하다. 이 값이 높으면 매번 답이 달라지고 길이도 들쭉날쭉해진다. 그러면 "GPU가 느린 건지 답이 길어진 건지" 구분이 안 된다. 성능을 재려면 매번 같은 답이 나오게 묶어둬야 한다.
파이썬 25줄이면 부하 테스트가 된다
동시 요청을 보내려면 스레드가 필요하다. 자바로 치면 ExecutorService에 작업을 던지는 것과 같다. 파이썬은 ThreadPoolExecutor 한 줄이면 된다.
import json, sys, time, urllib.request
from concurrent.futures import ThreadPoolExecutor
URL, KEY = sys.argv[1], sys.argv[2]
N = int(sys.argv[3]) # 동시 요청 수
def one(i):
body = json.dumps({
"model": "google/gemma-4-e4b-it",
"messages": [{"role": "user", "content": f"클라우드 컴퓨팅을 세 문장으로 설명해주세요. ({i})"}],
"max_tokens": 300, "temperature": 0,
}).encode()
req = urllib.request.Request(URL + "/v1/chat/completions", data=body,
headers={"Authorization": "Bearer " + KEY, "Content-Type": "application/json"})
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=300) as r:
d = json.load(r)
return i, round(time.time()-t0, 2), d["usage"]["completion_tokens"], "OK"
except Exception as e:
return i, round(time.time()-t0, 2), 0, str(e)[:40]
call("안녕") # 예열용 1건, 측정에서 제외
t0 = time.time()
with ThreadPoolExecutor(max_workers=N) as ex:
rows = list(ex.map(one, range(1, N+1)))
total = time.time() - t0
중요한 건 예열 호출이다. 첫 요청은 항상 느리다. 그걸 측정에 넣으면 숫자가 망가진다. 톰캣 띄우고 첫 페이지 호출이 느린 것과 같은 이치다.
숫자로 보니 생각과 달랐다
동시 요청 수를 올려가며 돌렸다. 출력 토큰은 전부 300으로 같다.
| 동시 요청 | 성공 | 평균 소요 | 처리량 |
|---|---|---|---|
| 5건 | 5/5 | 3.72초 | 390 토큰/초 |
| 9건 | 9/9 | 3.57초 | 698 토큰/초 |
| 13건 | 12/13 | 3.63초 | 872 토큰/초 |
| 20건 | 15/20 | 3.52초 | 실패 5건 |
처음엔 이게 이상했다. 동시 요청을 2배, 3배로 올렸는데 한 건당 걸리는 시간이 거의 안 늘었다. 3.5초에서 3.7초 사이에 머물렀다.
이유는 LLM 서버가 들어온 요청을 묶어서 한꺼번에 처리하기 때문이다. 13건이 들어오면 13건을 한 묶음으로 GPU에 태운다. 그래서 전체 처리량은 390에서 872 토큰/초로 늘었는데, 각자 기다린 시간은 그대로였다. 자바에서 쿼리를 한 건씩 날리지 않고 묶어서 날리는 것과 비슷한 셈이다.
대신 20건부터는 5건이 실패했다. 실패한 요청은 전부 120초를 기다리다 503을 받았다. 성공한 건 3.5초에 끝났는데, 실패한 건 2분을 버티다 떨어졌다.
그런데 처음 측정했을 때는 이 표가 전혀 다르게 나왔다.
처음 측정했을 때는 숫자가 엉망이었다. 5건에서 345 토큰/초가 나왔는데 9건에서 704, 20건에서 다시 346. 올라갔다 내려갔다 했다.
원인은 간단했다. 테스트 사이에 쉬지 않았다. 실패한 요청이 120초 뒤에 떨어지니까, 앞 테스트의 실패 대기분이 다음 테스트와 겹쳐서 돌아간 거다. 테스트마다 150초씩 쉬고 다시 재니 그제야 깔끔한 곡선이 나왔다.
부하 테스트를 하는 분들은 이거 꼭 챙기시길. 타임아웃 시간보다 길게 쉬어야 한다.
인스턴스를 2개로 늘렸는데 그대로였다
동시 20건에서 5건이 떨어지니 당연히 이렇게 생각했다. "GPU가 모자라구나. 하나 더 띄우면 되겠네."
인스턴스를 2개로 올렸다. 시간당 5,000원이 됐다. 그리고 같은 테스트를 돌렸다.
| 동시 요청 | 인스턴스 1개 | 인스턴스 2개 |
|---|---|---|
| 13건 | 12건 성공 | 8건 성공 |
| 20건 | 15건 성공 | 15건 성공 |
똑같았다. GPU 값을 두 배로 내고 있는데 처리 건수는 그대로다. 13건에서는 오히려 줄었다.
그래서 의심이 갔다. 두 대가 정말 같이 일하고 있나? 서버 이중화를 해본 사람이면 떠오르는 구분이 있다.
- Active-Standby: 한 대만 일하고 나머지는 대기. 주 서버가 죽을 때만 투입된다.
- Active-Active: 두 대가 동시에 일하고 요청을 나눠 받는다.
Active-Active라면 나누는 규칙이 또 있다. 1번, 2번, 1번, 2번 순으로 기계적으로 돌리는 라운드 로빈, 지금 일이 제일 적은 쪽에 주는 최소 연결 같은 것들. LLM처럼 요청마다 처리 시간이 들쭉날쭉한 경우엔 라운드 로빈보다 최소 연결이 맞다. 긴 요청이 한쪽에 몰리면 그쪽만 밀리니까.
만약 Standby 구성이거나 분배가 한쪽으로 쏠린다면, 돈만 두 배 내고 효과는 없는 셈이다. 확인할 방법은 하나뿐이었다. 로그를 열어보는 것.
로그를 열어보고 알았다
인스턴스 두 개의 로그를 각각 받아서, 같은 시각에 들어온 요청 수를 세어봤다. 리눅스 명령 두 줄이면 된다.
# 인스턴스별로 처리한 요청 건수 세기
grep -c 'POST /v1/chat/completions' 인스턴스_1.txt
grep -c 'POST /v1/chat/completions' 인스턴스_2.txt
# 특정 시각대만 보기
grep '06:16' 인스턴스_1.txt | grep 'POST /v1/chat/completions'
동시 10건을 보낸 시각의 결과다.
- 인스턴스 1번이 처리한 요청: 5건
- 인스턴스 2번이 처리한 요청: 2건
- 합계 7건 — 그리고 테스트 성공 건수도 정확히 7건
우선 Active-Standby는 아니었다. 두 대 다 요청을 받고 있었다. 그런데 분배가 고르지 않았다. 다른 테스트들까지 같은 방식으로 세어보니 이랬다.
| 테스트 | 1번 | 2번 | 합계 = 성공 건수 |
|---|---|---|---|
| 13건 요청 | 5 | 3 | 8 |
| 20건 요청 | 5 | 10 | 15 |
| 10건 요청 | 5 | 2 | 7 |
5:3, 5:10, 5:2. 라운드 로빈이면 반반에 가까워야 하는데 그렇지 않다. Active-Active는 맞지만 분배가 고르지 않았다.
그런데 더 중요한 건 따로 있었다. 실패한 요청은 어느 로그에도 없었다. 두 로그의 합계가 매번 성공 건수와 정확히 같다. 실패한 요청은 인스턴스까지 오지도 못한 거다. 그 앞 어딘가에서 사라졌다.
같은 시각 GPU 쪽 상태를 보면 더 분명하다. 모델 서버 로그에 이런 줄이 주기적으로 찍힌다.
Avg generation throughput: 150.0 tokens/s, Running: 0 reqs,
Waiting: 0 reqs, GPU KV cache usage: 0.0%
대기 중인 요청 0건, 메모리 사용률 0%. GPU는 받은 일을 다 끝내고 놀고 있었다. 밀린 게 아니라 들어오질 않았다.
자바로 치면 WAS 스레드풀은 텅 비어 있는데 앞단 웹서버 커넥션 수에서 막혀 있는 상황이다. 톰캣 maxThreads를 아무리 올려도 아파치 쪽에서 막히면 소용없는 것처럼.
실제로 인스턴스 로그 한쪽에 이런 흔적도 남아 있었다. 모델 앞에 요청을 중계하는 프록시 프로그램이 있는데, 거기서 전달에 실패한 기록이다.
proxy.py, line 74, in _forward
TimeoutError
proxy.py, line 76, in _forward
self.send_error(502, f"Bad Gateway: {exc}")
BrokenPipeError: [Errno 32] Broken pipe
프록시가 전달에 실패해서 502를 돌려주려 했는데, 클라이언트는 이미 2분을 기다리다 끊은 뒤라 그마저 못 보냈다는 뜻이다. 내부 구조를 다 볼 수는 없으니 어디까지나 추정이지만, 병목은 GPU가 아니라 그 앞의 프록시 단으로 보인다.
여기까지 재보고 정리한 게 이렇다.
- GPU 성능과 서비스 처리량은 다른 문제다. GPU가 멀쩡해도 앞단에서 막히면 사용자는 오류를 본다.
- 돈으로 해결 안 되는 구간이 있다. 인스턴스를 두 배로 늘려도 한도가 그대로였다. 분배도 5:2처럼 고르지 않았다.
- 실패는 조용히 일어난다. 성공한 요청은 3.5초 만에 멀쩡히 돌아온다. 중간에 끼어 떨어진 요청만 2분을 기다리다 죽는다. 모니터링을 안 하면 모른다.
- 그래서 호출하는 쪽에 재시도 로직이 필요하다. 503을 받으면 잠깐 쉬었다 다시 보내는 것. 이게 없으면 사용자한테는 그냥 "AI가 응답하지 않습니다"로 보인다.
다음에 같은 걸 해보실 분은 순서만 기억하면 된다. 예열 1건 → 동시 요청 수를 5, 9, 13, 20으로 올리며 측정 → 테스트 사이 150초 휴식 → 실패가 나오면 서버 로그에서 그 요청이 도달했는지 확인. 이 네 가지면 "우리 환경의 한계는 어디인가"가 숫자로 나온다.
GPU 빌려서 모델 띄우는 건 클라우드를 이용하니 생각보다 쉬웠다. 아울러 애매하긴 하지만 실제로 몇 명을 받아주는지 대략적인 감은 잡은거 같다. 동접 처리도 중요하고 건당 처리 속도도 중요하고... 아직은 오리무중 진행중이라는것으로 결론낸다.
이상 GPU 부하 테스트 끝!



댓글
댓글 쓰기