
지난 포스팅에서 CSR과 SSR의 차이를 공부하며 Next.js에 대해 알아봤습니다. 저희 팀이 개발 중인 HandShake 서비스는 검색 엔진 최적화(SEO)와 빠른 초기 로딩 속도가 중요했기에 Next.js를 선택했고, 저는 그 중 서비스의 첫인상인 '온보딩(회원가입)' 구현을 맡게 되었습니다.
우리 서비스의 온보딩은 단순한 가입이 아니라, 직무 및 기술 스택 선택과 개발 성향 테스트(a.k.a. DSTI) 까지 총 3단계로 이어집니다. 이를 매끄럽게 구현하기 위해 Toss 개발자 컨퍼런스 SLASH 23을 보며 공부한 Funnel 패턴을 도입하여 상태 관리의 복잡도를 해결해 보고자 합니다.
Handshake에 소셜 로그인을 통해 처음 진입을 하면, 총 3단계의 프로필 정보 입력 과정을 거칩니다. 그래서 처음에는 단순히 /signup/1, /2, /3 이렇게 페이지를 나누면 되는 거 아닌가 생각했습니다.
하지만 이렇게 하면 몇 가지 불편한 점이 있습니다.
기획된 디자인 요구사항을 바탕으로 설계한 단계별 로직입니다.
POST /user/nickname): 통과 필수모든 데이터를 들고 있다가 3단계가 끝나는 시점에 연속 호출합니다.
1. POST /user/dsti: 테스트 결과를 먼저 등록해서 DSTI 유형 생성
2. POST /user/info: 수집된 모든 프로필 정보와 DSTI 결과(유형 코드+캐릭터 이미지)를 합쳐 최종 회원가입 완료
프로젝트 규모가 커질 것을 대비해 Feature 중심 설계를 도입하여 도메인 단위의 응집도를 높였습니다. 동시에 백엔드와의 통신(Service) 및 데이터 규격(DTO)은 전역적으로 관리하여 데이터 모델의 일관성을 유지할 수 있도록 구성했습니다.
src/
├── app/
│ └── (auth)/ # 라우트 그룹 (URL에 포함되지 않음)
│ └── login/page.tsx # 소셜 로그인 페이지
│ └── signup/
│ └── page.tsx # 온보딩 컨트롤 타워 (useFunnel 활용)
├── components/
│ └── ui/ # 프로젝트 공통 UI (Button, Input, Progress 등)
├── features/
│ └── auth/ # 🔵 인증/회원가입 관련 도메인 응집 (UI/로직)
│ ├── components/ # 각 가입 단계별 UI
│ │ └── Step1Profile.tsx/
│ │ └── Step2Networking.tsx/
│ │ └── Step3DSTI.tsx/
│ └── hooks/ # Funnel 제어 등 도메인 특화 로직
│ └── useFunnel.tsx/
├── services/ # 🟠 백엔드 API 통신
│ ├── api.ts
│ └── auth/
│ │ └── api.ts/ # 인증 관련 API 엔드포인트
│ │ └── hooks.ts/ # React-Query와 연결된 API 호출 훅
│ └── user/
│ └── api.ts/ # 유저 정보 관련 API 엔드포인트 함수 정의
│ └── hooks.ts/ # React-Query와 연결된 API 호출 훅
├── types/ # ⭐ API 응답 및 데이터 공통 타입 (DTO)
│ ├── auth.ts # 인증 관련 타입
│ └── user.ts # 유저 프로필, 경력, 기술스택 등 핵심 데이터 타입
├── assets/icons # 아이콘, 이미지 등 정적 파일
└── utils/ # 토큰 관련 유틸
useFunnel 커스텀 훅Toss의 useFunnel 라이브러리 콘셉트를 참고해서, 현재 단계(step)를 관리하고 해당 단계일 때만 화면을 보여주는 컴포넌트를 반환하도록 설계했습니다.
import { useState } from 'react';
export type SignupStep = 'step1' | 'step2' | 'step3' | 'step4';
export function useFunnel(defaultStep: SignupStep) {
const [step, setStep] = useState<SignupStep>(defaultStep);
const Step = ({ name, children }: { name: SignupStep; children: React.ReactNode }) => {
return step === name ? <>{children}</> : null;
};
return { step, setStep, Step };
}
Toss의 useFunnel은 내부적으로 React.Children을 순회하며 현재 단계에 맞는 자식을 찾아내는 고도화된 인터페이스를 제공합니다. 하지만 라이브러리의 복잡한 내부 로직을 그대로 가져오기보다, Step 컴포넌트를 직접 정의하여 반환함으로써 구현은 단순화하되, Toss가 지향하는 '선언적인 코드'와 '응집도' 는 그대로 가져올 수 있도록 커스터마이징했습니다.
단순히 step === 'step1' && <Component />라고 쓸 수도 있지만, 이렇게 컴포넌트로 추상화하면 부모 페이지의 JSX가 마치 '하나의 큰 폼을 단계별로 명세한 것' 처럼 읽혀 가독성이 좋아집니다.
signup/page.tsx여기가 응집도의 핵심으로, 모든 단계의 데이터가 이 페이지의 formData 하나에 모입니다.
export default function SignupPage() {
const { Step, setStep, step } = useFunnel('step1');
const [formData, setFormData] = useState<UserProfile>(INITIAL_DATA);
// 데이터 업데이트 핸들러
const updateFormData = (newData: Partial<UserProfile>) => {
setFormData((prev) => ({ ...prev, ...newData }));
};
return (
<main>
<Progress value={calculateProgress(step)} />
<Step name="step1">
<Step1Profile
data={formData}
onNext={(data) => { updateFormData(data); setStep('step2'); }}
/>
</Step>
<Step name="step2">
<Step2Networking
data={formData}
onNext={(data) => { updateFormData(data); setStep('step3'); }}
onPrev={() => setStep('step1')}
/>
</Step>
{/* ... 반복 ... */}
</main>
);
}
- 데이터 흐름의 단방향성 확보: 각 단계별 컴포넌트가 비즈니스 로직을 직접 수행하지 않고, 입력받은 데이터를 부모에게 넘겨주는 구조를 택했습니다. 덕분에 데이터 흐름이 단방향으로 명확해졌고, 복잡한 온보딩 과정에서도 상태 추적이 용이해졌습니다.
- 선언적인 UI와 추상화의 힘:
{step === 'step1' && <Step1 />}같은 조건부 렌더링 대신,<Step name="step1">컴포넌트로 감싸주었습니다. 이를 통해 JSX 가독성이 상승했고, 코드가 마치 '단계별 명세서'처럼 읽히는 추상화의 이점을 확인할 수 있습니다.- 사용자 경험(UX): Next.js의 App Router 환경에서 실제 페이지를 이동하지 않고 컴포넌트만 교체되므로, 웹이지만 앱처럼 끊김 없는 전환 효과를 줄 수 있었습니다.
- 가입 절차의 원자성: 데이터 누락이나 중간 이탈로 인한 불완전한 회원 정보 생성을 방지하기 위해, 중간 저장 방식 대신 마지막 단계에서 모든 데이터를 한 번에 처리하는 전략을 세웠습니다. 이는 DB의 정합성을 지키는 데에도 유리합니다.
단순히 기능을 구현하는 것을 넘어, "어떻게 하면 더 읽기 쉬운 코드를 짤 것인가?"와 "어떻게 하면 중복을 줄이고 재사용할 수 있을까?"를 고민해볼 수 있는 시간이었습니다.
Toss의 좋은 레퍼런스를 우리 프로젝트의 규모에 맞게 적절히 변형하여 적용해본 의미 있는 경험이었습니다.
고민과 학습이 잘 느껴지는 포스트 였습니당 ~ 굿 ~