AI 챗봇을 사용하다 보면 어떤 서비스는 질문을 던지자마자 글자가 ‘타닥타닥’ 한 글자씩 나타나는데, 어떤 서비스는 화면에 “생각 중…”이라는 문구만 한참 떠 있다가 어느 순간 전체 답변이 한 번에 ‘뿅’ 하고 나타납니다.
이 차이는 AI 응답 속도 최적화 방법을 어떻게 적용하는가에 따라 달라집니다. LLM(거대언어모델) 서버가 응답을 클라이언트에 전달하는 방식, 즉 블로킹(Blocking) 방식과 스트리밍(Streaming) 방식 중 무엇을 선택했느냐에 따라 갈리는 결과입니다. 오늘은 llama.cpp 기반의 라마 서버(Llama Server)를 예로 들어, 두 응답 방식의 원리와 차이, 그리고 실제 파이썬·FastAPI 코드로 구현하는 방법까지 정리합니다.
1. 라마 서버(Llama Server)란 무엇인가?
본격적인 비교에 앞서, AI 응답을 만들어내는 뒤편에서 묵묵히 일하고 있는 ‘라마 서버’부터 정확히 정의하고 넘어가겠습니다.
쉽게 비유하면: ‘AI의 엔진과 조향장치’
‘라마(Llama)’는 메타(Meta)에서 만든 인공지능 모델의 이름입니다. 그런데 이 모델은 혼자서는 아무 일도 하지 못합니다. 모델을 실제로 실행시키고, 우리가 보낸 질문(프롬프트)을 해석해서 답을 계산해내는 ‘엔진’이 필요한데, 이 역할을 하는 것이 바로 llama.cpp입니다.
여기에 외부 애플리케이션이 이 엔진을 쉽고 편하게 호출할 수 있도록 **API(연결 통로)**라는 옷을 입히고, 자동차로 치면 핸들에 해당하는 제어 인터페이스를 달아놓은 것이 바로 **라마 서버(Llama Server)**입니다.
즉, 라마 서버란 “llama.cpp 엔진으로 구동되는 LLM 모델을 외부에서 HTTP API로 호출해서 답변을 받아낼 수 있도록 설계된 프로그램”이라고 이해하면 됩니다. 대부분의 라마 서버는 OpenAI API와 호환되는 형태(/v1/chat/completions 엔드포인트)로 동작하기 때문에, 기존에 OpenAI API용으로 짜여진 코드를 거의 그대로 재사용할 수 있다는 장점이 있습니다.
2. 블로킹 스트리밍, 식당에 비유하면?
이제 이 글의 핵심 주제인 응답 방식 비교로 넘어가겠습니다. 두 방식의 차이는 식당에 비유하면 아주 쉽게 이해할 수 있습니다.
① 블로킹(Blocking) 방식 — “요리가 다 될 때까지 주방 앞에서 대기”
사용자가 질문을 던지면, 서버는 LLM 모델이 답변을 100% 완성할 때까지 기다립니다. 답변 생성이 모두 끝난 뒤에야 비로소 결과값을 한 번에 클라이언트로 전달합니다.
이 방식에서 사용자는 10초에서 30초 가까이 화면에서 아무런 반응도 보지 못합니다. 답변이 길어질수록 대기 시간은 비례해서 늘어나며, 사용자는 “이 프로그램이 멈췄나?”라는 불안감을 느끼기 쉽습니다.
② 스트리밍(Streaming) 방식 — “완성된 조각부터 바로 서빙”
서버는 LLM이 첫 번째 단어(토큰)를 만들어내는 즉시 그 조각을 클라이언트로 전송합니다. 그 뒤로 생성되는 단어들도 실시간으로 계속 이어서 전달합니다.
이 방식을 쓰면 질문 직후 0.5초 내외로 첫 글자가 화면에 나타나기 시작하므로, 사용자는 AI가 즉각 반응하고 있다고 느끼며 훨씬 쾌적한 대화 경험을 하게 됩니다. 우리가 흔히 접하는 ChatGPT나 Claude 같은 실시간 챗봇 서비스가 바로 이 스트리밍 방식을 기본으로 채택하고 있습니다.

3. 두 방식의 기술적 원리 차이
블로킹과 스트리밍의 근본적인 차이는 HTTP 응답을 어떤 단위로 끊어서 보내느냐에 있습니다.
- 블로킹 방식은 서버가 응답 전체를 하나의 JSON 객체로 완성한 뒤, 단일 응답(single response)으로 한 번에 전송합니다. 클라이언트 입장에서는
response.json()한 번 호출로 전체 결과를 받습니다. - 스트리밍 방식은 Server-Sent Events(SSE) 프로토콜을 활용해, 토큰이 생성될 때마다
data: {...}형태의 청크(chunk)를 계속 흘려보냅니다. 클라이언트는 이 청크들을 실시간으로 순회하며 하나씩 이어붙여 화면에 출력합니다.
이 구조적 차이 때문에 스트리밍 방식은 구현 난이도가 블로킹 방식보다 확실히 높습니다. 단순히 응답을 기다렸다가 받는 것이 아니라, 비동기(async) 방식으로 데이터 조각을 지속적으로 파싱하고 처리하는 로직이 필요하기 때문입니다.
4. 방식별 장단점 비교
| 구분 | 블로킹 (Blocking) | 스트리밍 (Streaming) |
|---|---|---|
| 반응 속도 | 느림 (전체 완료까지 대기) | 매우 빠름 (즉각적 반응) |
| 기술 난이도 | 쉬움 (일반적인 API 통신) | 높음 (데이터를 쪼개서 송수신) |
| UX(사용자 경험) | 답답함, 지루함 유발 | 대화의 흐름이 끊기지 않음 |
| 서버 자원 | 상대적으로 적게 소요 | 연결 유지로 자원 소요 큼 |
| 주요 용도 | 단순 데이터 분석, 보고서 생성 | 실시간 챗봇, 음성 대화 에이전트 |
5. 왜 스트리밍이 대세일까? — TTFT(Time To First Token)의 중요성
최근 AI 서비스에서 LLM 스트리밍은 선택이 아니라 필수에 가깝습니다. 그 이유를 설명하는 핵심 지표가 바로 TTFT(Time To First Token), 즉 “질문을 던진 뒤 첫 글자가 화면에 나타나기까지 걸리는 시간”입니다.
TTFT는 사용자가 AI의 성능을 체감하는 데 있어 실제 전체 응답 생성 시간보다 훨씬 중요하게 작용합니다.
- 블로킹 방식은 모델의 추론 속도와 무관하게, 사용자가 전체 대기 시간을 온몸으로 견뎌야 합니다. 답변이 500토큰이든 1000토큰이든 사용자는 그 전체 시간 동안 빈 화면만 바라봐야 하는 것입니다.
- 스트리밍 방식은 모델이 나머지 문장을 계산하는 동안에도 사용자는 이미 앞부분을 읽고 있을 수 있습니다. 실제 총 생성 시간은 블로킹과 동일하더라도, 체감 지연 시간(perceived latency)은 획기적으로 줄어듭니다.
이것이 바로 실시간 챗봇, 음성 대화 에이전트를 구축할 때 스트리밍 구현이 사실상 필수로 여겨지는 이유입니다.
6. 실전 구현: 블로킹(Blocking) 방식 코드
이제 실제로 파이썬에서 두 방식을 어떻게 구현하는지 살펴보겠습니다. 아래 예제는 aiohttp와 OpenAI 호환 API를 제공하는 라마 서버를 기준으로 작성되었습니다.
블로킹 방식은 session.post()의 결과가 완전히 도착할 때까지 기다린 후, 응답 전체를 한 번에 처리하는 구조입니다.
import aiohttp
import json
async def get_response_blocking(message: str):
payload = {
"model": "gemma4",
"messages": [{"role": "user", "content": message}],
"stream": False # 핵심: 스트리밍을 끄고 전체 응답을 기다림
}
async with aiohttp.ClientSession() as session:
# 서버가 응답을 완료할 때까지 대기
async with session.post(
"http://localhost:8080/v1/chat/completions", json=payload
) as response:
result = await response.json()
content = result['choices'][0]['message']['content']
print(f"최종 답변: {content}")
특징: 구현이 매우 단순합니다. 전체 텍스트를 한 번에 받아 변수에 저장하므로, 응답 전체를 후처리하거나 데이터베이스에 한 번에 저장해야 하는 작업에 유리합니다.
7. 실전 구현: 스트리밍(Streaming) 방식 코드
스트리밍 방식은 response.content를 비동기 반복문(async for)으로 순회하며, 서버로부터 데이터 조각(chunk)이 들어오는 즉시 처리하는 구조입니다.
import aiohttp
import json
async def get_response_streaming(message: str):
payload = {
"model": "gemma4",
"messages": [{"role": "user", "content": message}],
"stream": True # 핵심: 스트리밍을 활성화
}
async with aiohttp.ClientSession() as session:
async with session.post(
"http://localhost:8080/v1/chat/completions", json=payload
) as response:
# 스트림 데이터를 행 단위로 읽기
async for line in response.content:
if line:
line_str = line.decode('utf-8').strip()
if line_str.startswith('data: '):
data = line_str[6:]
if data == '[DONE]':
break
chunk = json.loads(data)
delta = chunk['choices'][0].get('delta', {}).get('content', '')
if delta:
# 실시간으로 글자 출력 (end=""로 줄바꿈 없이 출력)
print(delta, end="", flush=True)
특징: async for 문을 통해 데이터가 도착하는 즉시 루프가 실행됩니다. 사용자 화면에는 글자가 한 자씩 실시간으로 타이핑되는 것처럼 보이며, 전체 응답을 기다릴 필요가 없어 체감 지연 시간(Latency)이 0에 가깝게 느껴집니다.
8. 한 단계 더: FastAPI에서 LLM 스트리밍 응답 중계하기
실무에서는 클라이언트(웹 브라우저나 앱)가 라마 서버에 직접 붙지 않고, FastAPI 같은 백엔드 서버를 한 단계 거쳐서 스트리밍 응답을 중계하는 구조가 일반적입니다. 이때는 FastAPI의 StreamingResponse를 활용해 라마 서버로부터 받은 청크를 그대로 클라이언트에 재전송(proxy)하면 됩니다.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import aiohttp
import json
app = FastAPI()
async def stream_generator(message: str):
payload = {
"model": "gemma4",
"messages": [{"role": "user", "content": message}],
"stream": True
}
async with aiohttp.ClientSession() as session:
async with session.post(
"http://localhost:8080/v1/chat/completions", json=payload
) as response:
async for line in response.content:
if line:
line_str = line.decode('utf-8').strip()
if line_str.startswith('data: '):
data = line_str[6:]
if data == '[DONE]':
break
chunk = json.loads(data)
delta = chunk['choices'][0].get('delta', {}).get('content', '')
if delta:
# SSE 형식으로 클라이언트에 재전송
yield f"data: {json.dumps({'text': delta})}\n\n"
@app.post("/chat/stream")
async def chat_stream(message: str):
return StreamingResponse(
stream_generator(message),
media_type="text/event-stream"
)
이렇게 구성하면 프런트엔드는 라마 서버의 존재를 몰라도 되고, FastAPI 서버 하나만 바라보며 SSE 이벤트를 구독하는 방식으로 실시간 챗봇 UI를 구현할 수 있습니다. 인증, 로깅, 사용량 제한 같은 부가 로직도 이 중계 지점에서 자연스럽게 처리할 수 있다는 것이 장점입니다.
9. 실무에서 반드시 확인해야 할 3가지
AI 스트리밍을 도입하기 전에 반드시 아래 세 가지를 점검해야 합니다.
① 서버 부하(Load) 문제
스트리밍은 실시간으로 데이터를 계속 흘려보내야 하므로, 연결을 유지하는 데 필요한 서버 자원(커넥션, 메모리)이 블로킹 방식보다 더 많이 소요될 수 있습니다. 동시 접속자가 많은 서비스라면 워커 프로세스 수와 커넥션 풀 크기를 미리 계산해두는 것이 좋습니다.
② 스트리밍 호환성 테스트
일부 ‘OpenAI 호환 API’를 표방하는 서버들은 실제로는 스트리밍을 온전히 지원하지 않고, 내부적으로 응답을 버퍼링한 뒤 마치 스트리밍인 것처럼 흉내만 내는 경우가 있습니다. 이런 경우 사용자에게는 10초 이상의 지연 시간이 그대로 발생합니다.
애플리케이션을 구축하기 전에, 사용하려는 백엔드가 진짜 실시간 스트리밍을 지원하는지 아래처럼 curl로 간단히 확인해볼 수 있습니다.
curl -N http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma4",
"messages": [{"role": "user", "content": "안녕"}],
"stream": true
}'
-N 옵션은 curl의 버퍼링을 끄는 옵션입니다. 이 명령을 실행했을 때 data: ... 청크가 한 줄씩 시간차를 두고 화면에 출력되면 정상적인 스트리밍이고, 아무 반응 없이 있다가 결과가 한 번에 쏟아진다면 서버가 스트리밍을 제대로 지원하지 않는다는 신호입니다.
③ 타임아웃(Timeout) 설정
스트리밍 연결은 블로킹 방식보다 연결이 열려 있는 시간이 길기 때문에, 네트워크가 중간에 끊기지 않도록 ClientTimeout을 적절히 설정하는 것이 중요합니다.
import aiohttp
# 전체 타임아웃 대신, 연결/응답 사이의 개별 청크 대기 시간을 넉넉하게 설정
timeout = aiohttp.ClientTimeout(total=None, sock_read=60, sock_connect=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
...
total=None으로 전체 타임아웃을 없애고, 청크 사이의 대기 시간(sock_read)만 별도로 설정하면 답변이 길어져도 중간에 연결이 강제로 끊기는 문제를 방지할 수 있습니다.
10. 언제 무엇을 써야 할까? 상황별 선택 방법
- 블로킹 방식이 적합한 경우: API 호출 횟수가 적고, 답변 내용 전체가 한 번에 꼭 필요한 작업입니다. 예를 들어 JSON 포맷을 강제로 변환해 즉시 데이터베이스에 저장해야 하는 배치성 작업, 보고서 자동 생성, 대량 텍스트 분류 같은 단순 데이터 분석 작업에는 굳이 스트리밍을 쓸 필요가 없습니다.
- 스트리밍 방식이 적합한 경우: 사용자와 실시간으로 대화하는 챗봇, 음성 인식 에이전트, 실시간 코딩 어시스턴트 등 반응 속도가 사용자 경험을 좌우하는 모든 서비스에는 스트리밍 구현이 사실상 필수입니다.
11. 자주 묻는 질문(FAQ)
라마 서버(Llama Server) 스트리밍 구현 방법이 어렵나요?
클라이언트 측 구현 자체는 stream: True 옵션을 켜고 async for로 청크를 순회하는 정도이므로 어렵지 않습니다. 다만 서버가 실제로 스트리밍을 지원하는지 사전에 확인하는 절차가 반드시 필요합니다.
FastAPI LLM 스트리밍은 어떻게 프런트엔드에 연결하나요?
FastAPI의 StreamingResponse로 SSE 형식의 응답을 만들고, 브라우저 쪽에서는 EventSource API나 fetch + ReadableStream으로 이 이벤트를 구독하면 됩니다.
AI 챗봇 응답이 느린 이유는 무엇인가요?
블로킹 방식으로 구현되어 있거나, 스트리밍을 표방하지만 실제로는 서버 내부에서 버퍼링이 걸려 있는 경우가 대부분입니다. 위에서 소개한 curl -N 테스트로 원인을 먼저 확인해보는 것을 권장합니다.
블로킹과 스트리밍은 단순한 구현 방식의 차이가 아니라, 사용자가 AI 서비스를 얼마나 쾌적하게 느끼는지를 좌우하는 핵심 요소입니다. 라마 서버 기반의 LLM 애플리케이션을 구축한다면, 단순 배치 작업이 아닌 이상 스트리밍 방식을 기본으로 채택하고, TTFT를 낮추는 방향으로 아키텍처를 설계하는 것이 좋습니다. 다만 스트리밍을 도입하기 전에는 반드시 서버의 실제 스트리밍 지원 여부를 curl -N으로 검증하고, 타임아웃과 서버 부하까지 함께 고려한 뒤 적용하시길 권합니다.