2025.03.10 - [Programming Language/React] - [React] Design Pattern in React (2)
이전 글에 이어,
이번엔 프로토타입(Prototype) 패턴의 자주 쓰이는 용례들을 적어보고자 한다.
프로토타입 패턴(Prototype)
프로토타입 패턴은 기존 객체를 복제하여 새로운 객체를 생성하는 디자인 패턴으로, React에서는 이 개념을 컴포넌트 재사용과 확장에 적용하여, 기본 컴포넌트를 만들고 이를 확장하여 다양한 변형을 만드는 방식으로 활용한다.
1. 기본 구조
React에서 프로토타입 패턴의 기본 구현:
// 기본 프로토타입 컴포넌트
function Button({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
{...props}
>
{children}
</button>
);
}
// 프로토타입을 확장한 컴포넌트들
function PrimaryButton(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return <Button {...props} />;
}
function SecondaryButton(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<Button
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
{...props}
/>
);
}
function DangerButton(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<Button
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
{...props}
/>
);
}
2. 컴포넌트 변형 시스템
프로토타입 패턴을 활용한 변형 시스템:
// 기본 카드 컴포넌트 (프로토타입)
interface CardProps {
title?: string;
children: React.ReactNode;
footer?: React.ReactNode;
variant?: 'default' | 'compact' | 'expanded';
}
function Card({ title, children, footer, variant = 'default' }: CardProps) {
// 변형에 따른 스타일 결정
const variantStyles = {
default: 'p-6 space-y-4',
compact: 'p-3 space-y-2',
expanded: 'p-8 space-y-6'
};
return (
<div className={`bg-white rounded-lg shadow ${variantStyles[variant]}`}>
{title && <h3 className="text-xl font-semibold">{title}</h3>}
<div>{children}</div>
{footer && <div className="pt-4 border-t">{footer}</div>}
</div>
);
}
// 특화된 카드 컴포넌트들
function ProductCard({ product, ...props }: { product: Product } & Omit<CardProps, 'title' | 'children'>) {
return (
<Card
title={product.name}
{...props}
footer={
<div className="flex justify-between items-center">
<span className="font-bold">${product.price.toFixed(2)}</span>
<button className="px-3 py-1 bg-blue-500 text-white rounded">Add to Cart</button>
</div>
}
>
<div className="space-y-2">
<img src={product.image} alt={product.name} className="w-full h-48 object-cover" />
<p>{product.description}</p>
</div>
</Card>
);
}
function UserCard({ user, ...props }: { user: User } & Omit<CardProps, 'title' | 'children'>) {
return (
<Card
title={user.name}
variant="compact"
{...props}
>
<div className="flex items-center space-x-4">
<img src={user.avatar} alt={user.name} className="w-12 h-12 rounded-full" />
<div>
<p>{user.email}</p>
<p className="text-sm text-gray-500">{user.role}</p>
</div>
</div>
</Card>
);
}
3. 컴포넌트 팩토리
프로토타입 패턴과 팩토리 패턴을 결합한 접근법
// 컴포넌트 팩토리
function createFormField<T extends Record<string, any>>(config: {
component: React.ComponentType<any>;
defaultProps?: Partial<React.ComponentProps<any>>;
}) {
const { component: Component, defaultProps = {} } = config;
return function FormField({
name,
label,
error,
...props
}: {
name: keyof T & string;
label?: string;
error?: string;
} & React.ComponentProps<typeof Component>) {
return (
<div className="mb-4">
{label && (
<label htmlFor={name} className="block mb-2 text-sm font-medium text-gray-700">
{label}
</label>
)}
<Component
id={name}
name={name}
{...defaultProps}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
};
}
// 사용 예시
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
const TextField = createFormField<LoginForm>({
component: 'input',
defaultProps: { type: 'text', className: 'w-full px-3 py-2 border rounded-md' }
});
const PasswordField = createFormField<LoginForm>({
component: 'input',
defaultProps: { type: 'password', className: 'w-full px-3 py-2 border rounded-md' }
});
const CheckboxField = createFormField<LoginForm>({
component: 'input',
defaultProps: { type: 'checkbox', className: 'h-4 w-4 text-blue-600' }
});
// 로그인 폼 컴포넌트
function LoginForm() {
const [formData, setFormData] = useState<LoginForm>({
email: '',
password: '',
rememberMe: false
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<form className="space-y-6">
<TextField
name="email"
label="Email"
value={formData.email}
onChange={handleChange}
/>
<PasswordField
name="password"
label="Password"
value={formData.password}
onChange={handleChange}
/>
<div className="flex items-center">
<CheckboxField
name="rememberMe"
checked={formData.rememberMe}
onChange={handleChange}
/>
<label htmlFor="rememberMe" className="ml-2 text-sm text-gray-700">
Remember me
</label>
</div>
<button
type="submit"
className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Sign in
</button>
</form>
);
}
4. 테마 시스템
프로토타입 패턴을 활용한 테마 시스템:
// 테마 타입 정의
interface Theme {
colors: {
primary: string;
secondary: string;
background: string;
text: string;
error: string;
};
spacing: {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
};
typography: {
fontFamily: string;
fontSize: {
small: string;
medium: string;
large: string;
};
};
}
// 기본 테마 (프로토타입)
const baseTheme: Theme = {
colors: {
primary: '#3b82f6',
secondary: '#10b981',
background: '#ffffff',
text: '#1f2937',
error: '#ef4444',
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
},
typography: {
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: {
small: '0.875rem',
medium: '1rem',
large: '1.25rem',
},
},
};
// 다크 테마 (프로토타입 확장)
const darkTheme: Theme = {
...baseTheme,
colors: {
...baseTheme.colors,
primary: '#60a5fa',
secondary: '#34d399',
background: '#111827',
text: '#f9fafb',
},
};
// 테마 컨텍스트
const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({
theme: baseTheme,
toggleTheme: () => {},
});
// 테마 프로바이더
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [isDark, setIsDark] = useState(false);
const theme = isDark ? darkTheme : baseTheme;
const toggleTheme = () => setIsDark(!isDark);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<div
style={{
backgroundColor: theme.colors.background,
color: theme.colors.text,
fontFamily: theme.typography.fontFamily,
transition: 'background-color 0.3s, color 0.3s',
}}
>
{children}
</div>
</ThemeContext.Provider>
);
}
// 테마 사용 훅
function useTheme() {
return useContext(ThemeContext);
}
// 테마를 사용하는 버튼 컴포넌트
function ThemedButton({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { theme } = useTheme();
return (
<button
style={{
backgroundColor: theme.colors.primary,
color: '#ffffff',
padding: `${theme.spacing.sm} ${theme.spacing.md}`,
borderRadius: '0.25rem',
border: 'none',
cursor: 'pointer',
}}
{...props}
>
{children}
</button>
);
}
Next.js에서 고려해야할 사항
1. 서버 컴포넌트와 클라이언트 컴포넌트 구분
프로토타입 패턴 적용시 서버/클라이언트 컴포넌트 구분이 중요하므로 이를 고려해야 한다.
서버 컴포넌트
// components/ui/Button.tsx (서버 컴포넌트)
import { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
}
export function Button({
children,
className,
variant = 'primary',
...props
}: ButtonProps) {
const variantClasses = {
primary: 'bg-blue-500 hover:bg-blue-600 text-white',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
danger: 'bg-red-500 hover:bg-red-600 text-white'
};
return (
<button
className={cn(
'px-4 py-2 rounded transition-colors',
variantClasses[variant],
className
)}
{...props}
>
{children}
</button>
);
}
클라이언트 컴포넌트
// components/ui/InteractiveButton.tsx (클라이언트 컴포넌트)
'use client';
import { useState } from 'react';
import { Button } from './Button';
interface InteractiveButtonProps extends React.ComponentProps<typeof Button> {
activeText?: string;
}
export function InteractiveButton({
children,
activeText,
onClick,
...props
}: InteractiveButtonProps) {
const [isActive, setIsActive] = useState(false);
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
setIsActive(true);
onClick?.(e);
// 1초 후 상태 복원
setTimeout(() => setIsActive(false), 1000);
};
return (
<Button
onClick={handleClick}
{...props}
>
{isActive && activeText ? activeText : children}
</Button>
);
}
2. 서버 액션과 프로토 타입 패턴 통합
서버 액션을 프로토타입 패턴과 통합하는 예시:
// actions/form.ts (서버 액션)
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
// 기본 폼 액션 프로토타입
export async function createFormAction<T>(
schema: z.ZodType<T>,
handler: (data: T) => Promise<{ success: boolean; message?: string }>
) {
return async (formData: FormData) => {
// FormData를 객체로 변환
const rawData = Object.fromEntries(formData.entries());
// 유효성 검증
const result = schema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
};
}
try {
// 핸들러 실행
const response = await handler(result.data);
return response;
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'An error occurred'
};
}
};
}
// 사용자 생성 스키마
const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
role: z.enum(['user', 'admin'], {
errorMap: () => ({ message: 'Invalid role' })
})
});
// 사용자 생성 액션
export const createUser = createFormAction(userSchema, async (data) => {
// 데이터베이스에 사용자 생성 로직
console.log('Creating user:', data);
// 성공 시
revalidatePath('/users');
return { success: true, message: 'User created successfully' };
});
3. 클라이언트 컴포넌트에서 사용
'use client';
import { useFormState } from 'react-dom';
import { createUser } from '@/actions/form';
import { Button } from '@/components/ui/Button';
// 폼 컴포넌트 프로토타입
function Form({
action,
children,
...props
}: {
action: any;
children: React.ReactNode;
} & React.FormHTMLAttributes<HTMLFormElement>) {
return (
<form action={action} {...props}>
{children}
</form>
);
}
// 사용자 생성 폼
export default function CreateUserForm() {
const [state, formAction] = useFormState(createUser, {
success: false,
errors: {},
message: ''
});
return (
<Form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
id="name"
name="name"
type="text"
className="mt-1 block w-full rounded-md border p-2"
/>
{state.errors?.name && (
<p className="mt-1 text-sm text-red-600">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
className="mt-1 block w-full rounded-md border p-2"
/>
{state.errors?.email && (
<p className="mt-1 text-sm text-red-600">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="role" className="block text-sm font-medium">
Role
</label>
<select
id="role"
name="role"
className="mt-1 block w-full rounded-md border p-2"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
{state.errors?.role && (
<p className="mt-1 text-sm text-red-600">{state.errors.role[0]}</p>
)}
</div>
<Button type="submit">Create User</Button>
{state.message && (
<p className={`text-sm ${state.success ? 'text-green-600' : 'text-red-600'}`}>
{state.message}
</p>
)}
</Form>
);
}
4. with React19 (use, useOptimistic)
'use client';
import { use, useOptimistic } from 'react';
import { addComment } from '@/actions/comments';
// 댓글 컴포넌트 프로토타입
function Comment({ comment }: { comment: CommentType }) {
return (
<div className="p-4 border-b">
<div className="flex items-center space-x-2">
<img
src={comment.author.avatar}
alt={comment.author.name}
className="w-8 h-8 rounded-full"
/>
<span className="font-semibold">{comment.author.name}</span>
</div>
<p className="mt-2">{comment.content}</p>
<span className="text-sm text-gray-500">
{new Date(comment.createdAt).toLocaleString()}
</span>
</div>
);
}
// 댓글 목록 컴포넌트
export default function CommentSection({
postId,
initialComments
}: {
postId: string;
initialComments: Promise<CommentType[]>
}) {
// React 19의 use 훅으로 Promise 직접 사용
const comments = use(initialComments);
// 낙관적 UI 업데이트를 위한 상태
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(state, newComment: CommentType) => [...state, newComment]
);
async function handleAddComment(formData: FormData) {
// 현재 사용자 정보 (실제로는 인증 시스템에서 가져옴)
const currentUser = {
id: 'user-1',
name: 'Current User',
avatar: '/avatars/user.png'
};
// 낙관적 업데이트를 위한 임시 댓글
const optimisticComment = {
id: `temp-${Date.now()}`,
content: formData.get('content') as string,
author: currentUser,
createdAt: new Date().toISOString(),
postId
};
// 낙관적 UI 업데이트
addOptimisticComment(optimisticComment);
// 서버 액션 호출
await addComment(postId, formData);
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Comments ({optimisticComments.length})</h2>
<form action={handleAddComment} className="space-y-2">
<textarea
name="content"
placeholder="Add a comment..."
className="w-full p-2 border rounded-md"
rows={3}
required
/>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
>
Post Comment
</button>
</form>
<div className="space-y-4">
{optimisticComments.map(comment => (
<Comment key={comment.id} comment={comment} />
))}
{optimisticComments.length === 0 && (
<p className="text-gray-500">No comments yet. Be the first to comment!</p>
)}
</div>
</div>
);
}
장점
- 재사용성: 기본 컴포넌트를 정의하고 다양한 변형을 만들 수 있음
- 일관성: UI 요소 간의 일관성을 유지하기 쉬움
- 확장성: 기존 컴포넌트를 확장하여 새로운 기능 추가 가능
- 유지보수: 기본 컴포넌트 변경 시 모든 파생 컴포넌트에 자동 반영
- 코드 중복 감소: 공통 로직과 스타일을 한 곳에서 관리
단점
- 복잡성 증가: 컴포넌트 계층이 깊어질 수 있음
- 과도한 추상화: 너무 많은 옵션과 변형을 지원하면 복잡해질 수 있음
- 성능 오버헤드: 불필요한 props 전달이 많아질 수 있음
- 디버깅 어려움: 여러 계층을 통과하는 props 추적이 어려울 수 있음
어느 때에 쓰는게 적합할까
- 디자인 시스템 구축: 일관된 UI 컴포넌트 라이브러리 개발 시
- 대규모 애플리케이션: 여러 페이지와 기능에서 일관된 UI 요소가 필요할 때
- 테마 지원: 다양한 테마와 스타일 변형을 지원해야 할 때
- 폼 요소: 다양한 입력 필드와 컨트롤을 일관되게 관리해야 할 때
- 반복적인 UI 패턴: 비슷한 구조의 UI 요소가 반복될 때
React를 사용하면 기본적으로 다루게 되는 흔한 패턴이지만,
용례를 적으면서 어느 때에 써야할지 명확해지는 것 같다.