Published on

TS 5.5에서 import type 누락으로 빌드 깨질 때

Authors

서론

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 typeJS 출력에서 완전히 제거(타입 전용)되므로, 번들러/런타임이 해당 심볼을 찾을 일이 없어집니다.

왜 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에 남아 런타임/번들 단계에서 폭발합니다.

빠른 진단 체크리스트

빌드가 깨졌을 때 아래 순서로 보면 원인 파악이 빨라집니다.

  1. 에러 난 import가 가리키는 모듈에서 해당 심볼이 값으로 export되는지 확인
    • export type Foo만 있고 export const Foo/export function Foo가 없으면 런타임 export는 없습니다.
  2. 소비 코드에서 import { Foo } ...인지 import type { Foo } ...인지 확인
  3. tsconfig.json에서 다음 옵션 확인
    • verbatimModuleSyntax
    • importsNotUsedAsValues
    • isolatedModules
    • module, moduleResolution
  4. 빌드 산출물(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”입니다.

실전 패턴: 빌드 깨지는 파일을 빠르게 찾는 방법

대규모 코드베이스에서는 어디서 누락됐는지 찾는 게 더 어렵습니다. 다음 방법이 빠릅니다.

  1. 에러 메시지에서 문제 심볼(Foo)과 모듈 경로를 확인
  2. 해당 모듈 파일에서 export type Foo인지 확인
  3. 리포지토리 전체에서 import { Foo } from "..."를 검색
  4. 모두 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 전환을 하더라도 같은 류의 빌드 실패를 크게 줄일 수 있습니다.