Mock Service Worker는 API Mocking을 위한 라이브러리
실제 네트워크 요청을 가로채서(Intercept) Service Worker 수준에서 응답을 제공함
= 백엔드 개발이 완료되지 않아도 프론트엔드 개발자가 독립적으로 API 응답을 Mocking하여 개발을 진행할 수 있음
setupWorker: 브라우저 환경에서 Service Worker를 등록하고 Mocking을 활성화함setupServer: setupServer는 Node.js 기반 테스트 환경에서 사용되며, 따로 설치하는 것이 아니라 MSW(Mock Service Worker) 라이브러리 안에 포함됨. 패키지 설치
npm install msw --save-dev
MSW를 브라우저에서 사용하려면 Service Worker 파일을 프로젝트에 설치
npx msw init public/ --save
SSE표준이 아닌 스트리밍 응답 API를 테스트하고 싶은데
실제 응답 API는 bedrock 모델을 쓰기 때문에 토큰비용이 발생하여 모킹이 필요하였음.
개발(브라우저) 환경에서만 사용하여 setupWorker가 Service Worker를 등록해서 사용함.
src/mocks/handlers.ts)HttpResponse에 ReadableStream을 담아 보내 스트림처럼 보이도록 지연효과를 줌
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',
},
});
}),
];
src/mocks/browser.ts)import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
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>,
);
});
// 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>
);
};