코드가 조금만 커지면 한 파일에 다 때려 넣는 게 불가능해진다. 함수 수백 개가 한 파일에 뒤엉키면 어디에 뭐가 있는지 찾다가 하루가 간다. 그래서 코드를 기능별로 여러 파일에 나눠 담고, 필요한 걸 서로 가져다 쓰는 장치가 필요하다. 그게 모듈 시스템이다. 나는 옛날에 script 태그를 순서 맞춰 여러 개 늘어놓고, 파일들이 전역(global, 어디서나 보이는 공용 공간)에 함수를 흩뿌려 이름이 겹쳐 덮어쓰는 사고를 숱하게 겪었다. 모듈은 그 난장판을 끝냈다.


모듈의 원리는 단순하다. 파일 하나가 곧 모듈 하나이고, 그 안의 것들은 기본적으로 그 파일 안에만 숨어 있다. 밖으로 내보내겠다고 표시한 것만 다른 파일이 가져다 쓸 수 있다. 이번 편은 내보내는 export와 가져오는 import, 이름이 정해진 방식과 대표 하나만 내보내는 방식의 차이, 그리고 오래된 방식과 표준 방식이 어떻게 다른지를 실제 파일을 돌려가며 정리한다.


내보낸 것만 밖에서 보인다. 가장 기본은 이름을 붙여 내보내는 방식이다. 변수나 함수 앞에 export를 붙이면 그게 그대로 밖으로 열린다. 파일 하나에서 여러 개를 이렇게 내보낼 수 있다. math.mjs라는 파일을 하나 만들어보자.


export const PI = 3.14;
export function add(a, b) { return a + b; }
export function square(x) { return x * x; }


가져오는 쪽에서는 중괄호 안에 필요한 이름만 골라 적는다. 셋 다 내보냈어도 쓸 것만 집어 올 수 있다.


import { add, PI } from "./math.mjs";
console.log(add(2, 3));
console.log(PI);


실행 결과다.


5
3.14


이렇게 이름을 그대로 주고받는 방식을 이름 있는 내보내기(named export)라 한다. 핵심은 가져올 때 이름이 내보낸 이름과 정확히 같아야 한다는 거다. addplus로 적으면 안 된다. 중괄호 안 이름은 내가 짓는 게 아니라, 그 파일이 내건 이름을 그대로 부르는 것이다.


대표 하나는 default로 내보낸다. 파일이 딱 하나의 주된 것만 내놓을 때는 default(기본)를 붙인다. 클래스 하나가 통째로 그 파일의 주인공일 때 흔히 쓴다.


export default class User {
constructor(name) { this.name = name; }
hello() { return "안녕 " + this.name; }
}


가져올 때는 중괄호가 없고, 이름도 내 마음대로 지을 수 있다.


import User from "./user.mjs";
const u = new User("김철수");
console.log(u.hello());


실행 결과다.


안녕 김철수


여기가 이름 있는 방식과 갈리는 지점이다. default로 내보낸 건 파일당 하나뿐이라 이름이 따로 없고, 그래서 받는 쪽이 UserPerson이든 원하는 이름을 붙여 받는다. 중괄호가 있으면 이름 있는 것, 없으면 기본인 것으로 구분하면 된다. 나는 초보 때 이 둘을 섞어 이름 있는 걸 중괄호 없이 가져오려다 값이 undefined(값 없음)로 나와 한참 헤맸다. 중괄호 유무 하나로 완전히 다른 방식이라는 걸 그때 몸으로 배웠다.


둘을 섞고 이름도 갈아 끼운다. 한 파일이 대표 하나와 곁다리 여럿을 같이 내보낼 수도 있다. 그러면 가져올 때 기본과 이름 있는 것을 한 줄에 같이 적는다. as로 이름을 바꿔 받을 수도 있다.


export default function greet() { return "기본 인사"; }
export const version = "1.0";
export function reset() { return "초기화"; }


이걸 가져오는 쪽이다. 기본은 중괄호 밖에, 이름 있는 것들은 중괄호 안에 적고, resetclear라는 이름으로 바꿔 받았다.


import greet, { version, reset as clear } from "./lib.mjs";
console.log(greet());
console.log(version);
console.log(clear());


실행 결과다.


기본 인사
1.0
초기화


as로 이름을 바꾸는 건 멋이 아니라, 서로 다른 파일에서 가져온 것들의 이름이 겹칠 때 충돌을 피하려고 한쪽을 갈아 끼우는 실용적인 장치다.


import는 맨 위로 끌려 올라가고, 모듈은 한 번만 실행된다. 모듈에는 두 가지 성질이 있는데, 이걸 모르면 이상한 순서 버그에 걸린다. 첫째, import는 파일 어디에 적든 그 파일이 실행되기 전에 맨 위로 끌어올려져 먼저 처리된다. 둘째, 같은 모듈을 여러 번 가져와도 그 파일의 코드는 딱 한 번만 실행되고, 그 결과가 캐시(cache, 저장해두고 재사용)되어 공유된다.


console.log("모듈 코드 실행됨");
export const x = 1;


side.mjs를 한 파일에서 두 번 가져와 봤다.


import { x } from "./side.mjs";
import { x as x2 } from "./side.mjs";
console.log("x =", x, x2);


실행 결과다.


모듈 코드 실행됨
x = 1 1


두 번 가져왔는데도 모듈 코드 실행됨은 한 번만 찍혔다. 모듈은 한 번 로드되면 그 인스턴스가 재사용되기 때문이다. 이 성질 덕에, 설정 객체나 데이터베이스 연결처럼 앱 전체가 하나만 공유해야 하는 것을 모듈에 담으면 저절로 단 하나만 존재하게 된다. 나는 이걸 몰라, 모듈을 가져올 때마다 코드가 새로 도는 줄 알고 초기화를 여기저기 넣었다가 이미 되어 있는 걸 확인하고 지운 적이 있다.


이름을 틀리면 조용히가 아니라 대놓고 터진다. 이름 있는 방식의 좋은 점은 실수가 즉시 드러난다는 거다. 없는 이름을 가져오려 하면 코드가 돌기도 전에 에러가 난다.


import { subtract } from "./math.mjs";
console.log(subtract(5, 2));


실행하면 이런 에러가 뜬다.


SyntaxError: The requested module './math.mjs' does not provide an export named 'subtract'


math.mjssubtract를 내보낸 적이 없다는 걸 실행 전에 딱 짚어준다. 표준 모듈의 importexport는 코드가 돌기 전 구조를 미리 분석하는 정적(static, 실행 전에 정해짐) 방식이라, 오타나 없는 이름을 초반에 잡아준다. 값이 undefined로 새서 한참 뒤에 터지는 것보다 이 편이 백배 낫다.


오래된 방식과 표준 방식이 공존한다. 여기까지가 표준 방식인 ESM(ECMAScript Modules, 표준 모듈)이고, importexport를 쓴다. 그런데 서버 쪽 Node.js(자바스크립트 실행 환경)에는 그전부터 쓰던 오래된 방식인 CommonJS가 따로 있다. 이건 require로 가져오고 module.exports로 내보낸다.


function add(a, b) { return a + b; }
const PI = 3.14;
module.exports = { add, PI };


가져오는 쪽은 require가 돌려준 객체에서 필요한 걸 꺼낸다.


const { add, PI } = require("./math.cjs");
console.log(add(10, 20));
console.log(PI);


실행 결과다.


30
3.14


둘의 차이는 문법만이 아니다. CommonJS의 require는 코드가 그 줄에 닿는 순간 파일을 읽어 오는 동기(순서대로 즉시 처리) 방식이고, 무엇을 가져올지 실행 중에 정할 수 있다. 반면 ESM은 실행 전에 구조를 먼저 분석하는 정적 방식이라 없는 이름을 미리 잡아낸다. 요즘 새로 짜는 코드는 브라우저와 Node.js 양쪽이 표준으로 미는 ESM을 쓰는 게 맞고, CommonJS는 옛 코드와 오래된 라이브러리에서 계속 만난다.


확장자와 설정 한 줄이 방식을 가른다. 내가 이 둘 때문에 제일 자주 데인 건 어느 방식으로 해석될지가 파일마다 다르다는 점이었다. Node.js는 파일 확장자가 .mjs면 ESM, .cjs면 CommonJS로 읽는다. 그냥 .js는 그 프로젝트의 package.json"type": "module"이 있으면 ESM으로, 없으면 CommonJS로 해석된다. 나는 이 설정을 안 맞춰두고 .js 파일에 import를 썼다가 문법이 틀렸다는 에러를 보고 오타를 찾아 헤맸다. 알고 보니 그 파일이 CommonJS로 해석되고 있어서 import 문법 자체가 막힌 거였다. 에러 메시지가 문법 탓처럼 보여도, 이럴 땐 이 파일이 어느 방식으로 읽히고 있는지부터 확인해야 한다.


정리하면 모듈은 파일 하나를 상자 하나로 삼아, 내보낸 것만 밖에서 보이게 하는 장치다. 여럿을 이름 그대로 주고받는 이름 있는 내보내기와, 대표 하나를 이름 없이 내놓는 기본 내보내기가 있고, 둘은 중괄호 유무로 갈린다. import는 맨 위로 끌려 올라가 먼저 처리되고, 모듈 코드는 여러 번 가져와도 한 번만 돈다. 표준인 ESM과 오래된 CommonJS가 공존하니, 새 코드는 ESM으로 쓰되 확장자와 type 설정으로 어느 방식인지 분명히 해두면 이름 겹침도 순서 사고도 없이 코드를 깔끔하게 쪼갤 수 있다.