저는 이번에HolySheep AI와 Next.js App Router를 결합하여 실시간 AI 스트리밍 응답 기능을 구현하면서 여러 가지 벽을 마주했습니다. ConnectionError: timeout, 401 Unauthorized, ReadableStream is not defined 같은 에러들이 끊이지 않았죠.

이 튜토리얼에서는 제가 실제 프로젝트에서 경험한 문제들과 그 해결책을 바탕으로, Next.js 14+ App Router에서 Server Actions와 SSE(Server-Sent Events)를 활용하여 HolySheep AI의 스트리밍 응답을 구현하는 방법을 단계별로 설명드리겠습니다.

왜 Server Actions + SSE인가?

전통적인 API Routes 방식 대신 Server Actions를 선택한 이유는 크게 세 가지입니다:

HolySheep AI의 스트리밍 API를 사용하면平均 응답 시간 1,200ms 이내에 첫 토큰을 수신할 수 있으며, GPT-4.1의 경우 $8/MTok, DeepSeek V3.2는 $0.42/MTok의 비용으로 경제적으로 운영할 수 있습니다.

1. 프로젝트 설정

먼저 필요한 패키지를 설치합니다:

npm install next@14 react@18 react-dom@18 openai zod

환경 변수를 설정합니다:

# .env.local
HOLYSHEEP_API_KEY=YOUR_HOLYSHEEP_API_KEY
HOLYSHEEP_BASE_URL=https://api.holysheep.ai/v1

지금 가입하시면 무료 크레딧과 함께 API 키를 즉시 발급받으실 수 있습니다.

2. HolySheep AI 스트리밍 클라이언트 설정

저는 먼저 HolySheep AI의 스트리밍 API를 호출하는 유틸리티 함수를 만들었습니다. 핵심은 ReadableStream을 올바르게 반환하는 것입니다:

// lib/holysheep-stream.ts
import OpenAI from 'openai';

const holysheep = new OpenAI({
  apiKey: process.env.HOLYSHEEP_API_KEY,
  baseURL: process.env.HOLYSHEEP_BASE_URL || 'https://api.holysheep.ai/v1',
});

export interface StreamOptions {
  model: 'gpt-4.1' | 'claude-sonnet-4-5' | 'gemini-2.5-flash' | 'deepseek-v3.2';
  messages: OpenAI.Chat.ChatCompletionMessageParam[];
  systemPrompt?: string;
}

export async function createAIStream(options: StreamOptions) {
  const { model, messages, systemPrompt } = options;
  
  // 모델별 매핑 (HolySheep AI 모델명)
  const modelMap: Record<string, string> = {
    'gpt-4.1': 'gpt-4.1',
    'claude-sonnet-4-5': 'claude-sonnet-4-5',
    'gemini-2.5-flash': 'gemini-2.5-flash',
    'deepseek-v3.2': 'deepseek-v3.2',
  };

  const mappedModel = modelMap[model] || 'gpt-4.1';
  
  const stream = await holysheep.chat.completions.create({
    model: mappedModel,
    messages: [
      ...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
      ...messages,
    ],
    stream: true,
    temperature: 0.7,
    max_tokens: 2048,
  });

  //ReadableStream 변환 (App Router 호환)
  const encoder = new TextEncoder();
  
  const readable = new ReadableStream({
    async start(controller) {
      try {
        for await (const chunk of stream) {
          const content = chunk.choices[0]?.delta?.content || '';
          if (content) {
            controller.enqueue(encoder.encode(data: ${JSON.stringify({ content })}\n\n));
          }
        }
        controller.enqueue(encoder.encode('data: [DONE]\n\n'));
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    },
  });

  return readable;
}

3. Server Actions 구현

이제 Server Action을 만들어 스트리밍 응답을 반환합니다:

// app/actions/stream-chat.ts
'use server';

import { createAIStream, StreamOptions } from '@/lib/holysheep-stream';

export async function streamChat(options: StreamOptions) {
  try {
    const stream = await createAIStream(options);
    return new Response(stream, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache, no-transform',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no', // Nginx 버퍼링 비활성화
      },
    });
  } catch (error) {
    console.error('Stream Error:', error);
    return new Response(JSON.stringify({ 
      error: '스트리밍 응답 생성 실패',
      details: error instanceof Error ? error.message : '알 수 없는 오류'
    }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

4. 클라이언트 컴포넌트 구현

스트리밍 응답을 소비하는 클라이언트 컴포넌트를 만들겠습니다:

// components/stream-chat.tsx
'use client';

import { useState, useRef, useCallback } from 'react';

interface Message {
  role: 'user' | 'assistant';
  content: string;
}

interface StreamChatProps {
  model?: 'gpt-4.1' | 'claude-sonnet-4-5' | 'gemini-2.5-flash' | 'deepseek-v3.2';
}

export default function StreamChat({ model = 'gpt-4.1' }: StreamChatProps) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);

  const handleSubmit = useCallback(async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isStreaming) return;

    const userMessage: Message = { role: 'user', content: input };
    setMessages(prev => [...prev, userMessage]);
    setInput('');
    setError(null);
    setIsStreaming(true);

    // 이전 스트림 취소
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    abortControllerRef.current = new AbortController();

    try {
      const response = await fetch('/api/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          model,
          messages: [...messages, userMessage],
        }),
        signal: abortControllerRef.current.signal,
      });

      if (!response.ok) {
        throw new Error(HTTP ${response.status}: ${response.statusText});
      }

      const reader = response.body?.getReader();
      if (!reader) throw new Error('ReadableStreamReader 생성 실패');

      const decoder = new TextDecoder();
      let assistantMessage = '';

      setMessages(prev => [...prev, { role: 'assistant', content: '' }]);

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

        const chunk = decoder.decode(value);
        const lines = chunk.split('\n');

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            if (data === '[DONE]') break;

            try {
              const parsed = JSON.parse(data);
              if (parsed.content) {
                assistantMessage += parsed.content;
                setMessages(prev => {
                  const updated = [...prev];
                  updated[updated.length - 1] = {
                    role: 'assistant',
                    content: assistantMessage,
                  };
                  return updated;
                });
              }
            } catch (parseError) {
              // 파싱 에러는 무시 (부분 데이터)
            }
          }
        }
      }
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') {
        console.log('스트리밍 취소됨');
      } else {
        const errorMessage = err instanceof Error ? err.message : '알 수 없는 오류';
        setError(연결 오류: ${errorMessage});
        setMessages(prev => prev.slice(0, -1)); // 실패한 메시지 제거
      }
    } finally {
      setIsStreaming(false);
    }
  }, [input, messages, model, isStreaming]);

  const handleStop = () => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
      setIsStreaming(false);
    }
  };

  return (
    <div className="max-w-2xl mx-auto p-4">
      <div className="bg-gray-900 rounded-lg p-4 h-96 overflow-y-auto mb-4">
        {messages.length === 0 && (
          <p className="text-gray-500 text-center">
            HolySheep AI와 대화를 시작하세요.
          </p>
        )}
        {messages.map((msg, idx) => (
          <div
            key={idx}
            className={`mb-4 ${
              msg.role === 'user' ? 'text-right' : 'text-left'
            }`}
          >
            <span
              className={`inline-block px-4 py-2 rounded-lg ${
                msg.role === 'user'
                  ? 'bg-blue-600 text-white'
                  : 'bg-gray-700 text-gray-100'
              }`}
            >
              {msg.content}
            </span>
          </div>
        ))}
        {isStreaming && (
          <div className="text-left">
            <span className="inline-block px-4 py-2 rounded-lg bg-gray-700">
              <span className="animate-pulse">응답 생성 중...</span>
            </span>
          </div>
        )}