[Next.js] Funnel 패턴으로 복잡한 온보딩 흐름 정복하기 (feat. Toss)

Mijeong Kwon·2026년 1월 25일

웹 개발

목록 보기
2/4
post-thumbnail

왜 Next.js와 Funnel 패턴인가?

지난 포스팅에서 CSR과 SSR의 차이를 공부하며 Next.js에 대해 알아봤습니다. 저희 팀이 개발 중인 HandShake 서비스는 검색 엔진 최적화(SEO)와 빠른 초기 로딩 속도가 중요했기에 Next.js를 선택했고, 저는 그 중 서비스의 첫인상인 '온보딩(회원가입)' 구현을 맡게 되었습니다.

우리 서비스의 온보딩은 단순한 가입이 아니라, 직무 및 기술 스택 선택과 개발 성향 테스트(a.k.a. DSTI) 까지 총 3단계로 이어집니다. 이를 매끄럽게 구현하기 위해 Toss 개발자 컨퍼런스 SLASH 23을 보며 공부한 Funnel 패턴을 도입하여 상태 관리의 복잡도를 해결해 보고자 합니다.


흩어진 페이지의 한계

Handshake에 소셜 로그인을 통해 처음 진입을 하면, 총 3단계의 프로필 정보 입력 과정을 거칩니다. 그래서 처음에는 단순히 /signup/1, /2, /3 이렇게 페이지를 나누면 되는 거 아닌가 생각했습니다.
하지만 이렇게 하면 몇 가지 불편한 점이 있습니다.

  • 데이터 파편화: 1단계 기본 정보, 2단계 추가 정보, 3단계 개발 성향 유형 분석 결과를 마지막에 한꺼번에 API로 쏴야 하는데, 페이지가 갈리면 이 데이터를 들고 다니기가 매우 번거로워집니다.
  • 뒤로가기 제어: 사용자가 뒤로가기를 눌렀을 때의 데이터 보존 로직이 복잡해집니다.

우리 서비스의 온보딩 및 API 흐름

기획된 디자인 요구사항을 바탕으로 설계한 단계별 로직입니다.

설문조사 단계 및 요구사항

  • 1단계: 기본 정보
    • 닉네임 중복 확인 (POST /user/nickname): 통과 필수
    • 모달을 통한 직무 및 기술 스택 선택 (최대 5개)
  • 2단계: 네트워킹 정보
    • 네트워킹 목적, Github ID, 자기소개(선택)
  • 3단계: 개발 성향 유형(DSTI) 분석 테스트(메인)
    • 12개의 질문을 한 페이지당 하나씩 배치
    • 답변 선택 시 자동으로 데이터 저장 후 다음 질문으로 이동
  • 4단계: 분석 결과 및 완료
    • 결과 카드(캐릭터 이미지 및 각 코드 설명) 확인 후 홈으로 이동

API 호출 시점

모든 데이터를 들고 있다가 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/                    # 토큰 관련 유틸

💡 포인트

  • Feature 기반 UI 응집: features/auth 내부에는 오직 해당 도메인의 화면 구성(Components)상태 흐름(Hooks)만 두어 UI 복잡도를 낮췄습니다.
  • 서비스 계층 분리: 여러 페이지나 다른 기능에서도 인증 관련 API를 재사용하기 쉽게 설계했습니다.
  • DTO 중앙 관리: 백엔드 명세가 변경될 경우 src/types 폴더 내의 파일만 수정하면 프로젝트 전체에 반영되도록 관리 포인트를 일원화했습니다.

핵심 구현: 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의 좋은 레퍼런스를 우리 프로젝트의 규모에 맞게 적절히 변형하여 적용해본 의미 있는 경험이었습니다.

profile
꾸준히 배우고 기록하는 개발자

1개의 댓글

comment-user-thumbnail
2026년 1월 25일

고민과 학습이 잘 느껴지는 포스트 였습니당 ~ 굿 ~

답글 달기