MSW로 실시간 SSE 스트리밍 API Mocking 하기

김민주·2025년 11월 26일

MSW (Mock Service Worker) 란?

Mock Service Worker는 API Mocking을 위한 라이브러리
실제 네트워크 요청을 가로채서(Intercept) Service Worker 수준에서 응답을 제공함

= 백엔드 개발이 완료되지 않아도 프론트엔드 개발자가 독립적으로 API 응답을 Mocking하여 개발을 진행할 수 있음


구성

Service Worker

  • 브라우저가 웹페이지와는 별도의 스레드에서 실행하는 스크립트
  • 네트워크 요청을 가로채고(Intercept) 프로그래밍 방식으로 응답을 캐싱하거나 수정함

Handler

  • MSW에서 특정 요청에 대한 Mock 응답을 정의하는 함수
  • 사용자는 HTTP 메서드 (GET, POST 등)와 경로(Path)를 지정하고, 해당 요청이 발생했을 때 반환할 Mock 데이터를 정의함

Setup Worker / Setup Server

  • setupWorker: 브라우저 환경에서 Service Worker를 등록하고 Mocking을 활성화함
  • setupServer: setupServer는 Node.js 기반 테스트 환경에서 사용되며, 따로 설치하는 것이 아니라 MSW(Mock Service Worker) 라이브러리 안에 포함됨.

설치

  1. 패키지 설치
    npm install msw --save-dev

  2. MSW를 브라우저에서 사용하려면 Service Worker 파일을 프로젝트에 설치
    npx msw init public/ --save


ReadableStream을 사용하여 API mocking

SSE표준이 아닌 스트리밍 응답 API를 테스트하고 싶은데
실제 응답 API는 bedrock 모델을 쓰기 때문에 토큰비용이 발생하여 모킹이 필요하였음.
개발(브라우저) 환경에서만 사용하여 setupWorker가 Service Worker를 등록해서 사용함.

1. msw 핸들러작성(src/mocks/handlers.ts)

HttpResponseReadableStream을 담아 보내 스트림처럼 보이도록 지연효과를 줌

import { http, HttpResponse, delay } from 'msw';

// 모의 응답 텍스트 (AI가 생성해줄 법한 긴 텍스트)
const MOCK_RESPONSE_TEXT = `
모의 응답 텍스트 ...

네, 요청하신 코드에 대한 분석 결과를 스트리밍으로 전달합니다.

~~~

이 텍스트는 MSW를 통해 청크 단위로 전송되고 있습니다. 실제 AI 토큰 생성처럼 보이도록 지연 시간을 두었습니다.
`;

export const handlers = [
  // POST 요청, :code 경로 매개변수 포함
  http.post(url, async ({ params }) => {
    const { code } = params;
    
    // 스트림 생성
    const stream = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();
        
        // 실제 AI처럼 보이기 위해 텍스트를 조금씩 쪼개서 전송
        const fullText = MOCK_RESPONSE_TEXT.replace(':code', code as string);
        const chunkSize = 10; // 한 번에 보낼 글자 수

        for (let i = 0; i < fullText.length; i += chunkSize) {
          const chunk = fullText.slice(i, i + chunkSize);
          const dataChunk = JSON.stringify({
	          content: chunk,
	          inputToken: 0,
	          outputToken: 0,
	          totalToken: 0,
          })
          controller.enqueue(encoder.encode(dataChunk + '\n'));
          
          // AI가 타이핑하는 듯한 지연 효과 (30ms ~ 100ms 랜덤)
          await delay(Math.random() * 70 + 30);
        }
				const finalChunk = JSON.stringigy({
					content: '',
					inputToken: 33333,
	        outputToken: 333,
	        totalToken: 33666,
				})
        // 스트림 종료
        controller.enqueue(encoder.encode(finalChunk + '\n'));
        controller.close();
      },
    });

    // 스트림을 담아 응답 반환
    return new HttpResponse(stream, {
      headers: {
        'Content-Type': 'text/ndjson; charset=utf-8',
      },
    });
  }),
];

2. 워커 설정 (src/mocks/browser.ts)

import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

3. 앱 진입점 설정 (index.tsx)

개발 환경(development)일 때만 MSW가 동작


async function enableMocking() {
  if (import.meta.env.VITE_RUN_MODE !== 'development') {
    return;
  }
  const { worker } = await import('./src/mocks/browser');
  // onUnhandledRequest: 'bypass'는 모킹되지 않은 요청 경고를 무시합니다.
  return worker.start({ onUnhandledRequest: 'bypass' });
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
  );
});

4. 보여주는 컴포넌트에서 수동파싱으로 모의텍스트 보여주기

// 1. 응답 데이터 타입 정의
interface StreamResponse {
  content?: string;
  inputToken?: number;
  outputToken?: number;
  totalToken?: number;
}

interface AiStreamProps {
  codeId: string;
}

const AiStreamViewer = ({ codeId }: AiStreamProps) => {
  const [content, setContent] = useState<string>('');
  const [metrics, setMetrics] = useState({ inputToken: 0, outputToken: 0, totalToken: 0 });
  const [isLoading, setIsLoading] = useState(false);
  
  const handleStartStream = async () => {
    // 초기화
    setContent('');
    setMetrics({ inputToken: 0, outputToken: 0, totalToken: 0 });
    setIsLoading(true);

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream', 
        },
      });

      if (!response.body) return;

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        // 1. 청크 디코딩 및 버퍼링
        const chunk = decoder.decode(value, { stream: true });
        buffer += chunk;

        // 2. 줄바꿈 기준으로 분리 (SSE는 라인 단위 프로토콜)
        const lines = buffer.split('\n');
        
        // 마지막 라인은 불완전할 수 있으므로 버퍼에 남김
        buffer = lines.pop() || '';

        for (const line of lines) {
          if(!line.trim()) continue;

          try {
            const json: StreamResponse = JSON.parse(line);

            if (json.content) {
              setContent((prev) => prev + json.content);
            }

            // B. 토큰 정보는 덮어쓰기 (Replace)
            if (json.totalToken && data.totalToken > 0) {
              setMetrics({
                inputToken: json.inputToken ,
                outputToken: json.outputToken ,
                totalToken: json.totalToken ,
              });
            }
          } catch (e) {
            console.error('JSON 파싱 실패 (청크 오염 가능성):', e);
          }
        }
      }
  };

  return (
    <div className="p-4 border rounded bg-white shadow-sm">
      <button 
        onClick={handleStartStream}
        disabled={isLoading}
        className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 disabled:bg-gray-400"
      >
        {isLoading ? 'Generating...' : 'Start Stream'}
      </button>

      <div className="mt-4 whitespace-pre-wrap p-4 bg-gray-50 rounded min-h-[100px] border">
        {content}
      </div>

      <div className="mt-2 text-sm text-gray-500 flex gap-4">
        <span>Total Tokens: {metrics.totalToken}</span>
        <span>(In: {metrics.inputToken} / Out: {metrics.outputToken})</span>
      </div>
    </div>
  );
};
profile
𝐃𝐨𝐧'𝐭 𝐛𝐞 𝐚 𝐩𝐫𝐨𝐜𝐫𝐚𝐬𝐭𝐢𝐧𝐚𝐭𝐨𝐫💫

0개의 댓글