바야흐로 처음 퍼널 패턴을 코딩해본건 벚꽃톤 프로젝트에서였다.
이 프로젝트에서의 퍼널 아키텍쳐는

<Funnel>이라는 특별한 상자를 쓰면, 넣어둔 여러 단계 중 “지금 단계와 같은 것만” 화면에 보여줘요.<Funnel.Step name="..."> 형태로 각 단계 화면을 적어놓을 수 있어요. (책의 각 장을 구분해 꽂아둔 느낌)<Funnel>
<Funnel.Step name="STEP1"> … </Funnel.Step>
<Funnel.Step name="STEP2"> … </Funnel.Step>
<Funnel.Step name="STEP3"> … </Funnel.Step>
</Funnel>
이 코드는 step이 바뀔 때마다 Funnel의 “정체성”이 조금씩 새로워져요.
→ 장점: 단계가 바뀌면 그 단계 안의 로컬 상태가 초기화되어 “새로운 화면처럼” 동작합니다.
→ 단점: “이전 단계에서 입력해 둔 값이 그대로 남아야” 하는 경우라면, 값을 상위/공용 상태로 올려서 보존해야 합니다. (보통 컨텍스트나 상위 컴포넌트에 저장)
즉, 이 코드는 step이 바뀔 때마다 Funnel의 “정체성”이 조금씩 새로워져요.
“step이 바뀔 때마다 Funnel의 정체성이 새로워진다”
React 입장에서 <Funnel>의 컴포넌트 타입(함수 참조)이 매 스텝 변경 때마다 달라진다 → 그래서 해당 서브트리를 언마운트하고 새로 마운트한다는 뜻인거죠.

이미지에서도 보이다시피
useMemo의 의존성이 [step] 이라서, step이 바뀔 때마다 새로운 함수(= 새로운 컴포넌트 타입)를 만들어서 반환합니다.
호출하는 쪽에서는 <Funnel>에 이 “새 함수”를 컴포넌트로 쓰고 있죠?
React는 엘리먼트의 타입(함수 참조)이 바뀌면 다른 컴포넌트로 간주하고, 이전 것을 언마운트 → 새로 마운트합니다.
이게 “정체성이 새로워진다(remount가 난다)”는 의미입니다.
좀 더 이해하기 쉽게 말씀드리자면,
리액트 함수 컴포넌트는 말 그대로 “함수”예요. TSX인 은 컴파일되면 대략 이렇게 바뀝니다:
React.createElement(FunnelComponent, props)
여기서 FunnelComponent가 곧 함수(컴포넌트 타입)예요.
다시 돌아와 step이 변할 때마다 useMemo가 새 함수를 리턴해요. 즉, 오늘 <Funnel />은 함수A를 타입으로 쓰고 있고, 다음 스텝 전환 후엔 함수B를 타입으로 쓰는 셈이죠.
이걸 “<Funnel>에 새(로운) 함수를 컴포넌트로 쓴다”라고 표현한 거예요!
마운트/언마운트 vs 다시 렌더링 차이
- 렌더(Render): 컴포넌트 함수를 호출해서 “UI 설명서(React 엘리먼트)”를 만드는 과정. 같은 인스턴스가 여러 번 렌더될 수 있어요.
- 커밋(Commit): 렌더 결과를 실제 DOM에 반영하는 단계.
- 마운트(Mount): 그 컴포넌트 인스턴스가 트리에 ‘처음’ 생기는 것. DOM 노드가 새로 만들어지고, useEffect가 처음 실행되고, useState/useRef가 초기화돼요.
- 언마운트(Unmount): 인스턴스가 트리에서 제거되는 것. 이때 useEffect 클린업이 호출돼요.
- 업데이트(Re-render/Update): 같은 인스턴스가 props/state 변화로 다시 렌더되는 것. state/ref는 유지돼요.
방금처럼 step이 바뀔 때마다 새 함수를 컴포넌트 타입으로 쓰니, 이전 타입(함수A) 인스턴스를 언마운트하고, 새 타입(함수B) 인스턴스를 마운트합니다.
이 전체 과정을 흔히 리마운트가 난다고 말해요. → 결과적으로 그 아래 트리의 로컬 state/ref/effect가 모두 초기화돼요.
function A() { return null }
function B() { return null }
let C = A;
<C /> // 타입은 A
C = B;
<C /> // 이제 타입은 B (다른 컴포넌트로 간주 → A 언마운트, B 마운트)
현재 형태가 바로 이런 식으로 타입이 바뀌는 상황을 만들고 있는 겁니다.
그렇기에 지금 퍼널은 활성 스텝만 렌더하고 나머지는 언마운트되니까, 스텝 안 useState로 들고 있던 값들은 단계가 바뀌면 사라지게 됩니다. 그걸 안 사라지게 하려고,1년 전의 저는 스텝 바깥(공통 상위)에서 Context Provider가 하나의 “중앙 저장소” 역할을 하게 만들었습니다.
스텝 전환 시 해당 스텝 컴포넌트는 언마운트 → 마운트됨 → 로컬 state 초기화.
이전 단계를 가서 내가 클릭한 값을 봐야되잖아요. 즉, 입력값/선택값이 유지되어야 하니, 상위(Provider)에 상태를 두고 스텝은 읽고/패치만 하도록 설계를 한거죠.

그래서 이렇게 라우터에 렌더링하는 부분에 Context API(지역적인 전역상태를 관리하기 위해) 를 설계해뒀습니다.

결론: OnboardingProvider는 바로 이 “상위 중앙 저장소”고, 각 스텝은 useOnboardingContext()로 읽고 updatePostInfo()로 씁니다.
1-1. <Funnel> 타입이 매 스텝마다 바뀜 → 서브트리 리마운트
1-2. 비활성 스텝은 전부 언마운트
<Funnel>이 현재 스텝과 이름이 같은 자식 하나만 반환 → 나머지는 언마운트
대안: 정말 유지가 필요할 때만 특정 컴포넌트에 key를 줘 선택적 리마운트,
혹은 keep-alive(숨김 처리) 패턴을 일부 구간에만 적용(접근성/성능 고려)
2-1. “한 값 변경 → 모든 소비자 리렌더”
컨텍스트 value가 바뀌면 그걸 구독한 모든 컴포넌트가 다시 렌더됩니다.
onboardingInfo가 큰 객체고, 자주 바뀌는 필드(타이핑 등)가 있으면 광범위 리렌더 → 느려짐/깜빡임
대안:
컨텍스트 분할(읽기 많은 값과 쓰기 함수 분리),
useCallback으로 액션 참조 안정화,
use-context-selector 같은 셀렉터 기반 컨텍스트,
혹은 외부 스토어(Zustand/Jotai/Redux)를 이용하는 것이 대안이 될 수 있겠죠.
2-2. value 참조 안정성
value = useMemo(() => ({ onboardingInfo, updatePostInfo }), [onboardingInfo])
updatePostInfo 참조가 매번 바뀌면 value가 항상 새 객체가 되어 전 소비자 리렌더됩니다.
코드는 useMemo를 쓰지만 updatePostInfo를 useCallback으로 고정하지 않으면 결국 효과 반감되죠.
앞서 말한 퍼널은 화면 전환 위젯이였던 것 같습니다. “현재 스텝만 렌더”하고 나머지는 언마운트하는, 단순하지만 강력한 뷰 스위처랄까요.
그리고 이제 먼지 치우기에서 관련 설문을 테스트하는 퍼널을 통해 “분기/스킵/이어하기/점수/저장·제출” 같은 도메인 요구가 붙어서 퍼널을 도메인 주도 상태머신으로 재설계해봤습니다.
핵심 변화는 두 가지:
① 정적 단계 → 동적 질문 흐름(answers에 의해 flow가 바뀜)
② 뷰 중심 → 데이터/행동 중심(setAnswer/next/prev/복원/정리/검증/전송)
다시 짧게 앞에서의 퍼널의 한계를 애기해보고 간다면,
⓵ 리마운트 이슈(정체성 변화)
useMemo 의존성이 [step]이라 스텝 전환 시 의 컴포넌트 타입이 바뀝니다 → 언마운트→마운트(리마운트) 발생.
덕분에 내부 공통 상태/이펙트도 초기화될 수 있습니다.
⓶ 비활성 스텝은 전부 언마운트
현 스텝 외 스텝은 모두 사라집니다. 스텝 내부의 useState/useRef는 돌아오면 초기화.
→ “입력 유지”를 원한다면 상위(컨텍스트 등)로 상태 승격이 필수.
⓷ 이전/다음 로직 드리프트
화면 쪽에서 인덱스/숫자 기반으로 prev/next를 따로 들고가면, steps 배열 변경과 불일치가 나기 쉽습니다.
⓸ URL·복원·분기 부족
새로고침/딥링크/이어하기, 답변에 따른 동적 분기는 뷰 스위처만으로는 한계.
그래서 1세대에서는 Context API로 “중앙 저장소”를 만들었습니다. 스텝이 언마운트되어도 값이 남아있도록 상위에 올려두는 방식.
하지만 Context는 값이 바뀌면 모든 소비자 리렌더가 발생한다는 구조적 한계가 있고(특히 타이핑처럼 고빈도 변경에 약함), 퍼널이 커질수록 렌더 파급이 커집니다.
const funnel = useFunnel(qs, { initialAnswers: cont?.answers ?? null });
funnel.flow // 현재 질문 흐름(분기/스킵 반영)
funnel.current // 현재 질문
funnel.index // 현재 위치
funnel.answers // 답변 맵
funnel.totalScore // 점수 합산
funnel.progress // 진행도(answered/total)
funnel.setAnswer(id, answer) // 답변 기록
funnel.next() / prev() // 이동
funnel.goToIndex(n) // 임의 이동
funnel.reset() // 전체 초기화