
"하이브리드 스트리밍 구조"를 이해할 수 있도록 설명합니다.
💡 CLOVA Studio 개요 및 Chat Completions API 포맷은 네이버 클라우드 공식 문서를 참고하였습니다. (NCloud Docs)
이 글에서 설명하는 전체 구조보다 조금 더 단순화된 예제 코드(상수 분리 X, 소켓 연동 X, 에러/로그 최소화 버전)는 아래 Gist에 정리해 두었으니 이해가 어렵다면 참고하기 좋습니다.
전체 구조를 텍스트로 그리면 대략 아래와 같이 구성됩니다.
클라이언트 (코드 에디터)
├─ HTTP POST /editor/clova (content, windowId, 선택: systemPromptType, history)
↓
EditorController.chatCompletions()
↓
EditorService.callClovaStudio()
├─ System Prompt 선택 (code-assistant / refactor / review)
├─ 기본 파라미터 적용 (topP, temperature 등)
└─ messages 배열 구성 (system + history + user)
↓
CLOVA Studio Chat Completions API 호출
├─ (1) 단일 JSON 응답 모드
└─ (2) SSE 스트리밍 모드 (text/event-stream)
↓
Response.body.getReader() 로 스트림 읽기
buffer 로 라인 재조합 후 data: {...} 만 파싱
choices[0].delta.content / message.content 등에서 텍스트 추출
↓
(선택) WebSocketGateway를 통해 windowId 룸으로 실시간 브로드캐스트
→ content(프롬프트), windowId(에디터/탭 ID), 선택적인 systemPromptType, conversationHistory 정도만 전달
→ 시스템 프롬프트, 온도, 토큰 개수 등은 전부 서버에서 관리
→ CLOVA Studio에서 오는 SSE 스트림을 안전하게 파싱하고
→ 필요한 경우 WebSocket으로 여러 클라이언트에게 보냄

저는 프롬프트 예제의 <바이브 코더>를 활용하여 프롬프트를 작성하였습니다.

우상단의 코드 보기를 눌러서 기본적인 header나 data 등을 확인할 수 있습니다.
네이버 클라우드에서 CLOVA Studio 콘솔에 들어가 Chat Completions 용 프로젝트를 만들고 API 키를 발급합니다.
📖 참고: NCloud Docs
.env 설정# .env
CLOVA_STUDIO_API_KEY=your-clova-studio-api-key
참고로 이번 프로젝트에서는 NestJS의 @nestjs/config 의 ConfigService 를 사용하여 적용하였습니다.
📖 참고: NestJS 공식 문서
먼저, CLOVA Studio 호출에 필요한 역할(Role) 프롬프트와 기본 파라미터를 한 곳에 모아둡니다.
// constant.ts
export type SystemPromptType =
(typeof SYSTEM_PROMPT_TYPES)[keyof typeof SYSTEM_PROMPT_TYPES];
/**
* CLOVA Studio 시스템 프롬프트 타입 정의
*/
export const SYSTEM_PROMPT_TYPES = {
CODE_ASSISTANT: 'code-assistant', // 코드 생성 및 해설
CODE_REFACTOR: 'code-refactor', // 코드 개선 및 최적화
CODE_REVIEW: 'code-review', // 코드 리뷰 및 버그/품질 진단
} as const;
/**
* CLOVA Studio 시스템 프롬프트 맵
* - 필요에 따라 템플릿 내용을 자유롭게 수정/보강 가능합니다.
*/
export const SYSTEM_PROMPTS: Record<SystemPromptType, string> = {
[SYSTEM_PROMPT_TYPES.CODE_ASSISTANT]: `
숙련된 개발자 파트너로서, 사용자의 요청에 따라 코드 작성·수정 및 간결한 해설을 제공합니다.
- 가급적 실제로 컴파일/실행 가능한 코드를 작성합니다.
- 너무 장황하지 않게, 핵심 위주로 설명합니다.
`,
[SYSTEM_PROMPT_TYPES.CODE_REFACTOR]: `
코드 리팩토링 전문가로서, 기존 코드를 개선하고 최적화하는 데 중점을 둡니다.
- 가독성, 유지보수성, 성능을 함께 고려합니다.
- 변경 이유를 짧게 주석이나 텍스트로 설명합니다.
`,
[SYSTEM_PROMPT_TYPES.CODE_REVIEW]: `
코드 리뷰 전문가로서, 코드의 품질, 버그 가능성, 보안 이슈를 검토합니다.
- 잠재적인 버그나 취약점을 구체적으로 지적합니다.
- 개선 방안을 함께 제안합니다.
`,
};
/**
* CLOVA Studio API 기본값 파라미터
* - 필요 시 프로젝트 상황에 따라 조정
*/
export const DEFAULT_CLOVA_PARAMS = {
topP: 0.8,
topK: 0,
maxTokens: 500,
temperature: 0.4,
repeatPenalty: 1.1,
stopBefore: [] as string[],
seed: 0,
includeAiFilters: true,
} as const;
이 과정은 Gist 에선 생략하기 때문에,
프롬프트를 한 개만 사용하거나 파라미터를 통일하여 사용하고 싶다면 해당 링크를 참고해주세요.
클라이언트 ↔ 서버 사이에 어떤 데이터가 오가는지를 먼저 명확히 정의합니다.
// dto.ts
export class ChatMessageDto {
role: 'system' | 'user' | 'assistant';
content: string;
}
export class ChatCompletionsRequestDto {
content: string; // 사용자 입력
systemPromptType?: SystemPromptType;// AI 역할 지정(옵션, 기본은 code-assistant)
windowId: string; // WebSocket 룸/에디터 식별용
conversationHistory?: ChatMessageDto[]; // 이전 대화 맥락
}
💡
conversationHistory에assistant역할 메시지를 포함하면,
그대로 CLOVA Studio에 같이 전달되어 대화 맥락을 유지할 수 있습니다. (NCloud Docs)
마찬가지로, 이전 대화 맥락을 참고하지 않고 싶거나 소켓 연동을 하지 않고,
프롬프트를 하나만 주고 싶다면 몇 가지 필드는 생략이 가능합니다.
// service.ts
@Injectable()
export class EditorService {
private readonly clovaStudioApiKey: string;
private readonly clovaStudioUrl =
'https://clovastudio.stream.ntruss.com/v1/chat-completions/HCX-003';
constructor(private readonly configService: ConfigService) {
// 없으면 에러 발생
this.clovaStudioApiKey =
this.configService.get<string>('CLOVA_STUDIO_API_KEY') ?? '';
}
/**
* API 키 유효성 검사
*/
private validateApiKey(): void {
if (!this.clovaStudioApiKey) {
throw new BadRequestException(
'CLOVA_STUDIO_API_KEY가 설정되지 않았습니다. 환경 변수를 확인해주세요.',
);
}
}
/**
* CLOVA Studio API 요청 메시지 구성
* - System Prompt + History + User 메시지 순서로 messages 배열 생성
*/
private buildApiRequest(request: ChatCompletionsRequestDto): any {
const promptType: SystemPromptType =
request.systemPromptType || SYSTEM_PROMPT_TYPES.CODE_ASSISTANT;
const systemPrompt = SYSTEM_PROMPTS[promptType];
const messages: Array<{ role: string; content: string }> = [];
// 1. System Prompt
messages.push({ role: 'system', content: systemPrompt });
// 2. 기존 대화 기록
if (request.conversationHistory?.length) {
messages.push(...request.conversationHistory);
}
// 3. 이번 사용자 메시지
messages.push({ role: 'user', content: request.content });
return {
messages,
...DEFAULT_CLOVA_PARAMS,
};
}
// 이후 단계에서 callClovaStudio, fetchClova, parseSseStream 등을 추가합니다.
}
buildApiRequest가 여러 메서드가 섞여 복잡하다면 gist를 참고합니다.여기까지가 공통 기반입니다.
이 상태에서 먼저 단일 JSON 응답으로 붙여보고, 이후에 SSE 스트림으로 바꾸는 흐름으로 가겠습니다.
SSE까지 한 번에 가도 되지만, 보통은 "일단 API가 제대로 붙었는지" 확인하기 위해 한 번에 JSON을 받는 모드로 먼저 구현해보면 디버깅이 훨씬 편합니다.
// service.ts
async callClovaStudioOnce(
request: ChatCompletionsRequestDto,
): Promise<string> {
this.validateApiKey();
const apiRequest = this.buildApiRequest(request);
const response = await fetch(this.clovaStudioUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.clovaStudioApiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json', // 아직은 JSON 모드
},
body: JSON.stringify(apiRequest),
});
if (!response.ok) {
const errText = await response.text();
throw new BadRequestException(
`CLOVA Studio 호출 실패: ${response.status} ${errText}`,
);
}
const json = await response.json();
// CLOVA Studio 실제 응답 형식: result.message.content
// 다양한 응답 형식도 지원
const content =
json?.result?.message?.content ?? // CLOVA Studio 실제 형식 (우선)
json?.choices?.[0]?.message?.content ?? // OpenAI 호환 형식
json?.message?.content ?? // 직접 message 형식
json?.choices?.[0]?.delta?.content ?? // 스트리밍 형식
json?.data?.content ?? // data.content 형식
json?.result?.content ?? // result.content 형식
json?.content ?? // 직접 content 필드
''
return content
}
이 단계에서 Postman / curl 등으로 POST /editor/clova-once 같은 엔드포인트를 만들어 테스트하면, 아래 내용이 제대로 들어가는지 쉽게 검증할 수 있습니다.

이제 SSE 모드(text/event-stream) 로 스위칭합니다.
CLOVA Studio Chat Completions API는 SSE(Server-Sent Events) 형식으로 스트리밍 응답을 제공합니다.
📖 참고: NCloud Docs
SSE 기반 스트리밍을 구현하기 전에, “CLOVA Studio가 실제로 어떤 형식으로 응답을 보내는가?” 를 먼저 이해해야 합니다.
스트리밍 중에 LLM이 보내는 텍스트 조각은 아래 세 필드 중 하나에 등장합니다.
이는 OpenAI ChatCompletion 포맷을 따르면서도 CLOVA Studio가 자체 이벤트 구조를 추가했기 때문입니다.
| 추출 위치 | 코드에서의 경로 | 특징 및 사용 맥락 |
|---|---|---|
| ① delta.content (스트리밍 조각) | choices?.[0]?.delta?.content | 가장 일반적인 스트림 청크입니다. 단어·문장 단위의 작은 조각이 data: 줄로 계속 들어옵니다. |
| ② message.content (완성된 메시지) | choices?.[0]?.message?.content | 스트림의 마지막 이벤트에서 전체 메시지가 한 번에 올 수 있습니다. 스트리밍이 아닌 단일 JSON 응답도 이 구조입니다. |
| ③ direct message.content (최상위 message) | message?.content | CLOVA Studio의 특정 이벤트(event: result) 포맷에서 사용됩니다. choices 없이 바로 message만 올 수 있습니다. |
LLM이 상황에 따라 포맷을 바꾸기 때문에 하나라도 누락되면 문장이 끊기거나 사라집니다.
따라서 extractContentFromParsed 는 세 필드를 모두 검사합니다.
스트림은 데이터를 완성본이 아니라 조각(Chunk) 으로 보내는 방식입니다.
LLM 같은 생성형 모델은 답변 전체가 완성되기 전에 조각을 만들어낼 수 있기 때문에,
사용자에게 즉시 보여주는 실시간 타이핑 효과를 만들 수 있습니다.
🚨 하지만 문제: 네트워크는 “예쁘게 한 줄씩” 데이터를 주지 않는다
SSE는 줄(line) 단위로 메시지를 보내고 싶어 하지만, 실제 네트워크에서 들어오는 데이터는 아래처럼 쪼개져 들어옵니다.
"data: {\"message\""
" :{\"content\":\"안녕하"
"세요\"}}\n\n"
원래 서버가 보내고 싶었던 형태는:
data: {"message":{"content":"안녕하세요"}}
즉, 하나의 SSE 이벤트(data: ...) 가 여러 청크로 조각나서 도착한다는 뜻입니다.
그래서 반드시 필요한 것이 바로 버퍼(buffer) 입니다.
\n 기준으로 완전한 한 줄만 분리data: 로 시작하는 줄만 JSON.parseCLOVA Studio 스트리밍 응답은 다음 구조를 따릅니다.
id: <이벤트ID>
event: result
data: {"choices":[{"delta":{"content":"안"}}]}
id: <이벤트ID>
data: {"choices":[{"delta":{"content":"녕"}}]}
id: <이벤트ID>
data: {"choices":[{"message":{"content":"안녕하세요"}}]}
data: [DONE]
| SSE 라인 | 코드에서 처리하는 부분 |
|---|---|
data: 로 시작하는 줄 찾기 | trimmedLine.startsWith('data:') |
| “data:” 제외한 JSON만 추출 | substring(5).trim() |
| 청크 파싱 | JSON.parse(data) |
| 스트림 종료 | if (data === '[DONE]') continue |
SSE 스트림을 제대로 파싱하려면 다음을 반드시 고려해야 합니다.
✔ 스트림은 여러 청크로 조각나서 도착한다
✔ JSON은 라인 단위로 쪼개서 파싱해야 한다
✔ 텍스트는 세 가지 위치 중 어디서든 올 수 있다
✔ 버퍼를 두지 않으면 줄 단위 분리가 불가능하다
이제 이 원리를 이해했으니, 구현으로 다시 넘어갑니다.
private async fetchClova(
apiRequest: any,
requestId: string,
): Promise<Response> {
this.validateApiKey();
const response = await fetch(this.clovaStudioUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.clovaStudioApiKey}`,
'X-NCP-CLOVASTUDIO-REQUEST-ID': requestId,
'Content-Type': 'application/json',
Accept: 'text/event-stream', // ⭐️ 여기서 SSE 모드로 요청
},
body: JSON.stringify(apiRequest),
});
if (!response.ok || !response.body) {
const errText = await response.text().catch(() => '');
throw new BadRequestException(
`CLOVA Studio SSE 호출 실패: ${response.status} ${errText}`,
);
}
return response;
}
interface GatewayWrapper {
broadcastChunk: (windowId: string, chunk: string) => void;
broadcastComplete: (windowId: string, fullContent: string) => void;
}
private async parseSseStream(
response: Response,
windowId: string,
gatewayWrapper?: GatewayWrapper,
): Promise<string> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let fullContent = '';
let buffer = ''; // ⭐ 불완전한 줄을 임시 저장
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 1. 바이트 → 문자열 + 버퍼 누적
buffer += decoder.decode(value, { stream: true });
// 2. 줄 단위로 분리
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 마지막 줄은 미완성일 수 있으므로 버퍼로 되돌림
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine.startsWith('data:')) continue;
const data = trimmedLine.substring(5).trim(); // "data:" 이후 부분
// [DONE] 마커는 무시
if (data === '[DONE]' || data.includes('[DONE]')) continue;
const extractedContent = this.extractContentFromParsed(data);
if (!extractedContent) continue;
fullContent += extractedContent;
// 4단계: WebSocket 브로드캐스트(옵션)
gatewayWrapper?.broadcastChunk(windowId, extractedContent);
}
}
// 스트림 종료 시 최종 완료 알림
gatewayWrapper?.broadcastComplete(windowId, fullContent);
return fullContent;
}
CLOVA Studio는 스트리밍 중에 여러 형태의 JSON을 보낼 수 있습니다.
📖 참고: NCloud Docs
대표적으로 세 곳에서 텍스트가 올 수 있습니다:
choices[0].delta.contentchoices[0].message.contentmessage.content (CLOVA 고유 포맷)그래서 아래처럼 세 곳을 모두 확인해야 합니다.
private extractContentFromParsed(jsonData: string): string {
try {
const parsed: any = JSON.parse(jsonData);
let extracted = '';
// 1. 스트리밍 청크 (Delta)
const deltaContent = parsed?.choices?.[0]?.delta?.content;
if (typeof deltaContent === 'string' && deltaContent) {
extracted += deltaContent;
}
// 2. 완료된 메시지 (Choices Message)
const messageContent = parsed?.choices?.[0]?.message?.content;
if (typeof messageContent === 'string' && messageContent) {
extracted += messageContent;
}
// 3. 직접 message 객체 (CLOVA 전용 event:result)
const directContent = parsed?.message?.content;
if (typeof directContent === 'string' && directContent) {
extracted += directContent;
}
return extracted;
} catch {
// JSON 파싱 실패 시 조용히 무시
return '';
}
}
CLOVA Studio는 스트리밍이 끝날 때 두 개의 특별한 이벤트를 연속으로 보냅니다:
stopReason: "stop_before" + 빈 contentstopReason: "stop_before" + 전체 메시지(content 전체)예시:
{"message":{"content":""},"stopReason":"stop_before"} // 종료 신호
{"message":{"content":"출력:\n전체 내용"},"stopReason":"stop_before"} // 전체 메시지 재전송
여기서 문제는 다음과 같습니다:
✔ delta 스트리밍 조각으로 이미 모든 텍스트가 들어온 상태
✔ 그런데 스트림 마지막에 “전체 메시지”를 다시 보내므로 중복 출력이 발생
예:
[1~20] delta.content → "출력:\n전체 내용"
[21] stop_before + "" → 종료 신호
[22] stop_before + "출력:\n전체 내용" → ❗ 전체 메시지 재전송 → 중복 발생
그래서 4-6의 기본 추출 로직만 쓰면, 아래와 같이 delta로 받은 내용 + 종료 직전에 다시 내려오는 전체 메시지로 두 번 출력이 일어납니다.
{
"success": true,
"data": {
"content": "출력:\n1. 코드: \n ```javascript\n console.log('apple');\n ```\n2. 설명: 'apple'을 콘솔에 출력합니다.출력:\n1. 코드: \n ```javascript\n console.log('apple');\n ```\n2. 설명: 'apple'을 콘솔에 출력합니다."
}
}
따라서, 중복을 삭제하기 위해 stopReason이 존재하는 content는 스킵하는 방법을 사용합니다.
stopReason이 null → 스트리밍 중 조각(delta), 정상 처리
stopReason이 not null → 종료 신호 또는 전체 메시지, 스킵
private extractContentFromParsed(parsed: any): string {
let extracted = '';
const stopReason = parsed?.stopReason;
const hasStopReason = stopReason !== null && stopReason !== undefined;
// 1. delta(정상 스트리밍)
const delta = parsed?.choices?.[0]?.delta?.content;
if (delta) return delta;
// 2. 메시지(content) — 하지만 stopReason이 있으면 전체 메시지 → 스킵
const message = parsed?.choices?.[0]?.message?.content;
if (message && !hasStopReason) return message;
// 3. direct message — 마찬가지로 stopReason 체크
const direct = parsed?.message?.content;
if (direct && !hasStopReason) return direct;
return '';
}
async callClovaStudio(
request: ChatCompletionsRequestDto,
windowId: string,
gatewayWrapper?: GatewayWrapper,
): Promise<string> {
const apiRequest = this.buildApiRequest(request);
const requestId = crypto.randomUUID();
const response = await this.fetchClova(apiRequest, requestId);
const fullContent = await this.parseSseStream(
response,
windowId,
gatewayWrapper,
);
return fullContent;
}

다음 단계에서 이 스트림을 WebSocket으로 중계해봅니다.
SSE는 "서버 → 백엔드" 구간에서만 사용되고,
프론트엔드는 WebSocket(socket.io 등)을 통해 실시간 조각을 받도록 만들 수 있습니다.
📖 참고: NestJS 공식 문서
이 글에서는 windowId를 룸 ID로 사용해, 각 에디터 창마다 독립적으로 스트림을 전달하는 구조를 예로 들겠습니다.
// gateway.ts
@WebSocketGateway({
cors: { origin: '*' },
namespace: '/editor',
})
export class EditorGateway {
@WebSocketServer()
server: Server;
/**
* 스트리밍 중간 청크 전송
*/
broadcastClovaStudioChunk(windowId: string, chunk: string) {
this.server.to(windowId).emit('editor:clova-chunk', {
windowId,
chunk,
});
}
/**
* 스트리밍 완료 알림
*/
broadcastClovaStudioComplete(windowId: string, fullContent: string) {
this.server.to(windowId).emit('editor:clova-complete', {
windowId,
content: fullContent,
});
}
// handleConnection / handleDisconnect 등은 필요에 따라 구현
}
프론트에서는 예를 들면:
socket.emit('join', { windowId })socket.join(windowId) 처리editor:clova-chunk, editor:clova-complete 이벤트를 수신같은 방식으로 연동할 수 있습니다.
// controller.ts
@Controller('editor')
export class EditorController {
constructor(
private readonly editorService: EditorService,
private readonly editorGateway: EditorGateway,
) {}
@Post('clova')
async chatCompletions(@Body() body: ChatCompletionsRequestDto) {
const { windowId } = body;
const gatewayWrapper: GatewayWrapper = {
broadcastChunk: (wid, chunk) =>
this.editorGateway.broadcastClovaStudioChunk(wid, chunk),
broadcastComplete: (wid, full) =>
this.editorGateway.broadcastClovaStudioComplete(wid, full),
};
const result = await this.editorService.callClovaStudio(
body,
windowId,
gatewayWrapper,
);
// HTTP 응답은 최종 fullContent 위주로 반환
return {
fullContent: result,
message: '스트리밍이 완료되었습니다.',
};
}
}
하이브리드 구조를 가지게 됩니다.
마지막으로 전체 흐름을 다시 한 번 요약해보겠습니다.
POST /editor/clovacontent: 사용자 프롬프트windowId: 에디터/탭 식별자systemPromptType, conversationHistoryEditorController.chatCompletions)EditorGateway를 주입받아 GatewayWrapper 생성EditorService.callClovaStudio(...) 호출buildApiRequestcode-assistant / code-refactor / code-review)DEFAULT_CLOVA_PARAMS 합치기messages 배열에 system → history → user 순서로 쌓기fetchClova 에서Authorization: Bearer {API_KEY}Accept: text/event-stream 로 설정Response.body.getReader() 로 청크 단위로 읽기buffer 에 문자열 누적 → \n 기준으로 줄 나누기data: 로 시작하는 줄만 골라 JSON 파싱choices[0].delta.content / choices[0].message.content / message.content 에서 텍스트 추출GatewayWrapper.broadcastChunk(windowId, chunk) 호출EditorGateway 가 editor:clova-chunk 이벤트로 각 클라이언트에 실시간 전송GatewayWrapper.broadcastComplete(windowId, fullContent) 호출{ fullContent, message: ... } 반환여기까지 작업하면 클라이언트는 최소 정보만 보내고,
서버가 CLOVA Studio와 통신 및 파싱, 프롬프트 관리, 스트리밍 브리지 역할을 모두 담당하는 요청을 보낼 수 있게 됩니다.
- CLOVA Studio 개요 & API 가이드 – NCloud Docs
- CLOVA Studio Chat Completions API (HCX-003 / HCX-DASH-001) – NCloud Docs
- Server-Sent Events (SSE) 기본 구조 & EventSource – MDN Web Docs
- Server-Sent Events 개념 정리 – WHATWG HTML Living Standard
- NestJS WebSocket Gateways 공식 문서 – NestJS Docs