Skip to content

Conversation

@junny97
Copy link
Member

@junny97 junny97 commented Jun 5, 2025

온보딩 퍼널 시스템 구현 PR

폴더 구조

multi-step-form/
├── src/
│   ├── pages/
│   │   ├── OnboardingPage.tsx          # 온보딩 퍼널 메인 페이지
│   │   └── OnboardingResult.tsx        # 완료 결과 페이지
│   ├── features/
│   │   ├── funnel/                     # 퍼널 기능 도메인
│   │   │   ├── Funnel.tsx             # Compound Pattern 퍼널 컴포넌트
│   │   │   ├── Step.tsx               # 단계별 컴포넌트
│   │   │   └── use-funnel.ts          # 퍼널 상태 & URL 동기화 훅
│   │   └── onboarding/                 # 온보딩 기능 도메인
│   │       ├── components/
│   │       │   ├── NicknameStep.tsx   # 닉네임 입력 단계
│   │       │   ├── GenderStep.tsx     # 성별 선택 단계
│   │       │   ├── GenresStep.tsx     # 장르 선택 단계
│   │       │   └── FavoriteStep.tsx   # 선호작품 입력 단계
│   │       ├── hooks/
│   │       │   ├── mutations/
│   │       │   │   └── use-post-onboarding-data.ts
│   │       │   └── queries/
│   │       ├── onboarding.type.ts     # 온보딩 타입 & 스키마 정의
│   │       ├── onboarding.constant.ts # 온보딩 상수 정의
│   │       └── onboarding.api.ts      # 온보딩 API 함수
│   ├── shared/
│   │   ├── components/                # 공통 컴포넌트
│   │   │   ├── Button.tsx
│   │   │   ├── Input.tsx
│   │   │   ├── Header.tsx
│   │   │   ├── ProgressBar.tsx
│   │   │   └── ErrorMessage.tsx
│   │   ├── guards/                    # 라우트 가드
│   │   └── constants.ts               # 전역 상수
│   ├── routers/                       # 라우팅 설정
│   ├── App.tsx
│   └── main.tsx

제출 전 체크리스트

🔗 배포 링크 : https://multi-step-form-lac-six.vercel.app/

  • 기능 요구 사항을 모두 구현했고, 정상적으로 동작하는지 확인했나요?
  • 기본적인 프로그래밍 요구 사항(코드 컨벤션, 에러 핸들링 등)을 준수했나요?
  • 배포한 페이지에 정상적으로 접근할 수 있나요?

리뷰 요청 & 논의하고 싶은 내용

💬 리뷰를 통해 중점적으로 논의하고 싶은 부분

최종 제출 로직의 위치: Hook vs Component

현재 OnboardingPage.tsx에서는 각 단계별 진행을 위한 handleStepComplete와 최종 제출을 위한 handleFinalSubmit을 분리하여 구현했습니다:

비즈니스 로직이 OnboardingPage에서 중앙화된 상태에서, 최종 제출의 성공과 실패 흐름을 컴포넌트 내부에서 명시적으로 처리할지 아니면 mutation hook 내부에서 캡슐화하여 처리할지에 대해 의견을 듣고 싶습니다.

1. 현재 방식 (Component에서 처리)

// 각 단계별 진행 처리
const handleStepComplete = (newData: Partial<OnboardingData>) => {
  setOnboardingData((prevData) => ({ ...prevData, ...newData }));
  goToNextStep();
};

// 최종 제출 처리
const handleFinalSubmit = (finalData: OnboardingData) => {
  onboardingMutation.mutate(finalData, {
    onSuccess: (user: OnboardingUserResponse) => {
      navigate('/onboarding/result', {
        state: { userId: user.id },
        replace: true,
      });
    },
    onError: (error: Error) => {
      console.error('온보딩 제출 에러:', error);
    },
  });
};

2. 대안 방식 (Hook 내부에서 처리)

export const usePostOnboardingData = () => {
  const navigate = useNavigate();

  return useMutation<OnboardingUserResponse, Error, OnboardingData>({
    mutationFn: async (data: OnboardingData) => {
      // ... 기존 로직
    },
    onSuccess: (user: OnboardingUserResponse) => {
      navigate('/onboarding/result', {
        state: { userId: user.id },
        replace: true,
      });
    },
    onError: (error: Error) => {
      console.error('온보딩 제출 에러:', error);
    },
  });
};

💭 설계 / 구현 단계에서 가장 많이 고민했던 문제와 해결 과정에서 얻은 것

기존 방식의 문제점과 Compound Pattern을 활용한 해결

초기에는 각 온보딩 단계를 별도의 페이지(/onboarding/nickname, /onboarding/gender 등)로 구성하여 각 페이지에서 개별적으로 데이터를 관리하고 다음 페이지로 데이터를 전달하는 방식을 고려했습니다. 하지만 이 방식은 온보딩 전체 흐름을 이해하기 위해 여러 컴포넌트를 단계별로 찾아봐야 하는 불편함이 있었습니다.

해결 방법: 중앙화된 퍼널 시스템

  1. Compound Pattern 적용: FunnelFunnel.Step을 조합하여 선언적이고 직관적인 다단계 폼 구조 구현
<Funnel step={currentStep}>
  <Funnel.Step name='nickname'>
    <NicknameStep data={onboardingData} onNext={handleStepComplete} />
  </Funnel.Step>
  // ... 다른 단계들
</Funnel>
  1. URL과 현재 단계 동기화: useFunnel 훅을 통해 브라우저 URL과 현재 단계를 동기화하여 뒤로가기/앞으로가기 시에도 올바른 단계 표시
// URL: /onboarding?step=gender
const { currentStep, goToNextStep, goToPrevStep } = useFunnel({
  steps: STEPS,
});
  1. 중앙화된 상태 관리: 모든 단계의 데이터와 플로우 제어를 상위 컴포넌트에서 관리하여 일관성 있는 상태 흐름 보장. 각 컴포넌트에는 필요한 동작과정을 props로 전달하여 전체 플로우를 명확하게 유추할 수 있도록 구조화
// 🎯 중앙화된 상태 관리 - OnboardingPage.tsx
const [onboardingData, setOnboardingData] = useState<OnboardingData>({
  nickname: '', gender: '남성', genre: [], favorite_movie: '',
});

// 🔄 각 단계별 진행을 위한 공통 로직
const handleStepComplete = (newData: Partial<OnboardingData>) => {
  setOnboardingData(prev => ({ ...prev, ...newData }));
  goToNextStep();
};

// 📋 각 컴포넌트에 명확한 역할과 동작 전달
<Funnel.Step name='nickname'>
  <NicknameStep data={onboardingData} onNext={handleStepComplete} />
</Funnel.Step>
<Funnel.Step name='gender'>
  <GenderStep data={onboardingData} onNext={handleStepComplete} onPrev={goToPrevStep} />
</Funnel.Step>
<Funnel.Step name='favorite'>
  <FavoriteStep data={onboardingData} onPrev={goToPrevStep} onComplete={handleFinalSubmit} />
</Funnel.Step>

✅ 리뷰어 체크 포인트

1. Form 상태 관리 & 반복되는 로직 분리

  • 반복되는 로직을 custom hook으로 분리했는가?
  • hook 내부와 UI 컴포넌트의 역할이 명확하게 분리되어 있는가?
  • 상태 흐름이 직관적이며, Form을 일관되게 관리할 수 있는 구조인가?

2. 컴포넌트 구조 및 재사용성

  • 각 컴포넌트의 역할과 책임이 명확한가?
  • 컴포넌트 분리와 중첩이 적절한가?
  • 공통 컴포넌트(Input, Button 등)의 재사용성이 확보되었는가?

3. 상태 기반 유효성 검사 및 확인 버튼 활성화

  • 필드 유효성에 따른 다음 버튼 활성화/비활성화가 정상 작동하는가?
  • 유효성 검사 기준이 명확하고 상태-UI 연동이 적절한가?

@junny97 junny97 requested review from KIMSEUNGGYU and dgd03146 June 5, 2025 15:32
@junny97 junny97 self-assigned this Jun 5, 2025
@junny97 junny97 added the enhancement New feature or request label Jun 5, 2025
Copy link

@KIMSEUNGGYU KIMSEUNGGYU left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다.

몇가지 리뷰 남겼습니다.


추가로 react-hook-form 을 각 컴포넌트 마다 작성하셨는데 저 같은 경우 복잡한 form 인 경우에만 react-hook-form 을 사용하는 편인데 각각의 스텝(form 엘리먼트) 요소마다 form 으로 제어하셨는데 이유가 있으실까요??

제가 만약 react-hook-form 을 사용했더라면 funnel 있는 컴포넌트에 form provider 으로 감싸고, 각각의 컴포너넌트에서 useForm 으로 각각의 폼 요소를 제어할 수 있도록 할거 같습니다.
해당 방식을 선택한 이유가 궁금합니다. 의견 나눠 보면 좋을거 같아요!! ㅎㅎㅎ

};

const currentStep = getCurrentStep();
const stepIndex = steps.indexOf(currentStep);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentStepIndex 로 하면 좀 더 명확한 네이밍이 될거 같습니다 ㅎㅎㅎ

Comment on lines +74 to +75
variant={isSubmitting || !isValid ? 'disabled' : 'primary'}
disabled={isSubmitting || !isValid}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

variant 를 기본적으로 primary 인데 제출중 또는 유효하지 않은 경우 variant 의 스타일을 변경하셨는데 disabled 일때 스타일을 지정하면 해당 중복 검증 로직을 한쪽에서만 관리할 수 있을거 같아요

<input
type='radio'
value='여성'
className='sr-only'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sr-only 라는걸 처음 보는데 "화면에는 안보이지만, 스크린 리더를 위한 사용자" 를 위한 스타일이군요! 👍👍

Comment on lines +38 to +40
React.useEffect(() => {
setValue('genre', selectedGenres, { shouldValidate: true });
}, [selectedGenres, setValue]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저 같은 경우 useEffect 에 함수명을 작성하는걸 선호하는데 함수명을 작성하면 따로 주석 처리 없이 가독성이 좋아진다고 생각해요 (+ 디버깅시 함수명이 나옴)

React.useEffect(
  function validateChangeSelectedGenresEffect() {
    setValue('genre', selectedGenres, { shouldValidate: true });
  },
 [selectedGenres, setValue]);

Comment on lines +68 to +72
className={`px-3 py-2 rounded-full text-sm transition-colors ${
selectedGenres.includes(genre)
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조건에 따른 스타일이 className 에 있다보니깐 가독성을 헤치는 거 같아서 아쉬움이 있습니다.

  • clsx, class-variance-authority, tailwind-merge 라이브러리를 활용해서 className 을 좀 더 잘 정의할 수 있을거 같아요
    -> shadcn 에서 자주 사용하는 패턴입니다.


return (
<button
className={`${getButtonClass()}${className}`}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • shadn 에서 className 관리하는 방식 (cn()) 을 참고하시면 좀 더 깔끔하게 className 을 관리할 수 있을거 같아요!!


export default function ErrorMessage({
message,
className = '3',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

className = '3', 에서 3의 의미가 무엇인가요???

message,
className = '3',
}: ErrorMessageProps) {
if (!message) return null;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!message 은 빈문자열을 방지하기 위한 인가요?

fallbackPath?: string;
}

export default function OnboardingResultGuard({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 온보딩 퍼널 페이지에서 가드 코드를 해당 컴포넌트 내부에서 작성했는데 이렇게 따로 guard 컴포넌트로 관리하면 괜찮을거 같습니다! ㅎㅎ (사실 추출하기 귀찮아서 안했지만 ㅋㅋㅋ)

Comment on lines +21 to +25
const getButtonClass = () => {
if (isDisabled) return 'btn btn-disabled';
if (variant === 'secondary') return 'btn btn-secondary';
return 'btn btn-primary';
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저 같은 경우 이런 경우는 컴포넌트 내부에서 객체 상수로 추출해서 사용할거 같아요

함수가 리렌더링 될 때마다 해당 함수가 계속 생성되고 호출되어서 불필요한 리소스가 낭비가 될 수 있는 포인트라고 생각합니다.


<Funnel step={currentStep}>
<Funnel.Step name='nickname'>
<NicknameStep data={onboardingData} onNext={handleStepComplete} />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onboardingData를 다 넣는거보다 각 스텝에 필요한 데이터만 넣는다면 불 필요한 리렌더링이 줄어들 것 같아요! props도 관리하기가 더 편할 것 같습니다ㅎㅎ

Comment on lines +8 to +10
currentStep,
totalSteps,
className,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 ProgressBar는 currentStep/totalSteps라는 네이밍 구조 때문에 funnel 전용으로 보여서 features/funnel/components 등으로 옮기는 게 응집도 측면에서 더 자연스러울 것 같아요.
재사용성을 염두하고 있다면 좀 더 범용적으로 value, max 같은 props 네이밍으로 고려해봐도 될 것 같습니다!

Comment on lines +60 to +90

<Funnel step={currentStep}>
<Funnel.Step name='nickname'>
<NicknameStep data={onboardingData} onNext={handleStepComplete} />
</Funnel.Step>

<Funnel.Step name='gender'>
<GenderStep
data={onboardingData}
onNext={handleStepComplete}
onPrev={goToPrevStep}
/>
</Funnel.Step>

<Funnel.Step name='genres'>
<GenresStep
data={onboardingData}
onNext={handleStepComplete}
onPrev={goToPrevStep}
/>
</Funnel.Step>

<Funnel.Step name='favorite'>
<FavoriteStep
data={onboardingData}
onPrev={goToPrevStep}
onComplete={handleFinalSubmit}
isSubmitting={onboardingMutation.isPending}
/>
</Funnel.Step>
</Funnel>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드를 읽는 사람 입장에서 온보딩 흐름이 명확하게 드러나서 좋은 구조인 것 같습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants