테스트는 통과하는데 tsc 빌드에서만 깨지는 default import — esModuleInterop
esModuleInterop 이 꺼진 TypeScript 빌드에서 import sharp from 'sharp' 는 타입 검사를 통과합니다. 그런데 tsc 가 내보낸 코드는 require('sharp').default 를 호출하고, sharp 에는 .default 가 없습니다.
NestJS 백엔드의 이미지 처리 경로 몇 곳이 이 모양이었습니다. 테스트는 모두 통과했습니다. 테스트를 돌리는 Jest + @swc/jest 가 기본 가져오기(default import)를 스스로 감싸 .default 를 만들어 주었기 때문입니다. 운영 빌드에서는 실행하는 순간 TypeError(«… is not a function»)로 실패하는 구조였는데, 그 TypeError 를 넓은 catch 가 400 «잘못된 파일»로 바꿔 놓아 결함처럼 보이지도 않았을 것입니다.
앞서 「코드 주석은 다섯 종류만 남깁니다」에서 레포의 코드 주석을 판정한 이야기를 적었습니다. 그 후속으로 린트 억제 주석을 하나씩 살펴보다가 이 문제를 만났습니다. 다만 이 글은 주석이 아니라, 같은 소스를 두 변환기가 다르게 컴파일하는 CommonJS interop 의 이야기입니다.
먼저 밝혀 둘 것이 있습니다. 운영에서 이 경로가 실제로 실패했는지는 확인하지 않았습니다. 확인한 것은 «실행하면 실패하는 구조였다»는 데까지입니다. 아래의 컴파일 출력과 런타임 값은 이 글을 쓰며 2026년 11월 1일에 다시 돌린 결과입니다.
esModuleInterop 과 allowSyntheticDefaultImports — 타입은 허락하고 출력은 그대로입니다
백엔드의 tsconfig 는 이렇게 되어 있었습니다(관련 옵션만 발췌했습니다).
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": false,
"allowSyntheticDefaultImports": true
}
}ES 모듈 문법의 import x from 'pkg' 는 «그 모듈의 default 내보내기를 가져온다»는 뜻입니다. 그런데 CommonJS 패키지는 module.exports 에 값을 통째로 걸 뿐, default 라는 이름의 내보내기가 따로 없습니다. 그래서 CommonJS 로 컴파일할 때 이 둘을 어떻게 이어 줄지를 정해야 하고, 그 일을 하는 것이 esModuleInterop 입니다.
esModuleInterop 이 켜져 있으면 tsc 는 기본 가져오기를 __importDefault 로 감쌉니다. 이 도우미는 __esModule 표시가 없는 모듈을 { default: module } 로 만들어 줍니다. 꺼져 있으면 감싸지 않습니다. 적힌 그대로 require(...).default 를 읽습니다.
함정은 allowSyntheticDefaultImports 쪽에 있었습니다. 이 옵션은 default 내보내기가 없는 모듈에도 기본 가져오기를 써도 된다고 타입 검사기에게만 허락합니다. TypeScript 공식 문서도 이 옵션이 내보내는 JavaScript 에는 영향을 주지 않는다고 설명합니다. 그러니 import mime from 'mime-types' 는 타입 오류 없이 통과하고, 출력 코드는 여전히 .default 를 읽습니다. «타입 검사가 통과했으니 실행도 된다»는 익숙한 가정이 정확히 이 자리에서 깨집니다.
같은 소스, 두 개의 출력 — tsc 5.9.3 과 @swc/core 1.16.2 실측
두 줄짜리 파일을 두 변환기에 넣어 보았습니다.
import mime from 'mime-types';
export const t = mime.lookup('a.png');tsc 5.9.3 에 --module commonjs --esModuleInterop false --allowSyntheticDefaultImports true 를 주면, 타입 오류 없이 rc=0 으로 끝나고 다음을 내보냅니다.
const mime_types_1 = require("mime-types");
exports.t = mime_types_1.default.lookup('a.png');같은 파일을 @swc/core 1.16.2 에 레포의 .swcrc(module commonjs)로 넣으면 이렇게 나옵니다.
const _mimetypes = /*#__PURE__*/ _interop_require_default(require("mime-types"));
function _interop_require_default(obj) {
return obj && obj.__esModule ? obj : {
default: obj
};
}
const t = _mimetypes.default.lookup('a.png');swc 는 CommonJS 로 바꾸면서 기본 가져오기를 _interop_require_default 로 감쌌습니다. 그러면 실제 패키지는 .default 를 갖고 있을까요. 설치된 패키지를 Node 에서 직접 불러 보았습니다.
| 패키지 | require() 결과 | .default |
|---|---|---|
| mime-types 2.1.35 | 함수 모음 객체(.lookup = function) | undefined |
| sharp 0.35.5 | 함수 자체 | undefined |
| cheerio 1.2.0 | __esModule = true | undefined |
테스트(swc)에서 동작하던 같은 소스가 운영 빌드(tsc)에서는 실패합니다. 이 글을 쓰며 tsc 5.9.3 의 출력을 설치된 패키지로 Node 에서 실행해 재현한 오류입니다. mime-types 는 .lookup 을 읽는 자리에서 실패합니다.
TypeError: Cannot read properties of undefined (reading 'lookup')sharp 는 tsc 가 (0, sharp_1.default)(Buffer.alloc(0)) 를 내보내고, 호출하는 순간 실패합니다.
TypeError: (0 , sharp_1.default) is not a functionswc 테스트는 왜 통과했고, 운영은 왜 조용했나
이 백엔드의 테스트는 Jest 가 @swc/jest 로 소스를 변환해 돌리고, 배포 산출물은 tsc 로 만듭니다. 테스트를 만드는 변환기와 배포를 만드는 변환기가 다른 것입니다. 모듈 interop 처럼 둘이 다르게 내는 부분에서는, 테스트가 아무리 초록이어도 운영 빌드의 동작을 보증하지 못합니다. Twelve-Factor App 이 말하는 개발·운영 일치(dev/prod parity)의 아주 작은 사례라는 생각이 들었습니다.
영향을 받는 실행 경로는 네 곳이었습니다.
- 사용자가 올린 첨부 이미지를 해석하는 경로 (sharp)
- 관리 화면의 이미지 업로드 (sharp)
- 제품 문서의 이미지 업로드 (sharp)
- 저장소 공용 유틸의 MIME 판별 함수 두 개 (mime-types — 백엔드 안에는 아직 호출처가 없습니다)
세 이미지 경로에는 공통점이 하나 더 있었습니다. 이미지를 읽는 부분을 넓은 catch 가 감싸고 있었고, 그 안에서 난 오류를 400 으로 바꿔 돌려주었습니다. 예를 들면 «파일을 안전하게 읽을 수 없습니다» 같은 응답입니다. sharp 가 undefined 여서 난 TypeError 도 여기에 걸립니다. 사용자에게는 서버 오류가 아니라 «잘못된 파일»로 보였을 것이고, 결함이 드러나지 않은 이유도 이것으로 보입니다.
참고로 한 경로의 첨부 테이블은 2026년 10월 28일 조회 시점에 0행이었습니다.
원리를 알고 있던 린트 규칙, 목록으로 걸린 방어선
조금 씁쓸했던 것은, 이 원리를 아는 사람이 이미 있었다는 점입니다. 레포에는 Node 코어 모듈(fs·path 등)의 기본 가져오기를 막는 ESLint 규칙이 걸려 있었고, 그 메시지는 이렇게 말하고 있었습니다(원문을 옮겼습니다).
Node 코어 모듈은 default import 금지 — esModuleInterop:false 라 dist 런타임에서 undefined 가 된다
설명은 정확합니다. 그리고 같은 원리는 외부 패키지에도 그대로 적용됩니다. 그런데 규칙은 원리가 아니라 코어 모듈 이름의 목록으로 걸려 있었습니다. 목록에 없는 sharp 와 mime-types 는 그 사이로 빠져나갔습니다. 누군가 원인을 정확히 알고 있었는데도, 방어를 «알려진 사례의 목록»으로 걸어 두는 바람에 목록 밖이 샌 것입니다.
앞 글에서는 주석이 말하던 사실을 기계가 확인하지 않아 아무것도 실패하지 않았다고 적었습니다. 이번에는 기계가 확인하고 있었는데도 샜습니다. 확인하는 범위가 원리보다 좁았기 때문입니다.
고친 방법 — mime-types 는 네임스페이스로, sharp 는 require 로
mime-types 는 쉬웠습니다. lookup 같은 함수를 모아 둔 모듈이라 네임스페이스 가져오기가 맞는 모양입니다.
import * as mimeTypes from 'mime-types';sharp 는 더 까다로웠습니다. sharp 0.35 의 최상위 타입 선언은 ESM(dist/index.d.mts)인데, require() 는 함수 자체를 내보내는 CommonJS 쪽을 읽습니다. 그래서 흔히 떠올리는 형태가 각자 한쪽에서 어긋났습니다.
| 형태 | 어긋나는 곳 |
|---|---|
import sharp from 'sharp' | tsc 출력이 .default 를 읽어 undefined |
import sharp = require('sharp') | 타입이 «호출 불가»가 됨 |
import * as sharp from 'sharp' | swc 에서 호출할 수 없는 네임스페이스 객체가 됨 |
타입을 붙인 require() | tsc·swc 모두에서 함수 그대로 |
두 도구 모두에서 맞는 형태는 마지막 줄이었습니다. 실제로 넣은 코드는 이렇습니다.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- sharp ships ESM types (dist/index.d.mts) but require() returns the CommonJS function itself; without esModuleInterop a default import compiles to require('sharp').default, which is undefined
const sharp: typeof import('sharp').default = require('sharp');타입은 ESM 선언의 default 에서 가져오고, 값은 require() 로 받습니다. 린트 억제에는 바꿀 수 없는 외부 패키지의 사정을 사유로 적었습니다. 레포의 다른 이미지 엔진은 이미 이 require 방식을 쓰고 있었습니다.
확인은 실제 nest build 산출물로 했습니다. 세 파일 모두 const sharp = require('sharp') 와 sharp(...) 직접 호출로 나왔고, .default 를 거치는 호출은 0개였습니다. 컴파일된 유틸은 node 로 불러 getMimeType('a.png') 가 image/png, getExtensionFromMimeType('image/webp') 가 webp 를 돌려주는 것을 보았습니다.
esModuleInterop 을 켜면 되지 않나
솔직히 말하면 이번에 켜지 않은 이유를 따로 적어 둔 결정 기록은 없습니다. 말할 수 있는 것은 두 가지입니다. 켜면 기본 가져오기를 쓰는 모든 모듈의 출력이 바뀌는 큰 변경이라는 것, 그리고 이번 수정은 동작을 바꾸지 않는 범위로 잡았다는 것입니다. 켜는 쪽이 맞는 코드베이스도 있을 것이고, 그건 전 모듈의 출력을 다시 확인할 각오와 함께 고를 선택지라고 생각합니다.
재발 방지 — 린트 목록 대신 실행 시점에 .default 를 확인합니다
린트 목록에 두 패키지를 더하는 방법도 있었지만, 다음에 들어올 패키지는 또 목록 밖에 있을 것입니다. 그래서 원리 쪽으로 걸었습니다. 소스 폴더에서 spec·test·선언 파일을 뺀 .ts 파일의 외부 패키지 기본 가져오기를 모두 모아, 각 패키지가 실행 시점에 정말 .default 를 갖고 있는지 확인하는 테스트입니다.
const DEFAULT_IMPORT =
/^import\s+(?!type\b)[A-Za-z_$][\w$]*\s*(?:,\s*\{[^}]*\})?\s+from\s+'([^'.@][^']*|@(?!\/)[^']*)';/gm;
describe('default imports of external packages', () => {
const imports = collectDefaultImports(__dirname);
it('finds the default imports it is meant to guard', () => {
expect(imports.size).toBeGreaterThan(0);
});
it.each([...imports.entries()])(
'%s has a default export at runtime (tsc emits require(...).default because esModuleInterop is false)',
(specifier, files) => {
const loaded: unknown = require(specifier);
const exported = loaded as { default?: unknown };
expect({ specifier, files, hasDefault: exported.default !== undefined })
.toEqual({ specifier, files, hasDefault: true });
}
);
});핵심은 테스트 안에서 import 가 아니라 require() 를 쓴다는 점입니다. import 로 불러오면 swc 가 또 감싸 줄 테니, 운영과 같은 것을 볼 수 없습니다. require() 로 받아야 swc 가 메워 주기 전의 원본 CommonJS 내보내기, 즉 tsc 출력이 실제로 마주할 값을 보게 됩니다.
«찾아야 할 것을 찾았는가»(imports.size > 0) 단언을 따로 둔 이유도 있습니다. 정규식이 어느 날 깨져 0건을 모으면 it.each 는 아무것도 돌리지 않고, 스위트는 초록으로 끝납니다.
수정 전에 돌리자 기본 가져오기 9건 중 sharp·mime-types 2건이 실패했고, 수정 뒤에는 모두 통과했습니다.
리뷰에서 고친 점 — __esModule 은 근거가 아니었습니다
처음 버전은 __esModule 표시가 있는 패키지를 통과시켰습니다. 하지만 이 테스트가 흉내 내야 하는 것은 swc 가 아니라 interop 이 꺼진 tsc 출력이고, 그 출력은 __esModule 을 보지 않고 무조건 .default 를 읽습니다. 그러니 __esModule 만 있고 .default 는 없는 패키지(앞의 표의 cheerio 가 그렇습니다)도 똑같이 깨집니다.
리뷰에서 이 점을 지적받고 판정을 .default 의 존재만으로 바꿨습니다. 기록된 지적에 따르면 백엔드 의존성 중 이런 모양의 패키지가 24개였습니다. 바꾼 뒤 임시로 import cheerio from 'cheerio' 를 넣어 보니 테스트가 실패하는 것을 확인했습니다. 지키려던 원리를 테스트 안에서 다시 한 번 좁혀 버릴 뻔했다는 생각이 들었습니다.
이 테스트가 보지 못하는 것
정규식은 한 줄짜리 import X from '...' 만 봅니다. 여러 줄로 나뉜 import, export { default } from 재수출, 동적 import 는 보지 않습니다. spec 파일 속 기본 가져오기는 운영 빌드에 들어가지 않으므로 그대로 두었습니다. 목록보다는 넓어졌지만, 이것도 원리 전체를 덮지는 못합니다.
정리 — 타입 검사와 테스트가 보증하지 않는 것
다른 코드베이스에 건넬 것을 추리면 이렇습니다.
- 테스트를 돌리는 변환기와 배포를 만드는 변환기가 다르면, 둘이 다르게 내는 부분은 테스트가 보증하지 못합니다. swc·babel·esbuild·ts-jest 의 isolatedModules 가 그렇고, 모듈 interop 이 대표적입니다.
allowSyntheticDefaultImports는 타입만 허락합니다.- 방어를 «알려진 이름 목록»으로 걸면 목록 밖이 샙니다. 원리로 걸거나 실행 시점에 확인하는 편이 낫습니다.
- 넓은 catch 는 프로그래밍 오류를 사용자 입력 오류로 바꿔 숨깁니다.
돌이켜 보면 각 층은 자기 몫을 정직하게 확인했지만, 그 대상이 운영에서 실행되는 것과 달랐습니다. 무엇을 확인했는지만큼, 그 확인이 무엇을 대상으로 했는지를 같이 물어야겠다고 다짐했습니다.
관련 글
빌드는 성공했는데 런타임에 죽었습니다 — Turbopack이 빠뜨린 파일 한 개
배포 도구는 빌드 실패로 기록했지만 빌드는 성공했고 컨테이너가 부팅 중에 죽었습니다. 번들러가 빠뜨린 파일 하나였고, 그 파일이 필요해진 건 한 달 전 Node 버전을 올렸기 때문이었습니다.
TypeScript 7을 타입 검사에만 쓰기 — 빌드와 lint는 5.x에 남겨 둔 이유
TypeScript 7은 API 없이 나와 lint와 next build에는 쓸 수 없습니다. pnpm 모노레포에서 타입 검사 명령만 7로 옮기고 나머지는 5.x에 둔 구성과 peer 함정, 검증 방법, 8코어 CI 26초가 6초가 된 측정을 정리했습니다.
코드는 한 줄도 안 건드렸는데 모든 커밋이 막혔습니다 — Node 버전업과 require(ESM) 인터롭 함정
코드는 한 줄도 안 건드렸는데 pre-commit이 모든 커밋을 막았습니다. 'Definition for rule not found'의 범인은 Node 버전업이었습니다. require(ESM) 인터롭이 ESLint 플러그인의 규칙을 .default에 가둔 과정을 추적했습니다.