Next.js URL 기반 테이블 상태 관리 구현하기
페이지네이션, 필터링, 정렬처럼 사용자 인터랙션이 많은 테이블은 상태 관리가 쉽게 복잡해집니다. 특히 Next.js 앱 라우터의 서버 사이드 환경에서는 이러한 클라이언트 상태를 어떻게 다룰지가 고민거리입니다. 클라이언트 사이드에서 필터를 변경했을 때 서버에서 데이터를 어떻게 가져올지, URL이나 브라우저 뒤로가기 같은 웹의 기본 기능들을 어떻게 지원할지 등 고려해야 할 점이 많습니다. 이 글에서는 **@tanstack/react-table**과 **nuqs**를 조합하여 URL 기반의 테이블 상태 관리 시스템을 구축하는 방법을 다룹니다. 핵심 아이디어는 테이블의 모든 상태(페이지, 필터, 정렬 등)를 URL에 저장하여 상태를 공유 가능하게 만들고, 클라이언트의 상태 변경이 서버의 새로운 데이터 페칭으로 이어지는 구조입니다. --- ## 전체 아키텍처 개요 구현할 기능의 전체 동작 과정을 정리하면 ``` 사용자 인터랙션 (필터 변경, 페이지네이션 등) ↓ 클라이언트 사이드에서 URL 업데이트 (shallow: false) ↓ 서버사이드 렌더링 및 URL 파라미터 파싱 ↓ 새로운 파라미터로 API 호출 ↓ 클라이언트로 새 데이터 전달 및 UI 업데이트 ``` ## URL 파라미터 스키마 정의하기 테이블에서 어떤 정보들을 URL에 담을지 먼저 정해야 합니다. ```tsx //search-params.ts import { parseAsInteger, parseAsString, createSearchParamsCache } from 'nuqs/server'; export const searchParamsSchema = { page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), email: parseAsString, role: parseAsString, createdAt: parseAsString, }; export const searchParamsCache = createSearchParamsCache(searchParamsSchema); ``` nuqs에서 제공하는 `searchParamsCache`는 URL 쿼리 파라미터를 파싱하고 타입 변환하여 메모리에 저장하는 캐시 객체입니다. ```tsx // URL: /users?page=2&email=john&perPage=20 // 이렇게 변환됩니다: { page: 2, perPage: 20, email: "john", role: undefined, createdAt: undefined } ``` `searchParamsCache`는 서버 사이드에서만 사용가능합니다. ## 서버에서 URL 기반 데이터 가져오기 사용자가 URL을 변경하면 Next.js는 이를 새로운 페이지 요청으로 인식하고, 가장 먼저 `page.tsx` 컴포넌트를 실행합니다. ```tsx // page.tsx export default async function Page(props: Props) { const searchParams = await props.searchParams; searchParamsCache.parse(searchParams); // 캐시에 URL 정보 저장 return ( <Suspense fallback={<DataTableSkeleton />}> <UserListingPage /> </Suspense> ); } ``` `/users?page=2&email=john` 같은 URL에서 쿼리 파라미터를 추출하고, 미리 정의된 스키마에 따라 `page`는 숫자로, `email`은 문자열로 타입을 변환한 뒤 메모리의 캐시에 저장합니다. 캐시에 저장된 파라미터들은 이후 하위 컴포넌트들에서 타입 안전하게 접근할 수 있게 됩니다. ### URL 파라미터를 API 요청으로 변환하는 컴포넌트 ```tsx // UserListingPage.tsx export default async function UserListingPage() { const page = searchParamsCache.get('page'); const email = searchParamsCache.get('email'); const limit = searchParamsCache.get('perPage'); const role = searchParamsCache.get('role'); const createdAt = searchParamsCache.get('createdAt'); const filters = { page, limit, ...(email && { email }), ...(role && { role }), ...(createdAt && { createdAt }), }; // 실제 API 호출 const response = await getUserList(filters); const users = response.data ?? []; const pageCount = response.totalPages ?? 1; // 데이터를 클라이언트 컴포넌트로 전달 return <UserTable data={users} pageCount={pageCount} columns={columns} />; } ``` ## 클라이언트에서 테이블 상태 관리 ```tsx // UserTable.tsx 'use client' export function UserTable({ data, pageCount, columns }) { const { table } = useDataTable({ data, columns, pageCount, shallow: false, // 서버 리페칭을 위해 false debounceMs: 500, }); return ( <DataTable table={table}> <DataTableToolbar table={table} /> </DataTable> ); } ``` `shallow` 옵션에 따라 URL 변경 시 동작이 결정됩니다. shallow: true로 설정하면 URL만 업데이트되고 shallow routing이 적용되어 서버 데이터 요청이 다시 발생하지 않습니다. 따라서 클라이언트 전용 상태 관리에 적합합니다. shallow: false로 설정하면 Next.js가 URL 변경을 새로운 네비게이션으로 인식해 서버 렌더링이 다시 수행되고, 필요한 경우 서버에서 새 데이터를 가져옵니다. 우리는 테이블 상태 변경이 서버의 데이터 페칭으로 이어져야 하므로 `shallow: false`를 사용합니다. ### useDataTable 훅 **nuqs 옵션 설정** ```tsx // useDataTable.ts export function useDataTable<TData>(props: UseDataTableProps<TData>) { const { debounceMs = 300, shallow = true, startTransition, // ... } = props; // nuqs에 전달할 옵션들 const queryStateOptions = React.useMemo(() => ({ history: 'replace', scroll: false, shallow, throttleMs: 50, debounceMs, clearOnDefault: false, startTransition, }), [shallow, debounceMs, startTransition]); } ``` nuqs는 URL 상태 관리를 위한 다양한 옵션들을 제공하는데, `queryStateOptions` 객체에 이런 설정들을 모아서 정의해둡니다. 이렇게 하면 이후에 사용할 모든 `useQueryState` 훅들이 동일한 동작 방식을 가지게 됩니다. 브라우저 히스토리 관리부터 성능 최적화, 스크롤 위치 유지까지 URL 상태 변경과 관련된 모든 세부 동작을 여기서 통합 관리하는 셈입니다. **URL과 React 상태 연결** ```tsx // 페이지네이션 상태를 URL과 연결 const [page, setPage] = useQueryState( 'page', parseAsInteger.withOptions(queryStateOptions).withDefault(1) ); const [perPage, setPerPage] = useQueryState( 'perPage', parseAsInteger.withOptions(queryStateOptions).withDefault(10) ); const pagination: PaginationState = React.useMemo(() => { return { pageIndex: page - 1, // URL은 1부터, React Table은 0부터 시작 pageSize: perPage, }; }, [page, perPage]); ``` `useQueryState`를 사용하면 테이블의 모든 상태가 URL에 저장되어서 브라우저의 기본 기능들(뒤로가기, 새로고침, URL 공유)이 자연스럽게 작동합니다. 무엇보다 사용자가 필터를 변경할 때마다 URL이 업데이트되고, 이것이 서버에서 새로운 데이터를 가져오는 트리거가 됩니다. --- ## 필터링 시스템 구현 **동적 파서 생성** 테이블에서 다양한 타입의 필터를 지원하려면 각 컬럼에 맞는 URL 처리 방식이 필요합니다. 이메일 검색은 단순 텍스트이지만, 역할 선택은 여러 값을 선택할 수 있고, 날짜는 범위로 처리해야합니다. ```tsx const columns = [ { id: 'email', }, { id: 'role', meta: { options: ['admin', 'user', 'guest'] // 다중 선택 } }, { id: 'createdAt', meta: { variant: 'dateRange' } } ]; const filterParsers = React.useMemo(() => { return filterableColumns.reduce((acc, column) => { const columnId = column.id ?? ''; if (!columnId) return acc; if (column.meta?.variant === 'dateRange') { // URL: ?createdAt=2023-01-01,2023-12-31 // 결과: ["2023-01-01", "2023-12-31"] acc[columnId] = parseAsArrayOf(parseAsString, ',').withOptions(queryStateOptions); } else if (column.meta?.options) { // URL: ?role=admin,user // 결과: ["admin", "user"] acc[columnId] = parseAsArrayOf(parseAsString, ',').withOptions(queryStateOptions); } else { acc[columnId] = parseAsString.withOptions(queryStateOptions); } return acc; }, {}); }, [filterableColumns, queryStateOptions]); const [filterValues, setFilterValues] = useQueryStates(filterParsers); ``` 결과적으로 URL이 `/users?email=john&role=admin,user&createdAt=2023-01-01,2023-12-31`이면: ```tsx filterValues = { email: "john", role: ["admin", "user"], createdAt: ["2023-01-01", "2023-12-31"] } ``` **디바운싱 적용** 성능을 위해 디바운싱을 적용합니다. 사용자가 500ms 동안 입력을 멈춘 후에야 URL이 업데이트되고 서버에서 새 데이터를 가져옵니다. ```tsx const debouncedSetFilterValues = useDebouncedCallback((values) => { void setPage(1); void setFilterValues(values); }, debounceMs); ``` ### 필터 삭제와 URL 정리 사용자가 검색 필터를 지웠을 때 URL에서도 해당 파라미터를 제거해야 합니다. ```tsx const onColumnFiltersChange = React.useCallback( (updaterOrValue: Updater<ColumnFiltersState>) => { setColumnFilters((prev) => { const next = typeof updaterOrValue === 'function' ? updaterOrValue(prev) : updaterOrValue; // 1. 현재 활성화된 필터들을 URL 업데이트 객체에 수집 const filterUpdates = next.reduce((acc, filter) => { if (filterableColumns.find(column => column.id === filter.id)) { acc[filter.id] = filter.value as string | string[]; } return acc; }, {}); // 2. 삭제된 필터들을 찾아서 null 값으로 설정 (URL에서 제거됨) for (const prevFilter of prev) { if (!next.some(filter => filter.id === prevFilter.id)) { filterUpdates[prevFilter.id] = null; } } debouncedSetFilterValues(filterUpdates); return next; }); }, [debouncedSetFilterValues, filterableColumns] ); ``` --- ## 마무리 이 글에서 다룬 내용은 Next.js 서버 컴포넌트 환경에서 클라이언트의 테이블 인터랙션을 URL을 통해 서버의 데이터 페칭과 연결하는 구조입니다. 사용자가 필터를 변경하거나 페이지를 넘기면, useDataTable 훅이 URL을 업데이트하고, shallow: false 설정으로 인해 서버 컴포넌트가 새로운 searchParams와 함께 재실행됩니다. 이때 searchParamsCache가 URL 파라미터를 파싱해서 API 호출에 사용하고, 최종적으로 새로운 데이터가 클라이언트로 전달되는 구조입니다. URL을 통한 상태 관리로 서버 렌더링의 성능 이점을 유지하면서도 클라이언트의 인터랙션을 자연스럽게 서버의 데이터 페칭과 연결할 수 있습니다. nuqs와 TanStack Table이 제공하는 타입 안전성과 함께 사용하면 복잡한 테이블 상태도 안정적으로 관리할 수 있습니다.2025년 09월 20일Multi-Step Form
웹 애플리케이션에서 복잡한 사용자 정보 수집이 필요할 때가 있습니다. 긴 회원가입 폼이나 설문조사를 한 페이지에 모두 담으면 사용자가 부담스러워하기 때문에, 여러 단계로 나누어 진행하는 것이 일반적입니다. 여러 단계의 회원가입이나 설문조사 폼을 구현할 때, 아래 코드처럼 useState와 switch문을 활용한 방식을 많이 사용합니다. ```tsx const [currentStep, setCurrentStep] = useState(0); const [formData, setFormData] = useState({}); const handleNext = (data) => { setFormData(prev => ({ ...prev, ...data })); setCurrentStep(prev => prev + 1); }; const renderStep = () => { switch(currentStep) { case 0: return <EmailStep onNext={handleNext} />; case 1: return <RoleStep onNext={handleNext} />; case 2: return <PasswordStep onNext={handleNext} />; default: return null; } }; ``` 코드자체는 간단하지만 몇가지 문제가 있습니다. **URL과 UI 상태 불일치** 이 코드에서는 URL이 폼의 진행 상태를 반영하지 못합니다. 사용자가 2단계까지 진행했어도 URL은 고정되어있습니다. - 브라우저 뒤로가기/앞으로가기 버튼 미작동: 사용자가 뒤로가기 버튼을 눌러도 폼의 단계는 변하지 않아 혼란을 줍니다. - 새로고침 시 초기화: 페이지를 새로고침하면 currentStep 상태가 초기화되어 사용자는 다시 처음부터 모든 정보를 입력해야 하는 불편함을 겪게 됩니다. - 링크 공유 불가: 특정 단계로 바로 이동할 수 있는 URL을 공유할 수 없습니다. **확장성, 유지보수 문제** 프로젝트가 복잡해질수록 점점 관리하기 어려워지는 문제가 있습니다. 처음에는 단순한 3단계의 Form이지만, 기획이 변경되면서 역할에 따라 다른 플로우를 보여줘야 하거나, 특정 조건에서만 나타나는 스텝이 추가되면 switch문이 복잡해집니다. 스텝이 늘어나고 조건부 로직이 추가될 때마다 복잡한 조건 분기가 들어가면서 코드의 가독성은 떨어집니다. ```tsx const renderStep = () => { switch(currentStep) { case 0: return <EmailStep onNext={handleNext} />; case 1: return <RoleStep onNext={handleNext} />; case 2: // 역할에 따른 분기 if (formData.role === 'enterprise') { return ( <CompanyInfoStep onNext={handleNext} onPrev={handlePrev} /> ); } else if (formData.role === 'individual') { return ( <PersonalInfoStep onNext={handleNext} onPrev={handlePrev} /> ); } else { return ( <BasicInfoStep onNext={handleNext} onPrev={handlePrev} /> ); } case 3: // 이전 스텝의 선택에 따른 분기 if (formData.role === 'enterprise' && formData.companySize > 100) { return ( <EnterpriseVerificationStep onNext={handleNext} onPrev={handlePrev} /> ); } else if (formData.needsApproval) { return ( <ApprovalRequestStep onNext={handleNext} onPrev={handlePrev} /> ); } else { // 일반 사용자는 바로 패스워드 스텝으로 setCurrentStep(4); return null; } case 4: return ( <PasswordStep onNext={handleNext} onPrev={handlePrev} /> ); case 5: return showTermsStep ? ( <TermsStep onNext={handleNext} onPrev={handlePrev} /> ) : ( <CompleteStep /> ); default: return <ErrorStep />; } }; // 뒤로 가기 로직도 마찬가지로 복잡해짐 const handlePrev = () => { if (currentStep === 3 && formData.role === 'individual') { setCurrentStep(2); } else if (currentStep === 4 && formData.needsApproval) { setCurrentStep(3); } else if (currentStep === 4 && formData.role === 'enterprise' && !formData.needsApproval) { setCurrentStep(2); } else { setCurrentStep(prev => prev - 1); } }; ``` --- ## Multi-Step Form 이런 문제들을 해결하고 더 나은 개발 경험을 제공하기 위해, URL과 상태가 동기화되고, 선언적이며, 타입 안전성까지 보장되는 새로운 Multi-Step Form 구현 방식을 소개합니다. ```tsx import { EmailStep, RoleStep, PasswordStep, type FunnelState, registerAction } from '@/features/auth'; import { useFunnel } from '@/shared/lib'; export default function RegisterPage() { const [Funnel, funnelState, updateFunnelState, clearFunnelState] = useFunnel( ['email', 'role', 'password'] as const, { initialStep: 'email', history: 'replace', } ).withState<Omit<FunnelState, 'currentStep'>>({}); return ( <Funnel> <Funnel.Step name="email"> <EmailStep defaultValue={funnelState.email} next={(email) => { updateFunnelState({ email, currentStep: 'role' }); }} /> </Funnel.Step> <Funnel.Step name="role"> <RoleStep defaultValue={funnelState.role} next={(role) => { updateFunnelState({ role, currentStep: 'password' }); }} /> </Funnel.Step> <Funnel.Step name="password"> <PasswordStep defaultValues={funnelState} next={async ({ password, confirmPassword }) => { await registerAction({ email: funnelState.email!, role: funnelState.role!, password, confirmPassword, }); clearFunnelState(); }} /> </Funnel.Step> </Funnel> ); } ``` --- ### 선언적인 스텝 정의 ```tsx <Funnel> <Funnel.Step name="email"><EmailStep /></Funnel.Step> <Funnel.Step name="role"><RoleStep /></Funnel.Step> <Funnel.Step name="password"><PasswordStep /></Funnel.Step> </Funnel> ``` 기존의 switch문과 숫자 인덱스를 사용한 명령형 방식과 달리, 폼의 전체 구조를 한눈에 파악할 수 있습니다. 새로운 스텝을 추가할 때도 단순히 `<Funnel.Step>` 태그 하나만 추가하면 되기 때문에 확장성도 뛰어납니다. --- ### type-safe 스텝 네비게이션 `['email', 'role', 'password'] as const`처럼 타입을 정확하게 지정함으로써 컴파일 타임에 오타나 잘못된 스텝명 사용을 방지할 수 있습니다. ```tsx const [Funnel, funnelState, updateFunnelState] = useFunnel( ['email', 'role', 'password'] as const // 리터럴 유니온으로 추론 ); // IDE에서 자동완성 지원 updateFunnelState({ email: 'test@test.com', currentStep: 'role' }); // 컴파일 에러 - 존재하지 않는 스텝명 updateFunnelState({ email: 'test@test.com', currentStep: 'invalid' }); ``` as const를 사용하면 타입이 단순한 string[]이 아닌 정확한 리터럴 유니온 타입으로 추론됩니다. 이로 인해 `updateFunnelState({ currentStep: 'invalid-step' })`처럼 존재하지 않는 스텝명을 사용하면 IDE에서 바로 에러가 표시됩니다. 스텝명 오타로 인한 런타임 오류를 완전히 방지할 수 있습니다. --- ### URL과 상태 동기화 nuqs 라이브러리를 활용해 URL 쿼리 파라미터와 React 상태를 동기화합니다. 사용자가 2단계에 있으면 URL도 `/register?funnel-step=role`처럼 실제 상태를 보여줍니다. 이로 인해 브라우저의 뒤로가기/앞으로가기 버튼이 자연스럽게 작동하고, 페이지를 새로고침해도 현재 스텝이 그대로 유지됩니다. 다음으로는 어떻게 구현하는지 살펴보겠습니다. --- ### URL 기반 스텝 관리- useRouteFunnel ```tsx export function useRouteFunnel<T extends FunnelSteps>( steps: T, options: RouteFunnelOptions<T> = {} ): [FunnelComponent, (step: T[number], navOptions?: NavigateOptions) => void] { const { initialStep, stepQueryKey = DEFAULT_STEP_QUERY_KEY, onStepChange, history: defaultHistory } = options; const defaultStep = initialStep ?? steps[0]; const [currentStep, setCurrentStep] = useQueryState( stepQueryKey, parseAsString.withDefault(defaultStep) ); const navigateToStep = (step: T[number], navOptions?: NavigateOptions) => { const { history = defaultHistory || DEFAULT_HISTORY } = navOptions || {}; setCurrentStep(step, { history }); onStepChange?.(step); }; const FunnelContainer = useMemo(() => { const Container = ({ children }: FunnelProps) => { return React.createElement(React.Fragment, null, children); }; const Step = ({ name, children }: FunnelStepProps) => { return currentStep === name ? React.createElement(React.Fragment, null, children) : null; }; (Container as FunnelComponent).Step = Step; return Container as FunnelComponent; }, [currentStep]); return [FunnelContainer, navigateToStep]; } ``` **URL과 상태 동기화** useQueryState를 사용해서 URL 쿼리 파라미터를 React 상태처럼 사용하는 것입니다. URL의 ?funnel-step=email 부분이 React 상태와 동기화됩니다. 사용자가 브라우저의 뒤로가기를 누르면 URL이 변하고, 그에 따라 currentStep 상태도 자동으로 업데이트됩니다. **History 방식 제어** `navigateToStep` 함수에서 history 옵션으로 브라우저 히스토리를 세밀하게 제어할 수 있습니다. 이는 nuqs 라이브러리가 내부적으로 제공하는 기능으로, 브라우저의 History API를 직접 조작합니다. push를 사용하면 `history.pushState()`가 호출되어 새로운 히스토리 엔트리가 브라우저 히스토리 스택에 추가됩니다. 이렇게 하면 사용자가 뒤로가기 버튼을 눌렀을 때 이전 스텝으로 돌아갈 수 있습니다 반면 replace 모드를 사용하면 `history.replaceState()`가 호출되어 현재 히스토리 엔트리를 덮어씁니다. 이 경우 사용자가 뒤로가기를 누르면 폼에 진입하기 전 페이지로 바로 이동합니다. **동적 컴포넌트 생성** ```tsx const Container = ({ children }) => { return React.createElement(React.Fragment, null, children); }; ``` 이 코드는 `<>{children}</>`과 동일합니다. 모든 자식 요소들을 그대로 렌더링하는 래퍼 역할입니다. ```tsx const Step = ({ name, children }) => { return currentStep === name ? React.createElement(React.Fragment, null, children) : null; }; ``` Step 컴포넌트는 자신의 name prop이 현재 활성화된 currentStep과 일치할 때만 `children`을 렌더링하고, 그렇지 않으면 null을 반환합니다. 즉, 현재 스텝이 아닌 모든 스텝들은 DOM에서 완전히 제거됩니다. ```tsx (Container as FunnelComponent).Step = Step; return Container as FunnelComponent; ``` 여기서 JavaScript의 함수 객체 특성을 활용합니다. 함수도 객체이므로 프로퍼티를 추가할 수 있습니다. Container 함수에 Step 프로퍼티를 붙여서 `Container.Step`으로 접근할 수 있게 만듭니다. 그래서 아래와 같이 사용가능합니다. ```tsx <Funnel> <Funnel.Step name="email">...</Funnel.Step> <Funnel.Step name="role">...</Funnel.Step> </Funnel> ``` --- ### 세션 스토리지 상태관리 - withState ```tsx export const withState = <TSteps extends FunnelSteps>( [Funnel, navigateToStep]: ReturnType<typeof useRouteFunnel<TSteps>> ) => <TState extends Record<string, unknown>>( defaultValue: Partial<TState> ) => { const [state, setState, clearStorage] = useSessionStorage( storageKey, defaultValue ); // 상태 업데이트와 네비게이션을 처리 const updateState = useCallback((action) => { setState((prevState) => { const newState = { ...prevState, ...action }; if (newState.currentStep) { navigateToStep(newState.currentStep); // URL도 함께 업데이트 } return newState; }); }, [setState, navigateToStep]); return [Funnel, state, updateState, clearState] as const; }; ``` withState는 기본적인 URL 스텝 관리에 세션 스토리지 기반 데이터 지속성 기능을 추가해주는함수입니다. URL만 관리하던 useRouteFunnel을 받아서 데이터 저장/복원 기능을 하게됩니다. 다음과 같은 3단계 과정을 통해 기능을 확장합니다: 1. useRouterFunnel의 반환값 `[Funnel, navigateToStep]`을 받아와서 2. 세션 스토리지 기반 상태 관리 기능을 더하고 3. `[Funnel, state, updateState, clearState]`로 반환 --- ### 통합 인터페이스 - useFunnel ```tsx export function useFunnel<T extends FunnelSteps>( steps: T, options: RouteFunnelOptions<T> = {} ) { const [Funnel, navigateToStep] = useRouteFunnel<T>(steps, options); const withState = _withState<T>([Funnel, navigateToStep]); return Object.assign([Funnel, navigateToStep], { withState }); } ``` useFunnel은 복잡한 내부 로직들을 감추고 간단한 인터페이스만 노출하는 추상화 레이어 역할을 합니다. 개발자는 URL 동기화나 세션 스토리지 처리 같은 세부 구현을 몰라도 되고, 필요한 기능만 체이닝으로 선택해서 사용할 수 있습니다. **기본 사용법 (URL 관리만)** ```tsx const [Funnel, navigateToStep] = useFunnel(['email', 'role', 'password']); ``` **확장 사용법 (세션 스토리지 사용)** ```tsx const [Funnel, state, updateState, clearState] = useFunnel( ['email', 'role', 'password'] ).withState({ email: '', role: '' }); ``` --- ### 마무리 지금까지 useState와 switch문으로 구현된 명령형 Multi-Step Form의 한계점을 짚어보고, 해결하기 위한 구현 방식을 살펴보았습니다. - useRouteFunnel: URL 쿼리 파라미터(nuqs 활용)를 통해 스텝 상태를 관리하여, 브라우저 히스토리와 완벽히 연동되고 새로고침에도 스텝을 유지할 수 있습니다. - withState: useRouteFunnel 위에 세션 스토리지를 얹어, 사용자가 입력한 데이터를 안전하게 저장하고 복원할 수 있는 지속성 계층을 제공합니다. - useFunnel: 이 모든 기능을 하나의 통합된 인터페이스로 묶어, 필요에 따라 유연하게 체이닝하며 확장할 수 있는 편리한 개발 경험을 제공합니다. 이 패턴을 사용하면 복잡한 조건 분기와 URL-UI 상태 불일치 문제를 해결할 수 있습니다. 선언적 구조와 TypeScript 타입 안전성 덕분에 코드 가독성이 높아지고, 새로운 기능 추가도 수월해집니다.2025년 03월 13일React에서 Polymorphic Component 만들기
React에서 UI 컴포넌트를 개발할 때, 특정 HTML 태그에 종속되지 않으면서도 재사용성과 확장성을 유지할 수 있는 컴포넌트를 자주 필요로 합니다. Button 컴포넌트를 만들었는데, 경우에 따라서 `<button>` 이나 `<a>` 로 렌더링하고 싶다면 어떻게 해야할까요? 이 글에서는 Polymorphic Component란 무엇인지, 그리고 TypeScript와 forwardRef를 활용해 구현하는 방법을 정리하려고 합니다. --- ## Polymorphic Component란? Polymorphism(다형성)은 하나의 요소가 여러 형태를 가질 수 있다는 의미입니다. 프로그래밍에서 다형성이란 같은 인터페이스나 메서드를 사용하지만, 다양한 자료형이나 동작을 가질 수 있도록 하는 개념을 뜻합니다. React 컴포넌트에서 Polymorphism을 적용한다는 건, 하나의 컴포넌트가 상황에 따라 다양한 HTML 태그나 다른 React 컴포넌트로 유연하게 렌더링될 수 있다는 뜻입니다. 예를 들어, Button 컴포넌트를 Polymorphic Component로 만들면 `<button>`, `<a>`, `<div>` 등 필요에 따라 원하는 태그로 쉽게 바꿀 수 있습니다. 단순히 태그가 바뀌는 것뿐 아니라, 각 태그에 맞는 속성도 자동으로 안전하게 처리되기 때문에, 코드의 재사용성과 유연성이 크게 향상됩니다. 이런 구조 덕분에 다양한 UI 요구사항을 하나의 컴포넌트로 깔끔하게 해결할 수 있습니다. 아래는 일반적인 Button 컴포넌트입니다. ```tsx const Button = ({ children, onClick }) => { return <button onClick={onClick}>{children}</button>; }; <Button onClick={() => alert("Clicked!")}>Click Me</Button>; ``` 기존 Button 컴포넌트는 기본적으로 `<button>` 태그로 렌더링되기 때문에 페이지 이동을 위한 링크(`<a>` 태그)로는 사용할 수 없습니다. 만약 페이지 이동 기능이 필요한 버튼을 만들고 싶다면, 새로운 LinkButton 컴포넌트를 추가로 만들어야 합니다. 문제는 이렇게 하면 버튼 역할을 하는 컴포넌트가 계속 늘어날 수 있다는 점입니다. 페이지 이동을 위한 LinkButton, 제출을 위한 SubmitButton 등 상황에 맞춰 컴포넌트를 계속 추가하게 됩니다. 결국 버튼 컴포넌트가 많아질수록 관리하기도 복잡해집니다. 이런 문제를 해결하기 위해 Polymorphic 컴포넌트를 사용하는 게 좋습니다. Polymorphic Component는 컴포넌트가 지정된 태그 (as prop)로 동적으로 렌더링될 수 있으며, 그에 따른 속성도 자동으로 타입이 지정되는 React 컴포넌트입니다. 하나의 Button 컴포넌트에서 as="a" 속성을 전달하면 링크로 동작할 수 있고, as="button"으로 전달하면 기본 버튼으로 동작할 수 있습니다. 결과적으로 하나의 컴포넌트로 다양한 버튼을 처리할 수 있고, 스타일이나 동작도 한 곳에서 관리할 수 있어 코드가 훨씬 깔끔해집니다. ```tsx <Button as="a" href="/home">홈 이동</Button> <Button as="button" onClick={handleClick}>Click Me</Button> ``` 이 방식은 MUI, Mantine, Radix 등 여러 UI 라이브러리에서 사용되는 패턴으로 재사용하기 쉽고, 유연한 컴포넌트를 설계할 수 있습니다. --- ## 버튼 컴포넌트 만들기 Polymorphic Component를 적용할 Button 컴포넌트를 만들기 전, 요구사항을 정리해보겠습니다. 1. as prop을 통해 다양한 태그(button, a, div)로 렌더링할 수 있어야 한다. 2. ref를 지원하여 외부에서 DOM 요소에 직접 접근할 수 있어야 한다. 3. as prop으로 지정된 태그에 맞는 속성을 자동으로 지원해야 한다. 4. TypeScript를 활용해 올바른 타입을 추론할 수 있어야 한다. 이제 이러한 요구사항을 만족하는 Button 컴포넌트를 만들어보겠습니다. ## 기본적인 Polymorphic Component 만들기 가장 기본적인 형태로 as props로 받아 동적으로 태그를 변경하는 컴포넌트를 만들어보겠습니다. ```tsx import React from "react"; type ButtonProps<T extends React.ElementType> = { as?: T; children: React.ReactNode; } & React.ComponentPropsWithoutRef<T>; function Button<T extends React.ElementType = "button">({ as, ...props }: ButtonProps<T>) { const Element = as || "button"; return <Element {...props} />; } ``` 위의 코드는 as props로 통해 원하는 태그를 설정할 수 있습니다. 하지만 React에서 ref는 props로 직접 전달할 수 없기 때문에 forwardRef를 적용해야 합니다. React에서는 ref를 특별한 props로 취급하여 일반적인 컴포넌트에서는 전달되지 않으며, 이를 해결하기 위해 forwardRef를 사용하면 부모 컴포넌트에서 ref를 전달하여 하위 요소에 접근할 수 있습니다. --- ## forwardRef를 적용 ref를 사용할 수 있도록 forwardRef를 적용해보겠습니다. ```tsx const Button = forwardRef(function Button< T extends React.ElementType = "button", >( { as, ...props }: ButtonProps<T>, ref: ComponentPropsWithRef<T>["ref"] ) { const Element = as || "button"; return <Element {...props} />; }); ``` 그러나 이렇게하면 문제가 발생합니다. ```tsx const linkRef = React.useRef<HTMLAnchorElement>(null); <Button as="a" ref={linkRef} href="/home">Go Home</Button>; ``` 위 코드에서 Button에 as="a" 사용했지만, button의 ref타입 HTMLButtonElement로 인식되어 타입 에러가 발생합니다. 왜 그럴까요?? --- ### forwardRef 반환 타입 ```tsx function forwardRef<T, P = {}>( render: ForwardRefRenderFunction<T, P> ): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>; interface ForwardRefExoticComponent<P> extends NamedExoticComponent<P> { defaultProps?: Partial<P> | undefined; propTypes?: WeakValidationMap<P> | undefined; } interface NamedExoticComponent<P = {}> extends ExoticComponent<P> { displayName?: string | undefined; } interface ExoticComponent<P = {}> { (props: P): ReactNode; readonly $$typeof: symbol; } ``` React의 forwardRef자체가 반환하는 타입이 ForwardRefExoticComponent이고, forwardRef 의 최종 반환 타입은 React 컴포넌트 함수형 타입 (props: P) => ReactNode입니다. Button 컴포넌트 자체에 제네릭을 적용하지 않으면 forwardRef 내부에서만 제네릭이 작동하고, 반환 타입이 제대로 추론되지 않습니다. > forwardRef의 반환 타입 `ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>` 여기서 제네릭 T는 `RefAttributes<T>` 내부에서만 사용되기 때문에, 컴포넌트를 사용할 때 제네릭 T를 직접 지정할 수 없습니다. 따라서 버튼 컴포넌트 타입을 아래와 같이 작성해 볼 수 있습니다. ```tsx type ButtonComponent = <T extends React.ElementType = "button">( props: ButtonProps<T> & { ref?: React.ComponentPropsWithRef<T>["ref"]; } ) => React.ReactNode; const Button: ButtonComponent = forwardRef(function Button< T extends React.ElementType = "button", >( { as, ...props }: ButtonProps<T>, ref: React.ComponentPropsWithRef<T>["ref"] ) { const Element = as || "button"; return <Element {...props} />; }); ``` 제네릭을 사용할 수 있는 함수형 컴포넌트 타입 ButtonComponent를 먼저 정의합니다. 이 타입은 `<T>` 형태의 제네릭을 사용해 `(props) => ReactNode` 형태로 구성되어, props 안에 있는 as="a" 값을 기반으로 T = "a"로 자동 추론됩니다. --- ### 타입 재사용 가능하도록 만들기 ```tsx import type { ComponentPropsWithoutRef, ComponentPropsWithRef, ElementType } from 'react'; export interface AsProp<T extends ElementType> { as?: T; } export type PolymorphicRef<T extends ElementType> = ComponentPropsWithRef<T>['ref']; export type PolymorphicComponentPropsWithoutRef<T extends ElementType, Props = {}> = AsProp<T> & Props & Omit<ComponentPropsWithoutRef<T>, keyof Props>; export type PolymorphicComponentPropsWithRef< T extends ElementType, Props = {}, > = PolymorphicComponentPropsWithoutRef<T, Props> & { ref?: PolymorphicRef<T>; }; ``` - PolymorphicRef - T에 따라 ref의 타입이 자동으로 변경되도록 만듭니다. - 예를 들어, T가 "button"이면 ref 타입은 HTMLButtonElement, T가 "a"이면 ref 타입은 HTMLAnchorElement가 됩니다. - PolymorphicComponentPropsWithoutRef - ref를 제외한 모든 props 타입을 구성 ## 최종 Button 컴포넌트 ```tsx type PolymorphicButtonProps<C extends ElementType> = PolymorphicComponentPropsWithRef< C, ButtonProps >; type ButtonComponent = <C extends ElementType = 'button'>( props: PolymorphicButtonProps<C>, ) => ReactNode; const Button: ButtonComponent & { displayName?: string } = forwardRef( <C extends ElementType = 'button'>( { as, children, subText, disabled = false, size = 'lg', variant = 'solid', icon, ...props }: PolymorphicButtonProps<C>, ref?: PolymorphicRef<C>, ) => { const Component = as || 'button'; return ( <Component aria-disabled={disabled} disabled={disabled} ref={ref} className={ButtonStyle({ size: variant === 'sub' ? 'sm' : size, variant, })} {...props} > <styled.span className={ContentStyle({ size })}> {icon} {children} </styled.span> {subText && <styled.span textStyle="label3">{subText}</styled.span>} </Component> ); }, ); ``` Polymorphic Component는 UI 컴포넌트를 더 유연하고 재사용 가능하게 만들어줍니다. 버튼이나 링크처럼 다양한 태그로 렌더링할 수 있고, 각 태그에 맞는 속성을 안전하게 사용할 수 있어서 디자인 시스템이나 컴포넌트 라이브러리에서 자주 쓰입니다. 이번에는 TypeScript로 Polymorphic Component를 적용하면서, forwardRef와 제네릭을 어떻게 사용할 수 있는지 정리해봤습니다. 제네릭을 사용해 다양한 태그에 맞는 속성을 안전하게 처리할 수 있었고, 컴포넌트를 더 유연하게 관리할 수 있었습니다.2025년 02월 20일컴파운드 패턴으로 Dropdown Component 만들기
드롭다운 컴포넌트를 처음 구현했을 때 하나의 컴포넌트에서 모든 기능을 처리하도록 개발하였습니다. 하지만 다중 선택, 키보드 제어 등 다양한 기능이 추가되었을 때, 하나의 컴포넌트에 모든 로직이 몰리게 되었고, 코드가 복잡해져서 유지보수가 어려워졌습니다. 이 글에서는 컴파운드 패턴(Compound Component Pattern)을 적용하여 Dropdown을 설계하고, 주요 로직을 Hooks로 분리함으로써 확장성과 재사용성을 높이는 방법을 알아보겠습니다. --- ## 기존 Dropdown 컴포넌트의 문제점 아래의 Dropdown 컴포넌트는 버튼을 누르면 리스트가 나오고, 옵션을 선택할 수 있는 기본적인 컴포넌트입니다. ```tsx const Dropdown = ({ options, selectedValue, onChange }) => { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen((prev) => !prev)}> {selectedValue || "선택하세요."} </button> {isOpen && ( <ul> {options.map((option) => ( <li key={option} onClick={() => onChange(option)}> {option} </li> ))} </ul> )} </div> ); }; ``` 이 컴포넌트는 간단하지만, 새로운 요구사항이 추가될수록 코드가 점점 복잡해지는 문제가 발생합니다. ### 다중 선택 기능 추가 새로운 요구사항으로 다중 선택 기능이 필요해졌습니다. 이제 여러 개의 값을 선택하고 해제할 수 있도록 구현해야 합니다. ```tsx const Dropdown = ({ options, selectedValues, onChange, multiple = false }) => { const [isOpen, setIsOpen] = useState(false); const handleSelect = (option) => { if (multiple) { if (selectedValues.includes(option)) { onChange(selectedValues.filter((item) => item !== option)); } else { onChange([...selectedValues, option]); } } else { onChange(option); } }; return ( <div> <button onClick={() => setIsOpen((prev) => !prev)}> {multiple ? selectedValues.length > 0 ? selectedValues.join(", ") : "선택하세요." : selectedValues || "선택하세요."} </button> {isOpen && ( <ul> {options.map((option) => ( <li key={option} onClick={() => handleSelect(option)}> {option} {multiple && selectedValues.includes(option) ? "✔" : ""} </li> ))} </ul> )} </div> ); }; ``` 문제점 - props가 계속 추가됨 - 검색, 그룹화, 비활성화 옵션 등 새로운 기능이 추가될 때마다 props가 늘어나고, 어떤 prop이 어떤 역할을 하는지 파악하기 어려워집니다. - 로직의 복잡성 증가 - 단일 선택과 다중 선택을 동시에 지원하기 위해 조건 분기가 많아지고, 코드 가독성이 떨어집니다. - 유지보수가 어려워짐 - 새로운 요구사항이 생길 때마다 기존 컴포넌트를 직접 수정해야 하며, 이는 기존 로직에 영향을 줄 가능성이 큽니다. 이러한 문제를 해결하기 위해 컴파운드 패턴(Compound Pattern)을 적용하면, Dropdown을 작은 컴포넌트로 분리하여 보다 유연하고 확장성 있는 설계를 할 수 있고, 각 컴포넌트가 단일 책임 원칙을 준수한다면 새로운 요구사항을 더 쉽게 반영할 수 있습니다. --- ## 컴파운드 패턴이란? 컴파운드 패턴은 하나의 완결된 UI 컴포넌트를 여러 개의 작은 컴포넌트로 분리하여, 각 컴포넌트가 단일 책임을 갖도록 하는 디자인 패턴입니다. 메인 컴포넌트에서 활용하는 하위 컴포넌트들은 기능 단위로만 나누어지지 않으며 상황에 따라 액션, UI 등 다른 기준으로 나뉘어 조합될 수 있습니다. 재사용성을 극대화하기 위해 하위 컴포넌트는 단일 책임 원칙(SRP)에 따라 하나의 책임 · 역할만 맡아야합니다. #### UI 컴포넌트의 역할 분리 하나의 컴포넌트가 UI 렌더링, 사용자 인터랙션 처리, 상태 관리 등 모든 기능을 동시에 담당하도록 설계하면 코드가 복잡해지고 유지보수가 어려워집니다. 컴파운드 패턴에서는 각 기능을 독립된 컴포넌트로 분리하여, 각각의 컴포넌트가 단일 책임만 수행하도록 합니다. Dropdown 컴포넌트를 컴파운드 패턴으로 설계할 경우 다음과 같이 역할을 분리할 수 있습니다. Dropdown: 전체 상태를 관리하며, 하위 컴포넌트들에게 상태와 로직을 Context로 전달합니다. Dropdown.Trigger: 드롭다운을 열고 닫는 버튼 역할을 담당하며, 사용자의 클릭 이벤트를 처리합니다. Dropdown.OptionList: 옵션 목록을 렌더링하며, 사용자가 선택할 수 있는 항목들을 표시합니다. Dropdown.Option: 개별 옵션을 나타내며, 선택되었을 때의 동작을 정의합니다. 이렇게 역할을 분리함으로써, 각 컴포넌트는 자신의 역할에만 집중할 수 있으며, 다른 컴포넌트에 의존하지 않고 독립적으로 동작할 수 있습니다. #### 상황에 따른 조합 컴파운드 패턴에서는 컴포넌트가 단순히 기능 단위로만 나누어지지 않으며, 상황에 따라 유연하게 조합될 수 있습니다. 이는 사용자 상호작용 방식이나 UI 구성에 따라 컴포넌트들이 서로 다른 방식으로 동작할 수 있음을 의미합니다. Dropdown은 다음과 같은 다양한 상황에 맞춰 동작할 수 있습니다 기본적으로 클릭을 통해 열리고 닫힐 수 있어야 합니다. 키보드로 탐색할 수 있어야 하며(위/아래 화살표, Enter, Esc). 옵션 목록은 동적으로 렌더링될 수 있으며, 검색 기능이나 그룹화도 지원할 수 있습니다. 이러한 상황별 동작은 각 컴포넌트가 독립적으로 설계되어 있기 때문에, 새로운 기능을 추가할 때 전체 구조를 변경할 필요 없이 필요한 컴포넌트만 수정하거나 추가할 수 있습니다. #### 유연성 컴파운드 패턴은 새로운 기능을 추가하거나 기존 기능을 변경할 때도 유연하게 확장할 수 있는 구조를 제공합니다. Dropdown 컴포넌트에 다중 선택 기능을 추가하고 싶다면 다음과 같은 방법으로 확장할 수 있습니다: Dropdown.Option: 단일 선택 로직을 다중 선택으로 변경하거나, 새로운 멀티 셀렉트 모드를 추가할 수 있습니다. Dropdown: 선택된 옵션을 배열 형태로 관리하여 다중 선택을 지원할 수 있습니다. Dropdown.OptionList: 다중 선택일 경우 선택된 옵션을 시각적으로 표시할 수 있도록 스타일을 변경할 수 있습니다. ### 드롭다운 컴포넌트 요구사항 정리 이제 직접 컴포넌트를 만들어 봅시다. Dropdown이 가져야 할 기능은 무엇이 있을까요? - 드롭다운 버튼 클릭 시 옵션 목록 표시 - 옵션 선택 시 선택된 값이 버튼에 반영 - 단일 선택과 다중 선택 모두 지원 - 키보드(Enter, ArrowUp, ArrowDown, Escape)로 탐색 가능 - 옵션 목록이 버튼 아래 올바른 위치에 표시 - 부모 요소의 레이아웃 제약(overflow 등)과 관계없이 원하는 위치에 표시 - 옵션 외부 클릭 시 드롭다운이 닫힘 - 단일 선택과 다중 선택 모두 지원 ### 역할별 컴포넌트 분리 컴파운드 패턴을 적용하여 Dropdown 컴포넌트를 다음과 같이 분리합니다. - Dropdown (상태 관리 및 Context 제공) - Dropdown.Trigger (드롭다운을 열고 닫는 버튼) - Dropdown.Bar (선택된 값을 표시) - Dropdown.OptionList (옵션 목록을 감싸는 컴포넌트) - Dropdown.Option (개별 선택 가능한 항목) - Dropdown.ListSubHeader (옵션 그룹 구분을 위한 서브헤더) ### 이벤트 처리 기능이 많아질수록 로직이 컴포넌트 내에 많아지게 되는데, 마우스, 키보드, 위치 계산은 각기 다른 커스텀 Hook으로 분리하였습니다 useClick - 외부 클릭 감지, 옵션 선택, 트리거 토글 등 마우스 이벤트 처리 useKeyDown - 키보드 탐색 및 단축키 지원 (↑↓ Enter Escape 등) useOptionListPosition - 옵션 목록이 트리거 아래 정확히 위치되도록 계산 ## 드롭다운 컴포넌트의 설계 - **단일 책임 원칙 적용**: 각 요소는 명확한 역할만 수행 - **유지보수 용이성**: 기능 추가나 수정 시 해당 부분만 영향 - **재사용성 증가**: Hook, Option, Trigger 등 유사 UI에 반복 사용 가능 - **접근성 고려**: 키보드 탐색, role/aria 속성 분리 가능 - **기능 확장성**: 옵션 검색, 가상화 리스트, 애니메이션 등 기능 확장 시에도 구조 변경 최소화 **Dropdown** - 전체 Dropdown의 상태(열림/닫힘, 선택 등)를 관리하고, Context를 통해 하위 컴포넌트에 상태와 로직을 전달합니다. - 제어(props를 통한 외부 제어)와 비제어(내부 상태 관리) 모두 지원합니다. ```tsx export const Dropdown = <T extends string = DefaultDropdownValue>({ children, onChange, disabled = false, multiple = false, isOpen, // 외부에서 드롭다운 상태 제어 onClose, // 닫힘 콜백 }: DropdownProps<T>) => { const triggerRef = useRef<HTMLElement>(null); const optionListRef = useRef<HTMLUListElement>(null); const [container, setContainer] = useState<DropdownTypes['container']>(null); const isControlled = typeof isOpen === 'boolean'; // 외부에서 제어되는지 확인 const expanded = isControlled ? isOpen : container !== null; // 내부 상태와 props 결합 const uniqueId = useMemo(() => generateUniqueId(), []); const open = () => { if (isControlled) return; const newContainer = document.createElement('div'); newContainer.id = uniqueId; document.body.appendChild(newContainer); setContainer(newContainer); }; const close = () => { if (isControlled) { onClose?.(); return; } const container = document.getElementById(uniqueId); container?.remove(); setContainer(null); }; const toggle = () => { expanded ? close() : open(); }; return ( <DropdownContext.Provider value={{ container, triggerRef, optionListRef, expanded, disabled, close, onChange }} > {children} </DropdownContext.Provider> ); }; ``` **Dropdown.Trigger** - 원하는 버튼(또는 트리거 요소)을 그대로 받아, 드롭다운의 상태(열림, 비활성화, ref)를 추가하여 전달합니다. - 단순히 버튼을 "전달"하는 추상화된 컴포넌트로, React.cloneElemnt를 사용하여 추가 속성을 주입합니다. ```tsx Dropdown.Trigger = ({ children }: { children: React.ReactElement }) => { const { triggerRef, expanded, disabled } = useContext(DropdownContext); return React.cloneElement(children, { ...children.props, 'data-expanded': expanded, disabled, ref: triggerRef }); }; ``` **Dropdown.Bar** - 선택된 값을 보여주는 컴포넌트입니다. 선택된 값이 없을 경우 placeholder를 보여줍니다. - 옵션 목록은 Portal을 사용해 동적으로 생성된 컨테이너에 렌더링되어, 부모 요소의 레이아웃 제약을 피합니다. ```tsx Dropdown.Bar = React.forwardRef( ( { value, placeholder, disabled = false, suffixForId, ...props }: DropdownBarProps, ref: ForwardedRef<HTMLDivElement>, ) => { const uniqueId = useMemo(() => suffixForId ?? generateUniqueId(), [suffixForId]); return ( <Bar ref={ref} role="button" tabIndex={disabled ? -1 : 0} aria-disabled={disabled} {...props} > <span aria-placeholder={value ? undefined : placeholder}>{value || placeholder}</span> <ChevronDownIcon size={12} className="arrowIcon" suffixForId={`dropdown-arrow-icon__${uniqueId}`} /> </Bar> ); }, ); ``` **Dropdown.OptionList** - 옵션목록을 렌더링하는 컴포넌트입니다. - 옵션목록이 dropdown 내부가 아니라, container에 추가되도록 합니다. ```tsx Dropdown.OptionList = ({ children, zIndex = overHeaderZIndex, ...props }: OptionListProps) => { const { container, optionListRef } = useContext(DropdownContext); return ( <Portal container={container}> <OptionList role="listbox" ref={optionListRef} style={{ zIndex }} {...props}> {children} </OptionList> </Portal> ); }; ``` **Dropdown.Option** - 옵션목록에서 개별 옵션을 나타내는 컴포넌트입니다. ```tsx Dropdown.Option = ({ value, label, selected = false, disabled = false, ...props }: OptionProps) => { return ( <Option role="option" data-value={value} key={value} aria-disabled={disabled} aria-selected={selected} tabIndex={selected ? 0 : -1} {...props} > {label ?? value} </Option> ); }; ``` ### 사용 예시 ```tsx const Example = () => { const [selected, setSelected] = useState(null); return ( <Dropdown onChange={setSelected}> <Dropdown.Trigger> <button>{selected || "옵션 선택"}</button> </Dropdown.Trigger> <Dropdown.OptionList> <Dropdown.Option value="apple" label="사과" /> <Dropdown.Option value="banana" label="바나나" /> <Dropdown.Option value="grape" label="포도" /> </Dropdown.OptionList> </Dropdown> ); }; ``` --- ## Custom Hook을 통한 이벤트 처리 Dropdown 컴포넌트를 작성할 때 가장 복잡해지는 부분은 마우스 이벤트, 키보드 이벤트, 그리고 옵션 목록의 위치 계산입니다. 만약 이러한 로직을 모두 Dropdown내부에 작성하면, 코드 길이가 너무 길어지고, 이벤트 간 로직이 서로 꼬여 유지보수가 어려워집니다. 따라서 컴포넌트 로직을 분리하기 위해 커스텀 Hooks를 만듭니다. 각 Hook은 하나의 역할만 담당하도록 설계하여, 코드 가독성과 재사용성을 높입니다. **useClick 마우스 이벤트 처리** - 드롭다운 외부를 클릭하면 닫혀야 하고, 트리거(버튼)를 클릭하면 열리고 닫히는 토글 역할을 해야 합니다. - 옵션 목록을 클릭하면 해당 옵션이 선택되어야 하고, 다중 선택 여부에 따라 로직이 달라집니다. - 이 로직이 없으면, 마우스 이벤트가 발생할 때마다 Dropdown 컴포넌트 내부에서 여러 조건문으로 처리해야 하므로 코드가 복잡해집니다. 기능 1. **외부 클릭으로 닫기** - 사용자가 드롭다운 외부를 클릭하면, 드롭다운을 닫아야 합니다. 2. **버튼(트리거) 클릭 시 열기/닫기** - 트리거를 클릭하면 **드롭다운이 열림** → 다시 누르면 **닫힘** 3. **옵션 클릭 시 선택** - 옵션 목록을 클릭하면 **onChange**를 호출해 **값을 선택**하고, - 다중 선택이 아닐 경우 자동으로 닫음 **핵심 로직** - `document.addEventListener('click', handleClick)`로 마우스 클릭을 전역에서 감지 - triggerRef와 optionListRef를 사용하여 트리거와 옵션 목록 내부를 구분 - disabled 상태인 경우는 아무 동작도 하지 않음 ```tsx export const useClick = <T extends string>({ triggerRef, optionListRef, expanded, close, toggle, onChange, disabled, multiple, }: DropdownEventParams<T>) => { useEffect(() => { function handleClick(e: MouseEvent) { // 드롭다운이 비활성화( disabled = true )이면 아무 동작도 하지 않음 if (disabled) return; // 마우스로 클릭한 요소(target) const target = e.target as HTMLElement; // 트리거 영역과 옵션 목록 영역을 구분하기 위한 ref const triggerElement = triggerRef?.current; const optionListElement = optionListRef?.current; const isTriggerClick = triggerElement && triggerElement.contains(target); const isOptionClick = optionListElement && optionListElement.contains(target); // (트리거도 아니고, 옵션 영역도 아니면) → 드롭다운 닫기 if (!isTriggerClick && !isOptionClick) { close(); // 이미 열려있으면 닫고, 닫혀있으면 열기 else if (isTriggerClick) { toggle(); } // 3) 옵션 목록을 클릭한 경우 else if (isOptionClick) { const clickedOption = (Array.from(optionListElement.children) as HTMLLIElement[]) .filter((el) => el.ariaDisabled !== 'true') // 비활성화된 옵션은 제외 .find((el) => el.contains(target)); // 실제 클릭한 옵션 찾기 if (clickedOption) { // data-value에 옵션 값이 들어있음 const value = clickedOption.dataset.value as T; if (!value) return; // 옵션 선택 로직 onChange(e, value); // multiple이 false면(단일 선택) 옵션 클릭 후 드롭다운 닫기 if (!multiple) { close(); } } } } document.addEventListener('click', handleClick); return () => { document.removeEventListener('click', handleClick); }; }); }; ``` **useKeyDown (키보드 이벤트 처리)** - 드롭다운은 마우스뿐만 아니라 **키보드로도 열고 닫고, 옵션을 탐색하고 선택**할 수 있어야 접근성이 높습니다. 기능 1. **트리거에 포커스된 상태에서 `Enter`나 `Space`** → 드롭다운 열기/닫기 2. **옵션 리스트에서 `Enter`나 `Space`** → 해당 옵션 선택 3. **`ArrowUp`, `ArrowDown`** → 옵션 목록 내에서 **포커스 이동** 4. **`Escape` or `Tab`** → 드롭다운 닫고 트리거로 포커스 이동 **핵심 로직** - `document.addEventListener('keydown', handleKeyDown)`로 **키보드 이벤트 전역 감지** - 포커스가 트리거에 있는지, 옵션 목록에 있는지에 따라 다른 로직 처리 - 옵션을 선택하면 onChange를 호출하고, 필요한 경우 드롭다운을 닫고 트리거에 포커스를 다시 줌 ```tsx export const useOptionListPosition = ({ container, triggerRef, optionListRef, }: Pick<DropdownTypes, 'container' | 'triggerRef' | 'optionListRef'>) => { const changeOptionListPosition = useCallback(() => { const triggerElement = triggerRef?.current; const optionListElement = optionListRef?.current; if (triggerElement && optionListElement) { const rect = triggerElement.getBoundingClientRect(); const scrollX = window.scrollX; const scrollY = window.scrollY; const bodyWidth = document.body.scrollWidth; // 옵션 목록 최소너비 = 트리거의 너비 optionListElement.style.minWidth = `${rect.width}px`; // Y 좌표는 트리거 아래로 약간의 간격 주기 (예: 8px) optionListElement.style.top = `${scrollY + rect.bottom + 8}px`; // X 좌표는 트리거의 left optionListElement.style.left = `${scrollX + rect.left}px`; // 화면 오른쪽 경계를 넘어간다면 위치 보정 const optionRect = optionListElement.getBoundingClientRect(); if (scrollX + optionRect.right > bodyWidth) { optionListElement.style.left = `${rect.right - optionListElement.offsetWidth}px`; } // 애니메이션 효과 (살짝 팝업되도록) optionListElement.style.transform = 'scale(1)'; optionListElement.style.opacity = '1'; } }, [triggerRef, optionListRef]); useEffect(() => { // container가 존재한다는 것은 드롭다운이 열렸다는 뜻 if (container) { changeOptionListPosition(); } }, [container, changeOptionListPosition]); // 창 크기가 바뀔 때마다 위치 재계산 useEffect(() => { window.addEventListener('resize', changeOptionListPosition); return () => { window.removeEventListener('resize', changeOptionListPosition); }; }, [changeOptionListPosition]); }; ``` **useOptionListPosition (옵션 목록 위치 계산)** - 옵션 목록은 보통 **버튼 아래**에 표시하지만, - 화면이 스크롤되거나 창이 리사이즈되면 위치가 달라질 수 있습니다. - 부모 요소가 `overflow: hidden` 등으로 감싸져 있어도, Portal로 `body`에 추가된 경우 버튼 위치를 기준으로 옵션 목록을 올바르게 표시해야 합니다. 기능 - 버튼 크기에 맞춰 옵션 목록의 너비 설정 - 버튼의 `bottom` 좌표에 약간의 간격을 두고 옵션 목록 배치 - 화면 오른쪽 경계를 넘어가면 위치를 자동으로 보정 - 창을 리사이즈하면 위치 다시 계산 ```tsx export const useOptionListPosition = ({ container, triggerRef, optionListRef, }: Pick<DropdownTypes, 'container' | 'triggerRef' | 'optionListRef'>) => { const changeOptionListPosition = useCallback(() => { const triggerElement = triggerRef?.current; const optionListElement = optionListRef?.current; if (triggerElement && optionListElement) { const rect = triggerElement.getBoundingClientRect(); const scrollX = window.scrollX; const scrollY = window.scrollY; const bodyWidth = document.body.scrollWidth; // 옵션 목록 최소너비 = 트리거의 너비 optionListElement.style.minWidth = `${rect.width}px`; // Y 좌표는 트리거 아래로 약간의 간격 주기 (예: 8px) optionListElement.style.top = `${scrollY + rect.bottom + 8}px`; // X 좌표는 트리거의 left optionListElement.style.left = `${scrollX + rect.left}px`; // 화면 오른쪽 경계를 넘어간다면 위치 보정 const optionRect = optionListElement.getBoundingClientRect(); if (scrollX + optionRect.right > bodyWidth) { optionListElement.style.left = `${rect.right - optionListElement.offsetWidth}px`; } // 애니메이션 효과 (예: 살짝 팝업되도록) optionListElement.style.transform = 'scale(1)'; optionListElement.style.opacity = '1'; } }, [triggerRef, optionListRef]); useEffect(() => { // container가 존재한다는 것은 드롭다운이 열렸다는 뜻 if (container) { changeOptionListPosition(); } }, [container, changeOptionListPosition]); // 창 크기가 바뀔 때마다 위치 재계산 useEffect(() => { window.addEventListener('resize', changeOptionListPosition); return () => { window.removeEventListener('resize', changeOptionListPosition); }; }, [changeOptionListPosition]); }; ``` 1. **역할 분리**: 마우스 이벤트, 키보드 이벤트, 위치 계산 로직을 각각 다른 Hook으로 분리하여 **코드가 깔끔해짐** 2. **재사용성**: 프로젝트 내에서 유사한 구조(예: 다른 컴포넌트에서도 리스트를 열고 닫는 로직)를 재사용 가능 3. **유지보수 용이**: 새로운 마우스/키보드 이벤트를 추가하거나, 위치 계산 방식을 바꾸고 싶을 때 **Hook만 수정**하면 됨 4. **가독성**: 각 Hook이 하나의 역할만 담당하므로, **어떤 로직을 어디서 수정해야 하는지 명확** 결과적으로, **컴포넌트는 UI와 상태 관리**에 집중하고, 이처럼 공통 로직(이벤트 처리, 위치 계산)은 **커스텀 Hooks**에 분리함으로써, **큰 규모의 프로젝트**에서도 Dropdown과 같은 복잡한 컴포넌트를 **효율적**으로 유지보수할 수 있습니다. --- ## 마무리 Dropdown을 하나의 컴포넌트에서 모든 기능을 처리하던 기존 방식은 기능이 늘어날수록 프로퍼티가 계속 추가되고, 로직이 복잡해져 유지보수와 확장에 어려움을 겪었습니다. 컴파운드 패턴을 적용해 Dropdown, Trigger, OptionList, Option, Bar 같은 작은 컴포넌트로 나누고, 공통 로직(마우스·키보드 이벤트 처리, 옵션 목록 위치 계산)을 커스텀 Hooks로 분리함으로써, 각 컴포넌트는 단일 책임을 가지면서도 필요할 때 유연하게 조합할 수 있게 되었습니다. 이러한 구조 덕분에 코드 가독성이 높아지고, 새로운 기능을 추가할 때도 컴포넌트 전체를 수정하지 않고 필요한 부분만 변경하거나 확장할 수 있어 유지보수가 훨씬 수월해집니다.2025년 01월 16일Fetch API 기반 SSE로 실시간 이벤트 스트림 구현하기
실시간으로 서버에서 클라이언트로 데이터를 전달하는 방법은 여러 가지가 있지만, SSE(Server-Sent Events)는 단방향(서버 → 클라이언트) 통신을 위해 설계된 방식입니다. 클라이언트가 서버와 연결을 유지하면, 서버는 언제든 데이터를 클라이언트로 직접 푸시할 수 있습니다. 예를 들어, 이메일 발송 결과를 사용자에게 실시간으로 알리고 싶을 때 SSE가 유용합니다. 서버가 이메일을 성공적으로 발송하거나 실패했을 때, 그 결과를 클라이언트에 즉시 전달할 수 있기 때문입니다. 사용자 입장에서는 화면에서 바로 성공 여부를 확인할 수 있고, 서버는 필요할 때만 데이터를 보내기 때문에 불필요한 요청이 발생하지 않습니다. 브라우저에서는 기본적으로 EventSource API를 통해 SSE를 사용할 수 있습니다. 하지만 EventSource API에는 커스텀 헤더(Authorization) 설정이 불가능하다는 제약이 있기때문에, Fetch API를 사용하여 Authorization 헤더를 설정할 수 있는 SSE(Client-Sent Events) 커스텀 클래스를 구현해보겠습니다. --- ## SSE 적용 이유 이메일 대량 발송은 보통 몇 초 이상 걸리는 비동기 프로세스로, 실제로는 서버가 모든 이메일을 보내는 데 상당한 시간이 소요될 수 있습니다. 예를 들어, 여러 명에게 이메일을 동시에 보내야 할 때, 일반적인 HTTP 요청-응답 패턴으로는 다음과 같은 문제가 생깁니다 1. 언제 발송이 실제로 끝났는지 알 수 없다 - 요청 직후 "발송 완료"라고 표시해도, 실제로는 서버가 아직 메일을 전송 중일 수도 있습니다. - 사용자는 진짜로 성공했는지, 어느 시점에 완료됐는지를 정확히 확인하기 어렵습니다. 2. polling - "이메일 발송이 얼마나 진행됐는지" 알기 위해서 5초, 10초 간격으로 계속 "몇 %나 보냈나요?" 하는 식으로 서버에 물어볼 수는 있지만, - 이 방식은 요청이 쌓이면서 서버 부하가 늘어나고, 구현도 번거로워집니다. 3. WebSocket을 이용해 양방향 실시간 통신을 구현할 수도 있습니다. - 하지만 WebSocket은 양방향 통신이 필요한 경우에 적합하며, 비교적 구현이 더 복잡해질 수 있습니다. - SSE는 단방향(서버 → 클라이언트) 요구사항에 더 잘 맞고, HTTP 기반이라 설정이 간단한 장점이 있어 이메일 발송 상태 표시나 간단한 알림 스트리밍에 적합합니다. SSE는 서버가 이벤트를 직접 푸시하기 때문에, 클라이언트는 별도의 요청 없이도 "진행 상황", "성공", "실패" 등 상태 변화를 실시간으로 전달받을 수 있기 때문에, 사용자 경험이 향상되고, 서버 부하도 최소화할 수 있습니다. ## EventSource API 이해 EventSource Web API를 사용하면 아래와 같이 몇 줄의 코드로 서버의 실시간 이벤트를 받을 수 있습니다. ```tsx const eventSource = new EventSource('https://api.example.com/email-progress'); eventSource.onmessage = (event) => { console.log('Server says:', event.data); }; eventSource.onerror = (error) => { console.error('SSE error:', error); }; ``` ### EventSource 한계 하지만 EventSource는 브라우저의 보안 정책과 CORS 규칙 등 때문에 요청 헤더를 직접 수정하거나 추가할 수 없습니다. 만약 서버에서 인증 토큰(예: JWT나 Bearer 토큰)이 포함된 요청을 요구한다면, new EventSource(url)만으로는 필요한 Authorization 헤더를 전달할 수 없습니다. --- ## Fetch API 기반의 Custom SSE 구현 EventSource의 한계를 극복하기 위해 Fetch API를 사용한 CustomSSE 클래스를 구현합니다. ### 요구사항 1. 커스텀 헤더 설정 - Fetch API를 사용해 요청을 보낼 때, Authorization 등 필요한 커스텀 헤더를 포함할 수 있습니다. 2. 실시간 스트림 데이터 처리 - 서버에서 전송된 텍스트 형식의 SSE 스트림을 읽어, 하나의 긴 텍스트 덩어리에서 여러 이벤트 단위로 분리하고 파싱해야 합니다. 3. EventSource와 유사한 인터페이스 제공 - 기존 EventSource에서 제공하는 `onopen`, `onmessage`, `onerror`와 같은 이벤트 핸들러를 지원해야 합니다. 4. 연결 상태 관리 (readyState) - 연결 상태를 나타내는 `readyState` 속성을 도입하여, 현재 연결이 "연결 중", "연결됨", "연결 종료" 상태 중 어느 상태에 있는지 쉽게 파악할 수 있어야 합니다. --- ## CustomSSE 구현하기 SSE(Server-Sent Events)로 서버에서 클라이언트로 데이터를 전달할 때, 서버는 텍스트 형식의 이벤트 스트림을 전송합니다. 이 스트림은 실시간으로 클라이언트에 전달되지만 JSON이나 객체 형식이 아닙니다. ### SSE 데이터의 구조 ``` event: message data: Hello, world! event: update data: 50% ``` event : 이벤트의 타입을 지정합니다. 예를 들어, message, update 등 사용자 정의 이벤트 타입이 가능합니다. data : 이벤트의 실제 데이터이며, 여러 줄에 걸쳐 전달될 수 있습니다. id :이벤트 ID를 지정하여 클라이언트가 수신한 마지막 이벤트 ID를 기억할 수 있게 합니다. ### SSE 이벤트 파싱 (parseSSEEvent 함수) ```tsx function parseSSEEvent(raw: string): SSEEvent | null { const lines = raw.split('\n'); let eventType: string | undefined; let data = ''; let id: string | undefined; for (const line of lines) { if (line.startsWith('event:')) { eventType = line.replace('event:', '').trim(); } else if (line.startsWith('data:')) { // 여러 data: 줄이 있을 수 있으므로 줄바꿈으로 연결 data += line.replace('data:', '').trim() + '\n'; } else if (line.startsWith('id:')) { id = line.replace('id:', '').trim(); } } if (data !== '') { return { event: eventType, data: data.trim(), id }; } return null; } ``` - 서버에서 받은 원시 SSE 텍스트 데이터를 줄 단위로 분리하여, 이벤트 타입, 데이터, 아이디를 추출합니다. - 여러 줄에 걸친 `data:` 값을 하나의 문자열로 연결하며, 이벤트 단위는 `\n\n`(빈줄)으로 구분되어 파싱됩니다. ### CustomSSE 클래스 CustomSSE 클래스는 SSE(Server-Sent Events) 서버와의 실시간 연결하고, 메시지를 수신하여 사용자에게 전달하는 기능을 제공합니다. 먼저, CustomSSE 객체가 생성되면 URL과 인증 토큰이 저장되고, 즉시 서버와 연결을 시도합니다. 이때 Fetch API가 사용되며, Authorization 헤더에 Bearer 토큰이 설정되어 인증된 사용자로 연결이 시도됩니다. Accept 헤더는 'text/event-stream'으로 지정되어 서버가 SSE 형식으로 응답을 제공하도록 명시합니다. 또한, AbortController가 함께 생성되어 연결을 중단할 수 있는 제어권을 확보합니다. 서버 연결이 성공하면 readyState가 1 (OPEN)으로 변경되며, 사용자 지정 onopen 콜백이 실행됩니다. 이는 연결이 성공했음을 사용자에게 알리거나 초기화 작업을 수행할 수 있도록 도와줍니다. 서버로부터 수신된 데이터는 스트리밍 방식으로 지속적으로 읽혀지며, TextDecoder를 사용하여 UTF-8 형식의 문자열로 변환됩니다. 이 문자열 데이터는 버퍼에 누적되어 SSE 표준에 따라 '\n\n' (빈 줄)로 구분된 개별 이벤트 메시지로 파싱됩니다. 파싱된 각 메시지는 사용자 지정 onmessage 콜백으로 전달됩니다. 여기서 각 메시지는 JSON 형식으로 변환되거나 사용자 정의 로직에 맞게 처리될 수 있습니다. 이를 통해 사용자 애플리케이션은 서버로부터 실시간으로 이벤트를 받아 처리할 수 있습니다. 만약 연결 중 오류가 발생하면, CustomSSE 클래스는 onerror 콜백을 통해 사용자에게 에러를 알립니다. 이때 콘솔에 에러 메시지도 출력되므로 디버깅이 용이합니다. 에러는 서버 연결 실패, 네트워크 문제 또는 잘못된 토큰으로 인해 발생할 수 있으며, 각 상황에서 적절한 대응이 가능합니다. 마지막으로, 사용자가 CustomSSE 인스턴스의 close() 메서드를 호출하면 연결이 안전하게 종료됩니다. 이때 readyState는 2 (CLOSED)로 변경되고, AbortController의 abort() 메서드를 통해 Fetch 요청이 강제로 취소됩니다. 이는 불필요한 리소스 사용을 방지하고 서버와의 연결을 즉시 종료할 수 있도록 합니다. #### 클래스 멤버 변수 및 생성자 ```tsx export class CustomSSE { url: string; // SSE 서버의 URL token: string; // 인증 토큰 (예: Bearer 토큰) onopen: ((event: Event) => void) | null = null; // 연결 성공 시 호출될 콜백 onmessage: ((event: MessageEvent) => void) | null = null; // 메시지 수신 시 호출될 콜백 onerror: ((event: Event) => void) | null = null; // 에러 발생 시 호출될 콜백 readyState: number = 0; // 0: connecting, 1: open, 2: closed private _abortController: AbortController; // 연결을 중단하기 위한 컨트롤러 constructor(url: string, token: string) { this.url = url; this.token = token; this.readyState = 0; // 처음에는 연결 중 (CONNECTING) this._abortController = new AbortController(); this.connect(); // 객체 생성 시 바로 연결을 시도합니다. } } ``` #### connect, close 메서드 ```tsx private async connect() { try { const response = await fetch(this.url, { headers: { Authorization: `Bearer ${this.token}`, // 인증 헤더 설정 Accept: 'text/event-stream', // SSE 스트림 요청 }, cache: 'no-store', credentials: 'include', signal: this._abortController.signal, // 연결 취소를 위한 signal }); if (!response.ok || !response.body) { throw new Error(`HTTP error! status: ${response.status}`); } // 연결 성공: readyState를 OPEN(1)으로 변경하고 onopen 콜백 호출 this.readyState = 1; if (this.onopen) { this.onopen(new Event('open')); } // 서버로부터 데이터를 읽어오기 위한 리더 생성 const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; // 스트림에서 데이터를 계속 읽어옵니다. while (true) { const { done, value } = await reader.read(); if (done) break; // 더 이상 읽을 데이터가 없으면 종료 // 받아온 바이트 데이터를 문자열로 변환하고, 버퍼에 누적합니다. buffer += decoder.decode(value, { stream: true }); // 버퍼 내에서 "\n\n" 구분자로 이벤트 단위를 찾습니다. let delimiterIndex: number; while ((delimiterIndex = buffer.indexOf('\n\n')) !== -1) { const rawEvent = buffer.slice(0, delimiterIndex).trim(); buffer = buffer.slice(delimiterIndex + 2); if (rawEvent) { // parseSSEEvent 함수를 통해 원시 이벤트 텍스트를 파싱 const eventData = parseSSEEvent(rawEvent); if (eventData && this.onmessage) { // 파싱된 데이터를 MessageEvent 형태로 변환하여 onmessage 콜백에 전달 const messageEvent = new MessageEvent(eventData.event || 'message', { data: eventData.data, lastEventId: eventData.id || '', }); this.onmessage(messageEvent); } } } } } catch (error) { // 에러 발생 시, 연결 상태가 CLOSED가 아니라면 onerror 콜백 호출 if (this.readyState !== 2 && this.onerror) { this.onerror(new Event('error')); } console.error('CustomSSE connection error:', error); } } close() { this.readyState = 2; // CLOSED 상태로 변경 this._abortController.abort(); // fetch 요청을 취소하여 연결을 종료합니다. } ``` - **연결 설정:** - 생성자에서 URL과 토큰을 저장한 후, `_abortController`를 생성하고 `connect()`를 호출하여 서버에 연결을 시도합니다. - **커스텀 헤더 포함:** - Fetch API를 사용하여 요청 시 `Authorization` 헤더와 `Accept: 'text/event-stream'`를 포함합니다. - **데이터 스트림 처리:** - 서버로부터 읽어들인 바이트 데이터를 `TextDecoder`를 통해 문자열로 변환합니다. - 버퍼에 누적된 데이터를 `\n\n`(빈줄) 구분자로 분리하여 이벤트 단위로 파싱합니다. - 파싱된 데이터가 있으면 `onmessage` 핸들러를 호출해 개발자가 정의한 로직을 실행합니다. - **연결 상태 관리:** - `EventSource` API는 연결 상태를 0(연결 중), 1(연결됨), 2(연결 종료)로 관리합니다. - `CustomSSE` 클래스에서도 네이티브 EventSource와 동일한 방식으로 `readyState`를 설정합니다. - 이 상태 값은 네이티브 EventSource의 상태와 유사한 방식으로 정의되어 있습니다. - **에러 및 종료:** - 에러 발생 시 `onerror` 핸들러를 호출하고, 사용자가 `close()` 메서드를 호출하면 AbortController를 통해 연결을 중단합니다. ## 마무리 위에서 구현한 CustomSSE 클래스는 기본 EventSource API의 한계를 보완하여, 실시간 이벤트 스트림을 확장하여 관리할 수 있게 해줍니다. 인증이 필요한 경우에도 Fetch API를 통해 필요한 헤더를 자유롭게 설정할 수 있으며, 실시간으로 이벤트 데이터를 파싱하여 사용자에게 즉시 전달할 수 있습니다. 필요에 따라 CustomSSE를 확장하여 자동 재연결, 사용자 정의 이벤트 핸들러 같은 기능을 추가할 수 있을 것 같습니다.2024년 12월 04일