9 min read

우리도 이제 AI 에이전트 필요함!

ChatGPT처럼 하면 돼?

회사에서 AI 데이터 분석 에이전트의 프론트엔드를 맡게 됐습니다. 처음엔 단순하게 생각했어요. "ChatGPT처럼 스트리밍으로 텍스트 뿌려주면 되는 거 아닌가?"

근데 기획서를 보니 전혀 달랐습니다. 응답이 단순 텍스트가 아니었어요.

  • 핵심 지표는 카드 UI로 보여줘야 하고
  • 중간에 차트가 끼어들어야 하고
  • 분석 조건은 태그 형태로 표시해야 하고
  • 인사이트는 리스트 UI로 구조화해야 하고
  • 이 모든 게 스트리밍 중에 실시간으로 나타나야 했습니다

마크다운 스트리밍으로는 절대 안 되는 구조였어요.


왜 XML이었는가

응답 포맷을 뭘로 할지가 첫 번째 판단이었습니다.

마크다운 — 가장 흔한 선택이지만, 구조화된 UI 블록을 표현할 수 없습니다. "이 부분은 차트로 렌더링해"라는 의미를 마크다운으로 전달할 방법이 없어요.

JSON — 구조 표현은 가능하지만, 스트리밍 중 파싱이 불가능합니다. 닫는 }가 아직 안 왔으면 중간 상태를 파싱할 수 없어요. partial JSON parser 같은 것도 있지만 불안정합니다.

XML — 여는 태그가 도착하는 순간 "이 블록이 시작됐다"를 알 수 있습니다. 중첩 구조도 자연스럽고, LLM이 XML 생성을 잘 합니다. 그리고 결정적으로 — htmlparser2가 이미 청크 단위 스트리밍 파싱을 지원합니다.

<!-- 서버에서 이런 형태로 스트리밍됨 -->
<main-title>최근 7일 매출 분석</main-title>
<core-indicator value="1,234,567" label="총 매출" trend="+12%" />
<query-condition>기간: 5/13~5/20, 필터: 모바일, 그룹: 일별</query-condition>
<ai-visualization id="chart-abc123" />
<insight-discovery>
  <insight-list>
    <insight-item>수요일 매출이 전주 대비 23% 상승</insight-item>
    <insight-item>신규 유저 비중이 41%로 역대 최고</insight-item>
  </insight-list>
</insight-discovery>

각 태그가 곧 Vue 컴포넌트와 1:1 매핑됩니다. <core-indicator>는 카드 컴포넌트로, <ai-visualization>은 차트 컴포넌트로, <insight-item>은 리스트 아이템으로. 포맷 자체가 UI 설계서인 셈이죠.


전체 파이프라인

1.png
(오.. 이번에도 열일한 GPT 5.5 의 실력..!)

단순히 텍스트를 뿌리는 게 아니라, 9종류의 SSE 이벤트를 구분해서 각각 다른 UI 상태로 전환해야 했습니다.


SSE 스트리밍: EventSource를 안 쓴 이유

브라우저 내장 EventSource가 있는데 왜 직접 구현했냐면:

  1. EventSource는 GET만 지원 — 우리는 POST로 대화 컨텍스트를 보내야 함
  2. 헤더 커스터마이징 불가 — 인증 토큰을 넣을 수 없음
  3. 타임아웃 제어 불가 — 분석이 10분까지 걸릴 수 있음

그래서 fetch + ReadableStream + eventsource-parser 조합으로 갔습니다.

const response = await fetch(url, {
  method: 'POST',
  body: JSON.stringify(requestModel),
  headers: { Authorization: `Bearer ${token}` },
});

const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
const parser = createParser({ onEvent: handleSSEEvent });

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  parser.feed(decoder.decode(value, { stream: true }));
}

eventsource-parser가 SSE 프로토콜(event/data/id 필드)을 파싱해주니까, 우리는 이벤트 타입별 핸들러만 작성하면 됩니다.


XML 스트림 파싱: 청크가 태그 중간에서 잘리면?

SSE의 MESSAGE_DELTA 이벤트로 XML 조각이 날아옵니다. 문제는 — 네트워크 청크가 태그 중간에서 잘릴 수 있다는 거예요.

청크 1: "<insight-disc"
청크 2: "overy><insight-list><insight-it"
청크 3: "em>전주 대비 12% 상승</insight-item>"

htmlparser2가 이걸 처리해줍니다. parser.write(chunk)를 호출하면 내부적으로 버퍼링하면서 완성된 태그만 콜백을 발생시켜요.

const parser = new htmlparser2.Parser({
  onopentag(name, attribs) {
    // 새 블록 시작 → ParsedBlock 생성, 트리에 추가
    const newBlock = { type: name, content: '', attributes: attribs, children: [] };
    // 부모-자식 관계에 따라 트리 구성
  },
  ontext(text) {
    // 텍스트 도착 → 현재 블록에 추가 + 타이핑 애니메이션 큐잉
  },
  onclosetag(name) {
    // 블록 종료 → 스택에서 pop
  },
});

핵심은 부모-자식 관계를 미리 정의해두는 것이었습니다. insight-discoveryinsight-list를 자식으로 가질 수 있고, insight-listinsight-item을 가질 수 있고. 이 맵이 없으면 모든 태그가 플랫하게 나열돼서 구조를 잃어버려요.

const parentChildMap = {
  'insight-discovery': ['insight-list', 'text'],
  'insight-list': ['insight-item'],
  'data-pattern-analysis': ['pattern-list', 'text'],
  'pattern-list': ['pattern-item'],
};

타이핑 애니메이션: 스트림 속도 ≠ 렌더링 속도

서버에서 청크가 불규칙하게 옵니다. 어떤 때는 한꺼번에 200자가 오고, 어떤 때는 0.5초 동안 아무것도 안 오고. 이걸 그대로 화면에 뿌리면 뚝뚝 끊기는 느낌이 납니다.

requestAnimationFrame 기반 TextAnimator를 만들었습니다. 도착한 텍스트를 큐에 넣고, 프레임마다 N글자씩 꺼내서 렌더링합니다.

class TextAnimator {
  private animationQueue: { text: string; callback: Function }[] = [];
  private charsPerFrame = 3;

  addText(text: string, callback: (chunk: string) => void) {
    this.animationQueue.push({ text, callback });
    this.startIfNeeded();
  }

  private animateFrame() {
    // 프레임당 charsPerFrame 글자만 꺼내서 콜백 호출
    // 큐가 빌 때까지 반복
  }
}

이렇게 하면:

  • 서버에서 한꺼번에 많이 와도 → 일정 속도로 부드럽게 출력
  • 서버가 잠깐 멈춰도 → 큐에 남은 텍스트가 계속 흘러나옴

체감 속도가 균일해집니다. ChatGPT가 부드럽게 느껴지는 것도 같은 원리예요.


차트가 스트림 중간에 끼어드는 문제

텍스트만 스트리밍되면 단순한데, 중간에 차트가 끼어듭니다. 서버가 SQL을 실행하고 결과를 집계한 뒤, MESSAGE_VISUALIZATION 이벤트로 차트 데이터를 보내요.

MESSAGE_DELTA: "<main-title>매출 분석</main-title><core-indicator..."
MESSAGE_DELTA: "...value='1234' />"
MESSAGE_VISUALIZATION: { id: "chart-1", type: "line", data: [...] }
MESSAGE_DELTA: "<insight-discovery>..."

차트 이벤트가 오면 현재 파싱 중인 XML 블록 리스트에 차트 블록을 직접 삽입합니다. XML 파서와는 별개 경로로 들어오는 거예요.

case 'MESSAGE_VISUALIZATION':
  const chartBlock = convertToChartModel(parsedData);
  parsedBlocks.value.push(chartBlock);  // XML 블록 리스트에 직접 삽입
  break;

렌더링 쪽에서는 블록 타입을 보고 분기합니다:

<template v-for="block in parsedBlocks">
  <CoreIndicatorCard v-if="block.type === 'core-indicator'" :data="block" />
  <ChartVisualization v-else-if="block.type === 'ai-visualization'" :data="block" />
  <InsightDiscovery v-else-if="block.type === 'insight-discovery'" :data="block" />
  <BodyText v-else-if="block.type === 'text'" :content="block.content" />
</template>

복합 질문 분해: "분석 중..." 이 그냥 로딩이 아닌 이유

"지난달 대비 이번달 매출 변화를 채널별로 보여주고, 이탈률도 같이 분석해줘"

이런 질문은 서버가 여러 sub-question으로 쪼갭니다. 프론트에서는 각 단계의 진행 상태를 실시간으로 보여줘야 했어요.

PROCESS_START: { splitQuestions: ["채널별 매출 변화", "이탈률 분석"], jobId: "..." }
PROCESS_STREAM: { step: "query_1", data: "SQL 생성 중..." }
PROCESS_DATA: { step: "query_1", data: true }  // 완료
PROCESS_STREAM: { step: "query_2", data: "데이터 집계 중..." }
PROCESS_END: {}
MESSAGE_START: {}
MESSAGE_DELTA: "<main-title>..."

PROCESS_* 이벤트들은 "분석 진행 UI"를 제어하고, MESSAGE_* 이벤트들은 "응답 렌더링"을 제어합니다. 두 단계가 시간적으로 분리돼 있어서, 사용자가 "지금 뭘 하고 있는지" 알 수 있습니다. 그냥 스피너 돌리는 것보다 체감 대기 시간이 훨씬 짧아요.


취소 처리는 어떻게?

사용자가 분석 중에 "취소"를 누르면? 두 가지를 동시에 해야 합니다:

  1. 클라이언트: ReadableStream 읽기 중단 + 파서 리셋 + UI 상태 초기화
  2. 서버: 진행 중인 분석 job 취소 API 호출

클라이언트만 끊으면 서버는 계속 분석을 돌리고 있어요. 토큰 비용이 계속 나갑니다. 그래서 취소 시 별도 API를 호출해서 서버 job도 중단시킵니다.


돌이켜보면

좋았던 점: XML + 스트리밍 파싱 조합이 생각보다 잘 동작했습니다. 새로운 블록 타입을 추가할 때 태그 하나 + 컴포넌트 하나만 만들면 되니까 확장도 쉬웠어요. 타이핑 애니메이션도 UX를 확실히 개선했고요.

아쉬웠던 점: 태그가 18종까지 늘어나면서 관리가 복잡해졌습니다. 서버 LLM 프롬프트에도 태그 스펙을 전부 명시해야 하는데, 프론트에서 태그를 추가하면 백엔드 프롬프트도 같이 수정해야 해서 배포 의존성이 생겼어요.

다시 한다면: 태그 종류를 줄이고, 속성(attribute)으로 변형을 표현했을 겁니다. <insight-item><pattern-item>을 따로 만들 게 아니라 <list-item type="insight"> 같은 식으로요. 그리고 태그 스펙을 프론트/백엔드가 공유하는 스키마 파일로 관리했을 겁니다.


참고 자료