[Buddies-NextJS] 개발기 #3

ZenTechie·2023년 8월 26일
0
post-thumbnail

이전에 GPT와 채팅 시, 같은 답변을 여러 번 보내거나 엉뚱한 답변을 하는 경우가 발생했었다.
프롬프트 적용 후에 발생한 문제라, 아마도 프롬프트 적용하는 코드에 문제가 있는 것 같아 이를 전면 수정했다.

수행한 것

0. 스플래쉬 화면 추가

카카오톡을 예시로 들면, 처음 앱을 진입했을 때 스플래쉬 화면이 뜬다. 그 이후로는 다른 화면으로 넘어가더라도 스플래쉬 화면이 뜨지 않는다.

이와 비슷한 혹은 동일하게 스플래쉬 화면을 적용하려고 했다. 위의 기능을 구현하기 위해 어떤 것을 사용해야 할지 생각해봤다.

  • 쿠키 : 브라우저를 닫더라도, 쿠키는 사라지지 않는다. ➡️ 쿠키의 생명주기는 만료일 동안이다.
  • 세션 : 브라우저를 닫으면, 세션은 사라진다. ➡️ 세션의 생명주기는 브라우저가 켜져있는 동안이다.

쿠키에 만료일을 설정해서 사용한다면, 브라우저를 닫을 때 제거되도록 해야한다.
이렇게 번거롭게 하지 말고, 세션이 위와 동일한 기능을 하기 때문에 세션을 사용하기로 했다.

처음에 생각한 것은, middleware.ts에서 session storage에 접근해서 세션 존재여부에 따라 스플래쉬 화면을 보여주려고 했다. 그러나, middlewareserver-side에서 동작한다.

session storage는 browser에서 접근이 가능하므로, server-side에서는 접근이 불가능하다.
따라서, middleware에서의 로직 처리는 불가능하다.

그래서, 각 Routelayout에서 session storage로 접근하기로 했다.
(layout이 client-side라는 전제)

전체적인 로직은 다음과 같다. (스플래쉬 화면 렌더링 여부state로 관리하고 default: false이다.)

  1. layout이 로드되었을 때, session이 있는지 확인한다.
    1.1. 만약 세션이 없다면, 세션을 추가하고 state=true로 설정한다.그리고 timeout을 설정하여, n초 후에 state=false로 설정하도록 한다.
    1.2. 만약 세션이 있다면, 브라우저를 닫지 않았다는 의미이므로 아무런 수행을 하지 않는다.

각 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이다.


1. Loading UI 생성

현재, 페이지 간 이동에서 약간의 딜레이가 발생한다. Loading UI가 없다면 페이지 이동이 정상적으로 요청됐는지 사용자는 알 수가 없다. 그렇기에 Loading UI가 필요하다.

NextJS에서 Loading UI를 생성하는 것은 간단하다. app 바로 밑에 loading.tsx를 생성하고 안에 코드를 채우면 된다.

2. 기존의 채팅 코드를 전면 수정

변경 전

  • 기존 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는 오픈 소스 라이브러리로 JSTS대화형 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', // 메시지를 처리하는 백엔드 주소, 원하는 경로로 수정 가능
    });

기존의 statehandleruseChat이 모두 담당한다.

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);
}

핵심 코드는 위와 같다.
확실히 이전과 다르게 간결하고 프롬프트가 잘 적용된 것을 볼 수 있었다.

3. 채팅 메시지 저장하기

세부적으로 데이터베이스(저장소)가 필요한 기능은 아래와 같다.

  • 각 사용자의 채팅 기록을 모두 저장하여 다시 접속했을 때도 해당 채팅 기록을 모두 렌더링 해줘야한다.
  • 4가지의 다른 챗봇이 있을 때 각 챗봇을 얼마나 사용했는지 보여줘야 하고, 각 챗봇과의 채팅 기록을 각각 저장해야한다.
  • 카카오톡과 같이 채팅 화면에서도 각 메시지를 보낸 시각이 옆에 있어야 하고, 날짜가 지났을 때도 표시해줘야 한다.

(소셜 로그인을 Firebase에 연동했기에 저장소도 Firebase가 제공하는 Cloud Firestore을 사용했다.)

구조는 다음과 같이 구성했다.

  • 컬렉션: users
    • 문서: {uid}
      • 필드: uid, email, ...
      • 서브컬렉션: chatHistory
        • 문서: {날짜} (예: 2023-08-25)
          • 서브컬렉션: 'prompt1', 'prompt2', 'prompt3', 'prompt4'
            • 문서: {시각} (예: 11시 ➡️ 23)
              • 필드: gpt(배열), user(배열)
                • 대화 객체(gpt) : {content, role, timestamp}
                • 대화 객체(user) : {content, role, timestamp}

구조는 설정했으니, 대화기록을 언제 저장할지 즉 시점이 중요했다.
이는 다행히 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으로 호출한 함수는 본인이 직접 작성해야 한다.

고민한 것

  • Cloud Firestore를 처음 사용하기에, 데이터 저장 코드를 작성하는게 까다로웠다. 무엇보다 구조를 어떻게 짜야하는지에 많은 고민을 했다.
    • 예를 들어, 시각을 나타내는 문서를 처음에는 'HH:mm'의 형식으로 하려고 했는데 만약 11:59에 사용자가 메시지를 보냈는데 GPT가 11:59에 보냈지만 약간의 딜레이로 인해 12:00에 보냈다고 표시가 되면 이를 어떻게 처리해야할지가 애매해서 그냥 시각으로 문서를 설정했다.
      • 근데 생각해보니 시각으로해도 똑같은 문제가 발생한다는 것을 뒤늦게 알았다..

        아마, 현재는 임시로 구조를 작성한 것이어서 나중에 수정을 할 것 같긴 하다.

  • timestamp를 따로 생성하지 않고, messages에 들어있는 createdAt을 백엔드로 보내서 처리하고 싶은데, createdAtundefined라고 나온다. ➡️ 추가적인 확인이 필요하다.
  • 스플래쉬 화면 렌더링 여부를 어떻게 처리해야 할지 ➡️ session으로 접근!
  • 페이지 간 이동에서 딜레이가 너무 심한 것 같아, 원인을 살펴보니 assets가 영향을 끼치는 것일 수 있다고 한다. 이를 해결하려면 이미지 최적화, 지연 로딩, 동적 임포트, CDN 등의 방법이 있는데 아마 CDN으로 해결할 것 같다.

배운 것

  • Next.js에서 POST요청에 body를 실어서 route handler에게 보냈을 때, route handler에서 body를 어떻게 추출할까?

    • 일단, POST 요청에 아래의 옵션을 추가한다.
    headers: {
            'Content-Type': 'application/json',
             },
     body: JSON.stringify({ data: chat, uid: uid, prompt: character }),

    ➡️ 'application/json'이 있어야, 받는쪽에서 json구조임을 파악할 수 있다고 한다.
    ➡️ 보내고자 하는 데이터를 JSON.stringify()를 사용하여 body에 넣는다.

    • 받는 쪽에서는 아래와 같이 body를 추출할 수 있다.
    export async function POST(req: NextRequest) {
    	const {data, uid, prompt} = await req.json();
    }
  • ContextServer Side에서 사용할 수 없다.

  • Error: Failed to parse URL from '~~'
    ➡️ NextJS에서 백엔드에 fetch, axios 요청을 보낼 때 발생하는 에러이다.
    상대경로가 아닌 절대경로를 적으면 정상적으로 동작한다.
    e.g.) fetch(http://localhost:3000/api/firebase/saveChatHistory)

  • Session Storage는 Server-side에서 접근할 수 없다.(쿠키도 마찬가지)

결과물

보미여르미가으리겨우리

db 확인1db 확인2

프롬프트가 잘 적용되어, 본인의 이름을 잘 인지하고 있는 모습을 볼 수 있다. 추가적인 대화를 했을 때, 약간의 어색한 답변이 있기도 했지만 이전 버그를 수정했다는 점에서 굉장히 고무적이다.

profile
데브코스 진행 중.. ~ 2024.03

0개의 댓글