- Published on
TS 5.5에서 import type 누락으로 빌드 깨질 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 5.5로 올린 뒤, 로컬에서는 얼핏 잘 돌아가던 코드가 CI/프로덕션 빌드에서 갑자기 깨지는 경우가 있습니다. 특히 번들러(Vite, Rollup, esbuild, Webpack)나 Node ESM 환경에서 타입으로만 쓰는 심볼을 값(value) import로 가져오면서 런타임 모듈 로딩이 발생해 에러가 터지는 케이스가 대표적입니다.
겉으로는 “타입인데 왜 런타임에서 import를 하죠?”처럼 보이지만, TS는 기본적으로 import { X } from '...'를 값 import로 취급합니다. X가 타입으로만 쓰였더라도, 설정/트랜스파일 경로에 따라 해당 import가 JS 출력에 남거나(또는 ESM 로더가 해석하려다) 빌드가 깨질 수 있습니다. TS 5.5 자체가 갑자기 동작을 뒤집었다기보다, 업그레이드와 함께 모듈 해석/출력 옵션, 번들러의 tree-shaking, d.ts 생성, isolatedModules, verbatimModuleSyntax 같은 요소가 맞물리면서 문제가 표면화되는 일이 많습니다.
이 글에서는 “import type 누락”으로 인한 빌드 실패를 재현하고, 어떤 조건에서 터지는지, 그리고 팀 단위로 재발을 막는 방법까지 정리합니다.
증상: 로컬은 되는데 빌드에서만 터지는 전형적인 에러
다음 중 하나를 겪었다면 거의 같은 뿌리입니다.
SyntaxError: The requested module '...' does not provide an export named 'Foo'RollupError: 'Foo' is not exported by ...TSXXXX: ... is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled- Next.js/Vite에서 특정 파일만 빌드 시점에
export관련 에러
핵심은 이겁니다.
- 어떤 모듈은 타입만 export(예:
export type Foo = ...)하고 값 export는 없습니다. - 그런데 소비 코드에서
import { Foo } from '...'처럼 값 import로 가져오면, - 번들러/ESM 로더는 런타임에
Foo라는 named export를 찾다가 실패합니다.
재현: 타입만 export하는 모듈을 값으로 import한 경우
1) 타입만 export하는 파일
// src/contracts.ts
export type User = {
id: string;
name: string;
};
2) 소비하는 쪽에서 실수로 값 import
// src/index.ts
import { User } from "./contracts";
export function greet(user: User) {
return `hello ${user.name}`;
}
이 코드는 타입 체크는 통과할 수 있습니다. 하지만 트랜스파일/번들링 결과에서 import { User } from "./contracts"가 남아버리면 런타임에 User export가 없어서 빌드가 깨집니다.
3) 올바른 수정
// src/index.ts
import type { User } from "./contracts";
export function greet(user: User) {
return `hello ${user.name}`;
}
import type는 JS 출력에서 완전히 제거(타입 전용)되므로, 번들러/런타임이 해당 심볼을 찾을 일이 없어집니다.
왜 TS 5.5 업그레이드 뒤에 더 자주 터지나?
TS 5.5에서만 생기는 버그라기보다는, 업그레이드 과정에서 다음 설정/환경 변화가 같이 들어오는 경우가 많습니다.
1) verbatimModuleSyntax 도입/강화
프로젝트가 ESM으로 이동하거나 번들러 모드로 정리되면서 verbatimModuleSyntax: true를 켜는 경우가 많습니다.
- 이 옵션은 “타입/값 import를 개발자가 명시한 그대로 유지”하려는 방향입니다.
- 타입만 쓰는 심볼을 값 import로 적어두면, TS가 더 엄격하게 경고/에러를 내거나, 출력물이 의도치 않게 남아 문제를 드러냅니다.
2) moduleResolution: bundler 또는 ESM 전환
Vite/Next/Rollup 환경에서 moduleResolution: bundler를 사용하면 해석 규칙이 번들러 친화적으로 바뀝니다. 이때 타입-only export와 값 export의 구분이 더 중요해지고, “런타임에 실제로 존재하는 export인가?”가 빌드 단계에서 더 엄격하게 검증되기도 합니다.
3) isolatedModules + 빠른 트랜스파일 체인
Babel/SWC/esbuild처럼 타입을 제거만 하고(타입 체커가 아닌) 트랜스파일하는 경로에서는, 타입 정보 기반으로 import를 제거하는 최적화가 제한될 수 있습니다. 결국 “타입인데 값 import로 적혀있음”이 그대로 JS에 남아 런타임/번들 단계에서 폭발합니다.
빠른 진단 체크리스트
빌드가 깨졌을 때 아래 순서로 보면 원인 파악이 빨라집니다.
- 에러 난 import가 가리키는 모듈에서 해당 심볼이 값으로 export되는지 확인
export type Foo만 있고export const Foo/export function Foo가 없으면 런타임 export는 없습니다.
- 소비 코드에서
import { Foo } ...인지import type { Foo } ...인지 확인 tsconfig.json에서 다음 옵션 확인verbatimModuleSyntaximportsNotUsedAsValuesisolatedModulesmodule,moduleResolution
- 빌드 산출물(JS)에서 해당 import가 남는지 확인
dist/index.js등에import { Foo } from ...가 남아 있으면 거의 확정
이런 “원인-가설-검증” 흐름은 인프라/배포 문제를 10분 안에 좁히는 방식과 비슷합니다. 예를 들어 EKS에서 503을 빠르게 진단할 때도 증상→경로→원인 후보를 체크리스트로 압축하는 게 핵심인데, 이 접근은 그대로 코드 빌드 이슈에도 통합니다. (참고: EKS에서 503 Service Unavailable 원인 10분 진단)
해결 1: 문제 지점을 import type로 명시적으로 고치기
가장 정석적인 해결입니다.
- 타입으로만 쓰는 심볼은 무조건
import type사용 - 반대로 값으로도 쓰는 심볼(런타임에서 필요)은 일반
import유지
타입과 값을 동시에 가져와야 하는 경우
import { z } from "zod";
import type { ZodSchema } from "zod";
export function validate(schema: ZodSchema, input: unknown) {
return (schema as z.ZodTypeAny).parse(input);
}
또는 한 줄로도 가능합니다.
import { z, type ZodSchema } from "zod";
해결 2: 라이브러리/패키지 경계에서 “타입 전용 export”를 명확히 하기
모노레포나 사내 패키지에서 특히 자주 터집니다.
@myorg/contracts는 타입만 제공하는데- 소비 앱이 이를 값 import로 가져오면 빌드 실패
권장 패턴:
// packages/contracts/src/index.ts
export type { User, Order } from "./types";
그리고 소비자는:
import type { User } from "@myorg/contracts";
이렇게 “계약(contracts)은 타입-only”라는 의도를 코드로 박아두면 팀 내 실수가 줄어듭니다.
해결 3: 린트/CI로 재발 방지 (가장 효과적)
개별 파일을 고쳐도, 새 코드가 계속 들어오면 같은 문제가 재발합니다. 따라서 린트 규칙으로 강제하는 게 좋습니다.
ESLint + typescript-eslint 추천 규칙
// eslint.config.js (flat config 예시)
import tseslint from "typescript-eslint";
export default [
...tseslint.configs.recommendedTypeChecked,
{
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
fixStyle: "separate-type-imports"
}
]
}
}
];
consistent-type-imports는 타입 전용 import를 자동으로import type로 바꿔줍니다.--fix를 CI 전 단계(pre-commit)에서 돌리면 체감상 거의 사라집니다.
CI에서 “로컬은 되는데 CI만 실패”를 줄이는 팁
CI는 로컬과 Node 버전/번들러 옵션이 달라서 문제를 더 잘 드러냅니다. GitHub Actions에서 OIDC/권한 문제처럼 “환경 차이”가 원인인 경우가 흔한데, TS 빌드도 마찬가지로 Node/패키지 매니저/락파일/빌드 명령을 고정해야 재현이 쉽습니다. (참고: GitHub Actions OIDC로 AWS AssumeRole 실패 해결)
설정 관점: importsNotUsedAsValues는 만능이 아니다
가끔 “타입으로만 쓰면 TS가 알아서 import 제거해주지 않나?”라는 기대가 있는데, 다음 이유로 부족할 수 있습니다.
importsNotUsedAsValues는 주로 “타입으로만 사용된 import를 어떤 방식으로 처리할지(제거/보존/에러)”에 대한 옵션이지만,- 번들러/isolated transpile 경로에서는 TS가 최종 JS를 만들지 않거나, import 제거 최적화가 기대대로 적용되지 않을 수 있습니다.
- 무엇보다 타입-only export를 값 import로 가져오는 행위 자체가 의미적으로 잘못이므로, 설정으로 덮기보다 코드에서
import type로 명확히 하는 게 안전합니다.
즉, 설정은 보조 수단이고 근본 해결은 “타입은 타입으로 import”입니다.
실전 패턴: 빌드 깨지는 파일을 빠르게 찾는 방법
대규모 코드베이스에서는 어디서 누락됐는지 찾는 게 더 어렵습니다. 다음 방법이 빠릅니다.
- 에러 메시지에서 문제 심볼(
Foo)과 모듈 경로를 확인 - 해당 모듈 파일에서
export type Foo인지 확인 - 리포지토리 전체에서
import { Foo } from "..."를 검색 - 모두
import type로 변경
간단한 grep 예시:
grep -R "import { User }" -n src packages
또는 ripgrep:
rg "import \{\s*User\s*\}" -n
마무리
TS 5.5 업그레이드 후 import type 누락으로 빌드가 깨지는 문제는, 대부분 “타입-only export를 값 import로 가져온 것”에서 시작합니다. 해결 자체는 import type 한 줄이지만, 재발 방지를 위해서는 다음 3가지를 세트로 가져가는 게 좋습니다.
- 코드: 타입은
import type로 명시 - 패키지: 타입-only 모듈은
export type { ... }로 의도를 고정 - 팀: ESLint
consistent-type-imports로 강제 + CI에서 동일 환경 고정
이렇게 정리해두면, TS 버전이 올라가거나 번들러/ESM 전환을 하더라도 같은 류의 빌드 실패를 크게 줄일 수 있습니다.