← posts

분기마다 단계가 달라지는 퍼널, 읽기 좋게 짜기

2026-06-26 · 회고

어떤 어려움을 겪었나?

회사에서 동료와 함께 만든 입력 퍼널이 있다. 사용자가 고른 값에 따라 거쳐야 할 단계가 달라지는 부분이 어려웠다. 어떤 선택은 한 단계로 끝나고 어떤 선택은 두세 단계를 더 지난다.

동료와 코드 리뷰를 주고받으면서 어떻게 하면 이 퍼널을 읽기 쉽게 짤 수 있을지 고민했다. 시간상 전체 구조를 바꿀 순 없는 상황이어서 일단 마무리했지만 추가로 공부하고 싶어 글을 적게 되었다.

회사 코드를 그대로 올릴 수는 없어서 공개용으로 같은 구조의 지역 선택 퍼널을 만들었다. 맨 위에서 시/도를 고르면 광역시는 자치구를 더 고르고 도는 시/군을 더 고른다. 도 안의 큰 시는 그 아래 일반구까지 내려간다. 세종시는 한 단계, 서울은 두 단계, 경기 수원은 세 단계로 깊이가 제각각이다.

흐름을 그림으로 보면 이렇다.

분기 퍼널 흐름도 — 시/도 선택에서 광역시는 자치구로, 도는 시/군으로, 큰 시는 일반구까지 갈라지고 세종은 바로 완료

코드는 branching-funnel-refactor에 올려뒀다.

현재 코드에서 느낀 코드 스멜

먼저 현재 코드를 보며 어떤 점에서 코드가 안 읽힌다고 느꼈는지 뭐가 문제라고 느꼈는지 적어보았다.

A. 단계 이동 로직이 컴포넌트 전체에 흩뿌려져 있다.

단계가 어떻게 이동하는지 흐름은 한눈에 안 보이는데 막상 상세한 이동 로직은 컴포넌트 곳곳에 퍼져 있었다. 단계 이동이 하나의 단위로 추상화돼 있지 않다 보니 index를 계산해 이전/다음으로 이동하는 상세 구현이 그대로 드러났고 그 코드마저 한곳에 모여 있지 않아 이동 흐름을 따라가려면 컴포넌트 여기저기를 훑어야 했다.

// 데이터는 전부 nullable인 form 하나
interface RegionForm {
  sido: string | null;
  district: string | null;
  city: string | null;
  gu: string | null;
}
 
function RegionFunnel() {
  const [form, setForm] = useState<RegionForm>(EMPTY); // 데이터
  const [stepId, setStepId] = useState<StepId>('sido'); // 현재 단계
  const steps = planSteps(form);
  const at = steps.findIndex((s) => s.id === stepId);
  const back = () => setStepId(steps[at - 1].id); // 이동
  const forward = () => setStepId(steps[at + 1].id);
  // 데이터·현재 단계·이동이 한 컴포넌트에 다 모여 있다
}

B. 제목을 titleFor 함수로 떼어내니 응집도가 떨어졌다.

제목을 titleFor라는 함수로 만들어 별도 파일로 분리해두니 정작 화면을 그리는 렌더링 로직만 봐서는 이 단계가 무엇을 그리는지 한눈에 알기 어려웠다. 현재 스텝에 따라 무슨 제목을 보여줄지를 함수로 나타내고 있긴 했지만, 그게 렌더링과 떨어진 다른 곳에 있었다. 제목은 그 단계를 그리는 렌더링 로직 곁에 같이 두는 편이 응집도가 더 높겠다 느꼈다.

// 모든 단계의 제목을 이 함수 하나가 떠안는다
function titleFor(step: StepId, form: RegionForm): string {
  switch (step) {
    case 'sido':
      return '시/도를 선택해주세요';
    case 'district':
      return `${form.sido} 자치구 선택`;
    case 'city':
      return `${form.sido} 시/군 선택`;
    case 'gu':
      return `${form.city} 일반구 선택`;
  }
}
// 제목 결정이 렌더링 로직과 떨어진 별도 함수(별도 파일)에 모여 있다

C. 화면 흐름이 한눈에 안 보인다.

이 단계 다음에 뭐가 오는지 알려면 파일을 두세 개 넘나들어야 했다. 흐름은 데이터에 가까운 곳에 가능하면 한 번에 읽히는 모양으로 있어야 할 것 같았다.

// 경로는 routeFor에 switch로 박혀 있고
function routeFor(form: RegionForm): StepId[] {
  if (form.sido === '세종특별자치시') return ['sido'];
  if (isMetro(form.sido)) return ['sido', 'district'];
  return ['sido', 'city', ...(hasGu(form.city) ? ['gu'] : [])]; // 도 → 시/군 → (일반구)
}
 
// 완료 여부는 planSteps에서 그 경로를 다시 훑어 만든다
function planSteps(form: RegionForm) {
  return routeFor(form).map((id) => ({id, done: isStepDone(id, form)}));
}
// 다음에 뭐가 오는지 알려면 routeFor, planSteps, 그리고 컴포넌트의 stepId를 오가야 한다

D. 레이아웃인 FunnelFrame이 하는 게 많다.

뒤로가기, 다음 라벨, 다음 버튼 비활성화 같은 걸 프레임이 전부 받아 처리했다. 이건 틀이 알 일이 아니라, 어디로 갈지와 무엇을 검증할지 아는 쪽에서 결정해야 하는 일처럼 보였다.

// 레이아웃 틀이 이동·라벨·검증을 전부 떠안는다
<FunnelFrame
  title={titleFor(stepId, form)}
  onBack={back}
  onNext={forward}
  nextLabel={at === steps.length - 1 ? '완료' : '다음'} // 라벨 결정
  nextDisabled={!steps[at].done}                        // 검증
>
  <StepFields step={stepId} form={form} setForm={setForm} />
</FunnelFrame>

문제는 단순히 함수가 많다는 게 아니었다. routeFor에는 단계 경로가 switch로 박혀 있고, planSteps는 그 경로를 다시 완료 상태로 바꿨다. 실제 현재 위치는 stepId라는 별도 상태로 들고 있었다. form에서 파생한 경로와 stepId가 어긋나면 stale 상태가 생길 수 있는데, 이 불변식은 타입이 아니라 주석과 조심성으로만 지키고 있었다.

뒤로가기와 다음 이동도 현재 index를 계산해 직접 걷는 방식이었다.

토스 useFunnel은 어떻게 풀었나

막상 구조를 개선하려니 생각보다 어려웠다. 그래서 직접 검색하다 나오기도 했고, 루퍼스 멘토님이 알려주시기도 한 useFunnel 라이브러리를 개발기 블로그와 강연 영상까지 확인해봤다. 어떤 어려움을 어떻게 풀었을까?

정리해보니 직접 퍼널을 짤 때 어려웠던 지점은 세 가지였고 useFunnel은 각각을 이렇게 풀고 있었다.

  • 단계랑 데이터가 따로 논다. 단계는 단계대로 상태는 상태대로 관리해야 했는데, useFunnel은 단계와 그 단계 데이터를 한 묶음으로 둔다.
  • 뒤로가기가 깨진다. A에서 B, C까지 갔다가 뒤로 돌아오면 C에서 넣은 값이 남아 오염되는데 단계마다 그 시점 스냅샷을 history에 쌓아 자동으로 복원한다.
  • 이 단계엔 이 값이 있다는 보장이 없다. 단계별 context를 타입으로 명확히 정의한다.

코드로 보면 대략 이런 모양이다. 단계 이름마다 그 단계에서 보장되는 context 타입을 한 번씩 적어두고, 화면은 단계 이름을 키로 갖는 맵으로 그린다.

// 단계마다 "이 단계에 들어오면 이 데이터가 있다"를 타입으로 선언
const funnel = useFunnel<{
  SelectSido:     {};
  SelectDistrict: { sido: string };
  SelectCity:     { sido: string };
  SelectGu:       { sido: string; city: string };
}>({ id: 'region', initial: { step: 'SelectSido', context: {} } })
 
// 화면은 단계 이름을 키로 갖는 렌더 맵. 각 단계가 history.push로 다음을 직접 정한다
<funnel.Render
  SelectSido={({ history }) => (
    <Sido onNext={(sido) => history.push('SelectDistrict', { sido })} />
  )}
  SelectDistrict={({ context, history }) => (
    <District sido={context.sido} onNext={/* ... */} />
  )}
/>

여기서 세 가지가 눈에 들어왔다.

첫째, 단계마다 보장되는 타입을 한 번 선언해두면 null 분기 없이 각 스텝별 상태를 깔끔하게 정의할 수 있다.

둘째, history를 라이브러리 코어에 두지 않고 어댑터로 빼뒀다. 페이지로 갈지 쿼리 파라미터로 갈지가 어댑터를 갈아끼우는 선택으로 바뀐다.

셋째, context를 history 스냅샷으로 본다. 데이터를 변수 하나에 담지 않고 history 칸마다 그 시점 데이터를 쌓으니 뒤로가기는 포인터 이동이 된다. 이전 단계로 돌아가면 그 시점의 context가 복원되고, 나중 단계에서 넣은 값이 남아 오염되는 일이 구조적으로 줄어든다.

내 퍼널에 대입해보면 더 또렷해진다. 시/도가 한 단계, 그 아래 자치구나 시/군이 또 한 단계, 큰 시의 일반구가 마지막 단계다. 각 단계가 push로 다음 목적지를 정하면 세종은 한 칸, 서울은 두 칸, 경기 수원은 세 칸으로 깊이가 알아서 갈린다. 구 선택 칸에 들어왔다는 건 sidocity가 이미 채워져 있다는 뜻이고 그 사실은 타입으로 보장된다.

무엇보다 useFunnel을 보며 내가 어려움을 겪었던 부분에 대한 아이디어를 얻을 수 있었다. "어떻게 하면 데이터, 스텝 흐름, 렌더링 로직이 한눈에 보이면서도 각각을 잘 추상화할 수 있을까". 단계와 데이터를 한 묶음으로 두고 화면을 단계 이름을 키로 갖는 렌더 맵으로 그리는 방식, 각 렌더링 로직에 콜백 형태로 이동 흐름을 보여주는 것에서 그 실마리를 찾을 수 있었다.

라이브러리 없이 아이디어만 가져와 다시 짜기

라이브러리를 그대로 사용하기 보다 "분기마다 달라지는 단계 데이터를 어떤 구조로 둘 것인가"에 집중하고 싶었다.

단계와 데이터를 한 묶음으로 두는 것, 단계별 타입 안전성, 렌더 맵처럼 흐름을 선언적으로 읽히게 하는 아이디어는 적용하기로 했다. history 스냅샷과 라우터 어댑터는 따로 적용 하지 않고 단일 useState로 처리해 심플하게 했다.

데이터: nullable form 대신 FunnelState

가장 먼저 바꾼 건 데이터 모델이다. 전부 nullable인 form 하나 대신, 단계별로 그 시점에 보장되는 값만 담은 discriminated union을 만들었다.

type FunnelState =
  | {step: 'sido'; context: {}}
  | {step: 'district'; context: {sido: string}}
  | {step: 'city'; context: {sido: string}}
  | {step: 'gu'; context: {sido: string; city: string}};

읽는 법은 단순하다. step은 지금 어느 화면인지, context는 거기 오기까지 이미 고른 값들이다. sido 단계의 context는 비어 있고, district 단계엔 sido가, gu 단계엔 sidocity가 들어 있다. 한 칸씩 쌓이면서 다음 단계로 넘어가는 셈이다.

이제 gu 단계에선 context.sidocontext.citystring으로 보장된다. before에서 곳곳에 붙이던 ?? '' 같은 null 방어가 사라졌고, sido는 비었는데 gu만 채워진 모순 상태도 만들 수 없게 됐다. 이전 코드의 RegionForm은 네 필드가 다 nullable이라 그런 모순을 타입이 못 막았는데 이제는 컴파일러가 대신 막아준다.

완료했을 때 모이는 결과도 경로별 union으로 뒀다. 세종은 sido만, 서울은 sidodistrict, 수원은 sidocity, gu를 갖는다. 결과를 받는 쪽도 어떤 경로로 끝났는지를 타입으로 구분할 수 있다.

여기서 resetBelow도 사라졌다. before에선 sido 같은 상위 값을 바꾸면 이미 고른 하위 값이 무효가 돼서 직접 비워줘야 했다. 이제는 단계를 옮길 때마다 그 단계에 필요한 context만 새로 만들어 통째로 바꾸니, 남아서 무효가 될 값 자체가 생기지 않는다. 상위를 바꾸면 하위를 비우던 일이 상태 정의를 바꾸자 통째로 사라진 셈이다.

전이: 단계 컴포넌트에 숨기지 않기

처음엔 단계마다 SidoStep이나 DistrictStep 같은 컴포넌트를 만들고, 각자 onNextonDone으로 자기 전이를 결정하게 했다. 단계가 자기 다음을 안다는 점은 깔끔해 보였다.

// 첫 시도: 단계 컴포넌트가 자기 전이를 직접 정한다
function SidoStep({onNext}: {onNext: (next: FunnelState) => void}) {
  const [selected, setSelected] = useState<string | null>(null);
  const node = selected ? SIDO[selected] : null;
  return (
    <StepFrame
      confirmDisabled={!selected}
      onConfirm={() => {
        if (!selected || !node) return;
        // 어디로 갈지가 이 컴포넌트 '안'에서 결정된다
        if (node.kind === 'metro') onNext({step: 'district', context: {sido: selected}});
        else if (node.kind === 'province') onNext({step: 'city', context: {sido: selected}});
        // 세종(single)은 여기서 완료 처리…
      }}
    >
      <ChoiceList options={Object.keys(SIDO)} value={selected} onChange={setSelected} />
    </StepFrame>
  );
}
 
// 메인 switch는 setState만 넘긴다 — 이 단계가 어디로 가는지 여기선 안 보인다
switch (state.step) {
  case 'sido':
    return <SidoStep onNext={setState} />;
  // case 'district': ...
}

그런데 막상 메인의 switch를 보니 onNextsetState만 덩그러니 넘기고 있었다. 이 단계가 어디로 가는지는 전혀 보이지 않았다. SidoStep을 열어봐야 비로소 서울이면 자치구로, 경기면 시/군으로 간다는 걸 알 수 있었다. 정작 useFunnel에서 좋았던 점은 push로 목적지가 호출부에 그대로 드러나는 거였는데, 그걸 단계 컴포넌트 안에 다시 숨겨버린 셈이었다.

결국 전이를 다시 switch로 끌어올렸다. 단계 컴포넌트가 결정을 쥐면 흐름이 숨고, switch가 쥐면 흐름이 보인다. 둘 다 잡을 수는 없었다. 내 우선순위는 전체 흐름을 한 번에 읽는 쪽이었으니 switch를 택했다.

렌더: switch를 흐름 맵으로 쓰기

결과적으로 switch가 useFunnel의 렌더 맵 역할을 하게 됐다. 각 case의 onConfirm에 어디로 가는지가 그대로 적혀 있다.

switch (state.step) {
  case 'sido': {
    const node = selected ? SIDO[selected] : null
    return (
      <StepFrame
        title='시/도를 선택해주세요'
        confirmLabel={node?.kind === 'single' ? '완료' : '다음'}
        confirmDisabled={!selected}
        onConfirm={() => {
          if (!selected || !node) return
          if (node.kind === 'metro')         go({ step: 'district', context: { sido: selected } }) // 자치구 단계로
          else if (node.kind === 'province') go({ step: 'city',     context: { sido: selected } }) // 시/군 단계로
          else                               onComplete({ sido: selected })                         // 세종은 바로 완료
        }}
      >
        <ChoiceList options={Object.keys(SIDO)} value={selected} onChange={setSelected} />
      </StepFrame>
    )
  }
 
  case 'district': // 자치구 고르면 완료
  case 'city':     // 일반구 있으면 다음, 없으면 완료
  case 'gu':       // 구 고르면 완료
}

흐름은 이렇게 읽힌다. 서울은 자치구로, 경기는 시/군으로, 수원은 다시 구로 가고, 세종은 바로 완료다. 이제 이 switch만 읽으면 전체 경로가 들어온다.

데이터는 별도 조회 함수 없이 맵 하나에서 단계가 직접 꺼내 쓴다. 어느 시/도가 자치구를 갖고 어느 시에 일반구가 있는지는 그 맵의 모양에 다 들어 있다. 분기를 판단할 때도 맵을 직접 들여다보면 끝난다.

// 데이터 맵 하나. metro는 자치구, province는 시와 그 일반구, single은 끝.
const SIDO = {
  서울특별시: {kind: 'metro', districts: ['종로구', '중구' /* ... */]},
  경기도: {
    kind: 'province',
    cities: {수원시: ['장안구' /* ... */], 가평군: []},
  },
  세종특별자치시: {kind: 'single'},
};

좋아진 점과 남은 비용

확실히 느낀 게 있다. 렌더링 로직, 다음으로 넘어가는 흐름, 상태 관리가 한 화면에서 같이 보이니 전보다 훨씬 읽기 쉬웠다.

처음 적어둔 냄새와도 하나씩 맞아떨어졌다.

스멜beforeafter
A. 상태와 단계가 흩뿌려짐한 컴포넌트에 form, stepId, 이동이 다 섞임진행 상태는 FunnelState로, 전이는 switch로 모음
B. titleFor 공통화제목을 한 함수에 모아 단계 차이를 못 담음titleFor 없애고 제목을 각 case 안으로
C. 흐름이 안 보임경로가 routeFor, planSteps, 여러 파일에 흩어짐switch 한 곳에 전이가 다 드러남
D. 프레임이 네비 독점FunnelFrame이 다음, 이전, 라벨을 다 받음StepFrame은 틀만 맡고, 동작은 각 case가 결정

물론 아쉬움과 의문도 남았다.

한눈에 보이게 만들긴 했지만 한 case 안에 제목, 완료/다음 라벨, disabled 조건, 다음 목적지가 다 들어간다. 이 부분을 인터페이스에 공개할지 내부 구현으로 숨길지 살짝 아직도 고민이 되는 지점이다. 한눈에 흐름이 보이지만 그만큼 좀 복잡해진 건 아닌가 라는 생각도 들었다. 내 눈엔 흐름이 잘 읽혔지만 처음 보는 사람에게도 그럴지는 잘 모르겠다.

그래도 전보다 파일을 오가는 일이 확실히 줄었고, 퍼널이 어떻게 흐르고 데이터가 어떻게 유지되는지는 한곳에서 명확하게 보인다고 느꼈다.

코드는 깃허브 참고.

Reference