이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- streaming은 언제 쓰고 언제 queue 기반 비동기로 돌려야 하는가?
- streaming 중 연결이 끊기면 서버는 무엇을 결정해야 하는가?
- 긴 AI 작업을 job으로 분리할 때 API 계약은 어떻게 잡는가?
- worker에는 어떤 idempotency와 상태 관리가 필요한가?
개요
LLM 응답은 길고 느릴 수 있다. streaming은 사용자가 첫 token을 빨리 보게 해 체감 대기 시간을 줄인다. queue 기반 비동기 처리는 긴 작업을 API 서버에서 분리해 안정성을 높인다.
둘은 목적이 다르다. streaming은 실시간 UX에 가깝고, queue는 운영 안정성과 작업 관리에 가깝다.
왜 백엔드 개발자에게 중요한가
동기 HTTP 요청으로 긴 모델 호출을 모두 처리하면 API 서버가 쉽게 지친다. 사용자가 브라우저를 닫아도 서버는 모델 응답을 기다릴 수 있고, 같은 요청이 재시도되며 중복 비용을 만들 수 있다.
백엔드는 다음을 기능별로 판단해야 한다.
- 사용자가 생성 과정을 실시간으로 봐야 하는가?
- 결과가 반드시 저장되어야 하는가?
- 작업이 10초 안에 끝날 가능성이 높은가?
- 실패 후 재시도할 수 있는가?
- 같은 요청이 중복 제출되어도 안전한가?
Streaming 설계
streaming은 채팅, 긴 답변 생성, 실시간 코드 생성처럼 사용자가 진행 상황을 보는 기능에 적합하다. 그러나 다음 정책이 필요하다.
- 클라이언트 연결이 끊기면 provider 호출을 취소할 것인가?
- partial output을 DB에 저장할 것인가?
- 중간까지 생성된 답변을 사용자에게 다시 보여줄 것인가?
- moderation이나 validation은 streaming 전, 중, 후 어디서 할 것인가?
- token usage는 stream 종료 후 어떻게 기록할 것인가?
streaming은 “빠르게 보이는 UX”를 제공하지만, output이 완성되기 전에는 schema validation이 어렵다. JSON이나 tool call 결과처럼 완전한 구조가 필요한 작업은 streaming과 궁합이 좋지 않을 수 있다.
Queue 기반 비동기 처리
다음 작업은 queue로 분리하는 편이 좋다.
- 대량 문서 요약
- 긴 RAG 색인
- 다수 파일 OCR과 embedding
- eval batch
- 리포트 생성
- agent가 여러 tool을 호출하는 업무 자동화
API는 작업을 접수하고 job id를 반환한다.
POST /ai/jobs/summaries
202 Accepted
{
"jobId": "job_123",
"status": "queued"
}클라이언트는 상태를 조회한다.
GET /ai/jobs/job_123
200 OK
{
"jobId": "job_123",
"status": "completed",
"resultUrl": "/ai/jobs/job_123/result"
}코드로 이해하기
Spring Boot API 서버는 job을 저장하고 queue에 메시지를 넣는다.
@RestController
@RequestMapping("/ai/jobs/summaries")
public class AiSummaryJobController {
private final AiJobService aiJobService;
public AiSummaryJobController(AiJobService aiJobService) {
this.aiJobService = aiJobService;
}
@PostMapping
public ResponseEntity<AiJobResponse> create(@RequestBody SummaryJobRequest request) {
AiJob job = aiJobService.enqueueSummaryJob(request.documentId());
return ResponseEntity.accepted().body(
new AiJobResponse(job.id(), job.status().name())
);
}
}worker는 idempotency key를 기준으로 중복 실행을 막는다.
from dataclasses import dataclass
@dataclass(frozen=True)
class SummaryJob:
job_id: str
document_id: str
idempotency_key: str
class JobStore:
def mark_running_once(self, job_id: str, idempotency_key: str) -> bool:
raise NotImplementedError
def complete(self, job_id: str, result: str) -> None:
raise NotImplementedError
def fail(self, job_id: str, reason: str) -> None:
raise NotImplementedError
def handle_summary_job(job: SummaryJob, store: JobStore, llm_client) -> None:
locked = store.mark_running_once(job.job_id, job.idempotency_key)
if not locked:
return
try:
result = llm_client.summarize(document_id=job.document_id)
store.complete(job.job_id, result)
except TimeoutError:
store.fail(job.job_id, "llm_timeout")
raise핵심은 queue가 at-least-once delivery일 수 있다는 점이다. worker는 같은 메시지를 두 번 받을 수 있다고 보고 idempotency를 가져야 한다.
장애 상황과 대응
streaming 장애:
- 클라이언트 disconnect가 증가한다.
- partial output이 저장되어 사용자에게 깨진 답변이 보인다.
- stream 종료 후 usage 기록이 누락된다.
- 중간 moderation 실패를 처리하지 못한다.
queue 장애:
- queue depth가 계속 증가한다.
- DLQ가 쌓인다.
- 같은 job이 중복 실행된다.
- worker concurrency가 provider rate limit을 초과한다.
- batch indexing이 online 요청을 느리게 만든다.
대응은 상태 전이를 명확히 하는 것이다. queued, running, completed, failed, cancelled, expired 같은 상태를 두고 전이를 로그로 남긴다.
인프라 협업 포인트
인프라팀과는 다음을 합의한다.
- queue depth와 worker concurrency 알림 기준
- DLQ 처리 절차
- job retention 기간
- worker autoscaling 기준
- provider rate limit과 worker 수의 관계
- streaming connection timeout과 load balancer 설정
streaming은 load balancer와 gateway timeout 영향을 받는다. API 코드만 바꿔서는 충분하지 않다.
개인 프로젝트 최소 기준
- 15초 이상 걸릴 수 있는 작업은 job으로 분리한다.
- job status를 저장한다.
- worker 중복 실행을 막는 key를 둔다.
- 실패 이유를 사용자에게 짧게 보여준다.
기업 운영 수준 기준
- queue, worker, provider rate limit을 함께 모니터링한다.
- DLQ와 재처리 절차를 둔다.
- streaming gateway timeout을 인프라팀과 검증한다.
- job id와 trace id를 연결한다.
- batch와 online workload를 분리한다.
실전 팁
- streaming은 완성된 JSON이 필요한 작업보다 자유 텍스트 답변에 먼저 적용한다.
- 작업 접수 API는 가능하면 idempotency key를 받는다.
- queue retry는 provider retry와 중복되지 않게 설계한다.
- 사용자가 취소한 job을 worker가 계속 실행하지 않도록 cancellation check를 둔다.
위험 신호!
- 긴 AI 작업을 모두 동기 Controller에서 처리한다.
- queue worker에 idempotency가 없다.
- streaming partial output을 성공 결과로 저장한다.
- DLQ가 있지만 아무도 보지 않는다.
- batch indexing과 실시간 답변이 같은 worker pool을 쓴다.
확인 질문
확인 질문
- streaming과 queue 기반 비동기 처리는 목적이 어떻게 다른가?
- streaming은 실시간 UX 개선이고, queue는 긴 작업의 안정적 처리와 서버 리소스 보호다.
- queue worker가 idempotency를 가져야 하는 이유는 무엇인가?
- queue 메시지가 중복 전달될 수 있고, retry 중 같은 작업이 두 번 실행될 수 있기 때문이다.
- streaming 중 partial output 저장 정책이 필요한 이유는 무엇인가?
- 연결 끊김이나 중단 상황에서 깨진 답변이 성공 결과처럼 남을 수 있기 때문이다.