keepmhwn's blog
← 목록으로
타입스크립트생각정리

타입을 정의할 때 생각하는 것들

2025년 8월 12일

타입스크립트를 사용하다 보면 컴파일 에러가 발생하지 않고 대부분의 타입이 정확히 표현되어 있는 것만으로도 잘 정의했다는 생각이 들곤 합니다. 하지만 프로덕트를 오랫동안 유지보수하다 보면 타입은 생각보다 자주 변경됩니다. 새로운 요구사항이 생기기도 하고 외부 API가 변경되기도 하며 처음에는 몰랐던 도메인 규칙이 추가되기도 합니다.

저 역시 처음에는 필요한 형태만 표현하는 수준에서 타입을 정의했습니다. 하지만 시간이 지나면서 string으로 정의했던 값이 사실은 몇 가지 상태만 가져야 했고, 외부 API 응답 타입을 그대로 사용했다가 수정 범위가 커지는 경험을 여러 번 했습니다.

이런 경험을 반복하면서 타입은 단순히 자동완성을 위한 정보가 아니라 시스템이 무엇을 허용하고 무엇을 허용하지 않을지를 설계하는 도구라는 생각을 하게 되었습니다. 이번 글에서는 타입을 정의할 때 제가 항상 스스로에게 던지는 질문들을 정리해보려고 합니다.

1. 이 타입은 무엇을 보호하기 위해 존재하는가?

타입을 정의하는 가장 큰 목적은 코드 작성이 편하도록 자동완성을 제공하는 것이 아닙니다. 더 중요한 목적은 잘못된 사용을 막는 것입니다. 타입을 작성하기 전에 항상 "이 타입은 무엇을 보호하기 위해 존재하지?"라는 질문을 합니다. 보호해야 하는 대상은 다양합니다.

  • 잘못된 값으로부터 보호하는가?
  • 잘못된 함수 사용으로부터 보호하는가?
  • 잘못된 상태 전이를 막기 위한 것인가?
  • 다른 개발자가 API를 오용하는 것을 막기 위한 것인가?

예를 들어 아래 코드를 살펴보겠습니다.

type User = {
  role: string;
}

처음에는 문제가 없어 보입니다. 하지만 실제로 시스템에서는 "admin""user" 두 가지 값만 허용한다면 어떨까요?

type User = {
  role: "admin" | "user";
}

이제 "manager"처럼 의도하지 않은 값은 컴파일 단계에서 바로 발견할 수 있습니다. 타입을 정의할 때는 무엇을 표현할 것인가보다 무엇을 막을 것인가를 먼저 고민하는 것이 더 중요하다고 생각합니다.

2. 어디까지 엄격해야 하는가?

타입을 엄격하게 정의한다는 것은 단순히 타입을 더 세분화하는 것이 아닙니다. 엄격하다는 것은 허용되는 값과 구조, 변경 가능성, 상태 등을 제한하는 것입니다.

예를 들어 아래 코드를 보면 UserB가 더 엄격한 타입입니다.

type UserA = {
  role: string;
}

type UserB = {
  role: "admin" | "user";
}

또는 아래 코드처럼 객체 자체가 변경되지 않도록 제한할 수도 있습니다.

type User = Readonly<{
  id: number;
  name: string;
}>

그렇다면 모든 타입을 엄격하게 만드는 것이 좋을까요? 꼭 그렇지는 않습니다. 엄격해야 하는 곳은 주로 시스템의 경계 입니다. 예를 들어 외부 API 응답, 사용자 입력, 데이터베이스와 맞닿은 부분, 상태 전이가 발생하는 도메인같은 지점에서는 잘못된 데이터가 내부 시스템 전체로 퍼질 수 있기 때문에 가능한 한 엄격하게 정의하는 것이 좋습니다.

반면에 함수 내부에서 잠시 사용하는 객체, 단순히 데이터를 변환하는 중간 타입, 구현 세부사항에 가까운 코드 등은 너무 엄격한 타입이 오히려 개발 속도와 가독성을 떨어뜨릴 수도 있습니다.

그래서 결론적으로 경계에 가까울수록 엄격하게 정의하고 구현에 가까울수록 유연하게 설계해야 한다는 것입니다.

3. 외부 타입과 내부 타입을 분리해야 하는가?

실무에서는 외부 시스템의 데이터를 그대로 사용하는 경우가 많습니다. 예를 들어 API가 아래와 같이 응답한다고 가정해보겠습니다.

type UserResponse = {
  user_name: string;
}

이를 프로젝트 전체에서 그대로 사용하면 API가 변경될 때마다 모든 코드가 영향을 받게 됩니다. 대신 내부에서는 프로젝트가 이해하기 쉬운 형태로 변환하여 사용하는 편이 좋습니다.

type User = {
  name: string;
}

그리고 API 응답을 내부 모델로 변환합니다.

function toUser(response: UserResponse): User {
  return {
    name: response.user_name,
  };
}

이렇게 하면 외부 API가 변경되더라도 수정해야 하는 곳은 변환 함수 정도로 제한됩니다. 외부 시스템의 복잡함이 내부로 퍼지지 않도록 하는 것도 타입 설계의 중요한 역할이라고 생각합니다.

4. 변경되었을 때 수정해야 할 곳을 알려주는가?

소프트웨어는 계속 변경됩니다. 요구사항이 바뀌면 타입도 함께 변경됩니다. 좋은 타입은 변경을 막는 것이 아니라 변경이 필요한 곳을 컴파일 단계에서 알려주는 타입입니다. 예를 들어 프론트엔드에서 데이터를 캐싱하기 위해 다음과 같은 형식의 key를 사용한다고 가정해보겠습니다.

{데이터의 종류}:{데이터의 이름}:{timestamp}

이를 단순히 string으로 정의하면 어떤 문자열이든 허용됩니다.

type CacheKey = string;

이 경우에는 아래 코드가 모두 올바른 것으로 인식됩니다.

const key1: CacheKey = "user:john:12345";
const key2: CacheKey = "abc";
const key3: CacheKey = "hello";

하지만 실제로 시스템에서는 항상 아래 형식을 따라야 합니다.

{데이터의 종류}:{데이터의 이름}:{timestamp}

그렇다면 템플릿 리터럴 타입을 사용하여 의도를 표현할 수 있습니다.

type DataType = "user" | "post";
type Timestamp = number;

type CacheKey = `${DataType}:${string}:${Timestamp}`;

이제 형식에 맞지 않는 문자열은 컴파일 단계에서 오류가 발생합니다.

const key: CacheKey = "user:john:12345"; // ✅
const key2: CacheKey = "hello";          // ❌

여기서 중요한 점은 요구사항이 변경되었을 때입니다. 예를 들어 key 형식이 다음과 같이 변경되었다고 가정해보겠습니다.

{프로젝트}:{데이터의 종류}:{데이터의 이름}:{timestamp}

타입을 아래와 같이 수정합니다.

type Project = "admin" | "service";

type CacheKey =
  `${Project}:${DataType}:${string}:${Timestamp}`;

그러면 기존 형식을 사용하던 모든 코드에서 컴파일 에러가 발생합니다.

const key: CacheKey =
  "user:john:12345"; // ❌

덕분에 새로운 형식에 맞게 수정해야 하는 모든 위치를 쉽게 찾을 수 있습니다. 반대로 CacheKey를 단순히 string으로 정의했다면 타입은 변경할 것이 없고, 기존 코드도 모두 컴파일됩니다. 하지만 런타임에서 잘못된 key를 생성하거나 조회하는 버그가 발생할 가능성이 높습니다. 즉, 좋은 타입은 현재의 실수를 막는 것뿐만 아니라 미래의 변경을 안전하게 만들어 줍니다.


마치며

예전에는 타입을 "데이터의 형태를 표현하는 문법" 정도로 생각했습니다. 하지만 프로젝트를 오래 운영할수록 타입은 단순한 문법이 아니라 시스템의 규칙을 표현하는 설계 도구라는 생각이 들었습니다.

그래서 새로운 타입을 정의할 때마다 다음 네 가지 질문을 스스로에게 던집니다.

  • 이 타입은 무엇을 보호하기 위해 존재하는가?
  • 어디까지 엄격해야 하는가?
  • 외부 타입과 내부 타입을 분리해야 하는가?
  • 변경되었을 때 수정해야 할 곳을 알려주는가?

정답은 프로젝트마다 다를 수 있습니다. 하지만 이런 질문들을 한 번 더 고민하는 것만으로도 타입은 단순한 자동완성 정보를 넘어, 시스템을 안전하게 유지하는 중요한 설계 도구가 될 수 있다고 생각합니다.