tech.junha.kr

모듈 시스템 정리: Require와 Import, 모듈은 동기적으로 불러올까

모듈?

관련된 코드들을 하나의 코드 단위로 캡슐화한 하나의 파일을 말한다.

CommonJS (CJS)

Require 키워드를 사용한 모듈 시스템을 말한다.

Node.js와 구버전의 브라우저에서 주로 사용한다.

module.exports와 exports 두 가지 방법으로 모듈을 내보낼 수 있다

예시1. module.exports

// arithmetic.js

class Arithmetic {
    constructor(a, b) {
        this.a = a;
        this.b = b;
    }
    add() {return this.a + this.b;}
    subtract() {return this.a - this.b;}
};

module.exports = Arithmetic;
// main.js

const Arithmetic = require('./calculator.js');

const op = new Arithmetic(100,40)
op.add();
  • module.exports에 값을 담아서 내보내는 방식
  • 값은 클래스, 변수, 함수 등 하나의 레퍼런스(메모리 주소)등이 들어갈 수 있다.
  • require 키워드는 module.exports에 할당한 값을 반환한다.

예시2. module.exports

// module.js

module.exports = {
	name: 'junha',
	age: 27
}
// main.js

const obj = require('./module.js')

obj.name // 'junha'
obj.age // 27

예시3. exports

// arithmetic.js

exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;
// main.js

const Arithmetic = require('./calculator.js');

Arithmetic.add(100,40)
Arithmetic.subtract(100,40)
  • exports 객체는 module.exports 객체를 참조하는 변수다.
  • exports 객체에 할당된 프로퍼티들은 모두 module.exports 객체에서 접근이 가능하다.

ECMAScript Modules (ESM)

es6에서 공식적으로 채택된 모듈 시스템이다.

Default exports, Named exports 두 가지 방법으로 모듈을 내보낼 수 있다.

예시1. Default exports

// arithmetic.js

class Arithmetic {
    constructor(a, b) {
        this.a = a;
        this.b = b;
    }
    add() {return this.a + this.b;}
    subtract() {return this.a - this.b;}
};

export default Arithmetic;
// main.js

import Arithmetic from './arithmetic.js'

const op = new Arithmetic(100,40)
op.add()
op.subtract()
  • 한 파일에서 한 번만 사용할 수 있고, 대표로 값을 내보낼 때 사용한다.
// utils.js

const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const array = [1, 2, 3];
const object = { a: 1, b: 2, c: 3 };

const obj = {
	add,
    subtract,
    array,
    object
};

export default obj;
import { add, subtract, array, object }  from "./utils.js";
  • import시 아무 이름이나 사용 할 수 있으며, destructring도 가능하다.

예시2. Named exports

2개 이상 변수들을 객체에 담아 export할 때 사용한다.

export const getUpload = (req, res) => res.render("upload", obj);
export const postUpload = async (req, res) => {};
import { getUpload, postUpload } from "../controllers/videoController.js";

❌ import upload from "../controllers/videoController.js";
// SyntaxError: The requested module does not provide an export named 'default'

✅ import * as upload from "../controllers/videoController.js";
// as를 통해 객체로 가져올 수 있다

default exports 방식과 named exports 방식을 함께 사용할 수 있다.

// utils.js

const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const array = [1, 2, 3];

export { subtract, array };
export default add;
// app.js 파일

import add, { subtract, array } from './utils.js';

변수로 바인딩 없이, 단순히 모듈만 실행할 목적이면 import만 사용하는 것이 좋다.

import "my-module.js";

주의

❌ export default const a = 1;
  • var, const, let 할당하면서 사용할 수 없다.
  • 블록 {…} 안에선 동작하지 않는다.
  • import, export 는 파일내 최상위에 배치해야한다.
  • file 프로토콜로 열람할 경우
    • 모듈은 로컬 파일에서 동작하지 않고, HTTP 또는 HTTPS 프로토콜을 통해서만 동작한다.
    • 로컬에서 file:// 프로토콜을 사용해 웹페이지를 열면 import, export 지시자가 동작하지 않는다.
    • 브라우저에서 origin이 존재하지 않는 것으로 취급하기 때문이다.
    • **로컬 웹 서버인 ‘라이브 서버’**를 사용할 것!!

ESM 사용하기

브라우저에서 사용하려면, 모듈에 해당되는 **script 태그에 type="module"**을 추가한다.

Node.js에서 사용하려면, package.json에 **type="module"**을 추가한다.

Uncaught SyntaxError: Cannot use import statement outside a module”
  • commonjs 방식에서 import를 사용하려 했기 때문에 발생한 오류다.
  • package.json의 “type” 필드 값을 “module”로 설정해줘야함.
  • 단, 이전에 requre로 불러오던 모듈이 있었다면 import로 불러오도록 다시 선언해 줘야한다.

브라우저에서 ESM 특징

  1. 지연(defer) 실행: 다운로드와 파싱은 하지만, 실행은 페이지의 파싱이 완료된 후에 수행한다.
  2. 외부 오리진(도메인이나 프로토콜, 포트가 다른 오리진)에서 스크립트를 불러오려면 CORS 헤더가 있어야 한다.
  3. 중복된 외부 스크립트는 무시된다.
  4. 독립적인 스코프를 갖는다.
  5. 항상 엄격 모드로 실행(use strict)된다.
  6. 모듈 내 코드는 단 한 번만 실행된다. 모듈을 내보내면 이 모듈을 가져오기 하는 모듈 모두가 내보내진 모듈을 공유한다.
  7. 모듈 내에서 정의된 변수, 함수, 클래스 등은 모듈 외부에서 직접 접근할 수 없으며, 각각의 모듈에서 해당 모듈을 import 하여 가져와야 합니다. 모듈은 한 번만 실행되며, 이후에 다른 모듈에서 해당 모듈을 가져올 때는 다시 다운로드하지 않는다.
  8. 인라인 모듈 스크립트도 비동기 처리 할 수 있다.
(async () => { const { someFunction } = await import('./someModule.js');})();

잠깐, Import는 동기적 호출일까?

JavaScript 엔진은 구문의 형태를 보고 정적/동적 import를 자동으로 구분하여 처리한다.

1. 처리 순서

  1. 구문 분석: 모든 import/export 찾기
  2. 모듈 로딩: import된 모듈 불러오기
  3. 모듈 평가: 실제 코드 실행

2. 정적 Import (Static Import)

  • 이때 Import는 모듈 초기화 단계에서 처리된다.
    • 정적(Static) 구문
      1. 코드가 실행되기 전에 처리되는 문법 구조
      2. 컴파일/파싱 단계에서 처리됨
      3. 위치나 형태가 고정되어야 함
  • 코드가 실행되기 전에 평가 및 로드되기 때문에 동기적 호출로 생각할 수 있다.
  • 반드시 최상위 레벨에서 사용해야 함
import module from './module'
import { something } from './module'
import * as all from './module'

3. 동적 Import (Dynamic Import)

  • 이때 Import는 Promise로 감싸진 해당 모듈을 반환하는 비동기 런타임 함수다.
  • 코드 어디서든 사용 가능
async function someFunction() {
    const module = await import('./module');
}
import('./db.js')
  .then((module) => {
    const db = module.default;
    // 모듈 내 default export로 정의된 컴포넌트를 사용할 수 있음
    // module.default는 db.js 파일에서 export default로 내보낸 객체를 가리킴
  })
  .catch((error) => {
    console.error('Error loading the module:', error);
  });

4. CommonJS

  • require() 함수는 모듈을 동기적으로 불러오며, 처음 실행된 후 module.exports를 반환. 이후 동일한 모듈에 대한 호출은 캐시된 값을 반환한다.

CJS vs ESM

1. CJS는 기본 값이다. 왜냐면 ESM은 바뀔게 넘 많아서

기본 값으로 use strict가 설정되어 있어야하고, this는 global object를 참조하지 않고, 스코프는 다르게 작동 되는 등등 변화가 많다. 브라우저에서 조차

2. ESM과 CJS는 완전히 다르다.

CommonJS에서는 require()는 동기로 이루어진다. 따라서 promise나 콜백 호출을 리턴하지 않는다. require()는 디스크로 부터 읽어서 (네트워크 일수도 있다) 그 즉시 스크립트를 실행한다. 따라서 스스로 I/O나 부수효과 (side effect)를 실행하고 module.exports에 설정되어 있는 값을 리턴한다.

반면에 ESM은 모듈 로더를 비동기 환경에서 실행한다. 먼저 가져온 스크립트를 바로 실행하지 않고, import와 export구문을 찾아서 스크립트를 파싱한다. 파싱 단계에서, 실제로 ESM 로더는 종속성이 있는 코드를 실행하지 않고도도, named imports에 있는 오타를 감지하여 에러를 발생시킬 수 있다.

그 다음 ESM 모듈 로더는 가져온 스크립트를 비동기로 다운로드 하여 파싱한다음, import된 스크립트를 가져오고, 더 이상 import 할 것이 없어질 때까지 import를 찾은 다음 dependencies의 모듈 그래프를 만들어 낸다. 그리고, 스크립트는 실행될 준비를 마치게 되며, 그 스크립트에 의존하고 있는 스크립트들도 실행할 준비를 마치게 되고, 마침내 실행된다. ESM 모듈 내의 모든 자식 스크립트들은 병렬로 다운로드 되지만, 실행은 순차적으로 진행된다.

3. export default를 피해야 하는 두 가지 이유

export default 문법은 원래 기존의 CommonJS 모듈 시스템과 호환성을 유지하기 위해 만들어졌다. 그러나 named exports와는 다르게, import하는 곳에서 이름을 자유자재로 정할 수 있다. 때문에 1. 같은 모듈이더라도 사용처에 따라 이름이 달라져, 1. 큰 규모의 리팩토링을 어렵게 만든다. 반면 named exports는 export 되는 값들에 이름을 부여하기 때문에 모든 사용처에서 동일한 이름을 사용해 IDE가 이를 찾아서 자동으로 import를 도와줄 수 있어 이러한 문제가 발생하지 않는다.

2. tree-shaking을 통해 번들 사이즈를 줄일 수 없다. 예를 들어, 하나의 거대한 객체를 default로 한번에 export하는 경우에는 tree-shaking을 할 수 없다. 하지만 named exports로 값을 하나씩 export 해주면 사용하지 않는 값들은 tree-shaking을 통해 번들 사이즈를 줄일 수 있다. ES Modules로 작성된 모듈의 경우에는 tree-shaking을 할 수 있지만, CommonJS 모듈로 작성된 모듈의 경우에는 tree-shaking을 할 수 없다.

// 1. Tree-shaking 잘 되는 경우들
// math.js
export default function add(a, b) { return a + b }  // default export도 OK
export const subtract = (a, b) => a - b            // named export도 OK

// 2. Tree-shaking 안 되는 경우
// utils.js
export default {
 add: (a, b) => a + b,
 subtract: (a, b) => a - b,
 multiply: (a, b) => a * b,
 // 하나만 써도 모든 함수가 번들에 포함됨!
}

// main.js
import { add } from './utils'  // multiply, subtract도 함께 번들링됨
// 그저 destructring 한 것일 뿐이다

default export 자체가 문제라기 보다는, 객체로 묶어서 export하면 tree-shaking이 안된다는 점을 명심하자.