TypeScript를 사용하고있지만, 숙련도가 부족하다고 느끼는 가장 큰 포인트가 제너릭을 잘 몰라서인 것 같다.
그런 김에, 제너릭의 다양한 용례를 통해 제너릭을 정리해보려한다.
제너릭(Generic)
제너릭(Generic)은 TypeScript에서 매우 강력한 기능으로, 코드의 재사용성을 높이고 타입을 더욱 유연하게 다룰 수 있다.
제너릭을 사용하면 함수, 클래스, 인터페이스 등을 타입에 의존하지 않으면서도 타입 안전하게 작성할 수 있다.
1. 기본적인 제너릭 함수
제너릭 함수는 함수의 입력 타입과 반환 타입을 유연하게 지정할 수 있다.
function identity<T>(arg: T): T {
return arg;
}
// 사용 예시
const num = identity<number>(42); // T는 number로 추론됨
const str = identity<string>('Hello'); // T는 string으로 추론됨
2. 제너릭 인터페이스
제너릭 인터페이스를 사용하면 다양한 타입을 지원하는 구조를 정의할 수 있다.
interface KeyValuePair<K, V> {
key: K;
value: V;
}
// 사용 예시
const kvp1: KeyValuePair<string, number> = { key: 'age', value: 30 };
const kvp2: KeyValuePair<number, string> = { key: 1, value: 'one' };
3. 제너릭 클래스
제너릭 클래스를 사용하면 다양한 타입을 지원하는 클래스를 정의할 수 있습니다.
class DataStorage<T> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
this.data = this.data.filter(i => i !== item);
}
getItems(): T[] {
return [...this.data];
}
}
// 사용 예시
const textStorage = new DataStorage<string>();
textStorage.addItem('Hello');
textStorage.addItem('World');
textStorage.removeItem('Hello');
console.log(textStorage.getItems()); // ['World']
const numberStorage = new DataStorage<number>();
numberStorage.addItem(1);
numberStorage.addItem(2);
numberStorage.removeItem(1);
console.log(numberStorage.getItems()); // [2]
4. 제너릭 제약 조건
제너릭 타입에 제약 조건을 추가하여 특정 타입만 허용하도록 할 수 있다.
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length);
}
// 사용 예시
logLength('Hello'); // 5
logLength([1, 2, 3]); // 3
// logLength(42); // 오류: 'number' 타입에는 'length' 속성이 없음
5. Zustand + Tanstack-query를 활용한 CRUD 예제
제너릭을 사용하여 타입을 유연하게 지정할 수 있는 React 컴포넌트를 작성할 수 있다.
store.ts (전역 상태 관리용)
// store.ts
import create from 'zustand';
// 필요한 인터페이스 정의
interface Post {
id: number;
title: string;
content: string;
}
interface PostStore {
posts: Post[];
addPost: (post: Post) => void;
updatePost: (post: Post) => void;
deletePost: (id: number) => void;
}
// addPost, updatePost, deletePost 함수는 상태를 업데이트하는 역할
export const usePostStore = create<PostStore>((set) => ({
posts: [],
addPost: (post) => set((state) => ({ posts: [...state.posts, post] })),
updatePost: (post) => set((state) => ({
posts: state.posts.map((p) => (p.id === post.id ? post : p)),
})),
deletePost: (id) => set((state) => ({
posts: state.posts.filter((post) => post.id !== id),
})),
}));
api.ts (데이터 페칭용)
// api.ts
import axios from 'axios';
import { useQuery, useMutation, useQueryClient } from 'react-query';
const fetchPosts = async () => {
const { data } = await axios.get('/api/posts');
return data;
};
const addPost = async (post: { title: string; content: string }) => {
const { data } = await axios.post('/api/posts', post);
return data;
};
const updatePost = async (post: { id: number; title: string; content: string }) => {
const { data } = await axios.put(`/api/posts/${post.id}`, post);
return data;
};
const deletePost = async (id: number) => {
const { data } = await axios.delete(`/api/posts/${id}`);
return data;
};
// usePosts는 게시글 목록을 가져온다.
export const usePosts = () => {
return useQuery('posts', fetchPosts);
};
// useAddPost, useUpdatePost, useDeletePost는 각각 게시글 추가, 수정, 삭제를 처리
export const useAddPost = () => {
const queryClient = useQueryClient();
return useMutation(addPost, {
onSuccess: () => {
queryClient.invalidateQueries('posts');
},
});
};
export const useUpdatePost = () => {
const queryClient = useQueryClient();
return useMutation(updatePost, {
onSuccess: () => {
queryClient.invalidateQueries('posts');
},
});
};
export const useDeletePost = () => {
const queryClient = useQueryClient();
return useMutation(deletePost, {
onSuccess: () => {
queryClient.invalidateQueries('posts');
},
});
};
App.tsx (구현할 컴포넌트)
// App.tsx
import React, { useState } from 'react';
import { usePosts, useAddPost, useUpdatePost, useDeletePost } from './api';
import { usePostStore } from './store';
const App: React.FC = () => {
const { data: posts } = usePosts();
const addPostMutation = useAddPost();
const updatePostMutation = useUpdatePost();
const deletePostMutation = useDeletePost();
const addPostToStore = usePostStore((state) => state.addPost);
const updatePostInStore = usePostStore((state) => state.updatePost);
const deletePostFromStore = usePostStore((state) => state.deletePost);
const [newPost, setNewPost] = useState({ title: '', content: '' });
const handleAddPost = () => {
addPostMutation.mutate(newPost, {
onSuccess: (data) => {
addPostToStore(data);
setNewPost({ title: '', content: '' });
},
});
};
const handleUpdatePost = (post: { id: number; title: string; content: string }) => {
updatePostMutation.mutate(post, {
onSuccess: (data) => {
updatePostInStore(data);
},
});
};
const handleDeletePost = (id: number) => {
deletePostMutation.mutate(id, {
onSuccess: () => {
deletePostFromStore(id);
},
});
};
return (
<div>
<h1>게시글 목록</h1>
<ul>
{posts?.map((post: { id: number; title: string; content: string }) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
<button onClick={() => handleUpdatePost({ ...post, title: 'Updated Title' })}>수정</button>
<button onClick={() => handleDeletePost(post.id)}>삭제</button>
</li>
))}
</ul>
<div>
<h2>새 게시글 추가</h2>
<input
type="text"
value={newPost.title}
onChange={(e) => setNewPost({ ...newPost, title: e.target.value })}
placeholder="제목"
/>
<textarea
value={newPost.content}
onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
placeholder="내용"
/>
<button onClick={handleAddPost}>추가</button>
</div>
</div>
);
};
export default App;
다양한 상황에서 제너릭을 적용해보고, 각 상황에 맞는 최적의 제너릭 사용법을 익혀봐야겠다.