Multi-Step Form

2025년 03월 13일

웹 애플리케이션에서 복잡한 사용자 정보 수집이 필요할 때가 있습니다. 긴 회원가입 폼이나 설문조사를 한 페이지에 모두 담으면 사용자가 부담스러워하기 때문에, 여러 단계로 나누어 진행하는 것이 일반적입니다.
여러 단계의 회원가입이나 설문조사 폼을 구현할 때, 아래 코드처럼 useState와 switch문을 활용한 방식을 많이 사용합니다.

const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState({});
 
const handleNext = (data) => {
  setFormData(prev => ({ ...prev, ...data }));
  setCurrentStep(prev => prev + 1);
};
 
const renderStep = () => {
  switch(currentStep) {
    case 0: return <EmailStep onNext={handleNext} />;
    case 1: return <RoleStep onNext={handleNext} />;
    case 2: return <PasswordStep onNext={handleNext} />;
    default: return null;
  }
};
 

코드자체는 간단하지만 몇가지 문제가 있습니다.

URL과 UI 상태 불일치
이 코드에서는 URL이 폼의 진행 상태를 반영하지 못합니다. 사용자가 2단계까지 진행했어도 URL은 고정되어있습니다.

  • 브라우저 뒤로가기/앞으로가기 버튼 미작동: 사용자가 뒤로가기 버튼을 눌러도 폼의 단계는 변하지 않아 혼란을 줍니다.
  • 새로고침 시 초기화: 페이지를 새로고침하면 currentStep 상태가 초기화되어 사용자는 다시 처음부터 모든 정보를 입력해야 하는 불편함을 겪게 됩니다.
  • 링크 공유 불가: 특정 단계로 바로 이동할 수 있는 URL을 공유할 수 없습니다.

확장성, 유지보수 문제
프로젝트가 복잡해질수록 점점 관리하기 어려워지는 문제가 있습니다. 처음에는 단순한 3단계의 Form이지만, 기획이 변경되면서 역할에 따라 다른 플로우를 보여줘야 하거나, 특정 조건에서만 나타나는 스텝이 추가되면 switch문이 복잡해집니다. 스텝이 늘어나고 조건부 로직이 추가될 때마다 복잡한 조건 분기가 들어가면서 코드의 가독성은 떨어집니다.

 
const renderStep = () => {
  switch(currentStep) {
    case 0: 
      return <EmailStep onNext={handleNext} />;
    
    case 1: 
      return <RoleStep onNext={handleNext} />;
    
    case 2: 
      // 역할에 따른 분기
      if (formData.role === 'enterprise') {
        return (
          <CompanyInfoStep 
            onNext={handleNext} 
            onPrev={handlePrev} 
          />
        );
      } else if (formData.role === 'individual') {
        return (
          <PersonalInfoStep 
            onNext={handleNext} 
            onPrev={handlePrev} 
          />
        );
      } else {
        return (
          <BasicInfoStep 
            onNext={handleNext} 
            onPrev={handlePrev} 
          />
        );
      }
    
    case 3:
      // 이전 스텝의 선택에 따른 분기
      if (formData.role === 'enterprise' && formData.companySize > 100) {
        return (
          <EnterpriseVerificationStep 
            onNext={handleNext} 
            onPrev={handlePrev} 
          />
        );
      } else if (formData.needsApproval) {
        return (
          <ApprovalRequestStep 
            onNext={handleNext} 
            onPrev={handlePrev} 
          />
        );
      } else {
        // 일반 사용자는 바로 패스워드 스텝으로
        setCurrentStep(4);
        return null;
      }
    
    case 4:
      return (
        <PasswordStep 
          onNext={handleNext} 
          onPrev={handlePrev} 
        />
      );
    
    case 5:
      return showTermsStep ? (
        <TermsStep 
          onNext={handleNext} 
          onPrev={handlePrev} 
        />
      ) : (
        <CompleteStep />
      );
    
    default: 
      return <ErrorStep />;
  }
};
 
// 뒤로 가기 로직도 마찬가지로 복잡해짐
const handlePrev = () => {
  if (currentStep === 3 && formData.role === 'individual') {
    setCurrentStep(2);
  } else if (currentStep === 4 && formData.needsApproval) {
    setCurrentStep(3);
  } else if (currentStep === 4 && formData.role === 'enterprise' && !formData.needsApproval) {
    setCurrentStep(2);
  } else {
    setCurrentStep(prev => prev - 1);
  }
};

Multi-Step Form

이런 문제들을 해결하고 더 나은 개발 경험을 제공하기 위해, URL과 상태가 동기화되고, 선언적이며, 타입 안전성까지 보장되는 새로운 Multi-Step Form 구현 방식을 소개합니다.

import { EmailStep, RoleStep, PasswordStep, type FunnelState, registerAction } from '@/features/auth';
import { useFunnel } from '@/shared/lib';
 
export default function RegisterPage() {
  const [Funnel, funnelState, updateFunnelState, clearFunnelState] = useFunnel(
    ['email', 'role', 'password'] as const, 
    {
      initialStep: 'email',
      history: 'replace',
    }
  ).withState<Omit<FunnelState, 'currentStep'>>({});
 
  return (
    <Funnel>
      <Funnel.Step name="email">
        <EmailStep
          defaultValue={funnelState.email}
          next={(email) => {
            updateFunnelState({ email, currentStep: 'role' });
          }}
        />
      </Funnel.Step>
      <Funnel.Step name="role">
        <RoleStep
          defaultValue={funnelState.role}
          next={(role) => {
            updateFunnelState({ role, currentStep: 'password' });
          }}
        />
      </Funnel.Step>
      <Funnel.Step name="password">
        <PasswordStep
          defaultValues={funnelState}
          next={async ({ password, confirmPassword }) => {
            await registerAction({
              email: funnelState.email!,
              role: funnelState.role!,
              password,
              confirmPassword,
            });
            clearFunnelState();
          }}
        />
      </Funnel.Step>
    </Funnel>
  );
}

선언적인 스텝 정의

<Funnel>
  <Funnel.Step name="email"><EmailStep /></Funnel.Step>
  <Funnel.Step name="role"><RoleStep /></Funnel.Step>
  <Funnel.Step name="password"><PasswordStep /></Funnel.Step>
</Funnel>

기존의 switch문과 숫자 인덱스를 사용한 명령형 방식과 달리, 폼의 전체 구조를 한눈에 파악할 수 있습니다.
새로운 스텝을 추가할 때도 단순히 <Funnel.Step> 태그 하나만 추가하면 되기 때문에 확장성도 뛰어납니다.


type-safe 스텝 네비게이션

['email', 'role', 'password'] as const처럼 타입을 정확하게 지정함으로써 컴파일 타임에 오타나 잘못된 스텝명 사용을 방지할 수 있습니다.

const [Funnel, funnelState, updateFunnelState] = useFunnel(
  ['email', 'role', 'password'] as const  // 리터럴 유니온으로 추론
);
 
// IDE에서 자동완성 지원
updateFunnelState({ email: 'test@test.com', currentStep: 'role' });
 
// 컴파일 에러 - 존재하지 않는 스텝명
updateFunnelState({ email: 'test@test.com', currentStep: 'invalid' });

as const를 사용하면 타입이 단순한 string[]이 아닌 정확한 리터럴 유니온 타입으로 추론됩니다. 이로 인해 updateFunnelState({ currentStep: 'invalid-step' })처럼 존재하지 않는 스텝명을 사용하면 IDE에서 바로 에러가 표시됩니다. 스텝명 오타로 인한 런타임 오류를 완전히 방지할 수 있습니다.


URL과 상태 동기화

nuqs 라이브러리를 활용해 URL 쿼리 파라미터와 React 상태를 동기화합니다. 사용자가 2단계에 있으면 URL도 /register?funnel-step=role처럼 실제 상태를 보여줍니다. 이로 인해 브라우저의 뒤로가기/앞으로가기 버튼이 자연스럽게 작동하고, 페이지를 새로고침해도 현재 스텝이 그대로 유지됩니다.

다음으로는 어떻게 구현하는지 살펴보겠습니다.


URL 기반 스텝 관리- useRouteFunnel

export function useRouteFunnel<T extends FunnelSteps>(
  steps: T,
  options: RouteFunnelOptions<T> = {}
): [FunnelComponent, (step: T[number], navOptions?: NavigateOptions) => void] {
  const { 
    initialStep, 
    stepQueryKey = DEFAULT_STEP_QUERY_KEY, 
    onStepChange, 
    history: defaultHistory 
  } = options;
 
  const defaultStep = initialStep ?? steps[0];
  const [currentStep, setCurrentStep] = useQueryState(
    stepQueryKey, 
    parseAsString.withDefault(defaultStep)
  );
 
  const navigateToStep = (step: T[number], navOptions?: NavigateOptions) => {
    const { history = defaultHistory || DEFAULT_HISTORY } = navOptions || {};
    setCurrentStep(step, { history });
    onStepChange?.(step);
  };
 
  const FunnelContainer = useMemo(() => {
    const Container = ({ children }: FunnelProps) => {
      return React.createElement(React.Fragment, null, children);
    };
 
    const Step = ({ name, children }: FunnelStepProps) => {
      return currentStep === name ? 
        React.createElement(React.Fragment, null, children) : 
        null;
    };
 
    (Container as FunnelComponent).Step = Step;
    return Container as FunnelComponent;
  }, [currentStep]);
 
  return [FunnelContainer, navigateToStep];
}

URL과 상태 동기화
useQueryState를 사용해서 URL 쿼리 파라미터를 React 상태처럼 사용하는 것입니다. URL의 ?funnel-step=email 부분이 React 상태와 동기화됩니다. 사용자가 브라우저의 뒤로가기를 누르면 URL이 변하고, 그에 따라 currentStep 상태도 자동으로 업데이트됩니다.

History 방식 제어
navigateToStep 함수에서 history 옵션으로 브라우저 히스토리를 세밀하게 제어할 수 있습니다. 이는 nuqs 라이브러리가 내부적으로 제공하는 기능으로, 브라우저의 History API를 직접 조작합니다.

push를 사용하면 history.pushState()가 호출되어 새로운 히스토리 엔트리가 브라우저 히스토리 스택에 추가됩니다. 이렇게 하면 사용자가 뒤로가기 버튼을 눌렀을 때 이전 스텝으로 돌아갈 수 있습니다

반면 replace 모드를 사용하면 history.replaceState()가 호출되어 현재 히스토리 엔트리를 덮어씁니다. 이 경우 사용자가 뒤로가기를 누르면 폼에 진입하기 전 페이지로 바로 이동합니다.

동적 컴포넌트 생성

const Container = ({ children }) => {
  return React.createElement(React.Fragment, null, children);
};

이 코드는 <>{children}</>과 동일합니다. 모든 자식 요소들을 그대로 렌더링하는 래퍼 역할입니다.

const Step = ({ name, children }) => {
  return currentStep === name ? 
    React.createElement(React.Fragment, null, children) : 
    null;
};

Step 컴포넌트는 자신의 name prop이 현재 활성화된 currentStep과 일치할 때만 children을 렌더링하고, 그렇지 않으면 null을 반환합니다. 즉, 현재 스텝이 아닌 모든 스텝들은 DOM에서 완전히 제거됩니다.

(Container as FunnelComponent).Step = Step;
return Container as FunnelComponent;

여기서 JavaScript의 함수 객체 특성을 활용합니다. 함수도 객체이므로 프로퍼티를 추가할 수 있습니다. Container 함수에 Step 프로퍼티를 붙여서 Container.Step으로 접근할 수 있게 만듭니다.

그래서 아래와 같이 사용가능합니다.

<Funnel>
  <Funnel.Step name="email">...</Funnel.Step>
  <Funnel.Step name="role">...</Funnel.Step>
</Funnel>

세션 스토리지 상태관리 - withState

export const withState = <TSteps extends FunnelSteps>(
  [Funnel, navigateToStep]: ReturnType<typeof useRouteFunnel<TSteps>>
) => <TState extends Record<string, unknown>>(
  defaultValue: Partial<TState>
) => {
 
  const [state, setState, clearStorage] = useSessionStorage(
    storageKey, 
    defaultValue
  );
 
  // 상태 업데이트와 네비게이션을 처리
  const updateState = useCallback((action) => {
    setState((prevState) => {
      const newState = { ...prevState, ...action };
 
      if (newState.currentStep) {
        navigateToStep(newState.currentStep);  // URL도 함께 업데이트
      }
 
      return newState;
    });
  }, [setState, navigateToStep]);
 
  return [Funnel, state, updateState, clearState] as const;
};
 

withState는 기본적인 URL 스텝 관리에 세션 스토리지 기반 데이터 지속성 기능을 추가해주는함수입니다. URL만 관리하던 useRouteFunnel을 받아서 데이터 저장/복원 기능을 하게됩니다.

다음과 같은 3단계 과정을 통해 기능을 확장합니다:

  1. useRouterFunnel의 반환값 [Funnel, navigateToStep]을 받아와서
  2. 세션 스토리지 기반 상태 관리 기능을 더하고
  3. [Funnel, state, updateState, clearState]로 반환

통합 인터페이스 - useFunnel

export function useFunnel<T extends FunnelSteps>(
  steps: T, 
  options: RouteFunnelOptions<T> = {}
) {
  const [Funnel, navigateToStep] = useRouteFunnel<T>(steps, options);
  const withState = _withState<T>([Funnel, navigateToStep]);
 
  return Object.assign([Funnel, navigateToStep], { withState });
}
 

useFunnel은 복잡한 내부 로직들을 감추고 간단한 인터페이스만 노출하는 추상화 레이어 역할을 합니다. 개발자는 URL 동기화나 세션 스토리지 처리 같은 세부 구현을 몰라도 되고, 필요한 기능만 체이닝으로 선택해서 사용할 수 있습니다.

기본 사용법 (URL 관리만)

const [Funnel, navigateToStep] = useFunnel(['email', 'role', 'password']);

확장 사용법 (세션 스토리지 사용)

const [Funnel, state, updateState, clearState] = useFunnel(
  ['email', 'role', 'password']
).withState({ email: '', role: '' });

마무리

지금까지 useState와 switch문으로 구현된 명령형 Multi-Step Form의 한계점을 짚어보고, 해결하기 위한 구현 방식을 살펴보았습니다.

  • useRouteFunnel: URL 쿼리 파라미터(nuqs 활용)를 통해 스텝 상태를 관리하여, 브라우저 히스토리와 완벽히 연동되고 새로고침에도 스텝을 유지할 수 있습니다.
  • withState: useRouteFunnel 위에 세션 스토리지를 얹어, 사용자가 입력한 데이터를 안전하게 저장하고 복원할 수 있는 지속성 계층을 제공합니다.
  • useFunnel: 이 모든 기능을 하나의 통합된 인터페이스로 묶어, 필요에 따라 유연하게 체이닝하며 확장할 수 있는 편리한 개발 경험을 제공합니다.

이 패턴을 사용하면 복잡한 조건 분기와 URL-UI 상태 불일치 문제를 해결할 수 있습니다. 선언적 구조와 TypeScript 타입 안전성 덕분에 코드 가독성이 높아지고, 새로운 기능 추가도 수월해집니다.