개요
3주간의 유레카 종합 프로젝트가 끝이 났다.
이번 프로젝트의 주요 과업은 스트리밍과 이전 대화 내용을 기억하는 요금제 추천 챗봇 구현 이었고 우리 조의 주제는 개인과 가족이 함께 쓸수 있는 LGU+ 요금제 추천 챗봇 및 가족 스페이스 기반 플랫폼 MODi 였다.

나는 카카오 로그인과 챗봇 구현 파트를 맡았다.
프로젝트를 진행하며 했던 고민과 구현 과정을 정리해보고자 한다.
카카오 로그인
카카오 로그인은 최대한 카카오 공식 문서의 flow를 따르려고 했다.
카카오 공식 문서에서 권장하는 flow는 다음과 같다.

이번 프로젝트에서는 클라이언트를 Next.js, 서버를 Spring boot로 사용하였기 때문에 사용자 클라이언트를 Next.js 서비스 서버를 Spring boot로 두고 해당 플로우대로 구현하려고 했다.
하지만 해당 플로우에는 인가 코드 발급 요청 시 인가 코드 응답을 서비스 서버로 해주는지, 사용자 클라이언트로 해주는지 명시되어 있지 않다.
공식 문서에서는 인가 코드를 발급받을 redirect url을 kakao developers에 등록하라고 되어 있지만 일반적인 Rest api 요청 흐름을 생각하면 서비스 서버에 카카오 로그인 요청을 한 후 서비스 서버에서 인가 코드를 받게 된다면 클라이언트는 이후에 로그인 성공 여부에 대한 응답을 서비스 서버로부터 받을 수 없다는 문제가 있다.
왜냐하면 서비스 서버가 발급받은 인가 코드를 통해 카카오 로그인을 수행한 후 응답을 보내줄 수 없기 때문이다.
redirect 응답을 클라이언트에 보내게 되면 한번의 요청 응답이 끝나게 된다.
때문에 인가 코드를 클라이언트에서 발급 받는 방식으로 진행을 하였다.
사용자가 카카오 로그인을 성공한 후 발급받은 카카오 accessToken으로 사용자 이메일을 얻으면 해당 이메일이 회원 테이블에 존재하는지 확인한 후 없다면 회원 테이블에 저장하여 회원가입 처리 후 이메일로 JWT 토큰 생성, 이미 존재한다면 바로 JWT 토큰을 발급하도록 구현하였다.
챗봇 구현 - 정확도 향상시키기
개발 초기에 가장 고민을 많이 하였던 문제는 어떻게 하면 정확도가 가장 높으면서도 확실한 LGU+ 요금제를 추천해 줄 수 있을까? 였다.
일반적으로 gpt api를 사용하게 된다면 타사 요금제를 같이 추천해 줄 가능성이 높고, 존재하지 않는 요금제를 추천해줄 확률이 올라가게 된다.


때문에 파인 튜닝을 통해 정확도를 높이는 방안을 고안했다.
LGU+의 요금제를 집중적으로 미리 학습시켜 둔다면 LGU+ 요금제만을 추천해 줄 확률이 올라가지 않을까? 라는 단순한 생각이었다.
하지만 이 방법은 그렇게까지 좋은 output이 나오진 않았다.

요금제를 추천해주는 경향이 뚜렸해졌지만 여전히 타 통신사와 비교해서 응답하거나 두루뭉실한 요금제를 추천해 주는 문제가 발생했다.
때문에 일단 각 요금제 학습 데이터를 요금제당 5개씩 늘린 후 테스트 해보았다.

확실히 요금제 가격과 약정 할인 가격까지 보다 자세히 추천해 주는 경향이 생겼지만 아직 부족하다고 생각했다.
사용자가 특정 요금제만을 물어볼 가능성이 적다고 생각했기 때문이다.
때문에 상황별 데이터와 추가적인 요금제 학습을 진행했다.

만족할만한 응답 빈도가 높아진것을 확인했다.
테스트에서는 3.5 turbo 모델을 사용하였는데 꽤나 구형 모델에서 이정도 응답이 나왔다면 같은 데이터를 4.0 mini 모델에 학습시키면 정확도 높은 답변이 나올 것이라고 예상했다.

요금제 추천에 더불어 혜택까지 자세히 설명해 주기 시작했고 해당 모델을 개발에 사용하기로 했다.
해당 모델을 사용하여 개발된 챗봇은 다음과 같다.

gpt가 마크다운 형식으로 응답하는 경향이 많기에 마크다운 형식으로 받은 응답을 올바르게 출력할 수 있도록 추가적인 구현을 진행했다.
클라이언트에서 서버에 답변 생성 요청을 보낼 때 답변의 정확도를 높이기 위해 추가적인 prompt도 하나 추가하였다.

prompt에 요금제 추천은 LGU+만 하도록 추가적으로 명시했다.
이를 통해 LGU+ 요금제만 추천해주는 챗봇을 구현할 수 있었다.
추가적으로 해당 prompt를 추가함으로써 사용자가 알 수 없는 대화나 요금제와 관련 없는 채팅을 입력하는 경우에 요금제 추천에 관련된 질문을 하도록 유도하는 경향을 보였다.

이는 예상하지 못한 결과였는데 파인튜닝에 사용된 학습 데이터에 요금제라는 키워드가 많았어서 해당 부분에 많은 가중치가 들어간 것 같다.
챗봇 구현 - 스트리밍과 성능 최적화
챗봇은 반드시 스트리밍 방식으로 답변을 출력하여야 하는 것 또한 주요 과업 중 하나였다.
성능 저하가 예상되었던 부분은 다음과 같다.
1. 사용자가 챗봇을 많이 사용할수록 렌더링하는 컴포넌트의 양이 많아진다.
2. 1번의 이유에 따라 이미 많은 컴포넌트가 렌더링 된 상황에서 스트리밍 방식으로 답변을 출력하면 엄청나게 많은 리렌더링이 발생한다.
사용자의 채팅 내용은 클라이언트에서 state로 관리하였다.
gpt api는 스트리밍 방식으로 응답 시 finish_reason이 있기 전까지 1~3글자의 문자열을 쪼개서 지속적으로 응답해주기 때문에 응답을 받을 때마다 prevState를 수행하여야 했고 이에 따른 많은 리렌더링이 예상되었다.

이는 간단하게 해결했다.

React.memo를 사용하여 이전 채팅 내역을 메모이제이션 하였다.
MessageItem 컴포넌트는 오로지 채팅만을 출력하는 컴포넌트로, props로 전달받은 채팅 내용의 경우 이전 채팅 값이 변하지 않을 것이기 때문에 메모이제이션을 하였다.
하지만 사용자가 한 화면에서 채팅을 엄청나게 많이 하는 경우엔 여전히 성능 저하가 발생할 수 있는 문제가 있는 상태이다.
현재 다른 llm 모델도 한 세션에서 채팅을 많이 치면 렉이 많이 걸리는 문제가 있는데 이는 어쩔수 없는 고질적인 문제라고 생각하지만 더 좋은 방법이 있다면 알고 싶다.
챗봇 구현 - 이전 대화 내용 기억
이전 대화 내용을 기억하기 위해 나는 3가지 방법을 생각했다.
1. LangChain 라이브러리 사용
2. Redis와 같은 in memory 데이터베이스 사용
3. 대화 내역을 모두 데이터베이스에 저장
결과적으로 나는 3번 방식으로 구현을 진행했다.
왜냐하면 이번 프로젝트의 경우 추가하기로 한 기능 중에 STT/TTS 기능이 있었다.
TTS의 경우 매번 생성하는 것이 매우 비효율적이고 비용이 많이 드는 문제가 있다.
때문에 데이터베이스에 생성된 TTS를 관리하는 테이블을 따로 두었었다.

만약 이전 대화 내용 기억 구현을 LangChain이나 Redis를 사용하여 구현한다면 메모리에 저장되어있는 채팅 내역을 따로 불러온 후 tts로 변환 => tts 생성 => 생성된 tts url 저장 이라는 구조가 예상되었는데 구조의 복잡도가 높아진다고 생각했다.
LGU+ 현직자 멘토링 시간에 해당 부분에 대해 질문을 했었는데 구현에 필요한 좋은 라이브러리를 더 찾아보거나 주어진 시간 내에 구현을 올바르게 할 수 있는 방향을 고민해보라는 조언을 해주셨었고 3번 방법으로 진행하는 것이 좋다고 생각하여서 모든 채팅 내용을 DB에 저장하는 방향으로 구현하였다.
현재 챗봇은 3초 이내로 응답이 오기 때문에 이렇다할 성능 저하가 없는 편이지만 사용자가 만약 100만명을 넘어가는 서비스라고 한다면 메모리에 저장하는 방식이 반드시 성능이 더 좋게 나올 것이기 때문에 향후 추가적인 확장을 고려하여야 할 것이다.
챗봇 구현 - 가족 결합 추천
이번 프로젝트는 가족과 함께하는 것이 주요 컨셉이었기 때문에 가족과 관련된 요금제를 추천해주는 것 또한 주요한 문제였다.
파인 튜닝의 경우 요금제와 사용자의 예상 질문들만을 학습시킨 상황이었고 가족 결합과 관련된 데이터를 학습시키기에는 프로젝트 기한이 빠듯할 수도 있겠다는 문제가 있었다.
때문에 단기간에 정확도를 높이기 위하여 RAG 프로세스를 도입하였다.
RAG(Retrieval-Augmented Generation)는 대규모 언어 모델의 출력을 최적화하여 응답을 생성하기 전에 학습 데이터 소스 외부의 신뢰할 수 있는 지식 베이스를 참조하도록 하는 프로세스이다.
우선 사용자가 가족 모드로 챗봇에게 질문을 하였는지 알기 위해 요청에 따로 flag를 두었다.
데이터베이스에는 LGU+의 가족결합 할인 정보를 정제한 후 따로 저장해두고 지정해 둔 flag에 해당하는 요청이 들어올 시 데이터베이스에서 가족결합 할인 정보를 불러온 후 system prompt에 넣어서 답변 정확도를 단시간에 향상시킬 수 있었다.

챗봇 구현 - 일반 채팅과 가족 모드 채팅의 분리
이번 프로젝트에서는 사용자가 일반 챗봇과 가족 모드 챗봇 2가지를 사용할 수 있다.
현 프로젝트에서는 채팅을 식별할 수 있도록 사용자가 챗봇 페이지에 입장할 시 현재 날짜에 해당하는 Date 객체와 랜덤 난수, window.crypto의 randomUUID 함수를 사용하여 고유한 세션 문자열을 만들어 채팅 내역을 관리하도록 구현이 되어 있다.
우선 일반 채팅과 가족 모드를 따로 분리하기 위해 챗봇 페이지는 일반 세션과 가족 모드 세션 2가지의 세션 string을 가지도록 하였다.
채팅 내역의 경우 state를 2개로 나눠서 관리하는 것이 좋을지 고민을 하였는데 관리하는 state가 많아지는것은 지양해야 한다고 생각하여 기존의 방식대로 message를 관리하는 하나의 state만 두었다.
챗봇 메세지를 출력하는 컴포넌트에는 일반 모드와 가족 모드의 세션 id를 기준으로 filter함수를 사용하여 메세지를 가공한 후 props로 넘겨주도록 구현하였다.
후기
개발은 알면 알수록 더 많은 고민을 하게 되는 매력이 있는 것 같다.
프로젝트가 끝나고 내가 구현한 코드를 다시한번 살펴봤는데 챗봇 메세지를 출력해주는 컴포넌트에 props로 넘겨주는 함수의 useCallback 의존성 배열에 너무 많은 의존성이 들어 있었다.
때문에 memoization 성능이 떨어지는 문제가 있어 핫픽스를 진행했다.
챗봇을 만들어보는 것은 처음이었는데 예전의 나였으면 기능 구현에만 급급하였을 것 같은데 현재는 기능 구현에 급급하는 단계를 지나섰다고 생각한다.
프로젝트 평가라는 부분에 있어서는 나의 방식과 다른 점이 많은 것 같다는 생각이 든다.
평가를 받을 땐 이러한 고민들과 구현 과정을 설명하기엔 너무나 한정적이라고 생각한다.
단기간의 프로젝트의 경우 보다 많은 기능으로 심사위원들의 이목을 끌지, 아니면 내 맡은바 기능을 고도화할지, 그리고 좋은 포트폴리오란 무엇일지 프로젝트가 끝나고서 많은 생각을 했다.
차주에 융합프로젝트가 바로 시작되는데 팀원들과 함께 심도깊은 고민을 할 수 있는, 그리고 사람들이 사용하고 싶어하는 프로젝트를 만들고 싶다.