6 분 소요

웹뷰

배경 🧑🏻‍💻

요즘 Chat GPT가 핫한 것은 개발자라면 다 알것이다. 확실히 Chat GPT를 사용한 서비스들도 대거 출현하고 있는 요즘 Chat GPT를 활용하여 단톡방을 만드는 프로젝트가 있어서 참가하게 되었다. 추가로 이번 프로젝트는 Next.js 기반으로 진행한다고 하여 꾸준히 공부해오던 Next.js를 직접 사용할 수 있는 기회였다.

프로젝트 Github 링크


활용한 라이브러리와 그 이유 📁

1. Typescript

Next.js는 타입스크립트 기반의 FrameWork 이다보니 타입스크립트와 궁합이 잘 맞는다. 타입스크립트를 활용함으로서 next 기능들의 .을 찍었을때 자동완성 되는 쾌감은 경험해보면 타입스크립트를 계속 사용하는 사람들은 있어도 한번만 사용한 사람은 없다는 것을 알게 될 것이다.

추가로 강력한 타입 시스템으로 컴파일 단계에서 에러를 잡을 수 있다는 장점은 누구나 다 알 것이다. 항상 느끼는 것 이지만 타입스크립트는 함수를 작성할때 return 타입과 매개 변수 타입을 생각하면서 작성 할 수 있다는 장점이 정말 코딩할때 생각을 더 많이 하게 해준다.


2. Storybook

스토리북은 대표적인 CDD(컴포넌트 주도 개발)에 사용되는 툴이다. 독립적인 환경에서 컴포넌트를 개발하면서 각 컴포넌트들은 다른 페이지나 컴포넌트들과의 결합도를 낮추도록 개발을 진행 할 수 있다.

추가로 각 컴포넌트에 대한 문서를 작성 할 수 있다. 개발자들간 디자이너들과의 협업에 있어서 효율적으로 소통을 할 수 있는 매개체가 될 수 있다.

프로젝트 규모가 작은 만큼 컴포넌트 하나하나 고민하며 개발하기 위해 사용하였다.


3. tailwindCSS

기존에는 css-in-js 라이브러리들만 사용하여 개발을 진행해왔다. 그만큼 편했다. 그래서 이번에는 유틸리티성 classname의 집합체인 tailwindcss를 사용해 보기로 했다. 기존에 사용하던 css-in-js와 어떤부분이 다른지 어떤 장,단점이 있는지 파악하고 싶었다. npmtrends tailwindcss도 생각보다 많이 사용하고 있음에 놀랐다.


4. next.js

이번 프로젝트 기반 기술이긴 했지만, next.js는 react기반 풀스택 프레임워크이다. 서버 사이드 렌더링을 기본적으로 지원하기 때문에 html document자체를 서버에서 렌더링하여 클라이언트에 노출되기 때문에 기본적으로 SEO에 좋다.

프로젝트 페이지의 성격에 따라 SSR, CSR, SSG, ISR을 적용할 수 있다는 최대의 장점이 있다. 추가로 node.js 서버를 기본적으로 제공하기 때문에 백엔드 API를 작성할 수 있다. 민감한 정보나 API들은 next API Routes에 작성함으로서 Next.js 서버에서 외부 API통신을 함으로써 브라우저에서는 민감한 API endpoint나 정보들을 노출시키지 않을 수 있다.

마지막으로 next.js가 지원하는 최적화된 컴포넌트들 (Image, Link 등등), 파일 기반 라우팅 역시 next.js를 선택하게 되는 이유이다.


5. indexedDB

이번에 처음으로 indexedDB를 사용해 보았다. indexedDB는 cookie, localstorage와 비슷한 웹 스토리지 종류중 하나이며, cookie와 localstorage 보다 훨씬 더 많은 용량을 저장할 수 있고, 문자열 이외의 자료형들도 저장이 가능한 장점이 있다.

cookie나 localstorage보다는 난이도가 살짝높고, 비동기식으로 동작하기 때문에 성능이 좋다. 추가로 민감한 정보가 아니라면 웹 스토리에 저장하는 방식은 서버를 안 거쳐도 되기때문에 훨씬 빠르게 데이터를 보여줄 수 있다는 장점도 있다.

이번에 indexedDB를 사용한 이유에 앞서 웹 스토리지를 이용해야했던 이유는

  1. 채팅방에서 나갔다 들어오더라도 해당 채팅내용이 그대로 유지되어야 한다.
  2. 채팅방 목록들도 웹을 종료했다 다시 키더라도 유지되어야 한다.

위 조건을 구현하기 위해서는 DB가 있어야했는데 MongoDB나 SQL을 사용하기에는 너무 오버 스펙이었기 때문에 웹 스토리지를 사용했다.

그 중에서도 indexedDB를 사용한 이유는 기존 localStorage는 문자열만 저장 가능하기 때문에 JSON.stringify, JSON.Parse로 일일히 처리해줘야하는게 번거로웠고, chating, chatroom 테이블을 따로 관리해야했기 떄문에 조금 더 DB다운 indexedDB를 사용했고 너무 만족스러웠다.

indexedDB을 활용법에 대해서는 따로 올릴 계획이다.

indexedDB활용 정말 귀엽고 작은 db 이다.


프로젝트 디렉토리 구조

파일 구조는 해당 프로젝트의 Readme.md 파일에 정리해 놓았다.


메인 로직 🧠

1. ChatGPT API 사용하여 채팅 처럼 보이게.

2. indexedDB 추상화하여 사용. (다음 포스팅)


나머지 로직들은 평소에 많이 하는 CRUD 밖에 없다.


1. ChatGPT API 사용하여 채팅 처럼 보이게.

  • 사용한 open AI API Open AI 링크
  • open ai API와 직접 통신하는 부분은 next.js 서버에서 통신하도록 하였다.

  • api요청을 할때 api key로 인증을 하기 때문에 브라우저에서 노출 시키면 안된다.

  • 채팅 메시지를 POST Method으로 다음과 같은 형식으로 요청을 한다. model과 messages 배열을 openai.ChatCompletion.create({}}) 내부에 객체형태로 요청을 한다.
openai.ChatCompletion.create({
  (model = "gpt-3.5-turbo"),
  (messages = [
    { role: "system", content: "You are a helpful assistant." },
    { role: "user", content: "Who won the world series in 2020?" },
    {
      role: "assistant",
      content: "The Los Angeles Dodgers won the World Series in 2020.",
    },
    { role: "user", content: "Where was it played?" },
  ])
});
  • role 프로퍼티에는 ‘system’, ‘user’, ‘assistant’가 있는데, 쉽게 말해 ‘system’은 처음 채팅을 하기전에 chat GPT에게 역할을 지정해준다고 생각하면 된다. 예를들어
{ role: "system", content: "You are a math teacher in elementary school" },

이라고 첫 요청때 메시지와 같이 보내면 초등학교 수학 선생님 같은 답변이 온다. 다음으로 ‘user’는 사용자가 보내는 부분이라고 생각하면되고, ‘assistant’는 ai의 답변 부분이라고 생각하면 된다.

로직의 흐름은 클라이언트에서 입력한 messageList 형식의 배열을 next.js 서버로 전달하면, next.js 서버는 open ai API를 사용해 요청을 한 후 응답을 받아온 messageList를 클라이언트에게 응답데이터로 반환해주고 클라이언트에서는 화면에 그려주게 된다.

여기서 왜 배열이지? 분명 { role: "user", content: "안녕 나는 kks야" } 이렇게 객체를 보내야 하는게 아닌가? 생각이 들수 있지만, chat GPT API는 이전 대화 내용까지 포함시켜 배열형태로 보내줘야 이전 대화를 바탕으로 다음 대화를 진행 할 수 있기 때문이다.

즉, 이런 배열 형태가 클라이언트와 next.js 서버, open ai 사이에서 주고 받는 다는 말이다.

const messageList = [
  { role: "user", content: "안녕 나는 kks야" },
  { role: "assistant", content: "안녕하세요 저는 chat GPT 입니다." },
  { role: "user", content: "너는 몇살이니??" },
  { role: "assistant", content: "저는 AI이기 때문에 나이가 없습니다." },
];

이렇게 하면 기본적으로 어떻게 사람과 chat GPT의 채팅이 어떻게 진행되는지는 알게 되었을 것이다.

그럼 위의 요구 조건을 어떻게 구현했는지 단계별로 나타내 보겠다.

  • 최초에 채팅방을 만들고 들어왔을때는 아무것도 일어나지 않는다.
  • 사용자가 input에 작성을 하고 입력을 하면 해당 채팅방의 id (이전 방 목록 페이지에서 query로 넘겨줌)와 첫 메시지를 indexedDB에 create로 저장한다.
  • messages(화면에 보여줄 메시지 목록 상태) state를 setMessage()로 업데이트 시켜준다.
  • 방금 작성한 messages를 next.js 서버에 넘겨준다.
  • next.js 서버에서 open ai API를 사용해 요청한다.
  • ai의 답변이 next.js 서버에서 받아지면, 클라이언트로 ai답변을 전달한다.
  • 클라이언트에서는 ` setMessage((prev) => { return […prev, result] })` 를 사용해 이전 prev값에 ai 답변을 배열의 맨뒤에 붙여 화면을 업데이트 시켜준다.
  • messages state를 dependency array로 가진 useEffect안에서는 채팅 방 id를 기반으로 messages를 put으로 수정한다.
  • dependency array가 빈 배열인 useEffect 안에서는 indexedDB의 getAll 메서드로 모든 채팅목록을 최초 채팅방이 렌더링 될때 불러온다.

이것 순서가 무한 반복되면 Chat gpt와 1대1 대화를 하고 채팅방을 나갔다 들어오더라도 채팅 목록이 유지되고, 다음 채팅을 이어나갈 수 있다.

까다로운 것은 방 인원이 3명이 되면(자기 자신포함) ai끼리 대화를 하게 만드는 것인데 처음에는 정말 어떻게 해야하는지 감이 안왔다.

다시 한번 생각해보면 사용할 수 있는 gpt는 1개이고, 다수가 채팅하는 것 처럼 보이게 만드는 것이다.

그럼 3명 이상일때는 어떤 조건에 따라 messageList를 다시 요청하면된다. 그러하면 어떤 조건?일때 요청을 하면될까? 나는 유저가 input을 5초간 입력하지 않았을때로 그 조건을 잡았다. 즉 내가 input을 입력하지 않은지 5초가 지나면 다음 요청을 보내는 것이다.

const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);

useEffect(() => {
  async function startTimeout() {
    if (messages && messages.length > 1 && Number(query.headCount) > 2) {
      setTimeoutId((prevTimeoutId) => {
        if (prevTimeoutId) {
          clearTimeout(prevTimeoutId);
        }
        return setTimeout(async () => {
          const copiedMessage = [...messages];
          try {
            const response = await service.chatWithGpt([...copiedMessage]);
            const result = response.result.message;

            setMessage((prev) => {
              return [...prev, result];
            });
          } catch (error) {
            console.log(error);
            setHasError(true);
          }
        }, 5000);
      });
    } else return;
  }

  function resetTimeout() {
    startTimeout();
  }

  window.addEventListener("keydown", resetTimeout);

  if (hasError) {
    return;
  } else startTimeout();

  return () => {
    window.removeEventListener("keydown", resetTimeout);
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
  };
}, [messages, hasError]);

생각보다 많이 어려웠다…


어려웠던점 / 해결방법 🏋🏻‍♀️

GPT와의 채팅

사실 이 부분이 이 프로젝트의 전부이다. 1대1 채팅까지는 쉽게 구현했다. messageList를 누적해서 보내면 그게 1대1 채팅하는 기능이다. 요구 조건중에 3명이상이면 ai끼리 대화하게 하는 기능이 처음엔 진짜 감이 안왔다. 하지만 결국엔 gpt 1개로 다수 인 것처럼 보이게 하는게 해결책 이었다.


배운 것! 🏋🏻‍♀️

chat GPT API 사용법

처음으로 open ai API를 활용해 보았는데 어렵지는 않았고, 영어로된 공식문서를 다시한번 정독하는 스킬을 익혔다. chat API 말고 다른 API도 기회가 된다면 활용해 봐야겠다.

indexedDB 모듈 추상화

indexedDB의 강력함을 알게 되었고, Class 문법을 사용해서 모듈화 시켜놓기를 잘 한 것 같다. 항상 컴포넌트 단위 코드만 작성하다 보니 다른 방식의 코드를 작성하는데 어려움을 겪었는데 이번에 코드 작성 스킬이 조금 늘어난 것 같다. indexedDB 추상화 코드

tailwindCSS

처음 tailwindCSS를 접했을때는 기존에 tailwind가 정해놓은 classname을 홈페이지에서 일일히 찾아가며 작성을 했는데 익숙해지고 난 다음에는 classname에도 규칙이 있음을 깨달아 생산성이 조금 올라갔다.하지만 classname 부분의 코드가 너무 길어져 가독성이 떨어진 다는 단점은 어쩔수 없었다.


개선해야 할것, 고쳐나가야 할것 👨🏼‍💻

에러 핸들링

채팅 관련 에러처리를 제대로 못했다.

UX 관련

에러 메시지를 스낵바를 띄워서 보여주는 방법을 생각해봐야겠다.

아쉬운 스토리북 활용

스토리북을 작성은 했지만 뭔가 모르게 부족한 부분??

테스트 코드 작성

요구 조건에 테스트코드 작성이 있었지만, 작성을 하지 못했다..

함수 단위 리팩토링

중복되는 함수들을 한번 공통화 해서 처리해야 할 필요가 있다.

다시한번 내가 작성한 코드를 보면서 그 당시 이유에 대해 생각해보고 위의 개선해야 할 점을 반영하여 리팩토링을 진행 해야 겠다.

댓글남기기