- Published on
- 예제 Next.js AI Chatbot
1. 기본적인 AI 채팅 서비스
- Authors
- Name
- Pax Code
- Github
- @Github Repo
Table of Contents
학습 목표 및 소개
이제 ChatGPT를 기반으로 한 기본적인 AI 채팅 서비스를 구현해보겠습니다. 서버 측에서는 라우트 핸들러를 이용하고, 클라이언트 측에서는 useChat
훅을 통해 접근합니다.
ChatGPT에서 제공하는 기본적인 언어 모델은 실시간 정보를 제공하지는 않습니다.
이 페이지의 예제 실습을 위해서는 날씨 AI 챗봇 예제 소개에 대한 사전 학습이 필요합니다.
실행 화면
배울 내용
이번 실습을 통해 배울 수 있는 내용은 다음과 같습니다.
- 라우트 핸들러를 이용해 서버 측에서 데이터를 처리하고 이를 클라이언트에서 받아 사용자에게 보여주기
- 백엔드에서 버셀 AI SDK를 사용하여 OpenAI의 ChatGPT 모델과 연동하고 프론트엔드에 스트리밍으로 데이터를 제공하기
- 백엔드인 서버에서
streamText
함수를 이용하여 OpenAI의 언어 모델 접근 - 프론트엔드인 클라이언트에서
useChat
훅을 사용해 백엔드와 데이터를 주고받기
- 백엔드인 서버에서
- 프론트엔드에서 채팅 서비스를 위한 UI 구축하기하고 전송 받은 스트리밍 데이터를 실시간으로 보여주기
- 리액트의
useState
와useEffect
훅을 사용하여 마크다운 형식 텍스트를 HTML로 변환하여 보여주기
동작 구조
시퀀스 다이어그램으로 나타낸 전체 동작 구조는 아래와 같습니다.
클라이언트(Client)는 사용자가 실행한 웹 브라우저나 모바일 앱입니다. 서버(Server)는 Node.js 기반으로 동작되는 웹 서버로써 Next.js에서 제공하는 라우트 핸들로 방식으로 클라이언트에서 접근이 가능합니다. 보안을 위해 OpenAI와 같은 OpenAI(AI Provider)와 접근은 서버에서 이루어지며 OpenAI에게 받은 스트리잉 데이터를 클라이언트에게 다시 전송합니다.
다음과 같이 한글로 번역하고 교정할 수 있습니다:
환경 변수 설정
버셀 AI SDK를 기반으로 OpenAI의 대규모 언어 모델을 이용하기 위해 먼저 환경 변수를 설정해야 합니다.
OpenAI API 키 발급
OpenAI API 키는 OpenAI의 서비스에 접근하기 위한 인증 키입니다. 이 키를 통해 애플리케이션은 OpenAI의 다양한 언어 모델과 상호작용할 수 있습니다. API 키는 OpenAI 계정을 통해 발급받을 수 있습니다.
- OpenAI 웹사이트에 접속하여 계정을 만듭니다.
- 계정에 로그인한 후, API 키 섹션으로 이동합니다.
- "Create API Key" 버튼을 클릭하여 새로운 API 키를 생성합니다.
- 생성된 키를 복사하여 안전한 곳에 보관합니다.
OpenAI API 키를 환경 변수로 설정
프로젝트 루트 디렉터리에 .env.local
파일을 생성하고 OpenAI API 키를 추가합니다. 이 키는 OpenAI 서비스와 애플리케이션을 인증하는 데 사용됩니다.
.env.local
파일 생성:touch .env.local
.env.local
파일을 편집하여 다음과 같이 설정합니다:.env.localOPENAI_API_KEY=xxxxxxxxx
여기서
xxxxxxxxx
부분을 실제 OpenAI API 키로 교체하세요.
버셀 AI SDK의 OpenAI Provider는 기본적으로
OPENAI_API_KEY
환경 변수를 사용하도록 설정되어 있습니다.
서버 측 구현
이제 서버에서 동작되는 라우트 핸들러를 구현하겠습니다. app/
폴더 내에 api/
폴더를 생성하고 route.ts
파일을 생성하고 아래와 같이 구현합니다.
import { openai } from '@ai-sdk/openai';
import { convertToCoreMessages, streamText } from 'ai';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: openai('gpt-4o'),
messages: convertToCoreMessages(messages),
});
return result.toAIStreamResponse();
}
코드 설명
[Line 6] 라우트 핸들러로써 POST를 사용한 이유:
POST
메서드는 서버 측에서 데이터를 처리하고 이를 기반으로 동작을 수행하는 데 사용됩니다.- 클라이언트로부터 메시지를 받아 OpenAI의 모델을 사용해 응답을 생성하기 때문에,
POST
메서드를 사용합니다. - 이 메서드는 주로 데이터를 생성하거나 서버에 전달할 때 사용되며 보통 클라이언트로부터 입력 데이터를 받아 처리하는 경우에 적합합니다.
[Line 4]
export const maxDuration = 30;
설정 이유:- 이 설정은 Next.js에서 제공하는 라우트 세그먼트 설정 옵션입니다.
maxDuration
은 서버 측에서 실행되는 로직의 최대 실행 시간을 지정하는 설정입니다.- 기본적으로 Next.js는 서버 측 로직의 실행 시간을 제한하지 않지만, 배포 플랫폼(예: Vercel)에서 특정 실행 시간 제한을 설정할 수 있습니다.
- 이 경우
maxDuration
을 설정하여 해당 라우트 핸들러가 30초 이상 실행되지 않도록 제한할 수 있습니다. 이는 서버 리소스를 효율적으로 관리하고, 과도한 요청으로 인해 서버가 부담을 받지 않도록 하는 데 도움이 됩니다.
- 이 설정은 Next.js에서 제공하는 라우트 세그먼트 설정 옵션입니다.
[Line 1~2]
import
구문 설명:import { openai } from '@ai-sdk/openai';
:- 이 구문은 OpenAI와 통신하기 위해 필요한 기능을 가져오는 것입니다.
@ai-sdk/openai
패키지를 통해 OpenAI API를 사용할 수 있게 설정합니다.
- 이 구문은 OpenAI와 통신하기 위해 필요한 기능을 가져오는 것입니다.
import { convertToCoreMessages, streamText } from 'ai';
:convertToCoreMessages
함수는 클라이언트에서 받은 메시지를 OpenAI API와 호환되는 형식으로 변환하는 역할을 합니다.streamText
함수는 OpenAI 모델과 상호작용하여 텍스트 데이터를 스트리밍 방식으로 처리하고, 이를 클라이언트에 전달하는 기능을 합니다.
[Line 7]
req.json()
으로부터 메시지를 추출:const { messages } = await req.json();
:- 이 구문은 클라이언트로부터 전송된 요청의 본문을 JSON 형식으로 파싱한 후, 그 안에 포함된
messages
데이터를 추출합니다. 이messages
는 사용자가 입력한 텍스트나 기타 데이터가 포함된 객체일 것입니다.
- 이 구문은 클라이언트로부터 전송된 요청의 본문을 JSON 형식으로 파싱한 후, 그 안에 포함된
[Line 9~14]
streamText
함수를 사용하고 반환하는 과정:const result = await streamText({ model: openai('gpt-4o'), messages: convertToCoreMessages(messages) });
:- 이 구문은
streamText
함수를 호출하여 OpenAI의gpt-4o
모델을 사용해 처리된 결과를 받습니다. 이때,messages
는convertToCoreMessages
함수를 통해 OpenAI API와 호환되는 형식으로 변환됩니다.
- 이 구문은
return result.toAIStreamResponse();
:- 이 구문은 처리된 결과를 AI 스트림 응답 형식으로 변환한 후, 이를 클라이언트에 반환합니다. 스트리밍 방식의 응답은 대량의 데이터를 점진적으로 클라이언트로 전송하여 실시간 상호작용을 가능하게 합니다.
클라이언트 측 구현
마지막으로 클라이언트에서 동작하는 프론트엔드 코드를 구현하겠습니다.
재사용 가능한 공용 UI 컴포넌트 만들기
먼저 채팅 UI에서 사용할 제출(Submit) 버튼과, 채팅 중 서버에서 데이터를 받을 동안 사용자에게 보여줄 로딩 인디케이터를 구현하겠습니다.
프로젝트 최상단에 components
폴더를 생성하고, 공용 컴포넌트들을 이 폴더에 생성합니다.
제출 버튼
components
폴더에 submit-button.tsx
파일을 생성하고, 아래와 같이 구현합니다.
interface SubmitButtonProps {
isLoading: boolean;
isDisabled: boolean;
onStop: () => void;
}
function SubmitButton({ isLoading, isDisabled, onStop }: SubmitButtonProps) {
return (
<button
className={`px-1 text-sm text-white ${isLoading ? 'bg-red-500' : 'bg-blue-500'} rounded-r-lg`}
onClick={isLoading ? onStop : undefined}
type="submit"
disabled={isDisabled}
>
{isLoading ? '중지' : '전송'}
</button>
);
}
export default SubmitButton;
이 코드에서는 SubmitButton
컴포넌트를 정의하고 있습니다.
interface SubmitButtonProps
: 이 인터페이스는SubmitButton
컴포넌트에 전달되는 props의 타입을 정의합니다.isLoading
은 버튼이 로딩 중인지 여부를 나타내고,isDisabled
는 버튼이 비활성화 상태인지 나타내며,onStop
은 로딩 중일 때 버튼을 클릭하면 실행되는 함수입니다.SubmitButton
컴포넌트: 이 컴포넌트는 전달된 props에 따라 버튼의 텍스트와 동작을 결정합니다. 로딩 중일 때는 "중지" 버튼이 활성화되고, 그렇지 않을 때는 "전송" 버튼이 나타납니다.
로딩 인더케이터
components
폴더에 loading-indicator.tsx
파일을 생성하고, 아래와 같이 구현합니다. 이 코드는 LoadingIndicator
라는 컴포넌트를 정의하고 있습니다. 이 컴포넌트는 로딩 상태를 시각적으로 표시하는 역할을 합니다.
const LoadingIndicator = () => (
<div className="flex items-center justify-center w-full h-8 max-w-md p-2 mb-8">
<div className="w-8 h-8 mr-3 border-t-2 border-b-2 border-blue-500 rounded-full animate-spin"></div>
로딩 중...
</div>
);
export default LoadingIndicator;
이 컴포넌트는 로딩 중임을 사용자에게 알리기 위해 다른 컴포넌트나 페이지에서 애니메이션 스피너와 "로딩 중..."이라는 텍스트를 화면에 표시합니다.
useChat
훅으로 라우트 핸들러와 연동하기
채팅 UI 구성하고 app/
폴더에서 usechat/
폴더를 생성하고 아래와 같이 page.tsx
파일을 구현합니다.
'use client';
import { useChat } from 'ai/react';
import LoadingIndicator from '@/components/loading-indicator';
import SubmitButton from '@/components/submit-button';
export default function Chat() {
const { messages, input, isLoading, handleInputChange, handleSubmit, stop } =
useChat({
maxToolRoundtrips: 5,
});
return (
<div className="flex flex-col items-center justify-between w-full h-screen">
<div className="w-full h-full max-w-md p-6 bg-white rounded-lg shadow-md">
<div className="flex flex-col justify-between h-full">
<div className="overflow-y-auto">
{messages.map((m) => (
<div key={m.id} className="space-y-4">
<strong>{m.role}:</strong>
{ m.content }
</div>
))}
</div>
{isLoading && <LoadingIndicator />}
<form onSubmit={handleSubmit} className="flex w-full mt-4">
<input
value={input}
onChange={handleInputChange}
className="w-full p-3 border border-gray-300 rounded-l-lg focus:outline-none focus:ring focus:border-blue-300"
disabled={isLoading}
/>
<SubmitButton isLoading={isLoading} isDisabled={!input.trim()} onStop={stop} />
</form>
</div>
</div>
</div>
);
}
이 코드에서는 useChat
훅을 사용하여 채팅 UI를 구성하고 사용자가 입력한 메시지를 서버와 주고받는 기능을 구현하고 있습니다. 각 부분을 단계별로 설명하겠습니다.
useChat
훅을 이용한 채팅 UI 구성
useChat
훅은 채팅 애플리케이션에서 서버와 클라이언트 간의 상호작용을 관리하는 데 사용됩니다. 이 훅은messages
,input
,isLoading
,handleInputChange
,handleSubmit
,stop
등의 상태와 함수를 반환합니다.messages
: 현재까지 주고받은 메시지의 배열입니다.input
: 사용자가 입력한 현재 텍스트입니다.isLoading
: 서버로부터 응답을 기다리는 동안 로딩 상태를 나타냅니다.handleInputChange
: 입력 필드의 값이 변경될 때 호출되는 함수입니다.handleSubmit
: 폼이 제출될 때 호출되는 함수로, 메시지를 전송합니다.stop
: 로딩 중일 때 작업을 중지하는 함수입니다.
form
의 요소와 useChat
의 연결
- 사용자가 입력한 텍스트를 관리하고 서버로 전송하는 역할을 합니다.
<input>
요소는 사용자의 입력을 받아value={input}
,onChange={handleInputChange}
속성을 통해useChat
훅과 연결됩니다.input
값은 사용자가 입력하는 텍스트를 나타내고,handleInputChange
함수는 입력 값이 변경될 때마다 호출됩니다.<form onSubmit={handleSubmit}>
: 사용자가 메시지를 제출할 때handleSubmit
함수가 호출되어, 서버로 메시지를 전송합니다.<SubmitButton>
컴포넌트는 로딩 상태일 때 버튼의 텍스트와 동작을 관리하며,isLoading
상태와stop
함수를 통해 제어됩니다.
map
함수로 렌더링
메시지 목록을 messages.map((m) => ...)
구문을 통해messages
배열에 있는 각 메시지를 순회하며 UI에 렌더링합니다.- 각 메시지는
m.id
를 키로 사용하고,m.role
과m.content
를 통해 메시지의 발신자와 내용을 표시합니다. overflow-y-auto
클래스를 사용해 메시지가 많아지면 스크롤이 가능하도록 설정되어 있습니다.
이 코드에서는 useChat
훅을 통해 클라이언트와 서버 간의 메시지 전송을 관리하며, 입력 폼과 메시지 리스트를 통해 채팅 UI를 구성하고 있습니다. isLoading
상태를 이용해 로딩 중일 때 로딩 인디케이터를 보여주는 기능도 포함되어 있습니다.
위 코드를 실행하면 아래와 같이 동작됩니다.
정상적으로 동작하지만, ChatGPT 언어 모델이 마크다운 형식으로 텍스트를 출력하기 때문에 읽기 불편할 수 있습니다.
마크다운 텍스트를 HTML로 변환하기
아래 코드에서는 ChatGPT 언어 모델이 마크다운 형식으로 출력한 텍스트를 HTML로 변환하여 화면에 표시하는 기능을 추가했습니다.
'use client';
import { useEffect, useState } from 'react';
import { Message, useChat } from 'ai/react';
import LoadingIndicator from '@/components/loading-indicator';
import SubmitButton from '@/components/submit-button';
import { remark } from 'remark';
import html from 'remark-html';
import remarkGfm from 'remark-gfm';
interface ExtendedMessage extends Message {
htmlContent?: string;
}
export default function Chat() {
const { messages, input, isLoading, handleInputChange, handleSubmit, stop } =
useChat({
maxToolRoundtrips: 5,
});
const [renderedMessages, setRenderedMessages] = useState<ExtendedMessage[]>([]);
useEffect(() => {
const processMessages = async () => {
const updatedMessages = await Promise.all(
messages.map(async (m) => {
const processedContent = await remark()
.use(remarkGfm)
.use(html)
.process(m.content);
return { ...m, htmlContent: processedContent.toString() };
})
);
setRenderedMessages(updatedMessages);
};
processMessages();
}, [messages]);
return (
<div className="flex flex-col items-center justify-between w-full h-screen">
<div className="w-full h-full max-w-md p-6 bg-white rounded-lg shadow-md">
<div className="flex flex-col justify-between h-full">
<div className="overflow-y-auto">
{renderedMessages.map((m) => (
<div key={m.id} className="space-y-4">
<strong>{m.role}:</strong>
<div
className="prose"
dangerouslySetInnerHTML={{ __html: m.htmlContent || m.content }}
/>
</div>
))}
</div>
{isLoading && <LoadingIndicator />}
<form onSubmit={handleSubmit} className="flex w-full mt-4">
<input
value={input}
onChange={handleInputChange}
className="w-full p-3 border border-gray-300 rounded-l-lg focus:outline-none focus:ring focus:border-blue-300"
disabled={isLoading}
/>
<SubmitButton isLoading={isLoading} isDisabled={!input.trim()} onStop={stop} />
</form>
</div>
</div>
</div>
);
}
이 코드에서는 ChatGPT 언어 모델이 마크다운 형식으로 출력한 텍스트를 HTML로 변환하여 화면에 표시하는 기능을 추가했습니다. 변경된 부분을 중심으로 설명하겠습니다.
[Line 9~11]
remark
,remark-html
,remark-gfm
패키지 추가:- 이 패키지들은 마크다운 텍스트를 HTML로 변환하기 위해 사용됩니다.
remark
는 마크다운을 파싱하는 데 사용되며,remark-html
은 파싱된 마크다운을 HTML로 변환합니다.remark-gfm
은 GitHub Flavored Markdown(GFM) 형식을 지원하기 위해 사용됩니다.
[Line 13~15]
ExtendedMessage
인터페이스:- 기존의
Message
인터페이스를 확장하여, 변환된 HTML 콘텐츠를 저장할htmlContent
속성을 추가했습니다. - 이를 통해 각 메시지의 원본 마크다운과 변환된 HTML을 모두 관리할 수 있습니다.
interface ExtendedMessage extends Message { htmlContent?: string; }
- 기존의
[Line 3, 23, 25~39]
useState
와useEffect
훅을 사용한 메시지 처리:renderedMessages
상태를 추가하여 변환된 메시지 목록을 관리합니다.useEffect
훅을 사용하여messages
가 업데이트될 때마다 각 메시지의 내용을 마크다운에서 HTML로 변환하고, 변환된 결과를renderedMessages
상태에 저장합니다.- 이 변환 과정은
remark()
와.use(remarkGfm)
및.use(html)
을 통해 이루어집니다.
const [renderedMessages, setRenderedMessages] = useState<ExtendedMessage[]>([]); useEffect(() => { const processMessages = async () => { const updatedMessages = await Promise.all( messages.map(async (m) => { const processedContent = await remark() .use(remarkGfm) .use(html) .process(m.content); return { ...m, htmlContent: processedContent.toString() }; }) ); setRenderedMessages(updatedMessages); }; processMessages(); }, [messages]);
[Line 46~54] 렌더링된 메시지 표시:
renderedMessages
배열을 순회하며 각 메시지를 렌더링합니다.dangerouslySetInnerHTML
속성을 사용하여 변환된 HTML을 안전하게 출력합니다. 여기서m.htmlContent
가 있으면 이를 출력하고, 없으면 원본 마크다운을 출력합니다.
{renderedMessages.map((m) => ( <div key={m.id} className="space-y-4"> <strong>{m.role}:</strong> <div className="prose" dangerouslySetInnerHTML={{ __html: m.htmlContent || m.content }} /> </div> ))}
이 변경을 통해 마크다운 형식으로 출력된 텍스트를 사용자가 읽기 쉽게 HTML 형식으로 변환하여 화면에 표시할 수 있습니다. 이를 통해 코드가 보다 사용자 친화적인 인터페이스를 제공하게 됩니다.
위 코드를 실행하면 아래와 같이 동작됩니다.
globals.css에 리스트 스타일 적용하기
리스트 표기를 개선하기 위해 app/globals.css
파일에 아래의 코드를 추가합니다.
@tailwind base;
@tailwind components;
@tailwind utilities;
.prose ul {
list-style-type: square;
padding-left: 1.25rem;
}
.prose ol {
list-style-type: decimal;
padding-left: 1.25rem;
}
.prose ul li::marker,
.prose ol li::marker {
color: #3b82f6;
}
prose
클래스:.prose
는 Tailwind CSS의@tailwindcss/typography
플러그인에서 제공하는 유틸리티 클래스입니다. 이 클래스를 사용하면 텍스트 콘텐츠에 기본적인 서식이 적용됩니다.
리스트 스타일 설정:
.prose ul
및.prose ol
은 각각 순서 없는 리스트(ul
)와 순서 있는 리스트(ol
)에 대해 스타일을 정의합니다.list-style-type: square;
은 순서 없는 리스트 항목을 사각형(square
)으로 표시하며,list-style-type: decimal;
은 순서 있는 리스트 항목을 숫자(decimal
)로 표시합니다.padding-left: 1.25rem;
은 리스트 항목에 들여쓰기를 적용하여 가독성을 높입니다.
리스트 마커 색상 설정:
.prose ul li::marker
와.prose ol li::marker
는 리스트 항목의 마커(기호 또는 번호)의 색상을 설정합니다.color: #3b82f6;
은 마커의 색상을 Tailwind CSS에서 제공하는blue-500
색상으로 설정하여 스타일을 통일합니다.
이 설정을 통해, 마크다운 텍스트가 HTML로 변환되어 렌더링될 때 리스트 항목의 스타일이 일관되게 아래 실행 화면처럼 적용됩니다.
다음 과정 안내
위 채팅 서비스에서는 실시간 날씨 정보를 제공하지는 않습니다.
다음 과정인 채팅 서비스에 실시간 날씨 연동: 클라이언트에서 날씨 컴포넌트 렌더링에서는 OpenAI의 도구(함수 호출) 기능을 활용하여 채팅에서 날씨 실시간 정보를 물어보는 문맥이 있으면 외부 날씨 서비스와 연동하여 실시간 정보를 UI 컴포넌트로 보여주는 예제를 설명합니다.