[Typescript로 설계하는 프로젝트] 타입 한 줄로 552개 파일을 2주 만에 안전하게 수정한 방법
"회원 구조가 바뀌었습니다. 552개 파일을 수정해야 합니다."
보통은 이렇게 됩니다
- 어디를 수정해야 하는지 찾느라 1주
- 수정하다가 놓친 곳 때문에 버그 발생
- 회귀 테스트에 또 1주
- QA에서 엣지 케이스 발견
- 결국 한 달...
하지만 우리는 2주 만에, 사이드 이펙트 없이 끝냈습니다.
비결은 1년 전 작성한 이 한 줄이었습니다.
typescripttype ProfileId = string;
문제 상황
회원 구조가 바뀌었습니다.
[Before]
1 Account → 1 ProfileId (필수)
[After]
1 Account → N ProfileId (옵셔널)
예전에는 항상 ProfileId가 선택되어 있었지만, 이제는 선택하지 않을 수도 있게 되었습니다.
문제의 크기
거의 모든 서비스 기능이 영향을 받았습니다.
영향받은 주요 기능
- 콘텐츠 관리 (업로드, 수정, 삭제)
- 데이터 조회 (통계, 수익, 리포트)
- 권한 관리 (접근 제어, 팀 관리)
이런 코드가 곳곳에 있었습니다:
typescriptconst profileId = getProfileId(); // 항상 string이라고 가정 api.fetchData(profileId);
기존엔 getProfileId()가 항상 string을 보장했습니다. 하지만 이제는 undefined일 수 있게 되었습니다. 옵셔널이 되어야 합니다.
552개 파일을 수정해야 하는 상황. 어떻게 안전하게 해낼 수 있을까요?
왜 가능했을까? #1: 도메인 분리의 시작점
처음 코드를 작성할 때, 이렇게 할 수도 있었습니다:
typescript// ❌ 의미 없는 원시 타입 const id = cookies.get('ProfileId'); // any 타입 api.fetchData(id); // 타입 체크 없음
하지만 우리는 이렇게 했습니다:
typescript// ✅ 도메인 개념을 표현하는 타입 type ProfileId = string; const id = cookies.get<ProfileId>('ProfileId'); // ProfileId 타입 api.fetchData(id); // 타입 안전
무엇이 달라졌을까요?
string은 그냥 원시 타입입니다. "문자열" 이라는 것 외에 아무 의미가 없습니다.
하지만 ProfileId는 다릅니다:
- "이 값은 프로필을 식별하는 ID다"
- "이 값은 특정 도메인에 속한다"
코드에 의미 계층을 추가한 것입니다.
이 작은 차이가 대규모 변경을 가능하게 만들었습니다.
해결 전략
핵심은 타입 추상화에 있었습니다.
코드 곳곳에 ProfileId 타입이 명시되어 있었기 때문에, 반환 타입만 바꾸면 컴파일러가 수정이 필요한 모든 곳을 알려줄 수 있었습니다.
typescript// 변경 전 const getProfileId = () => cookies.get<ProfileId>('ProfileId'); // ProfileId 반환 // 변경 후 const getProfileId = () => cookies.get<ProfileId | undefined>('ProfileId'); // ProfileId | undefined 반환
타입 하나만 바꿨을 뿐인데, 타입스크립트 컴파일러가 수정이 필요한 모든 곳을 찾아냈습니다.
왜 가능했을까? #2: 변경 추적 가능한 단위 생성
타입 별칭이 없었다면 어땠을까요?
typescript// 만약 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라는 타입을 만들면:
typescripttype ProfileId = string; const getProfileId = (): ProfileId => cookies.get<ProfileId>('ProfileId'); const fetchData = (id: ProfileId) => api.fetch(id); const profileId: ProfileId = '456';
무엇이 달라지나?
1. 코드 자체가 문서가 됩니다
typescript// Before: 이게 뭔지 알 수 없음 function fetchData(id: string) {} // After: ProfileId를 받는구나! 명확함 function fetchData(id: ProfileId) {}
2. 변경 추적이 가능해집니다
이제 getProfileId()의 반환 타입만 바꾸면:
typescript// 이 한 줄만 수정 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 타입 덕분에:
- 검색: 정확히 관련 코드만 찾음
- 문서화: 코드 읽을 때 의미가 즉시 파악됨
타입 하나만 바꾸면, 그 변경이 자동으로 전체에 전파되고, 컴파일러가 수정이 필요한 모든 곳을 알려줍니다.
단순히 string이 아니라, ProfileId라는 의미를 부여한 것 - 이것이 안전하게 수정할 수 있었습니다.
수정 과정
우선순위별로 분류했습니다:
- 기획 확인 필요 - ProfileId가 없는 상황을 고려하지 않은 케이스
- 단순 null 체크 - 옵셔널 체이닝이나 조건문 추가
- 로직 변경 - 흐름 자체를 수정해야 하는 케이스
패턴 1: 옵셔널 체이닝
typescript// Before const data = profileId.something; // After const data = profileId?.something;
패턴 2: 필수값 보장하기
path parameter처럼 '반드시 있어야 하는' 경우를 위한 훅을 만들었습니다:
typescriptconst 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 역할까지 해주었습니다. 컴파일러가 알려주는 에러를 하나씩 해결하다 보니, 놓칠 수 있었던 엣지 케이스까지 모두 처리할 수 있었습니다.
심지어 기획에서 빠진 부분까지 발견했습니다.
추가 효과: 협업 속도 향상
타입 별칭의 효과는 여기서 끝나지 않습니다.
협업할 때도 차이가 납니다.
typescript// ❌ 의미 없는 원시 타입 function updateProfile(id: string, name: string) { // id가 UserId인가? ProfileId인가? TeamId인가? // name이 displayName인가? userName인가? } // ✅ 의미 있는 타입 function updateProfile(id: ProfileId, name: DisplayName) { // 명확합니다 }
코드 자체가 문서 역할을 합니다. 주석이나 별도 문서 없이도 "이 함수는 ProfileId를 받는구나" 를 즉시 알 수 있습니다.
하지만 주의하세요
모든 string을 타입으로 만들 필요는 없습니다.
❌ 과도한 적용
typescripttype UserName = string; type ButtonLabel = string; type ErrorMessage = string;
✅ 의미 있는 적용
typescripttype UserId = string; // 식별자 type ProfileId = string; // 도메인 핵심 개념 type ApiKey = string; // 보안/검증 필요
도메인의 핵심 개념이나, 추적이 필요한 식별자처럼 꼭 필요해 보이는 곳에만 적용하세요.
배운 점
타입 설계의 중요성
string 대신 ProfileId를 썼던 과거의 선택이 모든 차이를 만들었습니다.
"나중에 필요할지도 몰라" 하는 막연한 기대가 아니라, 지금 당장 코드의 의미를 명확하게 만들기 위해 타입을 만들었던 것이 1년 후 대규모 변경을 가능하게 만들었습니다.
타입은 단순히 에러를 잡는 도구가 아닙니다. 코드에 의미를 부여하고, 변경을 추적 가능하게 만드는 설계 도구입니다.
당신의 프로젝트에도
지금 바로 확인해보세요.
체크리스트:
□ string, number 같은 원시 타입을 직접 사용하고 있나요?
□ UserId, ProductId, Price 같은 의미 있는 타입으로 바꿀 수 있나요?
□ 도메인 핵심 개념을 타입으로 표현하고 있나요?
□ 제네릭을 활용해서 타입 정보를 유지하고 있나요?
지금 당장 시작하세요:
- 가장 중요한 식별자 하나를 골라보세요 (userId, orderId, productId...)
type UserId = string한 줄을 추가하세요- 해당 타입을 사용하는 모든 곳에 타입을 명시하세요
- 다음에 변경이 필요할 때, 그 위력을 경험하게 될 것입니다
3개월 후 이 글을 다시 읽는 당신에게
"그때 ProfileId 타입을 만들어둬서 다행이야."
이 문장을 하게 될 순간이 반드시 옵니다.
- API 응답 구조가 바뀔 때
- 권한 시스템을 추가할 때
타입 한 줄은 미래의 당신에게 보내는 선물입니다.