Frontend

Tree로 구현한 cancel survey flow

mechaniccoder 2023. 9. 6. 23:57

배경

최근 회사에서 유저가 구독 취소 이유를 여러 단계에 걸쳐 form으로 입력받는 기능을 개발했습니다. churn rate을 줄이기 위해 비지니스적으로 변경이 많은 기능인데요. 이러한 변경에 유연하게 대응하기 위해 tree 데이터 구조를 활용하여 개발한 경험을 공유하려 합니다.

구독 취소 기능

실제 구현을 설명하기에 앞서 구독 취소 기능을 간략히 설명하겠습니다. 기존의 구독 취소는 한가지 이유를 선택하고 submit하는 간단한 스텝으로 이뤄져 있었습니다.

그러나 시간이 흐름에 따라서 churn rate이 높아지고 있어서 더 상세한 이유를 알아야만 했죠. 그래서 유저가 선택한 항목의 하위 항목들을 만들고, 선택한 하위 항목에 따라 여러 스텝들이 있도록 기획이 변경됐습니다. 말로만 설명하기는 어려우니 실제 사진으로 살펴보겠습니다.

1. 구독 취소 이유 선택

유저가 구독 취소하려고 버튼을 클릭하면 왜 취소하는지 이유를 선택하는 화면입니다. 6가지 이유가 있습니다.

  • 서비스에 문제가 있다.
  • 더 이상 서비스가 필요하지 않다.
  • 다른 서비스를 선호한다.
  • 서비스가 제공하는 목소리에 문제가 있다.
  • 너무 비싸다.
  • 기타

2. 구독 취소 하위 이유 항목

유저가 "서비스가 제공하는 목소리에 문제가 있다."를 선택했다고 하겠습니다. 그러면 하위 항목을 선택할 수 있는 화면으로 전환됩니다. 해당 예시에서는
7가지 예시가 있네요.

  • 목소리가 자연스럽지 않다.
  • 목소리 발음이 별로다.
  • 목소리에 버그가 너무 많다.
  • 글로벌 목소리가 별로다.
  • 목소리 생성이 너무 느리다.
  • 마음에 드는 목소리가 없다.
  • 기타

3. 구독 취소 하위 이유에 대한 추가 스텝

유저가 "목소리가 자연스럽지 않다."를 선택했다고 하겠습니다. 그러면 왜 그렇게 생각하는지 이유를 작성해야 합니다.

4. 구독 취소 완료

최종적으로 구독 취소를 합니다.

구독 취소 구현

앞서 설명드린 구독 취소 기능을 다이어그램으로 정리해보면 아래와 같습니다. 다이어그램을 이해하기는 어려우니 다음과 같은 조건들만 알아주시면 됩니다.

  • 구독 취소 이유를 선택하면 그에 맞는 하위 이유를 선택해야 한다.
  • 하위 이유를 선택하면 그에 맞는 추가적인 스텝들으 진행해야 한다.

이를 기반으로 생각하면 tree 구조가 적절해보입니다. 아래 이미지에서 구독취소는 여러 자식 노드들을 가지고 있고 목소리 이슈도 여러 자식 노드를 가지고 있습니다. "부자연스럽다" 노드는 하나의 자식을 가지죠. 이를 tree를 사용하여 구현하면 항목 추가 삭제 및 스텝 추가 삭제가 쉬워질거라 생각했고 이를 구현했습니다.

실제 코드로 이를 구현해보겠습니다.

class CancelReasonTree {
  name: string;
  title: string;
  description: string;
  options: SelectOption[];
  children: Record<string, CancelReasonTree>;

  constructor(name: string, title: string, description: string, options: SelectOption[]) {
    this.name = name;
    this.title = title;
    this.description = description;
    this.options = options;
    this.children = {};
  }

  addChild(key: string, child: CancelReasonTree) {
    this.children[key] = child;
  }

  findNode(step: number, values: string[]): CancelReasonTree | null {
    if (values.length === 0) {
      return this;
    }

    let current: CancelReasonTree = this;

    for (let i = 0; i < step; i++) {
      const value = values[i];
      if (current.children[value]) {
        current = current.children[value];
      } else if (Object.keys(current.children).length > 1) {
        return null;
      } else if (Object.keys(current.children).length === 1) {
        current = Object.values(current.children)[0];
      } else {
        return null;
      }
    }

    return current;
  }
}

자식 노드를 객체인 children 필드로 관리해줍니다. 여기서 알아야할건 현재 스텝이 여러 가지 항목 중 하나를 선택해야 한다면 선택 값을 객체의 키로 관리하고 이에 해당하는 노드를 값으로 돌려준다는 점입니다. 예를 들어 목소리 이슈의 경우 4가지 선택 항목이 있기 때문에 객체는 다음과 같습니다.

{
    '부자연스럽다': 부자연스럽다 노드,
    '발음이 별로다': 발음이 별로다 노드,
    '버그가 많다': 버그가 많다 노드,
    '느리다': 느리다 노드
}

버그가 많다를 선택한 경우 해당하는 노드를 가져오게 됩니다.

현재 스텝이 선택하는 것이 아니라 이전 스텝에 관련된 추가적인 스텝인 경우에는 하나의 property를 가지도록 설계했습니다. 예를 들어 앞서 선택한 버그가 많다에 대해 추가적인 스텝을 해야하는 경우 객체를 다음과 같이 구성했습니다.

{
  '버그가 많다 추가 스텝': 버그가 많다 추가 스텝 노드
}

findNode 메소드를 보면 알 수 있다시피 스텝에 매칭되는 키를 가지거나 하나의 property만을 가졌을때 해당하는 노드를 가져옵니다. 만약 null을 반환하는 경우 마지막 confirm 단계로 넘어가게 됩니다.

마치며

알고리즘 문제를 풀때만 tree 구조를 사용했었는데 실제 실무에 적용해보니 색다른 느낌이었습니다. 이론으로만 알고 있던 지식이 실제 세상에도 도움이 되는구나! 이게 데이터 구조가 가지는 힘이구나 라는 생각이 들었던 것 같네요. 데이터 구조 알고리즘에 대한 이해도를 높여 실무에서 적절한 상황에 사용할 수 있도록 꾸준히 노력해야겠네요.