처음 TypeScript를 접했을 때의 감상은 딱 한 줄이었다. "왜 코드를 두 번 써야 하지?"
2021년쯤이었다. 팀에서 새 프로젝트를 TypeScript로 시작한다고 했을 때, 솔직히 반가운 마음은 없었다. JavaScript로 충분히 잘 돌아가는 코드를 쓰고 있었고, 타입이라는 걸 일일이 선언하는 게 생산성을 떨어뜨리는 일처럼 보였다. 함수 하나를 만들면 파라미터 타입, 리턴 타입, 인터페이스 정의... 실제 로직보다 타입 선언이 더 긴 경우도 있었다.
당시 나의 TypeScript 코드는 이랬다.
const fetchData = async (url: any, options: any): Promise<any> => {
const res = await fetch(url, options);
const data = await res.json();
return data;
};
any의 향연. 타입 체커가 에러를 뿜으면 any를 붙이고, 복잡한 타입이 나오면 as any로 캐스팅하고, @ts-ignore 주석을 달고 넘어갔다. "일단 돌아가니까"가 만능 변명이었다. 회사 코드베이스에서 any를 검색하면 내 이름이 가장 많이 나왔을 거다.
그때는 맞고 지금은 틀리다
TypeScript를 싫어하는 데는 나름의 이유가 있었다. 아니, 적어도 그때는 그렇다고 믿었다.
첫째, 속도. JavaScript로 바로 쓰면 한 시간이면 끝날 기능이 TypeScript로는 두 시간이 걸렸다. 타입 에러를 잡느라 실제 비즈니스 로직에 집중하지 못하는 기분이었다. "이 시간에 기능 하나 더 만들겠다"고 매번 생각했다.
둘째, 제네릭. <T extends keyof U> 같은 게 눈에 들어오기 시작하면 코드가 읽히질 않았다. 라이브러리 타입 정의 파일을 열어보면 외계어 같았다. 이걸 이해해야 프론트엔드를 할 수 있다고? JavaScript는 이런 거 몰라도 잘만 돌아갔는데.
셋째, 솔직히 말하면, 귀찮았다. 인터페이스를 정의하고, 유니온 타입을 만들고, 타입 가드를 쓰고. 이 모든 것들이 "지금 당장은" 필요 없어 보였다. 당장 내일 데모를 해야 하는데 타입 시스템이랑 씨름하고 있을 시간이 없었다.
그래서 나의 전략은 단순했다. 가능한 한 any를 쓰고, TypeScript의 장점을 최소한만 활용하자. IDE 자동완성이 좀 되는 것만으로도 충분하다고 스스로를 설득했다. tsconfig.json에서 strict는 당연히 false. 누군가 strict 모드를 켜자고 하면 "그러면 기존 코드 다 고쳐야 하는데요"라는 방패를 들었다.
금요일 밤 11시의 장애
전환점은 극적이었다. 어느 금요일 밤, 프로덕션에서 장애가 터졌다.
유저 프로필 페이지에서 특정 조건일 때 앱이 하얗게 죽어버리는 현상이었다. 에러 로그를 확인하니 Cannot read properties of undefined (reading 'address'). 너무나 익숙한 에러. 유저 데이터에서 address 필드를 참조하는데, 일부 유저는 주소를 입력하지 않았고, 서버에서 해당 필드를 아예 내려주지 않는 케이스가 있었다.
코드는 이렇게 되어 있었다.
const UserProfile = ({ user }) => {
return (
<div>
<p>{user.name}</p>
<p>{user.address.city}</p>
<p>{user.address.zipCode}</p>
</div>
);
};user.address가 undefined일 수 있다는 걸 아무도 체크하지 않았다. 그리고 이 코드를 작성한 사람이 나였다. API 응답 타입을 제대로 정의했다면, address가 옵셔널이라는 걸 타입 시스템이 알려줬을 거다. address?: Address 라고만 해뒀어도 user.address.city에서 빨간 줄이 뜨면서 "이거 undefined일 수 있는데?"라고 경고했을 거다.
그날 밤, 핫픽스를 배포하고 나서 모니터 앞에 멍하니 앉아 있었다. any를 썼기 때문에 타입 시스템이 이 문제를 잡아줄 수가 없었다. 내가 직접 비활성화시킨 안전장치에 대해 불평할 자격이 없었다.
주말 내내 생각했다. 그동안 TypeScript가 번거롭다고 느꼈던 그 시간, 그 타입 선언이 실은 이런 장애를 미리 막아주는 거였다. 코드를 쓸 때 30초 더 쓰면, 금요일 밤 2시간짜리 장애 대응을 안 해도 된다.
any에서 벗어나기
월요일에 출근해서 팀에 말했다. "TypeScript 제대로 쓰자. any 줄이자." 주변 반응은 의외로 미지근했다. "이미 any 투성이인데 이걸 어떻게 다 고쳐요?" 맞는 말이었다. 그래서 전략을 세웠다.
한 번에 다 바꾸려고 하면 안 된다. 새로 작성하는 코드부터 any 금지. 기존 코드는 건드릴 때 한 파일씩 타입을 입히자. ESLint에 @typescript-eslint/no-explicit-any 룰을 추가했는데, warn 단계로. error로 하면 CI가 전부 깨지니까.
처음 몇 주는 고통스러웠다. API 응답 타입을 정의하는 것부터 시작했다. 서버에서 내려주는 JSON 구조를 보면서 인터페이스를 하나씩 만들었다.
interface User {
id: number;
name: string;
email: string;
address?: {
city: string;
zipCode: string;
street?: string;
};
createdAt: string;
role: 'admin' | 'user' | 'guest';
}이렇게 타입을 정의하니까 코드를 쓸 때 감각이 달라졌다. user.address.city를 쓰려고 하면 에디터가 바로 경고한다. 옵셔널 체이닝을 써야 한다고. user.role에 이상한 값을 넣으려고 하면 컴파일 단계에서 잡힌다. 실행하기 전에.
한 달쯤 지나자 any가 점점 눈에 거슬리기 시작했다. 예전에는 편리하다고 느꼈던 any가 이제는 "여기에 버그가 숨어있을 수 있음"이라는 경고등으로 보였다. 동료가 PR에서 any를 쓰면 코멘트를 달게 됐다. "이거 타입 좁힐 수 있지 않을까요?"
strict 모드를 켜던 날
6개월쯤 지나서, 내가 직접 tsconfig.json의 strict를 true로 바꾸자고 제안했다. 예전의 나라면 절대 안 했을 일이다.
물론 순탄하지 않았다. strict 모드를 켜는 순간 에러가 300개 넘게 터져 나왔다. strictNullChecks, noImplicitAny, strictFunctionTypes 등이 한꺼번에 활성화되면서 그동안 가려져 있던 잠재적 문제들이 전부 드러났다.
300개의 에러를 보는 건 절망적이었지만, 다른 관점에서 보면 300개의 잠재적 버그를 발견한 거였다. 그중에는 진짜 위험한 것들도 있었다. null 체크 없이 메서드를 호출하는 코드, 타입이 맞지 않는 프로퍼티 접근, 함수 시그니처와 실제 호출부가 다른 경우.
팀에서 2주에 걸쳐 나눠서 고쳤다. 한 사람당 하루에 20~30개씩. 고치면서 코드를 다시 읽게 되니까 리팩토링도 자연스럽게 일어났다. "이 함수 이름이 뭐야", "이 조건문은 왜 이래", "이 로직은 이미 안 쓰이는 거 아닌가". 타입 에러를 잡으면서 코드 품질 전체가 올라갔다.
그래도 여전히 짜증나는 것들
TypeScript를 좋아하게 됐다고 해서 모든 게 장밋빛은 아니다. 솔직하게 써보겠다.
라이브러리 타입과의 싸움. 서드파티 라이브러리의 타입 정의가 부실한 경우가 아직도 많다. @types/가 존재하지 않는 패키지를 쓸 때 직접 .d.ts 파일을 작성해야 하는데, 이게 은근히 시간을 잡아먹는다. 라이브러리를 업데이트했더니 타입이 깨지는 경우도 있다. 라이브러리 코드 자체에는 문제가 없는데, 타입 정의만 뒤처져 있는 거다.
에러 메시지가 암호 같다. TypeScript 에러 메시지는 가끔 읽다가 포기하게 만든다. 제네릭이 여러 겹 중첩되면 에러 메시지가 20줄짜리로 나오는데, 핵심은 "이 타입과 저 타입이 안 맞아"인 경우가 대부분이다. 에러 메시지에서 실제 문제를 찾아내는 것 자체가 하나의 스킬이 됐다.
과도한 타입 체조. 가끔 타입을 정의하는 게 목적이 되어버리는 순간이 있다. "이걸 타입 레벨에서 검증할 수 있을까?"라는 도전이 재미있어지면 위험 신호다. 비즈니스 로직은 10줄인데 타입 정의가 30줄인 코드를 본 적 있다. 그건 좋은 코드가 아니다. 타입은 도구다. 목적이 아니다.
설정 지옥. tsconfig.json의 옵션이 너무 많다. paths, baseUrl, moduleResolution, esModuleInterop, resolveJsonModule... 프로젝트 셋업할 때마다 구글링을 한다. 기존 프로젝트의 설정을 복사해오는 경우가 대부분인데, 각 옵션이 정확히 뭘 하는지 100% 이해하고 있다고는 말 못하겠다.
그럼에도 돌아가지 않는 이유
이 모든 불편함에도 불구하고, 이제 JavaScript로 돌아가고 싶지 않다. 이유는 간단하다.
TypeScript가 잡아주는 에러의 대부분은 "사소한" 것들이다. 오타, null 체크 누락, 잘못된 프로퍼티 접근. 이런 사소한 에러가 프로덕션에 나가면 사소하지 않게 된다. 금요일 밤 장애가 된다. 고객이 이탈한다. 핫픽스를 배포하느라 주말이 날아간다.
그리고 타입이 있으면 코드를 읽는 속도가 확연히 다르다. 다른 사람이 작성한 함수를 쓸 때, 파라미터가 뭘 받는지 문서를 찾아보지 않아도 된다. 리턴 값이 뭔지 실행해보지 않아도 안다. 6개월 전에 내가 쓴 코드를 다시 볼 때도 마찬가지다. 타입이 문서 역할을 한다.
리팩토링할 때의 안정감도 크다. 함수 시그니처를 바꾸면 호출하는 쪽에서 전부 빨간 줄이 뜬다. 하나도 놓치지 않고 고칠 수 있다. JavaScript에서는 이게 안 된다. grep으로 찾아서 하나씩 확인해야 하는데, 동적으로 호출되는 경우는 grep으로도 못 잡는다.
나의 현재 원칙
지금 내가 TypeScript를 쓰는 방식을 정리하면 이렇다.
-
any는 마지막 수단이다. 정말 타입을 알 수 없는 외부 데이터에만 제한적으로 쓰고, 그마저도 가능하면unknown으로 대체한다. -
API 응답은 반드시 타입을 정의한다. 서버에서 내려오는 데이터는 우리가 통제할 수 없는 영역이다. 여기에 타입을 두는 게 가성비가 가장 좋다.
-
타입 체조는 하지 않는다. 복잡한 유틸리티 타입을 만들기보다는 단순하고 읽기 쉬운 타입을 선호한다. 동료가 봤을 때 바로 이해할 수 있는 수준.
-
점진적 적용은 유효하다. 처음부터 완벽한 타입 커버리지를 목표로 하면 지친다. 위험한 곳부터, 자주 바뀌는 곳부터, 버그가 났던 곳부터.
돌이켜보면, TypeScript를 싫어했던 건 TypeScript의 문제가 아니라 내가 익숙한 방식을 바꾸기 싫었던 거다. 익숙한 게 편한 건 맞지만, 편한 게 좋은 건 아니다. 금요일 밤의 그 장애가 없었다면 나는 아직도 any를 쓰고 있었을까. 모르겠다. 하지만 확실한 건, 그 장애가 나를 더 나은 개발자로 만들었다는 거다.
TypeScript를 좋아하느냐고 물으면, "좋아한다"보다는 "신뢰한다"가 더 정확한 표현 같다. 좋아하는 건 감정이지만 신뢰는 경험에서 온다. 충분히 데여봤고, 충분히 도움받았고, 그래서 이제는 믿는다.
