[Typescript로 설계하는 프로젝트] 타입 한 줄로 552개 파일을 2주 만에 안전하게 수정한 방법

ant·2025년 11월 6일
post-thumbnail

"회원 구조가 바뀌었습니다. 552개 파일을 수정해야 합니다."

보통은 이렇게 됩니다

  • 어디를 수정해야 하는지 찾느라 1주
  • 수정하다가 놓친 곳 때문에 버그 발생
  • 회귀 테스트에 또 1주
  • QA에서 엣지 케이스 발견
  • 결국 한 달...

하지만 우리는 2주 만에, 사이드 이펙트 없이 끝냈습니다.

비결은 1년 전 작성한 이 한 줄이었습니다.

type ProfileId = string;

문제 상황

회원 구조가 바뀌었습니다.

[Before]
1 Account → 1 ProfileId (필수)

[After]
1 Account → N ProfileId (옵셔널)

예전에는 항상 ProfileId가 선택되어 있었지만, 이제는 선택하지 않을 수도 있게 되었습니다.


문제의 크기

거의 모든 서비스 기능이 영향을 받았습니다.

영향받은 주요 기능

  • 콘텐츠 관리 (업로드, 수정, 삭제)
  • 데이터 조회 (통계, 수익, 리포트)
  • 권한 관리 (접근 제어, 팀 관리)

이런 코드가 곳곳에 있었습니다:

const profileId = getProfileId(); // 항상 string이라고 가정
api.fetchData(profileId);

기존엔 getProfileId()가 항상 string을 보장했습니다. 하지만 이제는 undefined일 수 있게 되었습니다. 옵셔널이 되어야 합니다.

552개 파일을 수정해야 하는 상황. 어떻게 안전하게 해낼 수 있을까요?


왜 가능했을까? #1: 도메인 분리의 시작점

처음 코드를 작성할 때, 이렇게 할 수도 있었습니다:

// ❌ 의미 없는 원시 타입
const id = cookies.get('ProfileId'); // any 타입
api.fetchData(id); // 타입 체크 없음

하지만 우리는 이렇게 했습니다:

// ✅ 도메인 개념을 표현하는 타입
type ProfileId = string;

const id = cookies.get<ProfileId>('ProfileId'); // ProfileId 타입
api.fetchData(id); // 타입 안전

무엇이 달라졌을까요?

string은 그냥 원시 타입입니다. "문자열" 이라는 것 외에 아무 의미가 없습니다.

하지만 ProfileId는 다릅니다:

  • "이 값은 프로필을 식별하는 ID다"
  • "이 값은 특정 도메인에 속한다"

코드에 의미 계층을 추가한 것입니다.

이 작은 차이가 대규모 변경을 가능하게 만들었습니다.


해결 전략

핵심은 타입 추상화에 있었습니다.

코드 곳곳에 ProfileId 타입이 명시되어 있었기 때문에, 반환 타입만 바꾸면 컴파일러가 수정이 필요한 모든 곳을 알려줄 수 있었습니다.

// 변경 전
const getProfileId = () => cookies.get<ProfileId>('ProfileId'); // ProfileId 반환

// 변경 후
const getProfileId = () => cookies.get<ProfileId | undefined>('ProfileId'); // ProfileId | undefined 반환

타입 하나만 바꿨을 뿐인데, 타입스크립트 컴파일러가 수정이 필요한 모든 곳을 찾아냈습니다.


왜 가능했을까? #2: 변경 추적 가능한 단위 생성

타입 별칭이 없었다면 어땠을까요?

// 만약 string을 직접 사용했다면
const getProfileId = () => cookies.get<string>('ProfileId');
const fetchData = (id: string) => api.fetch(id);
const userId: string = '123';
const profileId: string = '456';

이 코드의 문제는 의미가 없다는 것입니다.

string만 봐서는:

  • 어떤 string이 ProfileId인지 알 수 없음
  • 어떤 string이 UserId인지 알 수 없음
  • 나중에 코드를 읽을 때 매번 변수명을 확인해야 함

하지만 ProfileId라는 타입을 만들면:

type ProfileId = string;

const getProfileId = (): ProfileId => cookies.get<ProfileId>('ProfileId');
const fetchData = (id: ProfileId) => api.fetch(id);
const profileId: ProfileId = '456';

무엇이 달라지나?

1. 코드 자체가 문서가 됩니다

// Before: 이게 뭔지 알 수 없음
function fetchData(id: string) {}

// After: ProfileId를 받는구나! 명확함
function fetchData(id: ProfileId) {}

2. 변경 추적이 가능해집니다

이제 getProfileId()의 반환 타입만 바꾸면:

// 이 한 줄만 수정
export const getProfileId = () =>
  cookies.get<ProfileId | undefined>('ProfileId');

TypeScript 컴파일러가 영향받는 모든 곳을 찾아냅니다:

❌ Type 'string | undefined' is not assignable to type 'string'
❌ Argument of type 'string | undefined' is not assignable to parameter
❌ Object is possibly 'undefined'

이 에러들이 수정이 필요한 모든 파일의 위치를 정확히 알려줬습니다.


이것이 "변경 추적 가능한 단위"를 만든 것입니다.

ProfileId 타입 덕분에:

  1. 검색: 정확히 관련 코드만 찾음
  2. 문서화: 코드 읽을 때 의미가 즉시 파악됨

타입 하나만 바꾸면, 그 변경이 자동으로 전체에 전파되고, 컴파일러가 수정이 필요한 모든 곳을 알려줍니다.

단순히 string이 아니라, ProfileId라는 의미를 부여한 것 - 이것이 안전하게 수정할 수 있었습니다.


수정 과정

우선순위별로 분류했습니다:

  1. 기획 확인 필요 - ProfileId가 없는 상황을 고려하지 않은 케이스
  2. 단순 null 체크 - 옵셔널 체이닝이나 조건문 추가
  3. 로직 변경 - 흐름 자체를 수정해야 하는 케이스

패턴 1: 옵셔널 체이닝

// Before
const data = profileId.something;

// After
const data = profileId?.something;

패턴 2: 필수값 보장하기

path parameter처럼 '반드시 있어야 하는' 경우를 위한 훅을 만들었습니다:

const useProfileIdByPath = (): ProfileId => {
  const { profileId } = useParams<{ profileId: ProfileId }>();

  if (!profileId)
    throw new Error(
      'useProfileIdByPath hook must be used within an profileId path',
    );

  return profileId;
};

기획 논의는 이런 식이었습니다:

나: "컴파일 오류를 해결하다 보니 이 상황에서는 추가적인 화면 기획이 필요해요! 에러 화면이나 비어 있는 화면이 필요합니다."

기획: "아, 그 부분은 생각 못 했네요. 로그인 페이지로 보내주세요."

또는

나: "이 기능 자체에 대한 기획이 빠져 있는 거 같아요! 이 부분 추가로 필요합니다."

하나씩 수정하다 보니 총 552개 파일이 변경되었습니다.


결과

552개 파일. 2주. 사이드 이펙트 0건.

타입 시스템이 QA 역할까지 해주었습니다. 컴파일러가 알려주는 에러를 하나씩 해결하다 보니, 놓칠 수 있었던 엣지 케이스까지 모두 처리할 수 있었습니다.

심지어 기획에서 빠진 부분까지 발견했습니다.


추가 효과: 협업 속도 향상

타입 별칭의 효과는 여기서 끝나지 않습니다.

협업할 때도 차이가 납니다.

// ❌ 의미 없는 원시 타입
function updateProfile(id: string, name: string) {
  // id가 UserId인가? ProfileId인가? TeamId인가?
  // name이 displayName인가? userName인가?
}

// ✅ 의미 있는 타입
function updateProfile(id: ProfileId, name: DisplayName) {
  // 명확합니다
}

코드 자체가 문서 역할을 합니다. 주석이나 별도 문서 없이도 "이 함수는 ProfileId를 받는구나" 를 즉시 알 수 있습니다.


하지만 주의하세요

모든 string을 타입으로 만들 필요는 없습니다.

❌ 과도한 적용

type UserName = string;
type ButtonLabel = string;
type ErrorMessage = string;

✅ 의미 있는 적용

type UserId = string; // 식별자
type ProfileId = string; // 도메인 핵심 개념
type ApiKey = string; // 보안/검증 필요

도메인의 핵심 개념이나, 추적이 필요한 식별자처럼 꼭 필요해 보이는 곳에만 적용하세요.


배운 점

타입 설계의 중요성

string 대신 ProfileId를 썼던 과거의 선택이 모든 차이를 만들었습니다.

"나중에 필요할지도 몰라" 하는 막연한 기대가 아니라, 지금 당장 코드의 의미를 명확하게 만들기 위해 타입을 만들었던 것이 1년 후 대규모 변경을 가능하게 만들었습니다.

타입은 단순히 에러를 잡는 도구가 아닙니다. 코드에 의미를 부여하고, 변경을 추적 가능하게 만드는 설계 도구입니다.


당신의 프로젝트에도

지금 바로 확인해보세요.

체크리스트:

□ string, number 같은 원시 타입을 직접 사용하고 있나요?
□ UserId, ProductId, Price 같은 의미 있는 타입으로 바꿀 수 있나요?
□ 도메인 핵심 개념을 타입으로 표현하고 있나요?
□ 제네릭을 활용해서 타입 정보를 유지하고 있나요?

지금 당장 시작하세요:

  1. 가장 중요한 식별자 하나를 골라보세요 (userId, orderId, productId...)
  2. type UserId = string 한 줄을 추가하세요
  3. 해당 타입을 사용하는 모든 곳에 타입을 명시하세요
  4. 다음에 변경이 필요할 때, 그 위력을 경험하게 될 것입니다

3개월 후 이 글을 다시 읽는 당신에게

"그때 ProfileId 타입을 만들어둬서 다행이야."

이 문장을 하게 될 순간이 반드시 옵니다.

  • API 응답 구조가 바뀔 때
  • 권한 시스템을 추가할 때

타입 한 줄은 미래의 당신에게 보내는 선물입니다.

17개의 댓글

comment-user-thumbnail
2025년 11월 7일

좋은 글 감사합니다! 제 프로젝트에도 적용할 점이 있는지 확인해봐야 겠네요!

1개의 답글
comment-user-thumbnail
2025년 11월 7일

와 정말 멋진 경험인 것 같아요..! 다른 사람이 개발한 레거시 리팩토링을 앞두고 있는데, 도움이 많이 될 것 같습니다. 감사합니다 :)

1개의 답글
comment-user-thumbnail
2025년 11월 7일

TS 는 type ProfileId = stringstring 을 구분하지 못합니다. 그래서 이 코드는 타입 안정성 보다는, 코드를 읽는 사람으로 하여금 명확하게 인지를 시켜주는 용도 정도의 의미를 가진다고 생각합니다. (물론 대규모 마이그레이션이 발생하는 경우에도 효과적인 방법입니다.)

만약 ProfileId 의 타입 시그니쳐가 string 에서 string | number 로 변경되는 상황이라면, string 으로 선언된 N개의 파일을 수정하는게 아닌, ProfileId 타입 정의 한 곳과 그 타입을 사용하는 부분만 수정하면 글에서 설명하는 의도대로 정리가 될겁니다.

이건 타입뿐 아니라 코드 레벨에서도 동일하게 적용이 되는데요, 예를 들어서 어떤 데이터를 조회하는 로직이 여러곳에 흩어져 있다면 모든 호출부를 수정해야 하지만, 한 곳에서만 관리되도록 잘 래핑되어 있다면 그 한곳만 수정하면 됩니다.

그래서 글에서 핵심은 타입 별칭의 효과보다는, 프로필 ID 조회 로직이 한 곳에서 잘 관리되어 있었기 때문에 마이그레이션이 안전하게 끝날 수 있었다가 아닐까.. 하는 의견을 남겨봅니다 ㅎㅎ 잘 읽었습니다.

1개의 답글
comment-user-thumbnail
2025년 11월 7일

Profile 타입을 따로 만들고 Profile['id']를 하던가 Pick<Profile,"id">가 더 좋아보이긴해요.
Profile이 id만 갖고 있지않으니까요

1개의 답글
comment-user-thumbnail
2025년 11월 10일

"코드에 의미 계층을 추가한 것" 이 TS 뿐 아니라 대부분의 규모있는 프로젝트에서 정말 큰 차이를 만든다고 생각되네요,, 마음이 편해지는 글 잘 읽었습니다 🙏🙇🏻‍♂️

1개의 답글
comment-user-thumbnail
2025년 11월 15일

어디를 수정해야 하는지 찾느라 1주
...
결국 한 달...

얼마전 저를 보는 것 같네요 ㅠㅠ 다음에 개선할 때는 한번 적용해봐야겠습니다 감사합니다..!

답글 달기
comment-user-thumbnail
2025년 11월 28일

추상화를 통해 해결한 모습이 인상 깊었습니다.

답글 달기
comment-user-thumbnail
2025년 11월 28일

타입으로도 산탄총 수술 리팩토링이 가능하네요 좋은 레퍼런스 감사합니다!

답글 달기
comment-user-thumbnail
2025년 11월 29일

좋은 글 감사합니다!
이펙티브 타입스크립트라는 책을 읽으면서 보통 명확하게 타입이 추론가능한 원시타입은 굳이 타입을 설정하지 않아도 괜찮다는 의견?이 있었던 거로 기억하는데, (좀 오래되긴 해서 대충 저런 뉘앙스였던 것으로 기억합니다.. 잘못 기억하고 있었다면 죄송합니다)
이 글에서 소개해주신 ProfileId 케이스의 경우처럼 타입 정의가 필요한 경우도 있겠다는 걸 알게되었네요!

답글 달기
comment-user-thumbnail
2025년 11월 29일

처음엔 552줄인 줄 알고 읽다가 552개 파일이라는 걸 보고 깜짝 놀랐네요. 저라면 벌써 어디서부터 손대야 할지 몰라 막막했을 것 같은데, 타입 설계 단계에서 이미 해결의 실마리를 만들어두셨던 거군요. 개발할 때 자주 하던 말이 '업보청산'이었는데, 이번 글은 과거의 누군가에게 고마워해야하는 경험인 것 같네요 ㅋㅋ 좋은 글 감사합니다!

답글 달기
comment-user-thumbnail
2025년 12월 1일

코드 자체가 문서가 된다는 말에 공감했습니다. 보통 문서는 업데이트를 지속적으로 하지 않으면 결국 못쓰게 되는데 코드 자체가 문서 역할을 하도록 만들면 항상 유지될 수 밖에 없어서 좋은 것 같습니다.

답글 달기