LANGUAGE DOSSIER
TypeScript는 제품 팀이 커질수록 프런트엔드와 API 계약을 단단하게 붙잡아 준다
복잡한 웹 앱, BFF 서버, 설계가 자주 바뀌는 제품. 빠른 실험 속도는 유지하되 계약 오류를 줄이고 싶을 때 효율이 크다.
JavaScript의 민첩함을 유지하면서도 팀이 커질수록 생기는 “말 안 맞음”을 줄여 주는 언어가 TypeScript다.
Why It Works
이럴 때 특히 힘을 발합니다
계약 오류를 미리 본다
화면과 API 사이에서 필드 누락, 상태 이름 불일치 같은 문제를 배포 전에 잡을 수 있다.
도메인 용어를 코드에 남긴다
타입 이름이 곧 제품 언어가 되어 팀 회의와 개발이 더 가깝게 붙는다.
팀 확장에 유리하다
새로 합류한 개발자도 타입 선언을 따라가며 화면 흐름과 데이터 계약을 빠르게 이해할 수 있다.
Business Scenes
사업 현장에서 자주 보이는 장면
대시보드 제품
테이블, 필터, 상태 배지, 권한 분기처럼 복잡한 화면에서 데이터 계약을 안정적으로 유지할 수 있다.
BFF 서버
프런트엔드가 원하는 모양으로 응답을 조립하는 레이어에서 계약과 변환 책임을 분명히 하기에 좋다.
디자인 시스템
컴포넌트의 상태와 props 규칙을 타입으로 문서화해 팀 간 오해를 줄인다.
The Boundary
타입이 지켜 주는 것과 지켜 주지 못하는 것
TypeScript를 정확히 이해하는 가장 빠른 방법은 한 문장을 받아들이는 것입니다. 타입은 컴파일이 끝나면 사라집니다. 빌드 결과물은 타입 표기가 지워진 JavaScript이고, 실행 시점에는 어떤 검사도 남아 있지 않습니다. 어떤 값을 User 타입이라고 선언해도, 실제로 그 자리에 들어온 것이 서버가 보낸 이상한 JSON이라면 프로그램은 아무 불평 없이 그대로 굴러갑니다. 그러다 화면 어딘가에서 undefined를 읽는 순간 터집니다.
그래서 타입이 지켜 주는 범위는 명확합니다. 내가 쓴 코드끼리의 앞뒤가 맞는지, 함수가 요구하는 모양과 넘기는 값의 모양이 일치하는지, 유니온 분기를 빠뜨리지 않았는지, 이름을 바꿨을 때 참조를 전부 고쳤는지. 이 범위 안에서는 강력합니다. 필드 하나를 지웠을 때 그 필드를 쓰던 컴포넌트가 전부 빨간 줄로 표시되는 경험은 규모가 커질수록 값어치가 커집니다.
지켜 주지 못하는 것은 프로그램 바깥에서 들어오는 모든 것입니다. HTTP 응답, JSON.parse 결과, localStorage에 저장해 둔 값, URL 쿼리 스트링, 폼 입력, 환경 변수, 외부 SDK가 돌려주는 객체. 여기에 타입 표기를 붙이는 행위는 검사가 아니라 선언일 뿐입니다. as User는 컴파일러에게 확인시키는 것이 아니라 확인을 그만두게 하는 것에 가깝습니다. 실제로 프로덕션에서 나는 TypeScript 프로젝트의 런타임 오류는 대부분 이 경계에서 발생합니다. API 스펙이 조용히 바뀌었는데 타입 선언만 예전 그대로였던 경우입니다.
결론은 단순합니다. 타입 검사는 내부 일관성을 지키고, 경계에서는 런타임 검증을 따로 두어야 합니다. 이 둘을 같은 것으로 착각하면 "타입을 붙였는데도 왜 터지지"라는 질문에서 벗어나기 어렵습니다. HTTP 호출과 응답 처리 패턴은 HTTP 요청 비교와 JSON 처리 비교에서 다른 언어와 나란히 볼 수 있습니다.
구조적 타이핑이 만드는 착각
TypeScript의 타입은 이름이 아니라 모양으로 비교됩니다. 서로 아무 관계도 선언하지 않은 두 타입이라도 필드 구성이 맞으면 호환됩니다. type UserId = { value: string }와 type OrderId = { value: string }는 완전히 다른 개념이지만 컴파일러 입장에서는 같은 모양이므로 서로 대입됩니다. 클래스도 마찬가지라, 상속 관계가 없어도 메서드 시그니처만 맞으면 통과합니다. 자바나 C#에서 온 사람이 가장 자주 헛다리를 짚는 지점입니다.
이 성질은 장점이기도 합니다. 남이 만든 객체를 내 인터페이스에 맞추기 위해 상속을 끌어올 필요가 없고, 함수가 요구하는 최소한의 모양만 적어 두면 그 모양을 만족하는 어떤 값이든 받을 수 있습니다. 문제는 도메인 식별자처럼 "모양은 같지만 절대 섞이면 안 되는" 값들입니다. 사용자 ID 자리에 주문 ID를 넣어도 컴파일러가 잡아 주지 않습니다. 실무에서는 브랜드 필드를 하나 얹어 구분하거나, 최소한 그런 자리를 코드 리뷰에서 신경 써서 봅니다.
여기에 any가 겹치면 착각이 커집니다. any는 값을 아무 타입으로나 흘려보내면서 그 값이 지나가는 경로 전체의 검사를 조용히 꺼 버립니다. 한 곳에 붙인 any가 서너 단계 아래 함수까지 검사 없이 통과시키는 일이 흔합니다. 타입을 모르겠을 때 써야 하는 것은 unknown 쪽입니다. unknown은 받아 두기는 하되, 쓰려면 먼저 좁혀야 합니다. typeof 검사나 in 연산자, 사용자 정의 타입 가드를 통과하기 전까지는 어떤 속성도 읽을 수 없습니다. 검사를 강제한다는 점에서 any와 정반대입니다. 팀 규칙으로 any를 금지하고 unknown과 타입 가드를 쓰게 하는 것만으로도 경계 사고가 눈에 띄게 줄어듭니다.
제네릭도 처음에는 어렵게 보이지만, 실제로 하는 일은 "들어온 타입을 그대로 이어서 흘려보내기"입니다. Record<string, number>나 배열의 map처럼 이미 매일 쓰고 있고, 추론이 대부분을 대신해 줍니다. 타입 인자를 일일이 적고 있다면 대개 필요 이상으로 어렵게 쓰고 있다는 신호입니다. 함수 타입과 제네릭을 정리하고 싶다면 TypeScript 함수 가이드가 출발점으로 적당합니다.
strict 설정이 곧 프로젝트의 성격입니다
같은 TypeScript 프로젝트라도 tsconfig 하나로 전혀 다른 언어처럼 느껴집니다. 실질적인 갈림길은 strict 계열 옵션, 그중에서도 null 검사입니다. 이것을 켜면 값이 없을 수 있는 자리를 컴파일러가 전부 지적하고, 개발자는 그때마다 "없으면 어떻게 할 것인가"를 코드에 적어야 합니다. 처음에는 성가시지만, 실제 서비스에서 가장 자주 나는 오류가 없는 값을 읽는 오류라는 점을 생각하면 이 성가심이 대부분의 값을 만들어 냅니다. 반대로 꺼 두면 타입이 붙어 있어도 안전망은 거의 없고, 자동완성이 잘 되는 JavaScript에 가깝습니다.
기존 JavaScript 코드베이스를 옮기는 중이라면 처음부터 전부 켜기 어렵습니다. 이럴 때는 새로 만드는 디렉터리부터 엄격하게 가고, 기존 파일은 옮길 때마다 하나씩 정리하는 방식이 현실적입니다. 다만 "나중에 켜자"로 미룬 프로젝트가 실제로 켜는 경우는 드물다는 점은 알고 시작하는 편이 낫습니다.
Workflow
팀이 움직이는 방식
- 컴포넌트보다 도메인 타입 이름을 먼저 정한다.
- API 변경은 화면 수정 전에 타입 차이부터 살핀다.
- 런타임 검증이 필요한 경계와 컴파일 타임 보장으로 충분한 경계를 구분한다.
- 타입이 너무 복잡해지면 설계가 아니라 제품 규칙이 꼬인 건 아닌지 먼저 본다.
계약이 드러나는 타입 예시
type OrderSummary = {
id: string;
total: number;
status: 'draft' | 'paid' | 'refunded';
};
function toLabel(order: OrderSummary) {
return order.status === 'paid' ? '결제 완료' : '후속 확인 필요';
}
타입 이름 하나가 API 문서와 회의 언어를 동시에 정리해 줄 때가 많다.
Reading The Code
위 예제가 왜 저렇게 생겼는지
앞의 예제에서 status를 string으로 두지 않고 세 개의 문자열 리터럴 유니온으로 좁힌 것이 핵심입니다. string이면 오타가 난 상태값도 컴파일을 통과하고, 상태가 하나 추가되어도 아무 곳에서도 경고가 나지 않습니다. 유니온으로 적어 두면 오타는 그 자리에서 걸리고, 나중에 상태가 늘었을 때 이 타입을 쓰는 코드 중 처리되지 않은 분기를 컴파일러가 찾아 줍니다. 즉 타입 선언이 문서 역할과 변경 감지 역할을 같이 합니다.
또 하나는 함수가 OrderSummary 전체를 인자로 받는다는 점입니다. status만 받아도 되지 않느냐는 이야기가 나올 수 있는데, 표시 문구를 만드는 규칙이 나중에 금액이나 환불 여부까지 보게 되는 일이 흔하기 때문에 도메인 객체 단위로 받는 편이 변경에 강합니다. 반대로 정말 status만 쓰는 순수 함수라면 좁게 받는 것이 재사용에 유리합니다. 어느 쪽이 맞다기보다, 이 함수가 도메인 규칙인지 표시 유틸리티인지를 정하고 그에 맞추는 문제입니다.
주의할 점도 있습니다. 예제의 삼항 연산자는 paid가 아니면 전부 "후속 확인 필요"로 뭉갭니다. 상태가 늘어나도 컴파일 오류가 나지 않으므로, 실제 코드에서는 switch로 모든 값을 나열하고 남는 경우를 never로 받아 처리 누락을 컴파일 타임에 잡는 방식을 씁니다. 유니온을 좁혀 쓰는 이점은 이렇게 분기를 강제할 때 온전히 나옵니다.
경계에서 unknown을 좁혀 받기
type OrderStatus = OrderSummary['status'];
const STATUSES: readonly OrderStatus[] = ['draft', 'paid', 'refunded'];
function isOrderSummary(value: unknown): value is OrderSummary {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === 'string' &&
typeof v.total === 'number' &&
STATUSES.includes(v.status as OrderStatus)
);
}
export async function fetchOrder(id: string): Promise<OrderSummary> {
const res = await fetch(`/api/orders/${id}`);
if (!res.ok) throw new Error(`order fetch failed: ${res.status}`);
const body: unknown = await res.json(); // any가 아니라 unknown
if (!isOrderSummary(body)) {
throw new Error('order response shape changed');
}
return body; // 여기부터는 타입이 보장됩니다
}
res.json()의 결과를 unknown으로 받아 두면 검사를 통과하기 전에는 아무 속성도 읽을 수 없습니다. 서버 스펙이 바뀌면 화면 어딘가가 아니라 이 함수에서 즉시 실패합니다.
경계에서 검증하기
위 방식은 손으로 쓴 타입 가드입니다. 필드가 몇 개일 때는 충분하지만, 응답이 중첩되고 배열이 섞이기 시작하면 검사 코드가 타입 선언보다 길어집니다. 그래서 실무에서는 스키마를 한 번 정의하고 거기서 타입과 검증기를 동시에 얻어 내는 스키마 검증 라이브러리를 씁니다. 어떤 도구를 쓰든 원칙은 같습니다. 검증은 시스템의 바깥 경계에서 한 번만 하고, 그 안쪽에서는 타입을 믿고 씁니다. 컴포넌트마다 방어 코드를 넣기 시작하면 어디까지 믿어도 되는지 아무도 모르게 됩니다.
경계는 API 응답만이 아닙니다. localStorage에서 읽은 값, URL 파라미터, 폼 데이터, 환경 변수, 서버가 받는 요청 본문이 모두 경계입니다. 특히 요청 본문은 프런트엔드 타입과 같은 정의를 공유한다고 해서 검증을 생략하면 안 됩니다. 클라이언트는 신뢰 대상이 아니기 때문입니다. API 계층 설계 관점은 API 시스템 설계 정리에서, 실패를 어떻게 값으로 다룰지는 TypeScript 에러 처리 가이드에서 이어집니다.
검증 실패를 그냥 던지지 말고 어떤 필드가 왜 어긋났는지 로그에 남겨 두면, 스펙 변경을 하루 만에 찾아냅니다. 화면에서 undefined가 났다는 제보는 원인 추적에 며칠이 걸리지만, "orders 응답의 total이 문자열로 왔다"는 로그는 바로 백엔드 대화로 이어집니다.
빌드, 테스트, 배포에서 실제로 결정할 것들
타입 검사와 번들링을 나눈다
요즘 번들러들은 타입 표기를 지우고 변환만 할 뿐 타입을 검사하지 않습니다. 빠른 대신 오류가 그대로 통과합니다. 그래서 개발 서버는 번들러가 돌리더라도, CI에는 타입 검사만 수행하는 단계를 따로 둡니다. 이 단계를 빼면 타입 오류가 있는 코드가 배포될 수 있습니다.
린트와 테스트의 역할을 구분한다
ESLint는 타입이 잡지 못하는 습관을 잡습니다. 처리하지 않은 Promise, 쓰지 않는 변수, 금지한 any 사용 같은 것들입니다. 테스트는 Vitest나 Jest로 런타임 동작을 확인합니다. 타입이 맞는다는 것과 로직이 맞는다는 것은 전혀 다른 얘기라, 둘 다 필요합니다.
패키지로 낼 때는 타입도 함께 낸다
사내 공용 패키지를 만든다면 선언 파일을 함께 배포하고, package.json에서 타입 진입점을 정확히 가리켜야 합니다. 모노레포라면 프로젝트 참조로 패키지 간 경계를 나눠 두면 전체 재검사를 피할 수 있습니다.
서드파티 타입의 품질 차이도 미리 알아 두면 좋습니다. 라이브러리가 직접 제공하는 타입은 대체로 정확하지만, 커뮤니티가 따로 관리하는 선언 파일은 실제 구현보다 늦거나 느슨한 경우가 있습니다. 자동완성이 잘 된다고 해서 그 시그니처가 런타임 동작과 같다고 믿을 근거는 없습니다. 이상하면 라이브러리 문서와 실제 반환값을 확인하는 편이 빠릅니다. 테스트 구성은 TypeScript 테스트 가이드에 정리해 두었습니다.
JavaScript에 머물러도 되는 경우
TypeScript는 JavaScript 위에 컴파일 단계와 설정 파일과 도구 하나를 더 얹는 선택입니다. 얹을 값이 나오는 상황과 그렇지 않은 상황을 구분해 두면 팀 논쟁이 짧아집니다.
- 여러 명이 같은 코드베이스를 오래 고치고, 사람이 계속 바뀐다면 타입이 대신 설명해 주는 몫이 큽니다.
- 프런트엔드와 서버가 주고받는 데이터 모양이 자주 바뀐다면, 바뀐 순간 어디가 깨지는지 즉시 알 수 있다는 것만으로도 값을 합니다.
- 디자인 시스템이나 사내 공용 라이브러리처럼 남이 쓰는 코드를 만든다면, 타입이 곧 사용 설명서가 됩니다.
- 대규모 리팩터링이 예정되어 있다면, 이름 변경과 시그니처 변경의 영향 범위를 컴파일러가 훑어 주는 것이 큽니다.
- 지금은 아닙니다 — JavaScript 자체가 아직 손에 익지 않았다면 아닙니다. 비동기와 스코프에서 헤매는 중에 타입 오류까지 겹치면 원인 구분이 안 됩니다. JavaScript 예제와 비동기 가이드를 먼저 보는 편이 빠릅니다.
- 지금은 아닙니다 — 며칠 쓰고 버릴 스크립트, 일회성 크롤러, 짧은 데모라면 설정과 빌드 비용이 얻는 것보다 큽니다.
- 지금은 아닙니다 — 팀이 strict를 끄고 any를 자유롭게 쓰기로 이미 정했다면, 도입해도 안전망은 거의 생기지 않고 빌드 단계만 늘어납니다. 규칙에 합의한 뒤에 넣는 편이 낫습니다.
도입하기로 했다면 문법을 처음부터 훑기보다, 지금 쓰는 코드의 경계 한 곳을 골라 타입을 붙여 보는 편이 감이 빨리 옵니다. 기초 가이드에서 시작해 예제 모음으로 손을 풀면 됩니다.
리뷰 노트
TypeScript로 프런트와 BFF 계약을 다시 맞춘 날
제품팀이 커질수록 생기는 문제는 속도보다 용어 불일치였다. 팀은 타입 리뷰를 설계 리뷰처럼 다뤘다.