24
Posts
  • All (7)
  • React (5)
  • Nextjs (2)
  • thumbnail for useSuspenseQuery 제대로 사용하기

    useSuspenseQuery 제대로 사용하기

    프로젝트에서 TanStack Query의 `useQuery`를 사용하여 데이터를 조회하고 있었습니다. ```tsx const UserDashboard = () => { const { data: user, isLoading: userLoading } = useQuery(userQueries.detail(userId)); const { data: posts, isLoading: postsLoading } = useQuery(userQueries.posts(userId)); const { data: comments, isLoading: commentsLoading } = useQuery(userQueries.comments(userId)); if (userLoading || postsLoading || commentsLoading) { return <LoadingSpinner />; } return ( <div> <UserProfile user={user} /> <PostList posts={posts} /> <CommentList comments={comments} /> </div> ); }; ``` 여러 데이터를 조회할 경우, 각 쿼리의 로딩 상태를 개별적으로 관리해야 하기 때문에 번거롭습니다. --- ## useSuspenseQuery 도입 `useSuspenseQuery`를 사용하면 isLoading 없이 로딩과 에러 처리를 상위 컴포넌트에서 처리할 수 있습니다. ```tsx // App.tsx function App() { return ( <ErrorBoundary fallback={<ErrorPage />}> <Suspense fallback={<LoadingSpinner />}> <UserDashboard /> </Suspense> </ErrorBoundary> ); } // UserDashboard.tsx function UserDashboard() { const { data: user } = useSuspenseQuery(userQueries.detail(userId)); const { data: posts } = useSuspenseQuery(userQueries.posts(userId)); const { data: comments } = useSuspenseQuery(userQueries.comments(userId)); return ( <div> <UserProfile user={user} /> <PostList posts={posts} /> <CommentList comments={comments} /> </div> ); } ``` 로딩 상태의 조건문이 제거되어 코드가 간결해졌지만, 아래의 문제가 발생합니다. --- ## 의도치 않은 Request Waterfall 현상 이 코드는 언뜻 보기에 세 개의 API 요청이 병렬로 실행될 것 같지만, 실제로는 **순차적으로 실행**됩니다. 만약 세 API가 각각 100ms씩 걸린다면, 사용자는 총 300ms 동안 로딩 스피너를 보게 됩니다. 원인은 `useSuspenseQuery`의 동작 방식에 있습니다. 데이터가 캐시에 없으면 API 요청을 시작함과 동시에 **Promise를 throw**합니다. React는 이를 감지해 즉시 컴포넌트 렌더링을 중단하고 상위 Suspense의 fallback을 표시합니다. 결국 첫 번째 쿼리에서 Promise가 throw되는 순간 그 아래 코드는 실행되지 않습니다. 첫 번째 응답이 도착해 컴포넌트가 다시 렌더링될 때 두 번째 쿼리가 실행되고, 같은 방식으로 세 번째 쿼리까지 이어지며 전체 로딩 시간이 불필요하게 늘어납니다. --- ## 방법 1: 컴포넌트 분리 이 문제를 해결하려면 각 쿼리를 별도의 컴포넌트로 추출하고 각각 Suspense로 감싸야 합니다. ```tsx function UserDashboard({ userId }: { userId: string }) { return ( <ErrorBoundary fallback={<ErrorPage />}> <Suspense fallback={<UserSkeleton />}> <UserInfoSection userId={userId} /> </Suspense> <Suspense fallback={<PostsSkeleton />}> <PostsSection userId={userId} /> </Suspense> <Suspense fallback={<CommentsSkeleton />}> <CommentsSection userId={userId} /> </Suspense> </ErrorBoundary> ); } function UserInfoSection({ userId }: { userId: string }) { const { data: user } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) }); // ... } function PostsSection({ userId }: { userId: string }) { const { data: posts } = useSuspenseQuery({ queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) }); // ... } ``` 이제 각 섹션이 독립적으로 로딩되고 병렬 요청도 가능해지지만, 다른 문제가 생깁니다. - **데이터 흐름 파악의 어려움:** 페이지 전체에서 어떤 API들이 호출되는지 한눈에 보이지 않고 자식 컴포넌트 내부로 숨어버립니다. - **불필요한 컴포넌트 파편화:** 단지 로딩 UI 분리와 워터폴 방지를 위해 원래 하나였을 컴포넌트를 강제로 쪼개야 하며, 이 과정에서 파일이 늘어나고 코드가 파편화됩니다. --- ## 방법 2: SuspenseQuery 컴포넌트 파일은 하나로 유지하면서, 워터폴을 방지하고 영역별 로딩을 처리하고 싶다면 **Render Props 패턴**의 `SuspenseQuery` 컴포넌트가 좋은 방법입니다. ([@suspensive/react-query](https://github.com/toss/suspensive) 라이브러리 참고) useSuspenseQuery 호출부를 별도의 컴포넌트로 캡슐화하여, 부모 컴포넌트의 실행 흐름을 방해하지 않도록 설계합니다. ```tsx // src/components/common/SuspenseQuery.tsx import { useSuspenseQuery, UseSuspenseQueryOptions, UseSuspenseQueryResult } from '@tanstack/react-query'; import type { ReactNode } from 'react'; interface SuspenseQueryProps<TData = unknown, TError = Error> { children: (result: UseSuspenseQueryResult<TData, TError>) => ReactNode; } export function SuspenseQuery<TData = unknown, TError = Error>({ children, ...options }: UseSuspenseQueryOptions<TData, TError> & SuspenseQueryProps<TData, TError>) { return <>{children(useSuspenseQuery(options))}</>; } ``` ### 워터폴 방지와 구조적 응집도 확보 방법 1에서는 워터폴을 피하기 위해 각 섹션을 물리적인 파일이나 별도 컴포넌트로 쪼개야 했습니다. 하지만 `SuspenseQuery`를 사용하면 **훅의 호출 위치가 컴포넌트 내부로 격리**되므로, 부모 컴포넌트의 렌더링이 통째로 중단되지 않습니다. 덕분에 한 파일 내에서도 여러 개의 API 요청이 병렬로 실행되며, 평탄한 구조를 유지하면서도 독립적인 Suspense 경계를 가질 수 있습니다. ### Render Props 패턴 `SuspenseQuery`가 자식 함수를 실행하는 시점은 데이터 로드가 완료된 이후입니다. 이 **실행 시점의 차이** 덕분에 함수 내부의 data는 자연스럽게 Non-nullable 상태가 됩니다. 결과적으로 undefined를 대비한 불필요한 조건문이나 옵셔널 체이닝 없이 사용할 수 있습니다. ```tsx <SuspenseQuery {...userQuery}> {({ data }) => { // data는 무조건 존재 (T | undefined가 아니라 T) return <div>{data.name}</div>; // ?.name 불필요 }} </SuspenseQuery> ``` --- ## 실제 적용 예시 ```tsx function UserDashboard({ userId }: { userId: string }) { return ( <ErrorBoundary fallback={<ErrorPage />}> <Suspense fallback={<UserSkeleton />}> <SuspenseQuery {...userQueries.detail(userId)}> {({ data: user }) => ( <section> <h2>{user.name}</h2> <p>{user.email}</p> </section> )} </SuspenseQuery> </Suspense> <Suspense fallback={<PostsSkeleton />}> <SuspenseQuery {...userQueries.posts(userId)}> {({ data: posts }) => ( <section> <h3>작성한 글 ({posts.length})</h3> <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </section> )} </SuspenseQuery> </Suspense> <Suspense fallback={<CommentsSkeleton />}> <SuspenseQuery {...userQueries.comments(userId)}> {({ data: comments }) => ( <section> <h3>작성한 댓글 ({comments.length})</h3> <ul> {comments.map(comment => ( <li key={comment.id}>{comment.content}</li> ))} </ul> </section> )} </SuspenseQuery> </Suspense> </ErrorBoundary> ); } ``` --- - 방법 1은 각 섹션을 별도 컴포넌트로 분리해야 했지만, `SuspenseQuery`를 사용하면 한 파일 안에서 해결할 수 있습니다. - **선언적 UI와 코드 응집도:** 하나의 블록 안에 어떤 API를 호출하는지와 그 결과로 어떤 UI를 렌더링하는지가 함께 모여 있어 코드 응집도가 높습니다. 페이지 전체의 데이터 흐름을 한눈에 파악할 수 있는 평탄한 구조를 유지할 수 있습니다.

    2025년 12월 18일
  • thumbnail for 복잡한 권한 체크 로직을 깔끔하게 관리하는 방법

    복잡한 권한 체크 로직을 깔끔하게 관리하는 방법

    서비스가 커지고 기능이 늘어나면, 사용자마다 접근 권한과 수행 가능한 작업이 달라집니다. 초기에는 단순했던 코드도 요구사항이 쌓이면 점점 읽기 어려워지고, 유지보수가 힘들어집니다. 이번 글에서는 관리자 페이지 구현 경험을 바탕으로, 복잡한 조건문을 정리하고 권한 로직을 선언적으로 관리하는 방법을 소개합니다. --- ## 복잡해지는 권한 체크 로직 관리자 페이지에는 최고관리자와 일반관리자로 역할이 구분됩니다. 최고관리자는 사용자를 삭제할 수 있고, 일반관리자는 사용자를 생성할 수만 있습니다. 초기에는 간단한 조건문으로 버튼을 보여주거나 숨기는 방식으로 구현했습니다. ```tsx {currentUser.role === 'SUPER_ADMIN' && ( <button onClick={() => handleDelete(user)}>삭제</button> )} {currentUser.role === 'ADMIN' && ( <button onClick={() => handleCreate()}>생성</button> )} ``` 그러나 요구사항이 계속 추가될수록, 코드는 복잡해집니다. ```tsx {currentUser.role === 'SUPER_ADMIN' && currentUser.organizationId === user.organizationId && // 같은 조직 user.status === 'ACTIVE' && // 활성 상태인 사용자만 user.permissions.includes('CAN_BE_DELETED') && // 삭제 가능 플래그 ( <button onClick={() => handleDelete(user)}>삭제</button> )} {currentUser.role === 'ADMIN' && ( <button onClick={() => handleCreate()}>생성</button> )} ``` 이런 코드는 읽기 어렵고, 새로운 요구사항을 추가할 때 실수하기 쉽습니다. --- ## 권한 규칙 분리하기 처음에는 각 컴포넌트에 조건문을 직접 작성했습니다. 그런데 같은 권한 체크가 여러 곳에 반복되다 보니, 권한 규칙이 바뀔 때마다 모든 곳을 찾아 수정해야 했습니다. 이런 문제를 해결하기 위해 권한 로직을 컴포넌트에서 분리하기로 했습니다. 권한을 역할별로 정의하고 한 곳에서 관리하면, UI는 단순히 "가능한가?"만 물어보면 되고, 규칙이 바뀌어도 한 곳만 수정하면 됩니다. ### 권한 인터페이스 정의 먼저 우리 시스템에서 체크할 수 있는 모든 권한을 인터페이스로 정의합니다. ```tsx export interface Permissions { // 사용자 계정 관련 권한 createUser: () => boolean; deleteUser: (user?: User) => boolean; } ``` 관리자 페이지에서는 사용자(User)를 관리합니다. 최고관리자는 사용자를 삭제할 수 있고, 일반관리자는 사용자를 생성할 수 있습니다. ### 역할별 권한 정의 이제 각 역할이 무엇을 할 수 있는지 정의합니다. permissionsFactoryMap은 각 역할(SUPER_ADMIN, ADMIN)마다 권한 객체를 생성하는 함수를 담고 있습니다. 현재 로그인한 사용자(currentUser)를 받아서, 그 사용자가 대상(targetUser)에게 어떤 작업을 수행할 수 있는지 판단합니다. ```tsx const permissionsFactoryMap = { SUPER_ADMIN: (currentUser: AuthUser) => ({ createUser: () => true, deleteUser: (targetUser?: User) => targetUser?.status === 'ACTIVE' && targetUser.organizationId === currentUser.organizationId && targetUser.permissions.includes('CAN_BE_DELETED'), }), ADMIN: (currentUser: AuthUser) => ({ createUser: () => true, deleteUser: () => false, }), }; ``` - 각 권한 함수는 대상 객체를 받아 boolean을 반환합니다 - targetUser가 없을 때는 일반적인 권한 체크(목록 조회 등)에 사용됩니다. - 역할에 따라 제약 조건이 다르게 적용됩니다 ```tsx const superAdminPermissions = permissionsFactoryMap.SUPER_ADMIN(currentUser); // UI에서 사용 {superAdminPermissions.deleteUser(targetUser) && ( <button onClick={() => handleDelete(targetUser)}>삭제</button> )} ``` 이제 복잡했던 조건문이 deleteUser 메서드 안에 깔끔하게 정리되었습니다. 하지만 역할이 하드코딩되어 있고, 사용자가 여러 역할을 가진 경우를 다루기 어렵습니다. ### 여러 역할과 권한 통합 지금까지는 SUPER_ADMIN과 ADMIN 두 역할만 다뤘습니다. 하지만 팀이 커지면서 역할도 더 세분화되었습니다. 예를 들어 역할을 ContentAdmin(콘텐츠 관리)과 SecurityAdmin(보안 관리)으로 나누고, 한 관리자가 두 역할을 모두 가진다면 어떻게 될까요? ```tsx const contentPermissions = permissionsFactoryMap.CONTENT_ADMIN(currentUser); const securityPermissions = permissionsFactoryMap.SECURITY_ADMIN(currentUser); {(contentPermissions.deleteUser(user) || securityPermissions.deleteUser(user)) && ( <button onClick={() => handleDelete(user)}>삭제</button> )} ``` 역할이 2개, 3개.. 계속 늘어나면 코드가 다시 복잡해집니다. ### can 함수로 권한 간단 확인 이 문제를 해결하기 위해 `can` 함수를 만들었습니다. 이 함수는 사용자가 가진 모든 역할의 권한을 순회하면서, 하나라도 해당 작업을 수행할 수 있으면 true를 반환합니다. ```tsx {can(currentUser).deleteUser(user) && ( <button onClick={() => handleDelete(user)}>삭제</button> )} ``` can 함수는 두 단계로 동작합니다 #### can 함수 구현 ```tsx export function can(currentUser: AuthUser): Permissions { // 1단계: 사용자의 모든 역할에 대한 권한 객체 수집 const userRolePermissions = currentUser.roles .map(role => permissionsFactoryMap[role]?.(currentUser)) .filter(Boolean); // 2단계: Proxy로 동적 권한 체크 return new Proxy({} as Permissions, { get(_target, action: keyof Permissions) { return <T extends User>(data?: T) => userRolePermissions.some( permissions => permissions?.[action]?.(data) ); }, }); } ``` **1단계: 역할별 권한 객체 수집** ```tsx const userRolePermissions = currentUser.roles .map(role => permissionsFactoryMap[role]?.(currentUser)) .filter(Boolean); ``` - `currentUser.roles`: 사용자가 가진 역할 배열 (예: `['CONTENT_ADMIN', 'SECURITY_ADMIN']`) - `.map(role => ...)`: 각 역할을 권한 객체로 변환 - `permissionsFactoryMap[role]`: 해당 역할의 권한 생성 함수 찾기 - `?.(currentUser)`: 함수가 존재하면 호출해서 권한 객체 생성 - `.filter(Boolean)`: undefined/null 제거 결과적으로 다음과 같은 배열이 만들어집니다: ```tsx [ { createUser: fn, deleteUser: fn }, // CONTENT_ADMIN의 권한 { createUser: fn, deleteUser: fn } // SECURITY_ADMIN의 권한 ] ``` **2단계: Proxy로 동적 권한 체크** Proxy는 객체의 속성 접근을 가로채서 커스텀 동작을 실행합니다. ```tsx return new Proxy({} as Permissions, { get(_target, action: keyof Permissions) { return <T extends User>(data?: T) => userRolePermissions.some( permissions => permissions?.[action]?.(data) ); }, }); ``` **실제 호출 과정:** ```tsx can(currentUser).deleteUser(user) ``` 1. `can(currentUser)` → Proxy 객체 반환 2. `.deleteUser` 접근 → Proxy의 `get` 트랩 실행 - `action = 'deleteUser'` 3. `get` 트랩이 함수를 반환: ```tsx (user) => userRolePermissions.some( permissions => permissions?.deleteUser?.(user) ) ``` 4. `(user)` 호출 → `some`으로 모든 역할 체크: ```tsx [ contentAdminPermissions.deleteUser(user), // false securityAdminPermissions.deleteUser(user) // true ].some(result => result) // → true (하나라도 true면 true) ``` **`some` 메서드의 역할** `some`은 배열의 요소 중 **하나라도** 조건을 만족하면 true를 반환합니다. ```tsx userRolePermissions.some( permissions => permissions?.[action]?.(data) ) // 풀어쓰면 ContentAdmin.deleteUser(user) || SecurityAdmin.deleteUser(user) // false || true → true ``` **Proxy를 사용하는 이유** Proxy를 사용하지 않으면 모든 권한 메서드마다 같은 패턴을 반복해야 합니다: ```tsx return { createUser: (data) => userRolePermissions.some(p => p.createUser(data)), deleteUser: (data) => userRolePermissions.some(p => p.deleteUser(data)), updateUser: (data) => userRolePermissions.some(p => p.updateUser(data)), // 권한이 10개, 20개로 늘어나면... }; ``` Proxy를 사용하면 **어떤 권한 메서드가 호출되든** 자동으로 `some` 로직이 적용됩니다. 권한이 추가되어도 `can` 함수를 수정할 필요가 없습니다. **옵셔널 체이닝(`?.`)을 사용하는 이유** ```tsx permissions?.[action]?.(data) ``` - `permissions?.[action]`: 권한 객체에 해당 메서드가 없을 수 있음 - `?.(data)`: 메서드가 함수가 아닐 수 있음 안전하게 체크하면서 에러를 방지합니다. ### 사용 예시 이제 UI에서는 권한의 상세 조건을 알 필요가 없습니다. "삭제 권한이 있는가?"라고 선언적으로 코드를 작성합니다. ```tsx // 이전 코드 (복잡한 조건문) {currentUser.role === 'ADMIN' && targetUser.role !== 'SUPER_ADMIN' && ... && ( <button onClick={() => handleDelete(user)}>삭제</button> )} // 개선된 코드 (선언적인 권한 체크) {can(currentUser).deleteUser(targetUser) && ( <button onClick={() => handleDelete(user)}>삭제</button> )} ``` 이제 UI 컴포넌트는 누가 관리자인지, 조직이 같은지 같은 세부 조건을 신경 쓰지 않아도 됩니다. 단순히 "삭제 권한이 있는가?"만 확인하면 되기 때문에, 코드가 훨씬 깔끔하고 유지보수가 쉬워집니다. 새로운 권한이 생겨도 permissionFactoryMap만 수정하면 컴포넌트 코드는 그대로 사용할 수 있습니다.

    2025년 10월 14일
  • thumbnail for React에서 Polymorphic Component 만들기

    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일
  • thumbnail for 컴파운드 패턴으로 Dropdown Component 만들기

    컴파운드 패턴으로 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일
  • thumbnail for Fetch API 기반 SSE로 실시간 이벤트 스트림 구현하기

    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일
© All rights reserved. Powered by dokimion24.