고급 타입 패턴과 Best Practices
React와 타입스크립트를 함께 사용할 때, 기본적인 Props와 State 타입 정의만으로는 부족한 경우가 많습니다. 특히 복잡한 컴포넌트 구조나 재사용 가능한 유틸리티 컴포넌트를 만들 때는 고급 타입 패턴을 활용하여 더 유연하고 타입 안전한 코드를 작성할 수 있습니다. 이 절에서는 조건부 타입, 유틸리티 타입, 제네릭 컴포넌트 등 몇 가지 고급 타입스크립트 패턴을 React 맥락에서 살펴보겠습니다.
조건부 타입을 이용한 Props 제어
조건부 타입은 특정 타입에 따라 다른 타입을 조건부로 할당할 수 있게 해주는 타입스크립트 기능입니다. React 컴포넌트의 Props를 정의할 때, 특정 Prop의 존재 여부에 따라 다른 Prop이 필수가 되거나 타입이 달라지는 경우에 유용하게 사용할 수 있습니다.
예시: 버튼 컴포넌트의 조건부 Props
버튼 컴포넌트가 href
Prop을 받으면 <a>
태그로 렌더링되고, onClick
Prop을 받으면 <button>
태그로 렌더링된다고 가정해봅시다.
import React from 'react';
// 공통 Props 인터페이스
interface BaseButtonProps {
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
// <a> 태그에 필요한 Props
interface AnchorButtonProps extends BaseButtonProps {
href: string;
onClick?: never; // href가 있으면 onClick은 허용하지 않음
}
// <button> 태그에 필요한 Props
interface HtmlButtonProps extends BaseButtonProps {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
href?: never; // onClick이 있으면 href는 허용하지 않음
}
// 조건부 타입으로 최종 ButtonProps 정의
// LinkProps가 href 속성을 가지면 AnchorButtonProps 타입, 아니면 HtmlButtonProps 타입
type ButtonProps = AnchorButtonProps | HtmlButtonProps;
const ConditionalButton: React.FC<ButtonProps> = ({ children, className, disabled, href, onClick }) => {
if (href) {
// href가 제공되면 <a> 태그로 렌더링
return (
<a href={href} className={className} style={disabled ? { opacity: 0.5, pointerEvents: 'none' } : {}}>
{children}
</a>
);
} else {
// onClick이 제공되거나 href가 없으면 <button> 태그로 렌더링
return (
<button
onClick={onClick as (event: React.MouseEvent<HTMLButtonElement>) => void} // onClick이 반드시 있을 때만 여기로 오도록 타입 단언
className={className}
disabled={disabled}
>
{children}
</button>
);
}
};
export default ConditionalButton;
// 사용 예시
const AppWithConditionalButton: React.FC = () => {
const handleClick = () => alert('Button Clicked!');
return (
<div>
<h3>Conditional Button Examples</h3>
{/* <a> 태그로 렌더링됨 */}
<ConditionalButton href="https://google.com" className="link-btn">
Go to Google
</ConditionalButton>
{/* <button> 태그로 렌더링됨 */}
<ConditionalButton onClick={handleClick} className="html-btn">
Click Me
</ConditionalButton>
{/*
// 컴파일 에러: href와 onClick을 동시에 제공
<ConditionalButton href="/home" onClick={handleClick}>
Error Button
</ConditionalButton>
// Type '{ children: string; href: string; onClick: () => void; }'
// is not assignable to type 'ButtonProps'.
// Type '{ children: string; href: string; onClick: () => void; }'
// is not assignable to type 'AnchorButtonProps'.
// Types of property 'onClick' are incompatible.
// Type '() => void' is not assignable to type 'undefined'.
// 컴파일 에러: 아무것도 제공 안 함 (하지만 BaseButtonProps는 children만 필수이므로 이것만으로는 막기 어려움)
// 실제로는 HTML button 기본 동작을 원할 수 있으므로, onClick? 대신 onClick: required
// 또는 href?: string; onClick?: () => void; 로 하고 둘 다 없어도 허용할지 결정
// 여기서는 onClick/href 중 하나는 필수라는 가정을 위해 위와 같이 정의함
*/}
</div>
);
};
여기서 onClick?: never
와 href?: never
는 해당 속성이 존재해서는 안 됨을 의미합니다. 이렇게 하면 타입스크립트가 href
와 onClick
이 동시에 존재하는 경우를 감지하여 컴파일 에러를 발생시킵니다.
유틸리티 타입 활용
타입스크립트는 타입 변환 및 조작을 위한 다양한 유틸리티 타입을 내장하고 있습니다. 이를 React 컴포넌트의 Props나 State를 정의할 때 활용하면 코드의 중복을 줄이고 유연성을 높일 수 있습니다.
Partial<T>
: 모든 속성을 선택적으로
기존 인터페이스의 모든 속성을 선택적(optional
)으로 만들 때 사용합니다.
interface Product {
id: number;
name: string;
price: number;
category: string;
}
// Product의 모든 속성을 선택적으로 만듭니다.
type PartialProduct = Partial<Product>;
const updateProduct = (productId: number, changes: PartialProduct) => {
// ... 제품 업데이트 로직 (부분 업데이트 가능)
console.log(`Updating product ${productId} with changes:`, changes);
};
// 사용 예시
updateProduct(1, { price: 1250 }); // name이나 category 없이 price만 변경 가능
updateProduct(2, { name: 'Wireless Keyboard', category: 'Accessories' });
// updateProduct(3, { id: 3 }); // id는 Product에서만 필요하고 PartialProduct에서는 optional이다.
이것은 예를 들어, initialValues
를 Partial<FormValues>
로 정의하여 폼의 초기값이 모든 필드를 포함할 필요가 없을 때 유용합니다.
Pick<T, K>
: 특정 속성만 선택하기
T
타입에서 K
에 해당하는 속성들만 골라 새로운 타입을 만들 때 사용합니다.
interface UserProfile {
id: number;
name: string;
email: string;
address: string;
phone: string;
}
// UserProfile에서 'name'과 'email'만 선택
type UserContactInfo = Pick<UserProfile, 'name' | 'email'>;
interface ContactCardProps {
user: UserContactInfo;
}
const ContactCard: React.FC<ContactCardProps> = ({ user }) => {
return (
<div>
<h3>{user.name}</h3>
<p>Email: {user.email}</p>
</div>
);
};
// 사용 예시
const currentUser: UserProfile = {
id: 1,
name: 'John Doe',
email: 'john.doe@example.com',
address: '123 Main St',
phone: '555-1234'
};
// currentUser를 그대로 전달할 수 있습니다. 타입스크립트가 자동으로 Pick된 타입을 만족하는지 검사합니다.
<ContactCard user={currentUser} />;
// 혹은 명시적으로 Pick된 타입을 생성하여 전달할 수도 있습니다.
const pickedUser: UserContactInfo = { name: currentUser.name, email: currentUser.email };
<ContactCard user={pickedUser} />;
Pick
은 컴포넌트가 부모로부터 불필요한 모든 정보를 받는 대신, 필요한 정보만 명확히 받도록 인터페이스를 정의할 때 유용합니다.
Omit<T, K>
: 특정 속성 제외하기
T
타입에서 K
에 해당하는 속성들을 제외한 새로운 타입을 만들 때 사용합니다.
interface Post {
id: number;
title: string;
content: string;
authorId: number;
createdAt: Date;
}
// Post에서 'id'와 'createdAt'을 제외한 타입을 생성합니다.
type EditablePost = Omit<Post, 'id' | 'createdAt' | 'authorId'>;
interface PostEditorProps {
initialPost?: EditablePost;
onSave: (post: EditablePost) => void;
}
const PostEditor: React.FC<PostEditorProps> = ({ initialPost, onSave }) => {
const [title, setTitle] = useState(initialPost?.title || '');
const [content, setContent] = useState(initialPost?.content || '');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
onSave({ title, content }); // 저장 시 ID나 생성일시는 포함되지 않습니다.
};
return (
<form onSubmit={handleSubmit}>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="Title" />
<textarea value={content} onChange={e => setContent(e.target.value)} placeholder="Content"></textarea>
<button type="submit">Save Post</button>
</form>
);
};
Omit
은 API 응답 등에서 받은 객체에서 일부 속성만 제외하고 나머지를 Props로 사용하고 싶을 때 유용합니다.
제네릭 컴포넌트
제네릭 컴포넌트는 다양한 타입의 데이터를 다룰 수 있도록 일반화된(generic) 방식으로 작성된 컴포넌트입니다. 이는 여러 타입의 데이터를 보여주거나 처리하는 범용적인 컴포넌트를 만들 때 매우 유용합니다.
예시: 제네릭 리스트 컴포넌트
import React from 'react';
interface Item {
id: string | number;
[key: string]: any; // 다른 속성들도 허용
}
interface GenericListProps<T extends Item> { // T는 Item 타입을 확장해야 함
items: T[];
// 각 아이템을 렌더링하는 함수 (렌더 Props 패턴과 유사)
renderItem: (item: T) => React.ReactNode;
// 각 아이템의 고유 키를 반환하는 함수 (선택적)
getKey?: (item: T) => React.Key;
}
// 제네릭 함수형 컴포넌트 정의
function GenericList<T extends Item>({ items, renderItem, getKey }: GenericListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={getKey ? getKey(item) : item.id}>
{renderItem(item)}
</li>
))}
</ul>
);
}
export default GenericList;
// 사용 예시
interface User {
id: number;
name: string;
age: number;
}
interface Product {
id: string;
productName: string;
price: number;
}
const AppWithGenericList: React.FC = () => {
const users: User[] = [
{ id: 1, name: 'Alice', age: 30 },
{ id: 2, name: 'Bob', age: 24 },
];
const products: Product[] = [
{ id: 'p1', productName: 'Laptop', price: 1200 },
{ id: 'p2', productName: 'Mouse', price: 25 },
];
return (
<div>
<h3>User List:</h3>
<GenericList
items={users}
renderItem={(user) => (
<div>
<strong>{user.name}</strong> ({user.age} years old)
</div>
)}
getKey={(user) => user.id} // 숫자 id도 React.Key로 사용 가능
/>
<h3>Product List:</h3>
<GenericList
items={products}
renderItem={(product) => (
<div>
{product.productName} - ${product.price}
</div>
)}
// getKey는 생략해도 id 속성이 있기 때문에 기본적으로 item.id를 키로 사용
/>
</div>
);
};
GenericList
컴포넌트는 T extends Item
제네릭을 사용하여 items
배열의 요소 타입과 renderItem
함수의 인자 타입을 동적으로 지정할 수 있습니다. 이렇게 하면 User
배열이든 Product
배열이든 동일한 GenericList
컴포넌트를 재사용할 수 있으며, 타입스크립트가 각 아이템의 속성에 대한 정확한 타입 검사를 수행합니다.
타입 가드를 활용한 런타임 타입 검사
타입스크립트는 컴파일 시점에 타입을 검사하지만, 런타임에는 타입 정보가 사라집니다. 그러나 타입 가드를 사용하면 런타임에 특정 조건에 따라 변수의 타입을 좁힐 수 있으며, 이는 조건부 렌더링이나 데이터 처리 로직에서 유용합니다.
예시: 데이터 타입에 따른 렌더링
import React from 'react';
interface TextContent {
type: 'text';
value: string;
}
interface ImageContent {
type: 'image';
src: string;
alt: string;
}
type Content = TextContent | ImageContent;
// 타입 가드 함수
function isImageContent(content: Content): content is ImageContent {
return content.type === 'image';
}
interface ContentDisplayProps {
content: Content;
}
const ContentDisplay: React.FC<ContentDisplayProps> = ({ content }) => {
if (isImageContent(content)) {
// 이 블록 안에서 content는 ImageContent 타입으로 자동 추론됩니다.
return <img src={content.src} alt={content.alt} style={{ maxWidth: '100%', height: 'auto' }} />;
} else {
// 이 블록 안에서 content는 TextContent 타입으로 자동 추론됩니다.
return <p>{content.value}</p>;
}
};
// 사용 예시
const AppWithContentDisplay: React.FC = () => {
const textItem: TextContent = { type: 'text', value: 'This is a text content.' };
const imageItem: ImageContent = { type: 'image', src: 'https://via.placeholder.com/150', alt: 'Placeholder Image' };
return (
<div>
<h3>Content Display Examples</h3>
<ContentDisplay content={textItem} />
<ContentDisplay content={imageItem} />
</div>
);
};
export default AppWithContentDisplay;
isImageContent
함수는 content
의 type
속성을 확인하여 content
가 ImageContent
타입인지 여부를 런타임에 판단하고, 타입스크립트 컴파일러에게 해당 블록 내에서 content
의 타입을 좁히도록 지시합니다. 이는 유니온 타입(Content
)을 다룰 때 매우 강력한 패턴입니다.
요약
React 애플리케이션에서 타입스크립트의 고급 타입 패턴을 활용하는 것은 코드의 견고성, 유연성, 그리고 유지보수성을 한 단계 더 끌어올립니다.
- 조건부 타입은 Props의 복잡한 의존성을 선언적으로 표현하여 타입 안전성을 높입니다.
- 유틸리티 타입 (
Partial
,Pick
,Omit
등) 은 기존 타입을 재활용하고 변형하여 Props와 State 정의의 중복을 줄이고 명확성을 더합니다. - 제네릭 컴포넌트는 다양한 데이터 타입에 대응할 수 있는 재사용 가능한 컴포넌트를 만드는 데 필수적입니다.
- 타입 가드는 런타임에 타입을 안전하게 좁히고 조건부 로직을 작성하는 데 도움을 줍니다.
이러한 고급 패턴들을 익히면 React와 타입스크립트의 시너지를 극대화하여 대규모의 복잡한 애플리케이션도 자신감 있게 개발할 수 있습니다.