[카테고리:] Python Backend

  • AI Agent & MCP 2부 — Python으로 파일 분석용 MCP Tool 직접 만들어보기

    AI Agent & MCP 2부 — Python으로 파일 분석용 MCP Tool 직접 만들어보기

    Agent가 사용할 수 있는 백엔드 기능을 MCP Server로 제공하기

    1부에서는 파일 업로드 이후 모든 처리를 하나의 Worker가 담당하는 구조에서 출발했다.

    File Upload
        ↓
    Worker
        ↓
    Parse
        ↓
    Analyze
        ↓
    Save
    

    파일 종류와 업무 규칙이 다양해지면 Worker 내부의 판단 로직도 늘어난다. 그래서 이번에는 판단과 실행을 분리한다.

    File Upload
         ↓
    Event
         ↓
    Agent Worker
         ↓
    Orchestrator
         ↓
    MCP
         ↓
    Tools
    

    Agent는 현재 상황을 보고 무엇을 해야 할지 판단하고, 실제 작업은 Tool이 수행한다.


    1. MCP는 비즈니스 로직이 아니다

    MCP Server 안에 S3 접속, DB 조회, PDF Parsing, LLM 호출, 저장 로직을 모두 넣어버리면 MCP Tool이 새로운 비즈니스 로직 계층이 된다.

    기존 백엔드가 다음 구조라면 그대로 활용하는 편이 낫다.

    API
     ↓
    Service
     ↓
    Repository
     ↓
    Database
    

    MCP는 새로운 접근 경로를 추가한다.

                 FastAPI
                    │
                    ↓
                 Service
                    ↑
                    │
                 MCP Tool
                    ↑
                    │
                  Agent
    

    즉 FastAPI와 MCP Tool이 동일한 Service Layer를 사용할 수 있다.

    2. 예제 프로젝트 구조

    document-agent/
    │
    ├── mcp_server.py
    ├── services/
    │   ├── document_service.py
    │   ├── analysis_service.py
    │   └── storage_service.py
    ├── repositories/
    │   └── document_repository.py
    └── models/
        └── document.py
    

    mcp_server.py는 Tool 정의, services/는 업무 로직, repositories/는 Database 접근을 담당한다.

    3. 가장 작은 MCP Server

    from mcp.server.fastmcp import FastMCP
    
    mcp = FastMCP("document-tools")
    
    @mcp.tool()
    def hello(name: str) -> str:
        return f"Hello {name}"
    

    Agent에게 중요한 것은 함수 내부 구현보다 이 Tool이 무엇을 하는가다.

    4. 첫 번째 Tool: get_document

    class DocumentService:
        def get_document(self, document_id: str):
            return {
                "document_id": document_id,
                "filename": "invoice_2026.pdf",
                "content_type": "application/pdf",
                "storage_path": f"documents/{document_id}/invoice_2026.pdf",
                "status": "UPLOADED",
            }
    
    from mcp.server.fastmcp import FastMCP
    from services.document_service import DocumentService
    
    mcp = FastMCP("document-tools")
    document_service = DocumentService()
    
    @mcp.tool()
    def get_document(document_id: str) -> dict:
        """
        Retrieve metadata for an uploaded document.
    
        Use this tool when you need information about a document
        before deciding how the document should be processed.
        """
        return document_service.get_document(document_id)
    

    Agent Tool에서는 Docstring이 단순 개발 문서가 아니다. Tool 설명은 Agent의 Tool 선택에 영향을 준다.

    5. Tool 이름과 Description은 중요하다

    process 같은 이름보다 extract_invoice처럼 행동을 명확하게 드러내는 이름이 좋다.

    extract_invoice
    
    Extract structured invoice fields including
    invoice number, vendor, date, amount and currency
    from an uploaded invoice document.
    

    Agent가 이름과 설명을 보고 언제 이 Tool을 사용할지 판단할 수 있어야 한다.

    6. 두 번째 Tool: extract_document_content

    class DocumentService:
        def extract_content(self, document_id: str):
            document = self.get_document(document_id)
            content_type = document["content_type"]
    
            if content_type == "application/pdf":
                return self._extract_pdf(document)
    
            if content_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
                return self._extract_excel(document)
    
            raise ValueError(f"Unsupported content type: {content_type}")
    
    @mcp.tool()
    def extract_document_content(document_id: str) -> dict:
        """
        Extract machine-readable content from an uploaded document.
        """
        return document_service.extract_content(document_id)
    
    Agent
      ↓
    extract_document_content
      ↓
    DocumentService
      ↓
    PDF Parser
    

    7. Tool을 너무 작게 나누지 않는다

    download_file, open_pdf, read_page, normalize_text 같은 내부 함수를 전부 Tool로 만들면 Agent의 의사결정 공간만 커진다.

    MCP Tool의 단위는 내부 구현 단계가 아니라 Agent가 선택할 가치가 있는 행동의 단위여야 한다.

    8. 세 번째 Tool: analyze_document

    class AnalysisService:
        def analyze(self, document_id: str, text: str):
            return {
                "document_id": document_id,
                "document_type": "invoice",
                "summary": "Software service invoice",
                "confidence": 0.94,
            }
    
    analysis_service = AnalysisService()
    
    @mcp.tool()
    def analyze_document(document_id: str, text: str) -> dict:
        """
        Analyze extracted document content and return
        structured analysis information including confidence.
        """
        return analysis_service.analyze(
            document_id=document_id,
            text=text,
        )
    

    9. 네 번째 Tool: save_analysis

    @mcp.tool()
    def save_analysis(document_id: str, analysis: dict) -> dict:
        """
        Persist a validated document analysis result.
    
        Use this tool only when the analysis result
        is considered valid and ready to be stored.
        """
        return analysis_service.save_analysis(
            document_id=document_id,
            analysis=analysis,
        )
    

    Description에는 무엇을 하는지만 아니라 언제 사용해야 하는지도 적어주는 것이 좋다.

    10. Agent에게 네 가지 능력이 생겼다

    get_document
    extract_document_content
    analyze_document
    save_analysis
    

    Agent 코드에는 PDF Parser나 Database 코드가 없다. Agent는 Tool을 선택할 뿐이다.

    11. MCP Server 전체 형태

    from mcp.server.fastmcp import FastMCP
    from services.document_service import DocumentService
    from services.analysis_service import AnalysisService
    
    mcp = FastMCP("document-tools")
    document_service = DocumentService()
    analysis_service = AnalysisService()
    
    @mcp.tool()
    def get_document(document_id: str) -> dict:
        """Retrieve metadata for an uploaded document."""
        return document_service.get_document(document_id)
    
    @mcp.tool()
    def extract_document_content(document_id: str) -> dict:
        """Extract machine-readable content from an uploaded document."""
        return document_service.extract_content(document_id)
    
    @mcp.tool()
    def analyze_document(document_id: str, text: str) -> dict:
        """Analyze document content and return structured information."""
        return analysis_service.analyze(document_id=document_id, text=text)
    
    @mcp.tool()
    def save_analysis(document_id: str, analysis: dict) -> dict:
        """Persist a validated document analysis result."""
        return analysis_service.save_analysis(
            document_id=document_id,
            analysis=analysis,
        )
    
    if __name__ == "__main__":
        mcp.run()
    

    MCP Server 자체가 비즈니스 로직의 중심일 필요는 없다. 기존 기능을 Agent가 사용할 수 있도록 인터페이스를 제공하면 충분하다.

    12. Agent는 Tool을 어떻게 발견할까?

    Tools
    
    1. get_document
       input: document_id
    
    2. extract_document_content
       input: document_id
    
    3. analyze_document
       input: document_id, text
    
    4. save_analysis
       input: document_id, analysis
    

    Agent는 현재 상태와 Tool 목록을 보고 다음 행동을 선택한다.

    get_document
          ↓
    extract_document_content
          ↓
    analyze_document
          ↓
    save_analysis
    

    13. 일반적인 함수 호출과 무엇이 다른가

    일반 Python 프로그램에서는 개발자가 호출 순서를 작성한다.

    document = get_document(document_id)
    content = extract_document_content(document_id)
    analysis = analyze_document(document_id, content["text"])
    save_analysis(document_id, analysis)
    

    Agent 구조에서는 다음과 같다.

    Current State
         +
    Available Tools
         +
    LLM
         ↓
    Next Tool
    

    Tool은 같지만 누가 실행 순서를 결정하는지가 달라진다.

    14. Tool 입력은 명확해야 한다

    @mcp.tool()
    def analyze_document(
        document_id: str,
        text: str,
    ) -> dict:
        ...
    

    필요하면 Pydantic 모델로 구조를 명확하게 만들 수 있다.

    from pydantic import BaseModel
    
    class DocumentAnalysisInput(BaseModel):
        document_id: str
        text: str
    
    class DocumentAnalysisResult(BaseModel):
        document_id: str
        document_type: str
        summary: str
        confidence: float
    

    15. 반환값도 Agent가 이해하기 쉬워야 한다

    {
      "success": true,
      "document_id": "doc_123",
      "status": "SAVED"
    }
    
    {
      "type": "invoice",
      "confidence": 0.94,
      "requires_review": false
    }
    

    Tool의 Output은 다음 Agent Decision의 입력이기도 하다.

    16. 오류도 Tool의 결과다

    PDF에서 텍스트를 찾지 못한 것은 스캔 PDF라는 업무 상태일 수 있다.

    {
      "success": false,
      "reason": "NO_TEXT_FOUND",
      "suggested_action": "OCR"
    }
    
    extract_document_content
            ↓
    NO_TEXT_FOUND
            ↓
          Agent
            ↓
         run_ocr
    

    업무적으로 복구 가능한 상태와 시스템 오류를 구분하는 것이 중요하다.

    17. OCR Tool을 추가해보자

    @mcp.tool()
    def run_ocr(document_id: str) -> dict:
        """
        Extract text from a scanned document using OCR.
    
        Use this tool when normal document text extraction fails.
        """
        return document_service.run_ocr(document_id)
    
    get_document
          ↓
    extract_document_content
          ↓
    NO_TEXT_FOUND
          ↓
    run_ocr
          ↓
    analyze_document
          ↓
    save_analysis
    

    18. Human Review Tool

    @mcp.tool()
    def request_human_review(document_id: str, reason: str) -> dict:
        """
        Request human review when analysis cannot be completed
        with sufficient confidence.
        """
        return {
            "success": True,
            "document_id": document_id,
            "status": "WAITING_HUMAN_REVIEW",
            "reason": reason,
        }
    

    AI가 처리할 수 있는 것은 자동화하고 판단하기 어려운 것은 사람에게 넘길 수 있다.

    19. 저장 Tool에는 특별한 문제가 있다

    get_document는 두 번 호출해도 큰 문제가 없지만 save_analysis를 두 번 호출하면 중복 데이터가 생길 수 있다. ERP 등록, 이메일 전송, 결제 승인 같은 Tool은 더 위험하다.

    20. Idempotency를 생각해야 한다

    같은 요청을 여러 번 실행해도 결과가 한 번 실행한 것과 같도록 만들어야 한다.

    def save_analysis(document_id: str, analysis: dict, analysis_version: str):
        existing = repository.find_analysis(
            document_id=document_id,
            analysis_version=analysis_version,
        )
    
        if existing:
            return {
                "success": True,
                "already_exists": True,
                "analysis_id": existing.id,
            }
    
        result = repository.create_analysis(
            document_id=document_id,
            analysis=analysis,
            analysis_version=analysis_version,
        )
    
        return {
            "success": True,
            "already_exists": False,
            "analysis_id": result.id,
        }
    

    21. 읽는 Tool과 변경하는 Tool을 구분한다

    Read Tool
    - get_document
    - search_documents
    - get_analysis
    
    Write Tool
    - save_analysis
    - update_document
    - request_human_review
    - send_email
    - create_erp_record
    

    Write Tool에는 Validation, Idempotency, Authorization, Audit Log 같은 추가 통제가 필요하다.

    22. Agent가 Tool을 선택한다고 권한까지 갖는 것은 아니다

    Agent
      ↓
    Tool Request
      ↓
    Policy / Authorization
      ↓
    Tool Execution
    

    Agent가 Tool을 선택할 권한과 실제 시스템을 변경할 권한은 분리해서 생각해야 한다.

    23. Tool 실행 기록을 남긴다

    run_abc123
    
    10:00:01 get_document               SUCCESS
    10:00:02 extract_document_content   FAILED
    10:00:03 run_ocr                    SUCCESS
    10:00:08 analyze_document           SUCCESS
    10:00:11 save_analysis              SUCCESS
    

    run_id, document_id, tool_name, input, output, status, error, duration_ms 등을 기록하면 Agent 실행을 추적할 수 있다.

    24. Tool이 많아지면 MCP Server를 어떻게 나눌까?

                    Agent
                      │
               ┌──────┼──────┐
               ↓      ↓      ↓
           File MCP  AI MCP  Business MCP
    

    처음부터 지나치게 나누면 Deployment, Authentication, Network, Monitoring, Versioning 같은 운영 부담이 늘어난다. 처음에는 단순하게 시작하는 편이 현실적이다.

    25. 우리가 만든 구조를 다시 보자

                         Agent
                           │
                           ↓
                     Orchestrator
                           │
                           ↓
                      MCP Client
                           │
                           ↓
                      MCP Server
                           │
              ┌────────────┼────────────┐
              ↓            ↓            ↓
        Document Tool  Analysis Tool  Business Tool
              │            │            │
              └────────────┼────────────┘
                           ↓
                     Service Layer
                           │
              ┌────────────┼────────────┐
              ↓            ↓            ↓
           Storage        LLM        Database
    

    파일 업로드 이벤트까지 포함하면 다음과 같다.

    User
     │
     │ Upload
     ↓
    FastAPI
     │
     ├──────────────→ Object Storage
     │
     └── Event
           ↓
     Redis Streams / Queue
           ↓
      Agent Worker
           ↓
      Orchestrator
           ↓
       MCP Client
           ↓
       MCP Server
           │
           ├── get_document
           ├── extract_document_content
           ├── run_ocr
           ├── analyze_document
           ├── save_analysis
           └── request_human_review
                   ↓
              Service Layer
                   │
          ┌────────┼────────┐
          ↓        ↓        ↓
       Storage    LLM    Database
    

    기존 FastAPI, Queue, Service Layer, Database, Object Storage는 그대로 사용할 수 있다. 새롭게 추가되는 핵심은 Orchestrator와 MCP Interface다.

    26. 기존 Worker 방식과 비교해보자

    기존 Worker에서는 실행 순서가 코드에 들어 있다.

    def process_document(document_id):
        document = get_document(document_id)
        content = extract_document_content(document_id)
    
        if not content["text"]:
            content = run_ocr(document_id)
    
        analysis = analyze_document(
            document_id,
            content["text"],
        )
    
        if analysis["confidence"] < 0.8:
            request_human_review(document_id)
            return
    
        save_analysis(document_id, analysis)
    

    업무 규칙이 명확하고 실행 순서가 항상 동일하다면 이런 방식이 더 좋다.

    Agent 구조에서는 개발자가 실행 순서를 모두 작성하는 대신 Agent가 사용할 수 있는 Tool을 정의한다.

    Developer
        ↓
    Tool Definition
        ↓
    Agent
        ↓
    Runtime Decision
    

    같은 Tool을 가지고도 상황에 따라 실행 경로가 달라질 수 있다.

    일반 PDF
    
    get_document
          ↓
    extract_document_content
          ↓
    analyze_document
          ↓
    save_analysis
    
    스캔 PDF
    
    get_document
          ↓
    extract_document_content
          ↓
    NO_TEXT_FOUND
          ↓
    run_ocr
          ↓
    analyze_document
          ↓
    save_analysis
    
    낮은 신뢰도
    
    get_document
          ↓
    extract_document_content
          ↓
    analyze_document
          ↓
    LOW_CONFIDENCE
          ↓
    request_human_review
    

    27. 그렇다고 모든 판단을 LLM에게 맡기지는 않는다

    다음 조건은 Agent에게 물어볼 필요가 없다.

    if file_size > MAX_FILE_SIZE:
        reject_file()
    
    if retry_count >= 3:
        fail_job()
    
    if user_id != document.owner_id:
        raise PermissionError()
    

    시스템에는 크게 두 종류의 판단이 있다.

    Deterministic Decision
            ↓
           Code
    
    Semantic / Contextual Decision
            ↓
           Agent
    

    파일 크기 제한, 권한, Retry 횟수는 코드가 판단한다. 반면 문서 유형, OCR 필요 여부, 적절한 분석 방법, 추가 분석 필요 여부 같은 문제에는 Agent가 개입할 수 있다.

    코드가 잘하는 판단과 LLM이 잘하는 판단을 구분해야 한다.

    28. Agent와 Workflow를 섞는 것이 현실적이다

                     File Upload
                          ↓
                   Fixed Workflow
                          ↓
                 Security Validation
                          ↓
                     Store File
                          ↓
                    Create Record
                          ↓
                     Agent Start
                          ↓
                    Orchestrator
                          │
                ┌─────────┼─────────┐
                ↓         ↓         ↓
              Parser     OCR      Vision
                │         │         │
                └─────────┼─────────┘
                          ↓
                       Analyze
                          ↓
                      Validation
                          ↓
                ┌─────────┴─────────┐
                ↓                   ↓
              Save             Human Review
    

    항상 실행되어야 하는 앞부분은 Deterministic Workflow로 두고, 처리 방법을 선택해야 하는 영역에서 Agent를 사용한다. 중요한 비즈니스 규칙은 다시 코드나 Policy가 통제할 수 있다.

    29. MCP가 여기서 해결하는 문제는 무엇인가?

    Agent와 Tool이 하나의 Python 프로그램 안에 있고 앞으로도 분리할 필요가 없다면 일반적인 Function Calling으로 충분할 수도 있다.

    MCP를 사용한다고 Agent가 갑자기 더 똑똑해지는 것은 아니다. MCP의 중요한 장점은 Agent와 Tool 구현을 분리하는 것이다.

    Agent A ──────┐
                  │
    Agent B ──────┼──→ MCP Server
                  │
    Agent C ──────┘
    

    Tool을 제공하는 쪽은 Agent 프레임워크를 알 필요가 줄어들고, Agent는 Tool 내부 구현을 알 필요가 없다.

    MCP는 Agent와 Capability 사이의 인터페이스를 분리한다.

    30. REST API와는 무엇이 다른가?

    실제 업무 기능은 REST API로도 충분히 제공할 수 있다.

    GET  /documents/{id}
    POST /documents/{id}/extract
    POST /documents/{id}/analyze
    POST /documents/{id}/save
    

    일반적인 API에서는 개발자가 문서를 읽고 Client Code를 작성한다. Agent 환경에서는 Agent가 사용할 수 있는 Tool을 확인하고, 설명과 Schema를 이해하고, 현재 상황에 맞는 Tool을 선택해야 한다.

    Agent
      ↓
    Discover Tools
      ↓
    Understand Description
      ↓
    Understand Schema
      ↓
    Select Tool
      ↓
    Call Tool
    

    따라서 MCP를 REST를 대체하는 기술이라기보다 Agent가 외부 Capability를 발견하고 사용할 수 있도록 연결하는 Protocol로 이해하는 편이 자연스럽다.

    MCP Tool 내부에서 기존 REST API를 호출하는 구조도 가능하다.

    Agent
      ↓
    MCP
      ↓
    Tool
      ↓
    REST API
      ↓
    Existing Backend
    

    31. 기존 시스템에 MCP를 추가한다면

    FastAPI
       ↓
    Service Layer
       ↓
    Repository
       ↓
    MySQL
    
       +
    Object Storage
    
       +
    Redis Streams
    

    전체 시스템을 다시 만들 필요는 없다.

                     Existing Backend
    
    FastAPI ──────→ Service Layer ──────→ Database
                        ↑
                        │
                     MCP Tool
                        ↑
                        │
                    MCP Server
                        ↑
                        │
                      Agent
    

    기존 Redis Streams Worker 중 일부가 Agent 실행을 담당하게 할 수도 있다.

    FastAPI
       ↓
    Redis XADD
       ↓
    Agent Worker
       ↓
    Orchestrator
       ↓
    MCP
       ↓
    Existing Service
    

    기존 Event Driven Architecture를 유지하면서 필요한 부분에만 Agent를 도입할 수 있다.

    32. 이번 글에서 만든 MCP Tool 정리

    • get_document: 문서 기본 정보 조회
    • extract_document_content: 분석 가능한 내용 추출
    • run_ocr: 스캔 문서 OCR
    • analyze_document: 문서 분석
    • save_analysis: 검증된 결과 저장
    • request_human_review: 사람에게 검토 요청
                      get_document
                           ↓
                extract_document_content
                           ↓
                    ┌──────┴──────┐
                    ↓             ↓
                  TEXT       NO_TEXT_FOUND
                    │             ↓
                    │          run_ocr
                    │             │
                    └──────┬──────┘
                           ↓
                    analyze_document
                           ↓
                   ┌───────┴───────┐
                   ↓               ↓
            High Confidence   Low Confidence
                   ↓               ↓
            save_analysis   request_human_review
    

    33. 하지만 아직 해결하지 않은 것이 있다

    Tool을 만드는 것보다 어려운 문제는 Agent Runtime과 Orchestration이다.

    Agent가 같은 Tool을 반복 호출하면?
    잘못된 Tool을 선택하면?
    무한 Loop에 빠지면?
    Tool 실행 중 오류가 발생하면?
    LLM 호출 자체가 실패하면?
    Agent 실행 도중 서버가 재시작되면?
    Human Review가 하루 뒤 완료되면?
    Agent는 어디서부터 다시 시작해야 할까?
    

    34. Tool을 만드는 것보다 Tool을 통제하는 것이 더 어렵다

    Step 1
    extract_document_content
            ↓
    FAILED
    
    Step 2
    run_ocr
            ↓
    FAILED
    
    Step 3
    extract_document_content
            ↓
    FAILED
    

    실제 Agent Runtime에는 다음과 같은 제어 장치가 필요하다.

    Max Steps
    Retry Limit
    Timeout
    Loop Detection
    Tool Permission
    Execution State
    Checkpoint
    Audit Log
    

    Agent가 어디까지 행동할 수 있는지를 시스템이 통제해야 한다.

    35. Agent의 실행 상태는 어디에 저장할까?

    Human Review가 몇 시간 뒤에 끝날 수도 있다. Worker를 계속 실행한 채 기다리는 것은 좋은 방법이 아니다.

    Agent
      ↓
    request_human_review
      ↓
    Save State
      ↓
    WAITING_HUMAN
      ↓
    Worker 종료
    

    사람이 검토를 완료하면 다시 실행한다.

    Human Approve
          ↓
    Event
          ↓
    Agent Worker
          ↓
    Load State
          ↓
    Resume
    

    Agent는 자신의 현재 상태를 저장하고 종료한 뒤 나중에 복원할 수 있어야 한다.

    36. Agent의 상태에는 무엇이 들어갈까?

    {
      "run_id": "run_123",
      "document_id": "doc_123",
      "status": "WAITING_HUMAN",
      "step": 5,
      "last_tool": "request_human_review",
      "document_type": "invoice",
      "confidence": 0.58,
      "retry_count": 1
    }
    

    Tool 실행 이력도 함께 저장할 수 있다.

    Step 1  get_document               SUCCESS
    Step 2  extract_document_content   SUCCESS
    Step 3  analyze_document           SUCCESS
    Step 4  validate_invoice           FAILED
    Step 5  request_human_review       WAITING
    

    37. MCP Server는 상태를 가져야 할까?

    가능하면 Agent 실행 상태와 Tool 실행 상태를 구분하는 편이 좋다.

                  Agent Runtime
                        │
                  Execution State
                        │
                        ↓
                    MCP Client
                        │
                        ↓
                    MCP Server
                        │
                        ↓
                       Tool
                        │
                        ↓
                  Business Service
    

    MCP Server는 Tool 제공과 실행에 집중하고, Agent Runtime은 Step, Retry Count, Human Review Status, Execution History 같은 실행 상태를 관리한다.

    38. 결국 MCP는 Orchestrator가 아니다

    Agent / Orchestrator
            ↓
    무엇을 할지 결정
    
    MCP
            ↓
    Tool을 연결
    
    Tool
            ↓
    실제 작업 수행
    

    MCP가 Workflow를 결정하는 것은 아니다. 그 판단은 Agent 또는 Orchestrator의 책임이다.

    39. 이번 예제에서 가장 중요한 코드

    @mcp.tool()
    def extract_document_content(document_id: str) -> dict:
        """
        Extract machine-readable content from an uploaded document.
        """
        return document_service.extract_content(document_id)
    

    중요한 것은 코드의 복잡성이 아니다. 기존 document_service.extract_content() 기능을 Agent가 사용할 수 있는 Capability로 바꿨다는 점이다.

    40. MCP를 도입할 때 먼저 해야 할 일

    현재 시스템의 기능을 먼저 나열한다.

    파일 조회
    파일 다운로드
    PDF Parsing
    OCR
    Excel Parsing
    Document Classification
    Invoice Extraction
    Validation
    Database 저장
    Human Review 요청
    Notification
    ERP 등록
    

    그리고 각각에 대해 묻는다.

    이 기능을 Agent가 상황에 따라 선택할 필요가 있는가?

    이 과정을 거치면 자연스럽게 MCP Tool 후보가 나온다.

    Existing Functions
            ↓
    Capability Analysis
            ↓
    Agent Decision Boundary
            ↓
    MCP Tools
    

    MCP 설계는 코드 작성보다 Capability 설계가 먼저다.

    41. Tool의 수는 많다고 좋은 것이 아니다

    Tool이 너무 많으면 Agent의 선택 공간이 커지고 Description도 서로 비슷해질 수 있다.

    따라서

    How many functions can I expose?
    
    What decisions should the Agent make?
    

    를 먼저 생각하는 것이 좋다.

    42. 2부에서 만든 전체 구조

                        User
                         │
                      Upload
                         ↓
                      FastAPI
                         │
                    Redis Stream
                         ↓
                    Agent Worker
                         ↓
                    Agent Runtime
                         ↓
                    Orchestrator
                         │
                  ┌──────┴──────┐
                  │             │
              LLM Decision      State
                  │             │
                  └──────┬──────┘
                         ↓
                     MCP Client
                         ↓
                     MCP Server
                         ↓
              ┌──────────┼──────────┐
              ↓          ↓          ↓
           Parser       OCR      Analyzer
              │          │          │
              └──────────┼──────────┘
                         ↓
                    Service Layer
                         ↓
           ┌─────────────┼─────────────┐
           ↓             ↓             ↓
        Storage         LLM         Database
    

    Tool Result는 다시 Agent Runtime으로 돌아가 다음 Decision의 입력이 된다. 종료 조건을 만족하면 COMPLETED, 실패하면 FAILED, 사람의 판단이 필요하면 WAITING_HUMAN 상태가 된다.


    마치며

    이번 글에서는 Python으로 간단한 MCP Server를 만들고 기존 백엔드 기능을 MCP Tool로 제공하는 방법을 살펴봤다.

    중요한 것은 MCP 코드 자체보다 다음 설계 문제다.

    어떤 기능을 Tool로 만들 것인가?
    Tool의 크기는 어느 정도가 적절한가?
    Input / Output을 어떻게 정의할 것인가?
    오류를 Agent에게 어떻게 전달할 것인가?
    Write Tool의 중복 실행을 어떻게 막을 것인가?
    Agent가 Tool을 선택하는 것과 실행 권한을 어떻게 분리할 것인가?
    Tool 실행을 어떻게 추적할 것인가?
    

    기존 구조가 다음과 같다면

    FastAPI
       ↓
    Service
       ↓
    Repository
       ↓
    Database
    

    새로운 접근 경로를 하나 추가할 수 있다.

    FastAPI ──────────┐
                      ↓
                   Service
                      ↑
                      │
                   MCP Tool
                      ↑
                      │
                    Agent
    

    기존 비즈니스 로직은 그대로 두고 Agent가 사용할 수 있는 Capability를 제공하는 것이다.

    MCP Tool을 만드는 것은 생각보다 간단하다. 실제 Agent 시스템의 난이도는 Tool을 만드는 데 있는 것이 아니라 Tool을 사용하는 Agent를 어떻게 통제하고 복구할 것인가에서 시작된다.


    다음 글

    3부 — MCP Tool을 Agent에게 맡기면 정말 더 좋은가?

    Agent Loop, 상태 관리, Retry 그리고 Human-in-the-Loop

    다음 편에서는 MCP Server가 아니라 Orchestrator 내부로 들어간다.

            ┌──────────────────┐
            │                  │
            ↓                  │
       LLM Decision            │
            ↓                  │
         Tool Call             │
            ↓                  │
        Tool Result ───────────┘
    

    다음 문제들을 다룰 예정이다.

    State
    Max Steps
    Retry
    Timeout
    Loop Detection
    Checkpoint
    Idempotency
    Human-in-the-Loop
    Pause / Resume
    Observability
    

    특히 다음 상황을 하나의 예제로 살펴본다.

    Invoice Upload
          ↓
    Agent Start
          ↓
    Extract
          ↓
    Analyze
          ↓
    Confidence = 0.58
          ↓
    Human Review
          ↓
    Agent Pause
    
            ... 5시간 후 ...
    
    Human Approve
          ↓
    Agent Resume
          ↓
    ERP Registration
          ↓
    Completed
    

    여기까지 구현할 수 있다면 Agent는 단순히 LLM이 Tool 몇 개를 호출하는 프로그램이 아니다.

    오랫동안 실행되고, 중간에 멈출 수 있고, 실패에서 복구하며, 사람과 협업할 수 있는 하나의 백엔드 Workflow Runtime이 된다.

  • SSE와 Streamable HTTP 4부 — FastAPI + AI Agent + MCP에서 SSE와 Streamable HTTP는 어디에 써야 할까?

    SSE와 Streamable HTTP 4부 — FastAPI + AI Agent + MCP에서 SSE와 Streamable HTTP는 어디에 써야 할까?

    지금까지 세 편에 걸쳐 SSE와 Streamable HTTP의 구조를 살펴봤습니다.

    1부에서는 하나의 HTTP Response가 어떻게 데이터를 조금씩 전달할 수 있는지 알아봤습니다.

    Request
       │
       ▼
    Response
       │
       ├── data
       ├── data
       ├── data
       └── end

    2부에서는 SSE만으로 모든 통신 문제를 해결할 수 없다는 것을 살펴봤습니다.

    AI Agent와 같은 복잡한 시스템에서는 단순한 데이터 스트리밍을 넘어 다음과 같은 메시지 관계를 처리해야 합니다.

    Request
    Response
    Notification
    Streaming
    Session

    3부에서는 MCP가 왜 Streamable HTTP를 사용하는지 살펴봤습니다.

    HTTP Request / Response
              +
    필요할 때 Streaming
              =
    Streamable HTTP

    그렇다면 이제 실제 시스템을 만들어봅시다.

    이번 글에서는 사용자가 웹에서 인보이스를 업로드하면 AI Agent가 MCP Tool을 이용해 데이터를 추출하는 서비스를 예제로 사용하겠습니다.

    여기서 중요한 질문은 이것입니다.

    Browser, FastAPI, Agent, MCP Server 사이에서 SSE와 Streamable HTTP를 각각 어디에 사용해야 할까?


    1. 우리가 만들 시스템

    사용자가 웹 화면에서 PDF 또는 이미지 인보이스를 업로드한다고 가정해보겠습니다.

    Browser
    
    [ invoice.pdf ]
    
    [ 자동 입력 ]

    사용자가 자동 입력 버튼을 누르면 서버에서 다음 작업이 실행됩니다.

    파일 업로드
        │
        ▼
    문서 분석
        │
        ▼
    OCR / VLM
        │
        ▼
    필드 추출
        │
        ▼
    검증
        │
        ▼
    결과 반환

    최종적으로 다음과 같은 구조화된 데이터를 얻고 싶습니다.

    {
      "invoice_number": "INV-2026-001",
      "invoice_date": "2026-09-15",
      "vendor": "ABC Corporation",
      "total_amount": 1250.00,
      "currency": "USD"
    }

    그리고 Browser는 이 값을 화면의 입력 필드에 자동으로 채웁니다.

    Invoice Number : INV-2026-001
    
    Invoice Date   : 2026-09-15
    
    Vendor         : ABC Corporation
    
    Total Amount   : 1,250.00
    
    Currency       : USD

    사용자 입장에서는 단순한 기능입니다.

    하지만 내부에서는 여러 컴포넌트가 동작할 수 있습니다.


    2. 전체 아키텍처부터 그려보자

    시스템을 다음과 같이 구성해보겠습니다.

    ┌──────────────┐
    │   Browser    │
    │    Web UI    │
    └──────┬───────┘
           │
           │ HTTP
           ▼
    ┌──────────────┐
    │   FastAPI    │
    │     API      │
    └──────┬───────┘
           │
           ▼
    ┌──────────────┐
    │    Agent     │
    │   Runtime    │
    └──────┬───────┘
           │
           │ MCP
           ▼
    ┌──────────────┐
    │  MCP Server  │
    └──────┬───────┘
           │
           ├── Document Parser
           │
           ├── OCR / VLM
           │
           ├── Invoice Extractor
           │
           └── Validator

    각 컴포넌트의 역할은 다릅니다.

    Browser
    
    사용자 인터페이스
    
    
    FastAPI
    
    외부 API Endpoint
    
    
    Agent Runtime
    
    작업 판단 및 Tool orchestration
    
    
    MCP Server
    
    Agent가 사용할 Tool 제공

    여기서 중요한 것은 모든 구간에 같은 통신 방식을 사용할 필요가 없다는 것입니다.


    3. Browser → FastAPI는 일반 HTTP로 시작한다

    사용자가 파일을 업로드합니다.

    Browser
       │
       │ POST /invoices/analyze
       ▼
    FastAPI

    예를 들어 다음과 같은 API가 있을 수 있습니다.

    @app.post("/invoices/analyze")
    async def analyze_invoice(file: UploadFile):
        ...

    파일 업로드 자체는 일반적인 HTTP Request입니다.

    POST /invoices/analyze
    
    Content-Type: multipart/form-data

    여기서 굳이 WebSocket이나 SSE를 사용할 이유는 없습니다.

    Browser가 서버에게 명확한 요청을 보내는 동작이기 때문입니다.

    Browser ───── Request ─────> FastAPI

    4. 그런데 분석에 20초가 걸린다면?

    문제는 그다음입니다.

    인보이스 분석이 다음 과정을 거친다고 해보겠습니다.

    PDF Rendering        2 sec
           │
           ▼
    OCR / VLM            8 sec
           │
           ▼
    Field Extraction     6 sec
           │
           ▼
    Validation           4 sec

    총 20초입니다.

    가장 단순한 구현은 20초 동안 기다렸다가 결과를 반환하는 것입니다.

    Browser                         FastAPI
       │                               │
       │──── POST /analyze ───────────>│
       │                               │
       │                               │
       │          20 seconds           │
       │                               │
       │                               │
       │<──── JSON Result ─────────────│

    기술적으로 문제는 없습니다.

    하지만 사용자 경험은 좋지 않습니다.

    화면에서는 20초 동안 이런 상태만 보일 수 있습니다.

    분석 중...
    
    ████████████████

    사용자는 궁금해집니다.

    정상적으로 처리되고 있는 건가?
    
    서버가 멈춘 건가?
    
    파일 업로드가 실패한 건가?
    
    AI가 아직 분석하고 있는 건가?

    그래서 진행 상황을 보여주고 싶어집니다.


    5. 이 구간에서 SSE가 유용하다

    Browser가 받고 싶은 정보는 대부분 서버에서 발생합니다.

    파일 업로드 완료
    
    PDF 변환 중
    
    OCR 분석 중
    
    필드 추출 중
    
    데이터 검증 중
    
    완료

    통신 방향을 보면 명확합니다.

    FastAPI ───────────────────> Browser

    즉 Server → Client입니다.

    이런 상황은 SSE가 잘하는 영역입니다.

    Browser <══════ SSE ══════ FastAPI

    예를 들어 서버가 다음과 같은 이벤트를 보낼 수 있습니다.

    event: progress
    data: {"step":"rendering"}
    
    event: progress
    data: {"step":"ocr"}
    
    event: progress
    data: {"step":"extracting"}
    
    event: progress
    data: {"step":"validating"}
    
    event: completed
    data: {"status":"completed"}

    Browser에서는 이 이벤트를 받아 화면을 업데이트할 수 있습니다.

    ✓ 파일 업로드
    
    ✓ PDF 변환
    
    ● OCR 분석 중...
    
    ○ 필드 추출
    
    ○ 데이터 검증

    사용자는 시스템이 실제로 무엇을 하고 있는지 알 수 있습니다.


    6. FastAPI에서 SSE Endpoint 만들기

    개념적으로 다음과 같은 Endpoint를 만들 수 있습니다.

    from fastapi.responses import StreamingResponse
    
    
    @app.get("/tasks/{task_id}/events")
    async def task_events(task_id: str):
    
        async def event_generator():
    
            async for event in get_task_events(task_id):
    
                yield (
                    f"event: {event.type}\n"
                    f"data: {event.json()}\n\n"
                )
    
        return StreamingResponse(
            event_generator(),
            media_type="text/event-stream"
        )

    Browser는 이 Endpoint에 연결합니다.

    const source = new EventSource(
        `/tasks/${taskId}/events`
    );
    
    source.addEventListener(
        "progress",
        (event) => {
            console.log(event.data);
        }
    );

    이제 서버에서 이벤트가 발생할 때마다 Browser가 받을 수 있습니다.

    FastAPI
    
    yield progress
          │
          ▼
    Browser
    
    UI Update

    7. 여기서 중요한 설계 포인트

    한 가지 중요한 점이 있습니다.

    SSE가 실제 분석 작업을 수행하는 것은 아닙니다.

    SSE ≠ Worker

    SSE는 작업 결과나 상태를 전달하는 통신 채널일 뿐입니다.

    실제 작업은 Agent 또는 Worker가 수행할 수 있습니다.

    FastAPI
       │
       │ enqueue
       ▼
    Agent Worker
       │
       │ processing
       ▼
    MCP Server

    그리고 Worker가 작업 상태를 저장하거나 이벤트를 발행합니다.

    예를 들어 Redis를 사용할 수 있습니다.

    Agent Worker
         │
         │ progress event
         ▼
    Redis
         │
         ▼
    FastAPI
         │
         │ SSE
         ▼
    Browser

    전체 흐름은 다음과 같습니다.

    Browser
       │
       │ HTTP
       ▼
    FastAPI
       │
       │ enqueue
       ▼
    Worker
       │
       │ progress
       ▼
    Redis
       │
       ▼
    FastAPI
       │
       │ SSE
       ▼
    Browser

    즉 SSE를 사용한다고 해서 HTTP Endpoint에서 긴 작업을 직접 수행해야 하는 것은 아닙니다.


    8. 이제 Agent가 등장한다

    FastAPI는 분석 요청을 Agent에게 전달합니다.

    FastAPI
       │
       ▼
    Agent Runtime

    Agent의 역할은 단순히 OCR을 실행하는 것이 아닐 수 있습니다.

    예를 들어 다음과 같이 판단할 수 있습니다.

    파일 형식 확인
         │
         ▼
    PDF인가?
         │
         ├── Yes → PDF Parser
         │
         └── No
              │
              ▼
            Image?
              │
              ▼
            OCR / VLM

    그리고 결과에 따라 다른 Tool을 선택할 수 있습니다.

    Agent
      │
      ├── parse_document()
      │
      ├── extract_invoice()
      │
      └── validate_invoice()

    이 Tool들이 MCP Server에 있다고 가정해보겠습니다.


    9. Agent → MCP Server에서는 통신의 성격이 달라진다

    Agent가 MCP Tool을 호출합니다.

    Agent
      │
      │ tools/call
      ▼
    MCP Server

    예를 들어 다음과 같은 호출입니다.

    extract_invoice("invoice.pdf")

    이것은 Browser에게 진행 상태를 알려주는 것과 성격이 다릅니다.

    여기서는 명확한 RPC 관계가 있습니다.

    Request
    
    "extract_invoice를 실행해줘"
    
            ↓
    
    MCP Server
    
            ↓
    
    Response
    
    "추출 결과는 이것이야"

    즉 핵심은 Server → Client 스트리밍이 아니라,

    Request
       ↓
    Tool Execution
       ↓
    Response

    입니다.

    이 구간에서 Streamable HTTP가 자연스럽습니다.


    10. Streamable HTTP로 Tool 호출하기

    전체 구조를 단순화하면 다음과 같습니다.

    Agent                         MCP Server
      │                               │
      │──── POST /mcp ───────────────>│
      │                               │
      │       Tool Execution          │
      │                               │
      │<──── JSON Response ───────────│

    빠르게 끝나는 Tool이라면 일반 JSON Response면 충분합니다.

    예를 들어:

    get_vendor_info()

    같은 Tool이 100ms 안에 끝난다면 굳이 스트리밍할 이유가 없습니다.

    POST /mcp
        │
        ▼
    Tool
        │
        ▼
    JSON Response

    이것이 Streamable HTTP의 장점 중 하나입니다.

    모든 Tool 호출을 Streaming으로 만들 필요가 없습니다.


    11. 오래 걸리는 Tool이라면?

    이번에는 extract_invoice()가 15초 걸린다고 해보겠습니다.

    extract_invoice()
           │
           ▼
    Rendering
           │
           ▼
    VLM
           │
           ▼
    Extraction
           │
           ▼
    Validation

    필요하다면 서버는 스트리밍 Response를 사용할 수 있습니다.

    Agent                         MCP Server
      │                               │
      │──── POST /mcp ───────────────>│
      │                               │
      │<════ SSE Stream ══════════════│
      │<──── message ─────────────────│
      │<──── message ─────────────────│
      │<──── result ──────────────────│

    즉 같은 Streamable HTTP에서도 상황에 따라 다르게 처리할 수 있습니다.

                     Tool Call
                         │
                         ▼
                    MCP Server
                         │
               ┌─────────┴─────────┐
               │                   │
               ▼                   ▼
          빠른 작업             긴 작업
               │                   │
               ▼                   ▼
        JSON Response          SSE Stream

    12. 이제 두 종류의 Streaming이 보인다

    여기서 재미있는 부분이 나타납니다.

    우리 시스템에는 두 종류의 Streaming이 존재할 수 있습니다.

    첫 번째는:

    FastAPI ───── SSE ─────> Browser

    목적은 사용자 경험입니다.

    "분석 시작"
    
    "OCR 진행 중"
    
    "추출 중"
    
    "완료"

    두 번째는:

    MCP Server ─── Stream ───> Agent

    목적은 Agent와 Tool Server 사이의 프로토콜 통신입니다.

    둘 다 내부적으로 SSE를 사용할 수 있지만 목적은 전혀 다릅니다.

    Browser SSE
    
    목적:
    사용자에게 진행 상황 전달
    
    
    MCP Streaming
    
    목적:
    Agent와 MCP Server 사이 메시지 전달

    이 차이를 이해하는 것이 중요합니다.


    13. 전체 데이터 흐름을 다시 그려보자

    이제 전체 시스템을 하나의 그림으로 연결해보겠습니다.

    ┌──────────────┐
    │   Browser    │
    └──────┬───────┘
           │
           │ POST /analyze
           ▼
    ┌──────────────┐
    │   FastAPI    │
    └──────┬───────┘
           │
           │ Task
           ▼
    ┌──────────────┐
    │ Agent Worker │
    └──────┬───────┘
           │
           │ Streamable HTTP
           ▼
    ┌──────────────┐
    │  MCP Server  │
    └──────┬───────┘
           │
           ├── OCR / VLM
           ├── Extractor
           └── Validator

    그리고 사용자에게 진행 상황을 전달하는 별도의 흐름이 있습니다.

    Agent Worker
          │
          │ progress
          ▼
        Redis
          │
          ▼
       FastAPI
          │
          │ SSE
          ▼
       Browser

    두 흐름을 합치면 다음과 같습니다.

                             Streamable HTTP
                     ┌──────────────────────────┐
                     │                          ▼
    Browser ───> FastAPI ───> Agent Worker ───> MCP Server
       ▲                            │                │
       │                            │                ├── OCR
       │                            │                ├── Parser
       │                            │                └── Validator
       │                            │
       │                            │ progress
       │                            ▼
       │                          Redis
       │                            │
       └────────── SSE ─────────────┘

    이 그림이 이번 글의 핵심입니다.


    14. Redis는 왜 중간에 넣을까?

    Agent Worker에서 바로 Browser로 SSE를 보내면 되지 않을까요?

    Agent Worker ─────> Browser

    작은 시스템이라면 가능할 수 있습니다.

    하지만 운영 환경에서는 Worker와 Web API를 분리하는 경우가 많습니다.

    Web API
    
    Worker #1
    
    Worker #2
    
    Worker #3

    Browser는 FastAPI에 연결되어 있지만 실제 작업은 Worker #2가 수행하고 있을 수 있습니다.

    Browser
       │
       ▼
    FastAPI
    
    Worker #2

    두 프로세스는 서로 다른 서버에 있을 수도 있습니다.

    그래서 중간에 이벤트 전달 계층을 둘 수 있습니다.

    Worker
       │
       │ progress event
       ▼
    Redis
       │
       ▼
    FastAPI
       │
       ▼
    Browser

    Redis Streams나 Pub/Sub 같은 기능을 사용할 수 있습니다.

    예를 들어 Worker가 다음 이벤트를 발행합니다.

    {
      "task_id": "task-123",
      "type": "progress",
      "step": "ocr",
      "message": "OCR processing"
    }

    FastAPI는 해당 이벤트를 받아 SSE Client에게 전달합니다.


    15. Redis Streams와 Pub/Sub 중 무엇을 쓸까?

    진행 상황 전달만 필요하다면 Redis Pub/Sub도 사용할 수 있습니다.

    Worker
       │
       │ publish
       ▼
    Redis Pub/Sub
       │
       ▼
    FastAPI

    하지만 이벤트를 일정 기간 보존하거나 Consumer가 잠시 끊겼다가 다시 처리해야 한다면 Redis Streams가 더 적합할 수 있습니다.

    Worker
       │
       │ XADD
       ▼
    Redis Stream
       │
       ▼
    FastAPI

    이 둘의 차이를 아주 단순하게 표현하면 다음과 같습니다.

    Pub/Sub
    
    Worker ── publish ──> Redis ──> FastAPI
    
                          ↓
    
                    그 순간 구독자가
                    없으면 이벤트 소실
    
    
    Streams
    
    Worker ─── XADD ───> Redis Stream
                             │
                             ├── event 1
                             ├── event 2
                             ├── event 3
                             └── event 4
                                  │
                                  ▼
                               FastAPI

    따라서 단순히 현재 접속 중인 사용자에게 진행 상황만 보여주는 것이 목적이라면 Pub/Sub으로도 충분할 수 있습니다.

    반면 다음과 같은 요구사항이 있다면 Streams를 고려할 수 있습니다.

    • 이벤트를 일정 시간 보존해야 한다.
    • Consumer가 잠시 중단되어도 다시 읽어야 한다.
    • 작업 처리 이력을 남겨야 한다.
    • 여러 Consumer가 이벤트를 처리해야 한다.
    • 장애 이후에도 이벤트 처리를 이어가야 한다.

    중요한 것은 SSE와 Redis Streams를 같은 종류의 기술로 생각하지 않는 것입니다.

    Redis Streams
    
    Backend 내부의 이벤트 전달 / 저장
    
    
    SSE
    
    Backend → Browser 이벤트 전달

    서로 다른 계층에서 다른 문제를 해결합니다.


    16. 최종 결과는 어떻게 Browser까지 전달할까?

    이제 Agent의 작업이 완료됐다고 생각해보겠습니다.

    MCP Server가 구조화된 결과를 반환합니다.

    {
      "invoice_number": "INV-2026-001",
      "invoice_date": "2026-09-15",
      "vendor": "ABC Corporation",
      "total_amount": 1250.00,
      "currency": "USD"
    }

    이 결과는 먼저 Agent에게 전달됩니다.

    MCP Server
         │
         │ Tool Result
         ▼
       Agent

    Agent는 결과를 검증하거나 여러 Tool의 결과를 조합할 수도 있습니다.

    OCR Result
         │
         ├──────────┐
         │          │
         ▼          ▼
    Extractor    Validator
         │          │
         └────┬─────┘
              ▼
          Final Result

    최종 결과가 준비되면 Backend에 저장할 수 있습니다.

    Agent
      │
      │ save result
      ▼
    Database

    그리고 작업 완료 이벤트를 발행합니다.

    Agent
      │
      │ completed
      ▼
    Redis

    FastAPI는 이 이벤트를 받아 Browser에 전달합니다.

    Redis
      │
      ▼
    FastAPI
      │
      │ SSE
      ▼
    Browser

    예를 들어 마지막 SSE Event는 다음과 같을 수 있습니다.

    event: completed
    data: {
      "task_id": "task-123",
      "invoice_number": "INV-2026-001",
      "vendor": "ABC Corporation",
      "total_amount": 1250.00
    }

    Browser는 이 이벤트를 받으면 화면의 입력 필드를 자동으로 채웁니다.

    Invoice Number
    [ INV-2026-001          ]
    
    Vendor
    [ ABC Corporation       ]
    
    Total
    [ 1,250.00              ]
    
    Currency
    [ USD                    ]

    이제 사용자가 처음 눌렀던 자동 입력 버튼의 전체 작업이 완료됩니다.


    17. 그런데 최종 결과까지 SSE로 보내야 할까?

    여기서 설계 선택지가 하나 생깁니다.

    완료 이벤트에 최종 결과 전체를 담을 수도 있습니다.

    SSE
    
    event: completed
    data: { ... final result ... }

    하지만 다른 방법도 있습니다.

    완료 여부만 SSE로 전달하는 것입니다.

    event: completed
    data: {
      "task_id": "task-123"
    }

    그다음 Browser가 별도의 API를 호출합니다.

    Browser
       │
       │ GET /tasks/task-123/result
       ▼
    FastAPI
       │
       ▼
    Database
       │
       ▼
    JSON Result

    즉 다음과 같이 역할을 나눌 수 있습니다.

    SSE
    
    상태 변화 알림
    
    
    REST API
    
    최종 데이터 조회

    이 구조는 꽤 실용적입니다.

    SSE 연결이 중간에 끊겨도 최종 결과 자체는 Database에 남아 있기 때문입니다.

    SSE 연결 끊김
          │
          X
    
    하지만
    
    Database
       │
       └── Result 존재

    Browser는 다시 접속한 뒤 현재 작업 상태를 조회할 수 있습니다.

    GET /tasks/task-123

    응답:

    {
      "task_id": "task-123",
      "status": "completed"
    }

    그리고 결과를 다시 가져올 수 있습니다.

    GET /tasks/task-123/result

    실제 운영 환경에서는 이런 구조가 장애 복구 측면에서 더 안전할 수 있습니다.


    18. SSE 연결이 끊어지면 작업도 중단될까?

    여기서 아주 중요한 문제가 하나 있습니다.

    사용자가 Browser를 닫았다고 생각해봅시다.

    Browser
       │
       X
    SSE Disconnect

    그렇다면 Agent 작업도 중단해야 할까요?

    반드시 그렇지는 않습니다.

    SSE는 작업 실행 채널이 아니라 작업 상태를 관찰하는 채널이기 때문입니다.

    Browser
       │
       │ SSE
       ▼
    FastAPI
    
    
    Agent Worker
       │
       │ Task Processing
       ▼
    MCP Server

    Browser 연결과 실제 작업의 생명주기를 분리할 수 있습니다.

    따라서 Browser가 닫혀도:

    Browser
       X
    
    Agent Worker
       │
       ├── OCR
       ├── Extract
       ├── Validate
       └── Complete

    작업은 계속 수행될 수 있습니다.

    사용자가 다시 접속하면 현재 상태를 조회합니다.

    GET /tasks/task-123

    예를 들어:

    {
      "task_id": "task-123",
      "status": "completed"
    }

    이 구조에서 중요한 원칙이 하나 나옵니다.

    Client 연결의 생명주기와 Backend Task의 생명주기를 분리한다.

    Long-running AI 작업을 설계할 때 상당히 중요한 원칙입니다.


    19. 그렇다면 사용자가 작업을 취소하면?

    이번에는 사용자가 명시적으로 취소 버튼을 눌렀다고 생각해보겠습니다.

    [ 분석 취소 ]

    이것은 SSE로 보낼 필요가 없습니다.

    Client → Server 요청이기 때문입니다.

    일반 HTTP API로 충분합니다.

    Browser
       │
       │ POST /tasks/task-123/cancel
       ▼
    FastAPI

    FastAPI는 Task 상태를 변경합니다.

    RUNNING
    
       ↓
    
    CANCEL_REQUESTED

    그리고 Worker가 이를 확인합니다.

    Worker
       │
       │ cancel requested?
       ▼
      Yes
       │
       ▼
    Stop Processing

    최종 상태는 다음과 같이 바뀔 수 있습니다.

    CANCEL_REQUESTED
           │
           ▼
       CANCELLED

    그리고 다시 SSE로 Browser에 알립니다.

    FastAPI
       │
       │ SSE
       ▼
    Browser
    
    event: cancelled

    전체 흐름을 보면 재미있습니다.

                     HTTP POST
    Browser ─────────────────────> FastAPI
                 cancel
    
    
                        Backend
                           │
                           ▼
                        Worker
                           │
                        cancelled
                           │
                           ▼
    
    
                        SSE
    Browser <───────────────────── FastAPI
                cancelled

    즉 하나의 기능 안에서도 HTTP Request와 SSE가 함께 사용됩니다.


    20. Human-in-the-Loop가 들어가면 더 재미있어진다

    AI Agent가 항상 자동으로 모든 결정을 내릴 수 있는 것은 아닙니다.

    예를 들어 인보이스 금액이 너무 크거나 추출 신뢰도가 낮다고 생각해보겠습니다.

    Total Amount
    
    ₩ 95,000,000
    
    
    Confidence
    
    0.62

    Agent가 판단합니다.

    Confidence < 0.7
    
           ↓
    
    Human Review Required

    작업 상태를 변경합니다.

    RUNNING
       │
       ▼
    WAITING_HUMAN

    그리고 사용자에게 SSE 이벤트를 보냅니다.

    event: human_review_required
    data: {
      "task_id": "task-123",
      "reason": "low_confidence"
    }

    Browser에서는 검토 화면을 보여줍니다.

    AI가 추출한 값의 확인이 필요합니다.
    
    Vendor
    [ ABC Corporation ]
    
    Total
    [ 95,000,000 ]
    
    [ 승인 ]    [ 수정 ]    [ 거절 ]

    사용자가 승인합니다.

    이것은 다시 Client → Server 요청입니다.

    Browser
       │
       │ POST /tasks/task-123/approve
       ▼
    FastAPI

    Agent는 작업을 재개합니다.

    WAITING_HUMAN
          │
          │ approve
          ▼
       RUNNING
          │
          ▼
       COMPLETED

    전체 흐름은 다음과 같습니다.

    Agent
      │
      │ Human Review Required
      ▼
    Redis
      │
      ▼
    FastAPI
      │
      │ SSE
      ▼
    Browser
      │
      │ Approve
      ▼
    FastAPI
      │
      ▼
    Agent
      │
      ▼
    Continue

    이 구조를 보면 SSE와 HTTP Request의 역할이 아주 명확하게 나뉩니다.

    Server → Client 상태 알림
    
    SSE
    
    
    Client → Server 사용자 액션
    
    HTTP Request

    21. Agent와 MCP 사이에서도 HITL이 가능하다

    조금 더 깊게 들어가면 Human-in-the-Loop가 Agent Runtime 내부에 들어갈 수도 있습니다.

    Agent
      │
      │ Tool Call
      ▼
    MCP Server
      │
      ▼
    Invoice Tool
      │
      ▼
    Low Confidence

    Agent Runtime은 현재 상태를 Checkpoint에 저장합니다.

    Agent State
    
    {
      task_id,
      current_step,
      tool_result,
      extracted_fields,
      status
    }

    그리고 실행을 멈춥니다.

    RUNNING
       │
       ▼
    WAITING_HUMAN

    사용자 승인이 들어오면 저장된 상태에서 다시 시작합니다.

    Checkpoint
        │
        ▼
    Agent Resume
        │
        ▼
    Next Step

    이때 Transport와 Runtime을 구분해서 생각하는 것이 중요합니다.

    SSE
    
    상태 전달
    
    
    Streamable HTTP
    
    Agent ↔ MCP 통신
    
    
    Checkpoint
    
    Agent 실행 상태 저장

    각 기술이 해결하는 문제가 완전히 다릅니다.


    22. 실패 처리도 생각해야 한다

    실제 운영에서는 성공보다 실패 처리가 더 중요할 때가 많습니다.

    예를 들어 MCP Tool 호출이 실패했다고 생각해보겠습니다.

    Agent
      │
      │ tools/call
      ▼
    MCP Server
      │
      X
    VLM Timeout

    Agent는 Retry 정책을 적용할 수 있습니다.

    Attempt 1
       │
       X
    
    Retry
    
    Attempt 2
       │
       X
    
    Retry
    
    Attempt 3
       │
       ▼
    Success

    이 과정도 사용자에게 모두 보여줄 필요는 없습니다.

    Browser에서는 단순히:

    AI 분석 중...

    이라고 보여줄 수도 있습니다.

    즉 내부 이벤트와 사용자 이벤트를 분리하는 것이 좋습니다.

    Internal Event
    
    tool_retry
    mcp_timeout
    worker_retry
    model_fallback
    
    
    User Event
    
    processing
    review_required
    completed
    failed

    Backend 내부 구현 세부사항을 그대로 Browser에 노출하면 UI와 Backend가 지나치게 강하게 결합될 수 있습니다.


    23. Event를 계층별로 나누자

    실제 시스템에서는 이벤트를 다음과 같이 나누는 것이 좋습니다.

    Infrastructure Event

    worker_started
    worker_retry
    redis_error
    mcp_reconnect

    운영 및 모니터링 용도입니다.

    Agent Event

    tool_started
    tool_completed
    tool_failed
    human_review_required

    Agent Runtime의 상태를 표현합니다.

    User Event

    processing
    progress
    review_required
    completed
    failed

    사용자 UI에 전달하기 위한 이벤트입니다.

    전체 구조는 다음과 같습니다.

    Infrastructure
          │
          ▼
    Agent Runtime
          │
          ▼
    Application Event
          │
          ▼
    User Event
          │
          ▼
    SSE
          │
          ▼
    Browser

    이렇게 한 단계 추상화하면 Backend 내부 구현이 변경되어도 Frontend에 미치는 영향을 줄일 수 있습니다.


    24. SSE Event Schema도 미리 정의하는 것이 좋다

    처음에는 다음과 같이 문자열만 보내기 쉽습니다.

    data: OCR 진행 중

    하지만 시스템이 커지면 구조화된 이벤트가 필요해집니다.

    예를 들어:

    {
      "event": "progress",
      "task_id": "task-123",
      "step": "ocr",
      "progress": 40,
      "message": "OCR을 진행하고 있습니다."
    }

    완료 이벤트:

    {
      "event": "completed",
      "task_id": "task-123",
      "progress": 100
    }

    실패 이벤트:

    {
      "event": "failed",
      "task_id": "task-123",
      "error_code": "VLM_TIMEOUT",
      "message": "문서 분석에 실패했습니다."
    }

    이렇게 Schema를 정의해두면 Frontend 구현도 단순해집니다.

    event == progress
          ↓
    Progress UI
    
    
    event == completed
          ↓
    Result UI
    
    
    event == failed
          ↓
    Error UI

    25. Task 상태도 명확하게 정의하자

    Long-running 작업이라면 이벤트뿐만 아니라 Task 자체의 상태도 필요합니다.

    예를 들어:

    PENDING
    
    RUNNING
    
    WAITING_HUMAN
    
    COMPLETED
    
    FAILED
    
    CANCELLED

    상태 전이를 그려보면 다음과 같습니다.

    PENDING
       │
       ▼
    RUNNING
       │
       ├──────────────> FAILED
       │
       ├──────────────> CANCELLED
       │
       ├──> WAITING_HUMAN
       │          │
       │          ▼
       │       RUNNING
       │
       ▼
    COMPLETED

    이 상태는 Database에 저장하는 것이 좋습니다.

    tasks
    
    task_id
    status
    current_step
    progress
    result_id
    error_code
    created_at
    updated_at

    SSE는 이 상태 변화를 실시간으로 전달하는 역할을 합니다.

    다시 말해:

    SSE 자체를 상태 저장소로 사용해서는 안 됩니다.


    26. 연결이 끊겨도 복구할 수 있는 구조

    이제 시스템이 상당히 안정적으로 변했습니다.

    Browser의 SSE 연결이 끊겼다고 생각해봅시다.

    Browser
       X
    Network Error

    Agent 작업은 계속 진행됩니다.

    Agent Worker
       │
       ├── OCR
       ├── Extraction
       └── Validation

    상태는 Database에 저장됩니다.

    task-123
    
    status = COMPLETED

    사용자가 다시 페이지를 엽니다.

    Browser
       │
       │ GET /tasks/task-123
       ▼
    FastAPI

    서버가 현재 상태를 반환합니다.

    {
      "task_id": "task-123",
      "status": "completed"
    }

    Browser는 결과를 가져옵니다.

    GET /tasks/task-123/result

    즉 실시간 연결이 끊어져도 시스템의 상태는 사라지지 않습니다.

    이것이 운영 가능한 Long-running AI 시스템에서 매우 중요한 특성입니다.


    27. 전체 아키텍처를 완성해보자

    지금까지의 내용을 모두 합쳐보겠습니다.

                            ┌─────────────────┐
                            │     Browser     │
                            └───────┬─────────┘
                                    │
                        HTTP Request│
                                    ▼
                            ┌─────────────────┐
                            │     FastAPI     │
                            └───────┬─────────┘
                                    │
                                 enqueue
                                    │
                                    ▼
                            ┌─────────────────┐
                            │  Agent Worker   │
                            └───────┬─────────┘
                                    │
                         Streamable HTTP
                                    │
                                    ▼
                            ┌─────────────────┐
                            │   MCP Server    │
                            └───────┬─────────┘
                                    │
                      ┌─────────────┼─────────────┐
                      ▼             ▼             ▼
                    Parser        OCR/VLM      Validator
    
    
    Agent Worker
         │
         │ progress / status
         ▼
    ┌───────────────┐
    │ Redis Streams │
    └───────┬───────┘
            │
            ▼
        FastAPI
            │
            │ SSE
            ▼
         Browser
    
    
    Agent Worker
         │
         │ state / result
         ▼
    ┌───────────────┐
    │   Database    │
    └───────────────┘

    이제 각각의 통신 방식이 담당하는 역할이 명확하게 보입니다.

    Browser → FastAPI
        일반 HTTP Request
    
    FastAPI → Agent Worker
        Task 전달
    
    Agent Worker ↔ MCP Server
        Streamable HTTP
    
    Agent Worker → Redis
        Backend Event
    
    FastAPI → Browser
        SSE
    
    Agent Worker → Database
        Task 상태 / 최종 결과 저장

    중요한 것은 이 구조에서 어느 하나의 기술이 전체 시스템의 통신을 담당하지 않는다는 점입니다.

    각 구간의 요구사항에 가장 적합한 방식을 선택합니다.


    28. 요청부터 완료까지 전체 흐름

    이제 사용자가 자동 입력 버튼을 누르는 순간부터 화면에 데이터가 채워질 때까지 전체 과정을 순서대로 따라가 보겠습니다.

    Step 1. 사용자가 분석을 요청한다

    Browser
       │
       │ POST /invoices/analyze
       │ invoice.pdf
       ▼
    FastAPI

    FastAPI는 Task를 생성합니다.

    {
      "task_id": "task-123",
      "status": "pending"
    }

    그리고 바로 Browser에 반환합니다.

    HTTP 202 Accepted

    즉 파일 분석이 끝날 때까지 HTTP Request를 붙잡고 있지 않습니다.


    Step 2. Browser가 진행 상황을 구독한다

    Browser는 Task ID를 이용해 SSE Endpoint에 연결합니다.

    GET /tasks/task-123/events

    연결 구조는 다음과 같습니다.

    Browser
       │
       │ GET /events
       ▼
    FastAPI
       │
       │
       └════ SSE Stream ════> Browser

    이제 Browser는 서버에서 발생하는 상태 변화를 받을 준비가 됐습니다.


    Step 3. Worker가 작업을 시작한다

    FastAPI
       │
       │ enqueue
       ▼
    Agent Worker

    Task 상태가 변경됩니다.

    PENDING
       │
       ▼
    RUNNING

    Worker는 이벤트를 발행합니다.

    {
      "event": "progress",
      "step": "started",
      "progress": 5
    }

    이 이벤트가 Redis를 거쳐 Browser까지 전달됩니다.

    Worker
      │
      ▼
    Redis
      │
      ▼
    FastAPI
      │
      │ SSE
      ▼
    Browser

    Step 4. Agent가 필요한 Tool을 결정한다

    Agent가 문서를 확인합니다.

    Agent
    
    "What type of document is this?"
    
            │
            ▼
    
    Invoice PDF

    Agent는 필요한 Tool을 선택합니다.

    parse_document()
    
    extract_invoice()
    
    validate_invoice()

    Step 5. Agent가 MCP Tool을 호출한다

    Agent
      │
      │ tools/call
      ▼
    MCP Server

    이 구간에서는 Streamable HTTP가 사용됩니다.

    Agent                         MCP Server
      │                               │
      │──── POST /mcp ───────────────>│
      │                               │
      │       Tool Execution          │
      │                               │
      │<──── Result ──────────────────│

    빠른 작업이라면 일반 JSON Response가 반환될 수 있습니다.

    필요한 경우에는 Streaming Response를 사용할 수도 있습니다.


    Step 6. 작업 진행 상황을 사용자에게 전달한다

    Agent가 OCR 단계에 들어갑니다.

    Agent Worker
         │
         │ progress
         ▼
    Redis

    FastAPI가 이벤트를 전달합니다.

    FastAPI
       │
       │ SSE
       ▼
    Browser

    사용자 화면은 다음과 같이 변경됩니다.

    ✓ 파일 업로드 완료
    
    ✓ PDF 변환 완료
    
    ● OCR 분석 중...
    
    ○ 데이터 추출
    
    ○ 데이터 검증

    Step 7. 데이터 추출이 완료된다

    MCP Tool이 구조화된 데이터를 반환합니다.

    {
      "invoice_number": "INV-2026-001",
      "invoice_date": "2026-09-15",
      "vendor": "ABC Corporation",
      "total_amount": 1250.00,
      "currency": "USD"
    }

    Agent가 결과를 검증합니다.

    Extraction Result
           │
           ▼
       Validation
           │
           ▼
      Final Result

    Step 8. 결과를 저장한다

    Agent Worker
         │
         │ Final Result
         ▼
    Database

    Task 상태도 변경합니다.

    RUNNING
       │
       ▼
    COMPLETED

    Step 9. 완료 이벤트를 전달한다

    Agent Worker
         │
         ▼
    Redis
         │
         ▼
    FastAPI
         │
         │ SSE
         ▼
    Browser

    Browser가 다음 이벤트를 받습니다.

    event: completed
    data: {
      "task_id": "task-123"
    }

    Step 10. Browser가 최종 결과를 가져온다

    Browser
       │
       │ GET /tasks/task-123/result
       ▼
    FastAPI
       │
       ▼
    Database

    결과를 반환합니다.

    {
      "invoice_number": "INV-2026-001",
      "invoice_date": "2026-09-15",
      "vendor": "ABC Corporation",
      "total_amount": 1250.00,
      "currency": "USD"
    }

    그리고 화면에 자동으로 입력합니다.

    Invoice Number
    [ INV-2026-001       ]
    
    Invoice Date
    [ 2026-09-15         ]
    
    Vendor
    [ ABC Corporation    ]
    
    Total
    [ 1,250.00           ]
    
    Currency
    [ USD                 ]

    사용자 입장에서는 단순히 버튼 하나를 눌렀지만 Backend에서는 여러 종류의 통신이 협력하고 있었습니다.


    29. 왜 모든 것을 SSE로 만들지 않을까?

    여기까지 보면 이런 생각도 들 수 있습니다.

    이미 SSE 연결이 있는데 그냥 모든 데이터를 SSE로 주고받으면 안 될까?

    문제는 SSE 자체가 Server → Client 이벤트 전달을 위한 방식이라는 것입니다.

    Client가 분석을 시작하는 것은 명확한 Command입니다.

    POST /analyze

    작업을 취소하는 것도 Command입니다.

    POST /tasks/{id}/cancel

    승인하는 것도 Command입니다.

    POST /tasks/{id}/approve

    반면 진행 상황은 Event입니다.

    processing
    
    progress
    
    review_required
    
    completed

    그래서 역할을 다음처럼 나누면 구조가 명확해집니다.

    Command
    
    Browser ───── HTTP ─────> Server
    
    
    Event
    
    Browser <───── SSE ────── Server

    이 패턴은 꽤 이해하기 쉽습니다.

    Client의 의도는 HTTP Request로 전달하고, Server에서 발생하는 변화는 SSE Event로 전달한다.


    30. 왜 모든 것을 WebSocket으로 만들지 않을까?

    WebSocket으로도 구현할 수 있습니다.

    Browser <════════════════> Server
    
    analyze
    progress
    cancel
    review
    approve
    completed

    하지만 이 시스템에서는 Browser가 서버로 지속적인 실시간 데이터를 보내야 하는 상황이 많지 않습니다.

    대부분의 Client → Server 동작은 명확한 Command입니다.

    Analyze
    
    Cancel
    
    Approve
    
    Reject

    그리고 대부분의 Server → Client 데이터는 상태 변화입니다.

    Processing
    
    Progress
    
    Review Required
    
    Completed

    따라서 굳이 전체 시스템을 지속적인 Full-Duplex 연결로 만들 필요가 없다면,

    HTTP + SSE

    조합이 더 단순할 수 있습니다.

    물론 실시간 음성 대화나 공동 편집처럼 양쪽에서 지속적인 데이터가 발생한다면 이야기는 달라집니다.

    그때는 WebSocket이 더 자연스러울 수 있습니다.


    31. 왜 Browser가 MCP Server를 직접 호출하지 않을까?

    이런 구조도 생각할 수 있습니다.

    Browser
       │
       │ MCP
       ▼
    MCP Server

    하지만 일반적인 업무용 AI 시스템에서는 중간에 Application Backend를 두는 것이 자연스럽습니다.

    Browser
       │
       ▼
    FastAPI
       │
       ▼
    Agent
       │
       ▼
    MCP Server

    FastAPI와 Agent Runtime에는 다음과 같은 책임이 있기 때문입니다.

    Authentication
    
    Authorization
    
    Tenant Isolation
    
    Business Logic
    
    Task Management
    
    Rate Limiting
    
    Audit Logging
    
    Agent Orchestration
    
    HITL
    
    Usage Tracking

    MCP Server의 책임은 조금 다릅니다.

    Tool 제공
    
    Resource 제공
    
    외부 시스템 접근
    
    기능 실행

    즉 다음과 같이 책임을 나누는 것입니다.

    FastAPI
    
    Application Boundary
    
    
    Agent Runtime
    
    Decision / Orchestration
    
    
    MCP Server
    
    Tool Boundary

    이렇게 분리하면 MCP Tool을 다른 Agent에서도 재사용하기 쉬워집니다.


    32. FastAPI와 FastMCP의 역할도 명확해진다

    이 구조를 이해하면 자주 나오는 질문도 자연스럽게 해결됩니다.

    FastMCP를 사용하는데 FastAPI는 왜 필요한가?

    둘은 경쟁 관계가 아닙니다.

    FastAPI는 사용자가 접근하는 Application API를 담당할 수 있습니다.

    Browser
       │
       ▼
    FastAPI
    
    POST /login
    
    POST /documents
    
    POST /invoices/analyze
    
    GET /tasks/{id}
    
    GET /tasks/{id}/result

    FastMCP 기반 MCP Server는 Agent가 사용하는 Tool Interface를 담당할 수 있습니다.

    Agent
       │
       ▼
    MCP Server
    
    tools/list
    
    tools/call
    
    resources/read

    전체적으로 보면:

    User
     │
     ▼
    Web UI
     │
     ▼
    FastAPI
     │
     ▼
    Agent
     │
     ▼
    FastMCP
     │
     ▼
    Tools

    각 계층의 책임이 다릅니다.


    33. 결국 Transport는 계층별로 선택한다

    전체 시스템에서 사용된 통신 방식을 다시 정리해보겠습니다.

    Browser
       │
       │ HTTP Request
       ▼
    FastAPI

    사용자의 명령 전달입니다.

    FastAPI
       │
       │ Task Queue
       ▼
    Agent Worker

    장시간 작업을 API Process와 분리합니다.

    Agent
       │
       │ Streamable HTTP
       ▼
    MCP Server

    Agent가 Tool을 호출합니다.

    Agent Worker
       │
       │ Event
       ▼
    Redis Streams / PubSub

    Backend 내부에서 상태 변화를 전달합니다.

    FastAPI
       │
       │ SSE
       ▼
    Browser

    사용자에게 실시간 진행 상황을 전달합니다.

    어느 하나가 다른 것을 대체하는 관계가 아닙니다.

    각자의 역할이 있습니다.


    34. 선택 기준을 아주 단순하게 정리하면

    일반 HTTP

    이런 질문을 해봅니다.

    Client가 요청하고 결과를 받으면 끝나는가?

    그렇다면 일반 HTTP가 가장 단순합니다.

    Client ─── Request ───> Server
    Client <── Response ─── Server

    SSE

    이런 요구사항이라면 SSE를 고려합니다.

    Server에서 발생하는 데이터를 Client에게 계속 전달해야 하는가?

    Server ───────────────> Client

    예:

    LLM Streaming
    
    Progress
    
    Notification
    
    Agent Status

    WebSocket

    이런 요구사항이라면 WebSocket을 고려합니다.

    양쪽에서 지속적으로 실시간 메시지를 보내야 하는가?

    Client <══════════════> Server

    예:

    Realtime Chat
    
    Voice
    
    Game
    
    Collaborative Editing
    
    Realtime Control

    Streamable HTTP

    이런 요구사항이라면 Streamable HTTP가 잘 맞습니다.

    RPC Request/Response를 기본으로 하면서 필요할 때 Streaming도 필요한가?

    Request
       │
       ▼
    Response
    
    or
    
    Request
       │
       ▼
    Stream

    MCP가 대표적인 사례입니다.


    Redis Streams / PubSub

    이것은 Browser 통신 방식의 선택과는 다른 문제입니다.

    질문은 다음과 같습니다.

    Backend Process 사이에서 이벤트를 어떻게 전달할 것인가?

    Worker
       │
       ▼
    Redis
       │
       ▼
    API

    즉 애플리케이션 내부 메시징 계층의 문제입니다.


    35. 가장 중요한 설계 원칙

    이번 구조에서 몇 가지 중요한 원칙을 얻을 수 있습니다.

    첫 번째는:

    작업 실행과 Client 연결의 생명주기를 분리한다.

    SSE Disconnect
    
    ≠
    
    Task Cancel

    두 번째는:

    실시간 이벤트와 영구 상태를 분리한다.

    SSE / Redis
    
    Event
    
    
    Database
    
    State

    세 번째는:

    Client Command와 Server Event를 구분한다.

    Command
    
    Client ─── HTTP ───> Server
    
    
    Event
    
    Client <─── SSE ──── Server

    네 번째는:

    Agent와 Tool의 경계를 명확하게 한다.

    Agent
    
    판단 / Orchestration
    
    
    MCP Server
    
    Tool 제공 / 실행

    그리고 마지막으로:

    하나의 통신 기술로 모든 문제를 해결하려 하지 않는다.

    이 원칙이 가장 중요할 수 있습니다.


    마치며

    이번 시리즈는 SSE라는 비교적 단순한 기술에서 시작했습니다.

    1부

    HTTP 연결 하나로 데이터를 계속 보내는 SSE

    HTTP Response
       │
       ├── data
       ├── data
       ├── data
       └── end

    하나의 HTTP Response가 어떻게 데이터를 조금씩 전달할 수 있는지 살펴봤습니다.

    2부

    SSE만으로는 부족한가? 양방향 통신과 RPC의 문제

    Request
    
    Response
    
    Notification
    
    Streaming

    복잡한 AI 시스템에서는 단순한 Server → Client 스트리밍만으로는 충분하지 않다는 것을 살펴봤습니다.

    3부

    MCP는 왜 Streamable HTTP를 사용하는가

    HTTP Request / Response
              +
    필요할 때 Streaming
              =
    Streamable HTTP

    MCP가 왜 HTTP의 단순함을 유지하면서 필요할 때 스트리밍할 수 있는 Transport를 사용하는지 살펴봤습니다.

    그리고 이번 4부에서는 이 기술들을 실제 AI Agent 시스템 안에 배치해봤습니다.

    Browser
       │
       │ HTTP
       ▼
    FastAPI
       │
       ▼
    Agent Worker
       │
       │ Streamable HTTP
       ▼
    MCP Server
       │
       ├── Parser
       ├── OCR / VLM
       ├── Extractor
       └── Validator

    동시에 사용자에게 진행 상황을 전달합니다.

    Agent Worker
         │
         ▼
    Redis
         │
         ▼
    FastAPI
         │
         │ SSE
         ▼
    Browser

    결국 SSE, WebSocket, Streamable HTTP, Redis Streams는 서로 경쟁하는 기술이 아닙니다.

    각자 다른 문제를 해결합니다.

    HTTP
        Request / Response
    
    
    SSE
        Server → Client Streaming
    
    
    WebSocket
        Full-Duplex Communication
    
    
    Streamable HTTP
        RPC + Optional Streaming
    
    
    Redis Streams
        Backend Event Messaging

    AI 시스템이 복잡해질수록 중요한 것은 새로운 기술을 많이 사용하는 것이 아닙니다.

    각 통신이 무엇을 위해 존재하는지 구분하는 것입니다.

    사용자의 요청인지,

    서버의 이벤트인지,

    Agent의 Tool 호출인지,

    Backend 내부의 상태 변화인지,

    영구적으로 보존해야 하는 상태인지.

    이 경계를 명확하게 나누면 시스템의 구조도 자연스럽게 단순해집니다.

    그리고 그때 SSE와 Streamable HTTP가 어디에 필요한지도 훨씬 명확하게 보이기 시작합니다.

  • SSE와 Streamable HTTP 3부 — MCP는 왜 Streamable HTTP를 사용하는가

    SSE와 Streamable HTTP 3부 — MCP는 왜 Streamable HTTP를 사용하는가

    1부에서는 SSE가 하나의 HTTP Response를 유지하면서 데이터를 조금씩 전달하는 원리를 살펴봤습니다.

    2부에서는 한 단계 더 나아가 이런 질문을 던졌습니다.

    SSE만으로 복잡한 AI 시스템의 통신을 처리할 수 있을까?

    SSE는 Server → Client 방향의 스트리밍에는 매우 유용합니다.

    WebSocket을 사용하면 양방향 통신도 가능합니다.

    하지만 MCP 같은 RPC 기반 시스템에서 중요한 것은 단순히 단방향인가, 양방향인가의 문제가 아니었습니다.

    MCP에서는 다양한 형태의 메시지를 처리해야 합니다.

    Request
    Response
    Notification
    Streaming
    Session

    그리고 2부 마지막에 이런 질문을 남겼습니다.

    HTTP의 단순한 Request/Response 구조를 유지하면서, 필요한 순간에만 스트리밍할 수는 없을까?

    이번 글에서 살펴볼 Streamable HTTP가 바로 이 질문과 연결됩니다.


    1. 먼저 이름부터 살펴보자

    Streamable HTTP라는 이름을 처음 보면 흔히 이렇게 생각할 수 있습니다.

    SSE
     ↓
    새로운 스트리밍 기술
     ↓
    Streamable HTTP

    하지만 이렇게 이해하면 조금 어색합니다.

    Streamable HTTP는 SSE를 대체하는 새로운 스트리밍 포맷이라고 보기보다는,

    HTTP를 기본 전송 방식으로 사용하면서 필요할 때 스트리밍까지 사용할 수 있도록 구성한 MCP의 Transport 방식이라고 이해하는 편이 좋습니다.

    이름도 자세히 보면

    Streaming HTTP

    가 아니라

    Streamable HTTP

    입니다.

    항상 Streaming하는 HTTP가 아니라,

    필요하면 Stream할 수 있는 HTTP

    라는 관점입니다.


    2. 가장 단순한 경우부터 생각해보자

    MCP Client가 MCP Server에게 Tool 목록을 요청한다고 해보겠습니다.

    MCP Client                     MCP Server
        │                              │
        │──── tools/list ─────────────>│
        │                              │
        │<──── tool list ──────────────│
        │                              │

    이 요청은 일반적인 Request/Response로 충분합니다.

    굳이 장시간 연결을 유지하거나 지속적인 이벤트 스트림을 만들 필요가 없습니다.

    개념적으로는 평범한 HTTP 요청처럼 처리할 수 있습니다.

    POST /mcp
    Content-Type: application/json
    
    {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/list"
    }

    서버가 처리한 뒤 결과를 반환합니다.

    HTTP/1.1 200 OK
    Content-Type: application/json
    
    {
        "jsonrpc": "2.0",
        "id": 1,
        "result": {
            ...
        }
    }

    구조만 보면 우리가 오래전부터 사용해온 HTTP API와 크게 다르지 않습니다.

    Client
       │
       │ HTTP POST
       ▼
    Server
       │
       │ 처리
       ▼
    JSON Response

    여기까지만 보면 스트리밍은 필요하지 않습니다.


    3. 그런데 모든 Tool이 빨리 끝나는 것은 아니다

    이번에는 다른 Tool을 호출해보겠습니다.

    tools/call
        │
        ▼
    analyze_invoice()

    인보이스 분석 Tool 내부에서 다음과 같은 작업이 발생한다고 해봅시다.

    PDF
     │
     ▼
    Rendering
     │
     ▼
    OCR / VLM
     │
     ▼
    Field Extraction
     │
     ▼
    Validation
     │
     ▼
    Result

    이 작업이 20초 걸린다고 생각해봅시다.

    가장 단순한 방법은 20초를 기다린 뒤 결과를 반환하는 것입니다.

    Client                         Server
       │                              │
       │──── tools/call ─────────────>│
       │                              │
       │                              │
       │         20 seconds           │
       │                              │
       │                              │
       │<──── result ─────────────────│

    기술적으로 아무 문제 없습니다.

    하지만 경우에 따라 서버가 처리 과정에서 여러 메시지를 전달해야 할 수도 있습니다.

    processing...
    
    OCR completed
    
    extracting...
    
    validating...
    
    completed

    이제 단순 JSON Response 하나만으로는 표현하기 불편해집니다.

    이때 스트리밍이 필요해집니다.


    4. Streamable HTTP의 핵심 구조

    Streamable HTTP의 핵심 아이디어를 단순화하면 다음과 같습니다.

                      POST /mcp
    
    MCP Client ───────────────────────> MCP Server

    Client는 MCP 메시지를 HTTP POST로 보냅니다.

    그런데 서버의 Response는 상황에 따라 달라질 수 있습니다.

    단순한 요청이라면:

    MCP Client                     MCP Server
        │                              │
        │──── POST /mcp ──────────────>│
        │                              │
        │<──── JSON Response ──────────│

    스트리밍이 필요하다면:

    MCP Client                     MCP Server
        │                              │
        │──── POST /mcp ──────────────>│
        │                              │
        │<════ SSE Stream ═════════════│
        │<──── message ────────────────│
        │<──── message ────────────────│
        │<──── message ────────────────│
        │<──── result ─────────────────│

    즉 같은 HTTP 기반 Transport 안에서 상황에 따라 다른 Response 형태를 사용할 수 있습니다.

                     POST /mcp
                         │
                         ▼
                    MCP Server
                         │
                ┌────────┴────────┐
                │                 │
                ▼                 ▼
         JSON Response       SSE Stream

    이 구조가 Streamable HTTP를 이해하는 가장 중요한 그림입니다.


    5. 여기서 SSE가 사라진 것이 아니다

    이 부분에서 많이 헷갈립니다.

    MCP가 기존 SSE Transport에서 Streamable HTTP로 이동했다고 하면,

    “이제 MCP에서는 SSE를 사용하지 않는구나.”

    라고 생각할 수 있습니다.

    그렇지 않습니다.

    Streamable HTTP에서도 SSE가 사용될 수 있습니다.

    차이는 SSE가 Transport 전체를 지배하는 구조가 아니라는 것입니다.

    개념적으로 보면 다음과 같습니다.

    Streamable HTTP
          │
          ├── HTTP POST
          │
          ├── JSON Response
          │
          └── SSE Response

    즉 SSE는 사라진 것이 아니라 필요한 경우 사용할 수 있는 스트리밍 메커니즘으로 들어가 있습니다.

    그래서 이름이 꽤 정확합니다.

    HTTP
     +
    optional streaming
     =
    Streamable HTTP

    6. 기존 SSE Transport는 어떻게 동작했을까?

    이 차이를 이해하려면 기존 MCP의 HTTP+SSE Transport를 살펴보는 것이 좋습니다.

    단순화하면 Client는 먼저 SSE 연결을 만들었습니다.

    MCP Client                     MCP Server
        │                              │
        │──── GET /sse ───────────────>│
        │                              │
        │<════ SSE Connection ═════════│
        │                              │

    그리고 서버는 이 SSE 연결을 통해 Client가 메시지를 보낼 HTTP endpoint를 알려줄 수 있었습니다.

    개념적으로 다음과 같은 구조입니다.

                      SSE
    MCP Client <════════════════ MCP Server
        │
        │
        │ POST message
        └──────────────────────> MCP Server

    즉 메시지 흐름을 단순화하면:

    Client → Server
        HTTP POST
    
    Server → Client
        SSE

    였습니다.

    그래서 두 개의 통신 경로를 관리하게 됩니다.

              POST
    Client ─────────────> Server
    
              SSE
    Client <═════════════ Server

    동작은 가능하지만 구조가 조금 복잡합니다.


    7. Streamable HTTP에서는 구조가 단순해진다

    Streamable HTTP에서는 MCP 통신을 하나의 MCP HTTP endpoint 중심으로 구성할 수 있습니다.

    예를 들어:

    POST /mcp

    입니다.

    Client가 메시지를 보냅니다.

    Client
       │
       │ POST /mcp
       ▼
    MCP Server

    서버는 요청 특성에 따라 적절한 Response를 반환합니다.

                      POST /mcp
                          │
                          ▼
                     MCP Server
                          │
              ┌───────────┴───────────┐
              │                       │
              ▼                       ▼
      application/json        text/event-stream
              │                       │
              ▼                       ▼
         JSON Response            SSE Stream

    이렇게 보면 Streamable HTTP라는 이름이 훨씬 이해하기 쉬워집니다.


    8. 그런데 GET은 왜 필요할까?

    여기서 조금 더 깊이 들어가보겠습니다.

    Streamable HTTP에서 핵심은 POST이지만, 경우에 따라 Client가 서버에서 발생하는 메시지를 받기 위한 SSE Stream을 열 수 있습니다.

    개념적으로는 다음과 같습니다.

    MCP Client                     MCP Server
        │                              │
        │──── GET /mcp ───────────────>│
        │                              │
        │<════ SSE Stream ═════════════│
        │                              │

    이 연결을 통해 서버가 Client에게 메시지를 전달할 수 있습니다.

    즉 전체적으로 보면 이런 형태가 가능합니다.

                    POST /mcp
    Client ─────────────────────────> Server
    
    
                     GET /mcp
    Client ─────────────────────────> Server
    Client <════════ SSE ════════════ Server

    여기서 중요한 것은 모든 요청마다 반드시 SSE 연결이 필요한 것은 아니라는 점입니다.

    필요한 통신 패턴에 따라 사용할 수 있습니다.


    9. 하나의 Endpoint가 중요한 이유

    기존 구조를 다시 보겠습니다.

    /sse
    
    /messages

    처럼 통신 역할이 나뉘어 있을 수 있었습니다.

    Streamable HTTP에서는 개념적으로 다음처럼 단순화됩니다.

    /mcp

    그리고 HTTP Method가 역할을 구분합니다.

    POST /mcp
        │
        └── Client → Server message
    
    
    GET /mcp
        │
        └── Server → Client SSE stream

    이 구조는 일반적인 HTTP 인프라와도 잘 맞습니다.

    예를 들어:

    Client
       │
       ▼
    Load Balancer
       │
       ▼
    API Gateway / Reverse Proxy
       │
       ▼
    MCP Server

    기존 HTTP 운영 환경에서 다루기 훨씬 자연스러운 구조가 됩니다.


    10. HTTP 인프라를 그대로 활용할 수 있다

    WebSocket은 매우 강력하지만 일반 HTTP와 연결 생명주기가 다릅니다.

    HTTP
    
    Request
       ↓
    Response
       ↓
    End

    WebSocket은 연결을 Upgrade한 뒤 장시간 유지합니다.

    HTTP
     │
     │ Upgrade
     ▼
    WebSocket
     │
     │
     │ persistent connection
     │
     │
     ▼

    반면 Streamable HTTP는 기본적으로 HTTP의 세계에 남아 있습니다.

    HTTP POST
    
    HTTP GET
    
    HTTP Status
    
    HTTP Header
    
    Authentication
    
    Proxy
    
    Load Balancer

    따라서 기존 웹 인프라와 자연스럽게 통합할 수 있다는 장점이 있습니다.

    특히 MCP Server를 실제 운영 환경에 배포한다고 생각하면 이 차이가 중요해집니다.


    11. Stateless하게 사용할 수도 있다

    Streamable HTTP의 또 다른 중요한 특징은 반드시 모든 Client 연결에 대해 무거운 장기 세션 상태를 유지해야 하는 것은 아니라는 점입니다.

    예를 들어 아주 단순한 MCP Server라면:

    Client
       │
       │ POST tools/call
       ▼
    Server
       │
       │ Tool 실행
       ▼
    Response

    요청 하나가 끝나면 서버가 별도의 연결 상태를 유지하지 않아도 됩니다.

    이런 구조는 서버 확장에도 유리합니다.

                     Load Balancer
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
          MCP #1        MCP #2        MCP #3

    특정 Client가 반드시 같은 서버 프로세스에 계속 붙어 있어야 하는 구조보다 운영이 단순해질 수 있습니다.

    물론 실제 MCP 시스템에서는 세션이 필요한 경우도 있습니다.

    중요한 것은 Transport 자체가 모든 통신을 하나의 영구 연결에 강하게 묶지 않는다는 것입니다.


    12. Session이 필요한 경우도 있다

    반대로 Client와 Server 사이에 상태를 유지해야 하는 경우도 있습니다.

    예를 들어 Server가 Client에게 Session ID를 제공한다고 생각해봅시다.

    Client                         Server
       │                              │
       │──── initialize ─────────────>│
       │                              │
       │<──── Session ID: abc123 ─────│

    이후 Client는 해당 Session을 식별해서 요청할 수 있습니다.

    POST /mcp
    
    Mcp-Session-Id: abc123

    개념적으로 보면:

    Client
       │
       │ Session abc123
       │
       ├──── tools/list
       │
       ├──── tools/call
       │
       └──── resources/read
       │
       ▼
    MCP Server

    따라서 Streamable HTTP는 단순한 Stateless HTTP만을 의미하는 것도 아닙니다.

    필요한 경우 Session을 유지할 수도 있습니다.


    13. 그래서 Streamable HTTP는 양방향인가?

    여기서 재미있는 질문이 생깁니다.

    Streamable HTTP는 양방향 통신인가?

    WebSocket과 같은 의미의 Full-Duplex 양방향 연결이라고 생각하면 조금 다릅니다.

    WebSocket은 하나의 연결에서 양쪽이 자유롭게 메시지를 보냅니다.

    Client <════════════════════> Server

    Streamable HTTP는 기본적으로 HTTP Request와 Response 모델을 사용합니다.

    Client ───── Request ───────> Server
    Client <──── Response ─────── Server

    필요한 경우 서버 방향의 스트림을 추가할 수 있습니다.

    Client ───── GET ───────────> Server
    Client <════ SSE ════════════ Server

    따라서 통신 결과만 보면 양쪽 방향의 메시지를 처리할 수 있지만,

    WebSocket처럼 하나의 Full-Duplex 연결을 만드는 방식과는 구조적으로 다릅니다.


    14. 세 가지를 다시 비교해보자

    이제 SSE, WebSocket, Streamable HTTP를 한 번에 비교해보겠습니다.

    SSE

    Server ───────────────────> Client

    특징:

    • Server → Client 스트리밍
    • HTTP 기반
    • 단순한 이벤트 스트림
    • LLM 응답이나 진행 상태 전달에 적합

    WebSocket

    Server <══════════════════> Client

    특징:

    • Full-Duplex
    • 지속적인 연결
    • 양쪽에서 자유롭게 메시지 전송
    • 실시간 상호작용에 적합

    Streamable HTTP

    Client ───── POST ─────────> Server
    Client <──── JSON ────────── Server
    
    필요한 경우
    
    Client <════ SSE ═══════════ Server

    특징:

    • HTTP Request/Response 중심
    • 필요할 때 Streaming
    • SSE 사용 가능
    • RPC 형태의 통신에 적합
    • 기존 HTTP 인프라와 잘 맞음

    즉 셋은 누가 더 좋은 기술인가를 비교하는 관계가 아닙니다.

    해결하려는 문제가 다릅니다.


    15. 실제 AI 서비스에서는 어떻게 사용할까?

    이제 실제 시스템에 적용해봅시다.

    사용자가 웹에서 인보이스를 업로드하고 AI Agent가 이를 분석한다고 가정하겠습니다.

    전체 구조는 다음과 같습니다.

    Browser
       │
       │ POST /analyze
       ▼
    FastAPI
       │
       ▼
    Agent
       │
       │ MCP
       ▼
    MCP Client
       │
       │ Streamable HTTP
       ▼
    MCP Server
       │
       ├── OCR Tool
       ├── Invoice Parser
       └── Validation Tool

    여기서 두 종류의 스트리밍이 존재할 수 있습니다.

    첫 번째는 사용자 경험을 위한 스트리밍입니다.

    FastAPI
       │
       ├── "분석 시작"
       ├── "OCR 진행 중"
       ├── "필드 추출 중"
       └── "완료"
            │
            ▼
          Browser

    이 구간에서는 SSE가 좋은 선택이 될 수 있습니다.

    FastAPI ═════ SSE ═════> Browser

    반면 Agent와 MCP Server 사이에서는 Tool 호출이라는 RPC적인 통신이 발생합니다.

    Agent
      │
      │ tools/call
      ▼
    MCP Server
      │
      │ Tool 실행
      ▼
    Result

    여기에서는 Streamable HTTP가 자연스럽습니다.

    Agent ══ Streamable HTTP ══> MCP Server

    즉 같은 시스템 안에서도 서로 다른 통신 방식이 사용될 수 있습니다.


    16. SSE와 Streamable HTTP는 경쟁 관계가 아니다

    이 시리즈에서 가장 강조하고 싶은 부분입니다.

    처음에는 이렇게 생각하기 쉽습니다.

    SSE vs Streamable HTTP

    마치 Streamable HTTP가 SSE를 대체하기 위해 등장한 새로운 스트리밍 기술처럼 느껴집니다.

    하지만 실제 시스템에서는 이렇게 보는 편이 더 정확합니다.

    Web Application
    
    Browser
       │
       │ SSE
       ▼
    FastAPI
    
    
    Agent System
    
    Agent
       │
       │ Streamable HTTP
       ▼
    MCP Server

    두 기술은 서로 다른 구간에서 서로 다른 문제를 해결할 수 있습니다.

    Browser와 FastAPI 사이에서는 사용자가 작업 진행 상황이나 LLM의 생성 결과를 실시간으로 받아야 할 수 있습니다.

    Browser <════ SSE ════ FastAPI

    이때 SSE는 매우 단순하고 효과적인 방법입니다.

    반면 Agent와 MCP Server 사이에서는 단순한 이벤트 전달보다 Tool 호출, Request/Response, Notification, Session 같은 RPC적인 통신이 중요합니다.

    Agent ──── tools/call ──────> MCP Server
    Agent <──── result ────────── MCP Server

    이런 구간에서는 Streamable HTTP가 더 자연스럽습니다.

    따라서 둘의 관계를

    SSE
     ↓
    구식 기술
    
    Streamable HTTP
     ↓
    새로운 기술

    처럼 이해해서는 안 됩니다.

    오히려 다음과 같이 보는 편이 좋습니다.

    SSE
    │
    └── Server → Client Streaming
    
    
    Streamable HTTP
    │
    ├── HTTP Request / Response
    │
    └── 필요할 경우 SSE Streaming

    그리고 중요한 사실이 하나 있습니다.

    Streamable HTTP 자체도 필요할 때 SSE를 사용합니다.

    즉 SSE는 Streamable HTTP의 경쟁자가 아니라, Streamable HTTP가 스트리밍을 구현할 때 활용할 수 있는 메커니즘이기도 합니다.


    17. 왜 MCP는 이런 구조가 필요했을까?

    이제 다시 MCP의 목적을 생각해봅시다.

    MCP Server는 단순한 스트리밍 서버가 아닙니다.

    AI Agent에게 외부 기능을 제공하는 서버입니다.

    예를 들어 다음과 같은 Tool이 있다고 해보겠습니다.

    search_documents()
    
    analyze_invoice()
    
    send_email()
    
    query_database()
    
    generate_report()

    Agent는 필요한 순간에 특정 Tool을 호출합니다.

    Agent
      │
      │ tools/call
      ▼
    MCP Server
      │
      ▼
    Tool

    대부분의 Tool 호출은 매우 자연스러운 Request/Response 구조를 가집니다.

    Request
    
    "이 Tool을 실행해줘"
    
            ↓
    
    Tool 실행
    
            ↓
    
    Response
    
    "결과는 이것이야"

    그런데 Tool의 특성은 모두 다릅니다.

    어떤 Tool은 100ms 안에 끝날 수 있습니다.

    get_current_time()
    
          ↓
    
       100ms
    
          ↓
    
       Result

    어떤 Tool은 수십 초가 걸릴 수도 있습니다.

    analyze_invoice()
    
          ↓
    
    PDF Rendering
    
          ↓
    
    VLM Analysis
    
          ↓
    
    Field Extraction
    
          ↓
    
    Validation
    
          ↓
    
    Result

    따라서 MCP Transport는 두 가지 상황을 모두 다룰 수 있어야 합니다.

    짧은 작업
       │
       ▼
    Request / Response
    
    
    긴 작업
       │
       ▼
    Request / Streaming / Response

    Streamable HTTP는 바로 이런 특성에 잘 맞습니다.


    18. WebSocket을 기본 Transport로 사용하지 않는 이유도 보인다

    여기까지 이해하면 처음에 했던 질문으로 다시 돌아갈 수 있습니다.

    그냥 WebSocket을 사용하면 더 간단하지 않을까?

    WebSocket을 사용하면 물론 양방향 메시징을 구현할 수 있습니다.

    MCP Client <══════════════> MCP Server

    하지만 MCP의 대부분의 동작은 본질적으로 RPC에 가깝습니다.

    tools/list
    
    tools/call
    
    resources/read
    
    prompts/get

    즉 기본 패턴은 다음과 같습니다.

    Request
       │
       ▼
    Server Processing
       │
       ▼
    Response

    모든 요청을 처리하기 위해 반드시 영구적인 Full-Duplex 연결을 유지해야 하는 것은 아닙니다.

    HTTP가 이미 잘 처리하고 있는 문제를 굳이 다른 통신 모델로 바꿀 필요가 없는 것입니다.

    또 실제 운영 환경을 생각하면 HTTP가 가진 장점도 큽니다.

    Reverse Proxy
    
    Load Balancer
    
    Authentication
    
    HTTP Header
    
    HTTP Status
    
    Observability
    
    Rate Limiting
    
    Existing Infrastructure

    많은 인프라가 이미 HTTP를 중심으로 만들어져 있습니다.

    Streamable HTTP는 이런 생태계를 그대로 활용할 수 있습니다.


    19. 이름에 답이 있다

    이제 다시 이름을 보겠습니다.

    Streamable HTTP

    처음에는 조금 애매하게 느껴졌던 이름이 이제는 꽤 정확하게 보입니다.

    핵심은 이것입니다.

    HTTP를 사용한다. 그리고 필요한 경우 Stream할 수 있다.

    즉,

    HTTP
     │
     ├── 일반 Response 가능
     │
     └── Streaming Response 가능

    입니다.

    항상 연결을 유지하는 것도 아니고,

    항상 SSE를 사용하는 것도 아니며,

    항상 Streaming하는 것도 아닙니다.

    필요한 통신 방식에 따라 HTTP의 Response 형태를 선택할 수 있습니다.

    그래서 Streaming HTTP가 아니라 Streamable HTTP입니다.


    20. 세 기술을 한 문장으로 정리하면

    이제 SSE, WebSocket, Streamable HTTP를 아주 단순하게 정리해보겠습니다.

    SSE

    서버가 클라이언트에게 지속적으로 데이터를 보내고 싶다.

    Server ─────────────────> Client

    LLM 답변, 작업 진행 상황, 알림 등에 잘 맞습니다.


    WebSocket

    클라이언트와 서버가 지속적으로 서로 메시지를 주고받고 싶다.

    Server <════════════════> Client

    채팅, 게임, 협업 시스템처럼 지속적인 Full-Duplex 통신에 잘 맞습니다.


    Streamable HTTP

    HTTP Request/Response를 기본으로 사용하면서 필요한 경우 스트리밍도 하고 싶다.

    Client ───── Request ─────> Server
    Client <──── Response ───── Server
    
              또는
    
    Client ───── Request ─────> Server
    Client <════ SSE Stream ═══ Server

    RPC 기반의 MCP 통신에 잘 맞습니다.


    21. 실제 시스템에서는 함께 사용한다

    중요한 것은 하나의 기술을 선택해서 시스템 전체에 적용해야 하는 것이 아니라는 점입니다.

    예를 들어 AI 기반 인보이스 분석 서비스를 만든다고 생각해보겠습니다.

    ┌──────────────┐
    │   Browser    │
    └──────┬───────┘
           │
           │ HTTP
           ▼
    ┌──────────────┐
    │   FastAPI    │
    └──────┬───────┘
           │
           │
           ▼
    ┌──────────────┐
    │    Agent     │
    └──────┬───────┘
           │
           │ MCP
           ▼
    ┌──────────────┐
    │  MCP Server  │
    └──────┬───────┘
           │
           ├── OCR Tool
           ├── Parser Tool
           └── Validation Tool

    여기서 Browser와 FastAPI 사이에서는 사용자에게 진행 상황을 보여주고 싶을 수 있습니다.

    Browser <════ SSE ════ FastAPI
    
    "파일 업로드 완료"
    
    "OCR 진행 중"
    
    "데이터 추출 중"
    
    "검증 중"
    
    "완료"

    반면 Agent와 MCP Server 사이에서는 Tool 호출이 이루어집니다.

    Agent
      │
      │ tools/call
      ▼
    MCP Server
      │
      ▼
    OCR Tool

    이 구간에서는 Streamable HTTP를 사용할 수 있습니다.

    Browser
       │
       │ SSE
       ▼
    FastAPI
       │
       ▼
    Agent
       │
       │ Streamable HTTP
       ▼
    MCP Server

    이 그림을 보면 SSE와 Streamable HTTP를 왜 단순 비교해서는 안 되는지 명확해집니다.

    사용되는 계층과 해결하는 문제가 다르기 때문입니다.


    22. 결국 중요한 것은 Transport가 아니라 요구사항이다

    개발하다 보면 새로운 기술이 등장할 때마다 이런 질문을 하게 됩니다.

    SSE가 좋은가?
    
    WebSocket이 좋은가?
    
    Streamable HTTP가 좋은가?

    하지만 사실 이 질문에는 정답이 없습니다.

    먼저 물어야 하는 것은 이것입니다.

    데이터는 어느 방향으로 흐르는가?
    
    연결을 계속 유지해야 하는가?
    
    Request / Response 관계가 중요한가?
    
    서버가 임의의 시점에 메시지를 보내야 하는가?
    
    스트리밍이 항상 필요한가?
    
    세션 상태를 유지해야 하는가?
    
    기존 HTTP 인프라를 활용해야 하는가?

    이 요구사항이 정해진 뒤 Transport를 선택해야 합니다.

    예를 들어 단순한 LLM 스트리밍이라면:

    SSE

    만으로 충분할 수 있습니다.

    실시간 협업 시스템이라면:

    WebSocket

    이 자연스러울 수 있습니다.

    Agent와 Tool Server 사이의 RPC 통신이라면:

    Streamable HTTP

    가 좋은 선택이 될 수 있습니다.

    기술을 먼저 선택하고 시스템을 거기에 맞추는 것이 아니라,

    통신의 특성을 먼저 이해하고 그에 맞는 기술을 선택하는 것이 중요합니다.


    마치며

    이번 시리즈는 아주 단순한 질문에서 시작했습니다.

    HTTP는 Response를 한 번 보내는데 어떻게 데이터를 계속 전달할 수 있을까?

    1부에서는 그 답으로 SSE를 살펴봤습니다.

    HTTP Response
       │
       ├── data
       ├── data
       ├── data
       └── end

    그리고 2부에서는 SSE만으로 모든 통신 문제를 해결할 수 없다는 것을 살펴봤습니다.

    복잡한 AI 시스템에서는 단순한 스트리밍뿐 아니라 다음과 같은 메시지의 관계가 중요했습니다.

    Request
    
    Response
    
    Notification
    
    Streaming
    
    Session

    그리고 이번 3부에서는 MCP가 왜 Streamable HTTP라는 방식을 사용하는지 살펴봤습니다.

    Streamable HTTP의 핵심은 의외로 단순합니다.

    HTTP Request/Response를 기본으로 사용하고, 필요한 경우 스트리밍할 수 있도록 한다.

    그래서 일반적인 요청은 평범한 HTTP Response로 끝날 수 있습니다.

    Request
       │
       ▼
    JSON Response

    하지만 스트리밍이 필요하다면 SSE를 사용할 수도 있습니다.

    Request
       │
       ▼
    SSE Stream
       │
       ├── message
       ├── message
       ├── message
       └── end

    즉 Streamable HTTP는 SSE를 버린 것이 아닙니다.

    오히려 HTTP와 SSE를 필요한 상황에 맞게 활용할 수 있도록 Transport 구조를 정리한 것에 가깝습니다.

    그리고 이 구조는 MCP처럼 다양한 Tool을 호출하는 AI Agent 시스템과 잘 맞습니다.

    Browser
       │
       │ SSE
       ▼
    FastAPI
       │
       ▼
    Agent
       │
       │ Streamable HTTP
       ▼
    MCP Server
       │
       ├── Tool
       ├── Tool
       └── Tool

    결국 중요한 것은 어떤 기술이 더 최신인가가 아닙니다.

    어떤 구간에서 어떤 종류의 통신이 필요한가를 이해하는 것입니다.

    SSE, WebSocket, Streamable HTTP는 서로를 대체하기 위해 존재하는 기술이라기보다 각자 다른 통신 문제를 해결하기 위한 도구입니다.

    이 차이를 이해하면 AI Agent와 MCP 기반 시스템을 설계할 때 Transport를 훨씬 명확하게 선택할 수 있습니다.


    시리즈 정리

    1부

    HTTP 연결 하나로 데이터를 계속 보내는 SSE

    HTTP Response와 Streaming의 기본 원리를 살펴봤습니다.

    2부

    SSE만으로는 부족한가? 양방향 통신과 RPC의 문제

    SSE와 WebSocket의 차이, 그리고 MCP에서 Request/Response와 Notification이 왜 중요한지 살펴봤습니다.

    3부

    MCP는 왜 Streamable HTTP를 사용하는가

    HTTP Request/Response와 SSE Streaming을 함께 활용하는 Streamable HTTP의 구조와 MCP에서 이 방식이 필요한 이유를 살펴봤습니다.

  • SSE와 Streamable HTTP 2부 — SSE만으로는 부족한가? 양방향 통신과 RPC의 문제

    SSE와 Streamable HTTP 2부 — SSE만으로는 부족한가? 양방향 통신과 RPC의 문제

    1부에서는 SSE(Server-Sent Events)가 어떻게 하나의 HTTP Response를 이용해 서버에서 클라이언트로 데이터를 계속 전달할 수 있는지 살펴봤습니다.

    핵심은 단순했습니다.

    HTTP Response는 반드시 한 번에 전달될 필요가 없다.

    하나의 HTTP Response를 종료하지 않고 데이터를 조금씩 전달하면 서버는 클라이언트에게 지속적으로 이벤트를 보낼 수 있습니다.

    Client                         Server
       │                              │
       │──── HTTP Request ───────────>│
       │                              │
       │<──── data: message 1 ────────│
       │<──── data: message 2 ────────│
       │<──── data: message 3 ────────│
       │<──── data: done ─────────────│
       │                              │

    LLM의 답변 스트리밍이나 작업 진행 상태를 전달하는 용도라면 이 구조만으로도 상당히 많은 문제를 해결할 수 있습니다.

    그런데 시스템이 조금 더 복잡해지면 질문이 하나 생깁니다.

    서버만 계속 데이터를 보내면 충분할까?

    이번 글에서는 이 질문에서 시작해보겠습니다.


    1. SSE의 방향은 기본적으로 Server → Client다

    SSE의 데이터 흐름은 기본적으로 단방향입니다.

    Server ─────────────────────> Client

    서버가 이벤트를 생성하고 클라이언트가 이를 받습니다.

    예를 들어 LLM 서비스라면 이런 구조가 자연스럽습니다.

    Client
       │
       │ POST /question
       ▼
    Server
       │
       │ LLM 실행
       │
       ├──── token
       ├──── token
       ├──── token
       └──── done
             │
             ▼
           Client

    질문은 일반적인 HTTP Request로 보내고, 생성되는 결과는 스트림으로 받습니다.

    파일 분석 서비스도 비슷합니다.

    Client
       │
       │ PDF Upload
       ▼
    Server
       │
       ├── parsing...
       ├── OCR...
       ├── analyzing...
       ├── extracting...
       └── completed
             │
             ▼
           Client

    이런 상황에서는 서버에서 클라이언트 방향의 데이터 흐름이 대부분이기 때문에 SSE가 매우 잘 맞습니다.

    하지만 모든 시스템이 이렇게 단순하지는 않습니다.


    2. 클라이언트도 계속 메시지를 보내야 한다면?

    이번에는 채팅 시스템을 생각해봅시다.

    사용자가 메시지를 보내고 서버가 응답합니다.

    그런데 응답이 끝나기 전에 사용자가 작업을 취소할 수도 있습니다.

    Client                         Server
       │                              │
       │──── question ───────────────>│
       │                              │
       │<──── token ──────────────────│
       │<──── token ──────────────────│
       │                              │
       │──── cancel ─────────────────>│
       │                              │
       │<──── cancelled ──────────────│

    조금 더 복잡한 Agent 시스템에서는 이런 흐름도 생길 수 있습니다.

    Client                         Agent
       │                              │
       │──── task ───────────────────>│
       │                              │
       │<──── processing ─────────────│
       │                              │
       │<──── approval_required ──────│
       │                              │
       │──── approve ────────────────>│
       │                              │
       │<──── processing ─────────────│
       │                              │
       │<──── completed ──────────────│

    이제 통신 방향이 한쪽만이 아닙니다.

    Client <────────────────────> Server

    이쯤 되면 자연스럽게 떠오르는 기술이 있습니다.

    WebSocket입니다.


    3. 그러면 그냥 WebSocket을 쓰면 되지 않을까?

    WebSocket은 클라이언트와 서버가 하나의 연결을 유지하면서 양방향으로 메시지를 주고받을 수 있도록 합니다.

    Client                         Server
       │                              │
       │<═══════ WebSocket ═════════>│
       │                              │
       │──── message ────────────────>│
       │<──── message ────────────────│
       │──── message ────────────────>│
       │<──── message ────────────────│

    SSE와 비교하면 차이가 분명합니다.

    SSE
    
    Server ─────────────────────> Client
    
    
    WebSocket
    
    Server <────────────────────> Client

    실시간 채팅, 멀티플레이 게임, 공동 편집, 실시간 제어처럼 양쪽에서 지속적으로 메시지가 발생하는 시스템이라면 WebSocket은 매우 자연스러운 선택입니다.

    그렇다면 여기서 이런 의문이 생깁니다.

    MCP 같은 시스템도 Client와 Server가 계속 통신해야 하는데 그냥 WebSocket을 사용하면 되지 않을까?

    기술적으로는 가능합니다.

    하지만 여기에는 조금 다른 문제가 있습니다.


    4. 모든 통신이 양방향 스트림일 필요는 없다

    MCP Client가 MCP Server에게 Tool 목록을 요청한다고 생각해봅시다.

    MCP Client                    MCP Server
        │                             │
        │──── tools/list ────────────>│
        │                             │
        │<──── tool list ─────────────│
        │                             │

    이것은 전형적인 Request/Response입니다.

    Tool을 호출하는 경우도 비슷합니다.

    MCP Client                    MCP Server
        │                             │
        │──── tools/call ────────────>│
        │                             │
        │        Tool 실행            │
        │                             │
        │<──── result ────────────────│

    이런 요청을 처리하기 위해 반드시 장시간 유지되는 완전한 양방향 메시징 채널이 필요한 것은 아닙니다.

    HTTP가 이미 아주 잘하는 방식이 있습니다.

    Request
       │
       ▼
    Server
       │
       ▼
    Response

    그렇다면 기존 HTTP Request/Response 모델을 버릴 이유가 있을까요?

    오히려 필요한 것은 이런 구조일 수 있습니다.

    일반적인 요청
    
    Client ─── Request ─────────> Server
    Client <── Response ───────── Server
    
    
    스트리밍이 필요한 요청
    
    Client ─── Request ─────────> Server
    Client <── event ──────────── Server
    Client <── event ──────────── Server
    Client <── event ──────────── Server
    Client <── result ─────────── Server

    즉,

    평소에는 HTTP Request/Response를 사용하고, 필요할 때만 Response를 스트리밍할 수 있으면 어떨까?

    여기서 중요한 관점의 변화가 시작됩니다.


    5. MCP 통신은 단순한 채팅이 아니다

    MCP Client와 Server 사이에서는 다양한 종류의 메시지가 오갈 수 있습니다.

    예를 들어 다음과 같습니다.

    initialize
    
    tools/list
    
    tools/call
    
    resources/list
    
    resources/read

    그리고 모든 요청의 특성이 같지는 않습니다.

    어떤 요청은 즉시 끝날 수 있습니다.

    tools/list
        │
        ▼
    200 OK

    반면 어떤 Tool은 오래 걸릴 수 있습니다.

    tools/call
        │
        ▼
    PDF 분석
        │
        ▼
    OCR
        │
        ▼
    데이터 추출
        │
        ▼
    검증
        │
        ▼
    Result

    또 경우에 따라 서버에서 클라이언트로 별도의 메시지를 전달해야 할 수도 있습니다.

    즉 MCP 통신에는 다음과 같은 성격이 함께 존재합니다.

    Request / Response
    
    Notification
    
    Streaming

    이것이 단순한 SSE와 WebSocket의 선택 문제만으로 보기 어려운 이유입니다.


    6. RPC 관점에서 생각해보자

    여기서 RPC라는 개념을 잠깐 살펴볼 필요가 있습니다.

    RPC는 Remote Procedure Call의 약자입니다.

    이름 그대로 다른 프로세스나 다른 서버에 있는 기능을 마치 함수를 호출하듯 사용하는 개념입니다.

    로컬 Python 함수라면 다음과 같이 호출할 수 있습니다.

    result = analyze_invoice("invoice.pdf")

    하지만 analyze_invoice()가 다른 서버에 있다면 직접 호출할 수 없습니다.

    네트워크를 통해 요청해야 합니다.

    Agent
      │
      │ analyze_invoice("invoice.pdf")
      ▼
    Network
      │
      ▼
    MCP Server
      │
      ▼
    analyze_invoice()

    논리적으로는 함수 호출처럼 보이지만 실제로는 네트워크 메시지가 오갑니다.

    Client                           Server
       │                                │
       │──── method: analyze_invoice ──>│
       │                                │
       │            실행                │
       │                                │
       │<──── result ───────────────────│

    MCP 역시 이런 RPC적인 성격을 가지고 있습니다.

    Client가 Server에게 특정 기능의 실행을 요청하고 결과를 받습니다.


    7. 요청과 응답을 어떻게 구분할까?

    네트워크에서는 여러 요청이 동시에 발생할 수도 있습니다.

    Client                         Server
       │                              │
       │──── Request A ──────────────>│
       │──── Request B ──────────────>│
       │──── Request C ──────────────>│
       │                              │

    그런데 처리 시간이 모두 같다는 보장은 없습니다.

    Request A ───────────────── Result A
    
    Request B ─── Result B
    
    Request C ───────── Result C

    Request B가 A보다 늦게 들어왔지만 먼저 끝날 수도 있습니다.

    그래서 RPC에서는 요청을 식별할 수 있는 ID가 중요합니다.

    예를 들어 JSON-RPC에서는 개념적으로 다음과 같은 요청을 보낼 수 있습니다.

    {
      "jsonrpc": "2.0",
      "id": 10,
      "method": "tools/call",
      "params": {
        "name": "analyze_invoice"
      }
    }

    서버의 응답에는 같은 ID가 포함됩니다.

    {
      "jsonrpc": "2.0",
      "id": 10,
      "result": {
        "status": "completed"
      }
    }

    그러면 Client는 알 수 있습니다.

    Request id=10
          │
          ▼
      MCP Server
          │
          ▼
    Response id=10

    즉,

    이 Response는 내가 보냈던 id=10 요청의 결과구나.

    이렇게 Request와 Response를 연결할 수 있습니다.


    8. Notification은 조금 다르다

    모든 메시지가 Response를 요구하는 것도 아닙니다.

    상대방에게 어떤 사실을 알려주기만 하면 되는 메시지도 있습니다.

    Client                         Server
       │                              │
       │<──── Notification ───────────│
       │                              │

    Notification은 일반적인 RPC Request와 달리 응답을 요구하지 않습니다.

    따라서 통신 구조는 점점 다양해집니다.

    Request
       │
       └────────> Response
    
    
    Notification
       │
       └────────> No Response
    
    
    Long-running Request
       │
       ├────────> Progress
       ├────────> Progress
       ├────────> Progress
       └────────> Result

    이제 단순히

    Server → Client

    또는

    Server ↔ Client

    만으로 설명하기에는 부족합니다.


    9. 중요한 것은 방향만이 아니라 메시지의 성격이다

    SSE와 WebSocket을 비교하다 보면 흔히 통신 방향에만 집중하게 됩니다.

    SSE       : Server → Client
    
    WebSocket : Server ↔ Client

    물론 중요한 차이입니다.

    하지만 MCP 같은 RPC 시스템에서는 또 다른 질문이 중요합니다.

    이 메시지는 무엇을 의미하는가?

    예를 들어 다음을 구분해야 합니다.

    Request인가?
    
    Response인가?
    
    Notification인가?
    
    중간 진행 상황인가?
    
    최종 결과인가?

    즉 통신 채널뿐만 아니라 메시지의 의미와 생명주기도 관리해야 합니다.


    10. Agent 시스템에서는 더 복잡해진다

    AI Agent를 생각하면 이 문제가 더욱 명확해집니다.

    예를 들어 사용자가 인보이스 PDF를 업로드했다고 해봅시다.

    User
     │
     │ invoice.pdf
     ▼
    Agent

    Agent는 MCP Tool을 호출합니다.

    Agent
     │
     │ tools/call
     ▼
    MCP Server
     │
     ▼
    Invoice Tool

    그런데 분석 과정에서는 여러 단계가 발생할 수 있습니다.

    Invoice Tool
         │
         ▼
      Parsing
         │
         ▼
        OCR
         │
         ▼
    Field Extraction
         │
         ▼
     Validation
         │
         ▼
       Result

    어떤 작업은 100ms 만에 끝날 수도 있고, 어떤 작업은 20초가 걸릴 수도 있습니다.

    심지어 중간에 사용자 확인이 필요할 수도 있습니다.

    Agent
      │
      │ tools/call
      ▼
    MCP Server
      │
      ├── OCR
      │
      ├── Extraction
      │
      └── Human Review Required
                  │
                  ▼
                 User
                  │
               Approve
                  │
                  ▼
               Continue

    이런 시스템에서는 단순히 “연결 하나를 열어놓자”만으로 문제가 해결되지 않습니다.

    요청과 응답의 관계, 상태, 이벤트, 스트리밍 등을 함께 고려해야 합니다.


    11. 그렇다면 우리가 원하는 통신 방식은?

    지금까지의 요구사항을 정리해보겠습니다.

    우리는 기존 HTTP의 장점을 활용하고 싶습니다.

    POST
    GET
    HTTP Status
    Authentication
    Proxy
    Load Balancer

    단순한 요청이라면 평범하게 처리하고 싶습니다.

    Request
       │
       ▼
    Response

    하지만 필요한 경우에는 스트리밍도 하고 싶습니다.

    Request
       │
       ▼
    Response
       │
       ├── event
       ├── event
       ├── event
       └── result

    그리고 서버에서 발생하는 Notification도 처리할 수 있어야 합니다.

    결국 우리가 원하는 모습은 대략 이렇습니다.

                 HTTP
    
    Client ───── POST ───────────> Server
    Client <──── JSON ──────────── Server
    
    
           필요할 때 Streaming
    
    Client ───── Request ────────> Server
    Client <──── Event ─────────── Server
    Client <──── Event ─────────── Server
    Client <──── Result ────────── Server

    여기서 재미있는 질문이 하나 생깁니다.

    HTTP Request/Response의 단순함은 유지하면서 필요할 때만 스트리밍할 수는 없을까?

    바로 이 질문이 Streamable HTTP를 이해하는 출발점입니다.


    12. Streaming HTTP가 아니라 Streamable HTTP

    이름을 자세히 보면 재미있는 부분이 있습니다.

    Streaming HTTP가 아니라 Streamable HTTP입니다.

    이 차이가 꽤 중요합니다.

    모든 통신을 항상 스트리밍한다는 의미가 아닙니다.

    필요하다면 일반적인 HTTP Response를 사용할 수 있습니다.

    Request
       │
       ▼
    JSON Response

    그리고 필요한 경우에는 스트리밍 Response를 사용할 수도 있습니다.

    Request
       │
       ▼
    Streaming Response
       │
       ├── event
       ├── event
       ├── event
       └── end

    즉 핵심은 항상 Streaming이 아니라 Streamable, 다시 말해 필요하면 스트리밍할 수 있다는 것입니다.

    그리고 이것이 다음 편에서 살펴볼 핵심 주제입니다.


    마치며

    1부에서는 SSE를 통해 이런 사실을 확인했습니다.

    하나의 HTTP Response는 데이터를 조금씩 전달할 수 있다.

    이번 2부에서는 여기서 한 단계 더 나아갔습니다.

    SSE는 Server → Client 스트리밍에 매우 유용하지만, MCP 같은 RPC 시스템에서는 단순한 데이터 방향만으로는 충분하지 않습니다.

    우리가 다뤄야 하는 것은 다음과 같은 것들입니다.

    Request
    Response
    Notification
    Streaming
    Session

    WebSocket처럼 완전한 양방향 채널을 만드는 것도 하나의 방법입니다.

    하지만 모든 요청이 지속적인 양방향 통신을 필요로 하는 것은 아닙니다.

    오히려 이런 질문을 해볼 수 있습니다.

    HTTP의 단순한 Request/Response 구조는 그대로 사용하면서, 정말 필요한 순간에만 스트리밍할 수는 없을까?

    이 질문에 대한 답이 Streamable HTTP입니다.

    다음 3부에서는 드디어 Streamable HTTP의 구조를 본격적으로 살펴보겠습니다.

    왜 MCP가 이 방식을 사용하게 되었는지, 일반 HTTP 요청과 SSE가 어떻게 하나의 Transport 안에서 공존하는지, 그리고 기존 SSE 기반 MCP Transport와 무엇이 달라졌는지를 구조적으로 살펴보겠습니다.


    다음 편

    SSE와 Streamable HTTP 3부 — MCP는 왜 Streamable HTTP를 사용하는가

  • SSE와 Streamable HTTP 1부 — HTTP 연결 하나로 데이터를 계속 보내는 SSE

    SSE와 Streamable HTTP 1부 — HTTP 연결 하나로 데이터를 계속 보내는 SSE

    LLM을 이용한 서비스를 개발하다 보면 자연스럽게 스트리밍(Streaming)이라는 말을 접하게 됩니다.

    ChatGPT에 질문을 입력했을 때를 생각해봅시다. 답변 전체가 완성될 때까지 기다렸다가 한꺼번에 화면에 나타나는 것이 아니라, 생성되는 내용이 조금씩 화면에 나타납니다.

    Redis
    Redis Streams는
    Redis Streams는 메시지를
    Redis Streams는 메시지를 순서대로...

    서버에서는 아직 답변을 만들고 있는데 클라이언트는 이미 그 일부를 받고 있습니다. 이것이 우리가 흔히 말하는 스트리밍 응답(Streaming Response)입니다.

    그런데 여기서 한 가지 궁금증이 생깁니다.

    HTTP는 요청(Request)을 보내면 응답(Response)을 한 번 받는 프로토콜 아닌가?

    그렇다면 서버는 어떻게 하나의 HTTP 요청에 데이터를 여러 번 보내는 것처럼 동작할 수 있을까요? 이 질문을 이해하면 SSE(Server-Sent Events)의 동작 방식도 자연스럽게 이해할 수 있습니다.


    1. 우리가 익숙한 HTTP 요청과 응답

    일반적인 HTTP API는 다음과 같이 동작합니다.

    Client                              Server
       │                                   │
       │──── POST /question ──────────────>│
       │                                   │
       │                                   │ 처리
       │                                   │ 처리
       │                                   │ 처리
       │                                   │
       │<──────── Response ────────────────│
       │                                   │

    클라이언트가 요청을 보내면 서버가 작업을 수행합니다. 작업이 끝나면 서버는 결과를 응답으로 돌려줍니다.

    예를 들어 FastAPI에서 다음과 같은 API가 있다고 해봅시다.

    @app.post("/question")
    async def question():
        result = await generate_answer()
        return {"answer": result}

    generate_answer()가 10초 걸린다면 사용자는 10초 동안 아무것도 받지 못합니다.

    0초    질문 전송
     │
     │      서버 처리 중...
     │
    10초   전체 답변 수신

    일반적인 REST API에서는 별문제가 아닙니다. 하지만 LLM처럼 처리 시간이 길면서 중간 결과를 사용자에게 보여줄 수 있는 서비스에서는 이야기가 달라집니다.


    2. LLM은 이미 결과를 만들고 있다

    LLM이 다음과 같은 답변을 생성한다고 해봅시다.

    Redis Streams는 메시지를 순서대로 저장하고
    여러 Consumer가 이를 처리할 수 있도록 제공하는
    Redis의 메시징 기능입니다.

    모델이 10초 동안 아무것도 하지 않다가 마지막 순간에 문장 전체를 갑자기 만들어내는 것은 아닙니다. 출력 토큰은 순차적으로 생성됩니다.

    1초  → "Redis"
    2초  → " Streams는"
    3초  → " 메시지를"
    4초  → " 순서대로"
    ...

    그렇다면 서버는 전체 답변이 완성될 때까지 기다리지 않고, 생성되는 데이터를 클라이언트에게 바로 전달할 수 있습니다.

    LLM
     │
     ├── Redis
     ├── Streams는
     ├── 메시지를
     └── 순서대로...
           │
           ▼
         Client

    여기서 필요한 것이 HTTP Streaming입니다.


    3. HTTP 응답은 반드시 한 번에 보내야 할까?

    HTTP 요청 하나에는 HTTP 응답 하나가 대응합니다. 하지만 HTTP 응답 하나를 반드시 한 번에 전부 보내야 하는 것은 아닙니다.

    일반적인 응답은 다음과 같습니다.

    HTTP/1.1 200 OK
    Content-Type: application/json
    
    {"answer":"Redis Streams는 ..."}

    반면 서버가 응답을 바로 종료하지 않고 데이터를 조금씩 전달할 수도 있습니다.

    HTTP Response 시작
            │
            ├── 데이터 A
            ├── 데이터 B
            ├── 데이터 C
            ├── 데이터 D
            │
            └── Response 종료

    중요한 점은 응답을 네 번 보낸 것이 아니라는 것입니다. HTTP Response는 여전히 하나입니다.

    Request 1개
          │
          ▼
    Response 1개
          ├── chunk
          ├── chunk
          ├── chunk
          └── chunk

    단지 Response Body가 한꺼번에 전달되지 않고 점진적으로 전달되는 것입니다. 이 개념이 SSE를 이해하는 핵심입니다.


    4. SSE(Server-Sent Events)

    SSE는 서버에서 클라이언트 방향으로 이벤트를 전달하기 위한 방식입니다.

    Server ────────────────> Client

    서버는 HTTP 응답을 유지하면서 클라이언트에게 이벤트를 계속 전달합니다.

    Client                              Server
       │                                   │
       │──── GET /events ─────────────────>│
       │                                   │
       │<──── data: hello ─────────────────│
       │<──── data: processing ────────────│
       │<──── data: result ────────────────│
       │<──── data: done ──────────────────│
       │                                   │

    HTTP 요청이 계속 발생하는 것이 아닙니다. 처음 만들어진 HTTP 연결을 통해 하나의 Response가 계속 전달되고 있는 것입니다.


    5. SSE의 text/event-stream

    일반적인 JSON API는 다음과 같은 Content-Type을 사용합니다.

    Content-Type: application/json

    SSE에서는 다음을 사용합니다.

    Content-Type: text/event-stream

    실제 데이터는 대략 다음과 같은 형식으로 전달됩니다.

    data: hello
    
    data: processing
    
    data: result
    
    data: done

    각 이벤트는 빈 줄로 구분됩니다. 이벤트 이름과 JSON 데이터도 사용할 수 있습니다.

    event: progress
    data: {"progress": 30}
    
    event: progress
    data: {"progress": 70}
    
    event: complete
    data: {"result": "finished"}

    6. FastAPI에서는 어떻게 구현할까?

    FastAPI에서는 StreamingResponse를 이용할 수 있습니다.

    from fastapi import FastAPI
    from fastapi.responses import StreamingResponse
    import asyncio
    
    app = FastAPI()
    
    async def event_generator():
        for i in range(5):
            yield f"data: message {i}\n\n"
            await asyncio.sleep(1)
    
    @app.get("/stream")
    async def stream():
        return StreamingResponse(
            event_generator(),
            media_type="text/event-stream"
        )

    여기서 중요한 부분은 yield입니다. return은 값을 반환하고 함수를 종료하지만, yield는 값을 하나 내보낸 뒤 실행 상태를 유지합니다.

    event_generator()
           │
           ├── yield message 0
           │      잠시 중단
           ├── yield message 1
           │      잠시 중단
           ├── yield message 2
           │      ...
           └── 종료

    그래서 서버가 데이터를 생성하는 즉시 HTTP Response Body에 조금씩 전달하는 스트리밍 처리와 잘 어울립니다.


    7. LLM과 SSE가 잘 어울리는 이유

    개념적인 코드는 다음과 같습니다.

    async def generate():
        async for token in llm.stream(prompt):
            yield f"data: {token}\n\n"

    전체 데이터 흐름은 다음과 같습니다.

    LLM
     │ token 생성
     ▼
    Python Async Iterator
     │ yield
     ▼
    StreamingResponse
     │
     ▼
    HTTP
     │
     ▼
    Browser

    LLM이 데이터를 생성할 때마다 서버가 받아 클라이언트에게 바로 전달할 수 있습니다.


    8. SSE가 정말 데이터를 여러 번 보내는 것일까?

    겉으로는 서버가 메시지를 계속 보내는 것처럼 보입니다. 하지만 SSE는 기본적으로 하나의 HTTP Response를 이용합니다.

    Client
       │
       │ HTTP Request
       ▼
    Server
       │
       ▼
    HTTP Response
    ┌───────────────────────────────┐
    │ data: message 1               │
    │                               │
    │ data: message 2               │
    │                               │
    │ data: message 3               │
    │                               │
    │ data: message 4               │
    └───────────────────────────────┘

    새로운 Response가 계속 만들어지는 것이 아닙니다. 하나의 Response가 아직 끝나지 않은 것입니다.


    9. SSE는 양방향 통신이 아니다

    SSE 스트림 자체의 데이터 흐름은 기본적으로 서버에서 클라이언트 방향입니다.

    Server ────────────────> Client

    반면 WebSocket은 연결 이후 양쪽에서 메시지를 주고받을 수 있습니다.

    Client <──────────────> Server

    SSE를 사용하면서 클라이언트가 서버로 데이터를 보내야 한다면 별도의 HTTP 요청을 사용할 수 있습니다.

                 HTTP POST
    Client ──────────────────────> Server
    
                    SSE
    Client <────────────────────── Server

    이 차이는 이후 WebSocket과 Streamable HTTP를 이해할 때 중요합니다.


    10. SSE가 특히 잘 맞는 상황

    SSE는 서버에서 발생하는 정보를 클라이언트가 지속적으로 받아야 하는 상황에 잘 맞습니다.

    • LLM 답변 스트리밍
    • Agent 작업 진행 상태
    • 파일 분석 진행률
    • 실시간 알림
    • 로그 스트리밍
    • 장시간 작업 상태 전달

    예를 들어 AI 문서 분석 서비스에서는 다음과 같은 진행 상태를 전달할 수 있습니다.

    사용자
      │ PDF 업로드
      ▼
    Server
      │
      ├── 업로드 완료
      ├── OCR 시작
      ├── OCR 완료
      ├── 데이터 분석 중
      ├── 필드 추출 완료
      └── 분석 완료
            │
            ▼
          Browser

    11. 그렇다면 WebSocket을 쓰면 되지 않을까?

    WebSocket도 훌륭한 선택입니다. 특히 클라이언트와 서버가 지속적으로 양방향 메시지를 교환해야 한다면 WebSocket이 자연스러운 경우가 많습니다.

    하지만 모든 스트리밍 문제에 양방향 연결이 필요한 것은 아닙니다. LLM 답변처럼 대부분의 데이터가 서버에서 클라이언트 방향으로 흐른다면 HTTP 기반의 SSE만으로도 충분할 수 있습니다.


    12. 그런데 문제가 하나 있다

    AI Agent가 MCP Server와 통신한다고 생각해봅시다.

    Agent
      │
      ├── initialize
      ├── tools/list
      ├── tools/call
      └── tools/call
            │
            ▼
        MCP Server

    여기서는 단순한 Server → Client 스트리밍을 넘어 요청, 응답, 알림, 스트리밍, 세션 같은 통신 요구사항을 함께 생각해야 합니다.

    Request
    Response
    Notification
    Streaming
    Session

    그리고 여기서 Streamable HTTP 이야기가 시작됩니다.


    마치며

    SSE를 이해할 때 가장 중요한 것은 data: 문법이나 FastAPI의 StreamingResponse 사용법이 아닙니다.

    HTTP Response는 반드시 한 번에 전달될 필요가 없다.

    HTTP 요청 하나에 응답 하나가 대응한다는 기본 구조는 그대로 유지됩니다. 다만 그 하나의 Response를 바로 끝내지 않고 데이터를 조금씩 전달할 수 있습니다.

    Request
       │
       ▼
    Response
       │
       ├── data
       ├── data
       ├── data
       ├── data
       └── end

    SSE는 이 특성을 이용해 서버에서 클라이언트로 이벤트를 지속적으로 전달합니다. 그래서 LLM 스트리밍, 작업 진행 상태, Agent 실행 로그처럼 서버에서 계속 발생하는 데이터를 사용자에게 전달해야 하는 상황에서 유용합니다.

    다음 편에서는 SSE만으로는 무엇이 부족한지, WebSocket과는 어떤 차이가 있는지, 그리고 왜 MCP의 통신 구조를 이해하려면 단순한 Server → Client 스트리밍을 넘어 생각해야 하는지 살펴보겠습니다.


    다음 편

    SSE와 Streamable HTTP 2부 — SSE만으로는 부족한가? 양방향 통신과 RPC의 문제

  • AI 개발자가 알아야 할 비동기 프로그래밍 2부

    AI 개발자가 알아야 할 비동기 프로그래밍 2부

    동기와 비동기의 진짜 차이 — async는 동시에 실행한다는 뜻일까?

    1부에서는 AI 서비스를 개발할 때 왜 비동기 프로그래밍에 대한 이해가 중요한지 살펴봤다.

    특히 FastAPI API Server가 하는 일을 보면 CPU로 직접 계산하는 시간보다 다른 시스템의 응답을 기다리는 시간이 상당히 많다는 이야기를 했다.

    예를 들어 RAG 서비스 하나만 보더라도 다음과 같은 작업이 반복된다.

    FastAPI API Server
            │
            ├──→ Embedding API ─── 기다림
            ├──→ Vector DB ─────── 기다림
            ├──→ Reranker API ──── 기다림
            ├──→ LLM API ───────── 기다림
            ├──→ RDB ───────────── 기다림
            └──→ Redis ─────────── 기다림

    그리고 비동기의 핵심 아이디어를 아주 간단하게 정리했다.

    기다리는 동안 다른 일을 하자.

    그런데 Python 비동기 프로그래밍을 공부하기 시작하면 여기서부터 조금 혼란스러워진다.

    갑자기 비슷해 보이는 용어들이 쏟아져 나오기 때문이다.

    Synchronous
    Asynchronous
    
    Blocking
    Non-blocking
    
    Concurrency
    Parallelism
    
    async
    await
    
    Coroutine
    Task
    Event Loop

    특히 이런 설명을 자주 접한다.

    “async를 사용하면 여러 작업을 동시에 실행할 수 있습니다.”

    완전히 틀린 말은 아니다.

    하지만 이 표현만 기억하면 비동기를 잘못 이해하기 쉽다.

    여러 작업을 동시에 다루는 것과 여러 작업을 실제로 동시에 실행하는 것은 같은 이야기가 아니기 때문이다.

    이번 편에서는 이 차이를 하나씩 풀어보자.


    1. 가장 먼저 동기(Synchronous)부터 이해하자

    동기 방식은 우리가 일반적으로 프로그램을 작성할 때 가장 자연스럽게 생각하는 실행 흐름이다.

    예를 들어 다음 코드가 있다고 하자.

    result = call_llm()
    save_to_db(result)
    return result

    코드를 읽는 순서와 실행되는 순서가 거의 같다.

    call_llm()
        │
        ▼
    결과를 받음
        │
        ▼
    save_to_db()
        │
        ▼
    저장 완료
        │
        ▼
    return

    call_llm()이 끝나야 save_to_db()를 실행할 수 있고,

    save_to_db()가 끝나야 결과를 반환한다.

    매우 직관적이다.

    그래서 동기 프로그램은 이해하기 쉽고 디버깅하기도 상대적으로 쉽다.

    문제는 call_llm()이 오래 걸릴 때다.


    2. LLM API가 5초 걸린다면?

    FastAPI API Server가 외부 LLM API를 호출한다고 생각해보자.

    FastAPI API Server
            │
            │ HTTP Request
            ▼
         LLM API
            │
            │
            │ 5초 동안 추론
            │
            ▼
         Response
            │
            ▼
    FastAPI API Server

    LLM이 답변을 만드는 데 5초가 걸린다.

    중요한 것은 이 5초 동안 누가 계산하고 있는가다.

    외부 LLM 서비스를 사용한다면 실제 AI 추론은 LLM Provider의 시스템에서 수행된다.

    우리 FastAPI API Server의 CPU가 5초 동안 LLM 추론을 하는 것이 아니다.

    FastAPI 입장에서는 상당 부분이

    요청 보냄
       ↓
    기다림
       ↓
    응답 도착

    이다.

    바로 이 기다림을 어떻게 처리할 것인가에서 동기와 비동기의 차이가 드러나기 시작한다.


    3. 다시 식당으로 돌아가 보자

    1부에서 사용했던 식당을 다시 생각해보자.

    직원 한 명이 있고 손님 A가 주문한다.

    손님 A
       │
       ▼
    직원
       │
       │ 주문 전달
       ▼
    주방

    주방에서 음식을 만드는 데 10분이 걸린다.

    첫 번째 방식은 직원이 주방 앞에서 기다리는 것이다.

    손님 A
       ↓
    직원
       ↓
    주방
    
    직원 : "음식 나올 때까지 여기 있어야지."
    
            10분...
    
    음식 완성
       ↓
    직원
       ↓
    손님 A

    그동안 손님 B가 들어왔다.

    하지만 직원은 주방 앞에서 기다리고 있다.

    손님 C도 들어왔다.

    역시 기다린다.

    손님 B ─┐
    손님 C ─┤
    손님 D ─┤──→ 직원은 주방 앞에서 기다리는 중
    손님 E ─┘

    이것이 우리가 이해하기 쉬운 Blocking 형태의 기다림이다.

    현재 작업이 끝날 때까지 실행 흐름을 붙잡고 있다.


    4. 그렇다면 비동기 방식은 어떻게 다를까?

    이번에는 직원의 행동을 바꿔보자.

    손님 A의 주문을 주방에 전달한다.

    손님 A
       ↓
    직원
       ↓
    주방

    그런데 직원이 주방 앞에서 기다리지 않는다.

    직원은 이렇게 생각한다.

    “음식이 완성되면 알려주세요.”

    그리고 손님 B에게 간다.

    손님 A 주문 → 주방
    
            음식 만드는 중...
                  │
                  │
    직원 ─────────┼──→ 손님 B 주문 받기
                  │
                  ├──→ 손님 C 주문 받기
                  │
                  └──→ 손님 D 주문 받기

    잠시 뒤 주방에서 알린다.

    "A 주문 나왔습니다!"

    그러면 직원은 다시 A의 작업으로 돌아간다.

    주방
     ↓
    A 음식 완성
     ↓
    직원
     ↓
    손님 A

    여기서 직원이 여러 명이 된 것은 아니다.

    직원은 여전히 한 명이다.

    달라진 것은 기다리는 방법이다.

    이것이 비동기를 이해하는 핵심이다.


    5. 비동기(Asynchronous)는 “동시에 실행”이라는 뜻이 아니다

    여기서 가장 중요한 오해 하나를 정리하자.

    비동기라고 해서 반드시 여러 코드가 물리적으로 같은 순간에 실행되는 것은 아니다.

    앞의 식당을 보면 직원은 한 명이다.

    시간 ─────────────────────────────→
    
    직원
    
    A 주문 처리
        ↓
    A 주방 대기
    
            B 주문 처리
                ↓
            B 주방 대기
    
                    C 주문 처리
                        ↓
                    C 주방 대기
    
            A 음식 완성
                 ↓
            A 응답 처리

    직원 한 명이 매우 빠르게 여러 업무 사이를 이동하고 있을 뿐이다.

    이 상태를 이해하려면 Concurrency, 즉 동시성이라는 개념이 필요하다.


    6. Concurrency — 여러 일을 동시에 “다루는” 능력

    Concurrency는 보통 여러 작업이 같은 시간 구간에 진행될 수 있도록 관리하는 것이라고 이해하면 좋다.

    예를 들어 직원 한 명이 세 테이블을 담당한다.

    직원 1명
    
    Table A ──────── 기다림 ────── 처리
            \
    Table B ── 처리 ─── 기다림 ───── 처리
                 \
    Table C ───── 처리 ─── 기다림 ─── 처리

    어느 한 순간을 아주 잘게 잘라서 보면 직원은 한 가지 일을 하고 있을 수 있다.

    하지만 조금 긴 시간 범위에서 보면 A, B, C 세 작업이 모두 진행되고 있다.

    이것이 Concurrency다.

    중요한 표현은 이것이다.

    Concurrency는 여러 작업을 동시에 완료하는 것이 아니라 여러 작업이 함께 진행될 수 있도록 다루는 것이다.


    7. Parallelism — 진짜 동시에 실행한다

    이번에는 직원이 세 명이라고 생각해보자.

    직원 A → Table A
    
    직원 B → Table B
    
    직원 C → Table C

    세 직원이 같은 순간에 각각 다른 일을 할 수 있다.

    컴퓨터로 생각하면 여러 CPU Core에서 실제 계산이 동시에 실행되는 상황과 비슷하다.

    CPU Core 1 ─→ Task A
    
    CPU Core 2 ─→ Task B
    
    CPU Core 3 ─→ Task C
    
    CPU Core 4 ─→ Task D

    이것이 Parallelism, 병렬성이다.

    그래서 둘을 간단히 비교하면 다음과 같다.

    개념의미Concurrency여러 작업을 함께 진행되도록 관리Parallelism여러 작업을 실제 같은 순간에 실행

    비동기 프로그래밍에서 우리가 주로 활용하는 것은 Concurrency다.


    8. Concurrency와 Parallelism을 그림으로 비교해보자

    Concurrency는 이런 모습에 가깝다.

    CPU / Thread 1개
    
    시간 ─────────────────────────────→
    
    Task A ███───────███────────██
    
    Task B ───███────────███──────
    
    Task C ──────██──────────████

    하나의 실행 자원이 여러 Task 사이를 오간다.

    반면 Parallelism은 다음과 같다.

    시간 ─────────────────────────────→
    
    CPU Core 1 : Task A █████████████
    
    CPU Core 2 : Task B █████████████
    
    CPU Core 3 : Task C █████████████

    실제로 여러 작업이 동시에 실행된다.

    따라서

    Async
       ≠
    Parallel

    이다.

    비동기 코드를 작성했다고 CPU Core가 늘어나는 것은 아니다.


    9. 그렇다면 Blocking과 Non-blocking은 무엇일까?

    여기서 또 하나 헷갈리는 용어가 등장한다.

    Synchronous / Asynchronous
    
    Blocking / Non-blocking

    이 둘을 같은 의미로 사용하는 경우가 많은데 정확히는 서로 다른 관점의 개념이다.

    간단하게 접근하면 Blocking은

    어떤 작업을 기다리는 동안 현재 실행 흐름을 붙잡고 있는가?

    를 보는 것이다.

    예를 들어 다음 코드가 있다고 하자.

    import requests
    
    response = requests.get(url)

    HTTP 요청을 보낸 뒤 응답이 돌아올 때까지 현재 실행 흐름은 이 호출에서 기다린다.

    개념적으로는 다음과 같다.

    현재 실행 흐름
          │
          ▼
    HTTP Request
          │
          ▼
    ████████████████
     응답 기다리는 중
    ████████████████
          │
          ▼
    Response
          │
          ▼
    다음 코드

    이것이 Blocking I/O의 전형적인 모습이다.


    10. Non-blocking은 기다림을 붙잡고 있지 않는다

    비동기 HTTP Client를 사용하는 경우를 생각해보자.

    response = await client.get(url)

    개념적으로는 이렇게 생각할 수 있다.

    Task A
       │
       ▼
    HTTP Request
       │
       ▼
    응답 대기
       │
       └──────── 실행 기회 양보
                      │
                      ▼
                   Task B
                      │
                      ▼
                   Task C
    
            HTTP Response 도착
                      │
                      ▼
                   Task A 재개

    Task A는 여전히 결과를 기다리고 있다.

    중요한 것은 Task A가 기다리는 동안 Event Loop 전체가 같이 기다리지 않는다는 것이다.


    11. 여기서 Event Loop가 다시 등장한다

    1부에서 Event Loop를 잠깐 설명했다.

    다시 한 문장으로 정리하면 다음과 같다.

    Event Loop는 지금 실행할 수 있는 작업을 실행하고, 기다려야 하는 작업은 잠시 내려놓았다가 준비되면 다시 이어서 실행하도록 관리하는 반복 실행 구조다.

    예를 들어 세 개의 요청이 있다고 해보자.

    Request A → LLM API
    Request B → Vector DB
    Request C → RDB

    Event Loop 관점에서는 대략 이런 일이 반복된다.

    Event Loop
        │
        ▼
    Request A 실행
        │
        ▼
    LLM 응답 기다려야 함
        │
        ▼
    실행권 양보
        │
        ▼
    Request B 실행
        │
        ▼
    Vector DB 응답 기다려야 함
        │
        ▼
    실행권 양보
        │
        ▼
    Request C 실행
        │
        ▼
    ...

    그러던 중 LLM 응답이 준비된다.

    LLM 응답 도착
          │
          ▼
    Event Loop
          │
          ▼
    Request A 다시 실행

    이 구조 덕분에 하나의 Event Loop가 많은 I/O-bound 작업을 효율적으로 다룰 수 있다.


    12. 그렇다면 await는 정확히 무엇을 하는가?

    이제 다음 코드가 조금 다르게 보일 것이다.

    async def ask_llm():
        result = await call_llm()
        return result

    처음 보면 await라는 단어 때문에 이런 느낌이 든다.

    “여기서 기다리라는 뜻인가?”

    맞다.

    현재 Coroutine은 결과를 기다린다.

    하지만 중요한 것이 빠져 있다.

    어떻게 기다리는가?

    개념적으로는 이렇게 이해하는 것이 좋다.

    await
      │
      ▼
    "나는 이 결과가 있어야
     다음 단계로 갈 수 있습니다."
      │
      ▼
    "하지만 지금 당장 할 일이 없으니
     실행 기회를 다른 Task에 양보합니다."

    즉,

    await는 단순히 “멈춰서 기다려”가 아니라 “이 작업의 완료를 기다려야 하니, 그동안 다른 실행 가능한 작업이 있다면 실행 기회를 넘겨”에 가깝다.

    물론 await 가능한 객체와 Coroutine의 실제 동작은 조금 더 복잡하다.

    지금 단계에서는 이 정도 개념이면 충분하다.


    13. await를 만나면 무엇이 멈추는가?

    이 질문은 굉장히 중요하다.

    다음 코드가 있다고 하자.

    @app.get("/question")
    async def question():
        result = await call_llm()
        return result

    call_llm()의 결과가 필요하므로 현재 question() Coroutine의 진행은 잠시 멈춘다.

    하지만 제대로 구현된 비동기 I/O라면 Event Loop 자체가 멈추는 것은 아니다.

    question() Coroutine
            │
            ▼
    await call_llm()
            │
            ├──────────────→ LLM API
            │
            │
            │  현재 Coroutine 대기
            │
            ▼
       Event Loop는
       다른 Task 처리
            │
            ├─ Request B
            ├─ Request C
            └─ Request D

    이 차이를 반드시 기억해야 한다.

    await
    
    현재 Coroutine     → 기다릴 수 있음
    
    Event Loop 전체    → 반드시 멈추는 것이 아님

    바로 이 구조 때문에 비동기 I/O가 높은 동시성을 처리할 수 있다.


    14. 그런데 async 함수 안에서 Blocking 코드를 실행하면?

    이제 위험한 코드 하나를 보자.

    import requests
    
    @app.get("/question")
    async def question():
        response = requests.get(
            "https://example.com/api"
        )
    
        return response.json()

    Endpoint가 async def로 선언되어 있다.

    그런데 내부에서는 동기 HTTP Client인 requests를 사용했다.

    실행 흐름은 이렇게 될 수 있다.

    Event Loop
        │
        ▼
    Request A
        │
        ▼
    requests.get()
        │
        ▼
    ██████████████████████
     HTTP Response 기다림
     Event Loop 진행 방해
    ██████████████████████
        │
        ▼
    Response

    이 동안 같은 Event Loop가 담당하는 다른 Task들이 영향을 받을 수 있다.

    Request A ── requests.get() ─────────→
    
    Request B ───────────── 기다림 ──────→
    
    Request C ───────────── 기다림 ──────→
    
    Request D ───────────── 기다림 ──────→

    그래서 중요한 것은 함수 앞에 async를 붙였느냐가 아니다.

    그 함수 안에서 Event Loop를 Blocking하는 코드가 실행되고 있는가를 봐야 한다.


    15. CPU-bound 작업도 같은 문제가 생긴다

    다음 Endpoint를 생각해보자.

    @app.post("/parse")
    async def parse_pdf():
        result = parse_large_pdf()
        return result

    parse_large_pdf()가 CPU를 10초 동안 계속 사용하는 작업이라고 하자.

    Event Loop
        │
        ▼
    parse_large_pdf()
        │
        ▼
    CPU 계산
    ████████████████████████
    ████████████████████████
    ████████████████████████
              10초
    ████████████████████████
    ████████████████████████
        │
        ▼
    완료

    여기에는 기다리면서 실행권을 양보할 지점이 없다.

    CPU가 계속 Python 코드를 실행하고 있다.

    그 결과 Event Loop가 다른 Task를 처리할 기회를 얻지 못할 수 있다.

    Request A ─→ CPU-bound 작업 ─────────────────→ 완료
                        │
                        │ Event Loop 점유
                        │
    Request B ──────────┼──────────── 기다림
    Request C ──────────┼──────────── 기다림
    Request D ──────────┴──────────── 기다림

    여기서 중요한 차이가 있다.

    앞에서 살펴본 LLM API 호출은 기다리는 작업이었다.

    LLM API 호출
         ↓
    외부 시스템이 처리
         ↓
    우리 프로그램은 기다림

    하지만 PDF Parsing과 같은 CPU-bound 작업은 다르다.

    PDF Parsing
         ↓
    우리 CPU가 직접 계산
         ↓
    계속 실행 중

    따라서 단순히 async를 붙인다고 해결되지 않는다.

    async def parse_pdf():
        result = heavy_pdf_parsing()
        return result

    이 코드는 비동기 함수처럼 보이지만 heavy_pdf_parsing()이 CPU를 오래 점유한다면 Event Loop에게 실행권을 돌려주지 않는다.

    그래서 CPU-bound 작업에는 다른 접근이 필요하다.

    예를 들어

    CPU-bound 작업
          │
          ├── Thread
          ├── Process
          └── Background Worker

    등으로 분리하는 방법을 고려할 수 있다.

    어떤 방식을 선택해야 하는지는 작업의 특성과 Python의 실행 모델까지 함께 봐야 한다.

    이 부분은 뒤의 실전 편에서 자세히 다루겠다.


    16. async가 효과적인 곳은 어디일까?

    이제 어느 정도 기준이 생겼다.

    AI 서비스를 예로 들어보자.

    다음 작업들은 대표적으로 I/O 대기가 발생한다.

    FastAPI API Server
           │
           ├──→ LLM API
           ├──→ Embedding API
           ├──→ Vector DB
           ├──→ RDB
           ├──→ Redis
           ├──→ S3
           └──→ 외부 REST API

    이런 작업들은 대부분 다른 시스템에 요청을 보내고 결과를 기다린다.

    따라서 사용하는 Client나 Driver가 비동기를 지원한다면 async를 활용할 좋은 후보가 된다.

    예를 들어 다음과 같다.

    LLM API 호출             → I/O-bound
    Embedding API 호출       → I/O-bound
    Vector DB Query          → I/O-bound
    RDB Query                → I/O-bound
    Redis                    → I/O-bound
    S3 Download / Upload     → I/O-bound
    외부 HTTP API            → I/O-bound

    반대로 다음과 같은 작업은 성격이 다르다.

    이미지 변환
    PDF Parsing
    대규모 압축
    복잡한 데이터 변환
    로컬 ML Inference
    대규모 수치 계산

    이런 작업은 CPU-bound일 가능성이 있다.

    물론 실제 작업은 이렇게 깔끔하게 둘 중 하나로만 나뉘지는 않는다.

    예를 들어 PDF Parsing도 파일을 읽는 과정에는 I/O가 있고 실제 문서를 분석하는 과정에는 CPU 연산이 있을 수 있다.

    그래서 중요한 것은 이름만 보고 판단하는 것이 아니다.

    그 작업이 실제로 대부분의 시간을 어디에서 보내는지를 보는 것이다.


    17. Background Worker에서도 똑같이 생각해야 한다

    비동기는 FastAPI API Server에서만 필요한 개념이 아니다.

    Background Worker를 생각해보자.

    예를 들어 문서 처리 Worker가 다음 작업을 수행한다고 하자.

    Queue
      │
      ▼
    Background Worker
      │
      ├── S3에서 PDF 다운로드
      │
      ├── PDF Parsing
      │
      ├── Chunking
      │
      ├── Embedding API 호출
      │
      ├── Vector DB 저장
      │
      └── RDB 상태 업데이트

    각 단계의 성격을 구분해보면 다음과 같다.

    S3 Download        → I/O-bound
    
    PDF Parsing        → CPU-bound 가능성
    
    Chunking           → CPU-bound
    
    Embedding API      → I/O-bound
    
    Vector DB          → I/O-bound
    
    RDB                → I/O-bound

    하나의 Worker 안에서도 CPU 작업과 I/O 작업이 섞여 있다.

    그래서

    “Worker니까 동기로 만들어도 된다.”

    또는

    “AI 서비스니까 전부 async로 만들자.”

    둘 다 좋은 기준이 아니다.

    먼저 각각의 작업이 어떤 성격인지 분석해야 한다.


    18. 그렇다면 async를 쓰면 요청을 많이 받을수록 좋은가?

    여기서 또 다른 함정이 등장한다.

    비동기를 이해하기 시작하면 이런 생각을 하기 쉽다.

    “기다리는 동안 다른 작업을 할 수 있다면 최대한 많이 동시에 실행하면 되겠네?”

    예를 들어 Embedding API를 호출해야 할 Chunk가 10,000개 있다고 하자.

    Python에서는 다음과 같은 코드를 작성할 수 있다.

    await asyncio.gather(
        *[
            create_embedding(chunk)
            for chunk in chunks
        ]
    )

    Chunk가 10,000개라면 10,000개의 비동기 작업이 만들어질 수도 있다.

    처음에는 굉장히 효율적으로 보인다.

    Embedding 1      ─→
    Embedding 2      ─→
    Embedding 3      ─→
    Embedding 4      ─→
    ...
    Embedding 10000  ─→

    하지만 실제 Production 시스템에는 무한한 자원이 존재하지 않는다.

    10,000 Tasks
          │
          ▼
    HTTP Connection 증가
          │
          ├── Connection Pool 고갈
          ├── Socket 증가
          ├── Memory 증가
          ├── 외부 API Rate Limit
          └── Timeout 증가
                  │
                  ▼
                 Retry
                  │
                  ▼
              요청량 증가
                  │
                  ▼
                 장애

    즉 비동기는 동시성을 가능하게 하는 기술이지,

    무한한 동시성을 가능하게 하는 기술이 아니다.

    이 차이가 매우 중요하다.


    19. Concurrency에는 반드시 한계가 필요하다

    Production에서는 질문이 한 단계 더 발전해야 한다.

    처음에는

    “동기로 할까, 비동기로 할까?”

    를 고민한다.

    하지만 실제 운영에서는 이런 질문이 더 중요하다.

    “동시에 몇 개까지 실행할 것인가?”

    예를 들어 Embedding API를 최대 20개까지만 동시에 호출하도록 제한할 수 있다.

    개념적으로는 다음과 같다.

    10,000 Tasks
         │
         ▼
    ┌───────────────────┐
    │ Concurrency = 20  │
    └───────────────────┘
         │
         ├── Task 1
         ├── Task 2
         ├── Task 3
         │
         ...
         └── Task 20
    
    나머지 Task
         │
         ▼
        대기

    Python의 asyncio에서는 Semaphore 등을 이용해 이런 동시성 제한을 구현할 수 있다.

    semaphore = asyncio.Semaphore(20)
    
    async def create_embedding(chunk):
        async with semaphore:
            return await embedding_api(chunk)

    이제 최대 20개의 작업만 동시에 외부 Embedding API를 호출한다.

    하나가 완료되면 기다리던 다음 작업이 실행된다.

    동시에 실행
    
    1  2  3  4  ... 20
    │  │  │  │       │
    ▼  ▼  ▼  ▼       ▼
    
    하나 완료
        │
        ▼
    
    21번 Task 실행

    비동기 프로그래밍의 목적은 가능한 한 많이 동시에 실행하는 것이 아니다.

    시스템이 감당할 수 있는 범위에서 기다리는 시간을 효율적으로 활용하는 것이다.


    20. 결국 Connection Pool과도 연결된다

    Concurrency를 이야기하면 자연스럽게 Connection Pool 문제가 따라온다.

    예를 들어 RDB Connection Pool 크기가 20이라고 하자.

    FastAPI API Server
            │
            ▼
    RDB Connection Pool
    
    ┌─────────────────────────┐
    │ 1  2  3  ...       20   │
    └─────────────────────────┘
            │
            ▼
           RDB

    그런데 동시에 500개의 요청이 DB를 사용하려 한다.

    500 Requests
          │
          ▼
    Connection Pool = 20
          │
          ├── 20개 사용
          │
          └── 나머지 대기

    async라고 해서 500개의 Connection이 갑자기 생기는 것이 아니다.

    외부 시스템에는 각각 처리 가능한 용량이 있다.

    FastAPI
       │
       ├── HTTP Connection Pool
       │
       ├── RDB Connection Pool
       │
       ├── Redis Connection Pool
       │
       ├── Vector DB 처리량
       │
       └── LLM API Rate Limit

    그래서 비동기 프로그래밍을 Production 수준에서 이해하려면 결국

    Concurrency
    Connection Pool
    Timeout
    Rate Limit
    Semaphore
    Queue
    Backpressure

    같은 개념까지 연결해서 생각해야 한다.


    21. 동시성과 병렬성을 다시 AI 서비스에 적용해보자

    지금까지 내용을 실제 AI 서비스에 대입해보자.

    FastAPI API Server가 다음 세 요청을 처리하고 있다고 하자.

    Request A → LLM API
    Request B → Vector DB
    Request C → RDB

    각 작업이 비동기 I/O를 지원한다면 하나의 Event Loop에서도 다음과 같이 진행될 수 있다.

    시간 ───────────────────────────────────→
    
    Request A
    ████ LLM 요청 ─────── 대기 ─────── ███ 응답처리
    
    Request B
         ███ Vector DB ── 대기 ── ██ 응답처리
    
    Request C
              ███ RDB ── 대기 ── ██ 응답처리

    세 작업이 같은 시간 구간 안에서 함께 진행되고 있다.

    이것이 Concurrency다.

    하지만 CPU가 실제로 세 개의 Python 코드를 같은 순간에 실행하고 있다는 뜻은 아니다.

    반대로 CPU-bound 작업을 여러 Process에 분산한다고 생각해보자.

    Process 1 → CPU Core 1 → PDF Parsing A
    
    Process 2 → CPU Core 2 → PDF Parsing B
    
    Process 3 → CPU Core 3 → PDF Parsing C

    이 경우에는 실제 여러 CPU Core에서 계산이 동시에 이루어질 수 있다.

    이것이 Parallelism이다.

    따라서 AI 서비스를 설계할 때는 두 가지 문제를 구분해야 한다.

    I/O 대기가 많은가?
           │
           ▼
    Concurrency / Async
    
    CPU 계산이 많은가?
           │
           ▼
    Parallelism / Process / Worker

    이 구분만 제대로 해도 많은 설계 실수를 피할 수 있다.


    22. 한 장으로 정리해보자

    지금까지 나온 개념을 한 번에 정리하면 다음과 같다.

    ┌─────────────────────────────────────┐
    │            Synchronous              │
    │                                     │
    │ 현재 작업의 결과를 따라             │
    │ 순차적인 실행 흐름으로 진행          │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │            Asynchronous             │
    │                                     │
    │ 작업 완료를 기다리는 동안           │
    │ 다른 실행 가능한 작업을 진행         │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │              Blocking               │
    │                                     │
    │ 기다리는 작업이 현재 실행 흐름의     │
    │ 진행을 붙잡는다                      │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │            Non-blocking             │
    │                                     │
    │ 기다리는 동안 다른 작업이           │
    │ 진행될 기회를 가질 수 있다           │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │            Concurrency              │
    │                                     │
    │ 여러 작업을 같은 시간 구간 안에서    │
    │ 함께 진행되도록 관리한다             │
    └─────────────────────────────────────┘
    
    
    ┌─────────────────────────────────────┐
    │            Parallelism              │
    │                                     │
    │ 여러 작업을 실제로 동시에 실행한다   │
    └─────────────────────────────────────┘

    그리고 Python의 asyncio는 주로 다음 영역에 있다.

     I/O-bound
                   │
                   ▼
             Asynchronous
                   │
                   ▼
              Event Loop
                   │
                   ▼
              Concurrency 

    이 관계를 머릿속에 잡아두면 이후의 asyncio가 훨씬 이해하기 쉬워진다.


    23. async/await를 문법으로 먼저 배우면 어려운 이유

    많은 Python 비동기 강좌가 다음 코드부터 시작한다.

    import asyncio
    
    async def main():
        await something()
    
    asyncio.run(main())

    그러고 나서 Coroutine, Task, Future 같은 용어가 등장한다.

    문법부터 보면 당연히 어렵다.

    하지만 지금까지 살펴본 관점에서 보면 이야기가 달라진다.

    우리가 해결하고 싶은 문제는 처음부터 하나였다.

    외부 시스템에 요청했다.
            │
            ▼
    결과가 아직 없다.
            │
            ▼
    그동안 무엇을 할 것인가?

    동기 Blocking 방식이라면

    기다린다.

    비동기 방식이라면

    현재 작업은 잠시 멈추고
            │
            ▼
    실행 기회를 양보하고
            │
            ▼
    다른 작업을 처리하고
            │
            ▼
    결과가 준비되면
            │
            ▼
    다시 돌아온다.

    이 동작을 가능하게 만들기 위해 등장하는 것이

    Coroutine
    Task
    Event Loop
    await

    다.

    즉 이 용어들은 각각 따로 외워야 하는 이상한 Python 문법이 아니다.

    기다리는 시간을 다른 작업에 활용하기 위한 실행 모델을 구성하는 요소들이다.


    24. 이번 편에서 이것만은 기억하자

    이번 편의 내용을 모두 기억할 필요는 없다.

    다음 몇 가지만 정확히 기억하면 된다.

    첫째,

    비동기는 무조건 빠르게 만드는 기술이 아니다.

    LLM API가 5초 걸린다면 async로 호출해도 LLM 자체는 여전히 5초 걸릴 수 있다.


    둘째,

    비동기는 병렬처리와 다르다.

    Async ≠ Parallel

    비동기는 주로 I/O 대기시간을 이용해 여러 작업을 함께 진행시키는 Concurrency와 관련이 있다.


    셋째,

    await에서 현재 Coroutine이 기다리는 것과 Event Loop 전체가 멈추는 것은 다르다.

    await
    
    현재 Coroutine
          ↓
    대기
    
    Event Loop
          ↓
    다른 Task 처리 가능

    넷째,

    async 함수 안에 Blocking 코드를 넣으면 비동기의 장점을 잃을 수 있다.

    async def foo():
        requests.get(url)   # 주의

    async def라고 적었다고 내부 코드가 자동으로 비동기가 되는 것은 아니다.


    다섯째,

    CPU-bound 작업은 단순히 async로 해결되지 않는다.

    I/O-bound
        ↓
    Async / Event Loop / Concurrency
    
    
    CPU-bound
        ↓
    Thread / Process / Worker / Parallelism 검토

    그리고 마지막으로 가장 중요한 것.

    비동기는 무한한 동시성을 의미하지 않는다.

    Production에서는 항상

    "동시에 실행할 수 있는가?"

    다음에

    "동시에 몇 개까지 실행할 것인가?"

    를 물어야 한다.

    이 질문이 다음 단계의 시작이다.


    마치며

    비동기 프로그래밍을 처음 접하면 async와 await가 가장 눈에 띈다.

    하지만 실제로 중요한 것은 문법 뒤에 있는 실행 구조를 이해하는 것이다.

    우리는 이번 편에서 다음 흐름을 살펴봤다.

    I/O 요청
       ↓
    결과를 기다림
       ↓
    현재 작업 실행권 양보
       ↓
    Event Loop
       ↓
    다른 Task 실행
       ↓
    I/O 완료
       ↓
    기존 Task 재개

    이 흐름을 이해하면 이제 다음 질문이 생긴다.

    Event Loop는 실제로 이 작업들을 어떻게 관리하는 것일까?

    async def로 만든 함수는 일반 함수와 무엇이 다른가?

    Coroutine은 무엇인가?

    Coroutine과 Task는 무엇이 다른가?

    await를 만나면 Python 내부에서는 어떤 일이 벌어지는가?

    그리고 다음 코드는

    async def foo():
        print("A")
        await asyncio.sleep(1)
        print("B")

    실제로 어떤 순서로 실행되는가?

    이제 비동기의 필요성은 충분히 이해했다.

    다음 편부터는 Python 내부로 한 단계 들어가 보자.


    다음 글

    AI 개발자가 알아야 할 비동기 프로그래밍 3부

    Python asyncio는 어떻게 움직이는가 — Event Loop, Coroutine, Task 그리고 await

    다음 편에서는 다음 코드 하나를 출발점으로 삼는다.

    import asyncio
    
    async def worker(name):
        print(f"{name} start")
        await asyncio.sleep(2)
        print(f"{name} end")
    
    async def main():
        await asyncio.gather(
            worker("A"),
            worker("B"),
                    worker("C"),
        )
    
    asyncio.run(main())

    이 코드를 실행하면 세 개의 worker()는 어떤 순서로 움직일까?

    처음 비동기 코드를 접하면 다음과 같이 예상할 수도 있다.

    A start
    2초 대기
    A end
    
    B start
    2초 대기
    B end
    
    C start
    2초 대기
    C end

    그렇다면 전체 실행시간은 약 6초가 되어야 한다.

    하지만 실제 실행 흐름은 다르다.

    A start
    B start
    C start
    
          약 2초
    
    A end
    B end
    C end

    전체 실행시간은 약 2초에 가깝다.

    그렇다면 여기서 질문이 생긴다.

    worker A ─┐
    worker B ─┼── 정말 동시에 실행된 것일까?
    worker C ─┘

    CPU가 세 개의 worker()를 동시에 실행한 것일까?

    아니다.

    이 코드에서 핵심은 바로 다음 부분이다.

    await asyncio.sleep(2)

    worker(“A”)가 이 지점에 도달하면 A의 실행은 잠시 멈춘다.

    하지만 Event Loop까지 2초 동안 멈추는 것은 아니다.

    worker A
       │
       ▼
    await asyncio.sleep(2)
       │
       └──── 실행권 양보
                  │
                  ▼
              Event Loop
                  │
                  ▼
              worker B

    B도 같은 지점에 도달한다.

    worker B
       │
       ▼
    await asyncio.sleep(2)
       │
       └──── 실행권 양보
                  │
                  ▼
              Event Loop
                  │
                  ▼
              worker C

    C 역시 실행권을 양보한다.

    이제 세 Coroutine은 모두 기다리고 있다.

    Event Loop
    
    A ── sleep 2초 ── 대기
    B ── sleep 2초 ── 대기
    C ── sleep 2초 ── 대기

    약 2초가 지나면 다시 실행할 수 있는 상태가 된다.

    Event Loop는 준비된 작업들을 다시 실행한다.

    약 2초 후
    
    A ──→ 실행 가능
    B ──→ 실행 가능
    C ──→ 실행 가능
              │
              ▼
          Event Loop
              │
              ├── A 계속 실행
              ├── B 계속 실행
              └── C 계속 실행

    바로 이 과정에서 Python 비동기 프로그래밍의 핵심 개념들이 모두 등장한다.

    async def
       │
       ▼
    Coroutine
       │
       ▼
    Task
       │
       ▼
    Event Loop
       │
       ▼
    await
       │
       ▼
    실행권 양보
       │
       ▼
    다른 Task 실행

    3부에서는 이 구조를 코드 한 줄씩 따라가며 살펴볼 것이다.

    특히 다음 질문에 답해보려고 한다.

    • async def를 호출하면 왜 함수가 바로 실행되지 않을까?
    • Coroutine 객체는 일반 함수의 반환값과 무엇이 다를까?
    • Coroutine과 Task는 무엇이 다를까?
    • await를 만나면 현재 Coroutine에는 어떤 일이 생길까?
    • Event Loop는 기다리고 있는 작업이 완료됐다는 것을 어떻게 알까?
    • asyncio.create_task()는 무엇을 하는가?
    • asyncio.gather()는 실제로 무엇을 동시에 처리하는가?
    • asyncio.sleep()과 time.sleep()은 왜 완전히 다른 결과를 만들까?

    그리고 마지막에는 FastAPI로 돌아와 다음 구조가 실제로 어떻게 동작하는지도 연결해보자.

    @app.get("/question")
    async def question():
    
        embedding = await embedding_api()
    
        documents = await vector_db.search(embedding)
    
        answer = await llm.generate(documents)
    
        return answer

    겉으로 보면 몇 줄 되지 않는 코드다.

    하지만 내부에서는

    HTTP Request
          ↓
    FastAPI
          ↓
    Coroutine 생성
          ↓
    Event Loop
          ↓
    Embedding API 요청
          ↓
    await
          ↓
    다른 Request 처리
          ↓
    Embedding 응답 도착
          ↓
    Coroutine 재개
          ↓
    Vector DB 요청
          ↓
    await
          ↓
    다른 Request 처리
          ↓
    ...

    와 같은 일이 계속 반복되고 있다.

    이 구조가 머릿속에 그려지기 시작하면 async와 await는 더 이상 외워야 하는 Python 문법이 아니다.

    프로그램이 언제 실행되고, 언제 기다리고, 언제 다른 작업에 실행권을 넘기는지를 표현하는 코드로 보이기 시작한다.

    다음 편에서는 드디어 Python의 Event Loop 안으로 들어가 보자.

  • AI 서비스는 왜 개발할 때는 잘 되는데 운영에 가면 터질까?

    AI 서비스는 왜 개발할 때는 잘 되는데 운영에 가면 터질까?

    AI 개발자가 알아야 할 비동기 프로그래밍 1부

    AI 서비스를 개발하는 일은 예전보다 훨씬 쉬워졌다.

    Python으로 FastAPI API 서버를 만들고, LLM API를 호출하고, Vector DB를 연결하면 생각보다 빠르게 서비스를 만들 수 있다.

    예를 들어 간단한 RAG 서비스를 만든다고 해보자.

    글 내용

    개발 환경에서 테스트해 보면 꽤 잘 동작한다.

    질문을 입력한다.

    몇 초 기다린다.

    답변이 나온다.

    “잘 되는데?”

    그런데 이 서비스를 실제 Production 환경에 올리면 이야기가 달라진다.

    사용자가 10명, 100명으로 증가하면서 API 응답이 느려지기 시작한다.

    어떤 요청은 timeout이 발생한다.

    RDB Connection Pool이 부족해질 수도 있고, 외부 LLM API 호출이 밀릴 수도 있다. 메모리가 계속 증가하거나 API 프로세스가 더 이상 새로운 요청을 원활하게 처리하지 못하

    는 상황도 생긴다.

    경우에 따라서는 Background Worker의 작업이 밀리면서 Queue가 계속 쌓이는 문제도 발생한다.

    코드는 똑같은데 왜 개발할 때는 잘 되던 서비스가 운영에 가면 문제가 생길까?

    이 질문을 이해하려면 async와 await 문법을 배우기 전에 먼저 알아야 하는 것이 있다.

    우리 시스템에서 누가 무엇을 하고 있고, 누가 무엇을 기다리고 있는가?


    1. 먼저 ‘서버’라는 말을 구분하자

    개발하면서 우리는 흔히 이런 말을 한다.

    서버에서 처리하면 됩니다.

    하지만 실제 AI 서비스에는 서로 전혀 다른 역할을 하는 여러 프로그램과 시스템이 존재한다.

    예를 들어 다음과 같은 구조를 생각해볼 수 있다.

    글 내용

    여기에는 적어도 몇 가지 서로 다른 역할이 있다.

    FastAPI API Server

    사용자의 HTTP 요청을 받아 처리하고 HTTP 응답을 반환한다.

    예를 들면 다음과 같다.

    POST /question
    POST /documents
    GET  /documents
    GET  /status

    사용자가 질문을 보내면 API Server가 Vector DB와 LLM 등을 호출하여 결과를 사용자에게 반환할 수 있다.


    Background Worker

    사용자의 HTTP 요청과 직접 연결되지 않은 작업을 처리한다.

    예를 들어 문서 업로드 이후 다음 작업들이 있을 수 있다.

    PDF Parsing
        ↓
    Chunking
        ↓
    Embedding
        ↓
    Vector DB 저장

    이런 작업을 API 요청 안에서 끝날 때까지 수행하기보다는 Queue에 넣고 Worker가 처리하도록 만들 수 있다.

    예를 들면 Celery Worker, RQ Worker 또는 직접 만든 Python Worker 등이 이런 역할을 한다.


    외부 AI 서비스

    OpenAI, AWS Bedrock 같은 LLM/Embedding 서비스를 사용할 수도 있다.

    이 경우 실제 AI 추론은 우리의 FastAPI 프로세스에서 실행되는 것이 아니다.

    FastAPI API Server나 Worker는 외부 AI 서비스에 HTTP 요청을 보내고 결과를 기다린다.


    데이터 저장 시스템

    RDB, Redis, Vector DB, Object Storage 등도 별도의 시스템이다.

    예를 들어

    PostgreSQL
    MySQL
    Redis
    Qdrant
    S3

    등이 있다.

    따라서 앞으로 이 글에서는 가능한 한 막연하게 ‘서버’라고 표현하지 않고,

    FastAPI API Server
    Background Worker
    LLM API
    Embedding API
    Vector DB
    RDB
    Redis
    Object Storage

    처럼 어떤 역할을 수행하는 시스템인지 구분해서 이야기하겠다.

    이 구분이 비동기를 이해하는 데 상당히 중요하다.


    2. FastAPI API Server는 생각보다 많은 시간을 기다린다

    사용자가 RAG 질문을 보냈다고 생각해보자.

    사용자
       │
       │ POST /question
       ▼
    FastAPI API Server
       │
       ├─→ Embedding API
       │       ↓
       │     결과 대기
       │
       ├─→ Vector DB
       │       ↓
       │     결과 대기
       │
       ├─→ Reranker
       │       ↓
       │     결과 대기
       │
       ├─→ LLM API
       │       ↓
       │     결과 대기
       │
       └─→ RDB
               ↓
             저장 완료 대기
       │
       ▼
    사용자에게 응답

    각 단계가 다음 정도 걸린다고 가정해보자.

    Embedding API       150ms
    Vector DB            80ms
    Reranker             300ms
    LLM API            4,000ms
    RDB 저장              30ms

    전체 API 요청에는 약 4~5초가 걸린다.

    그렇다면 이 4~5초 동안 FastAPI가 실행되는 CPU가 계속 계산하고 있을까?

    대부분의 경우 그렇지 않다.

    예를 들어 FastAPI API Server가 외부 LLM API에 요청을 보냈다고 하자.

    FastAPI API Server
            │
            │ HTTP Request
            ▼
         LLM API
            │
            │ AI 추론 수행
            │
            │
            ▼
         HTTP Response
            │
            ▼
    FastAPI API Server

    LLM이 답변을 만드는 동안 실제 AI 추론을 수행하는 것은 LLM Provider 쪽 시스템이다.

    우리 FastAPI API Server는 결과가 돌아오기를 기다리고 있다.

    즉 API 요청 처리시간이 5초라고 해서 우리 CPU가 5초 동안 계산하고 있다는 의미는 아니다.

    이 차이가 중요하다.


    3. CPU가 일하는 것과 I/O를 기다리는 것은 다르다

    프로그램의 작업을 이해하기 위해 크게 두 종류로 나눠보자.

    글 내용

    첫 번째는 CPU가 실제로 계산해야 하는 작업이다.

    FastAPI / Worker Process
            │
            ▼
          CPU
            │
            ├─ 이미지 변환
            ├─ 대규모 데이터 계산
            ├─ 압축
            ├─ 암호화
            └─ 로컬 ML 모델 추론

    이런 작업을 일반적으로 CPU-bound 작업이라고 한다.

    CPU가 실제로 바쁘다.

    반면 다음과 같은 작업도 있다.

    FastAPI API Server / Worker
            │
            ├─ HTTP ─────→ LLM API
            │
            ├─ TCP ──────→ PostgreSQL
            │
            ├─ HTTP ─────→ Vector DB
            │
            ├─ TCP ──────→ Redis
            │
            └─ HTTP ─────→ S3

    요청을 보낸 뒤 상대 시스템이 결과를 반환하기까지 기다려야 한다.

    이런 작업은 대표적인 I/O-bound 작업이다.

    AI 서비스에는 이런 I/O 작업이 굉장히 많다.


    4. 문제는 기다리는 것 자체가 아니다

    LLM API가 답변을 생성하는 데 5초가 걸리는 것 자체는 반드시 문제가 아니다.

    LLM Provider가 답변을 만드는 데 5초가 필요하다면 우리 FastAPI API Server가 그 시간을 0.1초로 만들 수는 없다.

    중요한 질문은 이것이다.

    FastAPI API Server가 그 5초 동안 다른 사용자의 요청을 처리할 수 있는가?

    식당을 하나 생각해보자.

    FastAPI API Server가 식당 직원이라고 생각하면 된다.

    손님 A가 주문한다.

    직원은 주문을 주방에 전달한다.

    여기서 주방은 LLM API라고 생각해보자.

    손님 A
      │
      ▼
    직원(FastAPI)
      │
      │ 주문
      ▼
    주방(LLM API)

    주방에서 음식을 만드는 데 10분이 걸린다.

    그런데 직원이 음식을 받을 때까지 주방 앞에 서 있다고 생각해보자.

    손님 A
       │
       ▼
    직원
       │
       ▼
    주방
       │
       │
       │ 10분...
       │
       │
       ▼
    음식 완성

    그동안 손님 B가 들어온다.

    손님 C도 들어온다.

    하지만 직원은 여전히 주방 앞에서 기다리고 있다.

    이 식당은 손님 한 명일 때는 아무 문제가 없다.

    하지만 점심시간이 되면 상황이 완전히 달라진다.

    글 내용

    5. 개발 환경에서는 왜 문제가 잘 보이지 않을까?

    개발할 때는 대부분 한 사람이 테스트한다.

    개발자
      │
      │ POST /question
      ▼
    FastAPI API Server
      │
      ├─→ Vector DB
      ├─→ LLM API
      └─→ RDB
      │
      ▼
    응답

    잘 된다.

    다시 질문한다.

    또 잘 된다.

    그래서 생각한다.

    API 잘 만들어졌네.

    하지만 Production 환경은 다르다.

    User 1 ─┐
    User 2 ─┤
    User 3 ─┤
    User 4 ─┤
            │
            ▼
    ┌────────────────────┐
    │ FastAPI API Server │
    └────────────────────┘
            ▲
            │
    User 97 ─┤
    User 98 ─┤
    User 99 ─┤
    User100 ─┘

    사용자들은 순서대로 한 명씩 들어오지 않는다.

    여러 요청이 겹쳐서 들어온다.

    바로 여기에서 동시성(Concurrency) 문제가 등장한다.

    글 내용

    6. 사용자 한 명에게 5초 걸리는 API

    다음과 같은 FastAPI Endpoint가 있다고 생각해보자.

    POST /question

    이 Endpoint가 LLM API를 호출하고 답변을 반환하는 데 평균 5초가 걸린다.

    사용자가 한 명이라면 별 문제가 없다.

    User A
      │
      ▼
    FastAPI
      │
      ├──────── LLM API ────────┐
      │                         │
      │        5초 대기          │
      │                         │
      ◀─────────────────────────┘
      │
      ▼
    User A

    그런데 동시에 많은 사용자가 요청하면 상황이 달라진다.

    만약 어떤 실행 흐름이 LLM 응답을 기다리는 동안 다른 작업을 진행하지 못한다면 그 실행 흐름은 몇 초 동안 묶여 있게 된다.

    요청 수가 증가하면 이런 대기들이 계속 겹친다.

    결국 API Server가 처리할 수 있는 동시 요청 수와 Worker Process 수, Thread 수, Connection Pool 크기 등의 한계가 드러나기 시작한다.

    이때 흔히 나타나는 현상이 있다.

    동시 요청 증가
          ↓
    처리 대기 증가
          ↓
    응답시간 증가
          ↓
    Timeout 증가
          ↓
    Retry 증가
          ↓
    더 많은 요청
          ↓
    시스템 부하 증가

    개발 환경에서는 거의 볼 수 없었던 현상이다.


    7. 여기서 비동기가 등장한다

    이제 다시 LLM 호출을 생각해보자.

    FastAPI API Server가 LLM API에 요청을 보냈다.

    FastAPI
       │
       ├─────────────→ LLM API
       │
       │
       │   응답을 기다려야 함
       │
       │
       ◀────────────── LLM 응답

    비동기 방식의 핵심 아이디어는 간단하다.

    기다리는 동안 다른 일을 하자.

    개념적으로는 다음과 같다.

    Request A
       │
       ├──→ LLM API
       │
       │    응답 대기
       │
       ├──────────────┐
                      │
    Request B         │
       │              │
       ├──→ Vector DB │
       │              │
       │              │
    Request C         │
       │              │
       ├──→ RDB       │
                      │
                      │
    LLM 응답 도착 ◀───┘
       │
       ▼
    Request A 계속 처리

    LLM이 빨라진 것이 아니다.

    CPU가 빨라진 것도 아니다.

    I/O를 기다리는 동안 같은 실행 환경이 다른 처리 기회를 활용할 수 있게 된 것이다.

    이것이 비동기를 이해하는 가장 중요한 출발점이다.

    글 내용

    8. 그래서 async와 await가 등장한다

    Python에서는 이런 코드를 보게 된다.

    async def ask_llm():
        result = await llm.generate(...)
        return result

    처음 비동기 프로그래밍을 접하면 다음과 같은 용어가 한꺼번에 등장한다.

    async
    await
    coroutine
    event loop
    task
    future

    그래서 비동기 프로그래밍을 Python의 어려운 문법이라고 생각하기 쉽다.

    하지만 그 전에 기억해야 할 것은 단 하나다.

    I/O를 기다리는 동안 다른 일을 할 수 있도록 실행 흐름을 양보한다.

    async와 await는 이 아이디어를 프로그램으로 표현하기 위한 도구다.

    구체적으로 어떻게 동작하는지는 다음 편에서 자세히 살펴보자.


    9. 비동기는 LLM을 빠르게 만드는 기술이 아니다

    여기서 매우 중요한 오해 하나를 없애야 한다.

    비동기를 사용한다고 LLM API 자체가 빨라지는 것은 아니다.

    LLM 응답에 5초가 걸린다면

    동기 호출     약 5초
    비동기 호출   약 5초

    한 사용자가 느끼는 응답시간은 크게 달라지지 않을 수도 있다.

    차이가 나타나는 것은 여러 요청이 겹쳐 들어올 때다.

    여기서 두 가지 개념을 구분해야 한다.

    Latency : 한 요청이 완료되는 데 걸리는 시간.
    Throughput : 일정 시간 동안 시스템이 처리할 수 있는 작업량.

    비동기는 많은 경우 한 작업의 계산 자체를 빠르게 만드는 기술이라기보다,

    I/O 대기가 많은 프로그램이 대기시간을 효율적으로 활용하여 더 많은 작업을 처리할 수 있도록 만드는 방법이다.


    10. 비동기는 FastAPI API Server에서만 필요한가?

    아니다.

    Background Worker에서도 똑같은 문제가 생길 수 있다.

    예를 들어 사용자가 PDF 문서 100개를 업로드했다고 생각해보자.

    API Server는 작업을 Queue에 넣고 바로 응답한다.

    사용자
      │
      ▼
    FastAPI API Server
      │
      ▼
    Queue
      │
      ▼
    Background Worker

    Worker가 다음 작업을 수행한다.

    S3에서 PDF 다운로드
            ↓
    Parsing
            ↓
    Chunking
            ↓
    Embedding API 호출
            ↓
    Vector DB 저장
            ↓
    RDB 상태 업데이트

    여기에서도 작업의 성격은 서로 다르다.

    S3 Download       → I/O 대기
    Parsing           → CPU 작업 가능성
    Chunking          → CPU 작업
    Embedding API     → I/O 대기
    Vector DB         → I/O 대기
    RDB               → I/O 대기

    즉,

    API Server냐 Worker냐가 비동기 여부를 결정하는 것이 아니다.

    중요한 것은 그 프로세스가 수행하는 작업의 성격이다.

    지금 이 프로그램은 CPU로 계산하고 있는가?

    아니면,

    다른 시스템의 응답을 기다리고 있는가?

    이 질문이 먼저다.


    11. 그렇다면 모든 것을 async로 만들면 될까?

    그렇지 않다.

    예를 들어 Worker가 대용량 이미지 변환 작업을 수행한다고 생각해보자.

    Background Worker
          │
          ▼
    대용량 이미지 처리
          │
          ▼
    CPU 100%

    이 경우 문제는 기다림이 아니다.

    CPU가 실제 계산을 하고 있다.

    이런 CPU-bound 작업에 단순히 `async를 붙인다고 CPU가 하나 더 생기는 것은 아니다.

    예를 들어 다음과 같은 작업들은 대표적으로 CPU 사용량을 많이 요구할 수 있다.

    이미지 변환
    영상 인코딩
    압축 / 압축 해제
    대규모 데이터 변환
    OCR
    로컬 ML 모델 추론
    복잡한 수치 계산

    이런 작업은 비동기 I/O와는 다른 접근이 필요하다.

    경우에 따라 Thread나 Process를 사용하거나, CPU 작업 전용 Worker를 별도로 구성해야 할 수도 있다.

    즉,

    I/O 대기가 많다
         ↓
    비동기를 검토한다
    
    CPU 계산이 많다
         ↓
    Thread / Process / Worker 구조를 검토한다

    정도로 우선 구분해서 생각하면 좋다.

    물론 실제 시스템에서는 I/O-bound와 CPU-bound 작업이 섞여 있다.

    앞서 살펴본 문서 처리 Worker가 대표적인 예다.

    Background Worker
          │
          ├─ S3 Download ─────── I/O
          │
          ├─ PDF Parsing ─────── CPU + I/O
          │
          ├─ Chunking ────────── CPU
          │
          ├─ Embedding API ───── I/O
          │
          ├─ Vector DB ───────── I/O
          │
          └─ RDB Update ──────── I/O

    그래서 실제 Production 시스템에서는 단순히

    “우리 서비스는 async로 만들었습니다.”

    라는 말만으로는 시스템이 잘 설계되었는지 판단할 수 없다.

    어떤 작업을 어떤 방식으로 실행하고 있는지를 봐야 한다.


    12. async def 라고 쓰면 비동기 코드일까?

    여기서 또 하나의 중요한 함정이 있다.

    다음 코드를 보자.

    import requests
    
    async def call_external_api():
        response = requests.get("https://example.com")
        return response.json()

    함수 앞에 분명히 async가 붙어 있다.

    그렇다면 이 함수는 다른 작업을 방해하지 않고 비동기로 동작할까?

    그렇게 단순하지 않다.

    requests.get()은 일반적인 동기 HTTP 호출이다.

    HTTP 요청을 보낸 뒤 응답이 돌아올 때까지 현재 실행 흐름을 붙잡는다.

    즉 겉모습은

    async def

    이지만 함수 내부에서 blocking I/O를 수행하고 있다.

    FastAPI의 비동기 Endpoint에서도 이런 실수는 발생할 수 있다.

    @app.get("/question")
    async def question():
        result = requests.get("https://llm-api.example.com")
        return result.json()

    코드만 보면 비동기 API처럼 보인다.

    하지만 내부의 blocking 호출 때문에 해당 Event Loop의 진행을 막을 수 있다.

    반면 비동기 HTTP Client를 사용한다면 구조가 달라진다.

    import httpx
    
    async def call_external_api():
        async with httpx.AsyncClient() as client:
            response = await client.get("https://example.com")
            return response.json()

    여기서는 HTTP 응답을 기다리는 동안 await를 통해 실행 기회를 다른 작업에 넘길 수 있다.

    따라서 중요한 것은

    async def를 사용했는가?

    가 아니라

    그 함수 내부의 작업이 실제로
    비동기 방식으로 기다릴 수 있는가?

    이다.

    이 차이를 모르면 코드 전체에 async와 await가 가득한데도 실제 서비스에서는 동시 요청이 제대로 처리되지 않는 일이 생길 수 있다.


    13. FastAPI도 자동으로 모든 것을 해결해 주지는 않는다

    FastAPI가 비동기를 지원한다고 해서 FastAPI로 만든 API가 자동으로 효율적인 비동기 서비스가 되는 것도 아니다.

    예를 들어 구조가 다음과 같다고 해보자.

                  FastAPI API Server
    │
    ▼
    async Endpoint
    │
    ┌─────────┼─────────┐
    ▼ ▼ ▼
    LLM API RDB Vector DB
    │ │ │
    ▼ ▼ ▼
    async sync async

    LLM 호출과 Vector DB Client는 비동기로 구현되어 있는데 RDB 접근에 사용하는 라이브러리는 동기 방식일 수도 있다.

    또는 다음과 같은 경우도 있다.

    async Endpoint
         │
         ▼
    PDF Parsing
         │
         ▼
    CPU 100%

    이 경우 PDF Parsing 작업이 오래 걸리면 Event Loop가 다른 요청을 처리할 기회를 얻지 못할 수 있다.

    따라서

    FastAPI를 사용했다.

    와

    비동기 처리가 올바르게 설계되어 있다.

    는 전혀 다른 이야기다.

    FastAPI는 비동기 프로그램을 만들 수 있는 도구를 제공한다.

    그 도구를 어떻게 사용하는지는 개발자의 몫이다.


    14. 그렇다고 동기 방식이 나쁜 것도 아니다

    여기까지 읽으면 이런 생각이 들 수 있다.

    그러면 앞으로 모든 코드를 async로 작성해야 하나?

    그럴 필요는 없다.

    동기 방식은 훨씬 단순하고 이해하기 쉽다.

    예를 들어 하루에 몇 번 실행되는 관리용 Script가 있다고 해보자.

    Python Script
        │
        ├─ DB 조회
        │
        ├─ 데이터 가공
        │
        └─ 결과 저장

    실행시간이 3초에서 4초가 되는 것이 아무 문제가 없다면 굳이 복잡한 비동기 구조를 만들 이유가 없다.

    사용자 요청을 실시간으로 처리하지 않는 간단한 Batch 프로그램도 마찬가지다.

    매일 새벽 2시
    
    Batch 시작
        ↓
     DB 조회
        ↓
       처리
        ↓
     파일 생성
        ↓
       종료

    10분 안에 끝나면 되는 작업이 동기 방식으로 3분 만에 끝난다면 충분하다.

    비동기를 도입하면 얻는 것만 있는 것도 아니다.

    코드의 실행 흐름을 이해하기 어려워질 수 있고,

    동시 실행에 따른 새로운 문제도 생긴다.

    따라서 중요한 것은

    비동기를 사용하는 것 자체가 아니라 비동기가 필요한 상황을 판단하는 능력이다.


    15. 비동기는 무한 동시 실행을 의미하지 않는다

    비동기를 처음 배우고 나면 반대 방향의 실수를 하기도 한다.

    “기다리는 동안 다른 작업을 할 수 있다면 가능한 한 많이 동시에 실행하면 빠르지 않을까?”

    예를 들어 Embedding API를 호출해야 하는 데이터가 10,000개 있다고 하자.

    이론적으로는 다음과 같이 생각할 수 있다.

    Embedding 1   ─→
    Embedding 2   ─→
    Embedding 3   ─→
    ...
    Embedding 10000 ─→

    Python에서는 asyncio.gather() 등을 이용해 많은 작업을 동시에 실행할 수도 있다.

    하지만 10,000개의 요청을 한꺼번에 실행한다고 해서 좋은 시스템이 되는 것은 아니다.

    오히려 다음과 같은 일이 발생할 수 있다.

    10,000 Tasks
          │
          ▼
    HTTP Connection 급증
          │
          ├─ Connection Pool 고갈
          ├─ Memory 증가
          ├─ 외부 API Rate Limit
          ├─ Socket 증가
          └─ Timeout 증가
                  │
                  ▼
                 Retry
                  │
                  ▼
              요청량 더 증가
                  │
                  ▼
                 장애

    이것은 매우 중요한 사실이다.

    비동기는 동시성을 가능하게 하는 기술이지, 무한한 동시성을 가능하게 하는 기술이 아니다.

    글 내용

    16. 결국 Production에서는 ‘얼마나 동시에’가 중요해진다

    이제 질문이 조금 달라진다.

    처음에는 이렇게 질문했다.

    비동기로 만들 것인가?

    하지만 실제 서비스를 운영할 때는 더 중요한 질문이 생긴다.

    동시에 몇 개까지 실행할 것인가?

    예를 들어 FastAPI API Server가 외부 LLM API를 호출한다고 해보자.

    FastAPI API Server
    
    Request 1 ──────→ LLM
    Request 2 ──────→ LLM
    Request 3 ──────→ LLM
    ...
    Request 100 ────→ LLM

    비동기 구조라면 많은 요청이 동시에 LLM 응답을 기다릴 수 있다.

    그런데 시스템에는 여러 한계가 존재한다.

    FastAPI 동시 요청
            │
            ├─ HTTP Connection Pool
            │
            ├─ DB Connection Pool
            │
            ├─ Vector DB 처리량
            │
            ├─ LLM API Rate Limit
            │
            ├─ Memory
            │
            └─ Network

    어느 하나라도 한계를 넘으면 병목이 발생한다.

    예를 들어 RDB Connection Pool이 20개인데 동시에 500개의 요청이 DB Connection을 요구한다면 500개가 모두 즉시 DB를 사용할 수는 없다.

    결국 일부 요청은 기다려야 한다.

    그래서 Production에서는 단순히 async를 사용하는 것을 넘어

    Concurrency Limit
    Semaphore
    Connection Pool
    Timeout
    Rate Limit
    Queue
    Backpressure
    Worker

    같은 개념이 중요해진다.


    17. API Server와 Background Worker는 문제의 모습도 다르다

    FastAPI API Server와 Background Worker를 구분해서 보는 이유가 여기에도 있다.

    API Server의 가장 중요한 특징은 사용자가 응답을 기다리고 있다는 것이다.

    사용자
      │
      │ Request
      ▼
    FastAPI API Server
      │
      │ 처리
      ▼
    Response
      │
      ▼
    사용자

    처리가 밀리면 사용자가 직접 느낀다.

    2초
     ↓
    5초
     ↓
    15초
     ↓
    30초
     ↓
    Timeout

    반면 Background Worker는 조금 다르다.

    FastAPI
       │
       ▼
    Queue
       │
       ├─ Task 1
       ├─ Task 2
       ├─ Task 3
       ├─ Task 4
       └─ ...
            │
            ▼
    Background Worker

    Worker의 처리속도보다 작업이 들어오는 속도가 빠르면 Queue가 쌓인다.

    작업 유입
    
    100 task/sec
         │
         ▼
       Queue
         │
         ▼
    Worker 처리
    
    50 task/sec

    매초 100개의 작업이 들어오는데 Worker가 50개밖에 처리하지 못한다면 매초 50개의 작업이 밀린다.

    1분이면 3,000개다.

    10분이면 30,000개다.

    API Server가 바로 죽지 않더라도 서비스는 이미 정상적인 상태가 아니다.

    따라서 Worker에서는

    Queue Length
    Processing Rate
    Worker Concurrency
    Task Duration
    Retry
    Memory
    CPU

    같은 요소를 함께 봐야 한다.


    18. 그래서 ‘서버가 터졌다’는 말도 정확하지 않다

    현장에서 흔히 듣는 말이 있다.

    서버가 터졌어요.

    그런데 실제로 무엇이 문제인지 보면 전혀 다른 원인들이 있을 수 있다.

    "서버가 느려졌다"
    
                   │
            ├─ FastAPI Event Loop가 Blocking됨
            │
            ├─ API Worker가 모두 점유됨
            │
            ├─ RDB Connection Pool 고갈
            │
            ├─ HTTP Connection Pool 고갈
            │
            ├─ Background Queue 적체
            │
            ├─ Worker CPU 100%
            │
            ├─ Memory 부족
            │
            ├─ 외부 LLM API 지연
            │
            └─ 외부 API Rate Limit

    겉으로 사용자에게 보이는 현상은 비슷할 수 있다.

    “응답이 안 온다.”

    하지만 원인은 완전히 다르다.

    그래서 Production 문제를 해결하려면

    ‘서버가 느리다’가 아니라 ‘어떤 프로세스가 어떤 자원을 기다리고 있는가’를 봐야 한다.

    이 관점은 비동기를 공부하면서 반드시 익혀야 한다.


    19. AI 서비스는 ‘계산 서버’라기보다 ‘오케스트레이터’에 가까워지고 있다

    특히 최근의 AI 서비스를 보면 이런 특징이 더욱 강해진다.

    RAG 서비스를 생각해보자.

                        ┌──────────────┐
    사용자 ───────────→ │ FastAPI API │
    └──────┬───────┘
    │
    ┌───────────────┼────────────────┐
    │ │ │
    ▼ ▼ ▼
    Embedding API Vector DB RDB
    │
    ▼
    Reranker
    │
    ▼
    LLM API

    Agent 서비스는 더 심하다.

    FastAPI API Server
            │
            ▼
          Agent
            │
            ├─→ LLM API
            │
            ├─→ Search API
            │
            ├─→ Database
            │
            ├─→ MCP / Tool
            │
            ├─→ 다른 사내 API
            │
            └─→ 다시 LLM API

    우리 프로그램이 모든 계산을 직접 수행하는 것이 아니다.

    여러 시스템에 일을 요청하고,

    기다리고,

    결과를 받고,

    다른 시스템에 다시 요청하고,

    마지막 결과를 조합한다.

    따라서 현대적인 AI 애플리케이션의 API Server나 Worker는 많은 경우

    여러 시스템 사이의 작업을 조율하는 오케스트레이터(Orchestrator) 역할을 한다.

    이런 구조에서는 I/O 대기를 어떻게 다루느냐가 시스템 전체 처리량에 큰 영향을 미친다.

    그래서 AI 서비스 개발자에게 비동기는 선택적인 Python 고급 문법 정도로만 보기 어렵다.


    20. 우리가 정말 공부해야 하는 것

    이번 시리즈에서 공부하려는 것은 단순히 다음 문법을 외우는 것이 아니다.

    async def
    await
    asyncio.create_task()
    asyncio.gather()

    궁극적으로 다음 질문에 답할 수 있어야 한다.

    사용자 요청이 들어왔다.
            │
            ▼
    어느 프로세스가 받는가?
            │
            ▼
    FastAPI API Server인가?
    Background Worker인가?
            │
            ▼
    그 프로세스는 지금
    무엇을 하고 있는가?
            │
            ├─ CPU 계산?
            │
            └─ I/O 대기?
                    │
                    ▼
    그 대기가 다른 작업을 막는가?
                    │
                    ▼
    동시에 몇 개까지 실행되는가?
                    │
                    ▼
    그 숫자를 제한하고 있는 것은 무엇인가?

    여기까지 생각할 수 있다면 코드도 다르게 보이기 시작한다.

    예를 들어 이런 질문을 하게 된다.

    LLM API 호출에 5초가 걸릴 때 FastAPI API Server로 동시에 500개의 요청이 들어오면 어떻게 되는가?

    이 Endpoint 안에서 사용하는 DB Driver는 동기인가 비동기인가?

    HTTP Client의 Connection Pool은 몇 개인가?

    Vector DB가 10초 동안 느려지면 API 요청들은 어디에서 기다리게 되는가?

    Background Worker가 처리할 수 있는 속도보다 Queue에 작업이 들어오는 속도가 빠르면 어떻게 되는가?

    CPU-bound 작업을 FastAPI Event Loop 안에서 직접 실행하고 있지는 않은가?

    외부 LLM API가 응답하지 않을 때 우리는 몇 초까지 기다릴 것인가?

    이 질문들은 AI 모델에 대한 질문이 아니다.

    하지만 AI 서비스를 Production에서 운영하려면 반드시 답해야 하는 질문들이다.


    마치며

    AI 서비스를 만드는 진입장벽은 빠르게 낮아지고 있다.

    Python과 FastAPI를 사용하고 LLM API, Vector DB, RDB를 연결하면 비교적 짧은 시간에도 동작하는 서비스를 만들 수 있다.

    하지만

    동작하는 서비스를 만드는 것과 많은 사용자가 동시에 사용하는 서비스를 만드는 것은 다른 문제다.

    개발 환경에서는 보통 이런 구조다.

    개발자 한 명
         ↓
    FastAPI API Server
         ↓
    외부 서비스
         ↓
       응답

    Production에서는 이렇게 된다.

                    수많은 사용자
    │
    ▼
    FastAPI API Server
    │
    ┌───────────┼───────────┐
    ▼ ▼ ▼
    LLM API Vector DB RDB
    │
    ▼
    Queue
    │
    ▼
    Background Worker
    │
    ┌───────────┼───────────┐
    ▼ ▼ ▼
    S3 Embedding API RDB

    이제 단순히

    “API 호출이 성공하는가?”

    만 확인해서는부족하다.

    Production에서는 질문이 달라져야 한다.

    FastAPI API Server에 동시에 100개의 요청이 들어오면 어떻게 되는가?

    그 요청들은 어디에서 기다리고 있는가?

    LLM API를 기다리는 동안 FastAPI의 Event Loop는 다른 요청을 처리할 수 있는가?

    Background Worker 하나가 긴 작업을 수행하고 있을 때 다른 작업들은 어디에서 기다리는가?

    Worker가 처리하는 속도보다 Queue에 작업이 들어오는 속도가 빠르면 어떻게 되는가?

    RDB Connection Pool이 20개인데 동시에 100개의 작업이 Connection을 요구하면 어떻게 되는가?

    외부 API가 30초 동안 응답하지 않으면 FastAPI API Server나 Worker는 그동안 무엇을 하고 있는가?

    CPU-bound 작업을 Event Loop 안에서 실행하면 다른 요청에는 어떤 영향을 미치는가?

    이 질문에 답하기 시작하면 비동기는 더 이상 단순한 Python 문법이 아니다.

    시스템의 구조를 이해하는 방법이 된다.

    그리고 비동기 프로그래밍을 공부하면서 가장 먼저 기억해야 할 것은 의외로 간단하다.

    누가 무엇을 기다리고 있는가?

    FastAPI API Server가 LLM API를 기다리고 있는가?

    Background Worker가 S3 다운로드를 기다리고 있는가?

    RDB Connection을 얻지 못해 요청이 기다리고 있는가?

    아니면 프로그램은 기다리는 것이 아니라 실제로 CPU를 사용해 계산하고 있는가?

    이것부터 구분해야 한다.

    그다음 질문은 이것이다.

    기다리는 동안 다른 일을 할 수 있는가?

    바로 여기에서 비동기 프로그래밍이 시작된다.

    하지만 아직 중요한 질문 하나가 남아 있다.

    비동기 = 동시에 실행?
    
    동시성 = 병렬 처리?
    
    async = non-blocking?
    
    await = 기다리는 것?

    이 용어들은 비슷하게 느껴지지만 같은 의미가 아니다.

    특히 async와 await를 사용하면서도 이 차이를 정확히 이해하지 못하면 코드가 어떻게 실행되는지 예상하기 어려워진다.

    다음 글에서는 코드를 본격적으로 작성하기 전에 이 개념부터 정리해보자.


    다음 글

    AI 개발자가 알아야 할 비동기 프로그래밍 2부

    동기와 비동기의 진짜 차이 — async는 동시에 실행한다는 뜻일까?

    2부에서는 다음 네 가지를 구분하는 것부터 시작한다.

    Synchronous   vs   Asynchronous
    
    Blocking      vs   Non-blocking
    
    Concurrency   vs   Parallelism

    그리고 이것들이 FastAPI에서 실제로 어떤 의미를 갖는지 살펴본다.

    예를 들어 다음과 같은 상황이다.

    @app.get("/question")
    async def question():
        result = await call_llm()
        return result

    await를 만났는데 왜 프로그램은 멈추지 않는 것일까?

    아니, 정확히 말하면 무엇은 멈추고 무엇은 멈추지 않는 것일까?

    하나의 Python 프로세스가 여러 사용자의 요청을 처리한다는 것은 실제로 무엇을 의미할까?

    Event Loop는 무엇을 하고 있는가?

    그리고 흔히 말하는

    “비동기는 동시에 여러 작업을 실행한다.”

    라는 설명은 정확한 표현일까?

    2부에서는 식당의 직원 한 명으로 다시 돌아가 이 문제를 하나씩 풀어본다.

    직원 한 명이 여러 테이블을 담당할 수 있다고 해서 직원이 여러 명이 된 것은 아니다.

    이 차이를 이해하면 Concurrency와 Parallelism의 차이, 그리고 Python 비동기 프로그래밍의 핵심인 Event Loop가 왜 필요한지도 자연스럽게 이해할 수 있다.

    여기서 잠깐, Event Loop란 무엇일까

    앞에서부터 Event Loop라는 용어가 조금씩 등장하기 시작했다.

    비동기 프로그래밍을 처음 접한다면 이름부터 조금 어렵게 느껴질 수 있다.

    하지만 지금 단계에서는 복잡하게 생각할 필요가 없다.

    Event Loop는 아주 단순하게 말하면,

    “지금 실행할 수 있는 작업을 찾아 실행하고, 기다려야 하는 작업은 잠시 내려놓았다가 준비되면 다시 이어서 실행하도록 관리하는 반복 실행 구조” 라고 이해하면 된다.

    예를 들어 FastAPI API Server가 다음 세 개의 요청을 처리하고 있다고 생각해보자.

    Request A
       ↓
    LLM API 호출
       ↓
    응답 기다림 ─────────────┐
                          │
    Request B             │
       ↓                  │
    Vector DB 호출         │
       ↓                  │
    응답 기다림 ────────┐    │
                     │    │
    Request C        │    │
       ↓             │    │
    RDB 호출          │    │
       ↓             │    │
    응답 기다림         │    │
                     │    │
                     ▼    ▼
                   응답이 도착하면
                   해당 작업 계속

    Request A가 LLM API의 응답을 기다린다고 해서 Event Loop까지 함께 가만히 기다릴 필요는 없다.

    A가 기다리는 동안 실행할 수 있는 Request B의 작업을 처리하고,

    B 역시 I/O를 기다리게 되면 Request C의 작업을 처리할 수 있다.

    개념적으로 보면 Event Loop는 계속 이런 질문을 하는 셈이다.

    ┌──────────────────────────┐
    │ 지금 실행할 수 있는 작업은?    │
    └────────────┬─────────────┘
                 │
                 ▼
              실행한다
                 │
                 ▼
           I/O를 기다려야 하나?
              /          \
            YES           NO
             │             │
             ▼             ▼
       잠시 실행을 양보     계속 실행
             │
             ▼
      다른 작업을 확인
             │
             ▼
      I/O 완료 알림 도착
             │
             ▼
      이전 작업을 다시 진행
             │
             └──────────→ 반복

    그래서 이름도 Event Loop다.

    I/O 완료 같은 이벤트를 확인하면서 실행 가능한 작업들을 계속 처리하는 흐름이 반복된다.

    Python의 asyncio에서는 이 Event Loop가 비동기 작업을 실행하는 핵심 역할을 한다.

    그리고 await는 이 구조와 밀접한 관계가 있다.

    result = await call_llm()

    이 코드를 아주 개념적으로 표현하면,

    “LLM 응답이 필요하니 나는 지금 여기서 진행할 수 없다. 기다리는 동안 다른 실행 가능한 작업이 있다면 그쪽에 실행 기회를 넘기고, 결과가 준비되면 나를 다시 진행시켜 달라.”

    정도로 생각하면 된다.

    여기서 중요한 점이 하나 있다.

    Event Loop가 여러 작업을 관리한다고 해서 CPU 하나가 동시에 여러 Python 코드를 실행하고 있다는 뜻은 아니다.

    Event Loop는 실행 가능한 작업을 빠르게 전환하면서 I/O 대기시간을 활용한다.

    그래서 비동기 프로그래밍에서 중요한 것은 Event Loop를 오래 붙잡지 않는 것이다.

    예를 들어 다음처럼 오래 걸리는 CPU 작업을 Event Loop에서 직접 실행한다고 생각해보자.

    Event Loop
        │
        ▼
    PDF Parsing 시작
        │
        │
        │ CPU 작업 중...
        │
        │
        ▼
    PDF Parsing 완료
        │
        ▼
    다음 작업

    PDF Parsing 코드가 CPU를 계속 사용하면서 실행권을 놓지 않는다면 Event Loop는 그동안 다른 비동기 작업으로 넘어갈 기회를 얻지 못할 수 있다.

    그래서 다른 요청들은 실행 가능한 상태여도 기다리게 된다.

    Request A ─→ PDF Parsing ───────────────→ 완료
                        │
                        │ Event Loop 점유
                        │
    Request B ──────────┼──────────── 기다림
    Request C ──────────┼──────────── 기다림
    Request D ──────────┴──────────── 기다림

    이것이 흔히 말하는 “Event Loop를 막았다(Block the Event Loop)” 라는 상황이다.

    지금은 여기까지만 이해하면 충분하다.

    정리하면,

    Event Loop는 기다리는 작업과 실행할 수 있는 작업 사이를 조율하면서 비동기 프로그램이 효율적으로 움직일 수 있도록 관리하는 핵심 실행 구조다.

    그리고 앞으로 async, await, Coroutine, Task를 하나씩 살펴보면 Event Loop가 실제로 이 작업들을 어떻게 관리하는지도 자연스럽게 이해할 수 있게 된다.

    Event Loop의 내부 동작은 다음 편에서 조금 더 자세히 살펴보자.