자바스크립트 Dynamic Import로 모듈을 동적으로 불러오기
자바스크립트에서 모듈을 불러올 때 보통 파일 맨 위에 import 문을 작성하죠.
import { sum } from "./math.js";
이 방식은 간결하고 직관적이지만 항상 파일의 최상단에 위치해야 하고 조건에 따라 불러올 모듈을 바꾸는 게 불가능합니다. 앱 규모가 커지면서 모든 모듈을 한꺼번에 불러오는 것이 성능 문제로 이어지기도 하는데요.
이런 상황에서 빛을 발하는 게 바로 Dynamic Import, import() 표현식입니다.
이 글에서는 import()의 기본 사용법부터 코드 분할, React에서의 활용까지 차근차근 살펴보겠습니다.
정적
import/export문법이 아직 익숙하지 않으신 분은 자바스크립트 ES 모듈 내보내기/불러오기를 먼저 읽어보시면 좋습니다.
정적 import의 한계
일반적인 import 문은 정적(static)입니다. 파일 최상단에 선언해야 하고 경로에 변수를 쓸 수 없으며 if문이나 함수 안에서 사용할 수 없습니다.
// ❌ 이런 코드는 문법 오류가 납니다
if (user.isAdmin) {
import { AdminPanel } from "./admin.js"; // SyntaxError!
}
// ❌ 경로에 변수를 넣을 수도 없습니다
const lang = "ko";
import messages from `./i18n/${lang}.js`; // SyntaxError!
이런 제약이 있는 이유는 정적 import가 코드 실행 전에 모듈 의존 관계를 분석하기 위해 설계되었기 때문입니다. 번들러가 트리 쉐이킹(tree-shaking)을 하거나, 브라우저가 모듈을 미리 불러오는 데 유리하죠.
하지만 실제 개발에서는 상황에 따라 다른 모듈을 불러와야 할 때가 분명 있습니다. 사용자의 권한에 따라 관리자 모듈만 불러온다든지, 특정 페이지에 진입할 때만 무거운 라이브러리를 불러온다든지 하는 경우 말이죠.
import() 표현식
import()는 함수처럼 생겼지만 실제로는 표현식(expression)입니다. 모듈 경로를 인자로 받아서 해당 모듈의 내용이 담긴 Promise를 반환합니다.
import("./math.js").then((module) => {
console.log(module.sum(1, 2)); // 3
});
정적 import와 달리 코드 어디에서든 호출할 수 있고, 경로에 변수를 사용하는 것도 가능합니다.
const lang = navigator.language.slice(0, 2); // "ko", "en" 등
import(`./i18n/${lang}.js`).then((messages) => {
console.log(messages.greeting);
});
import()는 항상 Promise를 반환하기 때문에 모듈을 불러오면 .then()으로 결과를 받거나 불러오기에 실패하면 .catch()로 에러를 처리할 수 있습니다.
Promise가 생소하시다면 자바스크립트 비동기 처리 - Promise를 참고해보세요.
async/await와 함께 사용하기
Promise를 반환하니 당연히 async/await와도 궁합이 좋습니다. 오히려 실무에서는 이 조합을 더 자주 쓰게 됩니다.
async function loadMath() {
const { sum, multiply } = await import("./math.js");
console.log(sum(3, 4)); // 7
console.log(multiply(3, 4)); // 12
}
구조 분해 할당(destructuring)으로 필요한 함수만 꺼내 쓸 수 있어서 깔끔하죠. default export가 있는 모듈이라면 .default 속성으로 접근하면 됩니다.
async function loadChart() {
const { default: Chart } = await import("./chart.js");
// 또는
// const chartModule = await import("./chart.js");
// const Chart = chartModule.default;
const chart = new Chart("#canvas");
chart.render();
}
async/await문법에 대해서는 자바스크립트 비동기 처리 - async/await에서 자세히 다루고 있습니다.
조건부 모듈 로딩
import()는 조건에 따라 모듈을 선택적으로 불러올 때 가장 직관적입니다.
async function loadEditor(format) {
if (format === "markdown") {
const { MarkdownEditor } = await import("./markdown-editor.js");
return new MarkdownEditor();
} else {
const { RichTextEditor } = await import("./rich-text-editor.js");
return new RichTextEditor();
}
}
이 코드에서 마크다운 에디터와 리치 텍스트 에디터를 모두 미리 불러올 필요 없이, 실제로 필요한 모듈만 그때그때 불러옵니다.
사용자 권한에 따라 관리자 기능을 불러오는 것도 흔한 패턴입니다.
document.getElementById("admin-btn").addEventListener("click", async () => {
const { AdminPanel } = await import("./admin-panel.js");
const panel = new AdminPanel();
panel.mount(document.getElementById("admin-root"));
});
이렇게 하면 일반 사용자는 관리자 코드를 다운로드하지 않아도 되고, 관리자만 버튼을 클릭했을 때 해당 모듈을 네트워크로 가져옵니다.
코드 분할과 지연 로딩
대규모 웹 애플리케이션에서 import()가 가장 많이 쓰이는 곳은 단연 코드 분할(code splitting)입니다.
전통적으로 웹 애플리케이션은 모든 자바스크립트를 하나의 번들 파일로 묶어서 배포했습니다. 앱이 커지면 이 번들도 덩달아 커져서 첫 페이지 로딩이 느려지죠. 코드 분할은 번들을 여러 조각으로 나눠서, 당장 필요한 코드만 먼저 불러오고 나머지는 필요할 때 불러오는 기법입니다.
Webpack이나 Vite 같은 번들러는 import()를 만나면 자동으로 해당 모듈을 별도의 청크(chunk) 파일로 분리합니다.
// 번들러가 이 import()를 발견하면
// heavy-chart-lib.js를 별도 파일로 분리합니다
async function showChart(data) {
const { renderChart } = await import("./heavy-chart-lib.js");
renderChart(document.getElementById("chart"), data);
}
빌드 결과물을 보면 main.js와 별도로 heavy-chart-lib-xxxx.js 같은 청크 파일이 만들어지는 것을 확인할 수 있습니다.
라우트 기반 코드 분할
SPA(Single Page Application)에서 가장 효과적인 코드 분할 전략은 라우트(route) 단위로 나누는 것입니다. 사용자가 특정 페이지로 이동할 때 해당 페이지의 코드를 불러오면 되니까요.
const routes = {
"/": () => import("./pages/Home.js"),
"/about": () => import("./pages/About.js"),
"/dashboard": () => import("./pages/Dashboard.js"),
"/settings": () => import("./pages/Settings.js"),
};
async function navigate(path) {
const loadPage = routes[path];
if (loadPage) {
const { default: Page } = await loadPage();
Page.render(document.getElementById("app"));
}
}
홈 페이지에 접속한 사용자는 Home.js만 다운로드하고, 대시보드나 설정 페이지의 코드는 실제 이동할 때 불러옵니다. 페이지마다 수십 KB씩 하는 코드가 있다면 초기 로딩 시간이 크게 줄어들겠죠.
React에서 활용하기
React는 import()를 감싸서 컴포넌트를 지연 로딩할 수 있는 React.lazy()라는 API를 제공합니다.
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
lazy()는 import()를 반환하는 함수를 인자로 받고, Suspense는 컴포넌트가 로딩되는 동안 대체 UI(fallback)를 보여줍니다. 사용자가 /dashboard에 접속하면 그제야 Dashboard 컴포넌트의 코드가 네트워크를 통해 불러와지죠.
이 패턴은 React 앱에서 사실상 표준적인 코드 분할 방식으로 자리 잡았습니다.
React의 Suspense에 대해 더 알고 싶다면 React Suspense 소개를 참고해보세요.
에러 처리
네트워크를 통해 모듈을 불러오는 만큼 실패할 가능성도 항상 염두에 둬야 합니다. 사용자의 네트워크 연결이 불안정하거나 서버에 문제가 생길 수 있으니까요.
async function loadModule(path) {
try {
const module = await import(path);
return module;
} catch (error) {
console.error(`모듈 로드 실패: ${path}`, error);
// 대체 UI를 보여주거나 재시도 로직 구현
showErrorMessage("기능을 불러오는 데 실패했습니다. 다시 시도해주세요.");
}
}
React에서는 Suspense와 함께 Error Boundary를 사용하면 선언적으로 에러를 처리할 수 있습니다.
import { lazy, Suspense } from "react";
const HeavyComponent = lazy(() => import("./HeavyComponent"));
function App() {
return (
<ErrorBoundary fallback={<p>컴포넌트를 불러오지 못했습니다.</p>}>
<Suspense fallback={<p>불러오는 중...</p>}>
<HeavyComponent />
</Suspense>
</ErrorBoundary>
);
}
Node.js에서의 Dynamic Import
브라우저뿐 아니라 Node.js에서도 import()를 사용할 수 있습니다. CommonJS 환경에서 ES 모듈을 불러와야 할 때 특히 유용한데요. CommonJS의 require()는 ES 모듈을 직접 불러올 수 없지만, import()는 가능하거든요.
// CommonJS 파일에서 ES 모듈 불러오기
async function main() {
const { nanoid } = await import("nanoid"); // ESM 전용 패키지
console.log(nanoid()); // "V1StGXR8_Z5jdHi6B-myT"
}
main();
이 패턴은 CommonJS 프로젝트에서 ESM 전용 패키지를 써야 할 때 꽤 쓸만합니다. 물론 프로젝트 자체를 ES 모듈로 전환하는 것이 장기적으로는 더 나은 선택이지만, 점진적 마이그레이션 과정에서 import()가 다리 역할을 해줍니다.
Node.js의 모듈 시스템에 대한 자세한 내용은 Node.js에서 ES 모듈 사용하기를 확인해보세요.
import()와 정적 import의 비교
마지막으로 두 방식의 차이점을 정리해보겠습니다.
| 특성 | 정적 import | import() |
|---|---|---|
| 위치 | 파일 최상단만 가능 | 어디서든 호출 가능 |
| 경로 | 문자열 리터럴만 가능 | 변수, 템플릿 리터럴 가능 |
| 실행 시점 | 코드 실행 전 (파싱 단계) | 호출된 시점에 실행 |
| 반환값 | 바인딩을 직접 선언 | Promise 반환 |
| 트리 쉐이킹 | 지원됨 | 번들러에 따라 제한적 |
| 주요 용도 | 항상 필요한 의존성 | 조건부/지연 로딩 |
두 방식은 서로 대체하는 관계가 아닙니다. 항상 필요한 모듈은 정적 import로 불러오고, 특정 조건이나 시점에만 필요한 모듈은 import()로 불러오는 것이 올바른 사용법입니다.
마치며
import()로 모듈을 동적으로 불러오는 방법을 살펴봤는데요. 코드 분할과 지연 로딩을 통해 웹 애플리케이션의 초기 로딩 성능을 개선할 수 있고 조건부 모듈 로딩으로 불필요한 코드 다운로드도 방지할 수 있죠.
다만 import()를 남용하면 오히려 성능이 나빠질 수 있다는 점도 기억해 두세요. 모듈 하나를 불러올 때마다 네트워크 요청이 발생하고 너무 잘게 쪼개면 HTTP 요청 수가 늘어나 역효과가 나거든요. 항상 필요한 핵심 코드는 정적 import를 쓰고 조건부로 필요하거나 용량이 큰 모듈에만 import()를 적용하는 게 좋습니다.
자바스크립트 모듈 시스템에 대해 더 알아보고 싶으시다면 다음 글들도 참고해보세요.
This work is licensed under
CC BY 4.0