포스트

서버를 세우고 나서야 보이는 것들 — 20ms의 오버헤드, 그리고 브라우저가 만든 가짜 발열

ollama run은 첫날부터 잘 돌아갑니다. 그런데 그 앞에 HTTP 서버를 하나 세우는 순간, 세 개의 측정 함정이 한꺼번에 튀어나왔습니다. 그중 하나는 제가 열어둔 브라우저였습니다.

서버를 세우고 나서야 보이는 것들 — 20ms의 오버헤드, 그리고 브라우저가 만든 가짜 발열

이 글의 독자: LLM을 처음 다뤄보는 개발자. 앞선 두 글(1편, 2편)을 안 읽으셨어도 따라올 수 있게 용어는 다시 설명합니다.

글의 형식: 지난 글들과 같은 timeline입니다. 결과만이 아니라, 제가 틀렸던 순간과 그걸 어떻게 알아챘는지를 순서대로 적었습니다. 오늘은 특히 제가 하마터면 블로그에 자랑스럽게 써버릴 뻔했던 잘못된 발견이 주인공입니다.


들어가며 — 모델은 떴는데, 그래서 서버는?

1편에서 맥미니 M4에 Qwen2.5 7B를 띄우고 속도를 쟀습니다. 21.3 tok/s. 2편에서는 prefix cache 측정을 하다가 제 자신의 착각 두 개를 걷어냈습니다.

그런데 두 글 모두 공통점이 있습니다. 터미널에서 ollama run을 친 결과라는 것.

이건 서비스가 아닙니다. 사용자는 터미널에 접속하지 않습니다. 브라우저에서 요청을 보내고, 글자가 한 자씩 흘러나오는 걸 봅니다. 그 사이에는 HTTP 서버가 있어야 합니다.

오늘 할 일은 딱 그것입니다. 그리고 그 서버를 세우자마자, 터미널에서는 절대 보이지 않던 것 세 개가 튀어나왔습니다.


Step 0 — 오늘 나올 용어 4개

용어왜 중요한가
SSE (Server-Sent Events)서버가 클라이언트로 데이터를 조금씩 계속 밀어 보내는 HTTP 표준ChatGPT처럼 글자가 한 자씩 나오게 하는 기술
TTFT (Time To First Token)요청 후 첫 글자가 보이기까지의 시간사용자가 “느리다”고 느끼는 바로 그 시간
Prefill모델이 내 질문 전체를 한 번에 읽는 단계TTFT의 대부분을 차지함
Cold start모델이 메모리에 올라가 있지 않은 상태에서의 첫 요청모델 로딩 시간이 여기 숨음

SSE를 조금 더 풀면, 일반 HTTP 요청은 “질문 → (기다림) → 답변 전체”입니다. SSE는 “질문 → 답 → 변 → 이 → 나 → 온 → 다” 처럼 완성되는 대로 조각조각 보냅니다. LLM은 원래 토큰을 하나씩 만들어내므로, 궁합이 잘 맞습니다.


Step 1 — 무엇을 만들 것인가 (그리고 무엇을 안 만들 것인가)

제가 세운 목표는 한 문장입니다.

“가장 작고 정직한 baseline을 만든다.”

baseline(기준선)은 앞으로의 모든 최적화가 비교당할 숫자입니다. 이게 없으면 나중에 “2배 빨라졌다”는 말을 할 수가 없습니다.

그래서 만든 것:

  • Ollama의 토큰 스트림을 SSE로 중계하는 FastAPI 엔드포인트 1개
  • 요청을 보내고 시간을 재는 측정 스크립트
  • 토큰이 흘러나오는 걸 눈으로 볼 HTML 페이지 1장

그리고 만들지 않은 것: Docker, Prometheus, 인증, 동시성 처리, 큐, 데이터베이스. 전부 다음 단계 몫입니다.

포트폴리오 프로젝트가 실패하는 가장 흔한 이유는 측정 없이 아키텍처부터 쌓는 것입니다. 화려한 구조도는 만들기 쉽고, 정직한 숫자 하나는 만들기 어렵습니다.

전체 코드는 200줄이 안 됩니다.


Step 2 — 첫 번째 결정: 시간을 어디서 잴 것인가 (결정 1)

의외로 이게 제일 중요한 결정이었습니다. 두 가지 선택지가 있습니다.

A. 서버가 잰다. FastAPI가 Ollama를 호출한 시간을 스스로 기록합니다. B. 클라이언트가 잰다. 요청을 보내는 쪽에서, 응답 조각이 도착한 시각을 찍습니다.

B를 골랐습니다. 이유는 하나입니다.

서버가 자기 시간을 재면, 자기가 느린 부분을 빼먹을 수 있습니다.

A는 “Ollama가 800ms 걸렸다”고 보고합니다. 사실일 겁니다. 하지만 사용자가 실제로 기다린 시간은 그것보다 깁니다. FastAPI가 요청을 파싱하고, SSE 프레임을 만들고, 네트워크로 밀어내는 시간이 전부 빠져 있으니까요.

측정은 사용자가 서 있는 자리에서 해야 합니다. 이 원칙 하나 때문에 뒤에 나올 “20ms” 라는 숫자를 얻을 수 있었습니다.

다만 한 가지를 같이 기록하기로 했습니다. Ollama가 스스로 보고하는 토큰 개수와 소요 시간입니다. 응답의 마지막 조각에는 이런 값들이 들어 있습니다.

1
2
{"done": true, "prompt_eval_count": 200, "prompt_eval_duration": 795906000,
 "eval_count": 69, "eval_duration": 3160685000}

prompt_eval_count내 질문이 실제로 몇 개의 토큰이었는지를 서버가 직접 세어준 값입니다. 2편에서 저는 이걸 안 쓰고 단어 수로 추정했다가 두 세션을 날렸습니다. 그래서 이번 프로젝트의 규칙 3번은 이렇습니다.

잴 수 있는 값을 추정하지 말 것.


Step 3 — 첫 번째 함정: 1번 요청 안에 2초가 숨어 있다

측정 스크립트를 만들고 3B 모델로 시험 삼아 돌렸습니다. 그리고 응답의 마지막 조각을 보다가 이 값을 발견했습니다.

1
"load_duration": 2108179542

나노초 단위입니다. 2.1초. 모델을 메모리에 올리는 데 걸린 시간이고, 이게 1번 요청 안에 통째로 들어가 있습니다.

왜 문제인가? 제 측정 방법은 이렇습니다.

25번 요청을 순서대로 보내고, 처음 5개의 중앙값마지막 5개의 중앙값을 비교한다. 5% 넘게 느려졌으면 그 측정은 버린다.

이건 발열 때문에 성능이 떨어지는 걸 잡아내려고 만든 장치입니다. 그런데 1번 요청에 2.1초가 얹혀 있으면, 처음 5개가 부당하게 느려 보입니다. 그러면 “뒤가 느려졌다”는 신호가 희석되거나, 심하면 뒤집힙니다.

7B에서는 이 값이 3.0초였습니다.

해결은 간단합니다. 측정 전에 버리는 요청(warm-up)을 한 번 보냅니다. 결과에 포함하지 않습니다. 그 한 번이 모델을 올려놓고, 그 뒤부터가 진짜 측정입니다.

터미널에서 ollama run을 칠 때는 이 문제가 안 보입니다. 사람이 손으로 몇 번 쳐보는 동안 모델은 이미 올라가 있으니까요. 측정을 자동화하는 순간 생기는 함정입니다.


Step 4 — 두 번째 함정: 150단어가 273토큰이었다

측정용 질문(prompt)은 매번 다른 내용이어야 합니다. 2편에서 배운 것처럼, 같은 질문을 반복하면 prefix cache가 걸려서 가짜로 빠른 숫자가 나옵니다.

그래서 무작위 단어를 이어 붙여 질문을 만들었습니다. 목표는 약 200토큰. 흔히 쓰는 환산은 이렇습니다.

\[\text{토큰} \approx \text{단어 수} \times 1.33\]

그러면 200토큰은 150단어입니다. 그렇게 만들어 돌렸더니, 서버가 세어준 실제 값은:

273토큰, 261토큰, 265토큰.

35% 넘게 빗나갔습니다. 이유는 두 가지였습니다.

첫째, 제가 쓴 무작위 단어들(obsidian, juniper 같은)은 한 단어가 여러 토큰으로 쪼개집니다. 흔한 단어일수록 1토큰, 드문 단어일수록 여러 토큰입니다.

둘째, chat template입니다. Ollama는 제 질문을 그냥 보내지 않고 당신은 도움이 되는 어시스턴트입니다 같은 정해진 틀에 끼워 넣습니다. 그 틀도 토큰입니다. 짧은 질문 하나로 확인해보니 약 26토큰이 자동으로 붙고 있었습니다.

그래서 계산하지 않고 측정했습니다. 단어 수를 바꿔가며 실제 토큰 수를 서버에 물어봤습니다.

단어 수실제 토큰 수
100184, 196
105194, 200
110201, 208

105단어로 확정. 또 규칙 3번입니다.


Step 5 — P99를 보고하지 않기로 한 이유 (결정 2)

원래 계획서에는 Phase 1의 결과물로 P99가 적혀 있었습니다. P99는 “100번 중 99번은 이 시간 안에 끝난다”는 값입니다. 평균은 최악의 경우를 숨기기 때문에, 실무에서는 P99를 같이 봅니다.

그런데 이번 측정은 사용자 한 명이 순서대로 25번 보내는 구조입니다. 여기서 P99를 계산하면 어떻게 될까요?

25개 중 가장 느린 것 하나입니다. 그건 통계가 아니라 잡음입니다.

그럼 100번, 500번 보내면 되지 않느냐? 그러면 측정이 너무 오래 걸려서 기계가 뜨거워집니다. 그리고 뜨거워지면 뒤쪽 숫자가 느려지는데, 그건 측정하려는 대상과 아무 상관이 없습니다. 즉 P99를 의미있게 만들려는 노력 자체가 데이터를 망칩니다.

그래서 이번 글에는 P99가 없습니다. P50(중앙값), 최소, 최대, 그리고 드리프트만 적습니다. P99는 여러 사용자가 동시에 붙는 다음 단계에서, 그때 제대로 잽니다.

계획서에 적힌 항목을 지우는 건 찜찜한 일입니다. 하지만 의미 없는 숫자를 그럴듯하게 적는 것보다는 낫습니다.


Step 6 — 첫 측정: 아름다운 발견 (인 줄 알았던 것)

준비가 끝났습니다. 7B 모델, 25번 요청, 질문은 약 195토큰, 답변은 100토큰. 5분 식힌 뒤 실행했습니다.

결과는 오염(contaminated) 판정이었습니다. TTFT가 6.3% 느려졌으니까요. 그런데 항목별로 뜯어보니 이상했습니다.

지표 (처음 5개 → 마지막 5개)변화
TTFT+6.3%
Prefill (질문 읽는 시간)+4.8%
Generation (답 만드는 속도)−30.8%

답을 만드는 속도가 3분의 1 가까이 무너졌습니다. 22.0 tok/s에서 15.2 tok/s로요. 반면 질문을 읽는 속도는 거의 그대로였습니다.

여기서 저는 아주 그럴듯한 설명을 떠올렸습니다. 1편에서 정리한 내용이 딱 들어맞았거든요.

  • Prefill은 계산(compute)이 병목입니다. 질문 전체를 한 번에 처리하니 GPU가 바쁩니다.
  • Generation은 메모리 대역폭(bandwidth)이 병목입니다. 토큰 하나 만들 때마다 모델 전체를 메모리에서 읽어야 합니다.

그러니까 “발열이 오면 계산보다 메모리 대역폭이 먼저 무너진다”는 결론이 나옵니다. 깔끔하고, 직관에 반하고, 제 이전 글들과도 연결됩니다.

블로그에 쓰기 딱 좋은 문장이었습니다. 그래서 한 번 더 봤습니다.


Step 7 — 그 설명을 죽인 두 가지

첫째, 숫자가 단조롭게 떨어지지 않았습니다.

요청별 생성 속도를 순서대로 늘어놓으면 이렇습니다.

1
2
3
4
21.9  21.9  22.0  22.0  22.0  21.0  21.0   ← 빠름
16.9  17.8  17.0  17.0  17.5  18.7          ← 떨어짐
21.7  21.9  20.3  21.5  19.8  21.4          ← 다시 올라옴 (?)
16.7  14.0  14.8  16.0  15.2  15.6          ← 다시 떨어짐

발열은 이렇게 움직이지 않습니다. 열은 쌓입니다. 한번 뜨거워진 칩이 계속 부하를 받는 중에 스스로 시원해져서 원래 속도로 돌아올 수는 없습니다. 중간의 저 회복 구간이 설명되지 않습니다.

둘째, 제가 브라우저를 켜놓고 있었습니다.

조금 전에 HTML 페이지가 잘 동작하는지 브라우저로 확인했고, 그대로 열어뒀습니다. 브라우저는 메모리 대역폭을 씁니다. 그리고 지금 무너지고 있는 것은 정확히 메모리 대역폭에 의존하는 단계입니다.

즉 제 “발견”은 이렇게 바뀔 수 있습니다.

발열이 대역폭을 먼저 무너뜨린 게 아니라, 다른 프로그램이 대역폭을 나눠 쓰고 있었다.

두 설명 모두 “generation만 무너진다”를 예측합니다. 관측된 결과만으로는 구분이 안 됩니다. 구분하려면 실험을 하나 더 해야 합니다.


Step 8 — 다시 측정: 브라우저를 끄고

브라우저를 닫고, 5분 식히고, 완전히 같은 명령을 다시 돌렸습니다.

지표 (처음 5개 → 마지막 5개)1차 (브라우저 켜짐)2차 (조용한 상태)
TTFT+6.3%−0.2%
Prefill+4.8%−0.0%
Generation−30.8%+1.6%

완전히 평평합니다. 127초 동안 7B 모델을 쉬지 않고 돌렸는데 성능 저하가 없습니다.

1차 측정은 발열을 잰 게 아니라, 제 웹 브라우저를 잰 것이었습니다.

저는 하마터면 “Apple Silicon에서는 발열이 오면 메모리 대역폭이 먼저 무너진다”는 문장을 블로그에 쓸 뻔했습니다. 숫자는 진짜였고, 설명은 그럴듯했고, 제 이전 글들과도 아귀가 맞았습니다. 틀린 것은 원인이었습니다.

여기서 배운 것은 발열에 관한 게 아니라 측정에 관한 것입니다.

그럴듯한 설명이 떠오르면, 그게 바로 한 번 더 확인해야 할 신호입니다. 설명이 매력적일수록 검증을 건너뛰고 싶어집니다.

그리고 이번에 저를 구한 건 지식이 아니라 그래프의 모양이었습니다. 요약 숫자(−30.8%)만 봤다면 발열로 결론냈을 겁니다. 회복 구간이 있다는 건 순서대로 늘어놓고 봐야만 보입니다.


Step 9 — 그래서 baseline은

브라우저를 끈 2차 측정이 공식 기록입니다.

항목
TTFT (중앙값)813.9 ms (최소 803.8 / 최대 840.1)
생성 속도21.9 tok/s
Prefill796.1 ms (질문 약 195토큰 = 4.08 ms/토큰)
드리프트−0.2% (깨끗함)

그리고 오늘의 진짜 수확입니다.

\[813.9\ \text{ms} \ (\text{TTFT}) - 796.1\ \text{ms} \ (\text{Ollama의 prefill}) = \mathbf{17.8\ ms}\]

제가 만든 서버 계층 전체가 17.8ms입니다. FastAPI가 요청을 받고, Ollama에 넘기고, 돌아온 토큰을 SSE로 포장해서 클라이언트까지 보내는 그 모든 과정이요. (측정 방식을 조금 달리하면 20.2ms가 나오는데, 어느 쪽이든 결론은 같습니다.)

이 숫자가 중요한 이유는 이겁니다.

TTFT 814ms 중 98%는 모델이 질문을 읽는 시간입니다. 제 Python 코드를 아무리 최적화해도 건드릴 수 있는 건 나머지 2%입니다.

웹 개발자 출신으로서 제일 손이 가는 곳이 서버 코드인데, 거기엔 아낄 게 없습니다. 시간이 어디 있는지 재보지 않았다면 저는 분명 FastAPI를 튜닝하고 있었을 겁니다.


Step 10 — 예상 못 한 부수 효과: 이전 글의 숫자가 흔들린다

1편에서 저는 prefill 시간을 예측하는 공식을 만들었습니다.

\[\text{prefill (ms)} = 4.50T + 1.96 \times 10^{-4} T^2\]

$T$는 질문의 토큰 수입니다. 오늘 측정한 $T = 195$를 넣으면:

\[4.50 \times 195 + 1.96 \times 10^{-4} \times 195^2 = 877.5 + 7.5 = \mathbf{885\ ms}\]

실제 측정값은 796ms였습니다. 공식이 약 10% 비관적입니다.

이건 서버 오버헤드로 설명할 수 없습니다. 796ms는 Ollama가 직접 보고한 자기 시간이라 FastAPI와 무관하니까요.

가장 그럴듯한 설명은 불편합니다. 그 공식을 만들 때 쓴 데이터가 오염돼 있었을 가능성입니다. 1편의 측정은 5분짜리 배치를 쉬지 않고 돌려서 얻은 것이고, 그건 제가 지금 적용하는 기준으로는 버렸어야 할 데이터입니다.

아직 공식을 고치지는 않았습니다. 오늘 측정 하나로 뒤집기에는 조건이 다릅니다. 할 일 목록에 올려두고, 깨끗한 조건에서 다시 맞출 겁니다.

마찬가지로, 2편의 “이 기계는 2분이면 성능이 떨어진다”는 결론도 오늘 결과와 충돌하는 것처럼 보입니다. 하지만 2편은 prefill만 연속으로 돌린 측정이었고, 오늘은 생성이 대부분인 작업이었습니다. 부하의 종류가 다릅니다. 둘 다 참일 수 있습니다. 다만 2편에서도 “다른 프로그램이 돌고 있었는지”는 확인한 적이 없습니다.


정리 — 오늘 얻은 것

숫자 3개:

  1. TTFT 813.9ms, 생성 21.9 tok/s — 앞으로 모든 최적화가 비교당할 기준선
  2. 서버 계층 오버헤드 17.8ms — 최적화할 가치가 없는 곳을 알아낸 것
  3. 127초 연속 부하에서 드리프트 −0.2% — 이 작업량에서는 발열이 문제가 아니라는 것

방법 3개:

  1. 측정 전 버리는 요청 하나. cold start가 1번 요청에 숨는다.
  2. 단어 수로 토큰 수를 추정하지 않는다. 35% 빗나갔다.
  3. 기계를 조용하게 만들고 측정한다. 그리고 무엇이 돌고 있었는지 기록한다.

틀린 것 1개: 발열이 메모리 대역폭을 먼저 무너뜨린다는 설명. 실제로는 제가 켜둔 브라우저였습니다.


다음 글

다음은 동시성입니다. 사용자 한 명이 아니라 여러 명이 동시에 붙으면 언제 무너지는가. 계획서에는 이렇게 적혀 있습니다.

무너지는 지점을 찾는 것이 목표다. 피하는 것이 아니라.

그리고 오늘 배운 것 때문에 한 가지가 더 까다로워졌습니다. 동시성 측정은 기계를 일부러 바쁘게 만드는 일입니다. 즉 “조용한 기계에서 측정한다”는 원칙을 정면으로 위반해야 합니다. 부하와 오염을 어떻게 구분할 것인지부터 설계해야 합니다.

오늘처럼 그럴듯한 설명에 속지 않으려면요.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.