
"회원 구조가 바뀌었습니다. 552개 파일을 수정해야 합니다."
보통은 이렇게 됩니다
하지만 우리는 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개 파일을 수정해야 하는 상황. 어떻게 안전하게 해낼 수 있을까요?
처음 코드를 작성할 때, 이렇게 할 수도 있었습니다:
// ❌ 의미 없는 원시 타입
const id = cookies.get('ProfileId'); // any 타입
api.fetchData(id); // 타입 체크 없음
하지만 우리는 이렇게 했습니다:
// ✅ 도메인 개념을 표현하는 타입
type ProfileId = string;
const id = cookies.get<ProfileId>('ProfileId'); // ProfileId 타입
api.fetchData(id); // 타입 안전
무엇이 달라졌을까요?
string은 그냥 원시 타입입니다. "문자열" 이라는 것 외에 아무 의미가 없습니다.
하지만 ProfileId는 다릅니다:
코드에 의미 계층을 추가한 것입니다.
이 작은 차이가 대규모 변경을 가능하게 만들었습니다.
핵심은 타입 추상화에 있었습니다.
코드 곳곳에 ProfileId 타입이 명시되어 있었기 때문에, 반환 타입만 바꾸면 컴파일러가 수정이 필요한 모든 곳을 알려줄 수 있었습니다.
// 변경 전
const getProfileId = () => cookies.get<ProfileId>('ProfileId'); // ProfileId 반환
// 변경 후
const getProfileId = () => cookies.get<ProfileId | undefined>('ProfileId'); // ProfileId | undefined 반환
타입 하나만 바꿨을 뿐인데, 타입스크립트 컴파일러가 수정이 필요한 모든 곳을 찾아냈습니다.
타입 별칭이 없었다면 어땠을까요?
// 만약 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';
무엇이 달라지나?
// Before: 이게 뭔지 알 수 없음
function fetchData(id: string) {}
// After: ProfileId를 받는구나! 명확함
function fetchData(id: ProfileId) {}
이제 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 타입 덕분에:
타입 하나만 바꾸면, 그 변경이 자동으로 전체에 전파되고, 컴파일러가 수정이 필요한 모든 곳을 알려줍니다.
단순히 string이 아니라, ProfileId라는 의미를 부여한 것 - 이것이 안전하게 수정할 수 있었습니다.
우선순위별로 분류했습니다:
패턴 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 같은 의미 있는 타입으로 바꿀 수 있나요?
□ 도메인 핵심 개념을 타입으로 표현하고 있나요?
□ 제네릭을 활용해서 타입 정보를 유지하고 있나요?
지금 당장 시작하세요:
type UserId = string 한 줄을 추가하세요"그때 ProfileId 타입을 만들어둬서 다행이야."
이 문장을 하게 될 순간이 반드시 옵니다.
타입 한 줄은 미래의 당신에게 보내는 선물입니다.
TS 는 type ProfileId = string 과 string 을 구분하지 못합니다. 그래서 이 코드는 타입 안정성 보다는, 코드를 읽는 사람으로 하여금 명확하게 인지를 시켜주는 용도 정도의 의미를 가진다고 생각합니다. (물론 대규모 마이그레이션이 발생하는 경우에도 효과적인 방법입니다.)
만약 ProfileId 의 타입 시그니쳐가 string 에서 string | number 로 변경되는 상황이라면, string 으로 선언된 N개의 파일을 수정하는게 아닌, ProfileId 타입 정의 한 곳과 그 타입을 사용하는 부분만 수정하면 글에서 설명하는 의도대로 정리가 될겁니다.
이건 타입뿐 아니라 코드 레벨에서도 동일하게 적용이 되는데요, 예를 들어서 어떤 데이터를 조회하는 로직이 여러곳에 흩어져 있다면 모든 호출부를 수정해야 하지만, 한 곳에서만 관리되도록 잘 래핑되어 있다면 그 한곳만 수정하면 됩니다.
그래서 글에서 핵심은 타입 별칭의 효과보다는, 프로필 ID 조회 로직이 한 곳에서 잘 관리되어 있었기 때문에 마이그레이션이 안전하게 끝날 수 있었다가 아닐까.. 하는 의견을 남겨봅니다 ㅎㅎ 잘 읽었습니다.
Profile 타입을 따로 만들고 Profile['id']를 하던가 Pick<Profile,"id">가 더 좋아보이긴해요.
Profile이 id만 갖고 있지않으니까요
"코드에 의미 계층을 추가한 것" 이 TS 뿐 아니라 대부분의 규모있는 프로젝트에서 정말 큰 차이를 만든다고 생각되네요,, 마음이 편해지는 글 잘 읽었습니다 🙏🙇🏻♂️
어디를 수정해야 하는지 찾느라 1주
...
결국 한 달...
얼마전 저를 보는 것 같네요 ㅠㅠ 다음에 개선할 때는 한번 적용해봐야겠습니다 감사합니다..!
좋은 글 감사합니다!
이펙티브 타입스크립트라는 책을 읽으면서 보통 명확하게 타입이 추론가능한 원시타입은 굳이 타입을 설정하지 않아도 괜찮다는 의견?이 있었던 거로 기억하는데, (좀 오래되긴 해서 대충 저런 뉘앙스였던 것으로 기억합니다.. 잘못 기억하고 있었다면 죄송합니다)
이 글에서 소개해주신 ProfileId 케이스의 경우처럼 타입 정의가 필요한 경우도 있겠다는 걸 알게되었네요!
처음엔 552줄인 줄 알고 읽다가 552개 파일이라는 걸 보고 깜짝 놀랐네요. 저라면 벌써 어디서부터 손대야 할지 몰라 막막했을 것 같은데, 타입 설계 단계에서 이미 해결의 실마리를 만들어두셨던 거군요. 개발할 때 자주 하던 말이 '업보청산'이었는데, 이번 글은 과거의 누군가에게 고마워해야하는 경험인 것 같네요 ㅋㅋ 좋은 글 감사합니다!
좋은 글 감사합니다! 제 프로젝트에도 적용할 점이 있는지 확인해봐야 겠네요!