DeepSeek Vision and Grok Imagine models are now live on CometAPI →
guide/CometAPI 리서치

Next.js와 CometAPI로 멀티 모델 AI 채팅 앱을 구축하는 방법

하나의 CometAPI 키와 하나의 백엔드 엔드포인트를 통해 GPT, Claude, Gemini 모델 간 전환이 가능한 Next.js 기반 스트리밍 AI 채팅 앱을 구축하세요.

CometAPI
AnnaAI 모델 및 API 리서치 팀
업데이트됨 Aug 25, 2026 11 분 읽기
Next.js와 CometAPI로 멀티 모델 AI 채팅 앱을 구축하는 방법
이 패턴 사용

첫 API 호출하기.

from openai import OpenAI

client = OpenAI(
    api_key="YOUR_COMETAPI_KEY",
    base_url="https://api.cometapi.com/v1",
)

response = client.chat.completions.create(
    model="gpt-5-mini",
    messages=[{"role": "user", "content": "Build this workflow."}],
)

print(response.choices[0].message.content)

여러 모델 제공자가 필요한 Next.js 챗봇에서는, 인증을 중앙화하면서 요청마다 애플리케이션이 모델을 선택할 수 있게 하는 백엔드가 가장 유용합니다. CometAPI는 이 패턴을 제공합니다. 서버는 하나의 API 키와 OpenAI 호환 기본 URL https://api.cometapi.com/v1을 보관하고, 요청의 model 필드로 사용 가능한 GPT, Claude, Gemini 또는 기타 채팅 모델을 선택합니다. 

이 튜토리얼은 서버 측 모델 허용 목록, 스트리밍 Route Handler, 브라우저 모델 선택기, 보호된 환경 변수, 완전한 채팅 인터페이스, 배포 가이드를 갖춘 작동하는 App Router 프로젝트를 구축합니다. CometAPI 키는 브라우저로 전달되지 않습니다.

멀티 모델 AI 채팅 앱이란?

멀티 모델 AI 채팅 아키텍처는 사용자 인터페이스와 여러 모델 제공자 사이에 애플리케이션이 제어하는 단일 백엔드를 둡니다. 이 프로젝트에서 브라우저는 대화를 /api/chat으로 전송하고, 서버는 CometAPI 자격 증명을 비공개로 유지한 채 요청된 모델 ID를 검증하고, 호환 API를 통해 요청을 전달합니다.

Next.js에서 모델 전환은 어떻게 동작하나요?

모델 변경은 라우팅 결정입니다. 브라우저는 승인된 모델 ID를 대화와 함께 /api/chat으로 보냅니다. Route Handler는 CometAPI 자격 증명을 추가하고 POST /v1/chat/completions를 호출하여 선택된 모델의 응답을 스트림으로 브라우저에 반환합니다.

공유된 엔드포인트가 모든 모델을 동일하게 만들지는 않습니다. 출력 스타일, 도구 동작, 지원 파라미터, 컨텍스트 한도, 가격이 다를 수 있습니다. 모델 ID는 서버 측 허용 목록에 유지하고, 사용자에게 공개하기 전에 동일한 애플리케이션 프롬프트로 각 경로를 테스트하세요.

무엇을 만들게 되나요

완성된 애플리케이션은 하나의 Next.js 백엔드와 선택 가능한 세 가지 채팅 라우트를 제공합니다:

Model ID예시 역할CometAPI price
gemini-3.7-flash비용에 민감한 대량 채팅$0.60 input / $3.00 output
claude-opus-5집중된 프리미엄 추론$4 input / $20 output
gpt-5.6gpt-5.6은 GPT-5.6용 일반 CometAPI 라우트이며 현재 Sol 티어에 매핑됩니다.$3.2 input / $16 output in the short-context tier

가격 안내: 아래 가격은 100만 토큰당 USD 기준이며 2026년 8월 21일에 확인되었고 변경될 수 있습니다. 프로덕션 사용 전에는 항상 모델 페이지의 최신 요율을 확인하세요. 이 표는 라우팅 예시일 뿐, 품질 순위가 아닙니다. GPT-5.6은 272,000 토큰을 초과하면 더 높은 가격 티어를 사용합니다. 여기 사용된 날짜 기준 요율은 연결된 Gemini 3.7 Flash, Claude Opus 5, GPT-5.6 모델 페이지를 참고하세요.

CometAPI는 각 제공자의 공식 API를 기준으로 모델 가격을 투명하게 책정합니다. OpenAI, Claude, Gemini 등 통합된 공식 가격이 있는 모델은 공식 가격 대비 0.8:1 비율로 토큰당 과금되며, 20% 할인됩니다. 공식 API가 없는 모델(MidJourney, Kling, Luma 등)은 CometAPI가 설정한 호출당 요율로 청구되며, 이 또한 20% 할인됩니다. 가산 방식, 모델별 요율, 과금 단위는 CometAPI Pricing Guide를 참조하세요.

세 가지 라우트는 서로 다른 트레이드오프를 다룹니다. Gemini 3.7 Flash는 Google의 효율적인 에이전틱 워크호스로, 멀티모달 입력과 1,048,576-토큰 컨텍스트 윈도우를 통해 대량 채팅, 코딩, 지식 워크플로우에 적합합니다. Claude Opus 5는 Anthropic의 프런티어 추론 모델로, 다단계 분석, 코드, 신중한 장문 작성에 강하지만 프리미엄 가격을 갖습니다. GPT-5.6은 OpenAI의 범용 모델로, 넓은 컨텍스트 윈도우에서 일상적인 프로덕션 트래픽을 위한 추론, 도구 사용, 작성 균형을 제공합니다.

시작 전에

Node.js, npm, CometAPI 계정, CometAPI Quick Start에서 발급한 서버 측 API 키가 필요합니다. 위의 세 가지 정확한 모델 ID는 2026년 8월 21일 라이브 카탈로그에서 제공되었고, 예정 상태가 아니었으며 POST /v1/chat/completions를 노출했습니다.

이 튜토리얼은 다음의 공통 설정을 사용합니다:

1단계: Next.js 앱 생성

npx create-next-app@latest multi-model-chat --ts --app --eslintcd multi-model-chatnpm run dev

이 버전에는 모델 제공자 SDK가 필요 없습니다. 서버는 내장 fetch API를 사용하여 업스트림 Server-Sent Events 스트림을 전달합니다.

2단계: CometAPI 키를 서버에만 보관

프로젝트 루트에 .env.local을 생성하세요:

COMETAPI_API_KEY=replace_with_your_cometapi_keyCOMETAPI_BASE_URL=https://api.cometapi.com/v1

키에 NEXT_PUBLIC_ 접두사를 붙이지 마세요. Next.js는 해당 접두사가 있는 변수만 브라우저 번들에 노출합니다. API 키는 서버 측 Route Handler에 있어야 합니다.

3단계: 모델 정책 정의

허용된 ID를 드롭다운에만 두지 말고 백엔드에 두세요. 사용자는 브라우저 제어를 우회해 라우트를 직접 호출할 수 있으므로, 서버가 알 수 없는 모델 값을 거부해야 합니다.

const ALLOWED_MODELS = [  "gemini-3.7-flash",  "claude-opus-5",  "gpt-5.6",] as const;​type ModelId = (typeof ALLOWED_MODELS)[number];

클라이언트도 동일한 세 가지 ID를 선택기에 사용하지만, 서버가 단일 진실 공급원으로 남습니다.

4단계: Route Handler를 통해 CometAPI 스트리밍

app/api/chat/route.ts를 생성하세요. 이 라우트는 텍스트 전용 채팅 페이로드를 검증하고, stream: true로 CometAPI를 호출하며, 자격 증명을 노출하지 않고 업스트림 이벤트 스트림을 반환합니다.

export const runtime = "nodejs";export const dynamic = "force-dynamic";​const ALLOWED_MODELS = [  "gemini-3.7-flash",  "claude-opus-5",  "gpt-5.6",] as const;​type ModelId = (typeof ALLOWED_MODELS)[number];type ChatMessage = {  role: "system" | "user" | "assistant";  content: string;};​function isModelId(value: unknown): value is ModelId {  return (    typeof value === "string" &&    (ALLOWED_MODELS as readonly string[]).includes(value)  );}​function isChatMessage(value: unknown): value is ChatMessage {  if (typeof value !== "object" || value === null) return false;​  const message = value as Record<string, unknown>;  return (    ["system", "user", "assistant"].includes(String(message.role)) &&    typeof message.content === "string" &&    message.content.length > 0 &&    message.content.length <= 20_000  );}​export async function POST(request: Request) {  const apiKey = process.env.COMETAPI_API_KEY;  const baseUrl = (    process.env.COMETAPI_BASE_URL || "https://api.cometapi.com/v1"  ).replace(/\/$/, "");​  if (!apiKey) {    return Response.json(      { error: "COMETAPI_API_KEY is not configured." },      { status: 500 },    );  }​  let payload: { model?: unknown; messages?: unknown };​  try {    payload = await request.json();  } catch {    return Response.json({ error: "Invalid JSON body." }, { status: 400 });  }​  if (!isModelId(payload.model)) {    return Response.json({ error: "Unsupported model ID." }, { status: 400 });  }​  if (    !Array.isArray(payload.messages) ||    payload.messages.length === 0 ||    payload.messages.length > 50 ||    !payload.messages.every(isChatMessage)  ) {    return Response.json(      { error: "messages must contain 1 to 50 valid text messages." },      { status: 400 },    );  }​  const upstream = await fetch(`${baseUrl}/chat/completions`, {    method: "POST",    headers: {      Authorization: `Bearer ${apiKey}`,      "Content-Type": "application/json",    },    body: JSON.stringify({      model: payload.model,      messages: payload.messages,      stream: true,    }),    cache: "no-store",    signal: request.signal,  });​  if (!upstream.ok) {    const requestId = upstream.headers.get("x-request-id");​    console.error("CometAPI request failed", {      status: upstream.status,      requestId,    });​    return Response.json(      {        error: "The selected model request failed.",        status: upstream.status,        requestId,      },      { status: upstream.status },    );  }​  if (!upstream.body) {    return Response.json(      { error: "The model returned no response body." },      { status: 502 },    );  }​  return new Response(upstream.body, {    status: 200,    headers: {      "Content-Type": "text/event-stream; charset=utf-8",      "Cache-Control": "no-cache, no-transform",    },  });}

핸들러는 브라우저의 연결 해제 신호를 업스트림으로 전달하므로, 요청을 닫으면 불필요한 생성을 중단할 수 있습니다. 또한 서버 로그에 진단 세부 정보를 보관하면서 클라이언트에는 정제된 오류를 반환합니다.

5단계: 모델 선택과 스트림 파싱 추가

app/page.tsx에 클라이언트 컴포넌트를 생성하세요. 선택된 모델을 대화와 함께 전송하고, 각 data: 이벤트를 파싱하여 delta.content의 증분 텍스트를 마지막 assistant 메시지에 추가합니다.

브라우저는 CometAPI를 직접 호출하지 않습니다. 오직 서버의 /api/chat 라우트만 호출하며, 이 라우트가 키를 비공개로 유지하고 허용 목록을 강제합니다.

핵심 프로젝트 코드

위의 Route Handler는 전체 app/api/chat/route.ts 파일입니다. 다음의 페이지, 레이아웃, 스타일시트를 추가하면 실행 가능한 프로젝트가 완성됩니다.

app/page.tsx

"use client";​import { FormEvent, useState } from "react";​const MODEL_OPTIONS = [  { id: "gemini-3.7-flash", label: "Gemini 3.7 Flash" },  { id: "claude-opus-5", label: "Claude Opus 5" },  { id: "gpt-5.6", label: "GPT-5.6" },] as const;​type Role = "user" | "assistant";type Message = { role: Role; content: string };​function textFromSseLine(line: string): string {  const trimmed = line.trim();  if (!trimmed.startsWith("data:")) return "";​  const data = trimmed.slice(5).trim();  if (!data || data === "[DONE]") return "";​  try {    const event = JSON.parse(data);    return event.choices?.[0]?.delta?.content ?? "";  } catch {    return "";  }}​export default function Home() {  const [model, setModel] = useState("gemini-3.7-flash");  const [messages, setMessages] = useState<Message[]>([]);  const [input, setInput] = useState("");  const [loading, setLoading] = useState(false);  const [error, setError] = useState("");​  function appendAssistantText(text: string) {    if (!text) return;​    setMessages((current) => {      const next = [...current];      const lastIndex = next.length - 1;​      if (lastIndex >= 0 && next[lastIndex].role === "assistant") {        next[lastIndex] = {          ...next[lastIndex],          content: next[lastIndex].content + text,        };      }​      return next;    });  }​  async function sendMessage(event: FormEvent<HTMLFormElement>) {    event.preventDefault();​    const content = input.trim();    if (!content || loading) return;​    const outgoing: Message[] = [...messages, { role: "user", content }];    setMessages([...outgoing, { role: "assistant", content: "" }]);    setInput("");    setError("");    setLoading(true);​    try {      const response = await fetch("/api/chat", {        method: "POST",        headers: { "Content-Type": "application/json" },        body: JSON.stringify({ model, messages: outgoing }),      });​      if (!response.ok) {        const body = await response.json().catch(() => ({}));        throw new Error(body.error || `Request failed with ${response.status}`);      }​      if (!response.body) throw new Error("Streaming is not available.");​      const reader = response.body.getReader();      const decoder = new TextDecoder();      let buffer = "";​      while (true) {        const { value, done } = await reader.read();        buffer += decoder.decode(value, { stream: !done });​        const lines = buffer.split("\n");        buffer = lines.pop() ?? "";​        for (const line of lines) {          appendAssistantText(textFromSseLine(line));        }​        if (done) {          appendAssistantText(textFromSseLine(buffer));          break;        }      }    } catch (requestError) {      setError(        requestError instanceof Error ? requestError.message : "Request failed.",      );    } finally {      setLoading(false);    }  }​  return (    <main className="shell">      <section className="chat">        <header>          <p className="eyebrow">Next.js + CometAPI</p>          <h1>Multi-model chat</h1>          <label>            Model            <select              value={model}              onChange={(event) => setModel(event.target.value)}              disabled={loading}            >              {MODEL_OPTIONS.map((option) => (                <option key={option.id} value={option.id}>                  {option.label}                </option>              ))}            </select>          </label>        </header>​        <div className="messages" aria-live="polite">          {messages.length === 0 ? (            <p className="empty">Choose a model and send a message.</p>          ) : (            messages.map((message, index) => (              <article className={message.role} key={`${message.role}-${index}`}>                <b>{message.role === "user" ? "You" : "Assistant"}</b>                <p>{message.content || "…"}</p>              </article>            ))          )}        </div>​        <form onSubmit={sendMessage}>          <textarea            value={input}            onChange={(event) => setInput(event.target.value)}            placeholder="Ask something…"            rows={3}            maxLength={20_000}          />          <button disabled={loading || !input.trim()} type="submit">            {loading ? "Streaming…" : "Send"}          </button>        </form>​        {error ? <p className="error">{error}</p> : null}      </section>    </main>  );}

app/layout.tsx

import type { Metadata } from "next";import "./globals.css";​export const metadata: Metadata = {  title: "Multi-Model Chat",  description: "A streaming Next.js chat app powered by CometAPI.",};​export default function RootLayout({  children,}: Readonly<{ children: React.ReactNode }>) {  return (    <html lang="en">      <body>{children}</body>    </html>  );}

app/globals.css

:root {  color-scheme: dark;  font-family: Arial, sans-serif;  background: #07111f;  color: #eef4ff;}* { box-sizing: border-box; }body { margin: 0; }button, select, textarea { font: inherit; }​/* Layout shell and chat card */.shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; }.chat { width: min(820px, 100%); background: #0d1b2e; border: 1px solid #223957; border-radius: 20px; padding: 24px; }​/* Message list and bubbles */.messages { min-height: 360px; display: grid; align-content: start; gap: 12px; margin: 24px 0; }.messages article { max-width: 85%; padding: 12px 14px; border-radius: 14px; white-space: pre-wrap; }.user { justify-self: end; background: #164f8f; }.assistant { justify-self: start; background: #182a42; }​/* Form controls, buttons, and error states follow the same dark theme. */

6단계: 앱 실행 및 배포

npm run dev

http://localhost:3000을 열고 모델을 선택한 뒤 메시지를 보내세요. 프로덕션 Node.js 배포에서는 호스트의 서버 측 환경 설정에 COMETAPI_API_KEYCOMETAPI_BASE_URL을 추가한 다음 다음을 실행합니다:

npm run buildnpm run start

스트리밍 응답을 지원하는 호스팅 경로를 사용하세요. 정적 내보내기로는 /api/chat Route Handler를 실행할 수 없습니다.

스트리밍 라우트 테스트

개발 서버가 실행 중일 때, 백엔드를 직접 호출하세요:

curl -N http://localhost:3000/api/chat \  -H "Content-Type: application/json" \  -d '{    "model": "gemini-3.7-flash",    "messages": [      {"role": "user", "content": "Explain model routing in two sentences."}    ]  }'

성공한 요청은 Server-Sent Events를 반환합니다. 정확한 ID와 텍스트는 달라질 수 있지만, 스트림은 다음과 같은 형태를 따릅니다:

data: {"choices":[{"delta":{"content":"Model"}}]}​data: {"choices":[{"delta":{"content":" routing"}}]}​data: [DONE]

브라우저 파서는 각 SSE 이벤트를 읽고 choices[0].delta.content를 추출하여 도착하는 청크마다 assistant 메시지에 스트리밍 텍스트를 추가합니다.

일반적인 통합 오류

SymptomCauseFix
401 authentication error서버 측 키가 없거나 유효하지 않음COMETAPI_API_KEY를 설정하고, NEXT_PUBLIC_로 노출하지 마세요.
404 or wrong route기본 URL에 /v1 누락https://api.cometapi.com/v1 를 사용하세요.
400 unsupported model백엔드 허용 목록에 없는 ID정확한 라이브 텍스트 모델 ID를 사용하고 양쪽 선택기를 업데이트하세요.
The answer appears all at once호스트 또는 프록시가 이벤트 스트림을 버퍼링응답 변환을 비활성화하고 스트리밍을 지원하는 Node.js 배포를 사용하세요.

프로덕션 준비를 위한 채팅 백엔드 구성

  • 자체 사용자 인증을 수행하세요. 익명 트래픽에 비용을 소모하는 공개 라우트를 노출하지 마세요.
  • 사용자와 IP 기준으로 레이트 리미트를 적용하세요. 동시 스트림 수, 분당 요청 수, 메시지 수, 메시지 길이를 제한하세요.
  • 모델 허용 목록은 서버 측에 유지하세요. 브라우저 선택기는 편의일 뿐, 보안 경계가 아닙니다.
  • 배포 중 라이브 카탈로그를 검증하세요. GET https://api.cometapi.com/api/models를 조회하고, 구성된 ID가 예정 상태이거나, 사용 불가이거나, chat-completions 엔드포인트가 없으면 릴리스를 실패 처리하세요.
  • 라우트별 비용을 추적하세요. 선택된 모델, 요청 ID, 지연 시간, 토큰 사용량, 사용자 ID를 로깅하세요. 필요 시 CometAPI 대시보드에서 키 쿼터나 지출 상한을 설정하세요.
  • 연결 해제와 타임아웃을 처리하세요. request.signal을 보존하고 애플리케이션 타임아웃을 설정하며, 클라이언트가 떠나면 작업을 중단하세요.
  • 폴백으로 구성 오류를 숨기지 마세요. 400과 401 응답을 노출하세요. 요청 스키마가 호환되는 경우에 한해, 재시도 가능한 일부 실패에만 다른 모델을 사용하세요. 두 단계 폴백 체인은 CometAPI Model Fallback Guide를 참고하세요(CometAPI 기본 → CometAPI 폴백 모델 → 공식 제공자).
  • 로그를 마스킹하세요. API 키, 전체 프롬프트, 민감한 모델 출력을 프로덕션 오류 로그에 남기지 마세요.

하나의 Next.js 백엔드, 여러 모델 선택

모델 전환은 별도의 제공자 계정이 아니라 서버 정책에 속합니다. Next.js Route Handler는 하나의 CometAPI 키를 비공개로 유지하면서 요청마다 승인된 모델 ID를 받아, 하나의 OpenAI 호환 엔드포인트를 통해 선택된 모델을 스트리밍할 수 있습니다. 프런트엔드는 단순하게 유지되고, 백엔드는 접근, 검증, 가시성, 비용을 제어합니다. 동일한 계정과 키로 CometAPI의 네이티브 이미지 및 비디오 API(예: 이미지 생성을 위한 Flux, 비디오 생성을 위한 Kling)에도 접근할 수 있으므로, 이 채팅 백엔드는 두 번째 통합 없이 멀티모달 워크플로우로 확장될 수 있습니다.

모델 탐색에는 CometAPI public model directory를 사용하고, 자동 라우팅 검증에는 GET https://api.cometapi.com/api/models를 사용하세요.

자주 묻는 질문

하나의 Next.js 앱에서 여러 AI 모델을 사용할 수 있나요?

가능합니다. 서버 측 단일 라우트를 유지하고 각 요청에 허용 목록에 있는 모델 ID를 전달하세요. 브라우저 UI는 모델 선택을 제공할 수 있고, 서버는 어떤 ID가 허용되는지 제어합니다.

GPT, Claude, Gemini 간 전환은 어떻게 하나요?

선택한 모델 ID를 요청 본문에 보내세요. 서버가 허용 목록과 대조하여 검증하고, 동일한 채팅 페이로드를 OpenAI 호환 엔드포인트를 통해 선택된 모델로 전달합니다.

CometAPI API 키는 어디에 보관해야 하나요?

COMETAPI_API_KEY와 같은 서버 측 환경 변수에 보관하세요. NEXT_PUBLIC_ 변수나 클라이언트 코드로 노출하지 마세요.

OpenAI 호환 API면 모든 모델이 상호 대체 가능한가요?

아니요. 요청 형태는 이식 가능하지만, 지원 파라미터, 컨텍스트 한도, 도구 동작, 출력 스타일, 지연 시간, 가격은 모델마다 다릅니다. 허용 목록의 각 모델을 프로덕션 프롬프트로 테스트하세요.

Next.js Route Handler가 AI 응답을 스트리밍할 수 있나요?

예. 배포 플랫폼과 앞단의 프록시가 응답을 버퍼링하지 않는다면, Route Handler는 text/event-stream 콘텐츠 타입으로 업스트림 Server-Sent Events 스트림을 반환할 수 있습니다.

이 앱을 정적 Next.js 사이트로 배포할 수 있나요?

불가능합니다. 정적 내보내기는 서버 측 /api/chat Route Handler를 실행하거나 API 키를 보호할 수 없습니다. 스트리밍 응답을 지원하는 Node.js 또는 호환 서버 런타임을 사용하세요.

학습 계속하기

이 글을 다음 결정과 연결하세요.

모든 주제 보기
게시일 Aug 25, 2026
최종 업데이트 Aug 25, 2026
0 회 조회
명확성, 출처 표기 및 최신 API 용어에 대해 검토되었습니다.

AI 개발 비용을 20% 절감할 준비가 되셨나요?

몇 분 안에 무료로 시작하세요. 무료 체험 크레딧 제공. 신용카드 불필요.

더 보기