[TYPESCRIPT] 공통 타입을 분리하기 시작했을 때 생기는 결합과 관리 전략

프로젝트가 커지면 거의 반드시 등장하는 작업이 있습니다. 여러 모듈에서 반복되는 타입을 shared 패키지나 common/types로 옮기는 작업입니다. 처음에는 분명히 정리되는 느낌이 듭니다.

 

하지만 일정 규모를 넘기면 상황이 조금 달라집니다. shared가 편의 공간이 아니라 의존성의 중심이 되고, 타입 하나를 수정했을 뿐인데 여러 패키지가 동시에 영향을 받는 일이 생깁니다.

 

공통 타입 분리는 필요한 작업이지만, 기준 없이 진행하면 결합을 빠르게 키웁니다. 여기서는 실제 운영 코드에서 어떤 지점에서 문제가 드러나는지, 그리고 어떻게 관리하는 쪽이 유지에 유리했는지 중심으로 알아보겠습니다.

 

왜 shared/types가 빠르게 비대해지는가

공통 타입이 커지는 이유는 대부분 비슷합니다. 처음에는 명확한 공통 모델로 시작하지만, 이후에는 “비슷해 보여서” 추가되는 타입이 늘어납니다.

 

  • 여러 곳에서 userId를 쓰니까 공통으로 빼자
  • API 응답 구조가 비슷하니까 shared에 두자
  • DTO도 재사용 가능할 것 같으니 공통으로 올리자

 

이 과정이 반복되면, shared는 의미 기준이 아니라 위치 기준으로 커집니다. 이 시점부터 타입 변경의 파급 범위가 예측하기 어려워집니다.

 

문제가 드러나는 첫 신호

공통 타입 결합이 커지면 보통 다음과 같은 신호가 먼저 나타납니다.

 

  • shared 타입 수정 시 여러 패키지 빌드가 동시에 깨짐
  • 원래 의도와 다른 곳에서도 타입이 사용되고 있음
  • 타입 이름은 같지만 의미가 조금씩 달라짐

 

특히 “이 타입 여기서도 쓰고 있었네”라는 상황이 반복되면, 공통 타입이 이미 과도하게 퍼진 상태일 가능성이 높습니다.

 

DTO를 공통으로 뺐을 때 흔히 생기는 문제

실무에서 가장 자주 보는 패턴은 API DTO를 shared로 올리는 경우입니다.

 


// shared/dto.ts
export type UserResponseDto = {
  id: string;
  email: string;
  name: string;
};

 

처음에는 프론트와 백엔드가 같은 타입을 써서 좋아 보입니다. 하지만 시간이 지나면 API는 변하고, 도메인 요구도 바뀌고, 레거시 대응 필드가 추가됩니다.

 

이때 DTO가 공통으로 묶여 있으면, 한쪽의 변경이 다른 쪽에 강제로 전파됩니다. 특히 버전이 다른 클라이언트를 동시에 지원해야 할 때 유지 비용이 빠르게 올라갑니다.

 

공통 타입으로 남겨도 되는 기준

여러 프로젝트를 거치다 보면, shared에 남아도 비교적 안정적인 타입들이 있습니다.

 

  • 값의 의미가 도메인 전반에서 완전히 동일한 경우
  • 변경 주기가 매우 낮은 식별자/리터럴 타입
  • 여러 패키지가 동시에 의존해야 하는 계약 타입

 

예를 들어 이런 타입은 비교적 안전합니다.

 


export type UserId = string & { readonly __brand: 'UserId' };

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

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

 

의미가 명확하고, 레이어에 따라 달라질 가능성이 낮은 타입입니다.

 

공통으로 빼면 결합이 커지는 타입

반대로 다음 유형은 shared로 올릴수록 비용이 커집니다.

 

  • API 요청/응답 DTO
  • DB row 모델
  • 서비스 내부 명령/결과 타입

 

이 타입들은 레이어별로 요구가 다르고, 변경 주기도 서로 다릅니다. 공통으로 묶는 순간, 서로 다른 변화 속도를 강제로 맞추게 됩니다.

 

의존 방향을 먼저 고정하는 편이 덜 흔들린다

모노레포에서는 타입 자체보다 의존 방향이 더 중요합니다. shared는 보통 가장 아래 레이어에 위치합니다.

 

  • shared → api (허용)
  • shared → web (허용)
  • api → shared (허용)
  • shared → api 내부 타입 (금지)

 

shared가 상위 레이어를 참조하기 시작하면, 순환 의존이 빠르게 생깁니다. 이 상태가 되면 타입 분리가 아니라 타입 얽힘에 가깝습니다.

 

코드 예제: DTO를 shared로 두지 않는 구조

대규모 코드에서 자주 남는 구조는, DTO는 각 서비스에 두고, 공통 식별자나 리터럴만 shared에 두는 방식입니다.

 


// shared/id.ts
export type UserId = string & { readonly __brand: 'UserId' };

 


// api/user/dto.ts
export type UserResponseDto = {
  id: UserId;
  email: string;
  name: string;
};

 

이 구조는 타입 재사용은 유지하면서, API 변경이 shared 전체로 번지는 것을 막아줍니다.

 

운영에서 자주 겪는 문제

공통 타입 결합이 커진 프로젝트에서는 다음 문제가 반복됩니다.

 

  • shared 패키지 버전 올릴 때 영향 범위가 과도하게 넓음
  • 타입 하나 바꾸려다 여러 팀과 동시에 조율해야 함
  • 의도하지 않은 재사용이 계속 늘어남
  • 순환 의존을 끊기 위한 임시 타입이 생김

 

이 문제는 대부분 공통 타입의 범위가 넓어졌을 때 나타납니다.

 

실무 점검 체크리스트

  • shared 타입이 실제로 여러 레이어에서 같은 의미로 쓰이는가
  • DTO나 DB 모델이 shared로 올라가 있지는 않은가
  • shared 변경 시 영향을 받는 패키지 범위를 알고 있는가
  • 타입 이름은 같지만 의미가 다른 사용이 존재하지 않는가
  • 의존 방향이 한쪽으로만 흐르고 있는가
  • 순환 의존을 임시 타입으로 우회하고 있지는 않은가

 


 

 

공통 타입 분리는 코드 중복을 줄이는 작업이지만, 동시에 의존성을 만드는 작업이기도 합니다. shared에 무엇을 남길지보다, 무엇을 남기지 않을지가 더 중요해지는 순간이 옵니다.

 

의미가 완전히 동일한 계약만 공통으로 두고, 레이어에 따라 변하는 타입은 로컬에 남겨두는 편이 대규모 프로젝트에서는 유지가 수월합니다.