
요즘 프론트엔드 개발에서 타입스크립트는 선택이 아닌 기본값이죠 . 새로운 요구사항을 구현할 때마다 자연스럽게 type이나 interface를 정의하고, API 응답에 맞춰 타입을 매핑합니다.
하지만 프로젝트가 커지고 일정에 쫓기기 시작하면, 어느 순간부터 이상한 징후들이 생기기 시작합니다.
사실 원인은 명확합니다 . 처음엔 단순하게 출발했던 타입 정의가, 모든 케이스를 한 타입으로 처리하려고 하면서 점점 무거워졌기 때문입니다.
이번 글에서는 코드 예제를 기반으로, 확장 가능한 타입 설계 방식을 소개하려고 합니다 타입을 "에러 안 나게"가 아니라, 명확하게 나누고 조합함으로써 유지보수성과 재사용성을 확보하는 방식을 정리했어요.
이해를 돕기 위해 게시판 구성을 위한 가상의 상황을 가정하며 설명하겠습니다.
처음에는 간단하게 게시글을 보여주는 것부터 시작했죠.
기본 게시글 API 스펙은 다음과 같았습니다:
{
"id": 1,
"title": "첫 번째 게시글",
"body": "게시글 내용입니다.",
"userId": 123
}
// types.ts
type Post = {
id: number;
title: string;
body: string;
userId: number;
};
요구사항에 맞춰 타입을 정의하고 게시글 목록 컴포넌트도 만들었습니다.
// PostList.tsx
export const PostList = ({ posts }: { posts: Post[] }) => {
return (
<div>
{posts.map((post) => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</div>
))}
</div>
);
};
✅ 1주차 결과: 깔끔하게 구현 완료! 타입 오류도 없고 코드도 간결합니다.
"각 게시글마다 댓글 개수를 보여주면 좋겠어요. 사용자들이 어떤 글이 인기 있는지 알 수 있도록요."
요구사항 반영을 위해 게시글 목록 API에서 댓글 개수도 함께 내려주기로 했습니다
{
"id": 1,
"title": "첫 번째 게시글",
"body": "게시글 내용입니다.",
"userId": 123,
"commentsCount": 5
}
간단히 기존 Post 타입에 필드를 추가했습니다
// ❌ 단순한 해결책
type Post = {
id: number;
title: string;
body: string;
userId: number;
commentsCount: number; // ✅ 추가
};
그리고 컴포넌트도 수정했죠
// PostList.tsx
export const PostList = ({ posts }: { posts: Post[] }) => {
return (
<div>
{posts.map((post) => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
<span>댓글 {post.commentsCount}개</span> {/* ✅ 추가 */}
</div>
))}
</div>
);
};
✅ 2주차 결과: 문제없이 구현 완료! 타입도 잘 맞고 기획 요구사항도 충족했습니다.
"이제 게시글을 클릭하면 상세 페이지로 이동하도록 해주세요. 상세 페이지에는 게시글 내용과 실제 댓글들이 보여야 해요."
상세 페이지용 API를 따로 추가하고 해당 API는 댓글 정보까지 포함해서 내려줍니다
{
"id": 1,
"title": "첫 번째 게시글",
"body": "게시글 내용입니다.",
"userId": 123,
"commentsCount": 2,
"comments": [
{
"id": 101,
"userId": 456,
"text": "좋은 글이네요!",
"createdAt": "2024-01-15T10:30:00Z"
}
]
}
기본 API에서 댓글 정보만 추가됐으니 기존 Post 타입에 comments를 추가하려고 시도합니다
// ❌ 이렇게 하면...
type Post = {
id: number;
title: string;
body: string;
userId: number;
commentsCount: number;
comments: Comment[]; // 추가?
};
하지만 기존 Post type에 comments 추가 후 PostList로 돌아가보면
// PostList.tsx - 목록 페이지
export const PostList = ({ posts }: { posts: Post[] }) => {
return (
<div>
{posts.map((post) => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
<span>댓글 {post.commentsCount}개</span>
{/* ❌ comments 필드는 사용하지 않지만 타입상 필수 */}
</div>
))}
</div>
);
};
PostList 페이지에서는 comments 데이터가 실제로 필요하지 않고 API에서도 내려주지 않는데, 타입상으로는 필수가 됩니다. 그렇다고 옵셔널(comments?)로 처리하면, 상세 페이지에서 undefined 체크를 계속 해야 하죠.
기본 타입과 확장 타입을 분리해서 처리합니다:
// types.ts - 기본 Post 타입정의
export type Post = {
id: number;
title: string;
body: string;
userId: number;
};
export type Comment = {
id: number;
userId: number;
text: string;
createdAt: string;
};
// 상세 페이지 전용 확장 타입
export type PostWithLikeCountAndComments = Post & {
commentsCount: number;
comments: Comment[];
};
// PostDetail.tsx - 상세 페이지 컴포넌트
export const PostDetail = ({ post }: { post: PostWithLikeCountAndComments }) => {
return (
<div>
<h1>{post.title}</h1>
<p>{post.body}</p>
<div>
<h3>댓글 ({post.commentsCount}개)</h3>
{post.comments.map((comment) => ( // 타입 보장됨
<div key={comment.id}>
<p>{comment.text}</p>
<small>{comment.createdAt}</small>
</div>
))}
</div>
</div>
);
};
"메인 페이지에 인기 게시글 섹션을 만들어주세요. 여기서는 제목만 간단하게 보여주면 되고, 댓글 수는 필요 없어요. 대신 좋아요 수를 보여주면 좋겠어요."
인기 게시글 API:
{
"id": 1,
"title": "첫 번째 게시글",
"body": "게시글 내용입니다.",
"userId": 123,
"likesCount": 15
}
여기서도 모든 케이스를 하나의 타입으로 처리하려고 시도하려고 한다면?
// ❌ 모든 케이스를 하나로 처리하려는 시도
type Post = {
id: number;
title: string;
body: string;
userId: number;
commentsCount?: number; // 어떨 때는 있고 어떨 때는 없고...
comments?: Comment[]; // 상세 페이지에서만 필요
likesCount?: number; // 인기 게시글에서만 필요
};
모든 필드가 옵셔널이 되어버리고, 컴포넌트에서는 언제나 undefined 체크를 해야 합니다. 타입을 정의하면서 갖는 장점들이 퇴색되버리죠.
✅ 타입 확장성을 고려한 해결책
각 용도별로 명확한 네이밍과 함께 타입을 정의합니다:
// types.ts - 각 용도별 확장 타입 정의
export type PostList = Post & {
commentsCount: number;
};
export type PostWithLikeCount = Post & {
likesCount: number;
};
export type PostWithLikeCountAndComments = Post & {
commentsCount: number;
comments: Comment[];
};
// PostList.tsx
export const PostList = ({ posts }: { posts: PostList[] }) => {
return (
<div>
{posts.map((post) => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
<span>댓글 {post.commentsCount}개</span> {
</div>
))}
</div>
);
};
// PopularPosts.tsx
export const PopularPosts = ({ posts }: { posts: PostWithLikeCount[] }) => {
return (
<div>
<h2>인기 게시글</h2>
{posts.map((post) => (
<div key={post.id}>
<h3>{post.title}</h3>
<span>❤️ {post.likesCount}</span>
</div>
))}
</div>
);
};
// ❌ 모든 필드가 옵셔널인 거대한 타입
type Post = {
id: number;
title: string;
body: string;
userId: number;
commentsCount?: number; // 언제 있는지 불분명
comments?: Comment[]; // 언제 있는지 불분명
likesCount?: number; // 언제 있는지 불분명
};
// ❌ 매번 방어 코드 필요
<span>댓글 {post.commentsCount || 0}개</span>
// ✅ 목적이 명확한 타입들
export type PostList = Post & { commentsCount: number };
export type PostWithLikeCount = Post & { likesCount: number };
export type PostWithLikeCountAndComments = Post & { commentsCount: number; comments: Comment[] };
// ✅ 타입 안전성 보장
<span>댓글 {post.commentsCount}개</span> // 항상 존재함이 보장됨
PostWithLikeCount, PostWithLikeCountAndComments 등 이름만 봐도 어디서 사용하는지 알 수 있음Post 타입을 베이스로 다양한 상황에 맞는 확장 타입 생성 가능undefined 체크나 방어 코드가 불필요어찌 보면 당연한 말이죠, 하지만 타입스크립트 사용에 익숙지 않거나 일정에 쫓겨 급박히 개발하다 보면 쉽게 범할 수 있는 실수입니다. 저 역시 “일단 타입부터 맞춰 놓고 나중에 정리하자”는 마음으로, 다양한 케이스를 하나의 거대한 타입에 우겨넣었던 경험이 많습니다.
그렇게 쌓인 타입은 어느새 비대해지고, 변경이 생길 때마다 여기저기서 방어 코드를 쓰게 되며, 결국 타입스크립트의 가장 큰 장점인 ‘컴파일 타임의 안정성’을 스스로 무력화시키는 결과를 만들게 되죠.
필요한 데이터만 정확히 받도록 하고, 그 상황에 맞는 타입을 조합해 나가는 것. 처음엔 조금 번거로워 보여도, 결국엔 유지보수가 편하고, 일일이 타입 정의 파일을 확인하는 시간도 줄어들 겁니다.