Ch 03. strict 모드 점진적 적용
파일 전환이 끝났습니다. 이제 프로젝트는 TypeScript로 작성되어 있지만, 아직 느슨한 상태입니다. strict: false로 운영하고 있으니 TypeScript가 잡아줄 수 있는 많은 오류를 그냥 통과시키고 있습니다.
TypeScript의 진짜 힘은 strict: true 에서 나옵니다. 하지만 기존 코드베이스에 갑자기 strict: true를 켜면 수백 개의 에러가 쏟아집니다. 점진적으로 켜야 합니다.
strict가 담고 있는 것들
"strict": true는 단일 옵션이 아닙니다. 여러 옵션을 묶은 편의 설정입니다.
// strict: true와 동일한 효과{ "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, "noImplicitThis": true, "useUnknownInCatchVariables": true, "alwaysStrict": true}
각 옵션을 하나씩 켜면서 발생하는 에러를 순서대로 처리합니다. 권장 순서가 있습니다.
1번째: noImplicitAny
가장 먼저 켜야 할 옵션입니다. 타입이 명시되지 않아 any로 추론되는 경우를 에러로 잡습니다.
// 수정: tsconfig.json{ "compilerOptions": { "strict": false, "noImplicitAny": true, ... }}
이 옵션을 켜면 나타나는 에러 패턴입니다.
// 에러: Parameter 'user' implicitly has an 'any' type.function formatUserName(user) { return `${user.firstName} ${user.lastName}`;}
해결 방법. 매개변수 타입을 명시합니다.
function formatUserName(user: User): string { return `${user.firstName} ${user.lastName}`;}
타입을 아직 모르거나 설계가 복잡한 경우에는 임시로 unknown 또는 명시적 any를 씁니다.
// eslint-disable-next-line @typescript-eslint/no-explicit-anyfunction legacyProcess(data: any): any { // TODO: 타입 개선 필요 return data;}
any를 완전히 없애는 것이 목표이지만, 초기에는 "암묵적 any"를 "명시적 any"로 바꾸는 것만으로도 진전입니다. 명시적 any는 나중에 찾아서 고칠 수 있습니다.
2번째: strictNullChecks
null과 undefined를 타입 시스템이 구별하게 합니다. TypeScript에서 가장 많은 에러를 잡아주는 옵션입니다.
// 수정: tsconfig.json{ "compilerOptions": { "strict": false, "noImplicitAny": true, "strictNullChecks": true, ... }}
이 옵션을 켜면 null이나 undefined를 허용하는 타입에 | null 또는 | undefined를 붙여야 합니다.
// 에러: Type 'undefined' is not assignable to type 'User'.function findUser(id: number): User { return users.find(u => u.id === id); // Array.find()의 반환 타입은 T | undefined입니다}
해결 방법 1: 반환 타입 수정.
function findUser(id: number): User | undefined { return users.find(u => u.id === id);}
해결 방법 2: null 단언 (확실히 존재할 때).
function findUser(id: number): User { const user = users.find(u => u.id === id); if (!user) throw new Error(`User ${id} not found`); return user;}
해결 방법 3: 옵셔널 체이닝과 nullish coalescing.
// null일 수 있는 값을 안전하게 접근합니다const name = user?.firstName ?? 'Unknown';
strictNullChecks를 켜면 처음에는 에러가 많이 납니다. 하지만 이 에러들 중 상당수가 "실제로 null이 들어올 수 있는데 처리를 안 하고 있던" 버그입니다. TypeScript가 숨어 있던 문제를 수면 위로 올려주는 것입니다.
3번째: strictFunctionTypes
함수 타입의 공변성/반공변성을 검사합니다. 이 옵션이 잡는 에러는 미묘하지만 실제 런타임 오류로 이어질 수 있습니다.
// 수정: tsconfig.json{ "compilerOptions": { "strict": false, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, ... }}
이 옵션이 잡는 전형적인 패턴입니다.
type Handler = (event: MouseEvent) => void;type GeneralHandler = (event: Event) => void;// strictFunctionTypes: true에서 에러// MouseEvent를 받는 함수에 Event를 받는 함수를 할당할 수 없습니다const handler: Handler = (e: Event) => { console.log(e);};
콜백 함수를 다루는 코드가 많다면 이 옵션에서 에러가 납니다. 함수 타입을 정확하게 선언하면 해결됩니다.
4번째: strictNullChecks 이후 옵션들
나머지 옵션들은 상대적으로 영향이 작습니다.
strictBindCallApply. bind, call, apply의 인수 타입을 검사합니다.
function greet(name: string): void { console.log(`Hello, ${name}`);}// 에러: Argument of type 'number' is not assignable to parameter of type 'string'greet.call(null, 42);
strictPropertyInitialization. 클래스의 모든 프로퍼티가 생성자에서 초기화되었는지 검사합니다.
class UserService { // 에러: Property 'db' has no initializer and is not definitely assigned in the constructor. private db: Database; constructor() { // db를 초기화하지 않았습니다 }}
해결 방법. 생성자에서 초기화하거나, 나중에 할당될 것을 명시합니다.
class UserService { // 방법 1: 생성자에서 초기화 private db: Database; constructor(db: Database) { this.db = db; }}class UserService { // 방법 2: 확정 할당 단언 (나중에 반드시 초기화됨) private db!: Database;}
noImplicitThis. 타입이 없는 컨텍스트에서 this 사용을 에러로 처리합니다.
function showName(this: { name: string }): void { console.log(this.name);}
useUnknownInCatchVariables. catch 블록의 에러 변수를 any 대신 unknown으로 처리합니다.
try { await fetchUser(1);} catch (error) { // strictNullChecks 없이는 error가 any // useUnknownInCatchVariables: true에서는 unknown if (error instanceof Error) { console.error(error.message); }}
단계별 적용 전략 정리
권장 순서를 tsconfig.json에 주석으로 남겨두면 진행 상황을 팀이 공유할 수 있습니다.
// 수정: tsconfig.json{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "outDir": "./dist", "rootDir": "./src", "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "resolveJsonModule": true, // strict 옵션 점진적 적용 현황 (2024-01-15 기준) "strict": false, "noImplicitAny": true, // ✅ 2024-01-10 완료 "strictNullChecks": true, // ✅ 2024-01-15 완료 "strictFunctionTypes": true, // 🔄 진행 중 "strictBindCallApply": false, // ⬜ 예정 "strictPropertyInitialization": false, // ⬜ 예정 "noImplicitThis": false, // ⬜ 예정 "useUnknownInCatchVariables": false // ⬜ 예정 }}
모든 옵션이 true가 되는 시점에 "strict": true로 교체하고 나머지를 지웁니다.
strict 모드가 만들어주는 것
strict를 켜는 과정에서 드는 의문이 있습니다. "이 에러들, 원래 코드에서는 없던 거잖아요. 없는 문제를 만드는 건 아닌가요?"
맞습니다. 원래 코드에서는 에러가 없었습니다. 하지만 그것은 TypeScript가 눈을 감고 있었기 때문입니다. strict를 켠다는 것은 TypeScript가 눈을 뜨는 과정입니다. 그때 보이는 것들이 잠재적 버그입니다.
null을 처리하지 않던 코드는 런타임에 "Cannot read properties of null" 오류를 냈을 겁니다. any를 남발하던 코드는 의도하지 않은 타입이 들어왔을 때 조용히 잘못된 동작을 했을 겁니다. strict는 그 문제들을 배포 전에 잡아줍니다.
다음 챕터에서는 팀 단위에서 마이그레이션을 어떻게 관리하는지 체크리스트로 정리합니다.