프론트엔드

@use-funnel로 복잡한 온보딩 흐름 제어하기 (feat. Zod)

김민나 2026. 2. 23. 00:14

복잡한 서비스일수록 회원가입, 비밀번호 찾기와 같은 '온보딩' 과정은 단순한 페이지 이동 이상의 복잡한 상태 관리를 요구하는데요,

IT 연합 동아리 Cotato 리뉴얼 프로젝트에 참여하면서, 홈페이지 온보딩 프로세스를 작업하는 과정에서 토스의 @use-funnel을 이용하여 온보딩 로직을 작업한 과정을 공유합니다. 

 


1. @use-funnel이 무엇인가요? 

@use-funnel은 복잡한 UI 흐름을 간편하게 구현할 수 있도록 도와주는 리액트 훅입니다. 

 

사용자가 여러 단계를 거쳐 순서대로 입력해야 하는 UI 흐름을 가지는 프로세스를 구현하려면 각 단계별 상태와 히스토리를 관리해야 하는데, @use-funnel을 사용하면 이러한 흐름을 쉽게 구현할 수 있습니다.

 

 

1.1 3가지 개념 

  • step : 사용자에게 여러 화면에 걸쳐서 필요한 값을 입력 받을 때, 각 화면이 step
  • context : 각 step에서 입력한 값을 저장한 상태 
  • history : 전체 step의 이동과 각 step에서 입력한 context의 변경 기록을 가지고 있는 배열 

2.  @use-funnel을 이용한 스텝 관리

OnboardingContainer라는 하나의 상위 컨테이너에서 모든 분기 로직을 통합 관리하는 방식을 채택했습니다.

2.1 계층적 구조 설계

use-funnel을 통해 로그인, 회원가입, 비밀번호 찾기라는 세 가지 큰 줄기를 하나의 깔끔한 계층 구조로 정리했습니다. 

  • 로그인(login): 모든 온보딩의 진입점.
  • 회원가입 흐름: signUp (기본 정보) ↔ userInfo (추가 정보 입력)
  • 비밀번호 재설정 흐름: findPassword → code (인증) → resetPassword

 

재설정 완료 후에는 다시 login 스텝으로 이동시켜 프로세스를 자연스럽게 종료합니다. 

 

2.2 선언적인 Funnel 구현 (code)

useFunnel을 사용하여 각 스텝이 다루는 Context 타입을 정의하고, funnel.Render을 통해 각 스텝에 필요한 props와 이벤트를 주입합니다. 

const STEP_CONFIG: Record<string, {title: string; showLoginLink?: boolean}> = {
  login: {title: '로그인'},
  signUp: {title: '회원가입', showLoginLink: true},
  userInfo: {title: '회원가입', showLoginLink: true},
  code: {title: '인증번호 입력'},
  resetPassword: {title: '비밀번호 재설정'},
  findPassword: {title: '비밀번호 찾기'},
};

export const OnboardingContainer = () => {
  const funnel = useFunnel<{
    login: Context;
    signUp: Context;
    findPassword: Context;
    userInfo: Context;
    code: Context;
    resetPassword: Context;
  }>({
    id: 'funnel',
    initial: {
      step: 'login',
      context: {},
    },
  });
  
  const currentConfig = STEP_CONFIG[funnel.step];

  return (
    <div className='custom-scrollbar flex h-fit max-h-[90vh] w-134.75 flex-col gap-11.75 overflow-y-auto rounded-[40px] bg-neutral-100/30 px-11 py-18.5'>
      <div className='flex flex-col gap-4'>
        <div className='flex flex-row justify-between'>
          <h2 className='flex flex-row items-center gap-3.5'>
            <span className='text-h5 text-white'>Welcome to</span>
            <Logo />
          </h2>
          {currentConfig.showLoginLink && (
            <div className='text-body-m flex flex-col items-end justify-end text-neutral-400'>
              <span>이미 계정이 있나요?</span>
              <button
                className='text-primary'
                onClick={() => funnel.history.push('login')}>
                로그인
              </button>
            </div>
          )}
        </div>

        <h1 className='text-h2 text-neutral-100'>{currentConfig.title}</h1>
      </div>
      <funnel.Render
        login={({history}) => (
          <OnboardingLoginContainer
            onSignUpClick={() => history.push('signUp')}
            onFindPasswordClick={() => history.push('findPassword')}
          />
        )}
        userInfo={({history, context}) => (
          <OnboardingUserInfoContainer
            prevData={context}
            onPrev={() => history.push('signUp', context)}
          />
        )}
        signUp={({history, context}) => (
          <OnboardingSignUpContainer
            prevData={context}
            onNext={(data) => history.push('userInfo', data)}
          />
        )}
        findPassword={({history}) => (
          <OnboardingFindPasswordContainer
            onNext={(email) => history.push('code', {email})}
          />
        )}
        code={({history, context}) => (
          <OnboardingVerificationCodeContainer
            email={context.email}
            onNext={() => history.push('resetPassword', context)}
          />
        )}
        resetPassword={({history}) => (
          <OnboardingResetPasswordContainer
            onSuccess={() => history.push('login')}
          />
        )}
      />
    </div>
  );
};

3. 검증 로직의 분리: Zod 스키마 활용

UI 코드에서 비즈니스 로직을 걷어내기 위해 Zod를 적극 활용했습니다.

폼 입력값의 검증 규칙을 스키마로 캡슐화하여 에러 메시지와 함께 통합 관리했습니다.

 

필드 검증 규칙 (Zod) 에러 메시지 예시
이메일 .email() "이메일 형식이 아닙니다."
비밀번호 8~16자, 영문+숫자+특수문자 조합 "비밀번호는 영어, 숫자, 특수문자를 포함하여..."
전화번호 010 시작 숫자 11자리 정규표현식 "전화번호 양식이 올바르지 않습니다."
약관 동의 .refine((val) => val === true) "이용약관에 동의해야 합니다."

 

 

.or(z.literal('')) 설정을 통해, 사용자가 입력을 시작하기 전(초기 상태)에는 에러 메시지를 노출하지 않다가 입력을 시작하는 순간부터 실시간 검증이 진행되도록 구현하여 UX를 개선했습니다.

 

**
 * 로그인 스키마
 */
export const LoginSchema = z.object({
  email: z
    .string()
    .min(1, {message: '이메일을 입력해 주세요.'})
    .email({message: '이메일 형식이 아닙니다.'})
    .or(z.literal('')),

  password: z
    .string()
    .min(1, {message: '비밀번호를 입력해 주세요.'})
    .or(z.literal('')),
});

export const LoginResponse = z.object({
  accessToken: z.string(),
});

/*
 * 회원가입 스키마
 */
export const JoinRequestSchema = z.object({
  email: z
    .string()
    .email({message: '이메일 형식이 아닙니다.'})
    .or(z.literal('')),
  password: z
    .string()
    .min(8, {message: '비밀번호는 최소 8자 이상이어야 합니다.'})
    .max(16, {message: '비밀번호는 최대 16자 이내여야 합니다.'})
    .regex(/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,16}$/, {
      message: '비밀번호는 영어, 숫자, 특수문자를 포함하여 8~16자여야 합니다.',
    })
    .or(z.literal('')),
  name: z.string().min(1, {message: '이름을 입력해 주세요.'}).or(z.literal('')),
  phoneNumber: z
    .string()
    .length(11, {message: '전화번호는 숫자 11자리여야 합니다.'})
    .regex(/^010\d{8}$/, {
      message: '전화번호 양식이 올바르지 않습니다.',
    })
    .or(z.literal('')),
  termsOfServiceAgreed: z.boolean().refine((val) => val === true, {
    message: '이용약관에 동의해야 합니다.',
  }),
  privacyPolicyAgreed: z.boolean().refine((val) => val === true, {
    message: '개인정보 수집 및 이용에 동의해야 합니다.',
  }),
  gender: z.enum(['MALE', 'FEMALE']),
  university: z
    .string()
    .regex(/.*대학교$/, {
      message: '학교명이 올바르지 않습니다.',
    })
    .or(z.literal('')),
  position: z.enum(['NONE', 'PM', 'DE', 'FE', 'BE']),
  generationNumber: z
    .number()
    .min(1, '기수를 입력해 주세요.')
    .or(z.literal('')),
});

 


4. 컴포넌트 재사용성: 온보딩 폼의 모듈화

다양한 단계에서 유사한 UI가 반복되는 온보딩 특성을 고려해, 책임을 명확히 분리한 4가지 공통 폼 컴포넌트를 설계했습니다. 

 

  • OnboardingFormInput: 아이디, 기수 입력 등 일반 텍스트 입력 전용
  • OnboardingFormPassword: 보안이 필요한 비밀번호 입력 전용
  • OnboardingFormDropdown: 성별, 직군 등 선택형 입력 전용
  • OnboardingFormCode: 인증번호 전송 및 검증 전용

 

각 컴포넌트는 Props를 통해 상태를 주입받아 렌더링만 담당하며, 실제 데이터 제어는 컨테이너에서 수행함으로써 코드 중복을 최소화했습니다. 

 


5. 마치며

@use-funnel 도입으로 복잡하게 얽혀 있던 온보딩 로직을 하나의 선언적인 로직으로 구현할 수 있었는데요. 

여기에 Zod의 강력한 타입 검증이 더해지면서, 더 안전하고 확장성 있는 유저 온보딩 시스템을 구축할 수 있었습니다.

 

이 글을 읽으시는 분들도 회원가입과 로그인의 복잡한 흐름을 구현할 때 @use-funnel 라이브러리를 도입해 보시는 걸 추천합니다! 

 


 

참고 문서: