테스트는 통과하는데 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 = trueundefined

테스트(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 function

swc 테스트는 왜 통과했고, 운영은 왜 조용했나

이 백엔드의 테스트는 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 는 프로그래밍 오류를 사용자 입력 오류로 바꿔 숨깁니다.

돌이켜 보면 각 층은 자기 몫을 정직하게 확인했지만, 그 대상이 운영에서 실행되는 것과 달랐습니다. 무엇을 확인했는지만큼, 그 확인이 무엇을 대상으로 했는지를 같이 물어야겠다고 다짐했습니다.

esModuleInteropTypeScriptCommonJSswcJestNestJS