any 지옥에서 strict로 승급한 어느 프로젝트의 사투기
느슨하게 태어난 TypeScript 프로젝트에서 strict를 하루아침에 켜면 수백 개의 에러가 쏟아진다. 이 글은 신규 기능 개발과 버그 수정 틈새에서 strict 계열 옵션을 하나씩 켠 실제 진행 기록으로, 옵션별 난이도와 효과를 따져 최적의 적용 순서를 정리했다. any 소탕 순서부터 strictNullChecks, noImplicitAny까지 실무에서 겪은 장애와 회귀를 함께 담았다.
DevInsight에서 발행해요
"strict": true 한 줄을 추가하고 tsc --noEmit을 돌리는 건 몇 초면 끝난다. 그다음이 문제다. 느슨하게 태어난 프로젝트에서 에러는 수백 개 단위로 쏟아진다. 나는 그 숫자를 세다가 되돌렸고, 그 선택을 두 번 반복했다. 켰다 끄기를 거듭한 뒤에야 다른 길이 보였다.
strict는 스위치 하나가 아니다
strict는 단일 스위치처럼 보이지만 실제로는 옵션 묶음이다. strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization, strictBindCallApply, noImplicitThis, useUnknownInCatchVariables. 일곱 개를 한 번에 켜면 난이도가 제각각인 에러가 한 지붕 아래 뒤엉킨다. 어떤 건 타입 한 줄로 끝나고, 어떤 건 함수 동작을 바꿔야 통과한다. 에러 목록을 보면서 둘을 구분해내는 데 시간을 다 쓴다. 그게 한 번에 켜면 지는 이유다.
옵션별 난이도와 효과를 따져 순서를 잡으면 이야기가 달라진다.
- noImplicitAny — 에러가 정확한 줄에 매핑되고, 고치는 과정에서 동작이 바뀔 일이 거의 없다. 입문용이면서 효과는 크다.
- strictFunctionTypes, strictBindCallApply, noImplicitThis — 켜면 고칠 코드가 적다. 하루 안에 끝나는 옵션들.
- strictNullChecks — 난이도가 압도적으로 높다. 시간을 여기에 몰아 쓴다.
- strictPropertyInitialization, noUncheckedIndexedAccess — 선택이다. 후자는 안 켜는 편이 합리적일 때가 많다.
먼저 무난한 옵션부터 끝내는 편이 낫다. strictBindCallApply는 bind와 call 인자를 검사하는데, 켜면 고칠 곳이 몇 개 안 된다. noImplicitThis는 콜백에서 this가 any로 남는 걸 막는데, this를 안 쓰는 함수형 코드라면 에러가 거의 없다. useUnknownInCatchVariables는 catch의 e를 unknown으로 만든다. 여기서 e.message를 직접 쓰던 코드가 전부 망가지니, 에러 메시지 추출 헬퍼를 하나 만들어 두면 수정이 줄어든다.
any부터 치우는 이유
any를 먼저 소탕해야 하는 이유는 직관과 반대다. any 타입은 strictNullChecks를 켜도 null을 통과시킨다. 코드가 any로 뒤덮인 상태에서 strictNullChecks를 켜면 에러가 적게 나오는데, 덜 나온다는 건 그만큼 실제 문제를 가리고 있다는 뜻이다. 에러가 적게 나올수록 좋다는 판단은 여기서 무너진다. 그래서 any 정리가 strictNullChecks보다 먼저 온다.
any 정리는 파일 단위로 하지 않는다. 데이터가 들어오는 경계부터 타입을 세운다. API 응답, DB row, 서드파티 라이브러리의 반환값이 그 자리다. 응답 JSON을 any로 받던 fetch 함수의 반환 타입을 먼저 정의하면, 그걸 호출하는 안쪽 코드는 흘러온 타입을 따라가며 고치면 된다. 반대로 안쪽부터 손대면 추정 위에 추정을 얹어 틀린 타입이 전파되고, 고치지 않은 곳이 오히려 더 위험해진다.
noImplicitAny를 켜고 나면 any를 명시적으로 적어 컴파일러를 통과시키는 코드가 생기기 쉽다. 아래 수정이 대표적이다.
async function loadConfig(id: string) { const res: any = await fetchConfig(id); return res.data; }
컴파일은 통과한다. 의미는 없다. strictNullChecks가 켜져도 res.data의 null 검사가 무력화된다. 모르는 타입에는 any보다 unknown이 낫다. unknown은 값을 쓰기 전에 좁히는 걸 강제하니까. 지표도 그에 맞춘다. 에러 수가 아니라 남은 any의 위치를 본다.
strictNullChecks는 동작을 바꾼다
strictNullChecks는 타입 안전성의 핵심이면서 난이도가 가장 높다. 컴파일러 문제가 아니라 실행 시간 문제를 끄집어내기 때문이다. 고치는 방식이 이전과 달라진다. 분기를 추가하고, 기본값을 넣고, early return을 넣는다. 모두 런타임 동작을 바꾸는 수정이다. 회귀는 옵션 자체가 아니라 이 수정에서 산다.
user.name.toUpperCase()를 고치면서 user.name ?? 'unknown'을 넣으면, 이전에 null로 폭발하던 자리가 이제 문자열을 반환한다. 빈 문자열이 사라지고 기본값이 들어가면 그 아래 로직이 받는 값이 달라진다. 의도한 동작인지 리뷰에서 확인해야 하는 수준이다. 그래서 null 에러 한 개당 커밋 하나를 원칙으로 세웠다. 커밋을 분리해 두면 나중에 동작 변경만 골라 다시 본다.
가장 흔한 실수는 non-null assertion !의 남발이다. 컴파일러를 잠재우는 데는 최고고, 나중에 사람을 혼란시키는 데도 최고다. !가 옳은 자리는 보통 두 가지다. 초기화 직후를 보장할 수 있는 private 필드와, 라이브러리 문서가 값 존재를 보장하는 자리. 그 외는 ?나 ??로 푸는 게 맞다. eslint의 no-non-null-assertion을 켜 두면 !가 리뷰에서 강제로 드러난다.
켜는 과정 내내 '잠깐 끄고 배포하고 다시 켜자'는 유혹이 따라온다. 거기서 지켜야 한다. 꺼진 tsconfig는 리뷰에서 눈에 잘 안 띄고, 다시 켜는 날은 계획에 없었다. 옵션 상태는 CI가 지킨다. tsc --noEmit을 PR 체크에 걸고, 옵션별 에러 개수 기준값을 파일로 남겨 기준보다 많아지면 실패하게 만들었다. 그래도 남는 케이스는 @ts-ignore 대신 @ts-expect-error를 쓰고 옆에 사유를 적는다.
켜기 전에 에러 개수를 찍어라
효과 측정도 숫자로 한다. 옵션을 켜기 전의 에러 개수를 찍어 두고, 켠 뒤 그 수를 다시 찍는다. strictNullChecks는 처음에 에러가 가장 많아 보이지만 고치고 나면 남는 게 적다. noUncheckedIndexedAccess는 반대다. arr[0] 한 줄에 수십 곳이 울리고, 수정은 !나 ??를 코드 여기저기에 박는다. 배열 인덱스 접근이 드문 코드라면 켜는 게 맞고, 순회와 인덱스 접근이 일상인 코드라면 그 소음을 감당할 이유가 있는지 먼저 확인해야 한다. 없으면 빼고 그 이유를 커밋 메시지에 남긴다. 옵션 하나를 안 켠 건 부끄러운 일이 아니다. 이유 없이 켜고 못 지키는 쪽이 더 나쁘다.
레거시 코드에서 strict로 승급하는 일은 이벤트가 아니라 일정이다. 점진적 개선의 효율은 순서에서 나온다. 첫 행동은 noImplicitAny를 켜는 것. 주말을 통째로 비우지 말고, 오늘 tsconfig 한 줄과 에러 개수 기준값을 커밋하는 데서 시작한다. strictNullChecks의 날은 그다음에 온다. 다음 주에도, 그다음 달에도 같은 속도로 켜고 고치고를 반복하다 보면 어느 날 에러가 없는 tsc 출력을 처음 보게 된다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
2,731개의 타입 에러와 마주했던 한 주, strict 옵션을 끄고 켜는 사이의 현실
TypeScript strict 계열 옵션을 한 번에 켜면 수천 개의 타입 에러가 쏟아져 작업이 멈추기 십상이다. 실제 프로젝트 사례를 바탕으로 옵션별 변경 난이도와 영향 범위를 비교해 어떤 순서로 켜는 것이 안전한지, 어디까지 자동으로 고치고 어디를 수동으로 봐야 하는지 단계적 로드맵으로 정리했다.
ESLint Flat Config 마이그레이션 실패 일지와 살아남는 체크리스트
ESLint 9의 flat config로 넘어가면서 extends가 사라지고, 플러그인 호환성 문제, VS Code ESLint 확장과의 설정 불일치, 글로벌 변수 선언 방식 변화 등 현장에서 마주치는 장애물을 해결 순서대로 정리한다. 삽질을 줄이는 실전 체크리스트. 2025년 4월, ESLint 9가 정식 릴리스되면서 파일은 deprecated 경고를 넘어 아예 무시되기 시작했다.
에이전트의 블랙박스를 노트북 위로 끌어내리는 법
AI agent가 실패하거나 조용히 비용을 태울 때, 필요한 건 더 긴 prompt보다 실행 흔적을 읽는 눈이다. Lookspan은 local-first 방식으로 span, 비용, replay, eval을 한자리에 묶어두며 AI agent 개발의 디버깅 루프를 훨씬 짧고 선명하게 만들 가능성을 보여준다.
이전 글
200 OK는 무중단 배포를 지켜주지 않는다
다음 글
폰트가 보이기 전에 페이지는 이미 흔들리고 있다
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.