04. Toss@use-funnel + React Native + TypeScript

Gardener·2025년 6월 11일

WatSeo

목록 보기
4/4

0. 들어가며

저번 시간에서는 Web 환경에서 어떻게 use-funnel의 뼈대(?)를 세울 수 있을지 이야기 해보는 시간이었다면, 이번 시간에는 App 환경에서의 적용이다. React Native + TypeScript + use-funnel 의 적용이 힘들었다. TypeScript 에서 해당 context나 useFunnel의 Type을 맞추는 과정이 힘들었다. 생각보다 정보들이 많이 없어서, 사실 이것저것 다 해보면서 노가다 수준으로 Type을 짜맞출 수 있었다. 내가 풀어낸 흐름에 맞게 설명을 해볼 수 있도록 하겠다.

교회에서 사용할 출석 체크 앱을 만들어보고 있었다. 이 때문에 변수나 placeholder의 내용들이 교회 관련일 수 있다는 것을 명시한다.

1. 태초에 Type이 있었다.

전에도 알아보았듯이, 먼저 use-funnel을 올바르게 사용하기 위해 타입 구조 설계에 대한 내용이 필요하다.

export type SignUpFunnelSteps = {
  church: object; // {} 빈 객체로 두어도 되지만 object로 명시.
  name: { church: string };
  password: { church: string; name: string };
  role: { church: string; name: string; password: string };
  complete: { church: string; name: string; password: string; role: string };
};

각 단계에서 구성되는 context에는 반드시 전 단계의 해당 내용이 필요하다. 내가 설계한 구조는 church -> name -> password -> role -> complete 단계이다.

2. UseFunnelOptions

interface UseFunnelOptions<TSteps> {
  /** 퍼널 고유 ID (리액트 네비게이션 상태에 붙일 때 사용) */
  id: string;

  /** 시작 스텝과 그때의 컨텍스트를 지정 */
  initial: {
    step: keyof TSteps;
    context: TSteps[keyof TSteps];
  };

  /** 각 스텝별 진입 조건(타입 가드) */
  steps: {
    [K in keyof TSteps]: {
      guard: (ctx: any) => ctx is TSteps[K];
    };
  };
}

UseFunnelOptions는 useFunnel<T>(...) 훅에 넘겨주는 설정 객체 의 타입을 정의한 인터페이스이다. React Native 에 적용할 때 엄격한 타입 적용을 이유로, funnelOptions 객체를 직접 만들어서 usefunnel() 에 넘겨줄 때, 그 객체가 정확히 이 구조를 따르고 있는지 강제 검증 시켜주기 위함이다.

나는 아래와 같이 작성했다.

import { type UseFunnelOptions } from '@use-funnel/react-navigation-native';
import type { SignUpFunnelSteps } from '@screens/SignUpFunnel/types';

export const funnelOptions: UseFunnelOptions<SignUpFunnelSteps> = {
  id: 'signup-flow',
  initial: {
    step: 'church', // 첫 번째 스텝 키
    context: {}, // SignUpFunnelSteps['church'] === {}
  },
  steps: {
    church: {
      // 반드시 (ctx) 파라미터를 받고, ctx is TContext 형식의 리턴 타입을 가진다
      guard: (ctx): ctx is SignUpFunnelSteps['church'] => true,
    },
    name: {
      guard: (ctx): ctx is SignUpFunnelSteps['name'] =>
        typeof ctx.church === 'string',
    },
    password: {
      guard: (ctx): ctx is SignUpFunnelSteps['password'] =>
        typeof ctx.name === 'string',
    },
    role: {
      guard: (ctx): ctx is SignUpFunnelSteps['role'] =>
        typeof ctx.password === 'string',
    },
    complete: {
      guard: (ctx): ctx is SignUpFunnelSteps['complete'] =>
        typeof ctx.role === 'string',
    },
  },
};

이 때 import { type UseFunnelOptions } from 에서 type으로 가져오는 이유는, 빌드 단계에서 오직 타입 검사의 용도로만 사용되기 위해서이다.

3. 구현

기초적인 interface와 type에 대한 설계는 이제 구축되었다. 이제 해당 Step들에 대한 funnel을 직접 구현할 차례이다. 이후에 funnel의 step이나 context가 어떤 정보를 담고 있고, 어떤 type을 담고 있는지에 대한 검증을 거쳐야겠다.

아래는 내가 구현한 ChurchStep funnel의 일부이다. (구현부는 굳이 담지 안았다. 우리에게 필요한 부분은 use-funnel과 React Native를 이어주는 Type이니까 말이다.

주목해서 볼 부분은 StepProps<'church'> 이다.

import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, Alert } from 'react-native';

import { StepProps } from '@screens/SignUpFunnel/types';
import { stepStyles as cs } from '@screens/SignUpFunnel/styles/Step.styles';

export default function ChurchStep({ onNext }: StepProps<'church'>) {
 
  // 구현
  return(...);
}

갑자기 튀어나온 StepProps는 위에서 선언한 SignUpFunnelSteps에 대해 FunnelStep을 적용한 타입 별칭이다.

export type StepProps<K extends keyof SignUpFunnelSteps> = FunnelStep<
  SignUpFunnelSteps,
  K
>;

K extends keyof SignUpFunnelSteps : SignUpFunnelSteps 가 church,name,password,role,complete 중 하나만 들어올 수 있도록 제약을 건 것이다.

StepProps<'church'> : 위와 같이 K 에는 SignUpFunnelSteps에서 사용된 key의 값만 들어올 수 있다.

그렇다면 FunnelSteps<TSteps, TStepKey> 를 설정한 이유는 무엇일까?

각 스텝의 컴포넌트가 받는 props를 정의하기 위해 받았다. (아래 예시 코드를 보면서 이 Type이 어디 사용되는지 이해할 수 있을 것이다. 그 전에 먼저 어떻게 설정했는지 알아볼 필요가 있다.)

export type FunnelStep<TSteps, TStepKey extends keyof TSteps> = {
  context: TSteps[TStepKey];
  onNext: (ctx: TSteps[TStepKey]) => void;
  onPrev: () => void;
};

내가 설정한 FunnelStep의 props는 context, onNext, onPrev 가 있다.

context :해당 스텝의 context type
onNext : 다음 스텝으로 전달할 컨텍스트를 받는 것.
onPrev : 이전 스텝으로 돌아가는 Callback

나아가 TSteps, TStepKey에 대해 알아볼 필요가 있다. (해당 이름들은 그냥 제네릭 파라미터 이름일 뿐이며 다른 이름으로 바꿀 수도 있다.)
TSteps : 전체 Funnel의 Step 이름을 Key로, 각 스텝의 컨텍스트 타입을 값으로 매핑한 객체 타입이다.
TStepKey : 그 중에서 이번에 렌더링할 (혹은 해당 컴포넌트가 처리해야 하는) 스텝 키를 의미하게 된다.

4. 적용

import React from 'react';
import { View } from 'react-native';
import { useFunnel } from '@use-funnel/react-navigation-native';

import { funnelOptions } from '../../../utils/useSignUpFunnel';
import { SignUpFunnelSteps } from '@screens/SignUpFunnel/types';

export default function SignUpFunnel() {
  const funnel = useFunnel<SignUpFunnelSteps>(funnelOptions);

  console.log('current step:', funnel.step);
  console.log('full context:', funnel.context);

  return (
    <View style={{ flex: 1 }}>
      <funnel.Render
        church={({ history }) => (
          <ChurchStep onNext={(ctx) => history.push('name', ctx)} />
        )}
        name={({ context, history }) => (
          <NameStep
            church={context.church}
            onNext={(ctx) => history.push('password', ctx)}
            onPrev={() => history.back()}
          />
        )}
        password={({ context, history }) => (
          <PasswordStep
            context={context}
            onNext={(ctx) => history.push('role', ctx)}
            onPrev={() => history.back()}
          />
        )}
        role={({ context, history }) => (
          <RoleStep
            context={context}
            onNext={(ctx) => history.push('complete', ctx)}
            onPrev={() => history.back()}
          />
        )}
        complete={({ context }) => <CompleteStep context={context} />}
      />
    </View>
  );
}

이렇게 위에서 설정한 StepProps를 기반으로 funnel.Render 안에 묶인 해당 컴포넌트 파일들은 props와 type이 보장된 환경속에 놓이게 된다.

그렇기 때문에 우리는 마지막으로 각각의 Component마다 해당 Component가 받게 되는 Type을 마지막으로 지정할 필요가 있다.

말이 좀 어려워보이고 복잡해보이지만, ChurchStep의 다음 단계인 NameStep 을 보면 알 수 있다.

import React, { useState } from 'react';
import { NameStepProps } from '@screens/SignUpFunnel/types';

export default function NameStep({ context, onNext }: NameStepProps) {

그렇다 바로 NameStepProps 만 소개하게 된다면, 우리는 이제 React Native + TypeScript + use-Funnel을 완벽하게 이해할 수 있게 된다. 사실 간단하다. 이전의 ChurchStep의 context 값을 받아왔으므로, 그 자료형의 type을 지정해주는 것이다.

export type NameStepProps = {
  church: string; // 이전 스텝 church 값
  onNext: (ctx: { church: string; name: string }) => void;
  onPrev: () => void;
};

만약에 4단계에 위치한 RoleStepProps 라면? 아래와 같은 구조를 띄게 될 것이다.

export type RoleStepProps = {
  church:   string;                    // 1단계 church
  name:     string;                    // 2단계 name
  password: string;                    // 3단계 password
  onNext:   (ctx: { 
               church: string; 
               name: string; 
               password: string; 
               role: string 
             }) => void;              // complete 단계 컨텍스트
  onPrev:   () => void;
};

5. 마치며

이 글을 쓰기까지는, 나아가 정리하기까지는 굉장히 오랜 시간이 걸리고 결단이 필요했다.. 정리하면 좋을 것 같다는 생각을 했다. 내가 이를 알기 위해 조사했을 때는 꽤 많은 자료들이 없기 때문이었다... (사실 Type 오류만 수정하고 funnel에 의해 context 값들이 잘 담기는 것만 확인했지, 회원가입은 확인을 안해봤다..)

하지만 뭐 context에 data들이 잘 담기기만 한다면, 회원가입은 일도 아니니까 ㅎ 포기하지 않고 이 라이브러리를 완주해낸 나에게 (과거의) 바친다.

profile
영혼의 정원수

0개의 댓글