복잡한 서비스일수록 회원가입, 비밀번호 찾기와 같은 '온보딩' 과정은 단순한 페이지 이동 이상의 복잡한 상태 관리를 요구하는데요,
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 라이브러리를 도입해 보시는 걸 추천합니다!
참고 문서:
'프론트엔드' 카테고리의 다른 글
| URL 상태를 결합한 선언적 데이터 페칭 구현하기 (0) | 2026.03.09 |
|---|---|
| 모노레포에서 미디어 쿼리가 무시되는 이유 (0) | 2026.03.02 |
| 프로젝트 통합으로 생산성 높이기: Turborepo 도입 및 모노레포 마이그레이션 여정 (0) | 2026.02.22 |
| Next.js 프로젝트에서 Storybook 도입해보기 (0) | 2026.01.29 |
| 시각장애인을 위한 모달 컴포넌트 웹 접근성 개선기 (Focus Trap 적용기) (0) | 2026.01.29 |