모듈은 자체 스코프를 가진 하나의 파일이다
자바스크립트 ES 모듈이 등장하기 전에는 <script> 태그마다 변수를 전역 네임스페이스에 그대로 쏟아냈고, 무엇이 무엇을 볼 수 있는지는 로드 순서에 따라 결정됐다. ES6 모듈은 각 파일을 독립된 스코프로 만들어 이 문제를 해결한다. export로 명시하지 않으면 밖으로 새어 나가지 않고, import로 명시하지 않으면 안으로 들어오지도 않는다.
파일 두 개로 살펴보자. 하나는 내보내고, 하나는 가져온다.
add와 multiply는 math.js에 정의돼 있고, main.js에서는 import 덕분에 비로소 쓸 수 있게 됩니다. math.js 안의 나머지 것들(헬퍼 함수든 상수든)은 바깥에서 전혀 건드릴 수 없어요.
여기서 자연스럽게 따라오는 두 가지 규칙이 있는데, 초반에 머릿속에 각인해 두는 게 좋습니다.
- 모듈은 자동으로 strict mode로 동작합니다.
'use strict'를 따로 붙일 필요가 없어요. - 최상위
this는 전역 객체가 아니라undefined입니다.
named export: 선언하면서 바로 내보내기
가장 많이 쓰는 방식입니다. function, class, const, let 앞에 export만 붙이면 그 순간 해당 항목이 모듈의 공개 API가 됩니다.
중괄호 안의 이름은 내보낸 이름과 정확히 일치해야 합니다. 예를 들어 import { circlearea }처럼 쓰면 에러가 나죠. 이미 쓰고 있는 이름과 겹친다면 as로 별칭을 붙여 가져올 수 있습니다:
인라인으로 export를 붙이는 대신 파일 맨 아래에 모아서 export하는 방식도 있습니다. "공개 API" 영역을 한눈에 보여줄 수 있어서 이 스타일을 선호하는 분들도 많습니다.
두 방식 모두 결과는 같으니, 프로젝트 안에서는 하나만 정해서 일관되게 사용하세요.
export default: 모듈당 하나만
모듈에는 default export를 하나 둘 수 있습니다. 파일의 주인공이 딱 하나일 때 — 컴포넌트 하나, 클래스 하나, 설정 객체 하나 — 이럴 때 export default가 잘 어울립니다:
눈여겨볼 점이 세 가지 있습니다.
- import 쪽에서
log주위에 중괄호가 없습니다. - 가져올 때 이름은 마음대로 지어도 됩니다.
import shout from './logger.js'라고 써도 똑같이 동작합니다. - default export는 파일당 하나만 가능합니다. 두 개를 넣으면 파싱 자체가 실패합니다.
named export와 default export는 한 파일에 같이 쓸 수 있습니다.
default를 먼저 쓰고, 그다음에 중괄호로 named export를 가져옵니다. 순서는 고정입니다.
어떤 걸 써야 할까요? named export는 리팩터링이 훨씬 편합니다. 모든 import가 같은 이름을 쓰기 때문에 코드베이스 전체에서 이름을 바꿀 때 찾아 바꾸기 한 번이면 끝나거든요. 반면 default export는 유연한 대신 호출하는 쪽마다 이름을 제각기 붙일 수 있어서 grep으로 추적하기가 까다로워집니다. 요즘 나오는 스타일 가이드들은 대체로 named export를 권장하고, default export는 정말 하나의 용도만 있는 모듈에만 쓰도록 제한하는 분위기입니다.
전체 import, 재export, 사이드 이펙트 import
실무에서 마주치게 될 import 형태를 몇 가지 더 살펴봅시다.
모든 named export를 하나의 네임스페이스 객체로 묶어서 가져오려면:
현재 스코프로 가져오지 않고 다른 모듈에서 바로 재내보내기(re-export)할 수도 있습니다:
라이브러리의 엔트리 포인트가 내부 파일들을 끌어모아 하나의 공개 API로 노출할 때 바로 이 패턴을 자주 씁니다.
마지막으로, 아무것도 가져오지 않는 import도 있습니다. 폴리필, CSS-in-JS, 핸들러 등록처럼 사이드 이펙트만을 목적으로 하는 모듈에서 쓰는 방식이죠:
파일은 한 번만 실행되고, 이름으로 가져오는 값은 아무것도 없습니다.
import는 정적이고, 값은 살아 있다
import에는 가끔 사람들을 당황하게 만드는 두 가지 특성이 있습니다.
정적(static)이다. import 선언은 여러분의 코드가 실행되기 전에 먼저 해석됩니다. 그래서 if 문 안이나 함수 안, try 블록 안에 넣을 수 없습니다. 경로도 변수가 아니라 반드시 문자열 리터럴이어야 하죠. 바로 이 제약 덕분에 번들러, 타입 체커, 트리 셰이커 같은 도구들이 코드를 실제로 실행하지 않고도 import 관계를 분석할 수 있는 겁니다.
// 허용되지 않음 — SyntaxError.
if (userWantsFancy) {
import { fancy } from './fancy.js';
}
조건에 따라 모듈을 불러와야 한다면 import()를 쓰면 됩니다 (바로 다음에 다룹니다).
라이브 바인딩. import한 값은 원본을 복사해 둔 스냅샷이 아니라, export 쪽을 가리키는 읽기 전용 참조입니다. 따라서 export한 모듈에서 값을 다시 할당하면, import한 쪽에서도 바뀐 값이 그대로 보입니다:
컨슈머 쪽에서 import한 값을 재할당하는 것도 불가능합니다. main.js에서 count = 5라고 쓰면 에러가 발생하죠. import는 읽기 전용 뷰이기 때문입니다.
동적 import()로 필요할 때만 모듈 불러오기
런타임 시점에 모듈을 불러올지 말지 결정해야 하는 경우, 예를 들어 무거운 기능이나 라우트 기반 코드 스플리팅, 조건부 폴리필 같은 상황에서는 import()를 함수처럼 사용하면 됩니다. 이 함수는 모듈의 export들을 담은 프로미스를 반환합니다:
일반 함수 호출이기 때문에 다음과 같이 활용할 수 있습니다:
async함수 안에서await으로 기다릴 수 있습니다.- 경로 자리에 변수를 넘길 수 있습니다.
if문이나try/catch블록 안에서도 쓸 수 있습니다.
반환된 객체를 구조 분해하는 방식은 정적 import와 동일합니다:
구조 분해 할당을 할 때 default export는 default라는 키로 들어옵니다. 원하는 이름으로 바꿔서 받으면 됩니다.
실무에서 자주 쓰이는 경우는 코드 스플리팅(사용자가 "차트 보기"를 눌렀을 때만 차트 라이브러리를 내려받기), 기능 감지 기반 폴리필, 그리고 런타임에 동적으로 로드하는 플러그인 시스템 같은 것들입니다.
브라우저와 Node.js에서 ES 모듈 실행하기
문법 자체는 어디서나 동일합니다. 다만 런타임이 파일을 찾아서 불러오는 방식이 환경에 따라 다릅니다.
브라우저에서는 진입점 스크립트를 모듈로 표시해 줘야 합니다:
<script type="module" src="./main.js"></script>
type="module"을 지정하면 브라우저가 import/export를 인식하고, 코드를 strict mode로 실행하며, HTML 파싱이 끝날 때까지 실행을 미뤄 둡니다. 경로는 반드시 상대 경로(./, ../)나 절대 URL이어야 하고, import 'lodash'처럼 패키지 이름만 쓰는 bare specifier는 import map이나 번들러 없이는 동작하지 않습니다.
Node.js에서 ESM을 쓰는 방법은 두 가지입니다.
- 파일 확장자를
.mjs로 지정하거나, - 가장 가까운
package.json에"type": "module"을 추가해 모든.js파일을 모듈로 취급하게 만드는 방법.
또한 Node.js에서는 확장자까지 포함한 전체 경로가 필요합니다. import './utils'가 아니라 import './utils.js'처럼 써야 합니다.
// package.json
{
"type": "module",
"main": "./index.js"
}
네이티브 ESM 환경에서는 두 경우 모두 확장자를 명시적으로 써야 합니다. Vite, webpack, esbuild 같은 번들러는 개발 중에 확장자 없는 경로도 알아서 처리해 주지만, 그만큼 편한 대신 빌드 과정 없이는 소스가 그대로 동작하지 않는다는 뜻이기도 합니다.
자주 겪는 실수들
ES6 모듈을 쓰다 보면 흔히 걸리는 포인트들입니다:
- 브라우저에서
type="module"을 빠뜨리는 경우. 이걸 안 붙이면<script>가 일반 스크립트로 실행되고,import는 문법 오류가 납니다. - Node.js에서 파일 확장자를 빠뜨리는 경우.
import './utils'는 실패하고,import './utils.js'로 써야 동작합니다. 번들러는 이걸 가려주지만 네이티브 런타임은 봐주지 않습니다. - ES 모듈에서
__dirname이나require를 기대하는 경우. 이 둘은 CommonJS 전용입니다. ESM에서는import.meta.url을 쓰고, 경로가 필요하면 거기서 변환해서 쓰면 됩니다. - 준비되지 않은 값을 건드리는 순환 import. 두 모듈이 서로를 import하는 것 자체는 문제없지만, 아직 할당되지 않은 export를 읽으면
undefined가 돌아옵니다. 초기화 단계에서 순환이 발동하지 않도록 코드 구조를 잡거나, 아예 모듈을 분리하세요. - 조건부로
import를 쓰려는 경우. 정적import문으로는 불가능합니다. 런타임에 따라 달라지는 상황이라면 동적import()함수를 쓰세요.
다음 글: CommonJS vs ESM
ES 모듈이 표준이긴 하지만, 실무의 Node.js 코드에는 여전히 CommonJS가 많이 남아 있습니다. require, module.exports, 그리고 코드 실행 시점에 대한 규칙도 다릅니다. 이 둘을 모두 이해하고 서로 어떻게 맞물려 돌아가는지 아는 것이 다음 주제입니다.
자주 묻는 질문
자바스크립트에서 ES 모듈은 어떻게 사용하나요?
한 파일에서 export 또는 export default로 값을 내보내고, 다른 파일에서 import로 가져오면 됩니다. 브라우저에서는 진입 파일을 <script type="module" src="main.js"></script>로 불러오면 되고, Node.js에서는 확장자를 .mjs로 쓰거나 package.json에 "type": "module"을 추가하면 ESM으로 동작합니다.
default export와 named export는 뭐가 다른가요?
하나의 모듈에서 named export(export function foo() {})는 여러 개 만들 수 있지만, default export(export default ...)는 파일당 하나만 가능합니다. named export는 중괄호 안에 정확히 같은 이름으로 가져와야 합니다 — import { foo } from './x.js'. 반면 default export는 import whatever from './x.js'처럼 원하는 이름을 붙여 가져올 수 있습니다.
동적 import()는 무엇인가요?
import()를 함수처럼 호출하면 해당 모듈의 export들을 담은 Promise가 반환됩니다. 정적 import 문과 달리 호출 시점에 실행되기 때문에 조건부로 불러오거나 필요할 때만 로드할 수 있죠. 코드 스플리팅(code splitting)과 지연 로딩(lazy loading)을 구현할 때 바로 이 문법을 씁니다.
import 경로에 확장자를 꼭 써야 하나요?
브라우저나 Node.js의 ESM 로더처럼 네이티브 ES 모듈 환경이라면 꼭 써야 합니다. ./utils가 아니라 ./utils.js로 명시해야 해요. Vite나 webpack 같은 번들러는 확장자 없이도 알아서 해석해 주지만, 이에 의존하면 코드 이식성이 떨어지니 주의하세요.