복잡한 온보딩 플로우를 선언적으로 관리하기 [라이브러리 개발]


처음 온보딩 기획을 받았을 때, 가장 먼저 든 생각은 “이걸 어떻게 코드로 관리하지?”였다.

  • 아래 그래프도 실제 온보딩 플로우는 아니다. 이거보다 두 배 정도 복잡하다고 보면 된다.

단순히 단계가 많은 문제가 아니었다.
유입 경로마다 시작 지점이 달랐고, 어떤 유저는 이미 특정 온보딩을 봤을 수도 있었으며, 채팅 횟수 등 다양한 조건에 따라 흐름이 계속해서 갈라졌다.

이 모든 조건을 페이지 단위에서 if / else로 처리하기 시작하면, 온보딩 로직은 금방 서비스 전반으로 퍼질 게 분명해 보였다.

그래서 처음에는 기존에 잘 알려진 라이브러리들을 찾아봤다.
“채팅 5번 이상”, “이 온보딩을 아직 안 본 경우” 같은 조건을 선언적으로 관리할 수 있는 도구가 있을 거라 기대했지만, 실제로는 특정 use case에만 맞거나, 오히려 추상화가 과해 우리 상황에 맞추기 어려운 경우가 많았다.

무엇보다 “온보딩을 하나의 흐름이 아니라, 여러 흐름이 동시에 경쟁하는 상태”로 다루는 도구는 찾기 힘들었다.

결국 접근 방식을 바꿔서 온보딩을 화면이나 페이지의 문제가 아니라, 유저의 상태를 따라가는 여정(Journey)으로 정의하기로 했다.

어디서 시작하고, 어떤 조건에서 다음으로 넘어가며, 중간에 이탈했을 때 어디로 되돌아와야 하는지까지 모두 코드로 선언할 수 있다면, 복잡한 온보딩도 관리 가능한 문제가 될 것이라 생각했다.

그렇게 해서 개발한 것이 이 Journey 기반 온보딩 라이브러리다.

온보딩은 graph로, 단계는 step으로 정의하고, UI에서는 그저 이 단계가 끝났다고 선언적으로 알려주기만 하면 된다.

조건 판단과 분기, 우선순위, 재시작 로직은 전부 이 라이브러리가 책임지고, 클라이언트에서는 선언적으로 다음 단계 호출만으로 쉽게 온보딩 플로우가 흘러가도록 하는 것을 최종 목표로 삼았다.


Journey의 핵심 개념은 OnboardingStep이다.

1. Onboarding
하나의 온보딩 플로우를 정의한다.

“이 온보딩은 어떤 유저에게 어떤 상황에서 시작되는가?”를 선언적으로 정의하여 특정 컴포넌트에 온보딩 시작 로직이 종속되지 않도록 하였다.

TypeScript
guard: ({ util }) =>
  util.getQueryParams('appOnboarding') ===
  'creationOnboardingFeatureOnboardingFirst'
  • appOnboarding=creationOnboardingFeatureOnboardingFirst이면 온보딩 시작

2. step
온보딩 안에서의 개별 단계를 나타낸다. step은 전역적으로 공유되어서 step 이름이 같으면 다른 onboarding에 정의돼 있어도 같은 step으로 취급한다.

이 덕분에 paywallOnboarding 같은 여러 온보딩에 들어있는 Step을 어느 플로우를 타도 한 번만 경험하도록 제어할 수 있다.


OnboardingStep의 위상정렬된 형태이기 때문에, step에는 next가 존재한다.
next는 정상적으로 완료되었을 때 이동할 다음 step을 의미한다.

TypeScript
next: 'nameInputOnboarding'

그리고 이 라이브러리는 온보딩 중 강제로 앱을 껐을 때에도 온보딩을 이어서 할 수 있도록 설계되었다.

즉, “유저가 단계 도중에 흐름을 이탈했을 때, 다시 돌아오면 어디서부터 이어야 하는가?”를 담당한다.

TypeScript
restartHandler: ({ util }) => {
  const chatroomId = util.getPersistentStore('onboardingChatroomId', "")
  util.goTo(`/onboarding/chat?chatroomId=${chatroomId}`)
}

여러 Onboarding은 동시에 실행될 수 있으나, 사실 좋은 구조는 아니다.
하지만 한 페이지에 여러 온보딩이 있는 경우에는 우선순위를 정의해서 실행해야 한다.

priority: 100 와 같이 Onboarding이나 Step에 우선순위를 부여해서 온보딩 실행 조건을 동시에 만족시켰을 때 먼저 실행할 온보딩을 지정할 수 있다.

물론, 같은 페이지에서 여러 온보딩이 완전독립이라 동시에 일어나도 괜찮을 때, 같은 우선순위를 가지도록 하면 두 온보딩 모두 동시에 실행 가능하다.


위 내용은 모두 라이브러리 단에서 미리 정의돼야 하는 것들이다.
왜냐하면 온보딩 플로우는 런타임에서 변경되지 않기 때문이다.

앱에서 온보딩 플로우를 실행할 때는 매우 간단하게 선언적으로 사용할 수 있다.

아래 함수 하나로 다음 스텝으로 이동할 수 있다.
호출 시 현재 활성화 된 Onboarding을 찾아 현재 Step을 완료처리하고 다음 Step으로 이동할 수 있다.

TypeScript
goNextStep()

글로만 보면 뭔가 복잡해보이는데, 아래 예시 온보딩 플로우를 보자.

  • '/onboarding?group=A로 들어오면 온보딩 firstOnboarding 이 시작된다.
  • “Start Chat” 버튼 클릭 시 /onboarding/chat 으로 이동한다.
  • 채팅을 다섯 번 하면 /payment로 이동한다.

[기타 고려사항]

  • /onboarding에서 나갔다가 앱을 다시 들어와도 /onboarding로 다시 이동해야 한다.
  • /onboarding/chat에서 나갔다가 앱을 다시 들어와도 /onboarding/chat로 다시 이동해야 한다.

위 온보딩 플로우를 그래프로 나타내면 아래와 같다.

TypeScript
export const graphs = {
  firstOnboarding: {
    name: 'firstOnboarding',
    guard: ({ util }) => util.getQueryParams('group') === 'A',
    steps: [
      {
        step: 'onboardingFirstStep',
        next: 'onboardingChatStep',
        restartHandler: ({ util }) => {
          util.goTo('/onboarding')
        },
      },
      {
        step: 'onboardingChatStep',
        next: 'paymentStep',
        restartHandler: ({ util }) => {
          util.goTo('/onboarding/chat')
        },
      },
      {
        onboardingName: 'firstOnboarding'
      },
    ],
  },
}
  • restartHandler를 통해 앱을 다시 로딩했을 때, 다시 온보딩 페이지로 이동할 수 있다.

1. /onboarding

만약 이 페이지로 들어왔는데 group=A가 Parameter로 달려있으면 자동으로 온보딩이 활성화된다.
이는 라이브러리에서 정의해놓았으므로 조건이 만족되면 자동으로 활성화된다.

버튼 클릭 시 goNextStep()을 호출하기만 하면 된다.
그러면 finishHandler가 실행되고, /onboarding/chat로 이동한다.

TypeScript
function OnboardingPage() {
  const { goNextStep } = useJourney()

  useEffect(() => {
    // onboardingFirstStep이 완료되면 실행될 핸들러 
    setFinishHandler(
      'firstOnboarding',
      'onboardingFirstStep',
      () => {
        router.push('/onboarding/chat')
      },
    )
  }, [])

  return (
    <button
      onClick={() =>
        // 클릭 시 다음 단계로 이동, finishHandler가 동작한다.
        goNextStep()
      }
    >
      채팅 시작하기
    </button>
  )
}

2. /onboarding/chat

채팅을 5번 했으면 goNextStep()을 호출하면 된다.
역시 finishHandler가 실행되고, /payment로 이동한다.

TypeScript
function ChatPage() {
  const { setFinishHandler, goNextStep } = useJourney()
  const chatCount = useAtomValue(chatCountAtom)
  const router = useRouter()

  useEffect(() => {
    setFinishHandler(
      'firstOnboarding',
      'onboardingChatStep',
      () => {
        router.push('/payment')
      },
    )
  }, [])

  useEffect(() => {
    // 채팅 5번 하면 다음 단계로 이동,  finishHandler가 동작한다.
    if (chatCount >= 5) {
      goNextStep()
    }
  }, [chatCount])

  return <ChatUI />
}

3. /payment

버튼을 누르면 Onboarding이 완료된다.
내부적으로는 다음 Step이 없으므로 자동으로 온보딩 완료 처리가 된다.

TypeScript
function PaymentPage() {
  const { goNextStep } = useJourney()
  const router = useRouter()

  useEffect(() => {
    setFinishHandler(
      'firstOnboarding',
      'paymentStep',
      () => {
        router.push('/')
      },
    )
  }, [])

  return (
    <button
      onClick={() =>
        // 클릭 시 온보딩 종료, finishHandler가 동작한다.
        goNextStep()
      }
    >
      결제 완료
    </button>
  )
}

굉장히 다양한 Util을 제공해서 Pathname / QueryParam 뿐만 아니라, Local Storage / Jotai 기반 컨트롤도 가능하다.

TypeScript
export interface UtilAPI {
  getPathname: () => string
  getVariant: (experimentName: string) => string
  getQueryParam: (key: string) => string | null
  getJourneyComplete: (onboardingName: string) => boolean
  getStepCompleted: (onboardingName: string, stepName: string) => boolean
  getForceFinished: (onboardingName: string) => boolean
  getAtomValue: <T>(key: string) => T | undefined
  inject?: <T>(key: string, value: T) => void
  goTo: (path: string) => void
  setPersistentStore: (key: string, value: string) => void
  getPersistentStore: (key: string, returnWhenNull?: string | null) => string | null
}

또한 훅에서는 여러 API를 제공해서 Onboarding과 Step의 상태를 확인할 수 있다.

TypeScript
getJourneyStates() => JourneyState

getOnboardingStates() => OnboardingState[]
getOnboardingState(onboardingName: string) => OnboardingState
isOnboardingCompleted(onboardingName: string) => boolean

getStepStates(onboardingName: string) => StepState[]
getStepState(stepName: string) => StepState
isStepCompleted(stepName: string) => boolean

내부 구현은 복잡하지만 최대한 사용하기 쉽도록 추상화를 시켜서 복잡한 온보딩 플로우를 쉽게 선언적으로 사용할 수 있었다.

우선 온보딩 로직이 한 곳에 모이기 때문에 페이지마다 if 문을 사용할 필요가 없다.
그리고 새 온보딩 추가가 매우 쉽다. 온보딩 그래프에 Onboarding과 Step만 정의하면 된다.


또한 기획 변경에 강해서 Step 순서 변경이나 분기 조건이 바뀌어도 Step의 Field를 변경하기만 하면 된다.


위 라이브러리를 구상부터 개발하는데 딱 하루 걸렸다.
그리고 위 라이브러리를 사용해 실제 온보딩 플로우를 구현하는데에도 하루 걸렸다.

내 생각엔 라이브러리 개발 없이 if / else 로 단순하게 모든 플로우를 처리했으면 QA랑 온보딩 플로우를 수정하는데에만 며칠을 더 썼을 거라고 생각한다.

이 라이브러리는 단순 온보딩 플로우 처리 뿐만 아니라, A/B 테스팅에 따른 온보딩 플로우 분기에서 특히 좋은 모습을 보여주었다.

온보딩이 단순한 서비스라면 이런 구조는 분명히 과할 수도 있지만, 본 글 맨 위에 첨부한 온보딩 플로우 사진을 보면 충분히 도입 할 만한 정도의 복잡성이었다고 생각한다.

그리고 만약 온보딩이 단순하더라도 마케팅 유입이 늘어나고, 실험이 많아질수록 온보딩은 빠르게 복잡해질 수 있고, 그 복잡성은 결국 코드의 부담으로 돌아온다.

Journey 구조를 도입한 이후로, 온보딩 관련 변경이 훨씬 덜 두려워졌다.
새로운 플로우를 추가해도 기존 로직을 크게 건드리지 않아도 되었고, 특정 조건의 온보딩을 끼워 넣는 것도 매우 수월해졌다.

무엇보다 온보딩 로직이 한 곳에 모여 있어서, 프론트 코드에 온보딩 비즈니스 로직이 없다는 점이 가장 큰 안정감을 주었다.

이 글이 비슷한 고민 해 본 누군가에게 “아, 이런 식으로도 해결할 수 있구나” 정도의 느낌만 줄 수 있다면, 그것만으로도 이 라이브러리를 만든 보람이 충분하다고 생각한다.

댓글 남기기

Dalmeng's Footprints에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기