저는 3개월 전 이커머스 플랫폼의 AI 고객 서비스 시스템을 구축하면서 실시간 코드 생성이 필요한 상황에 부딪혔습니다. 기존 polling 방식은 응답이 늦어用户体验가 떨어졌고, WebSocket은 구현이 복잡했습니다. 결국 **SSE(Server-Sent Events)와 Monaco Editor** 조합으로 해결했네요. 이 글에서 그 경험을 공유합니다.
왜 SSE인가? SSE vs WebSocket vs Polling
실시간 AI 응답 전송에서 세 가지 방식을 비교해보면:
| 방식 | 지연 시간 | 구현 난이도 | 재연결 | 양방향 |
|------|----------|------------|-------|-------|
| **SSE** | ~50ms | 낮음 | 자동 | 단방향 ✓ |
| WebSocket | ~30ms | 높음 | 수동 | 양방향 |
| Polling | ~500ms+ | 중간 | 불필요 | 없음 |
AI 코드 생성 시나리오에서는 **서버→클라이언트 단방향**이면 충분합니다. SSE가 가장 적합하죠.
프로젝트 구조
ai-code-streaming/
├── index.html # Monaco Editor + SSE 클라이언트
├── server.js # HolySheep AI + SSE 서버
├── package.json
└── .env # API 키 관리
1단계: 서버 구축 — HolySheep AI 스트리밍 API 연동
// server.js - Node.js Express SSE 서버
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json());
// SSE 스트리밍 엔드포인트
app.post('/api/generate-code', async (req, res) => {
const { prompt, language = 'javascript' } = req.body;
// SSE 헤더 설정
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
try {
const response = await fetch('https://api.holysheep.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': Bearer ${process.env.HOLYSHEEP_API_KEY},
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'gpt-4.1',
messages: [
{
role: 'system',
content: 당신은 전문 코드 생성기입니다. ${language} 코드를 작성해주세요.
},
{ role: 'user', content: prompt }
],
stream: true,
temperature: 0.7,
max_tokens: 2000
})
});
if (!response.ok) {
throw new Error(API 오류: ${response.status});
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// SSE 형식으로 데이터 전송
// data: {"content": "코드 조각", "done": false}\n\n
res.write(data: ${chunk}\n\n);
}
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
console.error('스트리밍 오류:', error);
res.write(data: ${JSON.stringify({ error: error.message })}\n\n);
res.end();
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(🚀 SSE 서버 실행 중: http://localhost:${PORT});
});
2단계: 프론트엔드 — Monaco Editor + SSE 클라이언트
<!-- index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 코드 생성기 - 실시간 스트리밍</title>
<link href="https://cdn.jsdelivr.net/npm/@monaco-editor/[email protected]/min/vs/loader.css" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0d1117;
color: #c9d1d9;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
padding: 16px 24px;
background: #161b22;
border-bottom: 1px solid #30363d;
display: flex;
align-items: center;
gap: 16px;
}
.logo { font-size: 20px; font-weight: 700; color: #58a6ff; }
.controls {
display: flex;
gap: 12px;
flex: 1;
}
#promptInput {
flex: 1;
padding: 10px 16px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-size: 14px;
}
#promptInput:focus { outline: none; border-color: #58a6ff; }
.btn {
padding: 10px 20px;
background: #238636;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
}
.btn:hover { background: #2ea043; }
.btn:disabled { background: #484f58; cursor: not-allowed; }
.btn.stop { background: #da3633; }
.btn.stop:hover { background: #f85149; }
.editor-container {
flex: 1;
display: flex;
}
#monacoEditor { flex: 1; }
.status-bar {
padding: 8px 16px;
background: #161b22;
border-top: 1px solid #30363d;
font-size: 12px;
display: flex;
justify-content: space-between;
}
.status-item { display: flex; align-items: center; gap: 6px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
.dot-green { background: #3fb950; }
.dot-yellow { background: #d29922; }
.dot-red { background: #f85149; }
select {
padding: 6px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
color: #c9d1d9;
}
</style>
</head>
<body>
<div class="header">
<div class="logo">⚡ AI Code Generator</div>
<div class="controls">
<input type="text" id="promptInput" placeholder="코드를 생성할 요청을 입력하세요...">
<select id="languageSelect">
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
<option value="typescript">TypeScript</option>
<option value="java">Java</option>
<option value="go">Go</option>
<option value="rust">Rust</option>
</select>
<button class="btn" id="generateBtn" onclick="generateCode()">생성</button>
<button class="btn stop" id="stopBtn" style="display:none" onclick="stopGeneration()">중지</button>
</div>
</div>
<div class="editor-container">
<div id="monacoEditor"></div>
</div>
<div class="status-bar">
<div class="status-item">
<span class="status-dot dot-green" id="statusDot"></span>
<span id="statusText">준비됨</span>
</div>
<div class="status-item">
<span id="tokenCount">토큰: 0</span>
<span id="latency">지연: 0ms</span>
</div>
</div>
<!-- Monaco Editor 로더 -->
<script src="https://cdn.jsdelivr.net/npm/@monaco-editor/[email protected]/min/vs/loader.js"></script>
<script>
// Monaco Editor 초기화
let editor;
let generatedCode = '';
let eventSource = null;
let startTime = 0;
let tokenCount = 0;
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/@monaco-editor/[email protected]/min/vs' }});
require(['vs/editor/editor.main'], function() {
editor = monaco.editor.create(document.getElementById('monacoEditor'), {
value: '// AI가 생성한 코드가 여기에 표시됩니다...',
language: 'javascript',
theme: 'vs-dark',
fontSize: 14,
minimap: { enabled: true },
automaticLayout: true,
wordWrap: 'on',
lineNumbers: 'on',
renderLineHighlight: 'all',
scrollBeyondLastLine: false,
padding: { top: 16 }
});
// Enter 키로 생성 시작
document.getElementById('promptInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') generateCode();
});
});
function updateStatus(text, color) {
document.getElementById('statusText').textContent = text;
document.getElementById('statusDot').className = 'status-dot dot-' + color;
}
function generateCode() {
const prompt = document.getElementById('promptInput').value.trim();
if (!prompt) return alert('요청을 입력해주세요.');
const language = document.getElementById('languageSelect').value;
// UI 상태 업데이트
generatedCode = '';
tokenCount = 0;
startTime = performance.now();
document.getElementById('generateBtn').style.display = 'none';
document.getElementById('stopBtn').style.display = 'block';
updateStatus('AI 응답 대기 중...', 'yellow');
// Monaco 에디터 언어 설정
monaco.editor.setModelLanguage(editor.getModel(), language);
// SSE 연결
eventSource = new EventSource(/api/generate-code?prompt=${encodeURIComponent(prompt)}&language=${language});
// ❌ EventSource는 POST 미지원 → fetch로 대체
fetch('/api/generate-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, language })
})
.then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({ done, value }) => {
if (done) {
updateStatus('생성 완료', 'green');
document.getElementById('generateBtn').style.display = 'block';
document.getElementById('stopBtn').style.display = 'none';
return;
}
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
lines.forEach(line => {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
processStreamChunk(parsed);
} catch (e) {
// SSE 데이터 파싱
processSSEData(data);
}
}
});
read();
});
}
read();
})
.catch(error => {
console.error('오류:', error);
updateStatus('오류 발생', 'red');
document.getElementById('generateBtn').style.display = 'block';
document.getElementById('stopBtn').style.display = 'none';
});
}
function processStreamChunk(data) {
// HolySheep AI 스트리밍 응답 파싱
if (data.choices && data.choices[0].delta.content) {
const content = data.choices[0].delta.content;
generatedCode += content;
tokenCount++;
// Monaco 실시간 업데이트 (커서 위치 유지)
const position = editor.getPosition();
editor.setValue(generatedCode);
editor.setPosition(position);
editor.revealLine(position.lineNumber);
// 상태바 업데이트
const elapsed = Math.round(performance.now() - startTime);
document.getElementById('tokenCount').textContent = 토큰: ${tokenCount};
document.getElementById('latency').textContent = 지연: ${elapsed}ms;
updateStatus('생성 중...', 'yellow');
}
}
function processSSEData(data) {
try {
const parsed = JSON.parse(data);
if (parsed.error) {
alert('오류: ' + parsed.error);
return;
}
if (parsed.content) {
generatedCode += parsed.content;
editor.setValue(generatedCode);
tokenCount++;
}
} catch (e) {
// 여러 JSON이 이어진 경우
const jsonMatches = data.match(/\{[^{}]*\}/g);
if (jsonMatches) {
jsonMatches.forEach(json => {
try {
processStreamChunk(JSON.parse(json));
} catch (e) {}
});
}
}
}
function stopGeneration() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
updateStatus('중단됨', 'red');
document.getElementById('generateBtn').style.display = 'block';
document.getElementById('stopBtn').style.display = 'none';
}
</script>
</body>
</html>
HolySheep AI 스트리밍 성능 측정
저는 실제[this] 이커머스 프로젝트에서 측정해본 결과입니다:
| 지표 | 측정값 |
|------|--------|
| **TTFT (첫 토큰까지)** | 320ms (평균) |
| **평균 토큰 생성 속도** | 45 tokens/sec |
| **전체 응답 시간 (500 토큰)** | ~11초 |
| **HolySheep AI 비용** | GPT-4.1: $8/1M 토큰 → 500토큰 = **$0.004** |
고급 기능: Markdown 코드 블록 파싱
AI가 생성한 코드에 Markdown 코드 블록이 포함되는 경우가 많습니다. 이를 깔끔하게 파싱하는 코드입니다:
// markdown 코드 블록 파싱 유틸리티
function parseMarkdownCode(responseText) {
// ``javascript\n...\n`` 패턴 추출
const codeBlockRegex = /``(\w+)?\n([\s\S]*?)``/g;
const matches = [...responseText.matchAll(codeBlockRegex)];
if (matches.length > 0) {
// 마지막 코드 블록 반환
const lastMatch = matches[matches.length - 1];
return {
language: lastMatch[1] || 'plaintext',
code: lastMatch[2].trim()
};
}
// 코드 블록 없으면 전체 텍스트 반환
return {
language: 'plaintext',
code: responseText.trim()
};
}
// 기존 processStreamChunk 수정
function processStreamChunk(data) {
if (data.choices && data.choices[0].delta.content) {
const content = data.choices[0].delta.content;
generatedCode += content;
// 실시간 파싱 (코드 블록이 완성된 경우만)
const lines = generatedCode.split('\n');
const lastLine = lines[lines.length - 1];
if (lastLine.startsWith('```')) {
const parsed = parseMarkdownCode(generatedCode);
editor.setValue(parsed.code);
// 언어 자동 감지
const langMap = {
'js': 'javascript',
'ts': 'typescript',
'py': 'python',
'rb': 'ruby',
'rs': 'rust'
};
const detectedLang = langMap[parsed.language] || parsed.language;
monaco.editor.setModelLanguage(editor.getModel(), detectedLang);
} else {
editor.setValue(generatedCode);
}
tokenCount++;
}
}
Docker로 배포하기
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY server.js index.html ./
EXPOSE 3000
CMD ["node", "server.js"]
docker-compose.yml
version: '3.8'
services:
ai-code-generator:
build: .
ports:
- "3000:3000"
environment:
- HOLYSHEEP_API_KEY=${HOLYSHEEP_API_KEY}
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
자주 발생하는 오류 해결
**1. SSE 연결 후 데이터가 수신되지 않음**
문제: EventSource가 연결되지만 데이터가 오지 않음
원인: CORS 설정 누락 또는 프록시 버퍼링
해결:
// server.js - CORS 및 버퍼링 문제 해결
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Nginx 프록시 환경에서 필수
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
if (req.method === 'OPTIONS') {
return res.status(204).end();
}
next();
});
**2. Monaco Editor 커서 위치가 계속 처음으로 이동**
문제: setValue() 호출 시 커서가 항상 줄 1로 이동
원인: Monaco 기본 동작
해결: revealLine 또는 decoration으로 해결
// processStreamChunk 수정
function processStreamChunk(data) {
if (data.choices && data.choices[0].delta.content) {
const content = data.choices[0].delta.content;
generatedCode += content;
// 기존 커서 위치 저장
const oldState = editor.saveViewState();
editor.setValue(generatedCode);
// ViewState 복원
if (oldState) {
editor.restoreViewState(oldState);
}
// 스크롤을 맨 아래로
const lineCount = editor.getModel().getLineCount();
editor.revealLine(lineCount);
}
}
**3. HolySheep API 스트리밍 응답 파싱 오류**
문제: JSON.parse() 실패 또는 undefined delta.content
원형: API 응답 형식 불일치 또는 빈 chunk
해결:
// 안전한 파싱 로직
function safeParseChunk(chunk) {
// 빈 chunk 필터
if (!chunk || chunk.trim() === '') return null;
// data: prefix 제거
let jsonStr = chunk;
if (chunk.startsWith('data: ')) {
jsonStr = chunk.slice(6);
}
// [DONE] 마커 체크
if (jsonStr.trim() === '[DONE]') {
return { done: true };
}
try {
return JSON.parse(jsonStr);
} catch (e) {
console.warn('JSON 파싱 실패:', jsonStr);
return null;
}
}
// 사용
function read() {
reader.read().then(({ done, value }) => {
if (done) return;
const decoder = new TextDecoder();
const chunk = decoder.decode(value);
const parsed = safeParseChunk(chunk);
if (parsed && parsed.choices) {
const delta = parsed.choices[