CodeGym /행동 /ChatGPT Apps /스트리밍 UX: 진행률, 부분 결과, 장기 작업 취소

스트리밍 UX: 진행률, 부분 결과, 장기 작업 취소

ChatGPT Apps
레벨 13 , 레슨 2
사용 가능

1. 왜 스트리밍 UX가 ChatGPT App에서 특히 중요한가

일반 웹에서는 파일 업로드 진행률 바, 회전하는 스피너, 스켈레톤 화면에 이미 익숙합니다. 하지만 ChatGPT 앱에서는 또 하나의 “경쟁자”가 있습니다. 바로 실시간으로 텍스트를 스트리밍할 줄 아는 모델 자체입니다. 이때 위젯이 설명 없는 정적인 스피너만 보여주면 인상에서 밀리게 됩니다 — GPT는 “살아 있는데”, App은 “멈춘” 느낌이니까요.

장기 작업을 위한 UX는 여러 문제를 한 번에 해결합니다. 첫째, 사용자의 불안을 줄입니다. “멈춘 건가, 아직 생각 중인가?” 대신 상태, 단계, 퍼센트, 심지어 첫 번째 결과까지 보게 되죠. 둘째, 신뢰를 높입니다. App이 무엇을 하는지(후기를 분석, 가격 비교, 선물 필터링) 명확히 보여주면, 바로 그 operational transparency — 운영의 투명성이 생깁니다. 사용자는 “보이는 마법”이 아니라 이해 가능한 단계의 연속임을 느낍니다.

마지막으로, 스트리밍 UX는 진행률만이 아닙니다. 제어에 관한 것이기도 합니다. 무거운 선물 추천을 중간에 멈추고, 파라미터를 바꿔 즉시 다시 시작할 수 있는 기능은 “서버의 자비를 기다리는 게 아니라 내가 조종한다”는 감각을 줍니다.

이 강의에서 우리는 다음을 수행합니다.

  • 장기 작업을 위한 간단한 상태 모델을 설계합니다(pending / in_progress / partial_ready / …);
  • 이를 React 위젯 상태로 옮깁니다;
  • 진행률과 부분 결과를 정직하게 보여주는 법을 파악합니다;
  • 이런 작업의 취소를 깔끔하게 구현합니다.

이 모든 것을 우리의 GiftGenius 예제로 보여드립니다.

2. GiftGenius의 장기 작업 상태 모델

이벤트 스트림을 if (event.type === …) 난장판으로 만들지 않으려면, 클라이언트에서 장기 작업을 유한 상태 기계(state machine)로 생각하는 것이 편합니다. GiftGenius에서는 이론에서 이미 보았던 다음의 논리적 상태들을 사용합니다: pending, in_progress, partial_ready, completed, failed, canceled, 그리고 대기 상태인 idle.

이를 표로 정리하면 다음과 같습니다.

상태 백엔드에서의 의미 사용자가 위젯에서 보는 것
idle
아직 작업이 없음 일반 폼, “선물 추천 받기” 버튼
pending
Job이 생성됨, 워커 시작 대기 버튼 비활성화, 가벼운 스피너
in_progress
워커가 실행 중이며 job.progress를 전송 진행률 바 또는 “3단계 중 1단계” 같은 단계 표시
partial_ready
첫 결과가 있으며 작업이 계속됨 첫 선물 카드가 이미 보임 + 진행률 계속 표시
completed
job.completed 수신 최종 선물 목록, CTA(“구매”)
failed
job.failed 수신 오류 메시지 + “다시 시도” 버튼
canceled
job.canceled 또는 취소 플래그 수신 “선물 찾기를 중단했습니다” 텍스트 + “다시 시작”

이 모델은 MCP 이벤트에도 잘 들어맞습니다. 예를 들어 job.startedpending에서 in_progress로 전환합니다. job.progressin_progress에서 퍼센트만 갱신하거나, “첫 카드가 나왔습니다”라고 알려 partial_ready로 전환하게 만들 수 있습니다. job.completed, job.failed, job.canceled는 이 흐름을 마무리합니다.

상태 기계를 작게 그리면 다음과 같습니다.

stateDiagram-v2
    [*] --> idle
    idle --> pending: 잡 생성
    pending --> in_progress: job.started
    in_progress --> partial_ready: 첫 부분 결과
    partial_ready --> completed: job.completed
    in_progress --> completed: job.completed (부분 결과 없이)
    in_progress --> failed: job.failed
    partial_ready --> failed: job.failed
    in_progress --> canceled: job.canceled
    partial_ready --> canceled: job.canceled
    failed --> idle: 다시 시작
    canceled --> idle: 다시 시작

위젯 코드에서는 이를 간단한 타입으로 표현할 수 있습니다.

type JobStatus =
  | 'idle'
  | 'pending'
  | 'in_progress'
  | 'partial_ready'
  | 'completed'
  | 'failed'
  | 'canceled';

interface GiftJobState {
  status: JobStatus;
  percent?: number;
  stage?: string;
  error?: string;
}

지금은 데이터의 형태만 정의한 것입니다. 이후 MCP 또는 스트림에서 이벤트가 들어올 때마다 이 구조를 실제 값으로 채워나가겠습니다.

3. 위젯 상태: React‑컴포넌트가 스트림을 “청취”하는 방법

GiftGenius 위젯의 상태 모델을 React 코드로 옮겨봅시다. 우리는 다음을 저장해야 합니다.

  • 현재 jobId — 어떤 이벤트가 이 작업에 속하는지 식별;
  • 작업 상태(status, percent, stage);
  • 부분 결과 배열(선물 카드들);
  • 버튼을 위한 플래그: 취소 가능 여부, 재시작 가능 여부.

이를 하나의 인터페이스로 정의합니다.

interface GiftSuggestion {
  id: string;
  title: string;
  price: string;
}

interface GiftWidgetState extends GiftJobState {
  jobId?: string;
  partialGifts: GiftSuggestion[];
}

컴포넌트에서의 초기화는 매우 단순할 수 있습니다.

const [state, setState] = useState<GiftWidgetState>({
  status: 'idle',
  partialGifts: [],
});

이후 두 가지 핵심 포인트가 있습니다.

첫째, 작업 시작입니다. 이는 Apps SDK를 통한 MCP 도구 호출(callTool)일 수도 있고, job을 생성해 jobId를 반환하는 백엔드로의 HTTP 요청일 수도 있습니다. 이 강의에서는 async 파이프라인의 구체(큐와 워커)는 다루지 않습니다 — 다음 주제에서 자세히 다룹니다. 지금은 생성된 jobId에 대해 UI가 어떻게 반응하는지만 중요합니다.

둘째, 이 jobId의 이벤트를 구독합니다. 실제로는 useJobEvents(jobId) 같은 훅이나 subscribeToJobEvents 같은 래퍼일 수 있으며, 내부적으로는 SSE 연결이나 MCP 클라이언트를 사용하지만 외부에는 정상적인 JS 객체를 반환합니다. 아래에서는 단순화를 위해 useEffect 안에서 subscribeToJobEvents를 사용하는 예를 보여줍니다.

useEffect(() => {
  if (!state.jobId) return;

  const unsubscribe = subscribeToJobEvents(state.jobId, handleEvent);
  return () => unsubscribe();
}, [state.jobId]);

여기서 handleEvent는 이벤트 타입에 따라 state를 갱신할 뿐입니다. 이후 순서대로 그가 처리하는 세 부류의 이벤트 — 진행률, 부분 결과, 작업 취소 — 를 살펴보겠습니다.

4. 진행률 시각화: 퍼센트, 단계, 그리고 정직함

UX에서 진행률에는 두 가지가 있습니다. 결정형(determinate)과 비결정형(indeterminate). 전자는 실제로 얼마나 작업이 진행됐는지 아는 경우입니다. 예컨대 워크플로가 4단계이거나, 100개 파일 중 30개를 처리한 상황이죠. 후자는 솔직하게 “얼마나 더 기다릴지 모른다”고 인정하고, 가짜 “73%” 대신 “생각 중” 애니메이션을 보여주는 경우입니다.

GiftGenius에서는 다음과 같은 로직을 쓸 수 있습니다. 백엔드가 진행률을 실제로 계산한다면 — 예컨대 collect_sources, analyze_preferences, rank_candidates, enrich_descriptions 같은 단계가 있을 때 — job.progress 이벤트 payload에 stepCurrent, stepTotal, statusText, 그리고 (선택적으로) 합리적인 percent를 담아 보낼 수 있습니다.

TS에서의 이벤트 타입:

interface JobProgressPayload {
  stepCurrent: number;
  stepTotal: number;
  percent?: number;
  statusText: string;
}

interface JobEvent {
  type:
    | 'job.started'
    | 'job.progress'
    | 'job.partial_result'
    | 'job.completed'
    | 'job.failed'
    | 'job.canceled';
  jobId: string;
  payload?: any;
}

컴포넌트의 진행률 핸들러:

function handleJobProgress(payload: JobProgressPayload) {
  setState(prev => ({
    ...prev,
    status: prev.status === 'idle' ? 'in_progress' : prev.status,
    percent: payload.percent,
    stage: `${payload.stepCurrent} / ${payload.stepTotal}: ${payload.statusText}`,
  }));
}

JSX에서 진행률 바와 단계 텍스트를 함께 그릴 수 있습니다.

{(state.status === 'pending' || state.status === 'in_progress' || state.status === 'partial_ready') && (
  <div>
    {typeof state.percent === 'number'
      ? <progress value={state.percent} max={100} />
      : <div className="spinner" />}
    {state.stage && <p>{state.stage}</p>}
  </div>
)}

여기에는 심리적 뉘앙스가 있습니다. 정직한 퍼센트가 없다면 “3단계 중 2단계: 선호도 분석 중” 같은 단계 텍스트와 비결정형 진행률 바(움직이는 애니메이션)를 함께 보여주는 편이, 30초 동안 멈춰 있는 99%를 보여주는 것보다 낫습니다. 단계 + 비결정형 인디케이터의 조합은 남은 시간을 정확히 가늠하기 어려운 AI 작업에 특히 잘 맞습니다.

5. 부분 결과(Partial results): 다 완성될 때까지 기다릴 필요는 없다

스트리밍 UX에서 가장 유쾌한 부분은 부분 결과입니다. 5–7초만에 이미 관련성 높은 선물 몇 개가 있다면, 왜 사용자를 기다리게 할까요? 바로 보여주고 나머지는 나중에 채워 넣으면 됩니다.

GiftGenius에서는 백엔드가 작업 도중 job.partial_result 같은 전용 이벤트를 보내거나, 예컨대 새로운 추천 묶음과 함께 resource.updated를 보낼 수 있습니다. 각 이벤트는 선물 배열을 가져오며, 기존 목록에 추가됩니다.

payload의 형태(예시):

interface PartialResultPayload {
  gifts: GiftSuggestion[];
  isFinalChunk?: boolean;
}

핸들러:

function handlePartialResult(payload: PartialResultPayload) {
  setState(prev => ({
    ...prev,
    status: 'partial_ready',
    partialGifts: [...prev.partialGifts, ...payload.gifts],
  }));
}

JSX에서는 작업 완료 여부와 관계없이 카드를 렌더링합니다.

<section>
  {state.partialGifts.map(gift => (
    <GiftCard key={gift.id} gift={gift} />
  ))}
  {(state.status === 'in_progress' || state.status === 'partial_ready') && (
    <p>더 많은 옵션을 계속 찾고 있어요…</p>
  )}
</section>

여기에는 기억해야 할 중요한 UX 포인트가 몇 가지 있습니다.

첫째, 레이아웃 점프(layout shift)를 피하세요. 새 선물을 목록 맨 위에 추가하면 사용자가 읽던 위치를 잃을 수 있습니다. 끝에만 추가(append‑only)하고, 등장은 부드럽게 애니메이션 처리하는 편이 안전합니다.

둘째, 리파인먼트 전략(먼저 빠른 러프 리스트, 이후 “폴리싱”과 재정렬)을 사용한다면, 인터랙션을 조심스럽게 다뤄야 합니다. 결과가 “초안”일 때는 “구매”를 누를 수 없게 하거나, 목록을 명확히 “예비”라고 표시하세요. 그렇지 않으면 사용자가 선물을 고른 뒤 카드가 사라지거나 가격이 바뀌는 UX 재앙이 벌어질 수 있습니다.

셋째, partial_ready 상태는 completed와 시각적으로 구분되어야 합니다. 목록이 아직 채워지는 중임을 사용자에게 알려야 합니다. “선물 찾는 중” 같은 텍스트, 모서리의 작은 스피너, 새 카드에 중립적인 하이라이트 등을 사용할 수 있습니다.

6. 장기 작업 취소: UX와 기술

무거운 선물 찾기를 시작할 권한을 주었다면, 거의 항상 그것을 멈출 권한도 주어야 합니다. 취소는 LLM과 워커 자원 절약일 뿐 아니라, “내가 상황을 주도한다”는 제어감을 줍니다.

UX 측면에서 취소 버튼은 충분히 눈에 띄어야 하지만 화면 한가운데의 새빨간 경고판일 필요는 없습니다. “선물 찾기 취소” 같은 기본 버튼과 “언제든 다시 시작할 수 있어요” 같은 보조 텍스트의 조합이 잘 작동합니다. 무엇이 정확히 취소되는지 — 현재 분석이지 앱 전체가 아님 — 를 사용자에게 분명히 알려야 합니다.

기술적으로는 두 수준의 취소가 있습니다.

첫째, 프런트엔드 취소: 로컬 fetch를 중단하거나 SSE 연결을 닫을 수 있습니다. 이는 트래픽을 절약하지만, 그 자체로 백엔드의 워커를 멈추지는 않습니다.

둘째, 진짜 job 취소: MCP 도구 또는 POST /jobs/{jobId}/cancel HTTP 엔드포인트를 통해 작업을 canceled로 표시하고 워커가 정상 종료할 기회를 줍니다. 이때 서버는 job.canceled 이벤트를 보내며, 위젯은 이를 처리합니다.

위젯 관점에서의 예:

async function handleCancelClick() {
  if (!state.jobId) return;

  // 낙관적 UI 업데이트
  setState(prev => ({ ...prev, status: 'canceled' }));

  try {
    await cancelJobOnServer(state.jobId); // MCP tool 또는 HTTP
  } catch (e) {
    // 서버에서 취소가 실패했다면 상태를 롤백
    setState(prev => ({ ...prev, status: 'in_progress' }));
  }
}

그리고 버튼:

<button
  onClick={handleCancelClick}
  disabled={
    state.status !== 'pending' &&
    state.status !== 'in_progress' &&
    state.status !== 'partial_ready'
  }
>
  선물 찾기 취소
</button>

여기서는 낙관적 UI를 사용합니다. 서버의 확인을 기다리지 않고 곧바로 canceled로 전환하죠. 취소에 몇 초가 걸릴 수 있을 때 유용하며, 사용자는 즉시 자신의 동작이 수락되었음을 느낍니다. 하지만 워커가 끝까지 달려서 서버가 job.completed 또는 job.failed를 보낼 수도 있다는 점에 대비해야 합니다. 이벤트 처리기에서 이런 “지연된” 종료 이벤트를 필터링해 이미 canceled 상태를 덮어쓰지 않도록 하는 것이 좋습니다.

좀 더 보수적인 방식은 비관적 UI입니다. 먼저 “취소 중…” 상태를 보여주고 버튼을 비활성화한 뒤, job.canceled를 받은 후에야 canceled로 전환합니다. 구현은 더 단순하지만 시각적 반응성은 떨어집니다. 어떤 방식을 쓸지는 백엔드 SLA에 따라 선택할 수 있습니다.

7. 모두 모아보기: GiftGenius 미니 진행 패널

이제 조각들을 합쳐봅시다. 우리는 이미 다음을 작성했습니다.

  • 진행률 처리기 handleJobProgress,
  • 부분 결과 처리기 handlePartialResult,
  • 취소 처리기 handleCancelClick.

사실 이것이 바로 이전 섹션의 범용 handleEvent입니다. 그는 job.progress, job.partial_result, job.canceled 등의 이벤트에 반응하고, 하나의 컴포넌트 상태를 업데이트합니다. 이제 이를 작은 GiftJobPanel 컴포넌트로 감싸겠습니다. 이 컴포넌트는:

  • 선물 찾기를 시작하고;
  • jobId로 이벤트를 구독하며;
  • 진행률을 보여주고;
  • 부분 결과를 렌더링하며;
  • 작업을 취소할 수 있게 합니다.

Apps SDK / MCP 통합의 세부는 크게 단순화하고, 상태 로직에 집중하겠습니다.

export function GiftJobPanel() {
  const [state, setState] = useState<GiftWidgetState>({
    status: 'idle',
    partialGifts: [],
  });

  useEffect(() => {
    if (!state.jobId) return;
    const unsub = subscribeToJobEvents(state.jobId, event => {
      switch (event.type) {
        case 'job.started':
          setState(prev => ({ ...prev, status: 'in_progress' }));
          break;
        case 'job.progress':
          handleJobProgress(event.payload);
          break;
        case 'job.partial_result':
          handlePartialResult(event.payload);
          break;
        case 'job.completed':
          setState(prev => ({ ...prev, status: 'completed' }));
          break;
        case 'job.failed':
          setState(prev => ({
            ...prev,
            status: 'failed',
            error: event.payload?.message ?? '문제가 발생했어요',
          }));
          break;
        case 'job.canceled':
          setState(prev => ({ ...prev, status: 'canceled' }));
          break;
      }
    });
    return () => unsub();
  }, [state.jobId]);

MCP 도구 start_gift_search를 통해 작업을 시작할 수 있습니다.

async function handleStartClick() {
  setState({
    status: 'pending',
    partialGifts: [],
  });

  const jobId = await startGiftSearchOnServer(/* 사용자 파라미터 */);
  setState(prev => ({ ...prev, jobId }));
}

이후 JSX:

return (
  <div>
    {state.status === 'idle' && (
      <button onClick={handleStartClick}>선물 추천 받기</button>
    )}

    {['pending', 'in_progress', 'partial_ready'].includes(state.status) && (
      <ProgressSection state={state} onCancel={handleCancelClick} />
    )}

    <GiftsList gifts={state.partialGifts} status={state.status} />

    {state.status === 'failed' && (
      <ErrorSection error={state.error} onRetry={handleStartClick} />
    )}

    {state.status === 'canceled' && (
      <p>선물 찾기를 중단했습니다. 다른 파라미터로 다시 시작할 수 있어요.</p>
    )}
  </div>
);

ProgressSection, GiftsList, ErrorSection 같은 하위 컴포넌트로 나누면 메인 컴포넌트가 “스파게티”가 되는 것을 막을 수 있습니다. 하지만 핵심 아이디어는 하나입니다. 위젯 전체가 MCP 이벤트와 스트리밍 채널에 직접 대응하는 하나의 명확한 상태 모델로 구동된다는 점입니다.

8. ChatGPT 대화와의 연동에 관하여

이 강의는 위젯 자체에 초점을 두지만, 사용자는 여전히 모델과의 대화 속에 있다는 점을 잊지 마세요. 좋은 시나리오는 이렇습니다. GPT가 사용자에게 GiftGenius를 실행한다고 알리고, 그 뒤 위젯이 진행률을 보여주며, GPT는 “지금 확장된 선물 찾기를 시작했어요. 목록이 점차 채워질 거예요.” 같은 텍스트로 이를 보완합니다.

선물 찾기가 끝난 뒤에는 ChatGPT가 ToolOutput에서 결과를 받아 사람 친화적인 요약을 작성할 수 있습니다. “10가지 옵션을 찾았어요. 아래에 요약을 드리고, 전체 목록은 위젯에서 볼 수 있어요.” 텍스트 스트리밍과 스트리밍 UI의 이 듀엣이 일관된 경험을 만듭니다.

이 연동은 워크플로와 커머스 모듈에서 더욱 중요해집니다. 장바구니 분석, 재고 확인, 결제 대기 같은 각 장기 단계가 텍스트와 인터페이스 양쪽에서 모두 이해 가능해야 하기 때문입니다.

9. 스트리밍 UX에서 자주 하는 실수

실수 1: “텍스트 없는 영원한 스피너”.
가장 흔한 안티 패턴은 그냥 스피너만 돌리고 무슨 일이 일어나는지 설명하지 않는 것입니다. 사용자는 시스템이 유용한 일을 하는지, 멈춘 건지 알 수 없습니다. 간단한 단계 텍스트(“인기 선물 수집 중…”, “후기 분석 중”)로 해결할 수 있고, 더 좋게는 이미 위젯 상태에 담아둔 pending, in_progress, partial_ready 같은 명시적 상태를 보여주세요.

실수 2: 가짜 진행률 퍼센트.
신뢰를 “올리겠다”며 근거 없는 퍼센트(“73%”)를 그리는 시도는 보통 역효과를 냅니다. 사용자는 99%가 20초 동안 멈춰 있다는 걸 금방 눈치채고 인디케이터를 믿지 않게 됩니다. 정직한 지표가 없다면, 속이지 말고 단계 + 비결정형 진행률 바를 사용하세요.

실수 3: 모든 것을 망치는 부분 결과.
가끔 부분 결과를 매 이벤트마다 전체 리스트를 갈아엎는 식으로 구현합니다. 리스트가 사라졌다 나타나고, 매번 재정렬되죠. 결국 사용자가 카드를 클릭하면 카드가 갑자기 아래로 도망갑니다. 커머스 시나리오에서는 특히 치명적입니다. 카드는 조심스럽게 추가하고(대개 끝에만), 키를 유지하며, 레이아웃 점프를 최소화하세요.

실수 4: 아무 것도 취소하지 않는 취소.
위젯에 “취소” 버튼이 있지만 실제로는 UI만 숨기고 서버의 실제 job은 계속 도는 경우가 있습니다. 그 결과 자원이 계속 소모되고, 뒤늦은 job.completed가 들어오는데, 사용자는 이미 다 멈춘 줄 압니다. 진짜 취소는 프런트엔드(버튼 비활성화, 스트림 중단)와 백엔드(워커에 취소 신호 전달, job.canceled 이벤트 수신) 모두를 포괄해야 합니다.

실수 5: 결말을 무시하고 “멍청한” 오류 화면.
가끔 job.completed 이후 위젯이 아무 “다음 단계” 없이 선물 목록만 보여주거나, job.failed 시 기술적 메시지 “오류 500”만 띄우기도 합니다. 두 경우 모두 UX가 뚝 끊깁니다. 마지막에는 간단한 요약과 명확한 CTA(“선물 찾기 저장”, “구매로 이동”)를 주고, 오류일 때는 상태 코드만 던지지 말고 사람 말로 설명과 “다시 시도”, “파라미터 변경” 같은 버튼을 제공하는 편이 더 낫습니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION