Nextjs, AI SDK를 이용한 ChatGPT 채팅창 구현

힘센캥거루·2025-01-03

최근 블로그를 꾸미고 기능을 만드는데 노력했다.

다시한번 내 코드를 봤더니 어떻게 만들었는지 기억이 하나도 나지 않는다는 사실을 발견했다.

TIL의 중요성을 다시한번 깨닫는 날이었다.

그래서 다시한번 남겨본다.

mycode

1. ChatGPT API 공식문서

일단 기본적인 설정들은 공식문서를 확인해보면 된다.

순서는 아래와 같다.

  1. API key를 환경 변수로 등록한다. Nextjs 환경에서는 루트 폴더에 .env.local 확장자 파일을 만든 후 OPENAI_API_KEY="api_key_here"를 하면 된다.
  2. npm install openai으로 openai 라이브러리를 설치한다.
  3. 코드를 짠다.

공식 문서에서 제시하는 코드는 아래와 같다.

import OpenAI from "openai";
const openai = new OpenAI();
 
const completion = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [
    { role: "system", content: "You are a helpful assistant." },
    {
      role: "user",
      content: "Write a haiku about recursion in programming.",
    },
  ],
});
 
console.log(completion.choices[0].message);

이걸 Nextjs에서 yarn dev로 작동해보면 잘 되는걸 볼 수 있다.

그래서 api route로 만들어서 하면 그냥 될 것 같지만 문제는 그리 쉽지 않다.

2. 한번 써보기

개발 초기에는 스타일은 다 쌩까고 기능부터 만드는게 더 빠르다.

그냥 form을 하나 만들고 FormData와 fetch로 간단한 채팅창을 만들어보자.

/src/app/test/page.tsx
"use client";
 
import { useRef, useState } from "react";
 
export default function Page() {
  const [value, setValue] = useState("");
  const messages = useRef<HTMLDivElement>(null);
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setValue("");
    const me = document.createElement("p");
    me.textContent = value;
    messages.current?.appendChild(me);
    const formData = new FormData();
    formData.append("content", value);
    const res = await fetch("/api/test", {
      method: "POST",
      body: formData,
    });
    const resJson = await res.json();
    const p = document.createElement("p");
    p.textContent = resJson.content;
    messages.current?.appendChild(p);
  }
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={value}
          onChange={(e) => setValue(e.currentTarget.value)}
        />
        <button type="submit"></button>
      </form>
      <div ref={messages} className="border-2">
        <p>대화창</p>
      </div>
    </div>
  );
}

이렇게 하면 간단한 input 하나와 대화창이 보인다.

input에 내용을 입력하면 fetch를 이용해 '/api/test'로 formdata를 전송하고 응답을 처리하는 것.

alt text

이번에는 api쪽의 코드를 짜보자.

/src/app/api/test/route.ts
import { NextResponse } from "next/server";
import OpenAI from "openai";
const openai = new OpenAI();
 
export async function POST(req: Request) {
  const formData = await req.formData();
  const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [
      { role: "system", content: "You are a helpful assistant." },
      {
        role: "user",
        content: formData.get("content") as string,
      },
    ],
  });
  console.log(completion.choices[0].message);
  return NextResponse.json(completion.choices[0].message);
}

request에서 메세지를 파싱 후 openai 라이브러리로 응답을 전송하고 받아온다.

그리고 다시 이 응답을 json으로 돌려주면 끝이다.

이제 대화를 해보자.

test

테스트를 해보면 알겠지만, 응답이 길면 오랫동안 기다려야 한다.

여기서 문제가 발생한다.

3. 문제점

문제는 우리의 인내심이 그리 강하지 않다는 것이다.

웹에서 응답이 빠르게 오지 않으면 쉽게 피로감을 느끼게 된다.

wait

Chat GPT에서 타이핑 하듯 데이터가 전송되는 것은 API가 chunk라는 분절된 단위로 응답하고 출력하기 때문이다.

이를 구현할 방법을 열심히 크롤링 해보았다.

  1. socket을 이용해 실시간 채팅 구현
  2. openai의 steam으로 응답 객체 리턴
  3. Vercel의 AI SDK를 이용해 구현

이 중에서 3번째가 가장 쉬워 보였다.

4. Vercel의 AI SDK 써보기

일단 모를땐 검색과 공식문서가 짱이다.

위의 주소로 들어가면 그냥 Nextjs를 위한 컴포넌트가 다 있다.

그럼 우리 프로젝트에다가 변경없이 갖다 넣으면 스트리밍이 가능하다.

일단 AI SDK 설치부터 하자.

yarn add ai @ai-sdk/openai zod

여기서 zod는 입력을 검증하기 위한 툴이다.

그리고 아래 경로의 폴더에 라우트를 설정한다.

/src/app/api/aisdk/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
 
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
 
export async function POST(req: Request) {
  const { messages } = await req.json();
 
  const result = streamText({
    model: openai("gpt-4o"),
    messages,
  });
 
  return result.toDataStreamResponse();
}

해당 코드에서 maxDuration은 최대 응답 시간이다.

30초 내에 응답을 완료하지 않으면 스트리밍을 중단한다.

그리고 클라이언트에게 데이터 스트리밍을 위한 객체를 응답한다.

이번에는 page 컴포넌트를 보자.

/src/app/aisdk
"use client";
 
import { useChat } from "ai/react";
 
export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit } = useChat({
    api: "/api/aisdk",
  });
  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {messages.map((m) => (
        <div key={m.id} className="whitespace-pre-wrap">
          {m.role === "user" ? "User: " : "AI: "}
          {m.content}
        </div>
      ))}
 
      <form onSubmit={handleSubmit}>
        <input
          className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
          value={input}
          placeholder="Say something..."
          onChange={handleInputChange}
        />
      </form>
    </div>
  );
}

원래는 useState나 이벤트 핸들러를 이용해 복잡하게 다뤄야 할 것들이 useChat 하나로 끝난다.

useChat에서 구조 분해 할당 된 변수들이 어디에 어떻게 쓰이는지 잘 보자.

ueChat 내부에는 여러 옵션션들을 객체로 받는다.

여기서는 API 주소를 넣었다.

이제 실행해보자.

aisdktest

정말 복붙 두번으로 간단하게 chatgpt를 스트리밍으로 구현했다.

공식 문서에서는 날씨 관련 AI를 만드는 과정도 있으니 참고 바란다.

5. useChat과 streamText

각 함수의 파라미터는 역시나 공식문서에 찾으면 나온다.

내가 써본 몇 가지만 한번 적어보려고 한다.

1. useChat

useChat 내부에 init 메세지를 입력할 수 있다.

이렇게 하면 페이지에 접속하자마자 메세지가 입력된다.

const { messages, input, handleInputChange, handleSubmit } = useChat({
  api: "/api/aisdk",
  initialMessages: [
    {
      id: "first-message",
      role: "assistant",
      content: "하고싶은 말 입력해봐",
    },
  ],
});

init

2. streamText

streamText에서는 내부에 원하는 응답에서 필요한 함수들을 정의할 수 있다.

chatgpt에게 "힘센캥거루를 메롱으로 변환해줄래?" 라고 하면 제대로 대답하지 못한다.

before

이럴 때는 메롱이라는 함수를 정의하고 streamText 내부에 넣어줄 수 있다.

/src/app/api/aisdk/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { z } from "zod";
 
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
 
export async function POST(req: Request) {
  const { messages } = await req.json();
 
  const result = streamText({
    model: openai("gpt-4o"),
    messages,
    tools: {
      reverse: tool({
        description: "(이름)를 메롱으로 변환하면?",
        parameters: z.object({
          person: z.string().describe("이름"),
        }),
        execute: async ({ person }) => {
          const newPerson = person.split("").reverse().join("");
          return {
            newPerson,
          };
        },
      }),
    },
  });
  return result.toDataStreamResponse();
}

tool 함수 내부에서 description은 함수에 대한 설명, parameters는 대화에서 이름에 해당하는 내용을 찾아 파라미터로 넣어주며, 이 파라미터를 excute에서 처리한 뒤 리턴해주는 것이다.

이렇게 코드를 짜고 질문을 하면 내가 입력한 문자열을 뒤집어서 리턴해준다.

alt text

대답의 최대 길이도 정의할 수 있다.

만약 maxTokens를 아래와 같이 지정하면 대답의 길이가 무척 짧아진다.

 const result = streamText({
    ...
    maxTokens:10,
    ...
  });

maxTokens

6. 후기

chatgpt를 이용한 서비스를 만드는 것이 엄청 어려울 것이라 생각했는데, 생각보다 어렵지 않았다.

좋은 라이브러리들이 많기에, 마음만 먹으면 얼마든지 만들 수 있을 것이다.

빨리 교육용 챗본을 만들고 서비스해보고 싶다.