셀프서비스, 챗봇에게 물어보세요

Mar.26.2024 송지은, 김영빈

Backend Web Frontend

서비스 이용을 도와주는 챗봇, 사용해보신 적 있으신가요? 지난 12월 배달의민족의 사장님 서비스, ‘배민셀프서비스’에서도 챗봇 기능을 오픈했는데요, 이번 글에선 프론트, 서버 개발자가 함께 챗봇을 개발하며 고민했던 내용들을 소개드리겠습니다.

배민셀프서비스는요

셀프서비스에서는 배민 사장님들의 장사를 돕기 위해 다양한 정보를 제공하고 있습니다. 기본적인 가게관리 및 메뉴 관리, 리뷰 작성과 쿠폰 발행을 비롯하여 배달의민족 정산금액을 확인하실 수도 있죠. 자연스레 서비스에서 제공하는 정보의 종류가 다양해지고, 그에 따라 좌측의 메뉴 목록도 점차 늘어나, 현재 총 23개의 메뉴바를 보여주고 있습니다.

한 눈에 담기 어려운 셀프서비스의 메뉴 목록

개발자도, PM도 가끔 필요한 정보를 얻기 위해 어느 메뉴로 이동해야 하는지 헷갈릴 정도인데, 장사 관련 정보를 빠르고 쉽게 확인해야 하시는 사장님들의 어려움도 클 것이라는 생각이 들었습니다. 게다가 기존에는 셀프서비스 관련한 문의사항이 있을 경우 사장님이 직접 콜센터에 연락해서 확인하는 방법이 유일했었죠. 단번에 셀프서비스의 모든 메뉴를 개편할 수는 없기에, 사장님 스스로 셀프서비스의 다양한 기능을 탐색하고 습득하는 것을 도와주는 기능 및 콘텐츠를 제공하는 방법이 필요했습니다.

그렇게 해서 탄생하게 된 기능은 바로 챗봇! 셀프서비스 챗봇 프로젝트의 목적 및 기대효과는 다음과 같이 정의했습니다.

  • 사장님의 셀프서비스 사용 방법이나 문의사항을 봇을 통해 문의하고 답변을 받아볼 수 있도록 한다.
  • 사장님이 고객센터 상담원 연결을 기다리지 않고 봇을 통해 직접 탐색해 볼 수 있다.

벌써부터 기대되는 챗봇, 그 구체적인 내용들을 조금 더 탐구해 볼까요?
(셀프서비스를 이용하시는 ‘사용자’는 대부분 외식업 ‘사장님’이기 때문에, 이어지는 글에서 별도의 언급 없이 ‘사용자’라고 표기된 것은 ‘사장님’을 가리킵니다.)

어떤 것을 물어볼 수 있나요?

챗봇의 컨셉은 다음과 같습니다.

  1. 실제 사람과 채팅하는 것처럼 여겨지지 않을 것(셀프서비스 관련 답변을 제공하는 것이 목표기 때문에, 모든 영역의 내용에서 답변이 가능한 사람과의 채팅과는 다름)
  2. 사용자 친화적이어야 할 것

ML프로덕트팀에서 개발한 자연어 학습모델을 활용하여, 사용자가 질문하면 유사도가 높은 질문과 답변을 보여주고 있습니다.
고객센터로 들어오는 자주 묻는 질문들을 모아 대표질문으로 선정하고 대표질문과 유사한 질문을 생성한 뒤 Contrastive Learning 방법으로 학습해 질문에 대한 유사도를 판단합니다. 사용자가 질문을 하면 유사도를 측정해 가장 스코어가 높은 질문을 반환합니다.

Contrastive Learning
자기지도 학습의 한 유형으로, 데이터의 유사하고 다른 쌍의 샘플을 대조하여 유용한 표현을 학습하는 기계 학습 패러다임.
Contrastive Learning의 핵심 아이디어는 유사한 샘플을 표현 공간에서 가깝게 배치하고 다른 샘플을 멀리 떨어뜨리는 것
https://en.wikipedia.org/wiki/Self-supervised_learning

이때, 학습한 질문에 대한 유사도만 판단하고 있기 때문에 ‘아까 물어본 거 다시 답해줘’ 같은 문맥 분석이 필요한 질문이나, ‘지금 진행 중인 광고 알려주세요’ 등의 개별 데이터에 대한 질문의 답을 드리진 않습니다. 다만 셀프서비스에서 제공하는 가게관리, 광고관리, 메뉴관리 등과 관련해서는 적절한 답변을 제공하고 있습니다.

질문과 답변은 어떻게 관리하나요?

우선 고객센터로 들어오는 문의 중 사장님들이 셀프서비스를 통해 장사하는 데 필요한 질문들과, 셀프서비스 관련 질문들만 선정해 대표질문으로 만들어 학습시켰습니다.
입력한 질문에 대한 유사도를 바탕으로 대표질문을 추론해 선별하고, 답변은 정책에 따라 수정될 가능성이 있어 셀프서비스 RDB에 저장되어 있습니다.
질문과 답변의 추가는 사용자의 질문 중 답변을 받지 못한 질문을 추려 대표질문을 선정한 뒤, 유사질문을 만들어 학습시키는 과정을 통해 진행되고 있습니다.

사용자가 챗봇에 질문하고 답을 얻는 플로를 크게 두 가지로 나눠서 작업했는데요,

첫 번째는 카테고리별 자주 묻는 질문을 클릭하여 답변을 얻는 방법과 두 번째로 사용자가 직접 질문을 입력하여 답변을 얻는 방법으로 나눠봤습니다.

자주 묻는 질문 클릭
사용자가 직접 질문 입력

자주 묻는 질문 선택지

첫 번째 방법은 주기적으로 사용자 사용 패턴을 분석해, 사용자 질문을 카테고리로 분류하고 카운팅하여 질문 빈도수가 높은 순서대로 보여주고 있습니다. 자주 묻는 질문은 최근 2주간의 질문 이력을 바탕으로 만들어지는데, 질문에 답변이 없는 항목은 제외하고 보여 줍니다.

배치를 통해 생성된 질문 데이터는 레디스(Redis)를 활용해 저장하고 있는데요, 레디스를 활용한 이유는 다음과 같습니다.

  1. 챗봇 진입화면에서 보여줘야 하고, 셀프서비스 전 지면에서 접근 가능해서 조회가 빈번하게 발생할 것으로 예상된다.
  2. 자주 묻는 질문 리스트는 하루 동안만 유지해야 한다.
  3. 질문별 카운트를 위한 모델({key: string, {questionId: number, count: number}})의 데이터 구조에 적합하다.

자주 묻는 질문을 RDB에 저장하는 것도 고민했었지만, 위의 이유들을 고려했을 때 빠른 데이터 저장 및 조회가 가능한 메모리 기반의 레디스를 최종적으로 선택했습니다.

그리고 위의 플로를 도식화하면 아래와 같이 표현할 수 있습니다.

챗봇 진입 후 카테고리 선택 시 호출하는 API의 응답으로 위의 데이터에 기반해 카테고리별 자주 묻는 질문을 7개까지 보여주고 있습니다.

// 예시 응답 json 
{
    "bubbleType": "FAQ",
    // ... 
    "infoMap": {
        "매출상세내역 파일을 받아볼 수 있을까요?": "/ask/110",
        "기타 매출이 뭔가요?": "/ask/102",
        "세무 대리인에게 부가세 신고자료를 전달하려면 어떻게 해야 하나요?": "/ask/301",
        "카드사에 정산금을 직접 청구할 수 있나요?": "/ask/300",
        "카드사로부터 자료제공 협조 공문이 왔는데요.": "/ask/299",
        "바로결제 정산계좌 변경해 주세요.": "/ask/115",
        "입금예정금액이 뭔가요?": "/ask/114"
    }
},

사용자가 직접 입력하는 질문

두 번째 방법은 사용자가 질문을 직접 입력하고, 학습된 데이터를 기반으로 유사도가 높은 질문을 선정해 답변을 보여줍니다.

사용자가 직접 질문을 입력하고 답변을 얻는 방법으로, 질문을 입력하면 금칙어가 포함되어 있는지를 먼저 확인하고(금칙어가 포함된 경우는 질문을 할 수가 없어요), 사용자의 질문과 유사도가 높은 질문을 순서대로 3개까지 보여줍니다.

이때 질문에 대한 적절한 답변을 찾을 수 없거나(유사한 질문을 찾을 수 없는 경우), 유사도가 가장 높은 질문에 대한 답이 서비스 타입별로 다른 경우는 각각 다른 답변이 나가게 되는데요, 아래에서 에러핸들링 부분에서 답변 제너레이터의 역할을 통해 더 설명드리겠습니다.

챗봇인듯 챗봇아닌 챗봇이어야 하는

웹소켓(WebSocket)을 이용하는 다른 많은 채팅 서비스들과 달리, 셀프서비스에서 기획한 챗봇 ver.1은 사용자의 요청에 따라 서버에서 미리 준비된 응답을 반환하는 일반 HTTP 기술을 사용하고 있습니다. 적절한 응답을 찾아오기까지의 과정이 실시간으로 이루어질 필요가 없고, 클라이언트와 서버의 지속적인 연결이 불필요하다고 생각했기 때문이죠. 하지만 사용자는 채팅을 하는 것처럼 느낄 수 있도록 사전에 다양한 형식의 응답을 준비하여 대화의 플로가 자연스럽게 이어지도록 해야 했습니다.

웹소켓(WebSocket)
클라이언트와 서버 간 지속적인 양방향 연결 스트림을 만들어주는 기술

처음 기획안 및 디자인 시안을 본 날이 잊히지 않는데요, 예고 받았던 것보다 훨씬 스펙이 굉장했기 때문이었죠. 단순 텍스트 형태의 응답뿐 아니라, 카테고리 및 목록 선택 응답/ 이미지, 동영상, 하이퍼링크 등 미디어 객체가 포함된 응답/ 피드백 요청 관련 응답을 비롯하여 응답 UI의 디자인도 프론트엔드 단에서 동적인 마크업이 많이 이뤄져야 하는 그림이었죠.

😵 "모든 응답 유형에 대한 UI를 서버에서 다 그려줄 순 없을 텐데… 결코 쉽지 않은 작업이 되겠군”

그러다 보니 자연스레 서버 개발자와의 협업도 긴밀해졌습니다. 다양한 응답 타입에 대한 인터페이스를 정리하고, 대화의 흐름이 끊기지 않게 사용자의 목록 선택 또는 발화에 맞는 다음 응답을 연결해서 반환하도록 해야 했죠. 그 구체적인 예시를 설명드리겠습니다.

우선 기본적인 챗봇 응답 인터페이스는 다음과 같습니다.

// [FE] 챗봇 응답 인터페이스 정의
export interface IChatbotBubble {
  bubbleType: BubbleType;
  icon?: BUBBLE_ICON_NAME;
  badge?: string;
  serviceType?: ServiceType;
  title?: string;
  description: string;
  nextAction?: string;
  guideText?: string;
  guideUrl?: string;
  sourceUrl?: string[];
  caption?: string;
  infoMap?: Record<string, string>;
}

bubbleType으로 카테고리/ FAQ 목록/ 피드백 요청 응답 등 UI별 응답 타입을 구분하고, title, description 은 응답의 내용을 구성하고, sourceUrl, caption 등은 이미지나 동영상 등의 미디어가 포함된 응답에 추가된다는 것을 알 수 있습니다.

🤔 그런데 nextActioninfoMap은 어디서 쓰는 항목일까요?

챗봇이 반환하는 응답 중 사용자의 다음 액션을 유도하는 버블이 있습니다. 예를 들면, 답변이 도움이 되었는지 여부를 물어본 후 이어 등장하는 ‘의견 남기기’ 버블이 있죠.

// [FE] nextAction을 포함하는 응답 예시 json
{
    // ... 
    "answer": {
        // ... 
        "bubbles": [
            // ... 
            {
                "bubbleType": "BLUE",
                "title": null,
                "description": "의견 남기기",
                "nextAction": "/feedback/codes?isHelpful=true",
                // ... 
            }
        ]
    },
    "feedback": null
}

다른 버블과 달리 파란색으로 제공되는 ‘의견 남기기’ 버블의 경우 사용자가 클릭할 수 있는 버튼 UI로 제공되고, 버튼 클릭 시에는 주어진 nextAction 필드의 url endpoint를 호출하여 서버에서 의도한 동작을 수행합니다.

또 앞서 보여드렸듯 ‘자주 묻는 질문 제공’에서는 다음과 같은 형태의 사용자가 선택할 수 있는 선택지 응답을 제공합니다.

이러한 응답을 만들어내기 위해 서버에서는 infoMap 객체에 키-값 쌍의 데이터를 담아 전달합니다. 사용자가 목록 아이템을 클릭하면 프론트에서는 infoMap의 특정 질문 키의 값에 해당하는 url endpoint를 호출하기만 하면 됩니다. 즉 nextActioninfoMap은 사용자와의 지속적인 대화를 이어가기 위해 필요한 필드인 것이죠.

// [FE] 챗봇 응답 예시 json
{
    "bubbleType": "FAQ",
    // ...
    "infoMap": {
      "매입상세내역 보내주세요.": "/ask/111",
      "바로결제 정산계좌 변경해 주세요.": "/ask/115",
      "배민에서 진행하는 이벤트로 발행된 쿠폰은 정산금액에서 제외되나요?": "/ask/113",
      // ...
    }
  },

답변을 개선하기 위한 노력

그러면 내부에서 실제로 답변 정확성을 올리기 위한 트래킹은 어떻게 이루어질까요? 사용자의 질문 이력을 저장하는 이력 테이블이 존재하는데, 이 테이블에서 질문에 대한 답이 나가지 않은 데이터들을 따로 뽑아 질문과 답변을 새로 만들거나 개선하고 있어요.

이런 트래킹을 위해 새로 저장해야 하는 정보가 하나 생겼어요. 사용자 피드백과 관련한 내용이었습니다. 바로 사용자가 ‘도움이 되었어요/ 아쉬워요’ 선택 또는 직접 의견을 남겼을 때, 어떤 응답에 대한 피드백인지 알고 싶었죠. 그러기 위해서는 응답 인터페이스에 질문 및 응답 id가 추가되어야 했습니다. 그렇게 하여 최종적으로 만들어진 챗봇 응답 인터페이스는 다음과 같습니다.

// [FE] 챗봇 응답 인터페이스_최종
export interface IAnswerResponse {
  requestId: string;
  questionIds: number[];
  answer: IChatbotAnswer;
  feedback: IChatbotAnswer;
}

export interface IChatbotAnswer {
  expression: ExpressionType;
  searchCompleted: boolean;
  bubbles: IChatbotBubble[];
}

IAnswerResponserequestId는 내부적으로 답변 정확도 향상을 위한 트래킹 목적의 id를, questionIds는 특정 질문에 대해 사용자가 선택한 질문이거나 유사도가 높은 질문들을 가리킵니다. 프론트에서는 한 뭉치의 질문-응답 플로 동안 서버에서 받은 requestIdquestionIds를 저장해두었다가, 사용자가 피드백 전송 시 프론트 저장소에 저장된 requestIdquestionIds를 함께 전송합니다. 그렇게 하면 서버에서 사용자가 어떤 질문에 대한 어떤 응답이 만족스러웠는지 트래킹할 수 있게 되죠.

다양한 예외 상황 처리

셀프서비스의 다른 지면에선 에러가 발생하는 경우 보통 얼럿을 띄우고 있는데, 챗봇에선 사용성을 위해 얼럿이 아닌 챗봇이 답변을 뱉는 식의 응답 버블을 통해 보여주게 되었어요. 그래서 에러핸들러를 따로 두고 예외처리도 서비스 레이어 안에 있는 답변제너레이터가 각 케이스마다 다른 응답 버블이 보이도록 처리하게 되었답니다.

답변제너레이터는 다양한 상황에 대응해 적절한 응답 버블을 반환하게 구성되어 있는데요, 예를 들어 금칙어가 포함된 질문을 한 경우 비속어가 있는 질문은 답변을 도와드릴 수 없어요. 라는 내용이 담긴 버블과 도움말 보기 버블을 울고 있는 표정의 로봇과 함께 전달합니다.

// [BE] 금칙어 포함된 경우에 대한 답변
public static BubblesWithExpression hasBanword() {
    final var preInfo = AnswerBubble.ofDescription(BubbleType.WHITE, HAS_BANWORD);
    final var postInfo = AnswerBubble.ofDescription(BubbleType.GUIDE, ANSWERBOT_GUIDE);

    return BubblesWithExpression.of(CRYING, List.of(preInfo, postInfo));
}
// 금칙어 포함된 경우에 대한 응답 json
{
    "answer": {
        "expression": "CRYING",
        "searchCompleted": false,
        "bubbles": [
            {
                "bubbleType": "WHITE",
                "description": "비속어가 있는 질문은\n 답변을 도와드릴 수 없어요.",
                ....
            },
            {
                "bubbleType": "GUIDE",
                "icon": "CircleInformation",
                "description": "도움말 보기",
                ...
            }
        ]
    },
    ...
}

그 외에도 서버에선 500, 400, 401 등 다양한 코드의 예외가 발생하는 경우에도 정상 응답과 같은 형태의 응답 버블을 프론트에 반환해야 해 동일한 처리가 필요했습니다.
예외가 발생할 수 있는 상황은 다음과 같습니다.

  1. 서버 연결 지연(질문 분석을 통한 대표질문 반환 API 콜 실패)
  2. 사용자가 입력 유효성 검사 실패
  3. 비속어가 포함된 경우
  4. 인증되지 않은 사용자인 경우

위 케이스들을 효과적으로 처리하기 위해 별도의 ExceptionHandler를 두어 응답의 일관성을 유지하고, 로깅 및 모니터링을 통해 개발자도 예외 상황에 대한 처리를 효율적으로 관리할 수 있게 했습니다.

개발자가 수고로우면 사용자가 편하다

만드는 사람이 수고로우면 쓰는 사람이 편하고, 만드는 사람이 편하면 쓰는 사람이 수고롭다.

우아한형제들 기술 블로그에서도 몇 번 언급된 문구이자, 우아한형제들 서비스의 중요한 가치 중 하나이기도 합니다.

단순 CRUD(생성/조회/수정/삭제)가 아닌, 경우의 수가 다양하고 자칫하면 사용성이 크게 저하될 수 있는 챗봇의 특성상 개발 단에서 고민도 많았습니다. 그리고 그 고민 속에서 ‘제 무덤을 스스로 팠다’라고 불렀던 일이 많았습니다. 주어진 스펙대로 기능 개발을 하는 과정에서 항상 실사용자 입장을 생각해보기 마련인데요, 아직 개발자 2년 차지만 그새 배운 점이 하나 있다면 개발자가 고생하면 사용자가 당황하거나 실수하는 일이 줄어든다는 것이었습니다. 그래서 챗봇 개발 과정에서도 조금 더 고생해서 사용성을 높여보자고 생각했습니다.

1초의 로딩

먼저 데이터 학습모델에서 응답을 찾는 과정부터 결과를 노출하기까지의 사용자 경험을 생각해 보았습니다. 사용자가 질문을 직접 입력할 때는 서버에 미리 저장된 응답이 아니라, 질문을 가공하여 데이터 학습모델에서 적절한 응답을 찾아옵니다. 사용자가 어떤 입력을 보냈는지에 따라 데이터 학습모델이 답변을 찾아오는 시간은 짧게 걸릴 수도, 길게 걸릴 수도 있습니다. 그런데 어떤 질문에는 빠르게 응답하고, 또 다른 질문에는 하염없이 응답이 지연된다면 사용자의 경험에 일관성이 떨어질 것이라고 생각했습니다.

기획 논의 결과, 사용자 직접 질문 전송 시 ‘최소 1초’ 동안 로딩 버블을 띄워준 이후 데이터 학습모델이 반환하는 응답을 보여주기로 했습니다. 이 과정에서 고려해야 할 점은, 질문 전송에 대한 응답 반환 시점이 다음 두 가지 경우로 나뉜다는 것이었습니다.

  • 데이터 학습 모델에서 응답이 1초 안에 반환되는 경우
  • 데이터 학습 모델에서 응답이 1초 이후에 반환되는 경우

먼저 new Date().getTime() 함수로 질문 전송~응답 반환까지의 소요 시간을 구합니다. 이때 응답 시간이 최소 로딩 시간(1초)보다 작을 때는(responseTime < minLoadingTime) 바로 로딩 스피너를 제거하고, 응답 시간이 더 길 때는 응답이 올 때까지 기다렸다가 로딩 스피너를 제거합니다. 데이터 학습 모델에서 응답을 찾는 데 걸리는 시간이 각 질문 케이스별로 다를 수 있기 때문에, 질문별 응답 시간의 격차를 줄이기 위해서 ‘최소 1초’의 로딩을 제공하여 일관성을 유지하도록 했습니다.

// [FE] 챗봇에 직접 질문을 전송하는 함수
@action.bound
async sendChat(message: string) {
  try {
    // 생략...
    this.addLoadingBubble();

    const minLoadingTime = 1000;
    const startTime = new Date().getTime();

    const response = await this.findAnswerByInput({
      inputText: originalText,
      categoryId: this.categoryId,
      hasBanword: detected,
    });

    if (!response) return;

    const endTime = new Date().getTime();
    const responseTime = endTime - startTime;

    if (responseTime < minLoadingTime) {
      setTimeout(() => {
        this.removeLoadingBubble();
        this.convertToAnswer(response);
        this.convertToFeedback(response);
      }, minLoadingTime - responseTime);
    } else {
      this.removeLoadingBubble();
      this.convertToAnswer(response);
      this.convertToFeedback(response);
    }
  } catch (e) {
      // ... 
    } finally { 
        this.isLoading = false;
    }
}

진짜 ‘검색 완료’?

이렇게 잠시 기다려 데이터 학습모델을 다녀온 질문이라 하더라도, 적절한 답변을 찾지 못하는 경우가 발생합니다. 이때는 상황에 맞지 않는 답변을 제공하는 것보다는 솔직하게 ‘답변을 드릴 수 없어요’라고 말하는 것이 더 좋은데요, 처음 받아본 챗봇 UI에 어색한 부분이 있었습니다.

사용자의 직접 질문에 대해 적절한 답을 찾았다면, 첫 번째 이미지와 같이 ‘검색 완료’ 표시와 함께 방금 찾은 답변 응답을 보여줍니다. 하지만 두 번째 이미지와 같이 답을 찾지 못했는데도 여전히 ‘검색 완료’를 표시하는 것은 이상하죠. 게다가 실제 내용은 ‘제가 답변을 드릴 수 없는 질문이에요’라고 했으면서요.

데이터 학습모델이 답변을 찾은 경우/ 찾지 못한 경우를 구별할 필요가 있다고 판단하였고, 결과적으로 서버에서 API에 searchCompleted 필드를 추가하여 케이스별로 다르게 노출되도록 했습니다. searchCompleted가 true이면 프론트에서 ‘검색 완료’를 표시하고, false이면 표시하지 않기로 했죠.

같은 질문에 대한 피드백을 계속 누른다면?

챗봇을 개발하고 혼자 이것저것 테스트를 해보다가, 데이터가 잘못 적재될 위험이 있는 부분을 발견했습니다. 바로 만족도 응답(‘도움이 됐어요/아쉬워요’) 버튼과 관련된 것이었습니다. 위에서 언급한 내용 중에, 사용자의 만족도/피드백 전송 시 서버에 현재 질문과 응답의 id를 전송하여 어떤 질문/응답에 대한 만족도/피드백 응답인지 확인할 수 있도록 처리하고 있다고 했습니다. 이를 위해 프론트에서는 최신의 질문/응답 id를 관리하고 있었고요.

그런데, 만약 사용자가 이미 지나가 버린 질문에 대한 만족도/피드백 응답을 선택한다면 어떻게 될까요?

두 가지 문제가 있습니다.

  • 프론트에서는 가장 최신의 질문/응답 id만 관리하고 있기 때문에, 과거의 만족도/피드백 전송 시 해당하는 id를 보내줄 수 없다.
  • 사용자가 같은 질문/응답에 대해 여러 번 만족도/피드백을 전송한다면 데이터가 어뷰징될 수 있다.

그래서 피드백, 카테고리 칩 선택 버블 등 특수한 버블에는 별도의 처리를 했습니다. 각 버블별로 unique한 id 값을 부여하여 사용자의 최초 선택 후에는 다시 선택할 수 없도록 dimmed 처리를 해주기로 했죠.

총 3가지 케이스가 있습니다.

케이스 1: 다시 선택이 가능한 카테고리 목록 특성상 그룹 버블 별로 활성/비활성화 상태를 구분
케이스 2: 이미 선택한/지나간 만족도 답변은 비활성화
케이스 3: 의견 제출 시 ‘의견 남기기’ 버튼은 비활성화

카테고리 목록 응답/ 만족도 응답/ 피드백 제출(’의견 남기기’) 버튼 응답에는 응답을 받아올 때 해당 응답에 uuid를 이용하여 unique한 key 값을 부여하고, 현재 챗봇 채팅창에 존재하는 모든 해당 특수 타입 응답의 그룹을 객체 형태로 만들었습니다. 예시로 ‘만족도 응답’에 대한 코드를 가져와 보았습니다.

// [FE] 챗봇 관련 상태값을 관리하는 스토어
/**
 * 예시: 만족도 답변(도움이 되었어요/아쉬워요) 그룹
 * 이미 선택한/지나간 만족도 답변은 비활성화시키기 위한 목적
 */
export default class ChatbotStore {
    @observable currentHelpfulId: string;
    @observable selectedHelpfulGroupIds: Record<string, boolean | undefined> = {};
}

그리고 만족도 선택 버튼 클릭 시에는 해당 타입의 객체에 방금 선택한 버블의 id를 key로 하여 새로운 값을 넣어주었죠.

// [FE] 만족도 답변 버블 클릭 시 호출되는 함수
/** 예시: 이미 선택한/지나간 만족도 답변은 비활성화 */

const handleClickFeedback = async (isHelpful: boolean) => {
  chatbotStore.selectedHelpfulGroupIds = {
    ...chatbotStore.selectedHelpfulGroupIds,
    [data?.helpfulGroupId]: isHelpful,
  };

  // ... 
};

이렇게 하면, 현재 채팅창에 존재하는 모든 만족도 응답 버블 중에서 어떤 버블이 비활성화(dimmed) 처리되어야 하는지 알 수 있습니다.

// [FE] 만족도 답변 버블 클릭 시 호출되는 함수
/** 케이스 2: 이미 선택한/지나간 만족도 답변은 비활성화 */

const FeedbackAnswerBubble = ({ data }: IReceiveProps) => {
    const handleClickFeedback = async (isHelpful: boolean) => {
      chatbotStore.selectedHelpfulGroupIds = {
        ...chatbotStore.selectedHelpfulGroupIds,
        [data?.helpfulGroupId]: isHelpful,
      };

      // ... 
    };

    // ✅ 버블 비활성화
    const isFeedbackDisabled = Object.keys(chatbotStore.selectedHelpfulGroupIds).includes(data?.helpfulGroupId);

    return (
    <div className={classNames(styles.feedbackAnswer, isFeedbackDisabled && styles.dimmed)}>
      {/* ... */}
    </div>
  );

이렇게 챗봇을 이용하는 사용자들의 입장에서 동작을 고려하여 개발자가 조금 더 고생하면 다음과 같은 문제를 개선할 수 있습니다.

  • 응답을 기다리는 동안 응답이 생성 중인지 알 수 없는 문제
  • 응답의 실제 내용과 다른 의미의 안내문구를 노출하는 문제
  • 같은 버튼을 여러 번 눌러 사용자의 인지 과정 및 플로가 꼬이는 문제

반면 사용자는 알아채기 어려울 수 있지만, 개발자로서 해결하고 싶은 문제들도 있죠. 셀프서비스의 챗봇을 스펙대로 개발 완료한 이후에 비로소 개발자의 문제들이 하나 둘 눈에 들어오기 시작했습니다.

몇 백 번의 리렌더링? 😱

개발자의 눈에 곧바로 들어왔던 문제, 바로 성능 이슈였습니다. 전송-응답 버블을 자바스크립트의 리스트에 담아 렌더링하는 방식으로 구현했던 터라, 버블이 새로 생성될 때 리스트의 모든 버블 목록이 리렌더링된다는 문제가 있었습니다. 수 십 개의 채팅이 오갈 수 있는 상황에서는 성능 문제가 우려될 수도 있죠.

그래서 각 버블에는 메모이제이션(memoization)을 통해 리스트 리렌더링 시 성능을 유지하도록 했습니다. 컴포넌트를 React.memo()로 래핑하여, 그리고 다음 렌더링이 일어날 때 props가 같다면, React가 memoizing된 컴포넌트를 재사용할 수 있도록 했죠.

물론 React.memo가 잘 적용될 수 있도록 Bubble 컴포넌트의 상위에서 Bubble 컴포넌트를 렌더링할 때 unique한 key 값을 부여하고, props에 넘겨주는 함수는 useCallback으로 감싸 이미 렌더된 Bubble 컴포넌트는 항상 같은 props를 받을 수 있도록 설정도 해주었습니다.

// [FE] 사용자 입력값 전송 버블 컴포넌트
const Send = memo(({ content }: ISendProps) => {
  return <div className={styles.sendBubbleWrapper}>{content}</div>;
});

const Receive = memo(...) 

const Bubble = { Send, Receive };
export default Bubble;

그러면 memoization 전후로 리렌더링 횟수가 얼마나 차이 나는지 볼까요? 각 버블에 리렌더링이 발생할 때마다 콘솔을 찍도록 코드를 수정 후 테스트해 보았습니다.

memoization 적용 전
memoization 적용 후

memoization 적용 전에는 매번 전체 채팅 목록을 리렌더링하여 금세 한 번에 백 개가 넘는 리렌더링이 발생하는 것을 볼 수 있습니다. memoization을 적용한 이후에는 새로 생성되는 채팅 버블에 대해서만 리렌더링이 발생하는 것을 볼 수 있습니다.

이런 개선 작업은 실사용자가 직접 체감하기는 어려울 수 있지만, 컴포넌트의 불필요한 리렌더링 횟수를 줄여 서비스가 더욱 고도화되었을 때도 안정적으로 동작할 수 있는 기반을 마련하는 중요한 과정입니다.

마치며

단순 데이터 조회/생성 기반의 개발이 아닌, 마치 대화하는 것 같은 즉시성을 제공하는 챗봇 기능의 특성상 서버 데이터와의 원활한 소통을 위해 많은 고민이 필요했습니다.

프론트에서는 자연스러운 사용자 플로를 구현하기 위해 다양한 형식의 응답을 마크업하고 로딩이나 검색 완료 여부를 제공하는 등 섬세하게 신경 써야 하는 부분이 많았습니다. ‘내가 실제 사장님이라면 어떻게 사용하고 싶을까?’하는 사용자 관점부터, ‘성능이나 중복 코드를 더 개선할 수는 없을까?’ 하는 개발자 관점에 이르기까지 치열한 고민을 하며 셀프서비스와 셀프서비스의 새로운 기능인 앤서봇에 더 깊은 애정을 갖게 되었습니다.

서버에서도 역시 프론트로 전달하는 데이터의 형식을 일관되게 유지하여 프론트에서 데이터 처리 작업을 최적화할 수 있게 했습니다. 현재는 질문과 답변을 수동으로 관리하고 있어 질문과 답변을 업데이트하는 과정에 다소 번거로운 부분이 있지만, 어드민과 배치를 통해 자동화하여 개선해 나갈 예정입니다.

배달의민족 사장님들의 장사를 돕기 위해 셀프서비스 챗봇은 계속해서 기술적인 개선을 이뤄내고 있습니다. 앞으로도 셀프서비스 챗봇에 많은 사랑과 관심 부탁드려요 ( •◡-)✧

송지은

우아한형제들에서 배민셀프서비스 웹프론트엔드개발을 담당하고 있습니다.

김영빈

우아한형제들에서 배민셀프서비스, 배민장부 백엔드 개발을 담당하고 있습니다.