Doeun Kim

Frontend Developer

밑바닥부터 모듈 따라가보기

최근에 모듈 시스템에 대해 공부하게 되었는데요. 공부하다보니 내가 작성하는 이 모듈이 브라우저에서 로드되는 것을 당연하게 생각하고 마음껏 개발만 하고 있었던 것 같았어요.

이번 글에서는 모듈을 로드하고 트랜스파일하고 빌딩하고 브라우저에서 로드하는 과정 하나하나에 집중해 보려고 합니다.

구성하려는 환경

  • tsx 파일을 브라우저에서 실행

런타임 환경

프로그램을 실행할 수 있는 필수적인 환경을 제공 ex. Node.js, Deno, Bun

우선 런타임 환경이 필요합니다. 우리가 소스 코드를 작성하게 되면, 어떤어떤..〰️ 과정을 통해 브라우저에서 실행될텐데, 이 작업들이 런타임 환경에서 돌아가게 됩니다.

우리가 프로젝트에서 모듈을 활용했다면 경로도 처리해야할테고, tsx로 작성했다면 브라우저가 이해할 수 있는 js로 변환해야 하는 이런 처리들이 런타임 환경에서 이루어져야 합니다. 최종적으로 브라우저가 실행할 수 있는 파일로 만들어주는 것이라고 할 수 있겠습니다.

🤔

런타임 환경은 왜 특정 언어만을 지원하는걸까?

  • 각 언어는 문법, 메모리 관리 방식 등 세부적인 동작 원리가 다름
  • 그래서 최적화하는 방식도 다름
  • 그래서 특정 언어에 필요한 자원 관리 방식에 맞게 설계
🤔

나는 왜 당연하게 Node.js를 설치했던 것인가?

  • 일단 Node.js는 JavaScript 런타임 환경
  • JavaScript는 웹 브라우저 내에서 바로 실행될 수 있어서, 웹 애플리케이션을 만드는 데에 효율적
  • 브라우저에서 실행되는 JavaScript를, 브라우저에 닿기 전에도 처리할 수 있게 하여 여러 작업을 가능하게 해주는 것이 런타임 환경
  • Deno, Bun과 같은 다른 런타임 환경도 있지만, 아직은 성숙도와 생태계로 인해 많은 프로젝트에서 Node.js를 채택 중
🥟

Bun! 좋다

  • 개인적으로 Bun을 좋아한다.
  • 자세한 구성에 대해서는 아직 못 알아봤지만, 단순 사용해봤을 때 경험이 무척 좋았다. 정말 빨랐다.
  • 시간이 된다면 Bun에 대해 다루는 포스팅을 남겨볼까 한다.
  • 다들 써보시라!

패키지 매니저

  • 패키지: 파일이나 코드의 집합
  • 패키지 매니저: 프로젝트에 필요한 패키지를 설치, 관리, 업데이트, 제거하는 도구

우리는 이미 작성되어 있는 코드를 활용해서 프로젝트 개발을 빠르게 하곤 하는데요, React 역시 외부 라이브러리로, 패키지 매니저를 통해 설치하고 관리해야 합니다.

패키지 매니저가 무슨 일을 하는지는 토스 기술 블로그에 잘 설명되어 있어 생략합니다!

package.json

프로젝트에 대한 정보를 관리하는 파이롤, 패키지 매니저가 이 파일을 참조하여 모듈을 설치하고 관리

런타임 환경에서 사용할 변환 도구 같은 것들을 패키지 매니저를 통해 설치해서 사용할 겁니다. 이때 패키지 매니저는 package.json을 통해 이 의존성을 버전과 함께 관리하게 됩니다. 물론 node_moduleslock파일이 필요하지만 의존성 이야기니까 조금 생략해볼게요.

프로젝트에 package.json이 없어도 패키지 매니저는 알아서 package.json을 생성해서 관리하는 것을 확인할 수 있었어요.

🤔

패키지 매니저가 달라도 항상 package.json을 참조하던데, 누가 정한 표준 같은건가?

  • npm(Node Pacakge Manager)에서 표준화한 파일
  • npm이 처음 package.json 파일 형식을 정의했고, 이후 다른 패키지 매니저들도 이 표준을 따르며 사용 중
  • 덕분에 라이브러리에서 사용 중인 패키지 매니저와 내 환경에서의 패키지 매니저가 달라도 문제 없이 의존성을 설치하고 관리 가능

React 설치 & 구성

  • https://react.dev/learn/add-react-to-an-existing-project
  • https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts

index.html

브라우저가 처음 로딩할 실제 HTML 문서를 하나 추가합니다. React는 JavaScript를 실행해서 이 HTML 문서 안에서 컴포넌트를 렌더링 하게 됩니다.

id="root" 하위로 리액트 컴포넌트를 렌더링하기 위해 DOM을 하나 추가했습니다.

<!DOCTYPE html>
<html>
  <body>
    <div id="root"></div>
  </body>
</html>
🤔

그냥 body에 하면 안되나?

  • vite도 그렇고 body가 아니라 div#root에 리액트 컴포넌트를 렌더링 하는 편
  • body에는 새로운 portal이나 다른 전역 요소들이 들어갈 수 있음
  • 그 안에 React가 관리하는 영역은 따로 두는 것

main.tsx

index.html에서 생성해두었던 div#root에 리액트 컴포넌트 루트를 만들고 간단하게 하나 렌더할 수 있도록 했어요.

import React from 'react';
import { createRoot } from 'react-dom/client';
 
createRoot(document.getElementById('root')!).render(<h1>hello react</h1>);

우리는 이 코드가 실행된 결과를 브라우저에서 보기를 원합니다. 브라우저가 js를 실행하는 방법을 복기해봅시다.

  1. 브라우저는 HTML 파싱을 하기 시작합니다.
  2. 파싱 중 script 태그를 만나면 js를 로드하고 실행합니다.

즉, html 파일에서 우리가 방금 만든 main.tsx 파일을 만날 수 있게 해줘야 한다는 말인데요,

<!DOCTYPE html>
<html>
  <body>
    <div id="root"></div>
    <script src="/src/main.tsx"></scripts> <!-- 👈 추가 -->
  </body>
</html>

위 코드가 렌더링된다고 생각해보면 이런 흐름일 거 같아요.

index.html
   ↓
<script src="/src/main.tsx">
   ↓
JS 실행 (main.tsx → .render)
   ↓
<div id="root"> 안에 UI 렌더링

브라우저는 index.html을 읽을 수 있고, script를 실행할 수는 있지만, tsx 자체도 그렇고

import { createRoot } from 'react-dom/client';

이 코드가 main.tsx 안에 있다면 브라우저는 "음… react-dom이란 패키지가 필요하네? 그럼 node_modules/react-dom/client.js를 찾아야지!"를 하지 못합니다. 이건 바로 번들러가 하는 일로, 해당 파일을 찾고 필요한 소스코드를 번들링하며 이 번들 파일을 브라우저가 직접 실행 가능한 것이죠!

그럼 이제 우리는 트랜스파일링, 번들링을 해야겠네요.

트랜스파일러 & 번들러

https://github.com/s1owjke/js-bundler-benchmark

esbuild로 해볼게요. vite를 좀 따라해보고 싶어서 설정을 좀 해서 써보겠습니다. 사실 index.html에서 script의 src에는 번들링된 내용을 기준으로 경로를 찾을 수 있게 했어야 했는데요,

<script src="dist/main.js"></script>

vite에서는 src="src/main.tsx"처럼 개발 환경 기준에서 좀 더 직관적으로 쓰고 있더라구요. 저도 이 방법을 차용하고 조금 처리를 더해보겠습니다.

// scripts/build.ts
import { build } from 'esbuild';
import fs from 'fs/promises';
import path from 'path';
 
const htmlPlugin = () => ({
  name: 'html-plugin',
  setup(build) {
    build.onEnd(async () => {
      const templatePath = path.resolve('index.html');
      let html = await fs.readFile(templatePath, 'utf8');
      html = html.replace('src="src/main.tsx"', 'src="main.js"');
      await fs.writeFile(path.resolve('dist/index.html'), html);
    });
  },
});
 
build({
  entryPoints: ['src/main.tsx'],
  bundle: true, // 여러 파일을 하나로 묶기 <- import된 다른 파일들을 모두 묶음
  outfile: 'dist/main.js',
  plugins: [htmlPlugin()],
  loader: {
    '.tsx': 'tsx',
  },
});

esbuild는 기본적으로 TypeScript와 JSX를 자동으로 트랜스파일링한다고 합니다. 다만, 타입 검사는 하지 않기 때문에 실패 시 빌드를 깨트리려면 별도의 타입 검사 도구를 추가로 사용해야 합니다.

번들링에 대해서는 entryPoints로 지정한 파일을 시작으로, 그 파일에서 import된 파일들을 모두 묶어 최적화된 하나의 출력 파일을 생성합니다.

추가로 htmlPlugin을 넣었어요. index.html에서 개발 환경 시 좀 더 직관적인 확인이 가능한 경로인 src="src/main.tsx"를 유지하고 빌드 시점에 이걸 replace한 후에 빌드하도록 했어요.

package.json에는 아래와 같은 필드를 추가해서 빌드할 수 있었습니다. build.ts 파일을 ESM으로 작성했기 때문에 ESM 방식으로 해석할 수 있도록 type 필드를 추가했습니다. 또, TypeScript로 작성된 build.ts 파일을 실행하기 위해 tsx 패키지를 설치하고 이를 통해 실행했습니다.

node scripts/build.ts    # ❌ 기본 Node.js는 .ts 파일 실행 불가
tsx scripts/build.ts     # ✅ 바로 실행 가능
{
  "type": "module",
  "scripts": {
    "build": "tsx scripts/build.ts"
  },
  "devDependencies": {
    "tsx": "^4.19.4"
  }
}

브라우저

여기까지 해서 dist 파일이 만들어졌네요. 이제 이 파일이 어떻게 브라우저에서 로드되어 우리가 보고 있는건지 살펴보려고 합니다.

우리가 프로젝트를 배포한다고 하면, 빌드된 결과물인 dist 폴더가 실제로 서버에 올라가게 됩니다. 사용자가 웹사이트에 접속하면, 서버는 가장 먼저 dist/index.html 파일을 응답하게 되죠!

이 HTML 문서는 브라우저에 의해 파싱되면서, <script> 태그를 만나게 되면 해당 JavaScript 파일을 불러와 실행하게 됩니다. 즉, 브라우저는 index.html을 시작으로 우리가 번들링한 JavaScript를 실행해서 실제 화면을 구성하고, 필요한 기능들을 동작시킵니다.

모듈 시스템 좀 더 살펴보기

모듈 시스템은 플러그인 파일이나 잘게 쪼개져있는 코드 조각들을 재사용하기 위해서 각각의 파일을 등록하고, 등록된 파일을 불러와 사용할 수 있게 해주는 프로그램입니다.


브라우저에서는 여러 모듈을 어떤 방식으로 불러오고 실행할까요?

🤨

ESM이 나오기 전에는

과거에는 <script>로 많은 파일들을 그냥 다 불러왔다고 하는데요,

<script src="a.js"></script>
<script src="b.js"></script>

이렇게 되면 모든 파일이 window 변수에 등록이 되고, 순서가 잘못되면 런타임 에러가 발생할 수도 있었습니다. 이러한 문제를 해결하기 위해 런타임 환경에서는 AMD, RequireJS 같은 모듈 로더가 등장했다고 해요. 모듈 간의 의존성을 명시할 수 있도록 해서 순서를 관리할 수 있도록 하고 비동기로 로드할 수 있게도 했어요.

즉, 런타임 환경에서 비동기로 로드하기도 하고 모듈 로드 순서를 관리하는 방식을 활용합니다.

require(['foo', 'bar'], function (foo, bar) {
  foo();
  bar();
});

그런데 결국 위 방식도 개발자들이 모듈 의존성을 수동으로 관리해야했고, 불필요한 번들링이나 중복 로딩이 발생할 수 있었다고 해요.

✈️

ESM을 표준으로

ESM(ECMAScript Modules)는 ECMAScript 표준으로 JavaScript의 공식 모듈 시스템으로 설계되었어요. 비공식 모듈 시스템(ex. CJS, AMD 등) 방식들은 런타임 환경에서와 브라우저에서 모듈을 다르게 처리하는 문제가 있어 이를 동일한 방식으로 처리할 수 있는 표준화된 방법이 필요했다고 합니다.

ESM이 도입되면서 브라우저와 Node.js를 포함한 모든 자바스크립트 환경에서 사용할 수 있는 공식적인 방법이 되었답니다.

🤔

패키지에서 ESM으로 모듈을 정의했는데, 구 버전 브라우저라면?

배민 기술 블로그 - Vite로 구버전 브라우저 지원하기

구 버전 브라우저에서는 ESM을 지원하지 않을 수 있어요. ES6에서 처음 도입되어 ESM을 사용할 수 없는 경우가 생길 수 있는데요,

ESM 브라우저 지원 현황

트랜스파일러를 이용하여 팀에서 지원하고자 하는 범위에서 실행될 수 있도록 트랜스파일링하여 구형 브라우저에서도 사용할 수 있도록 해야 합니다.

<!-- ESM을 지원하는 최신 브라우저에서만 로드됨 -->
<script type="module" src="dist/main.esm.js"></script>
 
<!-- ESM을 지원하지 않는 구형 브라우저에서만 로드됨 -->
<script nomodule src="dist/main.es5.js"></script>
🪴

ESM은 기존에 어떤 문제를 해결했을까?

  1. 표준화된 모듈 시스템을 제공하여 다양한 시스템 간의 호환성 문제를 해결
  2. 비동기적이고 효율적인 모듈 로딩을 통해 초기 로딩 속도와 성능 최적화
  3. 정적 분석과 최적화가 가능하여, 더 작은 번들을 생성하고 불필요한 코드를 제거
  4. 모듈 의존성 관리의 명확성 제공으로 충돌이나 잘못된 순서 문제를 해결
  5. import, export 키워드로 개발자 경험 개선을 통해 모듈 시스템을 더 쉽게 이해하고 사용할 수 있게 함

그런데 이러한 이유로 CJS, ESM을 같이 지원하곤 하는데요, 점점 더 많은 라이브러들이 ESM 전용으로 전환을 고려하거나 이미 그렇게 하고 있다고 합니다.

CJS와 ESM은 근본적으로 다른 모듈 시스템으로, 상호 운용할 경우 까다로운 문제로 이어지는 경우가 여전히 많다고 하네요. 또, 라이브러리에서 두 포멧을 지원한다는 것을 패키지의 크기를 2배가 되는 것이죠.

🫢

Node.js에서 require로 esm 모듈을 로드할 수 있는 기능을 제공한다고 해요

https://socket.dev/blog/require-esm-backported-to-node-js-20

Node.js에서 require로 ESM을 로드할 수 있도록 지원한다는 것은 혼합 사용을 권장한다는 의미보다는 듀얼 포맷의 지원을 줄이고, ESM으로 통일해 나가려는 호환성 조치로 보여요.

장기적으로는 새로운 모듈은 ESM으로만 작성되고, 점차 CJS를 줄이려는 방향으로 보이기도 했습니다.

🧐

생태계의 움직임을 따라야 우리도 움직여야 이유

기술의 지속 가능성

생태계가 이동하는 방향은 기술적 한계를 극복다고 효율을 높이기 위한 집단의 선택인 것인데요, 예를 들어 ESM으로의 전환은 정적 분석 최적화 트리 쉐이킹 등의 필요에서 비롯된 것처럼요.

생태계에서 사용되지 않거나 구식이 되어버린 기술을 고수하면, 언젠가 지원이 끊기고, 보안 업데이트도 안 되며, 관련 자료도 점점 사라지게 됩니다.

반면 생태계가 움직이는 방향을 따르면 그에 맞춰 새로운 도구와 커뮤니티도 그 방향으로 발전된다는 말이며, 이와 방향이 같아야 그에 맞춰 시스템을 계속 지속할 가능성이 커진다는 것이겠죠?

😰

프로젝트에서 node 버전을 올리기 전에 고려해야하는 것?

프로젝트에서 사용 중인 패키지들이 새로운 node 버전과 호환되는지 확인해야 합니다. 저도 회사에서 레거시 프로젝트의 스토리북을 열어 봐야할 일이 있었는데, node v18로 전환해야만 스토리북이 실행되더라구요 🤔

이처럼 node 버전을 올렸을 때 사용 중인 라이브러리나 배포 환경에 문제가 없는지 테스트하고 문제가 생겼다면 의존하고 있는 것들의 버전을 함께 업데이트해야 하는 큰 작업이 될 수 있을 것 같네요.


너무 당연하게 런타임 환경, 패키지 매니저, 스캐폴딩을 사용하기만 했던 것 같았는데요, 이번 글을 통해 하나씩 필요한 걸 찾아가면서 그 도구들의 필요를 느끼고 당연하게 생각했던 것들에 대한 의문을 풀었던 것 같아요.

물론 빌드 설정은 당장 필요한 필드만 추가하여 더 정교한 최적화가 필요하다면 더 많은 설정들이 필요하겠지만, 모듈이 어떻게 인식될 것인가에 집중해보면 원하는 설정을 골라서 쓸 수 있을 것 같아요. 🏃

Reference