TypeScript의 strict mode: 무엇을 활성화하는가
1. 개요
"strict": true는 tsconfig.json에서 가장 중요한 줄입니다. 하지만 많은 개발자가 이를 활성화하면서 실제로 무엇을 켰는지 모르고 있습니다. 이 글에서는 strict가 활성화하는 각 플래그, 각 플래그가 잡는 실제 오류, 그리고 추가로 활성화해야 할 몇 가지 플래그를 살펴봅니다. 마지막 부분에서는 기존 프로젝트를 strict로 전환하되 한 번에 수천 개의 오류를 수정하지 않는 방법을 보여줍니다.
2. strict는 플래그의 그룹입니다
strict는 독립적인 검사가 아닙니다. 여러 플래그를 한 번에 활성화하는 마스터 스위치입니다.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true
}
}
TypeScript 5.x와 6.x에서 strict: true는 다음과 같습니다:
{
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"strictBuiltinIteratorReturn": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true
}
}
알아야 할 두 가지가 있습니다:
- TypeScript가 새로운 “strict” 검사를 추가할 때마다 (예: 4.4의
useUnknownInCatchVariables또는 5.6의strictBuiltinIteratorReturn), 그 검사는 자동으로strict와 함께 활성화됩니다. 따라서 TypeScript를 업그레이드하면 코드를 건드리지 않았는데도 새로운 오류가 나타날 수 있습니다. strict를 활성화한 후 개별 플래그를 비활성화할 수 있습니다. 구체적인 플래그가 항상 일반 플래그를 이깁니다.
TypeScript 6.0부터 strict의 기본값이 true로 변경되었습니다. 그래도 tsconfig.json에 명시적으로 적어 두면, 설정을 읽는 사람이 추측할 필요가 없습니다.
아래 예제를 시도하려면 TypeScript를 설치하고 tsc --noEmit을 실행하면 됩니다:
npm install --save-dev typescript
npx tsc --noEmit
3. 가장 중요한 플래그들
3.1. noImplicitAny
TypeScript가 타입을 유추할 수 없을 때, 암묵적으로 any를 할당합니다. 이 플래그는 이런 경우를 오류로 변환합니다.
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
error TS7006: Parameter 'items' implicitly has an 'any' type.
이 플래그가 없으면 items는 any입니다. 그러면 item.prise (오타)도 정상적으로 컴파일됩니다. 해결 방법은 타입을 명시적으로 선언하는 것입니다:
interface OrderItem {
name: string;
price: number;
}
function calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
이 플래그는 암묵적 any만 차단합니다. 정말 필요하면 여전히 명시적으로 any를 쓸 수 있습니다.
3.2. strictNullChecks
가장 가치 있는 플래그입니다. 비활성화하면 null과 undefined를 모든 타입에 할당할 수 있습니다. 활성화하면 이들이 독립적인 타입이 되므로 명시적으로 처리해야 합니다.
interface Customer {
id: number;
email: string;
}
const customers: Customer[] = [{ id: 1, email: "an@example.com" }];
function getCustomerEmail(id: number): string {
const customer = customers.find((c) => c.id === id);
return customer.email;
}
error TS18048: 'customer' is possibly 'undefined'.
Array.prototype.find는 Customer | undefined를 반환합니다. 플래그가 비활성화되면 TypeScript는 undefined를 무시하고, Cannot read properties of undefined 오류는 런타임에만 나타납니다. 해결 방법:
function getCustomerEmail(id: number): string | undefined {
const customer = customers.find((c) => c.id === id);
return customer?.email;
}
이제 함수는 string | undefined를 반환합니다. 반환 타입이 사실을 말하므로, 이 함수를 호출하는 코드도 찾지 못한 경우를 처리해야 합니다.
참고: non-null 단언 연산자
customer!.email로 오류를 “수정”하지 마세요.!연산자는 컴파일러를 침묵시킬 뿐이며, 아무것도 확인하지 않습니다. 값이 반드시 존재한다고 확신하고 컴파일러가 볼 수 없는 이유가 있을 때만 사용하세요.
3.3. strictPropertyInitialization
이 플래그는 클래스의 모든 property를 반드시 초기화해야 합니다. 선언 시 즉시 할당하거나 생성자에서 할당해야 합니다. strictNullChecks가 활성화되어야 작동합니다.
class PaymentService {
apiKey: string;
retries = 3;
constructor(private readonly baseUrl: string) {}
}
error TS2564: Property 'apiKey' has no initializer and is not definitely assigned in the constructor.
세 가지 방법으로 처리할 수 있습니다:
class PaymentService {
apiKey: string; // 1. 생성자에서 할당
webhookSecret?: string; // 2. optional로 선언
client!: HttpClient; // 3. definite assignment: "나를 믿어, 할당될 거야"
constructor(apiKey: string) {
this.apiKey = apiKey;
}
}
interface HttpClient {
post(url: string, body: unknown): Promise<unknown>;
}
방법 3 (!)은 dependency injection이나 decorator를 사용하는 프레임워크 (Angular, TypeORM 등)에서 자주 보입니다. 생성자가 끝난 후 property가 할당되기 때문입니다. 이런 경우 외에는 방법 1이나 2를 선호하세요.
3.4. useUnknownInCatchVariables
JavaScript에서는 무엇이든 throw할 수 있으므로 반드시 Error일 필요가 없습니다. 이 플래그는 catch 블록의 변수 타입을 any에서 unknown으로 변경합니다.
async function chargeOrder(orderId: string): Promise<void> {
try {
await fetch(`/api/orders/${orderId}/charge`, { method: "POST" });
} catch (err) {
console.error(err.message);
}
}
error TS18046: 'err' is of type 'unknown'.
err를 사용하려면 먼저 타입을 좁혀야 합니다:
catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Charge failed: ${message}`);
}
3.5. strictFunctionTypes
이 플래그는 함수 타입의 매개변수를 contravariant 방식으로 검사합니다. 간단히 말해, 더 좁은 타입만 처리할 수 있는 함수를 더 넓은 타입을 처리해야 하는 곳에 전달할 수 없습니다.
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
const handleDog = (dog: Dog) => dog.bark();
const handleAnimal: (animal: Animal) => void = handleDog;
error TS2322: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
위 할당이 허용되면 handleAnimal({ name: "Mèo" })를 호출할 때 crash되는데, 전달된 객체에 bark가 없기 때문입니다. 알아야 할 중요한 예외가 하나 있습니다: 이 플래그는 interface의 method(x: T): void 구문으로 선언된 메서드에는 적용되지 않습니다. 이는 기존 API와의 호환성을 유지하기 위한 TypeScript의 의도적인 선택입니다.
3.6. 나머지 세 플래그
이 세 플래그는 새 코드에서 드물게 오류를 발생시키지만 여전히 알아야 할 가치가 있습니다:
function formatPrice(amount: number, currency: string): string {
return `${amount.toFixed(2)} ${currency}`;
}
formatPrice.call(null, "100", "VND"); // strictBindCallApply
function onClick() {
console.log(this.id); // noImplicitThis
}
error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
error TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.
strictBindCallApply:call,apply,bind를 호출할 때 매개변수를 검사합니다.noImplicitThis:this가 암묵적으로any타입을 가질 때 오류를 발생시킵니다.function onClick(this: HTMLButtonElement)로this매개변수를 선언해서 수정합니다.alwaysStrict: 각 파일의 출력에"use strict"를 내보내고 JavaScript의 strict mode로 코드를 파싱합니다. ES module을 사용하면 strict mode는 기본값입니다.
4. strict가 활성화하지 않는 플래그들
많은 사람들이 strict: true가 가장 엄격한 수준이라고 생각합니다. 그렇지 않습니다. 다음 플래그들은 strict 그룹 밖에 있지만 새 프로젝트에 매우 권장됩니다:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
이들 중 noUncheckedIndexedAccess가 가장 중요합니다. 기본적으로 TypeScript는 배열 인덱스 접근이나 객체 동적 키 접근이 항상 값을 반환한다고 가정합니다:
const stockBySku: Record<string, number> = { "SKU-1": 10 };
const quantity = stockBySku["SKU-999"];
console.log(quantity.toFixed(0));
이 플래그가 없으면 quantity는 number 타입이고 런타임에 crash됩니다. 활성화하면 quantity는 number | undefined 타입이 됩니다:
error TS18048: 'quantity' is possibly 'undefined'.
나머지 플래그들:
exactOptionalPropertyTypes:discount?: number는 “키가 없을 수도 있음”을 의미하지만, 명시적으로discount: undefined를 할당하는 것은 허용하지 않습니다.noImplicitOverride: 부모 클래스의 메서드를 오버라이드할 때override키워드가 필요합니다. 부모 클래스에서 메서드 이름을 바꾸면, 자식 클래스에서 오류가 발생하므로 새로운 메서드가 암묵적으로 생성되는 실수를 방지합니다.noImplicitReturns: 함수의 모든 분기가return을 해야 합니다.noFallthroughCasesInSwitch:switch에서break를 빼먹은 경우를 차단합니다.
참고:
noUncheckedIndexedAccess는 상당히 “시끄럽습니다”.for (let i = 0; i < arr.length; i++)루프도arr[i]를T | undefined타입으로 만듭니다.for...of나arr.forEach로 전환하세요. 기존의 큰 프로젝트에서는 이 플래그를 마지막에 활성화하는 것이 좋습니다.
5. 테스트로 동작 확인하기
타입 체크는 컴파일 시점에 실행되지만, 여전히 런타임 동작과 반환 타입 모두에 대한 테스트를 작성해야 합니다. Vitest는 둘 다 지원합니다:
npm install --save-dev vitest
// customer.ts
export interface Customer { id: number; email: string; }
const customers: Customer[] = [{ id: 1, email: "an@example.com" }];
export function getCustomerEmail(id: number): string | undefined {
return customers.find((c) => c.id === id)?.email;
}
// customer.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { getCustomerEmail } from "./customer";
describe("getCustomerEmail", () => {
it("고객을 찾으면 이메일을 반환한다", () => {
expect(getCustomerEmail(1)).toBe("an@example.com");
});
it("찾지 못하면 undefined를 반환한다", () => {
expect(getCustomerEmail(42)).toBeUndefined();
});
it("반환 타입에 undefined가 포함된다", () => {
expectTypeOf(getCustomerEmail).returns.toEqualTypeOf<string | undefined>();
});
});
세 번째 테스트는 type test입니다. 나중에 누군가 함수 시그니처를 : string으로 바꾸고 !로 강제 변환한다면, vitest --typecheck (또는 테스트 파일에서 tsc --noEmit)를 실행할 때 반환 타입이 string | undefined와 맞지 않으므로 오류가 발생합니다.
npx vitest run --typecheck
✓ customer.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
6. 기존 프로젝트를 strict로 마이그레이션하기
큰 codebase에서 strict: true를 활성화하면 수천 개의 오류가 발생할 수 있습니다. 실제 방법은 단계별로 진행하는 것입니다.
단계 1: strict를 활성화하지만 가장 “시끄러운” 플래그들을 임시로 비활성화합니다.
{
"compilerOptions": {
"strict": true,
"strictNullChecks": false,
"strictPropertyInitialization": false,
"useUnknownInCatchVariables": false
}
}
noImplicitAny는 보통 가장 수정하기 쉬우므로 먼저 처리하세요. 각 플래그를 완료할 때마다 해당 비활성화 줄을 제거하세요. 새로 작성된 코드는 자동으로 활성화된 모든 검사를 받게 됩니다.
단계 2: 플래그별로 오류를 세어서 작업량을 추정합니다.
npx tsc --noEmit --strictNullChecks true | grep -c "error TS"
단계 3: strictNullChecks로 디렉토리별로 수정합니다. 아직 수정하지 못한 부분은 // @ts-expect-error로 임시 표시하되 이유를 기록하세요. @ts-ignore을 사용하지 마세요. 이유: @ts-expect-error는 아래 줄에 더 이상 오류가 없으면 자동으로 오류를 보고하므로, 불필요한 주석을 놓치지 않습니다.
// @ts-expect-error TODO(strict): customer가 undefined일 수 있음, PR #482에서 처리
const email: string = findCustomer(id).email;
단계 4: CI에서 회귀를 방지합니다. pipeline에 tsc --noEmit을 추가해서 새로운 타입 오류를 유발하는 코드를 merge하지 못하도록 합니다.
참고:
skipLibCheck를 남용하거나any를 마구 사용해서 “빨리” strict를 “활성화”하려는 시도는 하지 마세요.skipLibCheck: true는.d.ts파일 검사만 건너뛰므로 괜찮습니다. 하지만 코드 곳곳에any를 뿌리면 strict로 전환하는 이점이 모두 사라집니다.
7. 결론
strict: true는 아홉 가지 검사를 활성화합니다. 가장 큰 가치를 제공하는 두 검사는 noImplicitAny와 strictNullChecks입니다. 이들은 대부분의 undefined is not a function과 Cannot read properties of undefined 오류를 컴파일 시점에 차단합니다. 새 프로젝트에서는 처음부터 strict를 noUncheckedIndexedAccess, noImplicitOverride, exactOptionalPropertyTypes와 함께 활성화하세요. 기존 프로젝트에서는 strict를 활성화한 후 플래그를 하나씩 비활성화하고, 쉬운 것부터 어려운 것까지 순서대로 수정하되, CI를 사용해서 오류 개수가 줄어들기만 하는지 확인하세요.