안녕하세요 미소 MP Product 팀에서 개발자로 일하고 있는 김준욱입니다. 2015년 8월 16일부터 미소가 서비스를 시작해오면서 많은 일들이 있었고, 고객앱도 2016년 1월 23일에 처음 런칭이 된 만큼 앱에 많은 변화가 있었는데요. 그중에서도 저희가 가장 중요하게 생각하는 예약 시스템의 변화와 그 안에서 어떠한 개발적 결정들이 있었는지 이야기를 해보고자 합니다.

이런글을 굉장히 오랜만에 쓰고, 또 모두가 아실 수 있는 내용인데다가, 모든 코드를 상세히 공개할 수 없는 만큼, 조금 떨리고 걱정이 되기도 하는데요. 그래도 부풀은 마음을 안고, 이야기 할 수 있는 부분에서 한번 이야기를 해 보려고 합니다.

최초의 예약 시스템

최초의 예약 시스템은 저희가 급하게 앱을 런칭하게 되다 보니, 아무래도 많은 타 앱들을 벤치마킹하고, 그 당시의 최신 트렌드를 반영하여 예약 시스템을 만들었습니다. 바로 페이지형 방식인데요. 어떻게 되어 있는지 한번 저와 같이 보실까요?

(초기의 예약 페이지 - 페이징이 있는 방식이었다)
(오 의외로 나쁘지 않은데..?)

미소는 왜 채팅을 선택하게 되었는가.

이 예약 방식도 당시 트렌드를 따르고 나쁘지 않았지만, 아무래도 오리지널리티도 좀 떨어지고, 내부적으로는 여러가지 문제가 있었습니다. 크게 아래와 같은 이유가 있었어요.

  1. 서비스가 앞으로 가사도우미 뿐만 아니라 많은 서비스에 적용될 텐데, 다양한 서비스에 같은 예약 방식을 유용하게 적용하고 싶다.
  2. UX 적으로도 좌우보다는 상하로 하는것이 앱 사용(세로 화면)에 유리하다고 생각했다.
  3. 질문도 늘어날텐데, 질문이 늘어날수록 페이지가 늘어나는 것은 고객도 피로감을 느끼고, UX적으로도 좋지 않았다.

이러한 이유 때문에 당시 미소팀은 새로운 예약 방식을 고민하고 있었습니다. 그 때 떠올린것이 채팅 플로우였는데요. 당시 여러 SNS 플랫폼에서 챗봇을 통한 서비스들이 유행하고 있었고, 미소도 이런 식으로 채팅으로 하면 질문 양에 비해 느끼는 피로감이 줄어들고, 한 페이지에서 질문을 이동할 수도 있고 입력한 값을 볼수 있기 때문에 더 유리하다고 생각했습니다.

(한 때 정말 선풍적인 인기를 끌었던 심심이. 이런데서 아이디어를 얻으셨을 수도?!)

이제 남은것은 정말로 채팅으로 변경 했을 때 예약률이나 질문 선택의 결과 비율이 과연 정말로 오를 것인가였는데요. 그래서 고객 인증에 먼저 빠르게 도입해보기로 했습니다.

그렇게 해서 나온것이 현재까지 쓰고 있는 인증의 채팅 플로우입니다.

booking_system_1

빠른 프로토타이핑이었지만 사용한 렌더링 package의 완성도와, 프로토 타이핑에서 추가한 애니메이션의 반응이 좋았고, 실제로 채팅으로 인증을 변경한 이후에 더 좋은 결과가 나오게 되어서, 해당 시스템을 그대로 이용하여서 빠르게 채팅을 개발하게 되었습니다.

(인증 적용후 아주 NICE하게 올라갔던 인증 통과 비율)

모두가 아시다시피 이때의 react-native는 Class component만 지원하고 있었습니다.

따라서 life cycle 에 따른 state값 관리가 매우 중요했습니다. 그래서 componentDidMount, componentDidUpdate, render 이 세 함수를 잘 만드는것이 중요했고, 코드도 이 세가지에 집중하고 있었습니다. 각각의 함수에서는 아래와 같은 역할을 했습니다.

위 코드에서 보실수 있듯이, 당시의 react의 state 관리는 매우 복잡했고, 또 이대로라면 새로운 유형의 채팅 플로우를 만들때 스테이터스 관리를 매번 새롭게 만들어줘야 했습니다.

그렇다면, 채팅을 만드는 플로우는 과연 어떻게 되어 있었을까요?

당시의 플로우는 대략 아래와 같이 되어 있었습니다. (실제 플로우는 JSON으로 되어 있지만, 주석을 위해 javascript file로 각색했습니다.)

const flows = {
    "version": 1,
    "title": "가사도우미 예약",
    "events": {
        "start": "Booking Flow Start",
        "properties": {
            "serviceType": "가사도우미" //어떤 서비스를 예약할 것인가.
        }
    },
    "flows": [{
        "id": "#date",
        "type": "Common.DatePicker", //채팅 유형
        "contextProps": { //server에서 미리 내려준 context에서 읽어야 할 것
            ...
        },
        "predefinedProps": {
            ... //미리 정의된 부분
        },
        "fulfilled": [
            "booking",
            "date"
        ], //해당 값을 보고 pass 여부 결정
        "interactive": true //답변 이후에 다음으로 넘어갈지 결정
    },
    //....]
}

빠르게 만든 프로토타입에서 확장하다 보니, array로 된 flow를 쭉 찾으면서 사용자의 입력에따라 어디까지 skip할지를 정하는 부분이 있어서 디버깅에 어려움이 많았습니다. 또한 내부적으로 조건을 찾는 부분에서 플로우 자체에 코드 조건식을 string으로 넣어두고, 이 코드를 실행하는 등 비개발자가 수정하거나 질문을 추가하기 어렵고, 수정하더라도 런타임에서 앱이 crash가 나는 등의 문제가 발생하여 사용자들이 어려움을 겪는 경우가 많았습니다.

고객 앱 리팩토링 – Booking Flow Chat의 관점에서

위에서 상기한 문제점들이 있었지만, 그럼에도 불구하고 빠르게 만든 프로토타입이 잘 동작했기 때문에 서비스 확장(이사청소, 가전청소 추가)를 빠르게 달성할 수 있었고, 원하는 채팅의 개발이나 새로운 질문의 추가가 가능했기 때문에, 2020년까지 챗 서비스는 큰 틀의 변화 없이 새로운 질문 유형이나 답변을 추가하는 정도의 개발만 이루어지면서 그 기능을 잘 해주고 있었습니다.

하지만 2020년 React에서 함수형 컴포넌트(FC)가 대두가 되고, 개발자들이 새로 입사함에 따라서, 늦기 전에 고객앱과 파트너 앱을 리팩토링 및 전반적인 코드 버전업이 필요했고, 미소 프로덕트 팀은 고객앱을 먼저 함수형 컴포넌트로 변경하면서 typescript를 도입하는 작업을 결정합니다. 그러면서 채팅도 좀더 적은 인원으로 많은 확장을 할 수 있게, 비 개발자들도 JSON 문법을 안다면 채팅의 흐름이나 새 질문을 추가 할 수 있도록 전반적인 구조를 리팩토링 하게 됩니다.

리팩토링에서 중요한 부분은 아래와 같았어요.

  1. 다른 컴퍼넌트와 마찬가지로 Class component를 function component로 변환한다.
  2. JSON flow를 key object 기반으로 변경하고, 추후 자동화 및 비 개발자의 작업을 고려하여 단순화 한다.
  3. 챗을 렌더링 하는 인터페이스는 통합한다.
  4. 렌더링 부분을 두가지로 통합한다 (ChatQuestion FC, ChatAnswer FC)
  5. 채팅의 맨위 함수에서 이루어지던 props 및 스테이트의 변경을 Question에서 하고, 메인 Chat interface는 그것을 받아서, 다음 채팅으로 보내기만 한다.
  6. 각 질문을 구성하는 props를 type으로 정리하여 변경이나 추가시 일괄적으로 적용되게 한다.

모든 인터페이스를 보여드릴 수는 없기에, 가장 간단하고 저희 채팅 인터페이스에서 가장 많이 사용되는 보기 선택 Function component를 통해, 저희 채팅이 어떻게 구성되는지 간단히 보여드리려고 합니다.

먼저 가장 많은 분들이 보셨을 가사도우미 서비스의 반려동물 관련 여부를를 선택하는 질문을 예로 들어볼까요

booking_system_2
(바로 이 질문입니다!)

해당 질문의 JSON flow 는 아래와 같이 생겼습니다.

{
  "startKey": "checkAddress",
	"flowMap": {
		"checkPet": {
            "type": "CommonSelector",
            "title": "혹시 반려동물이 있나요?",
            "description": "",
            "target": ["booking", "checkpoints"],
            "nextKey": "checkDeepclean",
            "trackingLabel": "Select Pet Info",
            "option": [
                {
                    "label": "네, 있어요",
                    "value": "pet"
                },
                {
                    "label": "아니오",
                    "value": ""
                }
            ]
        },
	}
}

다른 부분도 있지만, 모든 flow에는 startKey 라는 값이 명시되어 있습니다. 이 값은 flowMap에서 어떤 key의 chat부터 시작할지를 정합니다. startKey 의 값에 checkAddress 라는 값이 명시되어 있기 떄문에 flowMap에 어딘가에 있는 checkAddress(실제 플로우에선 존재하지만 간소화를 위해 여기서는 일단 뺐습니다.)를 찾아서 채팅이 시작될껍니다. 그렇게 각각의 flow parts 안에 설정되어 있는 nextKey 를 따라서 오다가 그 값이 checkPet 이 되는순간 메인 인터페이스의 상황은 아래와 같습니다.

//currentFlow에 위의 flow가 들어온 상황.
useEffect(() => {
    //애니메이션 딜레이를 고려
    setTimeout(() => {
        if (currentFlow.key !== previousFlow?.key) {
            const { key, flow } = currentFlow;
            /**
             Flow가 chat이 아니고 백그라운드에서 흐르는 조건 관련 플로우 일 경우,
             조건을 변경하고 바로 다음 플로우로 간다.
             */
            if (flow.type === 'ConditionChecker') {
                return checkCondition(flow, context);
            } else if (flow.type === 'ReducerConditionChecker') {
                return checkReducerCondition(flow);
            }
            // 질문을 찾아서 보여준다.
            const question = getChatViewMessage(key, flow);
            currentUuid.current = question._id.toString();
            //채팅을 확장한다.
            appendMessages();
        }
    }, chattingUIDelayTime);
}, [currentFlow]);

이제 chatingUIDelayTime 이후에 getChatViewMessage 를 통해 위 질문의 ChatQuestion 이 렌더링되게 됩니다. 이제 사용하고 있는 챗 렌더링 패키지에서 렌더링 함수를 커스터마이징을 했는데요. 아래와 같이 렌더링 해주게 됩니다.

export const getParts = (parts: Parts): ReactElement => {
    const { flow } = parts;
    const key = flow.type;
    const part = partsList[key] || null;

    if (part) return React.createElement(part, parts);
		//혹시나 없는 parts가 왔을때 예외 처리
    return (
        <ChatQuestion title={flow.title} description={flow.description} showChildren={false} info={flow.info}>
            {flow.option}
        </ChatQuestion>
    );
};

react의 createElement 를 이용해 ChatQuestion을 가지고 있는 다양한 Parts들을 불러오게 됩니다.
그러면 이제 간단한 질문을 선택하는 CommonSelector Component 가 createElement를 통해 생성이 되는데요. 코드 구성은 대략적으로 아래와 같습니다.

....
type CommonSelectorProps = Parts;
//위에서 설명했던 미리 정의된 json으로 부터 가져온 값으로 props를 만든다.
const CommonSelector: FC<CommonSelectorProps> = (props: CommonSelectorProps) => {
    const { flow, onSubmit, current, context } = props;

		
    const option = flow.option as CommonSelectorOption;

    const [show, setShow] = useState<boolean>(current);

    const onPressOption = (button: ChatButton) => {
        setShow(false);
        const nextKey = button.nextKey || flow.nextKey;
        onSubmit({ text: button.label, value: button.value, nextKey });
    };

    const description = getDescription(flow, context);

    return (
        <ChatQuestion title={flow.title} description={description} showChildren={show} info={flow.info}>
            <View>
                <ButtonListSelector options={option} onPressOption={onPressOption} />
            </View>
        </ChatQuestion>
    );
};
export default CommonSelector;

flow의 option에 보시다시피 버튼이 셋팅되어 있기 때문에 ButtonListSelector 를 통하여 버튼이 렌더링 되게 되고, 그 버튼을 눌렀을때 onPressOption 이 실행되면서 다시 메인 인터페이스로 고객님이 선택한 값을 submit해주는거죠. 이 값을 바탕으로 이제 ChatAnswer 가 렌더가 될텐데요. 메인 렌더의 onSetContext 로 돌아가게 됩니다.

booking_system_3
(바로 이 상황입니다!)
const onSetContext = (submit: FlowSubmit): void => {
    //submit에서 각각의 변수 받음
    const { text, value, nextKey, delayTime, answer = true, endChat = false } = submit;

    const run = () => {
        const { key, flow } = currentFlow;
        //답변시 고객님의 답변을 채팅에 추가함.
        if (answer) {
            const answer = getAnswerMessage(text, key);
            appendMessages();
        }
        //질문 선택등 고객의 예약 정보를 저장하는 컨텍스트를 업데이트
        const newContext = makeNewContext(context, flow.target, value);
        // tracking이 필요할시 tracking
        if (!isEmpty(flow.trackingLabel))
            analytics.track(...);
        setContext(newContext);

        //채팅이 끝날 시에 어떤 행동을 할지 결정.
        if (endChat) {
            const option = flow.option as ChatEndOption;
            return showEndChatView(option);
        }

        //다음 질문으로
        const nextFlow = chatData.flowMap[nextKey];

        //채팅 JSON 오류시 예외 처리
        if (isEmpty(nextFlow)) return alertMsg(`key: ${nextKey} flow is Missing`, 'chat error');

        //설정된 다음 플로우를 통해 다시 처음의 useEffect로!
        setCurrentFlow({ key: nextKey, flow: nextFlow });
    };
    if (delayTime) setTimeout(run, delayTime);
    else run();
};

onSetContext 에서는 이제 아까 submit에서 바꾼 값을 바탕으로 답변을 할지 말지 결정하고, 고객의 답을 context에 업데이트 하게 된 이후에 context에서 다음 nextKey 를 찾아 다시 위의 useEffect로 보내는 작업을 하게됩니다.

state diagram을 통해 간단히 다시 요약해볼까요?

대략 위와 같은 상황이고 end chat에서는 chat이 끝날 때의 행동이 정의되어 있어서 다양한 기능을 실행하게 됩니다. (새 서비스 예약, 서비스 예약 변경 등등)

이렇게 FC를 통해 간단하게 채팅을 변경하면서, parts의 확장이나 condition checker의 확장을 통해 쉽게 채팅에 새 기능을 넣거나 기능을 수정할 수 있게 되었습니다.

앞으로의 과제

이렇게 채팅을 변경하게 되면서 현재 미소에서는 200개에 가까운 서비스를 동시에 최소의 인원으로 서비스하면서 갈 수 있게 되었고, 또 간단한 자동화툴을 통해 많은 채팅을 자동으로 생성하고 업로드 하며 관리하고 있습니다. 다만 현재의 채팅이 완벽하다고 볼 수는 없습니다. 현재 채팅에서 남은 과제는 아래와 같은데요.

  1. 현재 채팅 상태를 저장하고 다시 들어왔을 때 바로 방금 전에 답한 질문 부터 시작
  2. 현재 질문을 캔슬하고 이전 질문으로 돌아가기
  3. 다른 서비스간에 채팅 질문 연동하기
  4. 자동화 툴을 발전시켜 채팅 서비스 관련 내부 사이트 툴 개발 및 서버를 통한 실시간 채팅 관리

각각의 과제들을 간단히 설명드리면, 1번과 2번은 현재 구조를 보시면 아시다시피 셋팅된 context들을 localStorage 에저장하고 불러오는 기능을 만듬으로서 구현할 수있고, 이전 채팅으로 돌아가는것도 state diagram에서 취소관련 state를 추가하면 쉽게 달성 할 수 있으나, 현재 여러가지 우선순위에서 밀려 구현되지 못하고 있습니다. 다른 서비스간에 채팅 질문 연동은 현재 구상을 해보지는 않았는데, context를 연동하는 툴이 룰이 있다면 할 수 있을꺼라고 생각합니다. 마지막으로 자동화툴을 발전시켜서 채팅 관리및 s3 자동 업데이트를 통해 앱 버전에 따른 호환되는 최신 채팅 플로우를 주는 사이트도 구상에 있습니다만, 현재 Product team의 우선순위나 회사의 중요 OKR에서 밀려 기술 부채로 남아 있네요 ㅠㅠ.

(현재 업무에 치어 능이버섯이 되어버린 나..)

따라서, 능이버섯이 된 저와 함께 더 나은 채팅 서비스 개발과, 더 많은 재미있는 일들을 할 수 있는 천재님들을 모시고 있습니다! 미소의 개발자 채용은 현재 진행형으로 진행중입니다. 채용 공고는 아래 페이지에서 확인할 수 있습니다. 수많은 저를 뛰어넘는 뛰어난 개발자님들의 지원을 환영합니다! (커피 챗 언제나 대기 중입니다.)