사내 GPU 서버에 Qwen2.5-7B를 올렸다. 첫 호출에 12.6초가 걸렸다.
“생각보다 느리네”가 첫 느낌이었다. 그 느낌을 끝까지 따라가 봤더니, 범인은 내가 의심한 쪽이 아니었다.

1. 12.6초 동안 아무 일도 일어나지 않는다
구축 담당자에게 받은 엔드포인트는 OpenAI 호환 포맷이었다. 그래서 클라이언트 코드는 놀랄 만큼 단순하다. OpenAI 파이썬 SDK를 그대로 쓰고 base_url만 사내 서버로 바꾸면 끝이다.
from openai import OpenAI
import time
client = OpenAI(
api_key="dummy", # 내부 서버라 인증 없음
base_url="http://<서버-IP>/v1"
)
PROMPT = "OCI와 AWS의 차이점을 설명해줘."
start = time.perf_counter()
response = client.chat.completions.create(
model="qwen",
messages=[{"role": "user", "content": PROMPT}],
max_tokens=512
)
elapsed = time.perf_counter() - start
print(response.choices[0].message.content)
print(f"수행시간: {elapsed:.3f}초")
print("Completion:", response.usage.completion_tokens)
두 번 돌린 결과다.
수행시간: 12.616초 Completion: 512
수행시간: 12.579초 Completion: 512
편차 0.04초. 재현성은 훌륭하다. 문제는 그 12.6초 동안 터미널에 아무것도 나오지 않는다는 것이다. 커서만 깜빡인다.
여기서 이미 단서가 하나 나와 있었는데, 그때는 못 봤다. 두 번 다 Completion이 정확히 512다. 모델이 할 말을 마치고 끝낸 게 아니라, 내가 걸어둔 max_tokens=512 상한에 부딪혀 문장 중간에서 잘린 것이다. 실제로 출력도 “4. 가격 및 계약”에서 툭 끊겼다.
2. 첫 번째 의심: GPU를 나눠 쓰고 있나?
담당자 안내에 이런 문장이 있었다.
현재 GPU 1, 2번은 다른 작업으로 사용 중이라, 지금 쓰고 계신 GPU 0번에 함께 올려뒀습니다. 리소스를 공유하는 상태라 요청이 몰리는 시점엔 응답이 다소 느려질 수 있습니다.
그럴듯했다. nvidia-smi를 보니 실제로 GPU 0번에 프로세스가 여섯 개나 붙어 있었다.
GPU PID Process name GPU Memory
---------------------------------------------------
0 3977 python3 334MiB
0 3995 python3 528MiB
0 4361 ...vllm_server/bin/python 35344MiB
0 4533 python3 334MiB
0 5114 python3 2002MiB
0 21099 python3 428MiB
1 21099 python3 36750MiB
2 21099 python3 29104MiB
35,344MiB를 쓰는 4361번이 내 추론 서버다. 그런데 GPU 1·2에서 66GB를 쓰는 21099번이 GPU 0에도 발을 걸치고 있다. VRAM은 428MiB밖에 안 쓰지만, 메모리를 적게 쓴다고 연산 유닛을 안 쓰는 건 아니다. 이웃 프로세스가 SM을 시분할로 가져가면 내 토큰 생성은 그만큼 느려진다.
범인을 찾았다고 생각했다. 그런데 이 목록에는 정작 가장 중요한 숫자가 빠져 있다. GPU가 지금 실제로 일하고 있는지다.
3. 측정해보니 GPU는 놀고 있었다
nvidia-smi \
--query-gpu=index,name,memory.total,utilization.gpu \
--format=csv
index, name, memory.total [MiB], utilization.gpu [%]
0, NVIDIA RTX A6000, 49140 MiB, 0 %
1, NVIDIA RTX A6000, 49140 MiB, 47 %
2, NVIDIA RTX A6000, 49140 MiB, 0 %
GPU 0은 0%. 바쁜 건 GPU 1뿐이었다. 함께 올라가 있던 프로세스들은 VRAM만 붙잡고 연산은 하지 않는 유휴 컨텍스트였다. 담당자가 말한 “요청이 몰리면”은 미래의 리스크였지, 지금 이 12.6초의 원인이 아니었다.
경합 가설은 여기서 죽었다. 대신 진짜 중요한 정보가 나왔다. GPU가 RTX A6000이라는 것.
4. 그래서 이 GPU는 몇 tok/s가 나와야 정상인가
먼저 실측값을 초당 토큰으로 환산한다.
512 토큰 ÷ 12.616초 = 40.6 tok/s
이 숫자가 빠른 건지 느린 건지 판단하려면 기준이 필요하다. 그리고 그 기준은, 의외로 나눗셈 한 번으로 나온다.
LLM의 토큰 생성은 연산이 아니라 메모리 대역폭에 묶여 있다. 토큰을 하나 뽑을 때마다 모델 가중치 전체를 VRAM에서 읽어 와야 하기 때문이다. 100번째 토큰을 만들 때도, 500번째 토큰을 만들 때도 똑같이 전체를 읽는다. 그래서 상한이 이렇게 정해진다.
초당 최대 토큰 수 = 메모리 대역폭 ÷ 모델 크기
내 경우에 대입해 본다. 서버에 올라간 모델을 확인하면,
curl -s http://<서버-IP>:33000/v1/models | python3 -m json.tool
{ "id": "Qwen/Qwen2.5-7B-Instruct-1M", "owned_by": "local" }
Qwen2.5-7B, 정확히는 76.2억 파라미터다. bf16(파라미터당 2바이트)이면 가중치는 약 15.2 GB. RTX A6000의 메모리 대역폭은 768 GB/s다.
오픈소스 LLM은 매주 쏟아지지만(전에 신작 4종의 정체를 정리한 적이 있다), 모델 이름만 봐서는 내 장비에서 몇 tok/s가 나올지 알 수 없다. 그건 모델이 아니라 장비가 정한다.
768 GB/s ÷ 15.2 GB = 초당 50.5 토큰
이게 이 조합의 물리적 천장이다. 소프트웨어를 아무리 잘 짜도 이 위로는 못 간다.
실측 40.6 tok/s는 이론 천장의 80%다. 추론 서버로서 잘 나온 축이다.
5. 반전: A6000은 추론용으로 대역폭이 좁은 카드다
RTX A6000은 48GB라는 큼직한 VRAM 때문에 “좋은 GPU”로 여겨지지만, 용량과 대역폭은 다른 이야기다. A6000은 GDDR6를 쓰고, 데이터센터 카드는 HBM을 쓴다. 이 차이가 토큰 속도를 그대로 가른다.
| GPU | 메모리 | 대역폭 | 7B(bf16) 이론 최대 |
|---|---|---|---|
| RTX A6000 | 48GB GDDR6 | 768 GB/s | 약 50 tok/s |
| A100 80GB | 80GB HBM2e | 2,039 GB/s | 약 134 tok/s |
| H100 SXM | 80GB HBM3 | 3,350 GB/s | 약 220 tok/s |
내가 “느리다”고 느낀 기준은 무의식중에 상용 API였다. 그쪽은 H100 위에서 돌아간다. 같은 7B 모델이라도 하드웨어가 4배 이상 차이 난다. A6000에서 40 tok/s는 못 낸 성적이 아니라, 낼 수 있는 성적의 8할이었다.
참고로 GPU 2번은 48GB가 통째로 놀고 있었다. 옮기면 전용으로 쓸 수 있다. 하지만 같은 A6000이라 40이 50이 될 뿐, 체감은 거의 바뀌지 않는다. 병목이 점유율이 아니라 대역폭일 때, 카드를 비워주는 건 답이 아니다.
6. 진짜 범인은 따로 있었다
초당 40토큰은 정상 속도다. 그런데 왜 12.6초가 그렇게 길게 느껴졌나.
한 글자도 보여주지 않고 512개를 다 만든 뒤에 통째로 뱉었기 때문이다.
한국어에서 1토큰은 대략 1~1.5자다. 초당 40토큰이면 초당 40~60자가 만들어지고 있었다. 사람이 글을 읽는 속도는 초당 10자 안팎이다. 생성이 읽기보다 이미 4배 이상 빨랐다. 그런데 그걸 12.6초 동안 숨겨두고 마지막에 한꺼번에 보여준 것이다.
고치는 방법은 한 줄이다.
response = client.chat.completions.create(
model="qwen",
messages=[{"role": "user", "content": PROMPT}],
max_tokens=512,
stream=True # ← 이 한 줄
)
for chunk in response:
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
전체 소요 시간은 12.6초 그대로다. 1초도 안 줄어든다. 하지만 첫 글자가 0.5초 안에 뜨고, 그다음부터는 읽는 속도보다 빠르게 글이 채워진다. 기다림이 사라진다.
ChatGPT가 빠르게 느껴지는 이유의 절반은 실제 속도가 아니라 이것이다.
7. 정말로 빠르게 만들려면: 양자화
체감이 아니라 숫자 자체를 올리고 싶다면, 위의 나눗셈이 그대로 답을 알려준다. 대역폭은 하드웨어라 못 바꾼다. 그러면 분모인 모델 크기를 줄이면 된다.
4비트 양자화(AWQ, GPTQ)를 적용하면 가중치가 15.2GB에서 약 4.5GB로 줄어든다.
768 GB/s ÷ 4.5 GB = 약 170 tok/s (이론)
실제로는 양자화·역양자화 오버헤드가 붙어 100 tok/s 안팎을 기대할 수 있다. 그래도 지금의 2.5배다. A6000은 Ampere 세대라 Marlin 같은 최적화 커널도 잘 받는다. 대역폭 천장을 실제로 뚫는 사실상 유일한 방법이다.
동시 사용자가 늘어나는 건 따로 걱정하지 않아도 된다. 이 서버는 vLLM 엔진으로 서빙되고 있는데(구축 담당자에게 확인했다), vLLM의 연속 배칭(continuous batching)은 여러 요청을 한 번의 가중치 읽기로 함께 처리한다. 앞서 본 나눗셈에서 분자를 여럿이 나눠 쓰는 셈이라, 사용자가 늘어도 전체 처리량은 잘 버틴다. 위에서 측정한 “동시 요청 1건”이야말로 GPU 입장에서 가장 비효율적인 상태다.
8. 남는 것: 대역폭 나눗셈 하나
로컬 LLM을 올려놓고 “이게 정상 속도인가” 싶을 때, 벤치마크를 찾아 헤매기 전에 먼저 나눗셈을 해보면 된다.
(GPU 메모리 대역폭) ÷ (모델 파일 크기) = 초당 최대 토큰 수
여기서 70~85%가 나오면 소프트웨어는 제 할 일을 하고 있는 것이다. 튜닝할 게 아니라 모델을 줄이거나(양자화) 카드를 바꿔야 한다. 반대로 50% 아래라면 그때는 설정이나 경합을 의심할 차례다.
이번 추적에서 내가 틀린 곳은 두 군데였다. GPU 경합을 의심한 것(실제로는 0% 유휴), 그리고 40 tok/s를 느리다고 판단한 것(실제로는 이론치의 80%). 두 오해 모두 숫자를 보지 않고 느낌으로 판단해서 생겼다.
이렇게 올린 로컬 LLM을 실제 업무에 붙이는 쪽 이야기는 n8n + Telegram으로 URL 요약 답장 만들기에 따로 써뒀다.
느낌은 훌륭한 출발점이다. 다만 도착점은 아니다.
이 글은 개인 서버로 업무 자동화 만들기 — Oracle Cloud + n8n + LLM 전체 지도의 6단계(LLM 붙이기)에 해당합니다. 전체 순서와 다른 단계의 기록은 가이드에서 볼 수 있습니다.