타입 정의는 열심히 했는데 왜 유지보수는 점점 불편해질까?

서준·2025년 8월 7일
4

요즘 프론트엔드 개발에서 타입스크립트는 선택이 아닌 기본값이죠 . 새로운 요구사항을 구현할 때마다 자연스럽게 type이나 interface를 정의하고, API 응답에 맞춰 타입을 매핑합니다.

하지만 프로젝트가 커지고 일정에 쫓기기 시작하면, 어느 순간부터 이상한 징후들이 생기기 시작합니다.

  • 분명 타입이 있는데도 타입 오류가 납니다.
  • 똑같은 구조의 타입이 여기저기 흩어져 있습니다.
  • 타입 하나 수정했을 뿐인데 여러 파일이 깨집니다.

사실 원인은 명확합니다 . 처음엔 단순하게 출발했던 타입 정의가, 모든 케이스를 한 타입으로 처리하려고 하면서 점점 무거워졌기 때문입니다.

이번 글에서는 코드 예제를 기반으로, 확장 가능한 타입 설계 방식을 소개하려고 합니다 타입을 "에러 안 나게"가 아니라, 명확하게 나누고 조합함으로써 유지보수성과 재사용성을 확보하는 방식을 정리했어요.

📌 게시판 구성

이해를 돕기 위해 게시판 구성을 위한 가상의 상황을 가정하며 설명하겠습니다.
처음에는 간단하게 게시글을 보여주는 것부터 시작했죠.

1주차: 기본 게시글 목록 구현

기본 게시글 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주차 결과: 깔끔하게 구현 완료! 타입 오류도 없고 코드도 간결합니다.

2주차: 기획자의 새로운 요구사항

"각 게시글마다 댓글 개수를 보여주면 좋겠어요. 사용자들이 어떤 글이 인기 있는지 알 수 있도록요."

요구사항 반영을 위해 게시글 목록 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주차 결과: 문제없이 구현 완료! 타입도 잘 맞고 기획 요구사항도 충족했습니다.

3주차: 상세 페이지 개발 시작

"이제 게시글을 클릭하면 상세 페이지로 이동하도록 해주세요. 상세 페이지에는 게시글 내용과 실제 댓글들이 보여야 해요."

상세 페이지용 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>
  );
};

4주차: 새로운 요구사항의 등장

"메인 페이지에 인기 게시글 섹션을 만들어주세요. 여기서는 제목만 간단하게 보여주면 되고, 댓글 수는 필요 없어요. 대신 좋아요 수를 보여주면 좋겠어요."

인기 게시글 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>
  );
};

📈 결과: 타입 안전성과 유지보수성 확보

Before (문제 상황)

// ❌ 모든 필드가 옵셔널인 거대한 타입
type Post = {
  id: number;
  title: string;
  body: string;
  userId: number;
  commentsCount?: number;  // 언제 있는지 불분명
  comments?: Comment[];    // 언제 있는지 불분명
  likesCount?: number;     // 언제 있는지 불분명
};

// ❌ 매번 방어 코드 필요
<span>댓글 {post.commentsCount || 0}</span>

After (해결책 적용)

// ✅ 목적이 명확한 타입들
export type PostList = Post & { commentsCount: number };
export type PostWithLikeCount = Post & { likesCount: number };
export type PostWithLikeCountAndComments = Post & { commentsCount: number; comments: Comment[] };

// ✅ 타입 안전성 보장
<span>댓글 {post.commentsCount}</span> // 항상 존재함이 보장됨

장점 정리

  1. 타입 안전성: 각 컴포넌트에서 필요한 필드가 항상 존재함을 타입 레벨에서 보장
  2. 명확한 의도: PostWithLikeCount, PostWithLikeCountAndComments 등 이름만 봐도 어디서 사용하는지 알 수 있음
  3. 유지보수성: 새로운 요구사항이 생겨도 기존 타입을 건드리지 않고 새로운 조합 타입만 추가
  4. 재사용성: 기본 Post 타입을 베이스로 다양한 상황에 맞는 확장 타입 생성 가능
  5. 런타임 안전성: undefined 체크나 방어 코드가 불필요

📘 마무리: 타입도 분리와 조합이 필요하다

어찌 보면 당연한 말이죠, 하지만 타입스크립트 사용에 익숙지 않거나 일정에 쫓겨 급박히 개발하다 보면 쉽게 범할 수 있는 실수입니다. 저 역시 “일단 타입부터 맞춰 놓고 나중에 정리하자”는 마음으로, 다양한 케이스를 하나의 거대한 타입에 우겨넣었던 경험이 많습니다.

그렇게 쌓인 타입은 어느새 비대해지고, 변경이 생길 때마다 여기저기서 방어 코드를 쓰게 되며, 결국 타입스크립트의 가장 큰 장점인 ‘컴파일 타임의 안정성’을 스스로 무력화시키는 결과를 만들게 되죠.

필요한 데이터만 정확히 받도록 하고, 그 상황에 맞는 타입을 조합해 나가는 것. 처음엔 조금 번거로워 보여도, 결국엔 유지보수가 편하고, 일일이 타입 정의 파일을 확인하는 시간도 줄어들 겁니다.

profile
하나씩 쌓아가는 재미

0개의 댓글