저는 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[