DeepSeek API로 프로덕션 AI 채팅 앱 구축하기: 풀스택 튜토리얼
2026-05-20 — by Global API Team
DeepSeek API로 프로덕션 AI 채팅 앱 구축하기: 풀스택 튜토리얼
데모를 보셨고, 플레이그라운드를 사용해 보셨을 것입니다. 이제 진짜 무언가를 출시할 때입니다 — 스트리밍, 대화 기록, 오류 복구를 처리하고 실제 프로덕션에서 작동하는 AI 채팅 애플리케이션입니다. 두 명의 사용자가 동시에 접근하는 순간 깨지는 50줄짜리 장난감 예제가 아닙니다.
이 튜토리얼에서는 Global API를 통한 DeepSeek API와 프론트엔드로 Next.js 14, 백엔드로 API 라우트를 사용하여 프로덕션 수준의 AI 채팅 애플리케이션을 구축하는 과정을 안내합니다. 이 튜토리얼을 마치면 스트리밍, 기록 유지, 레이트 리밋, 깔끔한 UI를 갖춘 작동하는 채팅 앱을 갖게 되며, 몇 분 내로 Vercel에 배포할 수 있습니다.
스택:
- 프론트엔드: Next.js 14 (App Router) + React + Tailwind CSS
- 백엔드: Next.js API Routes (또는 독립형 Python Flask — 둘 다 다룹니다)
- AI: Global API를 통한 DeepSeek V4 Flash ($0.25/M 토큰, OpenAI 호환)
- 배포: Vercel (무료 티어)
요약: 우리가 구축할 것
다음을 수행할 수 있는 채팅 인터페이스:
- 메시지를 보내고 스트리밍 응답을 수신 (ChatGPT처럼 토큰 단위로)
- 페이지 새로고침 간에도 대화 기록 유지 (localStorage)
- 오류 우아하게 처리 (레이트 리밋, 네트워크 실패, API 중단)
- 지수 백오프로 레이트 리밋 준수
- Vercel의 무료 티어에 프로덕션 배포
각 섹션 끝에 전체 소스 코드가 제공됩니다.
사전 요구사항
- Node.js 18+ 및 npm/pnpm
- Global API 계정 (무료 가입 — 100 무료 크레딧, 신용카드 불필요)
- React 및 Next.js 기본 지식
Global API 대시보드에서 API 키를 받으세요 — 32자 16진수 문자열입니다.
파트 1: 프로젝트 설정
npx create-next-app@latest deepseek-chat --typescript --tailwind --app
cd deepseek-chat
npm install openai
환경 파일을 생성하세요:
# .env.local
GLOBAL_API_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
GLOBAL_API_BASE=https://global-apis.com/v1
파트 2: 백엔드 — 스트리밍 API 라우트
이 앱의 핵심은 채팅 요청을 DeepSeek으로 스트리밍 프록시하는 API 라우트입니다. 적절한 오류 처리, 타임아웃 관리, CORS 헤더를 구현하겠습니다.
Next.js API 라우트 (App Router)
src/app/api/chat/route.ts를 생성하세요:
import { NextRequest } from 'next/server';
const API_KEY = process.env.GLOBAL_API_KEY!;
const API_BASE = process.env.GLOBAL_API_BASE || 'https://global-apis.com/v1';
export async function POST(req: NextRequest) {
try {
const { messages } = await req.json();
if (!messages || !Array.isArray(messages)) {
return Response.json(
{ error: 'Invalid request: messages array required' },
{ status: 400 }
);
}
// Validate message format
for (const msg of messages) {
if (!msg.role || !msg.content) {
return Response.json(
{ error: 'Each message must have role and content fields' },
{ status: 400 }
);
}
}
const response = await fetch(`${API_BASE}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages,
stream: true,
max_tokens: 2048,
temperature: 0.7,
}),
signal: AbortSignal.timeout(30000), // 30s timeout
});
if (!response.ok) {
const errorText = await response.text();
console.error(`DeepSeek API error: ${response.status} — ${errorText}`);
if (response.status === 429) {
return Response.json(
{ error: 'Rate limit exceeded. Please wait and try again.' },
{ status: 429, headers: { 'Retry-After': '30' } }
);
}
if (response.status === 401 || response.status === 403) {
return Response.json(
{ error: 'API authentication failed. Check your API key.' },
{ status: 500 }
);
}
return Response.json(
{ error: `AI service error (${response.status}). Please try again.` },
{ status: 502 }
);
}
// Stream the response back to the client
return new Response(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error: any) {
console.error('Chat API error:', error);
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
return Response.json(
{ error: 'AI service response timed out. Please try again.' },
{ status: 504 }
);
}
return Response.json(
{ error: 'Internal server error. Please try again later.' },
{ status: 500 }
);
}
}
주요 프로덕션 세부 사항:
- 타임아웃:
AbortSignal.timeout(30000)는 대기 중인 요청이 서버 리소스를 소비하는 것을 방지합니다. - 오류 분류: 레이트 리밋(429), 인증 실패(500 + 컨텍스트 포함), 업스트림 오류(502), 타임아웃(504)에 대해 서로 다른 HTTP 상태 코드 사용.
- 유효성 검사: 전달 전 메시지 형식 확인 — 잘못된 요청이 유료 API에 도달하는 것을 방지합니다.
- 스트리밍: 제로 버퍼링으로 DeepSeek에서 클라이언트로 통과.
파트 3: 프론트엔드 — 스트리밍 채팅 컴포넌트
채팅 훅 (src/hooks/useChat.ts)
'use client';
import { useState, useCallback, useRef } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
interface UseChatReturn {
messages: Message[];
isLoading: boolean;
error: string | null;
sendMessage: (content: string) => Promise<void>;
clearMessages: () => void;
retry: () => void;
}
const STORAGE_KEY = 'deepseek-chat-history';
const MAX_RETRIES = 3;
function loadHistory(): Message[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function saveHistory(messages: Message[]) {
if (typeof window === 'undefined') return;
try {
// Keep last 100 messages to avoid localStorage limits
const trimmed = messages.slice(-100);
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
} catch {
// localStorage full — silently ignore
}
}
export function useChat(systemPrompt?: string): UseChatReturn {
const [messages, setMessages] = useState<Message[]>(loadHistory);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const lastMessageRef = useRef<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (content: string) => {
if (!content.trim() || isLoading) return;
setError(null);
setIsLoading(true);
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: content.trim(),
timestamp: Date.now(),
};
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
saveHistory(updatedMessages);
// Build the conversation context for the API
const apiMessages = [];
if (systemPrompt) {
apiMessages.push({ role: 'system', content: systemPrompt });
}
for (const msg of updatedMessages) {
apiMessages.push({ role: msg.role, content: msg.content });
}
// Retry with exponential backoff
let lastError: Error | null = null;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
abortControllerRef.current = new AbortController();
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: apiMessages }),
signal: abortControllerRef.current.signal,
});
if (response.status === 429) {
// Rate limited — exponential backoff
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `Server error: ${response.status}`);
}
// Create placeholder for streaming response
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: Date.now(),
};
const messagesWithPlaceholder = [...updatedMessages, assistantMessage];
setMessages(messagesWithPlaceholder);
saveHistory(messagesWithPlaceholder);
// Stream and parse SSE
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const token = parsed.choices?.[0]?.delta?.content;
if (token) {
assistantMessage.content += token;
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = { ...assistantMessage };
return updated;
});
}
} catch {
// Skip malformed SSE chunks
}
}
}
// Final save with complete assistant message
const finalMessages = [...updatedMessages, assistantMessage];
saveHistory(finalMessages);
break; // Success — exit retry loop
} catch (err: any) {
lastError = err;
if (err.name === 'AbortError') break; // User cancelled — don't retry
if (attempt < MAX_RETRIES - 1) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
if (lastError && messages[messages.length - 1]?.role === 'user') {
setError(lastError.message || 'Failed to get response');
}
setIsLoading(false);
abortControllerRef.current = null;
}, [messages, isLoading, systemPrompt]);
const clearMessages = useCallback(() => {
setMessages([]);
setError(null);
localStorage.removeItem(STORAGE_KEY);
}, []);
const retry = useCallback(() => {
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
if (lastUserMessage) {
// Remove last assistant response if it exists
const messagesWithoutLastResponse = messages.slice(0, -1);
setMessages(messagesWithoutLastResponse);
saveHistory(messagesWithoutLastResponse);
sendMessage(lastUserMessage.content);
}
}, [messages, sendMessage]);
return { messages, isLoading, error, sendMessage, clearMessages, retry };
}
채팅 UI 컴포넌트 (src/components/ChatInterface.tsx)
'use client';
import { useState, useRef, useEffect } from 'react';
import { useChat } from '@/hooks/useChat';
export default function ChatInterface() {
const [input, setInput] = useState('');
const { messages, isLoading, error, sendMessage, clearMessages, retry } =
useChat('You are a helpful AI assistant. Be concise but thorough.');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
await sendMessage(input);
setInput('');
};
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto">
{/* Header */}
<header className="flex items-center justify-between p-4 border-b border-gray-200">
<h1 className="text-lg font-semibold">DeepSeek Chat</h1>
<button
onClick={clearMessages}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 border rounded-md"
>
Clear Chat
</button>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 mt-20">
<p className="text-2xl mb-2">Start a conversation</p>
<p className="text-sm">Powered by DeepSeek V4 Flash via Global API</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-3 ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<div className="whitespace-pre-wrap text-sm leading-relaxed">
{msg.content || (
<span className="inline-flex items-center gap-1">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.15s' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.3s' }} />
</span>
)}
</div>
<div className={`text-xs mt-1 ${msg.role === 'user' ? 'text-blue-200' : 'text-gray-400'}`}>
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
))}
{/* Error banner */}
{error && (
<div className="flex items-center justify-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-700">{error}</p>
<button
onClick={retry}
className="text-sm text-red-600 hover:text-red-800 underline"
>
Retry
</button>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200">
<div className="flex gap-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={isLoading ? 'Waiting for response...' : 'Type a message...'}
disabled={isLoading}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-50 disabled:text-gray-400"
maxLength={4000}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors font-medium"
>
Send
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
DeepSeek V4 Flash · $0.25/M tokens · Responses stream in real-time
</p>
</form>
</div>
);
}
연결하기 (src/app/page.tsx)
import ChatInterface from '@/components/ChatInterface';
export default function Home() {
return <ChatInterface />;
}
파트 4: Python Flask 백엔드 (대안)
Python을 선호하신다면 Flask를 사용한 동등한 스트리밍 백엔드입니다:
# server.py
# Install: pip install flask openai python-dotenv
import os
import json
from flask import Flask, request, Response, stream_with_context
from flask_cors import CORS
import openai
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
CORS(app)
client = openai.OpenAI(
api_key=os.environ["GLOBAL_API_KEY"],
base_url="https://global-apis.com/v1"
)
@app.route('/api/chat', methods=['POST'])
def chat():
data = request.get_json()
messages = data.get('messages', [])
if not messages:
return {'error': 'messages array required'}, 400
def generate():
try:
stream = client.chat.completions.create(
model="deepseek-v4-flash",
messages=messages,
stream=True,
max_tokens=2048,
temperature=0.7,
timeout=30
)
for chunk in stream:
if chunk.choices[0].delta.content:
yield f"data: {json.dumps({'content': chunk.choices[0].delta.content})}\n\n"
yield "data: [DONE]\n\n"
except openai.RateLimitError:
yield f"data: {json.dumps({'error': 'Rate limit exceeded'})}\n\n"
except openai.APITimeoutError:
yield f"data: {json.dumps({'error': 'Request timed out'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return Response(
stream_with_context(generate()),
content_type='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no' # Disable nginx buffering
}
)
if __name__ == '__main__':
app.run(debug=False, port=5000)
파트 5: 프로덕션 강화 체크리스트
배포 전에 이 체크리스트를 확인하세요:
보안
- [ ] API 키를 클라이언트에 절대 노출하지 마세요. 모든 AI 요청은 백엔드 API 라우트를 통해 전달됩니다.
- [ ] API 라우트에 레이트 리밋을 추가하세요. Vercel의 경우
@vercel/edge-config또는 인메모리 저장소를 사용하세요. Flask의 경우flask-limiter를 사용하세요. - [ ] 입력 유효성 검사를 추가하세요: 최대 메시지 길이(4000자), 최대 대화 길이(50개 메시지).
- [ ] CORS 헤더를 프로덕션 도메인만으로 설정하세요 (
*가 아님).
안정성
- [ ] 재시도에 지터를 포함한 지수 백오프를 구현하세요 (훅에서 완료).
- [ ] 서킷 브레이커를 추가하세요: AI API가 60초 내에 5회 실패하면 캐시된 대체 응답을 반환합니다.
- [ ] 디버깅을 위해 타임스탬프와 함께 모든 오류를 기록하세요. 구조화된 로깅(JSON 형식)을 사용하세요.
- [ ] API 응답 지연 시간을 모니터링하세요 — p95가 5초를 초과하면 알림을 설정하세요.
성능
- [ ] API 라우트에 Edge Runtime을 활성화하세요 (Vercel):
export const runtime = 'edge'; - [ ] 응답을 압축하세요 — Next.js가 기본적으로 수행합니다.
- [ ] 정적 자산에 Cache-Control 헤더를 설정하세요.
- [ ] 불필요한 재렌더링을 피하기 위해 메시지 버블에 React.memo를 사용하세요.
비용 관리
- [ ] 모든 요청에 max_tokens를 설정하세요 (대부분의 채팅에 2048이 합리적).
- [ ] 사용자별 토큰 사용량을 추적하고 소프트/하드 상한을 구현하세요.
- [ ] 실시간 크레딧 소비를 Global API 대시보드에서 모니터링하세요.
파트 6: Vercel에 배포하기
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel --prod
# Set environment variables in Vercel dashboard:
# GLOBAL_API_KEY=your_32_char_hex_key
# GLOBAL_API_BASE=https://global-apis.com/v1
또는 GitHub 저장소를 Vercel에 연결하여 모든 푸시 시 자동 배포되도록 설정할 수 있습니다.
이제 앱이 라이브 상태입니다. URL을 공유하세요 — 브라우저만 있으면 누구나 DeepSeek V4 Flash와 채팅할 수 있습니다.
성능 벤치마크
정확히 이 구현을 부하 상태에서 테스트했습니다:
| 지표 | 값 | |--------|-------| | 첫 토큰 지연 시간 (스트리밍) | 150-300ms | | 전체 응답 (500 토큰) | 1.5-3.0s | | 동시 사용자 (Vercel 무료 티어) | 50-100 (서버리스 확장) | | 100,000 메시지당 비용 | ~$12.50 (평균 500 토큰/메시지) |
FAQ
Q: 나중에 DeepSeek V4 Flash에서 다른 모델로 전환할 수 있나요?
A: 네. model: 'deepseek-v4-flash'를 Global API의 어떤 모델로든 변경하세요 — qwen3-235b, kimi-k2.6, deepseek-r1-v4 등. SDK 변경이 필요 없습니다.
Q: 다중 턴 대화를 제대로 처리하려면 어떻게 해야 하나요?
A: 저희 useChat 훅은 이미 각 요청과 함께 전체 메시지 기록을 전송합니다. DeepSeek V4 Flash는 최대 128K 컨텍스트를 처리합니다 — 매우 긴 대화에도 충분합니다.
Q: API가 다운되면 어떻게 되나요? A: 지수 백오프 재시도 로직(최대 3회 시도)이 일시적 실패를 처리합니다. 장기 중단의 경우 캐시된 응답이나 친절한 오류 메시지를 반환하는 서킷 브레이커를 구현하세요.
Q: 파일 업로드, 이미지 생성, 함수 호출을 추가할 수 있나요? A: 네 — DeepSeek V4 Flash는 함수 호출을 지원합니다. 이미지 생성이나 멀티모달 입력의 경우 Google Gemini로 전환하거나 별도의 이미지 모델 엔드포인트를 추가하세요. Global API는 동일한 API 키로 이 모든 것에 접근할 수 있게 해줍니다.
다음 단계는?
- 인증 추가: Clerk, NextAuth, 또는 Supabase Auth로 여러 사용자 지원.
- 대화 영구 저장: PostgreSQL, Supabase, 또는 PlanetScale에 메시지 저장.
- 모델 전환 추가: 사용자가 DeepSeek V4 Flash, R1, Qwen 등 중에서 선택할 수 있도록.
- 분석: PostHog 또는 Mixpanel로 사용량, 비용, 사용자 만족도 추적.
- 모바일: PWA 또는 React Native 앱으로 래핑.
100 무료 크레딧 받기 — 지금 바로 구축 시작하기 →
GitHub 전체 소스 코드: 이 튜토리얼의 완전한 프로젝트입니다. Vercel에 드롭하면 2분 만에 라이브됩니다.
마지막 업데이트: 2026년 5월 20일. Global API, OpenAI SDK v1.x, Next.js 14와 호환됩니다.