이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 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 저장 정책이 필요한 이유는 무엇인가?
    • 연결 끊김이나 중단 상황에서 깨진 답변이 성공 결과처럼 남을 수 있기 때문이다.

참고 문서