📢 FEConf의 내 import 문이 그렇게 이상했나요?를 보고 정리한 글입니다.
Node.js도 없고 모듈이라는 개념이 존재하지 않던, 자바스크립트가 브라우저에서만 사용되던 시절이 있었습니다. 그 시절에는 script 태그를 직접 임포트해서 jQuery, lodash같은 값이 전역 변수에 채워지는 방식으로 라이브러리 관리를 했습니다. 라이브러리를 사용할 때는 정의된 전역 변수를 참조하는 방식이었죠.
// <!-- 전역 jQuery 객체를 정의 -->
<script src="https://cdn.com/jquery.js"></script>
// <!-- 전역 lodash 객체를 정의 -->
<script src="https://cdn.com/lodash.js"></script>
// <!-- 전역 객체를 참조하여 사용 -->
<script>
jQuery(document).ready(funtion() {
lodash.get(obj, 'foo');
});
</script>
당연하게도 전역 변수를 참조하는 방식은 변수의 이름이 겹친다거나, 관리해야 할 파일이 많아지면 하나하나 가져오기가 어려워지는 등 수많은 문제를 야기했습니다.
이러한 문제점을 해결하기 위해 CommonJs라고 하는 모듈 시스템이 탄생했습니다. CommonJs의 가장 큰 특징은 require라는 함수로, 이전에는 복잡하게 가져와야만 했던 라이브러리들을 함수 호출 한 번으로 처리할 수 있게 되었습니다.
const JQuery = require('jQuery')
const lodash = require('lodash')
jQuery(document).ready(funtion() {
lodash.get(obj, 'foo');
})
또한, CommonJs의 등장 이후로 함수를 외부에 노출하거나 가져오는 것이 간단해졌습니다. 이전에는 모듈마다 라이브러리 함수를 노출하고 가져오는 데 표준이 없었지만, CommonJs가 새로운 표준을 제시해 준 것이죠.
// CommonJs 이후, 아래의 add 같은 작은 함수도 노출하거나 가져오기 쉬워졌다.
exports.add = function (a, b) {
return a + b;
}
const { add } = require('./add.js');
정리해보자면, 거대한 파일 하나만 가지고 수정하는 워크플로우가 당연했던 과거와는 달리 CommonJs 모듈 시스템은 거대한 파일을 수백, 수천 개의 JS 파일로 분리해서 개발하는 것을 가능하게 했습니다. 이로 인해 코드를 재사용하기 쉬워졌으며, 손쉽게 라이브러리 함수를 재사용할 수도 있게 되었습니다.
이런 장점들 때문에 Node.js에서는 아직까지도 많은 부분에서 CommonJs를 사용하고 있습니다. 그런데 뭔가 이상합니다. 저희가 실제로 개발할 때에는 대부분 import와 export문을 사용하고 있는 것 같은데 Node.js는 어디서 CommonJs를 쓰고 있다는 걸까요?
Node.js가 CommonJs를 사용하고 있다는 것을 느끼지 못했던 이유, 그것은 우리가 개발할 때 대부분 타입스크립트 컴파일러나 바벨을 사용하고 있기 때문입니다.
import React from 'react'
------------------------------
// 위의 import문이 타입스크립트 컴파일러, 바벨을 거치면 아래처럼 변환된다.
⬇️ typescript compiler, babel ⬇️
------------------------------
const React = require('react')
우리가 쓰고있던 import, export는 사실 require문이었고, CommonJs였던 것입니다.
CommonJs는 언어 표준이 아니기 때문에 Node.js를 지원하고, CommonJs 모듈 시스템을 지원하는 런타임에서만 사용할 수 있습니다. 브라우저나 Deno같은 CommonJs 모듈 시스템이 없는 환경에서는 사용할 수 없습니다.
// 조건적으로 require 호출
if (SOME_CONDITION) {
React = require('react');
}
// 삼항연산자로 동적으로 변하는 require 대상
require(SOME_CONDITION ? 'foo' : 'bar');
// 변수에 require를 저장했다가 사용
const originalRequire = global.require;
originalRequire(...);
require 함수는 말 그대로 함수이기 때문에 import나 export처럼 언어의 키워드가 아닙니다. 그래서 조건적으로 require를 호출하거나, require 대상이 삼항연산자로 인해 동적으로 변한다거나, 다른 변수에 require를 저장했다가 꺼내 쓰는 것이 가능합니다.
이런 신기한 동작들은 코드가 어떤 코드를 참조하는지 분석하기 어렵게 만듭니다. 특히 브라우저에서는 성능을 위해 실제로 쓰는 코드만 포함하는 Tree shaking과 같은 작업이 필요한데, CommonJs에서는 이러한 작업이 상당히 어렵습니다.
let isInitialized = false;
exports.initialize = async function initialize() {
if (isInitialized) {
throw new Error('이미 initialize 되었습니다.');
}
await connectToDB();
isInitalized = true;
}
exports.readFromDB = async function readFromDB( ... ) {
if (!isInitialized) {
throw new Error('먼저 initialize를 호출하세요.');
...
}
CommonJs는 동기적으로 동작하기 때문에 비동기 모듈을 정의하기 힘들다는 단점도 있습니다. 위 코드처럼 어떤 DB에 읽고 쓰는 모듈을 만들고 싶다고 가정해 봅시다. DB에 조회나 삽입 명령을 내리려면 먼저 연결을 맺어야 합니다. 그런데 이런 연결동작은 비동기적으로 동작합니다. 따라서 CommonJs에서는 initialize 함수를 정의해서 이 모듈을 사용하기 전에 먼저 초기화를 하도록 하고, 다른 함수를 실행할 때마다 매번 초기화되었는지 검사하는 방식으로만 모듈을 사용할 수 있습니다. 이러한 CommonJs의 단점은 Javascript의 장점인 비동기 프로그래밍과도 궁합이 좋지 않습니다.
require는 함수이기 때문에 누구나 마음대로 동작을 바꿀 수 있습니다.
const defaultRequire = global.require;
const myRequire = (request: string) => {
...
}
global.require = myRequire;
위의 예시처럼 global require를 myRequire라고 재정의하는 것도 가능합니다. 이런 경우 require 함수가 Monkey Patch되어 예상하지 못한 동작을 할 위험이 있습니다.
CommonJs는 처음으로 성공한 자바스크립트 모듈 시스템이었지만, 다양한 문제점을 가지고 있었기 때문에 자바스크립트 생태계는 좀 더 좋은 모듈 시스템을 필요로 했습니다. 이 과정에서 ECMAScript Modules(ESM)라고 하는 표준 모듈 시스템이 등장했습니다. ESM은 앞서 언급했던 진짜 import문의 정체이기도 합니다.
export function add(x, y) {
return x + y
}
import { add } from './add.js'
console.log(add(1, 2));
ESM은 우리가 자주 사용하는 문법과 비슷하게, 아주 직관적으로 함수를 import와 export 할 수 있습니다.
// 틀린 코드 1
if (SOME_CONDITION) {
import React form 'react';
}
// 틀린 코드 2
import Something from CONDITION ? 'foo' : 'bar';
// 틀린 코드 3
const myImport = import;
myImport React from 'react'
ESM 환경에서는 조건적으로 모듈을 임포트할 수 없습니다. 따라서 자바스크립트 파일이 어떤 파일을 참조하고 있는지 쉽게 알 수 있습니다. 또, 브라우저 환경처럼 자바스크립트 크기를 줄여야 하는 환경에서 쓰기 좋습니다.
ESM의 import와 export는 함수인 require와는 달리 자바스크립트의 키워드(if, for, while 같은)이기 때문에 다른 변수로 재할당 할 수 없습니다. 이는 예측하기 어려운 Monkey Patch를 막아줍니다.
최근 출시된 Top-level await 기능을 사용하면 모듈의 가장 위에서 await를 사용할 수 있습니다. 덕분에 쉽게 비동기 모듈을 정의할 수 있는데요, 앞서 예시로 보여드렸던 DB 모듈도 아래 코드처럼 먼저 DB에 연결한 다음에 read나 write 함수를 노출하도록 설정할 수 있습니다. 이는 비동기적으로 동작하는 ESM의 특징 덕분에 가능한 것입니다.
const db = await connectToDB();
export async function readFromDB() {
await db.read();
}
export async function writeToDB() {
await db.write(...);
}
ESM이 CommonJS의 다양한 문제점들을 해결하게 되면서 언어 표준으로 지정되었습니다. 따라서 ESM은 자바스크립트 언어의 일부가 되었고, Node.js 지원 환경에서만 사용 가능했던 CommonJs와는 달리 브라우저, Deno 등 다양한 런타임에서도 쉽게 사용할 수 있습니다.
CommonJs | ESM |
---|---|
require | import/export |
정적 분석 어려움 | 정적 분석 쉬움 |
동기 | 비동기 |
언어 표준이 아님 | 언어 표준 |
ESM의 장점들이 명확하기 때문에 Node.js 생태계는 ESM으로 향하고 있다고 말씀드릴 수 있을 것 같은데요, 자료에 따르면 2022년 초부터 ESM 패키지의 비율이 점점 상승하고 있습니다. 특히 최근까지도 유지보수가 잘 되는 패키지의 경우 ESM을 사용하는 비율이 높았습니다.
하지만 ESM 패키지들이 많아지는 것이, 우리가 개발하면서 만나는 에러 메시지들의 원인이 되고 있습니다. 왜 그런걸까요?
비동기 함수는 바로 값을 반환하지 않고 promise나 callback으로 값을 돌려주는 함수, 동기 함수는 즉시 값을 반환하는 함수를 일컫습니다. 그렇다면 이 두 함수의 관계는 어떻게 될까요? 비동기 함수가 동기 함수를 호출할 수 있는지, 동기 함수가 비동기 함수를 호출할 수 있는지 생각해 보신 적이 있나요?
아시다시피 비동기 함수에서 동기 함수를 호출하기는 쉽지만, 동기 함수에서 비동기 함수를 호출하기는 굉장히 어렵습니다. 글의 앞부분에서 CommonJs는 동기적으로, ESM은 비동기적으로 동작한다고 했던 것을 기억하시죠? 마찬가지로, ESM(비동기)에서는 CommonJs(동기)를 쓰기가 수월하지만, CommonJs(동기)에서는 ESM(비동기)를 쓰기가 굉장히 어렵습니다.
CommonJs는 import 가능 (ESM의 방식대로 CommonJs 사용 가능)
react는 CommonJs 모듈이지만 ESM 방식으로 임포트가 가능한 모습
$ node
Welcome to Node.js v16.14.0.
Type ".help" for more information
> await import('react')
[Module: null prototype] { Component, Fragment, ... }
반대로, ESM은 require 불가능 (CommonJs의 방식으로 ESM 사용 불가능)
ky는 ESM 모듈로 CommonJs 방식 사용시 에러
$ node
Welcome to Node.js v16.14.0.
Type ".help" for more information
> require('ky')
Uncaught:
Error: require() of ES Module
/Users/.../index.js not supported.
우리를 괴롭히는 에러 메시지들을 해결하는 방법은 패키지를 ESM으로 옮기는 것밖에 없습니다. ESM을 적용하기 위해, 먼저 Node.js에서 ESM을 어떻게 다루고 있는지 이론적인 부분을 살펴보겠습니다.
아시다시피 Node.js 생태계는 굉장히 오랜 시간동안 CommonJs 모듈 시스템을 사용했기 때문에 굉장히 많은 라이브러리들이 CommonJs를 사용하고 있습니다. 따라서 점진적인 마이그레이션 계획이 중요한데요, Node.js는 그 과정에서 우선 package.json에 새로운 타입인 module을 추가했습니다. 이제 우리는 'type': 'module'
로 이 프로젝트는 ESM 방식으로 동작한다고 명시할 수 있습니다. type을 설정하면, 패키지 하위의 모든 자바스크립트 파일들이 ESM 방식으로 모듈을 사용하게 됩니다. 반대로, 'type': 'commonjs'
로 설정하면 하위 파일들은 CommonJs 방식으로 동작합니다. 하지만 type의 기본값이 CommonJs이므로 따로 명시해줄 필요는 없습니다.
// package.json - type: module
{
...
"type": "module",
...
}
자바스크립트 파일의 모듈 형식은 가장 가까운 package.json의 설정을 따릅니다. js 파일이 CommonJs 패키지 안에 속해있다면 그건 CommonJs 모듈 시스템을 사용하고, 반대로 ESM 패키지 안에 속해있다면 ESM을 사용합니다.
경우에 따라서 일부 파일만 CommonJs나 ESM을 사용하고 싶은 경우가 있습니다. 예를 들어, babel이나 Jest 설정 파일은 이미 CommonJs 방식으로 작성되어 있을 가능성이 높은데, 이런 경우 ESM으로 옮기고 난 다음에도 당분간은 CommonJs를 유지하고 싶을 수 있습니다. 이를 위해서 Node.js는 .cjs, .mjs 확장자를 제공해서 .cjs는 항상 CommonJs, .mjs는 항상 ESM으로 동작할 수 있도록 하고 있습니다.
현재 개발 환경에서는 가짜 ESM을 CommonJs로 변환해서 사용하는 경우가 많습니다. 따라서 실제 ESM 문법과 동작이 다른 경우가 있습니다.
// 문제 있는 코드
import { Component } from './MyComponent'
일반적으로 우리는 import문을 쓸 때 위와 같이 코드를 작성합니다. 너무 일반적이기 때문에 뭐가 문제인지도 잘 모르겠는데요. 그런데 사실은 이 import문은 ESM 표준 문법에 따르면 틀린 문법입니다. 과연 뭐가 틀렸을까요?
// 문제 없는 코드
import { Component } from './MyComponent.js'
차이가 느껴지시나요? 가짜 ESM에는 확장자가 없지만, 진짜 ESM은 확장자가 있습니다. Node.js의 require는 확장자를 생략하더라도 다양한 파일 시스템을 순회하면서 우리가 요청한 내용을 알아서 찾아주려고 노력합니다.
const { Component } = require('./MyComponent')
⬇️
./MyComponent
./MyComponent.js
./MyComponent.node
./MyComponent.index.js
당연하게도, 이런 순회 동작은 비용이 비쌉니다. webpack의 속도가 느려지고, Node.js의 성능이 나빠지는 대표적인 원인 중 하나이기도 합니다. 때문에 Node.js와 Deno의 창시자인 Ryan Dahl은 Node.js에서 후회하는 10가지 중 하나로 이 문제를 언급하기도 했습니다.
"나는 Node.js require() 에서 확장자를 명시하지 않아도 되도록 한 결정을 후회한다.
...
브라우저의 동작과도 맞지 않고, JS 파일을 불러오기 위해 몇 번의 파일시스템 접근을 해야 한다." - Ryan Dahl, Node.js 창시자
ESM에서는 이런 문제를 해결하기 위해서 확장자를 포함한 정확한 경로를 명시하는 것을 필수로 하고 있습니다.
"import하는 파일은 반드시 확장자가 명시되어야 한다.
... 브라우저에서 import가 동작하는 방법과 동일하다."
ㅤ
"A file extension must be provided when using the import keyword to resolve relative or absolute specifiers.
... This behavior matches how import behaves in browser environments." - Node.js 공식 문서
ESM으로 마이그레이션하기 가장 어려운 이유는 ESM 생태계가 충분히 성숙하지 않았다는 점입니다.
예를 들어 타입스크립트의 ESM 지원 문제가 있는데요, 자바스크립트를 사용하는 경우에는 절반 이상 문제가 해결되었다고 볼 수 있을 것 같지만 타입스크립트를 사용할 경우 문제의 복잡도가 상승하는 지점들이 있습니다.
2022년 5월 발표된 타입스크립트 4.7 버전에서부터 ESM이 지원되기 시작했으나, 지원 방식은 다소 혼란스럽습니다.
// 틀린 typescript 코드
import { add } from './add.ts';
// 올바른 typescript 코드
import { add } from './add.js';
타입스크립트 환경에서 ESM을 사용하려면 .ts 파일만 있더라도 .js 파일로 import해야 합니다. 타입스크립트의 Design Goals에 따르면 타입스크립트 컴파일러로 타입 정보를 다 지우더라도 파일이 완벽하게 동작하기를 바라기 때문에, 확장자를 re-write하게 되면 실제 코드의 내용을 바꾸게 되므로 올바른 디자인 결정이 아니라고 판단한 것입니다.
이렇게 되면 webpack이나 ts-node같은 도구들과 궁합이 맞지 않고, Deno처럼 타입스크립트를 기반으로 ESM을 쓰던 런타임의 동작과 달라지는 부분들이 있어 현재 이 결정에 대해서 많은 이슈들이 열려 있습니다. 장기적으로는 다른 방향으로 결정이 될 수도 있겠습니다만, 현재 ts 상태는 이렇다고 이해해주시면 될 것 같습니다.
추가로, 타입스크립트에서는 .mjs나 .cjs 확장자에 대응하기 위해 .mts, .cts 확장자를 추가했습니다. 타입스크립트 생태계에서 ESM을 사용할 때에는 이러한 러닝 커브도 존재합니다.
Javascript | Typescript |
---|---|
.js, .jsx | .ts, .tsx |
.cjs | .cts |
.mjs | .mts |
$ node
Welcome to Node.js v16.14.0.
Type ".help" for more information.
> await import('next/app')
❌ Uncaught:
Error: Qualified path resolution failed: we looked for
the following paths, but none could be accessed.
/(slash)를 이용해서 라이브러리의 일부만 참조할 때도 문제가 발생합니다. 예를 들어, next에서 next/app을 쓰는 경우 CommonJs 방식으로 import할 때는 문제가 없지만 ESM 방식으로 import하면 위와 같은 에러가 발생합니다. 확장자를 정확히 써야만 하는 ESM의 문법 때문에 await import('next/app')
이 아닌 await import('next/app.js')
와 같이 써야 하는 것이죠.
하지만 이런 식으로 작성하는 것이 라이브러리 개발자들의 의도는 아니라고 생각합니다. 라이브러리 개발자들은 next.js 안의 app.js를 정확하게 가져오라고 하기 보다는, next.js의 부분 중에서 app 부분만 가져오라는 의미로 쓰고 싶은 경우가 더 많기 때문입니다. 이런 점을 해결하기 위해서 node.js v12.20부터는 Exports field라는 것을 지원하기 시작했으나 2022년 10월 기준 Next.js에서는 Exports field를 지원하지 않고 있습니다.
// Exports Field - package.json
{
"exports": {
"./app": {
"import": "./app.js"
}
}
}
Jest, ts-node, Yarn은 require의 동작을 바꾼다는 공통점이 있습니다. Jest의 경우 Jest.mock()이라는 함수로 Mocking을 수행하는데요, Mocking 방법은 아래 코드와 유사하게 제스트에 require를 정의하고, Jest require를 Monkey Patching 하는 방식으로 수행합니다. 이는 require 함수의 동작을 바꾸는 것이기 때문에 ESM에서는 동작하지 않습니다.
// jest.mock() 이 동작하는 방법
const defaultRequire = global.require;
const jestRequire = (request: string) => {
if (isMocked(request)) {
return mockedModule(request);
}
return defaultRequire(request);
}
global.require = defalutRequire;
비슷하게 ts-node도 require의 동작을 바꾸는데요, 타입스크립트 파일에 require를 하면 원래는 동작하지 않아야 하지만 중간에 ts-node가 자바스크립트로 코드를 변환하기 때문에 잘 작동하게 됩니다. 이런 경우도 ESM에서는 잘 동작하지 않습니다.
Yarn의 Plug'n'Play를 쓰는 경우에는 require를 실행했을 때 node_modules를 참조하는 것이 아니라 Yarn이 스스로 정의한 곳을 참조합니다. 따라서 이 경우도 require의 동작을 바꾼다고 할 수 있겠습니다.
이런 종류의 라이브러리들은 require 대신 ESM에 맞는 API를 기준으로 다시 작성해야 합니다. 그 과정에서 Loaders라고 하는 API를 사용하게 되는데, 이 API는 2022년 10월 기준 충분하게 Stable하지 않습니다. 지금 사용하다보면 다양한 이슈가 발생할 수 있죠.
지금까지 ESM으로 마이그레이션하기 어려운 이유들에 대해 얘기해 보았습니다. 그렇다면 지금은 어떤 서비스를 옮길 수 있을까요?
- 타입스크립트를 사용하고 있지 않을 때 (또는 .js 확장자를 쓰는 것도 괜찮을 때)
- 사용하는 라이브러리가 ESM 환경을 지원할 때 (react, emotion 등 라이브러리에서 지원)
- Jest, Yarn PnP, ts-node와 같이 CommonJs에 깊이 의존하는 라이브러리를 사용하지 않을 때
현재로써는 위 조건들을 만족하는 서비스가 많지는 않을 거라 생각합니다. 하지만 지금은 ESM의 혼란이 해소되어가는 시기라고 보고 있고, 내년이면 상황이 많이 달라져있지 않을까 전망합니다.
// package.json에 type: module 추가
{
"name": "my-service",
"type": "module",
...
}
import { add } from './add.js';
import { add } from './add/index.js';
const path = require('path');
const url = require('url');
module.exports = { ... };
⬇️
import path from 'path';
import url from 'url';
export default { ... };
ESM에서 삭제된 Node.js의 API들이 몇 가지 있는데요. 대표적으로 __dirname같은 전역변수가 있습니다. 아래처럼 새롭게 정의해서 사용하는 방법이 있는데, 다른 변수들도 경우 구글에 검색하시면 대체 API를 쉽게 찾을 수 있습니다.
import { dirname } from 'path';
import { fileUrlToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
require를 남길 수밖에 없는 환경이라면 최후의 수단으로 사용할 수 있는 방법이 있습니다.
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
저는 사용하던 어플이나 OS의 새로운 버전이 출시되면 안정화 되기까지 기다리지 않고 바로 업데이트를 하는 편입니다. 개발을 할 때에도 그 성향이 묻어나는 것인지, 오래된 것 보다는 새로 나온 문법이나 라이브러리를 선호합니다. 하지만 글을 작성하면서 ESM 도입의 고충을 정리해보니 이 분야에서는 신기술이 아무리 뛰어날지라도 안정성이 보장되어야 도입을 고려해볼 수 있겠다는 생각이 들었습니다.
그럼에도 불구하고 수많은 오류를 해결하며 신기술을 도입하는 개발자들 덕분에 생태계가 더욱 성숙해지는 것 같습니다. CommonJs의 단점들을 개선하기 위해 등장한 ESM인 만큼, 지금의 혼란이 빨리 해결되어 모듈 시스템의 진정한 표준으로 거듭나길 기대해 봅니다.
너무 유익한 정보 감사합니다
이런 좋은글을 이제야 알게되네요 잘 보고갑니다 ~