본문으로 건너뛰기
Advertisement

10.5 제네릭 컴포넌트와 Context 타입 안전성

제네릭 컴포넌트

타입에 따라 다르게 동작하는 재사용 가능한 컴포넌트를 만들 때 제네릭을 사용합니다.

// 제네릭 Select 컴포넌트
interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string;
placeholder?: string;
}

function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
placeholder = '선택하세요',
}: SelectProps<T>) {
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selected = options.find(opt => getValue(opt) === e.target.value);
if (selected !== undefined) onChange(selected);
};

return (
<select
value={value ? getValue(value) : ''}
onChange={handleChange}
>
<option value="">{placeholder}</option>
{options.map(option => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}

// 사용 — 타입이 자동 추론됨
interface Country {
code: string;
name: string;
}

<Select<Country>
options={[{ code: 'KR', name: '한국' }, { code: 'US', name: '미국' }]}
value={selectedCountry}
onChange={setSelectedCountry}
getLabel={c => c.name}
getValue={c => c.code}
/>

타입 안전한 Context

기본 패턴: null 체크

import { createContext, useContext, useState, ReactNode } from 'react';

interface AuthUser {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}

interface AuthContextValue {
user: AuthUser | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}

// null 초기값
const AuthContext = createContext<AuthContextValue | null>(null);

// 안전한 커스텀 훅 — 반드시 Provider 안에서 사용 강제
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}

// Provider 컴포넌트
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(false);

const login = async (email: string, password: string) => {
setIsLoading(true);
try {
const userData = await authService.login(email, password);
setUser(userData);
} finally {
setIsLoading(false);
}
};

const logout = () => {
setUser(null);
authService.logout();
};

return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}

제네릭 Context 팩토리

재사용 가능한 Context 생성 패턴입니다.

function createSafeContext<T>(name: string) {
const Context = createContext<T | undefined>(undefined);

function useContext(): T {
const value = React.useContext(Context);
if (value === undefined) {
throw new Error(`use${name} must be used within ${name}Provider`);
}
return value;
}

return [Context.Provider, useContext] as const;
}

// 사용
interface ThemeContextValue {
theme: 'light' | 'dark';
toggleTheme: () => void;
}

const [ThemeProvider, useTheme] = createSafeContext<ThemeContextValue>('Theme');

function ThemeProviderComponent({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');

return (
<ThemeProvider value={{ theme, toggleTheme }}>
{children}
</ThemeProvider>
);
}

// 컴포넌트에서 사용
function ThemeButton() {
const { theme, toggleTheme } = useTheme(); // ✅ 타입 안전
return <button onClick={toggleTheme}>{theme} 모드</button>;
}

커스텀 훅 타입 설계

기본 커스텀 훅

// API 데이터 페칭 훅
interface UseFetchResult<T> {
data: T | null;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

const fetchData = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json: T = await response.json();
setData(json);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setIsLoading(false);
}
};

useEffect(() => { fetchData(); }, [url]);

return { data, isLoading, error, refetch: fetchData };
}

// 사용
interface Post {
id: number;
title: string;
body: string;
}

function PostList() {
const { data: posts, isLoading, error } = useFetch<Post[]>('/api/posts');

if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>오류: {error.message}</div>;
if (!posts) return null;

return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

TanStack Query (React Query) 타입 통합

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// API 함수 타입 정의
interface User {
id: string;
name: string;
email: string;
}

interface CreateUserInput {
name: string;
email: string;
}

async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}

async function createUser(input: CreateUserInput): Promise<User> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error('Failed to create user');
return res.json();
}

// 쿼리 훅
function useUser(id: string) {
return useQuery<User, Error>({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
enabled: !!id,
staleTime: 5 * 60 * 1000, // 5분
});
}

// 뮤테이션 훅
function useCreateUser() {
const queryClient = useQueryClient();

return useMutation<User, Error, CreateUserInput>({
mutationFn: createUser,
onSuccess: (newUser) => {
// 캐시 업데이트
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.setQueryData(['user', newUser.id], newUser);
},
onError: (error) => {
console.error('사용자 생성 실패:', error.message);
},
});
}

// 컴포넌트에서 사용
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId);
const { mutate: createUser, isPending } = useCreateUser();

if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>오류: {error.message}</div>;
if (!user) return null;

return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}

Render Props 패턴

interface RenderPropsPattern<T> {
data: T;
render: (data: T) => React.ReactNode;
renderEmpty?: () => React.ReactNode;
renderLoading?: () => React.ReactNode;
}

function DataRenderer<T>({
data,
render,
renderEmpty = () => <div>데이터 없음</div>,
}: RenderPropsPattern<T> & { data: T | null }) {
if (!data) return <>{renderEmpty()}</>;
return <>{render(data)}</>;
}

// 사용
<DataRenderer
data={user}
render={(u) => <UserCard user={u} />}
renderEmpty={() => <p>사용자를 찾을 수 없습니다</p>}
/>

고수 팁

1. ComponentType<P> — 컴포넌트를 Props로 받기

import { ComponentType } from 'react';

interface LayoutProps {
Header: ComponentType<{ title: string }>;
Sidebar?: ComponentType;
children: React.ReactNode;
}

function Layout({ Header, Sidebar, children }: LayoutProps) {
return (
<div>
<Header title="내 앱" />
<div className="content">
{Sidebar && <Sidebar />}
<main>{children}</main>
</div>
</div>
);
}

2. HOC (Higher Order Component) 타입

function withAuth<P extends object>(
WrappedComponent: ComponentType<P>
): ComponentType<P> {
return function AuthenticatedComponent(props: P) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" />;
return <WrappedComponent {...props} />;
};
}

const ProtectedPage = withAuth(DashboardPage);
Advertisement