맥미니에서 집 GPU 서버로 — 8GB 위에서 Ollama와 vLLM을 동시성으로 비교하기
사용자 한 명일 때는 무승부였습니다. 16명이 붙자 한쪽은 첫 글자까지 26초, 다른 쪽은 0.17초였습니다. RTX 4060 Ti 8GB 한 장에 두 엔진을 올리고, 그 과정에서 밟은 함정 네 개를 적었습니다.
이 글의 독자: LLM을 처음 다뤄보는 개발자. 앞선 글들(1편~4편)을 안 읽으셨어도 따라올 수 있게 용어는 다시 설명합니다.
이 시리즈: 집에 있는 GPU 한 장으로 “회사가 사내 LLM 서비스를 운영하는 방식”을 그대로 만들어 본 기록입니다. 이 글이 그 첫 편이고, 완성된 구조는 어떻게 만들었나 페이지에서 움직이는 지도로 볼 수 있습니다.
들어가며 — 지난 글이 약속한 것
지난 글들은 전부 맥미니 M4 한 대, 사용자 한 명이었습니다. 마지막 글은 이렇게 끝났습니다.
다음은 드디어 동시성입니다. 여러 명이 동시에 요청하면 언제 무너지는지를 잽니다.
그 약속을 지키려고 보니, 먼저 바꿔야 할 게 있었습니다. 어디서 돌릴 것인가.
맥미니는 제 직장으로 가게 됐습니다. 집에는 원래 쓰던 우분투 데스크톱이 있습니다. 서비스는 항상 켜져 있는 곳에 있어야 합니다. 그래서 역할을 나눴습니다.
| 기계 | 역할 |
|---|---|
| 우분투 노드 (집, 항상 켜짐) | 모델 엔진, 게이트웨이, 웹, 데이터베이스 — 서비스 전부 |
| 맥미니 (직장) | 코드 작성, 테스트, 그리고 브라우저로 접속하는 클라이언트 |
노드의 사양은 이렇습니다.
| 부품 | 사양 |
|---|---|
| GPU | RTX 4060 Ti, VRAM 8GB |
| CPU | Ryzen 5 7600 |
| RAM | 32GB |
8GB는 작습니다. 이 글의 모든 결정은 이 숫자에서 나옵니다.
Step 0 — 오늘 나올 용어 5개
| 용어 | 뜻 | 왜 중요한가 |
|---|---|---|
| VRAM | GPU에 붙은 메모리 | 모델 가중치와 KV cache가 여기 들어가야 빠름 |
| 양자화 (4-bit) | 가중치를 16비트 대신 4비트로 저장 | 8GB에 4B~8B 모델을 넣는 유일한 방법 |
| Continuous batching | 여러 요청을 한 번의 GPU 연산에 묶어 처리 | vLLM의 핵심. 동시 사용자 처리량을 결정 |
| KV cache | 모델이 이미 읽은 토큰의 계산 결과를 저장해 두는 메모리 | 동시에 몇 개의 대화를 들고 있을 수 있는지 결정 |
| Tailscale | 내 기기들끼리만 보이는 사설 네트워크 (WireGuard 기반) | 집 서버를 인터넷에 열지 않고 밖에서 쓰는 방법 |
Step 1 — 인터넷에 열지 않는다 (결정 1)
LLM 엔진(Ollama, vLLM)에는 인증이 없습니다. 주소만 알면 누구나 GPU를 쓸 수 있습니다. 그래서 첫 규칙은 이것이었습니다.
어떤 서비스도 인터넷에 노출하지 않는다. 밖에서는 Tailscale로만 들어온다. 공유기 포트포워딩은 하지 않는다.
여기서 웹 개발자라면 거의 확실히 밟을 함정이 하나 있습니다.
인터넷에 돌아다니는 설치 가이드는 대부분 Docker 포트를 이렇게 엽니다.
1
2
ports:
- "8000:8000"
그리고 “방화벽(ufw)을 켜두면 안전하다”고 덧붙입니다. 틀렸습니다. Docker는 자기 iptables 규칙을 직접 씁니다. 그래서 Docker가 연 포트는 ufw를 그냥 지나갑니다. ufw에서 막았다고 생각한 포트가 실제로는 열려 있는 겁니다.
그래서 모든 포트를 이렇게 묶었습니다.
1
2
ports:
- "127.0.0.1:8000:8000" # 이 기계 안에서만 보임
엔진은 아예 바깥 포트가 필요 없습니다. 게이트웨이만 Docker 내부 네트워크로 엔진에 닿으면 되니까요. 밖에서 들어오는 길은 Tailscale Serve 하나뿐입니다. 내 tailnet 안에서만 열리는 HTTPS 주소를 만들어 주고, 그 뒤에 127.0.0.1:8080의 nginx가 있습니다.
처음 계획(다른 AI와 같이 짠 초안)에는
"8000:8000"이 그대로 들어 있었습니다. 초안은 방향이지 정답이 아닙니다. 명령어 하나하나를 확인하지 않고 복사했다면, 인증 없는 GPU가 집 네트워크에 열려 있었을 겁니다.
Step 2 — 8GB에 무엇을 넣을까 (결정 2)
1편에서 했던 계산을 다시 합니다. 7B 모델을 4비트로 양자화하면 가중치만 약 4~5GB입니다. 8GB에서 나머지 3GB로 KV cache를 잡아야 하고, 여러 명이 동시에 쓰려면 KV cache가 넉넉해야 합니다.
그래서 한 단계 작은 Qwen3.5-4B를 골랐습니다. 가중치가 작아진 만큼 KV cache에 여유가 생깁니다. 같은 모델을 두 엔진에 올렸습니다.
| Ollama | vLLM | |
|---|---|---|
| 모델 파일 | GGUF Q4_K_M | AWQ 4-bit |
| 강점 | 모델을 쉽게 바꿔 끼움, 안 쓰면 메모리 반납 | 여러 요청을 묶어서 처리 (continuous batching) |
| 메모리 | 쓸 때만 올리고, 5분 놀면 반납 | 시작할 때 미리 잡아두고 계속 들고 있음 |
중요한 제약이 하나 있습니다. GPU는 한 번에 한 엔진만 씁니다. vLLM은 시작할 때 VRAM의 정해진 비율을 통째로 예약해 버리기 때문에, 둘을 같이 띄울 수 없습니다. 그래서 Docker Compose의 profile로 나눠 두고, 엔진 전환은 “운영 작업”으로 취급했습니다.
1
docker compose --profile vllm up -d # 또는 --profile ollama
Step 3 — 측정: 사용자 1명일 때
측정 방법은 지난 글들과 같은 원칙입니다. 같은 프롬프트, 응답 최대 256토큰, 스트리밍, 클라이언트 쪽에서 시간 측정. 동시 요청 수만 1, 4, 8, 16으로 바꿨습니다.
먼저 1명입니다.
| 첫 글자까지 (TTFT) | 생성 속도 | |
|---|---|---|
| Ollama | 0.05 s | 76 tok/s |
| vLLM | 0.03 s | 72 tok/s |
무승부입니다. 1편에서 배운 것처럼, 토큰을 하나씩 만드는 단계는 메모리 대역폭이 병목입니다. 토큰 하나를 만들 때마다 가중치 전체를 메모리에서 읽어야 하고, 두 엔진 모두 같은 카드의 같은 대역폭을 씁니다. 엔진이 바꿀 수 있는 게 거의 없습니다.
참고로 맥미니에서 7B는 22 tok/s였습니다. 모델이 작아지고(4B) 메모리 대역폭이 커지니(4060 Ti) 3배 넘게 빨라졌습니다.
Step 4 — 측정: 16명이 동시에 붙으면
| 동시 요청 | Ollama TTFT p50 / 최대 | Ollama 전체 tok/s | vLLM TTFT p50 / 최대 | vLLM 요청당 tok/s | vLLM 전체 tok/s |
|---|---|---|---|---|---|
| 1 | 0.05 / 0.05 s | 76 | 0.03 / 0.03 s | 72 | 71 |
| 4 | 5.2 / 10.3 s | 75 | 0.07 / 0.07 s | 64 | 253 |
| 8 | 12.1 / 24.1 s | 75 | 0.13 / 0.14 s | 58 | 451 |
| 16 | 25.8 / 51.5 s | 75 | 0.17 / 5.4 s | 51 | 460 |
두 엔진의 성격이 완전히 다르게 드러납니다.
Ollama는 줄을 세웁니다. 전체 처리량이 75 tok/s에서 움직이지 않습니다. 한 번에 한 요청만 처리하고 나머지는 기다립니다. 그래서 16명이 동시에 보내면 마지막 사람은 51초 동안 첫 글자를 못 봅니다. 대기 시간이 사용자 수에 정비례해서 늘어납니다.
vLLM은 묶어서 처리합니다. 16명이 붙어도 첫 글자가 0.17초에 나옵니다. 전체 처리량은 460 tok/s, Ollama의 6배입니다. 대신 각자의 속도는 72에서 51 tok/s로 떨어집니다.
왜 이게 가능한가? 아까 말한 병목 때문입니다. 토큰 하나 만들 때 가중치 전체를 읽는 비용은 요청이 1개든 16개든 같습니다. 한 번 읽은 가중치로 16개 요청의 다음 토큰을 한꺼번에 계산하면, 같은 대역폭으로 16배 일을 하는 셈입니다. 이게 continuous batching입니다.
\[\text{처리량} \approx \frac{\text{묶은 요청 수}}{\text{가중치 1회 읽는 시간} + \text{요청당 추가 계산}}\]요청을 묶을수록 분모의 두 번째 항이 커지니, 요청당 속도는 조금씩 떨어지고 전체 처리량은 결국 평평해집니다.
무너지는 지점: 8명에서 451, 16명에서 460 tok/s. 8에서 16 사이에서 카드가 포화됩니다. 16명일 때 한 요청은 5.4초를 기다렸습니다. 이 숫자가 다음 글에서 게이트웨이의 “동시 처리 한도”를 정하는 근거가 됩니다.
결론은 단순합니다. 여러 사람이 쓰는 서비스면 vLLM, 여러 모델을 갈아 끼우며 실험할 거면 Ollama. 저는 기본을 vLLM으로 두었습니다.
Step 5 — 함정 1: GPU가 조용히 사라졌다
벤치마크 전, Ollama가 이상하게 느렸습니다. 17 tok/s. GPU 숫자가 아니라 CPU 숫자입니다.
원인은 Docker가 GPU를 붙이는 방식이었습니다. 흔히 쓰는 gpus: all 설정은 오래된 NVIDIA 훅을 씁니다. 이게 systemctl daemon-reload(시스템 서비스 설정을 다시 읽는 명령) 이후 GPU를 잃어버렸습니다. 그리고 Ollama는 에러를 내지 않고 조용히 CPU로 돌아갔습니다.
해결은 CDI라는 새 방식으로 GPU를 요청하는 것이었습니다.
1
2
devices:
- nvidia.com/gpu=all # CDI 장치 이름으로 요청
“느리다”는 신호를 무시하지 않은 덕분에 잡았습니다. 숫자가 기대와 다르면 엔진이 아니라 환경부터 의심할 것. 3편에서 브라우저가 가짜 발열을 만든 것과 같은 교훈입니다.
Step 6 — 함정 2, 3: vLLM이 시작조차 안 된다
vLLM의 --gpu-memory-utilization 0.85는 “VRAM의 85%를 쓰겠다”는 뜻입니다. 그런데 이건 전체 VRAM의 비율이고, 시작하는 순간 그만큼이 비어 있어야 합니다. 우분투 데스크톱 화면이 1.2GB를 먹고 있었기 때문에 0.85는 실패했습니다. 0.75로 낮췄습니다.
두 번째. Qwen3.5는 일반 트랜스포머가 아니라 하이브리드 구조(일부 층이 Gated DeltaNet이라는 순환형 구조)입니다. 이런 층은 대화마다 고정 크기의 “상태”를 미리 잡아 둡니다. vLLM의 기본값은 동시에 256개 대화를 받을 준비를 하는데, 256개 분의 상태를 미리 잡다가 메모리가 터졌습니다. --max-num-seqs 16으로 줄였습니다. 위 측정에서 16명에서 포화된 것과도 맞아떨어집니다.
Step 7 — 함정 4: 한국어 답에 태국어가 섞인다
vLLM이 드디어 떴습니다. 한국어로 물었더니 답이 이렇게 나왔습니다. 문장 중간중간에 중국어, 태국어, 키릴 문자, 포르투갈어 조각이 섞여 있었습니다. 5번 물어서 5번 다요.
같은 모델을 Ollama로 돌리면 멀쩡했습니다. 그러니 모델 문제는 아닙니다.
원인은 샘플링 설정이었습니다. 모델은 다음 토큰 후보마다 확률을 매기고, 엔진은 그중 하나를 뽑습니다. 이때 “상위 몇 개 중에서만 뽑을지”(top_k), “누적 확률 몇 %까지만 볼지”(top_p)를 정합니다. Qwen은 temperature 0.7, top_p 0.8, top_k 20을 권장합니다.
그런데 제가 받은 AWQ 양자화 파일은 이 권장값이 담긴 설정 파일(generation_config.json)을 잃어버린 상태였습니다. 그래서 vLLM은 top_k 제한 없이 어휘 24만 8천 개 전체에서 뽑았습니다. 확률이 아주 낮은 외국어 토큰이 가끔 뽑힌 겁니다.
| 설정 | 외국어 조각이 섞인 답 |
|---|---|
| 기본값 (제한 없음) | 5 / 5 (답마다 5~12글자) |
temperature=0.7, top_p=0.8, top_k=20 | 0 / 5 |
Ollama는 모델 패키지에 이 값이 들어 있어서 멀쩡해 보였던 것입니다. vLLM 시작 옵션에 --override-generation-config로 권장값을 넣어 해결했습니다.
엔진만큼 샘플링 기본값이 중요합니다. “vLLM이 한국어를 못한다”고 결론 내리기 직전이었습니다. 같은 모델, 다른 엔진에서 결과가 다르면, 다른 건 엔진이 아니라 엔진에 딸려 온 설정일 수 있습니다.
정리 — 오늘 얻은 것
숫자 3개:
- 사용자 1명: 두 엔진 모두 72~76 tok/s, 첫 글자 0.1초 미만 — 무승부
- 사용자 16명: vLLM 전체 460 tok/s, Ollama의 6배. 첫 글자 0.17초 vs 25.8초
- 이 카드의 포화 지점: 동시 8~16개
결정 3개:
- 포트는
127.0.0.1에만, 밖에서는 Tailscale로만 - GPU는 한 번에 한 엔진만. 기본은 vLLM (
gpu-memory-utilization 0.75,max-num-seqs 16) - 샘플링 기본값은 모델 카드에서 가져와 엔진 설정에 박아 둔다
틀릴 뻔한 것 2개: Ollama가 느린 이유(실제로는 GPU를 잃고 CPU로 돌던 것), vLLM이 한국어를 못한다는 결론(실제로는 잃어버린 샘플링 설정).
다음 글
엔진은 섰습니다. 하지만 지금 상태로는 아무나 GPU를 쓸 수 있고, 누가 얼마나 썼는지 모르고, 16명을 넘으면 그냥 느려집니다.
회사의 사내 LLM 서비스라면 클라이언트는 모델과 직접 대화하지 않습니다. 그 사이에 게이트웨이가 있습니다. 다음 글은 Kotlin으로 그 게이트웨이를 세운 이야기입니다. 오늘 측정한 “8~16에서 포화”가 거기서 대기열의 크기가 됩니다.