배경: 왜 useFunnel을 사용하게 되었을까?
퍼널은 '깔대기'라는 의미로, 사용자가 특정 목표를 달성하기 위해 거치는 일련의 단계들을 말합니다. 우리 프로젝트에서는 회원가입, 비밀번호 찾기, 목표 설정처럼 여러 단계로 이루어진 프로세스가 많았습니다. useFunnel은 이런 다단계 프로세스를 더 쉽게 관리하려고 만들었습니다. 각 단계별 UI 컴포넌트 관리, 단계 간 데이터 전달, 그리고 특정 단계로 직접 접근을 편하게 하는 것을 목표로 했습니다.
더 자세한 이야기는 이 글에서 확인하실 수 있습니다!
useFunnel 훅 소개
useFunnel 훅은 다단계 프로세스를 관리하는 커스텀 훅입니다. 사용자가 단계별로 데이터를 입력하고 자연스럽게 다음 단계로 넘어갈 수 있게 도와줍니다. 각 단계마다 입력된 데이터의 유효성을 검사하고, 이전 단계에서 입력한 정보를 유지하면서 전체 프로세스를 진행할 수 있도록 만들었습니다.
주요 기능:
- 현재 단계 상태 관리와 URL 라우팅 처리
- 단계별 데이터 저장 및 유효성 검증 기능
- 세션 스토리지를 통한 사용자 데이터 유지
개발 과정에서 마주친 3가지 문제
useFunnel 훅을 구현하면서 React의 렌더링 메커니즘, Next.js의 라우팅, 그리고 서버 사이드 렌더링(SSR) 관련 여러 기술적 문제에 부딪혔습니다. 각 문제를 어떻게 분석하고 해결했는지 공유해 보겠습니다.
문제 1: 불필요한 리렌더링 문제
문제 코드 분석
Funnel에서 사용자가 다음 단계로 넘어갈 때 아래 함수가 실행됩니다:
const setStep = (nextStep: NextStep, currentStepData: CurrentStepData): void => {
// 현재 단계와 이동하려는 단계가 동일하면 함수 종료
if (currentStep === nextStep) return;
// 현재까지의 데이터와 새로 입력된 데이터를 병합
const newData = { ...funnelData, ...(currentStepData || {}) };
// 다음 단계로 이동하기 전 데이터 유효성 검증
if (!validateStep[nextStep](newData)) return;
// 상태 업데이트 -> 리렌더링 발생
setFunnelData(newData);
// URL 변경을 통한 라우팅 (비동기 작업)
router.push(`다음 단계로`);
};
이 코드에서 어떤 문제가 발생할까요?
setFunnelData
로 인한 컴포넌트 리렌더링과 router.push
의 실행 타이밍이 맞지 않습니다.
이 때문에 불필요한 리렌더링이 발생합니다.
실제 실행 순서를 보면:
setFunnelData(newData)
가 호출되면 React는 컴포넌트 리렌더링을 예약합니다.- 그 직후
router.push
가 호출되지만, 이것은 비동기로 작동합니다. - React는 먼저 예약된 리렌더링을 실행합니다.
- 리렌더링이 끝난 후에야 라우팅 변경이 적용됩니다.
결국, 사용자가 다음 페이지로 넘어가기 직전에 현재 컴포넌트가 다시 그려지는 것입니다.
해결 방법: useState를 useRef로 대체
우리 프로젝트는 각 단계마다 router.push로 페이지가 새로 렌더링되기 때문에, useRef를 써서 데이터 상태 관리를 해결할 수 있었습니다.
// useState 대신 useRef 사용
const funnelDataRef = useRef<Partial<K[T]>>({});
const setStep = (nextStep: NextStep, currentStepData: CurrentStepData): void => {
if (currentStep === nextStep) return;
// 데이터 병합
const newData = { ...funnelDataRef.current, ...(currentStepData || {}) };
// 유효성 검증
if (!validateStep[nextStep](newData)) return;
// useRef를 사용하여 리렌더링 없이 데이터 저장
funnelDataRef.current = newData;
// 다음 단계로 라우팅
router.push(`다음 스텝으로`);
};
해결 원리
useRef는 React의 렌더링 사이클과 독립적으로 동작하는 특징이 있습니다:
useRef
의.current
값을 바꿔도 리렌더링이 발생하지 않습니다.- 데이터는 메모리에 업데이트되지만 컴포넌트는 다시 그려지지 않습니다.
- 데이터 저장 후 바로
router.push
가 실행되어 다음 페이지로 넘어갑니다. - 다음 단계에서 컴포넌트가 새로 마운트될 때 업데이트된 funnelDataRef 값을 쓸 수 있습니다.
이렇게 하면 불필요한 리렌더링을 없애면서도 단계 간 사용자 데이터를 효과적으로 유지할 수 있게 되었습니다.
문제 2: 컴포넌트 재생성 시 데이터 유지 문제
문제 코드 분석
useRef를 도입한 후, 데이터 지속성과 관련된 새로운 문제가 발생했습니다:
// useRef로 데이터 관리
const funnelDataRef = useRef<Partial<K[T]>>({});
// useEffect 내에서 세션 데이터 로드
useEffect(() => {
const funnelSessionData = getSessionStorageItem(sessionId, {} as K[T]);
funnelDataRef.current = funnelSessionData;
}, []);
이 코드에서 발생하는 문제는:
- useEffect는 컴포넌트가 마운트된 후에만 실행됩니다.
- 따라서 첫 렌더링 시에는 funnelDataRef에 세션 데이터가 없는 상태로 화면이 그려집니다.
- useEffect 실행 후 데이터는 업데이트되지만, useRef 특성상 리렌더링이 일어나지 않습니다.
- 결국 사용자는 세션에 저장된 데이터가 있어도 UI에 반영되지 않는 현상을 경험합니다.
해결 방법: useEffect 바깥에서 초기화
초기 렌더링 시점에 바로 데이터를 로드하는 방법으로 문제를 해결했습니다:
const funnelDataRef = useRef<Partial<K[T]>>({});
if (isClient()) {
const funnelSessionData = getSessionStorageItem(sessionId, {} as K[T]);
funnelDataRef.current = funnelSessionData;
}
해결 원리
방식의 핵심은 useEffect의 지연 실행을 기다리지 않고 컴포넌트 렌더링 단계에서 바로 데이터를 로드하는 것입니다:
- useEffect는 컴포넌트가 DOM에 마운트된 후에 실행되지만, 컴포넌트 함수 본문 코드는 렌더링 과정에서 즉시 실행됩니다.
- isClient 유틸 함수를 통해 클라이언트 환경인지 확인합니다.
- 클라이언트 환경에서는 컴포넌트가 렌더링되는 동안 즉시 세션 스토리지에서 데이터를 가져옵니다.
- funnelDataRef.current에 바로 데이터를 할당하므로 컴포넌트의 첫 렌더링부터 최신 데이터가 반영됩니다.
이제 컴포넌트가 처음 렌더링될 때부터 정확한 데이터가 보이게 되었습니다!
문제 3: 서버/클라이언트 Hydration 불일치 문제
문제 상황 분석
2번째 문제를 해결한 후, Next.js의 서버 사이드 렌더링(SSR)과 관련된 또 다른 문제가 발생했습니다:
// 클라이언트 환경인지 확인
const isClient = typeof window !== 'undefined';
// 클라이언트 환경에서만 세션 스토리지 접근
if (isClient) {
const funnelSessionData = getSessionStorageItem(sessionId, {} as K[T]);
funnelDataRef.current = funnelSessionData;
setInitialData(funnelSessionData);
}
이 코드에서는 Hydration 불일치 문제가 발생합니다.
- 서버에서는
isClient
가 false이므로 세션 스토리지에 접근하지 않고initialData
와funnelDataRef.current
는 빈 객체{}
상태로 남습니다. - 클라이언트에서는
isClient
가 true가 되어 세션 스토리지에서 데이터를 가져와initialData
와funnelDataRef.current
를 업데이트합니다. - 이렇게 되면 서버에서 만든 초기 HTML과 클라이언트에서 React가 그리려는 DOM 구조가 달라지게 됩니다.
- Next.js는 이런 불일치를 "Hydration Error"로 감지하고 오류를 일으킵니다.
해결 방법: useSyncExternalStore 활용하여 useIsClient 훅 구현
React 18에서 새로 나온 useSyncExternalStore
훅을 활용해서 서버와 클라이언트 환경을 안전하게 구분하는 방식으로 문제를 해결했습니다:
'use client';
import { useSyncExternalStore } from 'react';
// 클라이언트 상태 관리 스토어 구현
const clientStore = {
// 상태 변화 구독 함수 (여기선 변화가 없으니 빈 함수 반환)
subscribe: () => () => {},
// 클라이언트 환경에선 true 반환
getClientSnapshot: () => true,
// 서버 환경에선 false 반환
getServerSnapshot: () => false
};
// 클라이언트 환경 여부를 안전하게 확인하는 커스텀 훅
const useIsClient = () => {
const isClient = useSyncExternalStore(
clientStore.subscribe,
clientStore.getClientSnapshot,
clientStore.getServerSnapshot
);
return isClient;
};
export default useIsClient;
해결 원리
useSyncExternalStore
는 서버와 클라이언트 사이의 렌더링 불일치 문제를 해결하기 위해 특별히 만들어진 React 훅입니다:
getServerSnapshot
은 서버 렌더링 때 쓰이는 초기값을 반환합니다 (여기선 false).getClientSnapshot
은 클라이언트 렌더링 때 쓰이는 값을 반환합니다 (여기선 true).- React는 이 두 환경에서 일관된 렌더링 결과를 만들기 위해 이 값들을 활용합니다.
- 서버에선 항상
getServerSnapshot
의 값을 쓰고, 클라이언트에서 hydration이 끝난 후엔getClientSnapshot
의 값을 씁니다. - 이런 방식으로 초기 렌더링 때 서버와 클라이언트의 불일치를 막으면서도, hydration 완료 후엔 클라이언트 기능을 안전하게 쓸 수 있게 됩니다.
이 접근 방식을 통해 서버와 클라이언트 간의 렌더링 결과를 일치시키면서도, 클라이언트에선 세션 스토리지 같은 브라우저 전용 기능을 안전하게 사용할 수 있게 되었습니다.
최종 구현
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import { useEffect, useRef } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { getSessionStorageItem, removeSessionStorageItem, setSessionStorageItem } from '@/utils/session-storage.util';
import useIsClient from './use-is-client';
const FUNNEL_QUERY_PARAM = 'step';
const DEFAULT_SESSION_ID = 'todayeat-funnel-data';
/**
* 객체 타입에서 필수 키를 추출하는 타입
* undefined가 될 수 없는 프로퍼티 키들을 추출
*/
type RequiredKeys<T> = {
[K in keyof T]-?: undefined extends T[K] ? never : K;
}[keyof T];
/**
* 다음 스텝에만 있고 현재 스텝에는 없는 필수 키를 추출
*/
type NewRequiredKeys<TNext, TCurrent> = Exclude<RequiredKeys<TNext>, RequiredKeys<TCurrent>>;
/**
* 다음 스텝으로 이동할 때 필요한 추가 필드 타입
* 필수 필드는 반드시 포함, 선택적 필드는 선택적으로 포함
*/
type RequiredFieldsForNewStep<TNext, TCurrent> = Pick<TNext, NewRequiredKeys<TNext, TCurrent>> & Partial<TCurrent>;
/**
* 객체가 비어있는지 확인하는 타입 (Record<string, never>인 경우 true)
*/
type IsEmptyObject<T> = T extends Record<string, never> ? true : false;
/**
* 스텝 컴포넌트에 전달되는 props 타입
* @template K - 각 스텝의 데이터 타입 매핑
* @template Step - 모든 가능한 스텝 타입
* @template CurrentStep - 현재 스텝 타입
*/
type StepComponentProps<K extends Record<string, unknown>, Step extends string, CurrentStep extends Step> = {
setStep: {
<NextStep extends Step>(nextStep: NextStep, data: RequiredFieldsForNewStep<K[NextStep], K[CurrentStep]>): void;
<NextStep extends Step>(
nextStep: NextStep
): IsEmptyObject<RequiredFieldsForNewStep<K[NextStep], K[CurrentStep]>> extends true ? void : never;
};
data: K[CurrentStep];
clearFunnelData: () => void;
};
/**
* Funnel 컴포넌트에 전달되는 props 타입
* 각 스텝별 렌더링 함수를 매핑
*/
type FunnelComponentProps<K extends Record<string, unknown>, T extends Extract<keyof K, string>> = {
[Step in T]: (props: StepComponentProps<K, T, Step>) => JSX.Element;
};
/**
* useFunnel 훅의 반환 타입
* Funnel 컴포넌트
*/
type UseFunnelReturnType<K extends Record<string, unknown>, T extends Extract<keyof K, string>> = {
Funnel: (props: FunnelComponentProps<K, T>) => JSX.Element;
};
/**
* 다단계 폼 프로세스(funnel)를 쉽게 관리하기 위한 커스텀 훅
* URL 동기화, 세션 스토리지 저장 및 데이터 유효성 검사를 지원
*
* @param initialStep - 초기 스텝 값
* @param validateStep - 각 스텝별 데이터 유효성 검사 함수 맵
* @param sessionId - 세션 스토리지에 사용할 키 (기본값: 'todayeat-funnel-data')
* @returns Funnel 컴포넌트
*/
const useFunnel = <K extends Record<string, unknown>, T extends Extract<keyof K, string>>(
initialStep: T,
validateStep: { [Step in T]: (data: K[Step]) => boolean },
sessionId: string = DEFAULT_SESSION_ID
): UseFunnelReturnType<K, T> => {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const stepInQueryParam = searchParams.get(FUNNEL_QUERY_PARAM) as T;
const isValidStepName = (step: string | null): step is T => {
if (!step) return false;
return Object.keys(validateStep).includes(step);
};
const getInitialStep = (step: string, initialStep: T): T => {
return isValidStepName(step) ? step : initialStep;
};
const currentStep = getInitialStep(stepInQueryParam, initialStep);
const funnelDataRef = useRef<Partial<K[T]>>({});
const isClient = useIsClient();
if (isClient) {
const funnelSessionData = getSessionStorageItem(sessionId, {} as K[T]);
funnelDataRef.current = funnelSessionData;
}
useEffect(() => {
const funnelSessionData = getSessionStorageItem(sessionId, {} as K[T]);
if (!validateStep[currentStep](funnelSessionData)) {
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.set(FUNNEL_QUERY_PARAM, initialStep);
router.replace(`${pathname}?${newSearchParams.toString()}`, { scroll: false });
}
}, []);
useEffect(() => {
if (currentStep === stepInQueryParam) return;
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.set(FUNNEL_QUERY_PARAM, currentStep);
router.replace(`${pathname}?${newSearchParams.toString()}`, { scroll: false });
}, [currentStep, stepInQueryParam]);
const clearFunnelData = () => {
removeSessionStorageItem(sessionId);
funnelDataRef.current = {};
};
const setStepImplementation = <NextStep extends T>(
nextStep: NextStep,
currentStepData: RequiredFieldsForNewStep<K[NextStep], K[typeof currentStep]>
): void => {
if (currentStep === nextStep) return;
const newData = { ...funnelDataRef.current, ...(currentStepData || {}) } as K[NextStep];
if (!validateStep[nextStep](newData)) {
alert('비정상적인 접근입니다.');
console.error(`Invalid data for step ${nextStep} and ${newData}`);
return;
}
funnelDataRef.current = newData;
setSessionStorageItem(sessionId, newData);
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.set(FUNNEL_QUERY_PARAM, nextStep);
router.push(`${pathname}?${newSearchParams.toString()}`, { scroll: false });
};
const setStep = setStepImplementation as typeof setStepImplementation & {
<NextStep extends T>(
nextStep: NextStep
): IsEmptyObject<RequiredFieldsForNewStep<K[NextStep], K[typeof currentStep]>> extends true ? void : never;
};
const Funnel = (props: FunnelComponentProps<K, T>) => {
return props[currentStep]({ setStep, data: funnelDataRef.current as K[T], clearFunnelData });
};
return { Funnel };
};
export default useFunnel;
위에서 설명한 세 가지 핵심 문제를 해결한 완성된 useFunnel 훅 구현은 본문에 제시된 코드와 같습니다. 이 구현으로 다단계 프로세스를 안정적으로 관리하면서도 성능을 최적화하고 서버/클라이언트 렌더링 일관성을 유지할 수 있게 되었습니다.
후기
처음에는 useState와 useRef의 차이점을 단순히 "하나는 리렌더링을 일으키고 다른 하나는 그렇지 않다"는 수준으로만 알고 있었습니다. 따라서 useRef는 주로 UI에 렌더링되지 않는 값을 저장하기 위한 용도로만 사용했습니다. 하지만 이번 기회에 useState 대신 useRef를 사용할 수 있는 상황과 이유를 알게 되면서 useRef의 새로운 사용 방법을 알 수 있었습니다.
Next.js에서 Hydration 오류를 겪으면서 왜 이런 문제가 발생하는지 원인과, 훅을 통해 관리했을 때는 왜 발생하지 않았는지 이유를 알게 되었습니다. 서버에서 렌더링한 HTML과 클라이언트에서 렌더링하려는 React 트리가 일치하지 않을 때 발생하는 문제를 useSyncExternalStore 훅을 활용하여 해결할 수 있었습니다.
이번 경험을 통해 Next.js에서 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR) 간의 상호작용에 대해 보다 깊이 있게 이해할 수 있었으며, 유사한 이슈가 발생했을 때 보다 체계적이고 효과적으로 대응할 수 있는 기반을 마련할 수 있었습니다!
'💻 개발 > 투데잇 - AI 자동 식단 일기' 카테고리의 다른 글
Next.js에서 단계적 사용자 경험을 위한 useFunnel 훅 구현하기 (0) | 2025.04.29 |
---|---|
투데잇: 개발 환경에서 Sentry 비활성화하기 - 문제와 해결 과정 (0) | 2025.04.02 |
투데잇: 우리 프로젝트의 코드 컨벤션 가이드 (0) | 2025.04.02 |