이전에 GPT와 채팅 시, 같은 답변을 여러 번 보내거나 엉뚱한 답변을 하는 경우가 발생했었다.
프롬프트 적용 후에 발생한 문제라, 아마도 프롬프트 적용하는 코드에 문제가 있는 것 같아 이를 전면 수정했다.
카카오톡을 예시로 들면, 처음 앱을 진입했을 때 스플래쉬 화면이 뜬다. 그 이후로는 다른 화면으로 넘어가더라도 스플래쉬 화면이 뜨지 않는다.
이와 비슷한 혹은 동일하게 스플래쉬 화면을 적용하려고 했다. 위의 기능을 구현하기 위해 어떤 것을 사용해야 할지 생각해봤다.
쿠키
: 브라우저를 닫더라도, 쿠키는 사라지지 않는다. ➡️ 쿠키의 생명주기는 만료일 동안이다.세션
: 브라우저를 닫으면, 세션은 사라진다. ➡️ 세션의 생명주기는 브라우저가 켜져있는 동안이다.쿠키에 만료일을 설정해서 사용한다면, 브라우저를 닫을 때 제거되도록 해야한다.
이렇게 번거롭게 하지 말고, 세션이 위와 동일한 기능을 하기 때문에 세션을 사용하기로 했다.
처음에 생각한 것은, middleware.ts
에서 session storage에 접근해서 세션 존재여부에 따라 스플래쉬 화면을 보여주려고 했다. 그러나, middleware
는 server-side
에서 동작한다.
session storage는 browser
에서 접근이 가능하므로, server-side
에서는 접근이 불가능하다.
따라서, middleware
에서의 로직 처리는 불가능하다.
그래서, 각 Route
의 layout
에서 session storage로 접근하기로 했다.
(layout이 client-side라는 전제)
전체적인 로직은 다음과 같다. (스플래쉬 화면 렌더링 여부는 state
로 관리하고 default: false
이다.)
layout
이 로드되었을 때, session
이 있는지 확인한다.state=true
로 설정한다.그리고 timeout
을 설정하여, n
초 후에 state=false
로 설정하도록 한다.각 layout의 코드는 아래와 같다.
// layout.tsx
export default function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
const [showSplash, setShowSplash] = useState(false);
useEffect(() => {
const sessionValue = sessionStorage.getItem('isInit');
if (!sessionValue) {
sessionStorage.setItem('isInit', 'true'); // 세션 값에 true를 저장
setShowSplash(true); // 스플래시 화면을 보여줌
const timer = setTimeout(() => {
setShowSplash(false);
}, 3000); // 3초 후에 setShowSplash(false)가 실행되면서 스플래시 화면이 사라짐
return () => clearTimeout(timer); // 컴포넌트가 언마운트되면 타이머를 제거
}
}, []);
return (
<>
<Splash showSplash={showSplash} />
{children}
</>
);
}
/
를 담당하는 route의 layout도 동일하게 하려고 했으나, 이는 server-side
로 남기기 위해서 다른 로직을 생각했다. 생각한 로직은, /
에 접근하면 세션 존재여부에 상관없이 항상 스플래쉬 화면을 보여주는 것이다.
이는 /
에 해당하는 page.tsx
에 작성했다.
// page.tsx
export default function Home() {
const [showSplash, setShowSplash] = useState(true); // 항상 보여줄 것이기에 true
useEffect(() => {
const timer = setTimeout(() => {
setShowSplash(false); // 3초가 지나면, false로 설정
}, 3000);
return () => clearTimeout(timer); // 컴포넌트가 언마운트되면 타이머를 제거
}, []);
return showSplash ? (
<Splash />
) : (보여줄 컴포넌트);
}
/
에 해당하는 layout.tsx 즉, Root Layout을 client로 만든다면 각 route마다 layout을 작성하지 않아도 된다.
NextJS에서는 Root Layout이 모든 다른 route에 영향을 주기 때문이다.NextJS 13 App router에서 렌더링 로직은, layout.tsx ➡️ page.tsx이다.
현재, 페이지 간 이동에서 약간의 딜레이가 발생한다. Loading UI
가 없다면 페이지 이동이 정상적으로 요청됐는지 사용자는 알 수가 없다. 그렇기에 Loading UI
가 필요하다.
NextJS에서 Loading UI를 생성하는 것은 간단하다. app
바로 밑에 loading.tsx
를 생성하고 안에 코드를 채우면 된다.
ChatSection.tsx(채팅 관련 state, handler 함수)
const [userMsg, setUserMsg] = useState<string[]>([]); // User가 보낸 모든 메시지
const [inputMsg, setInputMsg] = useState<string>(''); // User가 입력한 메시지
const [gptMsg, setGptMsg] = useState<string[]>([]); // GPT가 답변한 모든 메시지
const [totalMsg, setTotalMsg] = useState<totalStyle>([]);
const getMessage = async () => {
try {
const response = await fetch('/api/completion', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userMessages: userMsg,
gptMessages: gptMsg,
}),
});
const data = await response.json();
// GPT가 답변한 메시지를 gptMsg에 추가
setGptMsg((prev) => [...prev, data.assistant]);
setTotalMsg((prev) => [...prev, { type: 'gpt', text: data.assistant }]);
} catch (error) {
console.log(error);
}
};
const sendMessageHandler = useCallback(() => {
if (!inputMsg.trim().length) {
return;
}
const data = inputMsg;
setUserMsg((prev) => [...prev, data]);
setTotalMsg((prev) => [...prev, { type: 'user', text: data }]);
setInputMsg('');
getMessage();
}, [inputMsg]);
const inputHandler = (value: string) => {
setInputMsg((prev) => value);
};
return ( ... );
/api/completion/route.ts
export async function POST(request: Request) {
const { userMessages, gptMessages } = await request.json();
let messages: any = [...]; // 미리 설정할 프롬프트가 저장된 배열
try {
while (userMessages.length != 0 || gptMessages.length != 0) {
if (userMessages.length != 0) {
messages.push(
JSON.parse(
'{"role": "user", "content": "' +
String(userMessages.shift()).replace(/\n/g, '') +
'"}'
)
);
}
if (gptMessages.length != 0) {
messages.push(
JSON.parse(
'{"role": "assistant", "content": "' +
String(gptMessages.shift()).replace(/\n/g, '') +
'"}'
)
);
}
}
const chatCompletion: any = await openai.createChatCompletion({
model: 'gpt-3.5-turbo',
messages: messages,
temperature: 0.75,
});
const gptAnswer = chatCompletion.data.choices[0].message['content'];
// json 형식을 받는다.
return NextResponse.json({ assistant: gptAnswer });
} catch (error) {
console.log(error);
}
}
일단 기존의 코드는 리팩토링이 전혀 되어있지 않아서, 가독성이 좋지 않았다. 또한, 의도치 않은 채팅 흐름이 이어져서 이를 수정하려 해도 state끼리 많이 엮여있었기에 어떻게 고쳐야할 지 감이 잡히지 않았다.
그리고, 답변을 기다릴 때도 Streaming
형식이 아닌, Blocking
형식이었기에 사용자 경험(?) 측면에서 좋지 않았다.
Streaming을 포함하여 기존의 버그를 해결하기 위해 해결책을 찾다가, Vercel AI SDK를 찾았다.
Vercel AI SDK는 오픈 소스 라이브러리로
JS
와TS
로 대화형 streaming 유저 인터페이스를 만들 수 있게 도와준다. React, Next.js, Svelte, Vue, Nuxt에서 사용할 수 있다.
적용 후 코드는, 아래와 같이 매우 심플해졌고 가독성이 좋아졌다.
(기존의 50줄 코드를 8줄로 줄일 수 있었다)
const { messages, input, isLoading, handleInputChange, handleSubmit } =
useChat({
body: {
uid: uid,
id: Number(characterId),
}, // 원하는 데이터를 body에 실어 보낼 수 있다.(옵션)
api: '/api/chat', // 메시지를 처리하는 백엔드 주소, 원하는 경로로 수정 가능
});
기존의 state
와 handler
는 useChat
이 모두 담당한다.
useChat
은 대화형 유저 인터페이스를 쉽게 구축할 수 있게 해준다. 사용하는 AI Provider로부터의 채팅 메시지를 Streaming 형식으로 설정해주고, 사용자가 입력한 채팅의 상태를 관리해주며, 새로운 답변이 도착하면 UI도 자동적으로 업데이트 해준다.
기본적으로 useChat이 제공하는 option이 많은데 사용한 것만 살펴보자면,
messages
: 사용자의 메시지와 GPT의 메시지가 담겨있는 배열(type: Message[]
)이다. type Message = {
id: string; // 메시지의 ID
createdAt?: Date; // 메시지가 언제 생성되었는지
content: string; // 메시지 내용
role: 'system' | 'user' | 'assistant' | 'function'; // 역할 = OpenAI GPT API의 role과 같다.
/**
* If the message has a role of `function`, the `name` field is the name of the function.
* Otherwise, the name field should not be set.
*/
name?: string; // 메시지 이름
/**
* If the assistant role makes a function call, the `function_call` field
* contains the function call name and arguments. Otherwise, the field should
* not be set.
*/
function_call?: string | ChatCompletionMessage.FunctionCall; // role이 assistant일 때 호출할 함수
};
```
input
: 사용자가 input필드에 입력한 value를 의미한다.isLoading
: 요청에 대한 처리가 진행 중인지(즉, 답변을 하고 있는지)handleInputChange
: input필드에 입력한 value를 최신의 상태로 업데이트하는 함수handleSubmit
: input필드에 입력한 value를 전송하는 함수(즉, 사용자의 메시지를 GPT에 송신)api
: 메시지에 대한 처리를 할 백엔드 주소(default: /api/chat
➡️ 원하는 경로로 수정할 수 있다.)메시지를 처리하는 백엔드의 코드는 아래와 같이 변경되었다.
let chatWith : any = [...]; // 프로젝트에 필요한 프롬프트
export const runtime = 'edge';
export async function POST(request: Request) {
const { uid, id, messages } = await request.json();
// 프롬프트 적용을 위해, 프롬프트 배열에 메시지(채팅) 추가
messages.forEach((message: any) => {
chatWith.push(message);
});
const response: any = await openai.createChatCompletion({
model: 'gpt-3.5-turbo',
stream: true,
messages: messages,
temperature: 0.5,
top_p: 0.5,
});
if (!response.ok) {
return new Response(await response.text(), {
status: response.status,
});
}
// Convert the response into a friendly text-stream
// const stream = OpenAIStream(response);
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}
핵심 코드는 위와 같다.
확실히 이전과 다르게 간결하고 프롬프트가 잘 적용된 것을 볼 수 있었다.
세부적으로 데이터베이스(저장소)가 필요한 기능은 아래와 같다.
(소셜 로그인을 Firebase에 연동했기에 저장소도 Firebase가 제공하는 Cloud Firestore을 사용했다.)
구조는 다음과 같이 구성했다.
구조는 설정했으니, 대화기록을 언제 저장할지 즉 시점이 중요했다.
이는 다행히 AI SDK에서 OpenAIStream이 기능을 제공했다.
그래서, 코드 일부를 아래와 같이 수정했다.
const stream = OpenAIStream(response, {
onStart: async () => {
// This callback is called when the stream starts
// You can use this to save the prompt to your database
await saveChatHistoryInToFirebaseDatabase(uid, character, messages);
},
onCompletion: async (completion: string) => {
// This callback is called when the stream completes
// You can use this to save the final completion to your database
await saveCompletionInToFirebaseDatabase(uid, character, completion);
},
});
// Respond with the stream
return new StreamingTextResponse(stream);
onStart
➡️ 사용자의 메시지를 저장
onCompletion
➡️ GPT의 메시지를 저장
+) completion
은 GPT의 메시지를 의미한다. 자동으로 매개변수가 전달된다.
여기서 중요한 것은, async-await
이다.
메시지가 모두 저장이 된 뒤에 대화를 이어가야하므로 비동기적으로 처리해야한다.
await으로 호출한 함수는 본인이 직접 작성해야 한다.
아마, 현재는 임시로 구조를 작성한 것이어서 나중에 수정을 할 것 같긴 하다.
createdAt
을 백엔드로 보내서 처리하고 싶은데, createdAt
이 undefined
라고 나온다. ➡️ 추가적인 확인이 필요하다.session
으로 접근!Next.js에서 POST요청에 body를 실어서 route handler에게 보냈을 때, route handler에서 body를 어떻게 추출할까?
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ data: chat, uid: uid, prompt: character }),
➡️ 'application/json'이 있어야, 받는쪽에서 json구조임을 파악할 수 있다고 한다.
➡️ 보내고자 하는 데이터를 JSON.stringify()를 사용하여 body에 넣는다.
export async function POST(req: NextRequest) {
const {data, uid, prompt} = await req.json();
}
Context
는 Server Side
에서 사용할 수 없다.
Error: Failed to parse URL from '~~'
➡️ NextJS에서 백엔드에 fetch, axios 요청을 보낼 때 발생하는 에러이다.
상대경로가 아닌 절대경로를 적으면 정상적으로 동작한다.
e.g.) fetch(http://localhost:3000/api/firebase/saveChatHistory)
Session Storage는 Server-side에서 접근할 수 없다.(쿠키도 마찬가지)
보미 | 여르미 | 가으리 | 겨우리 |
---|---|---|---|
![]() | ![]() | ![]() | ![]() |
db 확인1 | db 확인2 |
---|---|
![]() | ![]() |
프롬프트가 잘 적용되어, 본인의 이름을 잘 인지하고 있는 모습을 볼 수 있다. 추가적인 대화를 했을 때, 약간의 어색한 답변이 있기도 했지만 이전 버그를 수정했다는 점에서 굉장히 고무적이다.