Static Imports가 JavaScript의 Isomorphism을 약화시키고 있다
Source: Dev.to
번역을 진행하려면 실제 기사 본문(마크다운 형식 포함)을 제공해 주시겠어요?
본문을 주시면 요청하신 대로 한국어로 번역해 드리겠습니다.
TL;DR
- 정적 import는 모듈 로드 시점에 의존성을 바인딩합니다.
- 조기 바인딩은 플랫폼 가정을 코드에 내재시킵니다.
- 선언된 의존성은 이러한 결정을 컴포지션 루트로 옮깁니다.
- 이것은 새로운 모듈 시스템이 아니라, 모듈 수준에 적용된 표준 의존성 주입(Dependency Injection)입니다.
JavaScript는 브라우저와 서버 양쪽에서 네이티브로 실행되므로 진정한 이소모르피즘이 가능합니다. 그러나 현대 JavaScript 아키텍처는 종종 이를 방해합니다.
import fs from "node:fs";
이 한 줄은 Node 전용 기능을 모듈에 직접 포함시킵니다. 브라우저는 기본적으로 "node:fs"를 만족시킬 수 없으므로, 해당 모듈은 더 이상 이소모르픽하지 않게 됩니다.
문제는 fs가 아니라 조기 바인딩입니다. 정적 import는 모듈 평가 단계에서 의존성을 해결하여, 코드가 실행되기 전에 그래프를 고정합니다. 의존성이 플랫폼에 특화되어 있으면, 모듈 자체도 플랫폼에 특화됩니다.
의존성을 명시적으로 만들기
바로 바인딩하는 대신, 모듈은 자신이 필요로 하는 것을 선언할 수 있습니다.
// user-service.mjs
export const __deps__ = {
fs: "node:fs",
logger: "./logger.mjs",
};
export default function makeUserService({ fs, logger }) {
return {
readUserJson(path) {
const raw = fs.readFileSync(path, "utf8");
logger.log(`Read ${raw.length} bytes`);
return JSON.parse(raw);
},
};
}
이 모듈은 직접적으로 아무것도 임포트하지 않습니다. 의존성 계약을 선언하고, 구체적인 구현은 외부에서 전달받습니다. 이는 모듈 수준에서 적용된 의존성 주입(Dependency Injection)이며, 구성 루트(composition root)가 무엇을 전달할지 결정합니다.
수동 구성 루트
Node
// node-entry.mjs
import fs from "node:fs";
import logger from "./logger.mjs";
import makeUserService from "./user-service.mjs";
const service = makeUserService({ fs, logger });
Browser
// browser-entry.mjs
import fsAdapter from "./browser-fs-adapter.mjs";
import logger from "./logger.mjs";
import makeUserService from "./user-service.mjs";
const service = makeUserService({
fs: fsAdapter,
logger,
});
모듈 자체는 변경되지 않으며, 구성 루트만 변경됩니다. 플랫폼 결정은 시스템의 가장자리에 남아 있으며, 의존성이 명시적으로 주입되기 때문에 테스트에서는 모듈 임포트를 모킹하는 대신 직접 페이크를 전달할 수 있습니다.
구성 자동화
__deps__를 통해 계약이 노출되기 때문에, 구성 루트를 데이터 기반으로 만들 수 있습니다:
// link.mjs
export async function link(entrySpecifier, overrides = {}) {
const mod = await import(entrySpecifier);
const depsSpec = mod.__deps__ ?? {};
const deps = {};
for (const [name, specifier] of Object.entries(depsSpec)) {
const finalSpecifier = overrides[specifier] ?? specifier;
const imported = await import(finalSpecifier);
deps[name] = imported.default ?? imported;
}
return mod.default(deps);
}
Node
const service = await link("./user-service.mjs");
Browser
const service = await link("./user-service.mjs", {
"node:fs": "./browser-fs-adapter.mjs",
});
바인딩은 로더 부작용이 아니라 명시적인 프로그램 로직이 됩니다.
import maps와 exports와 다른 점
| 메커니즘 | 제어하는 내용 |
|---|---|
| Import maps | 로드 시점에 지정자 해석 (호스트‑수준). |
package.json exports | 환경별 진입점 (패키지‑수준). |
| Bundlers | 빌드 시점에 그래프 최적화. |
| Composition root + DI | 런타임에 모듈이 받는 구체적인 기능 (애플리케이션‑수준). |
- Import maps는 다음에 답한다: 이 모듈은 어디에 있나요?
- Composition root는 다음에 답한다: 이 모듈이 받는 기능은 무엇인가요?
다른 레이어, 다른 관심사.
트레이드‑오프
- 정적 분석 가능성과 트리‑쉐이킹 정확도가 감소합니다.
- TypeScript 통합이 더 수동적으로 됩니다.
- 작거나 순수하게 단일 런타임만 사용하는 앱에는 불필요합니다.
- 아키텍처 규율(구성 루트)을 도입합니다.
이는 도구이며, 기본값이 아닙니다.
언제 사용해야 할까
다음과 같은 경우에 사용하세요:
- 진정한 크로스‑런타임 모듈이 필요할 때 (Node, 브라우저, Edge).
- 환경에 대한 결정을 중앙 집중화하고 싶을 때.
- 무거운 모킹 없이 테스트 가능성이 중요할 때.
- 명시적인 기능 경계가 선호될 때.
다음과 같은 경우에는 사용하지 마세요:
- 앱이 단일 런타임을 대상으로 할 때.
- 빌드 시 최적화와 트리 쉐이킹이 주요 관심사일 때.
- 단순함이 아키텍처 유연성보다 더 중요할 때.
정적 import는 잘못된 것이 아니며 효율적이고 관용적입니다. 하지만 정적 import는 일찍 바인딩되어 플랫폼 가정을 인코딩합니다. JavaScript의 이소모픽성을 유지하고 싶다면 바인딩이 발생하는 시점을 신중히 선택해야 합니다.