타입스크립트는 왜 컴파일 레벨에서 강제하지 않을까?

이펙티브 타입스크립트를 읽다가 문득 궁금했다.
“왜 타입스크립트는 자바나 C#처럼 컴파일 레벨에서 타입을 완전히 강제하지 않을까?”
이 질문의 답을 찾다 보니, 단순한 기술적 이유가 아니라 웹이라는 플랫폼의 철학과 자바스크립트의 역사적 맥락이 깊게 얽혀 있었다.
1. 웹 브라우저 환경의 철학: “부분적 실패를 견딘다”
자바스크립트를 해본 사람이라면 알겠지만, 자바스크립트는 그 어떤 실수에도 관대하다.
undefined를 덧셈해도 죽지 않고, 엉뚱한 타입을 넘겨도 그냥 실행된다.
이런 유연함(혹은 관대함) 은 단순한 결함이 아니라, 웹 환경의 생존 전략이었다.
웹의 기본 철학은 “부분적 실패를 견딘다(Tolerate partial failure)”이다.
이미지 하나가 깨지거나, API 호출이 실패했다고 해서 페이지 전체가 멈춰선 안 된다.
이건 웹이 탄생하던 시절부터의 철학이었다.
자바스크립트의 창시자 브렌던 아이크(Brendan Eich)도 이런 “관용성”을 언어 설계의 핵심으로 삼았다.
결국 타입스크립트도 그 철학을 이어받았다. 강제보다는 안전망을 제공하는 쪽을 택한 것이다.
“코드를 멈추게 하기보다, 개발자에게 경고를 주고 선택의 여지를 남긴다.”
이게 타입스크립트가 JS의 유연함을 유지하면서도 타입 안정성을 더한 이유다.
2. 점진적 마이그레이션 전략: 현실적인 접근

타입스크립트의 핵심 철학은 “기존 자바스크립트 코드와 공존하면서 점진적으로 이행할 수 있는 언어”다.
Microsoft는 타입스크립트를 만들 때 수백만 줄의 JS 코드베이스를 한 번에 바꾸는 게 현실적으로 불가능하다는 걸 알았다.
그래서 내린 결론은 단순했다.
“.js 파일의 확장자를 .ts로 바꾸기만 해도 일단 돌아가게 하자.”
이 전략은 대성공이었다.
- 기존 JS 프로젝트에 부담 없이 적용할 수 있었고,
- 타입 검사만 추가되는 구조라 빌드가 가볍고 빠르며,
- 자바스크립트의 동적 기능과 외부 라이브러리의 호환성을 그대로 유지할 수 있었다.
이 점진적 철학 덕분에 타입스크립트는 단기간에 대규모로 확산될 수 있었다.
3. 실용주의적 선택: “100% 안전보다 80%의 현실적 안전”
타입스크립트는 완벽한 타입 안전성보다는 실용적 안전성(pragmatic safety) 을 택했다.
“100%의 타입 안전”을 강제하는 대신, “20%의 노력으로 80%의 버그를 잡자”는 접근이다.
만약 타입스크립트가 자바처럼 모든 타입 오류를 컴파일 타임에 막았다면?
- 타입 정보가 불완전한 외부 API를 다루기 힘들었을 것이고,
- 타입 선언이 없는 NPM 패키지 대부분과 호환이 깨졌을 것이며,
- 수많은 기존 JS 코드베이스를 수용할 수 없었을 것이다.
결국 완벽을 포기하고, 현실을 받아들인 선택이 타입스크립트를 살렸다.
Anders Hejlsberg(타입스크립트 설계자)는 직접 이렇게 말했다.
“TypeScript is not trying to be sound. It’s trying to be useful.”
(타입스크립트는 완벽하려는 게 아니라, 유용하려는 것이다.)
이 ‘의도적인 불완전함’이 오히려 타입스크립트를 성공으로 이끌었다.
완벽하진 않지만, 현실적으로 가능한 최선의 타입 시스템 이었던 것 같다.
4. 마무리: 진화형 언어로서의 TypeScript
타입스크립트는 자바스크립트를 부정한 언어가 아니다. 오히려 그 유연함과 관용성을 존중하면서,
그 위에 “개발자가 선택할 수 있는 안전망”을 덧씌운 언어다. 즉, 강타입 언어의 안정성과 동적 언어의 자유로움 사이에서 균형을 찾은 언어다. 그 실용주의적 철학이 오늘날 JS 생태계의 표준으로 자리 잡은 이유일 것이다.
타입스크립트는 왜 개발자에게 타입 체커의 관리 권한을 위임하였을까?
타입스크립트가 나온 배경은 이제 알았는데 왜 타입체크 과정을 C#이나 자바처럼 컴파일러에게 위임하는 것이 아니라
개발자에게 설정 권한을 위임하였을까? 그 힌트는 타입스크립트가 그런 구조로 만들어진 배경에 대해 알 수 있었다.
1. 타입스크립트를 만든 철학에서 얻을 수 있는 힌트
Microsoft는 TypeScript를 “강력한(powerful)” 언어가 아니라 “유용한(useful)” 언어로 만들고자 했다.
그렇기 때문에 개발을 하는 개발자의 경험에 대해 중요하게 생각했고 이미 자바스크립트로 만들어진 수백만가지의 프로젝트들의 성향에 맞춰 컴파일러를 만든다는 것은 사실 불가능했을 것이다.
자바스크립트로 만들어진 프로젝트 중에서는 빠르게 실험해야하는 스타트업의 프로젝트부터 안정성을 매우 중요시하는 금융권까지 다양했을 것이다. 그래서 타입스크립트는 하나의 규율에 맞춰 제어하기 보다는 개발자에게 엄격함의 수준을 관리할 수 있도록 위임하여 상황에 따라 유연하게 지정할 수 있게 설계를 해놓은 것이다.
예시코드
const x: string = 42
// 실행은 된다.
console.log(x);
2. 유연한 타입 체커 설정을 통해 얻을 수 있었던 점
a. 프로젝트마다 다른 요구사항 충족
- 빠른 프로토타이핑 → 느슨한 타입으로 실험 속도를 우선시
- 금융·의료 시스템 → 최대한 엄격한 타입으로 안정성 확보
- 레거시 마이그레이션 → 점진적으로 강화하며 리스크 최소화
- 외부 API 통합 → 불완전한 타입 정의를 수용하며 실용성 유지
b. 시간에 따른 점진적 타입 체킹 강화
- 초기 → 느슨한 설정으로 JS에서 TS로 전환
- 중기 → 타입 커버리지를 점진적으로 확대
- 성숙기 → strict 모드로 완전한 타입 안정성 확보
c. 다른 오픈소스 생태계와의 공존
- 타입 검증 → TypeScript 컴파일러
- 런타임 검증 → Zod, Yup 같은 스키마 라이브러리
- 코드 품질과 스타일 → ESLint, Prettier
- 테스트와 보장 → Jest, Vitest
3. TypeScript에서 꼭 알아야 하는 타입 체커 설정
tsconfig.json에는 수십 가지 설정이 있지만, 실제로 모든 옵션이 중요한 것은 아니다.
실무에서 반드시 알아야 하는 핵심 설정만 간결하게 알아보자
1. strict
모든 타입 검사를 강화하는 기본 설정이다.
{
"compilerOptions": {
"strict": true
}
}
- noImplicitAny
- strictNullChecks
- strictFunctionTypes
- strictBindCallApply
- strictPropertyInitialization
- noImplicitThis
- alwaysStrict
추천
- 새 프로젝트는 무조건 strict: true로 시작.
- 기존 JS 프로젝트는 개별 옵션을 순차적으로 적용.
2. noImplicitAny
의미
타입을 추론할 수 없을 때 any를 허용하지 않습니다.
// 비활성화된 경우
function add(a, b) { // a, b는 암묵적으로 any
return a + b;
}
// 활성화된 경우
function add(a: number, b: number) {
return a + b;
}
이유
- any는 타입 검사의 이점을 완전히 무너뜨린다.
- 타입 명시를 강제해 오류를 조기에 잡을 수 있다.
3. strictNullChecks
의미
null과 undefined를 다른 타입으로 구분한다.
function length(str: string) {
return str.length;
}
length(null); // 오류
이유
- Cannot read property of null 류의 런타임 에러를 예방합니다.
- 모든 경로에서 null 가능성을 코드로 표현하도록 강제합니다.
4. noUncheckedIndexedAccess
의미
배열 또는 객체 접근 시 undefined 가능성을 고려합니다.
const users = ["Alice", "Bob"];
const user = users[5]; // string | undefined
이유
- 인덱스 접근은 자주 발생하는 버그의 원인이다.
- undefined 가능성을 타입으로 표시해 방어적 코드를 유도한다.
5. skipLibCheck
의미
외부 라이브러리의 타입 정의(.d.ts) 검사를 건너뜁니다.
{
"compilerOptions": {
"skipLibCheck": true
}
}
이유
- 외부 라이브러리의 오류로 인해 빌드가 막히는 문제를 방지합니다.
- 대규모 프로젝트에서 컴파일 속도를 단축합니다.
6. noImplicitReturns
의미
함수의 모든 코드 경로에서 반환값이 존재해야 합니다.
function discount(price: number): number {
if (price > 100) return 10;
// 반환 누락 시 오류
return 0;
}
이유
- 함수가 암묵적으로 undefined를 반환하는 실수를 방지.
요약
| 설정 | 역활 | 신규 프로젝트 | 레거시 프로젝트 |
| strict | 모든 엄격 모드 활성화 | 필수 | 단계적 적용 |
| noImplicitAny | 암묵적 any 금지 | 필수 | 초기 단계 |
| strictNullChecks | null/undefined 구분 | 필수 | 두 번째 단계 |
| noUncheckedIndexedAccess | 인덱스 접근 안전화 | 권장 | 후순위 |
| noImplicitReturns | 반환값 누락 방지 | 권장 | 선택 |
| skipLibCheck | 외부 타입 검사 생략 | 권장 | 권장 |
타입스크립트의 타입을 런타임에서 적용하는 법
이제는 타입스크립트가 타입을 컴파일 과정에서 타입을 체크하지 않는다는 것을 알게 되었다. 그럼에도 런타임 환경에서 타입을 체크해야되는 경우가 있을 것이다. 그럴 때에는 어떻게 적용 시킬 수 있을까?
1. typeof, instanceof, in 같은 JS 연산자 활용
가장 간단한 방법은 자바스크립트의 내장 연산자를 활용하는 것이다.
function formatCartValue(value: unknown) {
// 숫자면 가격으로 포맷
if (typeof value === "number") {
return `${value.toLocaleString()}원`;
}
// Date 객체면 주문일시로 포맷
if (value instanceof Date) {
return value.toLocaleDateString("ko-KR");
}
// 쿠폰 객체인지 체크
if (value && typeof value === "object" && "discountRate" in value) {
const coupon = value as { discountRate: number };
return `${coupon.discountRate}% 할인`;
}
return String(value);
}
이런 연산자들은 실제 런타임에서도 존재하는 정보이므로, TypeScript는 해당 분기 안에서 타입을 자동으로 좁혀준다
2. 클래스 기반 판별 (instanceof)
class 는 컴파일 후에도 실제 JS 객체로 남기 때문에, 런타임에서 instanceof로 안전하게 구분할 수 있다.
class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public details?: unknown
) {
super(message);
this.name = "ApiError";
}
}
class ValidationError extends Error {
constructor(
message: string,
public fields: Record<string, string>
) {
super(message);
this.name = "ValidationError";
}
}
function handleError(error: unknown) {
if (error instanceof ApiError) {
// TypeScript가 여기서 error를 ApiError로 인식
console.error(`API Error ${error.statusCode}: ${error.message}`);
return { status: error.statusCode, message: error.message };
}
if (error instanceof ValidationError) {
// 여기서는 ValidationError로 인식
console.error("Validation failed:", error.fields);
return { status: 400, errors: error.fields };
}
// 일반 Error
if (error instanceof Error) {
return { status: 500, message: error.message };
}
return { status: 500, message: "Unknown error" };
}
💡 왜 인터페이스가 아닌 클래스인가?
인터페이스는 컴파일 후 완전히 사라지지만,
클래스는 런타임에도 생성자 함수 형태로 남기 때문.
그래서 instanceof, constructor.name 등을 통해 실제 타입을 판별할 수 있다.
3. 판별자(discriminant) 속성으로 유니온 타입 구분
클래스를 쓰지 않고도, 런타임에서 구분 가능한 속성을 직접 둘 수 있다.객체 안에 타입 정보를 심는 방법이다.
type SuccessResponse = {
status: "success";
data: { id: number; name: string };
};
type ErrorResponse = {
status: "error";
error: { code: string; message: string };
};
type ApiResponse = SuccessResponse | ErrorResponse;
function processResponse(response: ApiResponse) {
if (response.status === "success") {
// TypeScript가 여기서 response를 SuccessResponse로 인식
console.log(`Success: ${response.data.name}`);
return response.data;
} else {
// 여기서는 ErrorResponse로 인식
console.error(`Error ${response.error.code}: ${response.error.message}`);
throw new Error(response.error.message);
}
}
이 패턴은 런타임에도 속성(type)이 존재하기 때문에, TS와 JS 양쪽에서 안정적으로 타입을 구분할 수 있다.
4. 스키마 기반 런타임 검증 (Zod, io-ts 등)
외부 API 응답이나 사용자 입력처럼 컴파일러가 알 수 없는 데이터는 스키마 기반 검증 라이브러리로 런타임 보장을 추가해야 한다.
import { z } from "zod";
// API 응답 스키마 정의
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number().positive(),
stock: z.number().min(0),
category: z.enum(["electronics", "clothing", "food"]),
});
const OrderResponseSchema = z.object({
orderId: z.string(),
items: z.array(ProductSchema),
totalAmount: z.number(),
createdAt: z.string().datetime(),
});
// 스키마로부터 타입 자동 추론
type OrderResponse = z.infer<typeof OrderResponseSchema>;
async function fetchOrder(orderId: string): Promise<OrderResponse> {
const response = await fetch(`/api/orders/${orderId}`);
const data = await response.json();
// 런타임 검증 + 타입 보장
return OrderResponseSchema.parse(data); // 실패 시 예외 발생
}
실무에서는 특히 외부 API, 폼 입력, 환경변수처럼 “컴파일러가 알 수 없는 영역”에서 이 방식이 필수.
타입스크립트와 구조적 타이핑의 관계
이펙티브 타입스크립트를 보면 타입스크립트는 구조적 타이핑을 한다고 이야기를 하고 있다.
처음에는 그냥 "형태가 같으면 같은 타입으로 본다"정도로 이해했지만 구조적이라는 의미가 어떤 의미로 사용된 것이고 타입 스크립트는 덕타이핑과 명시적 타이핑을 사용하지 않았는지 궁금해졌다
1. 구조적 타이핑이란?
구조적 타이핑은 타입의 이름이 아니라, 그 구조(Shape)가 같으면 호환된다고 보는 방식을 말한다.
즉, 타입 간의 관계를 “정의된 구조”로 판단하는 것이다.
interface Point {
x: number;
y: number;
}
function distance(p: Point) {
return Math.sqrt(p.x ** 2 + p.y ** 2);
}
const point = { x: 3, y: 4 };
distance(point); // Point로 선언되지 않아도 OK
위 코드에서 point는 Point 타입으로 명시되지 않았지만, 구조가 동일하기 때문에 TypeScript는 타입 호환이 가능하다고 본다.
이게 바로 구조적 타이핑의 기본 개념이다.
2. 덕 타이핑과의 관계
이펙티브 타입스크립트에서는 TypeScript의 구조적 타이핑을 “자바스크립트가 덕 타이핑 기반이고 이를 모델링하기 위해 구조적 타이핑을 사용하고 있다”라고 설명한다. 덕 타이핑이란 다음 문장에서 유래했다.
“If it looks like a duck, swims like a duck, and quacks like a duck — it probably is a duck.”
(오리처럼 생기고, 오리처럼 꽥꽥거린다면 아마 오리일 것이다.)
자바스크립트는 동적 언어다. 따라서 객체가 특정 타입으로 선언되었는지가 아니라, 필요한 속성과 메서드를 가지고 있는가로 판단한다.
function makeSound(animal) {
animal.speak(); // 있으면 실행, 없으면 런타임 에러
}
const dog = { speak: () => console.log("멍멍!") };
const robot = { speak: () => console.log("삐빅!") };
makeSound(dog); // 멍멍!
makeSound(robot); // 삐빅!
dog과 robot은 완전히 다른 객체지만, speak() 메서드를 가지고 있으므로 둘 다 같은 방식으로 동작한다.
이게 바로 런타임의 덕 타이핑이다. TypeScript는 이 덕 타이핑을 컴파일 시점에 구조적으로 검사하도록 만든 언어다.
interface Speakable {
speak(): void;
}
function makeSound(animal: Speakable) {
animal.speak(); // 컴파일 시점에 검증
}
const robot = { speak: () => console.log("삐빅!") };
makeSound(robot); // ✅ 구조가 같으므로 OK
- 자바스크립트: “지금 이 객체가 speak를 갖고 있나?” → 런타임에 판단
- 타입스크립트: “이 타입에 speak가 있나?” → 컴파일 시 판단
덕 타이핑(행동 기반)과 구조적 타이핑(형태 기반)은 같은 철학의 다른 시점 버전이라고 볼 수 있다.
3. 구조적 타이핑의 장점
TypeScript가 구조적 타이핑을 채택한 이유는 명확하다.
자바스크립트 생태계의 유연함을 그대로 유지하면서,
정적 타입의 안정성을 덧붙이기 위해서다.
const point3D = { x: 3, y: 4, z: 5 };
distance(point3D); // ✅ 필요한 속성(x, y)만 있으면 통과
- 유연함을 잃지 않고,
- 불필요한 선언이나 상속 없이,
- 형태(Shape)만으로 타입 호환이 가능하다.
이 덕분에 기존 JS 코드를 큰 수정 없이 TS로 옮길 수 있고,
“점진적 마이그레이션(Gradual Typing)”이 가능해진다.
정리
| 개념 | 판단 시점 | 판단 기준 | 예시 언어 |
| 덕 타이핑 | 런타임 | 실제 속성과 메서드 존재 여부 | JavaScript, Python |
| 구조적 타이핑 | 컴파일 타임 | 타입 정의상 속성 존재 여부 | TypeScript, Go |
| 명목적 타이핑 | 컴파일 타임 | 선언된 타입 이름의 일치 여부 | Java, C# |
Any 타입을 쓰면 안되는 이유

이펙티브 타입스크립트에서는 any 타입 사용을 지양하라고 말한다.
당연해 보이지만, 그 이유를 한 번 짚고 넘어갈 필요가 있다.
TypeScript는 자바스크립트의 자율성을 일정 부분 제한하고, 그 대신 예측 가능한 코드 실행을 보장하기 위해 만들어진 언어다.
그런데 any는 이 약속을 완전히 무너뜨린다.
any가 등장하는 순간, TypeScript의 타입 검사는 중단되고, 런타임 오류를 예방할 수 있는 모든 장치가 사라진다.
1. 타입 안전성 무력화
any를 쓰면 모든 타입 검사가 사라진다.
컴파일은 통과하지만, 런타임에서 오류가 터진다.
let value: any = "hello";
value = 123;
value.nonExist(); // ❌ 컴파일 OK → 런타임 에러 💥
any는 "TypeScript를 쓰지만, 검사하지 말자"는 선언과 같다.
2. 함수 계약이 깨트린다
any는 함수의 입력과 출력 간의 계약을 파괴한다.
이러면 IDE가 알려주는 자동 완성이나 타입 추론도 모두 무의미해진다.
function discount(price: any, rate: any) {
return price - price * rate; // 💥 NaN 가능
}
3. 전염성이 높다
any는 한 곳에서 쓰이면, 그 값을 전달받는 모든 코드로 퍼진다.
function getUser(): any {
return { name: "Alice" };
}
const user = getUser();
user.id.toUpperCase(); // ❌ 에러는 안 나지만 런타임에 터짐
4. IDE와 도구의 도움을 잃는다
자동 완성, 리팩토링 추적, 타입 추론 — any를 쓰는 순간 다 사라진다.
결국, TypeScript의 가장 큰 장점을 포기하는 셈이다.
any 대신 사용할 수 있는 대안
| 타입을 아직 모를 때 | unknown | 안전하게 처리 필요 |
| 다양한 타입이 올 때 | `union (A | B)` |
| 타입을 추후 주입할 때 | generic <T> | 재사용성 + 타입 안전성 |
| 일부만 알고 있을 때 | Partial<T> | 부분적 타입 정의 |
Any를 사용해도 되는 경우
| JS → TS 마이그레이션 초기 | 임시 우회 수단 |
| 외부 라이브러리 타입 없음 | 타입 정의 부재 보완 |
| 동적 JSON 구조 | 파싱/검증 전 임시 타입 |
타입스크립트의 배경을 이해하고 드는 생각
나는 C#으로 커리어를 시작했고, Java로 백엔드를 배우며 강한 타입 시스템의 장점을 몸으로 익혔다.
그 뒤 Ruby를 사용하면서 언어의 자유도가 가져오는 생산성과 동시에, 예측하기 어려운 문제들도 직접 경험했다.
그리고 Kotlin과 TypeScript처럼 강타입과 약타입의 중간지점에 있는 언어들을 다뤄보며,
결국 모든 언어는 장점이 곧 단점이 될 수 있다는 것을 깨달았다.
예를 들어, Java는 타입 안정성을 극단적으로 추구하는 대신 코드의 유연성을 잃고,
매 빌드마다 긴 컴파일 시간을 감수해야 한다. 반면 Ruby는 빠르고 유연하지만, 자유도가 높을수록 의존 관계가 복잡해지고 런타임 오류를 예측하기 어려워진다.
즉, 안정성과 유연성은 항상 맞바꿔야 하는 가치였다.
이런 점에서 흥미로운 건, 언어들이 시간이 지날수록 서로의 장점을 가져오고 있다는 것이다. 자바스크립트는 점점 타입을 도입하고, 자바는 더 간결한 문법과 동적 바인딩을 받아들이고 있다.
결국 모든 언어는 “자신의 철학을 지키는 것”보다 “개발자가 얼마나 유용하게 쓸 수 있는가”를 더 중요하게 생각하게 되었다. 결론적으로, 현대 언어는 철학보다 실용성의 균형을 추구한다.
'조금 불완전하더라도 더 많은 개발자가 “쓸 만하다”고 느낀다면, 그 언어는 오히려 오픈소스 시장에서 더 큰 사랑을 받을 수 있는 것이 아닐까?' 라는 생각이 들었다.
'Javascript > Node.js' 카테고리의 다른 글
| Node.js 란? (2) | 2021.08.20 |
|---|