
2023년 말에 SW개발 공모전 : 피우다 프로젝트에 참여하면서 플로깅 관련 웹서비스를 제작하게 되었고,
작년 말에 해당 프로젝트를 리팩토링하면서 플로깅 코스 추천 챗봇 페이지를 추가적으로 개발하게 되었다.
이전 글에서 useState와 useReducer의 동작 원리를 학습하면서 두 가지 Hook을 간단하게 비교해보았었는데,
실제 프로젝트에서 두 가지 Hook을 각각 적용했을 때 코드가 어떻게 나타날지, 그리고 어떤 Hook을 사용하는게 좋은지 상세히 비교해보고자 한다.
(챗봇 페이지 개발 과정에 관한 글을 정리하기 위함도 있다.
실제 코드는 더 복잡하나 상태 관리 부분만 따로 작성해보고자 한다.)
챗봇 개발에는 ChatGPT 4o-mini 모델 api를 사용했다.
챗봇 프로세스는 다음과 같다.
1. (필수 질문) 유저의 플로깅 코스 추천 요청 (ex. 성동구 플로깅 코스 추천해줘)
2. (필수 응답) GPT가 추천해주는 플로깅 코스 응답
3. (선택 질문) GPT 응답에 따른 유저의 추가 상세 요청
위 챗봇 프로세스를 개발하기 위해서 필요한 기능 요구 사항은 아래와 같다.
[✓] 챗 입력창에 “성동구 플로깅 코스 추천해줘” 와 같은 메시지를 입력한다.
[✓] 전송 버튼 클릭 시 유저 메시지가 화면에 표시되고, GPT에게 전송됨.
[✓] GPT 응답이 도착하면 챗봇 말풍선으로 표시된다.
[✓] GPT 응답은 백엔드 서버에 저장된다.
[✓] GPT 응답 하단에 상세 조건 버튼들이 나타난다:
[✓] 유저는 원하는 조건 버튼을 클릭 후 나타나는 입력창에 추가 조건을 작성한다.
[✓] 조건은 복수로 작성 가능하며, 조건에 대한 메시지가 화면에 표시된다.
[✓] 모든 조건 입력이 끝나면 "다시 물어보기" 버튼을 클릭할 수 있다.
[✓] 다시 물어보기 클릭 시, 기존 GPT 응답 + 유저가 입력한 추가 조건들을 종합하여 다시 GPT에 요청한다.
예: “홍제 폭포 추가해줘”, “평지 추천해줘” → GPT가 다시 응답
[✓] GPT 응답이 도착하면 말풍선에 표시되고 자체 백엔드 서버에 저장된다. (유저의 질문은 서버에 저장하지 않음)
만약 상세 요청 시 이미 보낸 내용을 수정하고 싶다면 해당 질문 버튼 클릭 후 다시 질문하면 된다.
ChatGPT api는 웹에서 이용하는 것과 다르게 기본적으로 이전 대화를 기억해주지 않는다.
따라서 대화를 계속 이어서 하려면 messages 인자에 이전 맥락의 대화들을 순서대로 배열로 묶어서 전달하면 된다.
인자로 보내야 하는 객체 배열 구조는 아래 형식과 같아야 한다.
{
{
role: "user", // 유저 질문
content:
"성동구 근처 플로깅 코스를 추천해줘.",
},
{
role: "assistant", // GPT 응답
content:
"성동구에서 플로깅을 즐기기 좋은 코스는 다음과 같습니다: ~~~~",
},
// 이와 같은 방식으로 user와 assistant의 대화 반복...
}
지금 개발할 서비스의 특성 상, 기본 질문인지 혹은 상세 질문인지에 따라서 처리해야할 프로세스가 달라지고, 자체 백엔드 서버에 보내야 할 정보(type, timestamp)가 있다.
따라서 ChatGPT API 요청 시 인자로 보내야 하는 객체 배열 구조를 그대로 유지하되,
상태 관리할 때는 type 속성과 timestamp 속성을 추가로 넣고
ChatGPT API에 chatList를 보내서 답변을 요청할 때는 추가로 넣은 두 가지 속성을 빼기로 했다.
{
role: "user" | "assistant",
type: "BASIC" | "DETAILED",
content: string,
timestamp: Date
}
import React, { useState } from "react";
const ChatbotPage = () => {
const [chatList, setChatList] = useState([]);
...
useState를 import 한 후, state(chatList)와 setState(setChatList)를 선언한다.const handleMessage = (type, content) => {
const [role, type] = type.split("_");
const newMessage = {
type: type,
role: role,
content: content,
timestamp: new Date(),
};
setChatList((prev) => [...prev, newMessage]);
setChatInput(""); // input state 초기화
};
<img src={chat_send} alt="chat_send" onClick={()=>handleMessage("user_BASIC", chatInput)} />
const fetchData = async (type) => {
try {
// user 채팅 입력
// 객체에서 role과 content만 받아오기 (filteredArray)
const res = await chatbotCallGPT(filteredArray); // GPT에게 질문 보내기
const messageContent = res.data.choices[0].message.content; // GPT 답변
handleMessage("assistant_BASIC", messageContent);
} catch {
alert("잠시 후 다시 시도해주세요");
}
};
위 로직을 useEffect에서 실행한다.
handleMessage 함수에 넣어 chatList state에 저장한다.chatList state에 GPT 답변 내용이 추가되면, 자체 백엔드 서버에 GPT가 보내온 답변을 저장한다.const handleDetailedUserMessage = (detailArray) => {
// 같은 타입 중 가장 마지막 요소만 남기기
const latestDetails = Object.values(
detailArray.reduce((acc, curr) => {
acc[curr.type] = curr;
return acc;
}, {})
);
// 배열을 문자열로 변환
const resultString = `Please provide additional information in Korean based on the plogging recommendation answer you mentioned before. ${latestDetails
.map((item) => `${item.type.toLowerCase()}: ${item.content}`)
.join(", ")}`;
const newMessage = {
type: "DETAILED",
role: "user",
content: resultString,
timestamp: new Date(),
};
setChatList((prev) => [...prev, newMessage]);
};
유저상세질문(user_DETAILED)일 경우, 프로세스가 더 추가된다.
1) detail 배열에서 중복된 카테고리의 데이터가 있다면, 마지막 것만 추출한다.
// detail 배열의 구조는 아래와 같다
{
type: "WHERE" | "TIME" | "ETC",
content: "~"
}2) 그리고 content 속성에 배열을 넣을 수 없으므로, 배열 내용을 요청사항과 함께 문자열로 바꾼다.
3) 기존 state에 type, role, content, timestamp가 담긴 객체를 추가한다.
{chatList[0] &&
(chatList[1] && chatList[1].content ? (
<AssiMessageBox>
<ChatbotProfile chat={chatList[1]} />
<AssiBubble>{chatList[1].content}</AssiBubble>
</AssiMessageBox>
) : (
<AssiMessageBox>
<ChatbotProfile />
<AssiBubble>
<BeatLoader size={10} margin={4} color="#7654FF" />
</AssiBubble>
</AssiMessageBox>
))}
chatList state의 어떤 인덱스에 있는 값이 존재하면 그 메시지 박스를 띄우고, 없으면 loading 컴포넌트를 띄우는 방식으로 UI를 설계했다.reducer 함수 작성하기export const chatListReducer = (state, action) => {
// action으로 content(string), detail(string[])
const [role, type] = action.type.split("_");
switch (action.type) {
case "user_BASIC":
case "assistant_BASIC":
case "assistant_DETAILED": {
return [
...state,
{
type: type,
role: role,
content: action.content,
timestamp: new Date(),
},
];
}
case "user_DETAILED": {
// 1. detail 배열에서 같은 type 중 마지막 요소만 남김
const lastItems = Object.values(
action.detail.reduce((acc, curr) => {
acc[curr.type] = curr; // 같은 type일 때 덮어씀
return acc;
}, {})
);
// 2. 배열을 문자열로 변환
const resultString = `Please provide additional information in Korean based on the plogging recommendation answer you mentioned before. ${lastItems
.map((item) => `${item.type.toLowerCase()}: ${item.content}`)
.join(", ")}`;
return [
...state,
{
type: type,
role: role,
content: resultString,
timestamp: new Date(),
},
];
}
default:
throw Error("Unknown action: " + action.type);
}
};
action type이 유저기본질문(user_BASIC), GPT기본응답(assistant_BASIC), GPT상세응답(assistant_DETAILED)일 경우,
기존 state에 type, role, content, timestamp가 담긴 객체를 추가한다.
action type이 유저상세질문(user_DETAILED)일 경우, 프로세스가 더 추가된다.
1) 먼저, action으로 보내온 detail 배열(다른 type의 경우 null로 들어온다)에서 중복된 카테고리의 데이터가 있다면, 마지막 것만 추출한다.
{
type: "WHERE" | "TIME" | "ETC",
content: "~"
} // detail 배열의 구조
2) 그리고 content 속성에 배열을 넣을 수 없으므로, 배열 내용을 요청사항과 함께 문자열로 바꾼다.
3) 기존 state에 type, role, content, timestamp가 담긴 객체를 추가한다.
reducer와 연결된 state와 dispatch를 선언import React, { useReducer } from "react";
import {
chatListInitialState,
chatListReducer,
} from "../../services/format/chatbotData";
const ChatbotPage = () => {
const [chatList, chatListDispatch] = useReducer(
chatListReducer,
chatListInitialState
); // 메세지 대화 목록
}
useReducer를 import 한 후, 앞서 만든 reducer 함수(chatListReducer)를 import 한다.useReducer를 이용해 state(chatList)와 dispatch(chatListDispatch)를 선언한다.dispatch와 action type을 연결 // input 입력값 제출 함수
const handleSubmit = () => {
if (curStep === "BASIC") {
// BASIC일 때
chatListDispatch({
type: "user_BASIC",
content: chatInput,
detail: null,
}); // action으로 BASIC_USER(0), chatInput, detail 배열 전달
} else if (curStep === "WHERE" || curStep === "TIME" || curStep === "ETC") {
// WHERE || TIME || ETC일 때
// detail 배열에 {type: "WHERE", content: "홍제천을 플로깅 루트에 포함해줘"} 같은 형식 추가
setDetail([...detail, { type: curStep, content: chatInput }]);
}
setChatInput(""); // input state 초기화
};
<img src={chat_send} alt="chat_send" onClick={handleSubmit} />
dispatch 함수를 통해 type, content, detail(null로 고정)을 reducer 함수에 전달한다. detail state에 내용을 추가하고, '다시 물어보기' 버튼을 클릭 시 dispatch 함수를 통해 detail 배열 state를 전달한다.reducer 함수가 chatList state에 내용을 추가함과 동시에, useEffect를 통해 GPT에 답변을 요청한다. const handleChatDispatch = (type, content) => {
chatListDispatch({
type: type,
content: content,
detail: null,
}); // chatList state에 저장
};
const fetchData = async (type) => {
try {
// user 채팅 입력
// 객체에서 role과 content만 받아오기 (filteredArray)
const res = await chatbotCallGPT(filteredArray); // GPT에게 질문 보내기
const messageContent = res.data.choices[0].message.content; // GPT 답변
handleChatDispatch("assistant_BASIC", messageContent, 0);
} catch {
alert("잠시 후 다시 시도해주세요");
}
};
위 로직을 useEffect에서 실행한다.
chatList state에 저장한다.chatList state에 GPT 답변 내용이 추가되면, 자체 백엔드 서버에 GPT가 보내온 답변을 저장한다.useState 방식과 동일하다.useState vs useReducer두 방식 모두 사용해보고 느낀 점은,
1. useState는 확실히 구현하기 간편하다. setState만으로도 간편하게 state를 변경할 수 있고, 코드 양도 적어서 좋다.
useReducer의 경우reducer)를 컴포넌트 외부에서 관리하기 때문에, 여러 컴포넌트에서 해당 데이터를 다뤄야할 때 쓰기 편하다.해당 프로젝트의 경우
1) 여러 컴포넌트에서 chatList state를 다뤄야 했고,
2) 객체 배열의 state를 변경해야 했기 때문에 다른 state보다 변경 로직이 조금 복잡했으며,
3) 챗봇 단계마다 각각 다른 방식으로 state를 변경해야했기 때문에
useReducer가 더 적합했던 것 같다.
하지만, 평소 간단한 state를 관리할 때는 useState를 쓸 것 같다.