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

SSE와 Streamable HTTP 4부 — FastAPI, AI Agent, MCP 통신 설계

지금까지 세 편에 걸쳐 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가 어디에 필요한지도 훨씬 명확하게 보이기 시작합니다.