본문 바로가기
이론/Frontend

JavaScript의 모듈 시스템에 대하여

by 유세지 2023. 3. 4.

 

 

오늘은 자바스크립트의 모듈 시스템에 대해 알아보겠습니다. 자바스크립트에서 어떻게 모듈 시스템이 생기고, 지금의 모습으로 발전해왔는지에 대한 내용을 배경 설명과 함께 작성해보도록 하겠습니다.

 

최초의 자바스크립트

최초의 자바스크립트에는 모듈 시스템이 없었습니다. 지난 글이었던 웹 표준에 대한 고찰에서 이야기 했던 내용처럼, 원래 HTML은 단순히 HyperText라고 부르는 정보를 전달하기 위한 문서였습니다. 자바스크립트는 이 HTML에 붙어 약간의 상호작용을 더해주는 한정된 역할만을 갖고 있었습니다. 다시 말하면, 그 당시의 자바스크립트 코드는 모듈 시스템을 구성할 필요가 없는 규모의 프로젝트였다는 의미입니다.

 

하지만 시간이 흘러 자바스크립트는 점점 더 많은 일을 할 수 있게 되었습니다. 단순한 정보 전달 문서에서 하나의 완전한 애플리케이션으로 변화하며 이전보다 코드는 더 많아지고, 비대해져가는 프로젝트를 본 개발자들은 복잡성을 조절할 대안으로 모듈화의 필요성을 느끼게 되었습니다.

 

자바스크립트 프로젝트는 전보다 훨씬 복잡해졌습니다

 

CommonJS

이전의 자바스크립트가 브라우저에 종속적인 스크립트 언어였다면, Node.js는 브라우저 밖에서 자바스크립트를 사용하려는 많은 시도 중 가장 먼저 생태계를 성공적으로 안착시킨 프로젝트입니다. 이전에도 많은 시도가 있었고, 특히나 Helma나 Rhino 처럼 서버사이드 프로젝트에서 사용하려한 경우가 많았습니다.

 

한 언어를 서버사이드 언어로 사용하기 위해서는 훌륭한 기술도 중요하지만, 체계적인 표준을 지정하여 생태계를 키워나가는 활동이 필요하다고 생각한 Kevin Dangoor는 뜻이 맞는 사람들과 함께 그룹을 만들어 CommonJS API를 발표 하였습니다.

 

CommonJS의 창시자 Kevin Dangoor의 블로그 글 중

 

What I’m describing here is not a technical problem. It’s a matter of people getting together and making a decision to step forward and start building up something bigger and cooler together.
《What Server Side JavaScript needs》, Kevin Dangoor

 

이 그룹에서 발표한 문법이 바로 Node.js에서 처음 지원했고, 지금까지도 기본값으로 사용되고 있는 CommonJS 모듈 방식입니다.

 

// A.js
exports.log = () => console.log('hello, world!');

// index.js
const A = require('./A.js');
A.log(); // "hellow, world!"

 

지금까지도 많은 자바스크립트 라이브러리에서 널리 사용되고 있는 방식입니다. 하지만 최근에 자바스크립트를 시작하신 분이라면 낯설게 느껴질 수 있는 문법입니다. (제가 그랬습니다.)

 

그 이유는 직접적으로 CommonJS 문법을 사용해 본 적이 없기 때문인데, 보통 라이브러리나 파일을 불러올때는 require이 아니라 import를 많이 사용했기 때문입니다. 분명 Node.js는 CommonJS 문법을 지원한다고 했는데, 어떻게 우리는 require을 사용하지 않고도 모듈들을 불러와 사용할 수 있었을까요?

 

 

ECMAScript Modules

그 이유에 대해 알아보기 전, 우리가 사용했던 모듈 방식에 대해 먼저 알아 볼 필요가 있습니다. 우리는 흔히 아래처럼 모듈을 불러와서 사용해왔습니다.

 

import React from 'react';

 

위 코드는 우리가 흔히 React를 사용하기 위해 사용하는, React 라이브러리를 가져오는 모습입니다. 이렇게 Import를 통해 모듈을 불러오는 것을 ECMAScript Modules, 줄여서 ESM이라고 합니다. 사실 위 예시는 정확한 ESM 코드는 아닌데, 그 이유는 좀 더 뒤에서 다시 설명하겠습니다.

 

이러한 ESM 방식은 CJS(CommonJS) 방식과 비교해서 몇 가지 특징이 있습니다. 가장 큰 차이점을 꼽아보자면,

 

  • CJS는 동기적으로 동작하지만, ESM은 비동기적으로 동작한다.
  • ESM에서는 CJS를 import 할 수 있지만, CJS에서 ESM을 require 하는 것은 불가능하다.
  • ESM은 표준이고, CJS는 비표준이다.

정도가 되겠습니다. 하나씩 살펴보겠습니다.

 

 

CJS는 동기적으로 동작하지만, ESM은 비동기적으로 동작한다.

두 모듈 시스템은 단순히 인터페이스 상으로는 비슷해보이지만, 그 동작 방식은 전혀 다릅니다.

 

앞서 설명했듯, CJS는 자바스크립트를 서버 사이드에서 이용하기 위해 고안된 방식입니다. 보통 서버에서는 내부에 모듈들을 직접 가지고 있고, File I/O 레벨로 접근해서 사용하기만 하면 되는 구조로 이루어져 있습니다. 대부분의 경우, 네트워크 통신을 통해 모듈을 가져와서 사용할 필요가 없으니 이러한 방식은 매우 효율적입니다.

 

이러한 특성에 기반하여 CJS는 런타임에 불러온 모듈들을 평가합니다. 일단 프로그램을 실행하고, require()이 되는 시점에 모듈을 동적으로 가져와서 사용하도록 구성되어 있습니다.

 

그러나 ESM은 CJS와 정반대입니다. 필요한 모듈이 있다면 네트워크를 통해 받아와야 하는, 브라우저 환경을 위해 고안된 방식이기 때문에 비동기적으로 동작하는 것을 기본으로 합니다. 이렇게 받아 온 모듈을 바로 실행하지 않고, 파싱 작업을 통해 모듈 내부의 모든 import를 찾아 의존성 그래프를 만들어냅니다. 모든 그래프가 그려지고 실행할 준비가 끝나면 비로소 모듈이 실행됩니다.

 

CJS의 런타임과 ESM의 파싱(컴파일 타임), 두 모듈 평가 시점의 차이는 CJS 모듈과 ESM 모듈을 함께 사용하기 어렵게 만들었습니다. 일례로, CJS에서 지원하는 named exports를 ESM에서는 사용할 수 없습니다.

 

/* 위의 A.js를 CJS 방식의 모듈이라고 가정하고, 이어서 사용하겠습니다. */

// 일반적인 import는 가능합니다.
import a from 'A';

// named exports는 import 할 수 없습니다.
import { log } from 'A';

 

물론 a를 먼저 불러오고 log를 구조분해 할당을 통해 선언해 줄 수는 있지만, 여전히 컴파일 타임에 평가되고 있지 않기 때문에 tree shaking이 되지 않아 번들 사이즈가 커질 우려가 있습니다.

 

 

ESM에서는 CJS를 import 할 수 있지만, CJS에서 ESM을 require 하는 것은 불가능하다.

말 그대로 CJS에서는 ESM 모듈을 require해서 사용할 수 없습니다. 태생적으로 모듈을 동기적으로 로딩해야 하는 CJS는 비동기적으로 로딩하는 ESM의 방식과 맞지 않습니다. 이 문제를 가장 간단하게 설명하는 예시로는 top-level await가 있습니다. CJS에서는 ESM의 top-level await를 트랜스파일링 할 방법이 없습니다.

 

내부의 코드가 모두 실행되어야 반환 값을 얻을 수 있는 CJS 방식에서는 top-level await을 사용하는 ESM 모듈을 로드하는건 사실상 불가능합니다.

 

반대로, ESM에서는 CJS 모듈을 import 할 수 있습니다. 다만 두 방식을 혼용하는 것은 그 자체로 권장되지 않는 방법입니다. 따라서 직접적으로 import를 시도하기보단, babel과 같은 트랜스파일러를 사용하는 것이 일반적입니다.

 

우리가 글의 처음에서 들었던 의문에 대한 답이 바로 이 부분입니다.

"어떻게 우리는 require을 사용하지 않고도 모듈들을 불러와 사용할 수 있었을까요?"

 

여러분께서 create-react-app 과 같은 경로로 리액트를 사용하셨다면, 또는 깃허브에 많이 보이는 보일러플레이트를 사용하셨다면 100중 99는 babel을 통한 트랜스파일링이 미리 세팅되어 있을 확률이 높습니다. 이러한 세팅 덕분에 우리는 모듈에 대한 고민없이, 쉽게 ESM에서 사용하는 import를 사용할 수 있게 된 것입니다.

 

babel은 또한 이 의문도 해결해줍니다.

"사실 위 예시는 정확한 ESM 코드는 아닌데, 그 이유는 좀 더 뒤에서 다시 설명하겠습니다."

 

import React from 'react';

 

ESM의 import에서는, 모듈을 불러올때 확장자를 반드시 명시해주어야 합니다. 모던 브라우저나 Node.js 처럼 일부 환경에서는 자동으로 확장자를 붙여서 가져오지만, 이는 표준에 명시된 동작이 아니기 때문에 환경에 따라 모듈을 정상적으로 불러오지 못할 수 있습니다.

 

이런 현상 또한 각 모듈 시스템이 동작하는 환경을 염두하여 정해졌다고 보는 견해도 있는데, 모든 파일이 내부에 있는 서버 사이드 자바스크립트의 CJS와 달리 ESM은 네트워크를 통해 자원에 접근하기 때문에 정확한 URL을 통해 모호성을 배제하고 자원에 접근하는 것을 원칙으로 하기 때문입니다.

 

 

ESM은 표준이고, CJS는 비표준이다.

CJS는 Node.js에서 기본값으로 지정되고, ESM보다 먼저 등장하여 오랜 기간 사용된 사실상의 표준이었지만 공식적인 표준인 ESM이 등장하면서 확실한 비표준이 되었습니다. 많은 분들이 CJS를 사용한다고 생각하는 Node.js에서도 약간의 설정만 변경하면 ESM을 사용할 수 있습니다. CJS나 ESM은 하나의 모듈 시스템일 뿐이고, 결국 Node.js는 어느 쪽에도 의존적이지 않은 자바스크립트 런타임 환경이니까요. Node.js가 CJS를 따른다는건 ESM의 등장 이후부터는 잘못된 설명입니다.

 

일례로, Node.js의 창시자였던 Ryan Dahl은 Node.js를 떠나 Deno를 개발하면서, 새롭게 표준이 된 ESM 방식만을 사용하도록 하였습니다. 물론 기존 Node.js와 CJS 기반의 생태계를 아주 버릴수는 없었기 때문에 양립가능한 모드를 따로 만들었지만요. 재미있지 않나요?

 

다시 돌아와서, 표준이 갖는 메리트는 결코 무시할 수 없습니다. 지금까지 언어와 생태계는 확실한 표준에 기반을 두고 발전해 나갔습니다. 자바스크립트 진영이 거쳐 온 과정을 보았을때, 최소한 서버 개발이 아닌 분야라면 CJS는 서서히 사용 빈도가 낮아질 것으로 보입니다. 물론 지원이 중단되거나 과거와 달리 사용할 이유가 흐려진 라이브러리들조차 아직까지도 많은 곳에서 사용되고 있으니 CJS의 사장은 당장 눈 앞에 닥칠 미래는 아닐 것 입니다. 다만 앞으로의 추세가 그러한 방향으로 흐를 것이라는 이야기이고, 이 부분은 어디까지나 개인적인 생각이니 다른 의견을 가지신 분이라면 댓글로 말씀해주시면 감사하겠습니다.

 

 

 

현재의 개발 환경

앞서 언급했던 Deno는 Node.js를 따라가기엔 아직 갈 길이 멀어 보이는 것이 현실입니다. 엄청난 빌드 속도를 자랑하며 새롭게 떠오르는 Bun 또한 마찬가지입니다. 사실상 아직까지는 Node.js의 독점이라고 보는 것이 타당한 상황이지요.

 

보통의 경우, ESM과 CJS를 섞어서 사용하기는 어렵습니다. 애써 두 모듈 시스템을 모두 사용하면서 시스템을 불안정하게 만들 필요는 없으니까요. 그러나 저는 이러한 모듈 시스템 문제에 대해 딱히 생각해 본 적이 없었습니다. 굳이 모르더라도 원하는 기능들을 개발할 수는 있었으니까요.

 

사실 이렇게 할 수 있게 된 원인은 바로 트랜스파일러 덕분입니다. 위에서 잠깐 나왔던 트랜스파일러인 babel이 코드를 어떻게 변환하는지 한 번 보겠습니다.

 

 

Babel이 트랜스파일링을 마친 모습

 

마치 ESM을 쓰는것처럼 import를 통해 모듈을 불러왔지만, Babel의 트랜스파일링을 거치고 나니 CJS의 키워드인 require이 적용되어 있는 것을 확인할 수 있습니다. import를 사용하고 있었으나 사실 import의 탈을 쓴 require이었던 것입니다. 이러한 트랜스파일러 덕분에 우리는 모듈 시스템을 자세히 이해하고 있지 않더라도 큰 문제 없이 개발에 집중할 수 있었던 것입니다.

 

 

 

 

제가 이 글을 쓰게 된 계기이기도 한데, 얼마 전 ESM과 CJS 방식이 섞여있기 때문에 발생하는 문제를 겪었습니다. Nest.js를 통해 백엔드 서버를 구축하던 중 외부와의 통신 후 값을 반환해줘야 할 기능이 필요하여 node-fetch를 사용해서 구현했는데, 모듈 시스템의 충돌 때문에 린트가 말썽을 부렸습니다. (아마도... 린트는 잘못이 없을겁니다.)

 

어렴풋이 이 문제의 원인이 모듈 시스템이 달라서 생긴 문제라는 것을 추측하고 큰 고민없이 두 모듈 시스템을 모두 지원하는 axios를 설치하여 사용하는 것으로 문제를 해결했었는데, 이렇게 보니 설정을 잘 바꿔주면 더 좋은 방법으로 고칠 수 있었을거라는 후회도 들면서 새삼스럽게 모듈 시스템에 대한 지원을 잘 해주는 것이 라이브러리의 범용성에 얼마나 큰 영향을 줄 수 있는지 깨닫는 시간이 되었습니다.

 

 

우주 어디에서도 사용할 수 있는 라이브러리라면 얼마나 멋질까요

 

 

 

참고 문서

ECMAScript® 2023 Language Specification

Node.js documentation - Modules: ECMAScript modules

FECONF 2022 - 내 import 문이 그렇게 이상했나요? by 박서진 님

NAVER D2 - JavaScript 표준을 위한 움직임: CommonJS와 AMD by 손병대 님

Using ECMAScript modules (ESM) with Node.js by Diogo Souza 님

Node Modules at War: Why CommonJS and ES Modules Can’t Get Along by Dan Fabulich 님

 

 

반응형

댓글