[TYPESCRIPT] 대규모 TypeScript 프로젝트 타입 설계 전략: 운영에서 흔들리지 않게 만드는 기준

프로젝트가 커지면 타입은 자연스럽게 늘어납니다. 처음에는 “안전하게 잡아두면 좋다” 수준이지만, 도메인과 기능이 쌓이면서 타입이 코드의 일부가 아니라 코드의 중심처럼 느껴지는 순간이 옵니다.

 

이때부터 문제가 바뀝니다. 타입이 부족해서 문제가 생기기보다, 타입이 많아서 유지가 어려운 상태가 됩니다. 잘못 설계된 타입은 개발 속도를 떨어뜨리고, 리팩터링 비용을 크게 만들고, 운영 장애 대응에서도 시간을 잡아먹습니다.

 

“타입을 어떻게 더 정교하게 만들까”보다, 대규모 코드베이스에서 오래 버티는 타입 설계가 어떤 모습인지, 왜 그런 선택을 하게 되는지 위주로 정리합니다.

 

배경: 타입은 설계가 아니라 경계다

대규모 프로젝트에서 타입은 문법 기능이 아니라 경계 도구로 쓰입니다. 레이어 간 계약을 고정하고, 변경의 영향을 국소화하고, 잘못된 사용을 컴파일 단계에서 막는 역할입니다.

 

반대로 타입을 “도메인 모델의 완벽한 반영”처럼 다루기 시작하면, 현실과 맞지 않는 부분이 쌓입니다. 실무에서는 예외 케이스가 계속 생기고, 외부 연동 포맷은 바뀌고, 레거시 데이터가 섞입니다.

 

이런 환경에서 타입을 과하게 정밀하게 만들면, 타입이 안전망이 아니라 변경을 막는 장벽으로 바뀝니다.

 

전략 1: 타입을 “레이어별로” 다른 목적으로 둔다

대규모 프로젝트에서는 한 타입이 모든 곳에서 그대로 쓰이지 않는 편이 안전합니다. 대표적으로 다음 레이어는 요구가 다릅니다.

 

  • API 입출력(클라이언트/외부 연동): 불완전한 입력을 전제로 함
  • 서비스/도메인 레이어: 검증된 데이터, 의도가 명확해야 함
  • DB 레이어: 스키마 중심, null/기본값/레거시를 포함

 

이 레이어를 하나의 타입으로 묶으면, 어느 레이어에서도 정확하지 않은 타입이 됩니다. 특히 API DTO를 도메인 모델로 그대로 흘려보내는 구조는 처음에는 편하지만, 시간이 지나면 변경이 전파되는 방식이 됩니다.

 

레이어 분리 예시


// API 입력 DTO: 외부 입력은 불완전함을 전제로 한다
export type CreateUserRequestDto = {
  email?: string;
  name?: string;
};

// 도메인 입력: 이 레이어부터는 필수값이 명확해야 한다
export type CreateUserCommand = {
  email: string;
  name: string;
};

// DB 모델: 스키마/레거시 기준을 반영한다
export type UserRow = {
  id: string;
  email: string | null;
  name: string;
  created_at: Date;
};

 

DTO와 도메인 타입을 분리해두면, API 스펙 변경이 서비스 내부로 바로 번지지 않습니다. 반대로 이걸 하나로 합치면, 한쪽 요구 때문에 전체 타입이 흔들립니다.

 

전략 2: “공통 타입”을 늘리기 전에, 공통이 무엇인지 확인한다

대규모 프로젝트에서 흔히 보게 되는 안티패턴은 “shared/types에 모든 걸 넣는 것”입니다. 처음에는 관리가 쉬워 보이지만, 몇 달만 지나면 shared가 사실상 전역 공간이 됩니다.

 

공통 타입은 정말로 공통일 때만 남습니다. 다음 조건을 만족하지 않으면 공통으로 빼는 순간 결합이 생깁니다.

 

  • 여러 패키지/모듈이 같은 의미로 사용한다
  • 한 쪽이 바뀌어도 다른 쪽이 즉시 따라가야 한다
  • 용어와 필드 의미가 팀 간에 합의돼 있다

 

이 조건이 모호하면, 공통이 아니라 “우연히 비슷한 형태”일 가능성이 높습니다.

 

전략 3: 타입 계산은 “경계에서만” 쓰고, 내부는 단순하게 둔다

조건부 타입, 매핑 타입, 템플릿 리터럴 타입 같은 고급 기능은 강력합니다. 다만 대규모 프로젝트에서는 이런 타입 계산이 내부로 침투하면 비용이 급격히 늘어납니다.

 

현실적으로 오래 버티는 방식은 이렇습니다.

  • API/ORM/프레임워크 같은 경계 레이어에서 타입 계산을 사용
  • 도메인 로직 내부는 읽기 쉬운 명시적 타입을 유지

 

도메인 로직은 자주 수정되고, 온보딩 대상도 많습니다. 여기에 타입 퍼즐이 들어가면 유지 비용이 바로 올라갑니다.

 

전략 4: “확실한 것”과 “변할 수 있는 것”을 타입에서 구분한다

실무에서는 데이터가 항상 완벽하지 않습니다. 그런데 타입이 너무 확정적이면, 불완전한 입력을 강제로 맞추려고 타입 단언(as)을 늘리게 됩니다.

 

이때부터 타입은 안전망이 아니라 형식적인 장치가 됩니다. 그래서 다음 구분이 중요합니다.

  • 검증 전 데이터: optional/unknown을 포함해 현실을 반영
  • 검증 후 데이터: 좁은 타입으로 고정

 

검증 전/후 타입 분리 예시


export type RawRequest = {
  userId?: unknown;
};

export type ValidRequest = {
  userId: string;
};

export function validate(raw: RawRequest): ValidRequest {
  if (typeof raw.userId !== 'string') {
    throw new Error('invalid userId');
  }
  return { userId: raw.userId };
}

 

검증을 통과한 순간부터 타입이 좁아지도록 만드는 편이, 타입 단언이 퍼지는 걸 막는 데 도움이 됩니다.

 

전략 5: enum보다 유니온 리터럴 + as const를 더 자주 보게 된다

대규모 코드에서는 enum이 오히려 애매한 위치가 되는 경우가 많습니다. 런타임 값이 생기고, 번들 결과나 직렬화 포맷과의 결합이 생깁니다.

 

대신 유니온 리터럴과 as const 조합이 자주 남습니다.

 


export const ORDER_STATUS = {
  CREATED: 'CREATED',
  PAID: 'PAID',
  CANCELED: 'CANCELED',
} as const;

export type OrderStatus = typeof ORDER_STATUS[keyof typeof ORDER_STATUS];

 

이 방식은 런타임과 타입의 경계를 비교적 명확히 유지하면서, 문자열 포맷을 그대로 계약으로 쓸 수 있습니다. 다만 팀/환경에 따라 enum을 더 선호하는 경우도 있습니다.

 

전략 6: 반환 타입을 과하게 좁히려다 생기는 복잡함을 경계한다

대규모 코드에서는 반환 타입을 지나치게 정밀하게 만들면, 조금만 변경해도 타입이 연쇄적으로 깨집니다. 특히 제네릭과 조건부 타입으로 “완벽한 반환 타입”을 만들려는 시도는 초반에는 멋져 보이지만 유지가 어렵습니다.

 

반환 타입은 보통 이 정도 선에서 멈추는 편이 유지가 됩니다.

  • 호출자가 실제로 필요로 하는 정보만 타입으로 보장
  • 나머지는 구현 디테일로 남김

 

반환 타입이 구현을 강하게 묶어버리면, 리팩터링이 어려워집니다.

 

코드 예제: API 입력부터 도메인까지 타입 흐름을 끊는 방식

대규모 프로젝트에서 자주 남는 패턴은 “입력 검증 → 도메인 명령 → 결과 타입”이 분리된 구조입니다.

 


export type CreateOrderRequestDto = {
  userId?: unknown;
  productId?: unknown;
  quantity?: unknown;
};

export type CreateOrderCommand = {
  userId: string;
  productId: string;
  quantity: number;
};

export function toCreateOrderCommand(dto: CreateOrderRequestDto): CreateOrderCommand {
  if (typeof dto.userId !== 'string') throw new Error('invalid userId');
  if (typeof dto.productId !== 'string') throw new Error('invalid productId');
  if (typeof dto.quantity !== 'number' || !Number.isFinite(dto.quantity)) throw new Error('invalid quantity');

  return {
    userId: dto.userId,
    productId: dto.productId,
    quantity: dto.quantity,
  };
}

export type CreateOrderResult =
  | { ok: true; orderId: string }
  | { ok: false; code: 'OUT_OF_STOCK' | 'INVALID_STATE' };

export async function createOrder(cmd: CreateOrderCommand): Promise<CreateOrderResult> {
  // 실제 구현에서는 트랜잭션, 재고 확인, 로그 등을 포함
  if (cmd.quantity <= 0) return { ok: false, code: 'INVALID_STATE' };
  return { ok: true, orderId: 'o-123' };
}

 

이 방식은 코드가 조금 길어 보일 수 있습니다. 하지만 경계가 명확해서, 입력 포맷이 바뀌거나 검증 규칙이 바뀌어도 도메인 로직으로 변화가 무작정 전파되지 않습니다. 운영에서 문제가 생겼을 때도, 어느 단계에서 데이터가 깨졌는지 추적하기 쉽습니다.

 

운영/실무에서 자주 겪는 문제

대규모 프로젝트에서 타입 관련 이슈는 보통 다음 형태로 나타납니다.

 

  • 공통 타입이 커지면서 모든 패키지가 서로 얽힌다
  • DTO를 도메인 타입으로 그대로 쓰다가 외부 변경이 내부까지 흔든다
  • 타입 계산이 내부로 퍼져서 온보딩이 어려워진다
  • 타입 단언이 늘어나면서 타입 안정성이 실제로는 떨어진다

 

이 문제들은 대부분 “타입이 부족해서”가 아니라, 타입이 경계를 잘못 잡아서 발생합니다.

 

실무 점검 체크리스트

  • API DTO와 도메인 타입이 분리되어 있는가
  • 검증 전 데이터와 검증 후 데이터를 타입으로 구분하고 있는가
  • 공통 타입(shared)이 실제로 공통 의미를 가지는가
  • 고급 타입 계산이 도메인 내부로 과하게 들어오지 않았는가
  • 타입 단언(as)이 늘어나는 구간이 있는가
  • 타입 변경이 예상보다 넓게 전파되는 지점이 있는가
  • 테스트/빌드/런타임에서 타입 경계가 일관되게 유지되는가

 


 

 

대규모 TypeScript 프로젝트에서 타입 설계는 정교함보다 경계와 책임을 분명히 하는 쪽이 오래 갑니다. 입력과 내부, 공통과 로컬, 검증 전과 검증 후를 나눠두면 변경이 들어와도 영향 범위를 좁힐 수 있습니다.

 

타입을 더 잘 쓰는 방법은 결국 타입을 더 많이 쓰는 것이 아니라, 어디에서 멈추고 어디서 단순하게 둘지 정하는 쪽에 가깝습니다. 팀과 환경에 따라 기준은 달라질 수 있지만, 경계를 먼저 세워두면 이후 선택은 훨씬 수월해집니다.