자바스크립트/모던 자바스크립트

모던 자바스크립트, 모듈 2 - 모듈의 동작 방식

Jake Seo 2023. 3. 26. 23:12

모듈 소개

  • 모듈이 있기 전까진 <script src="xxx.js"></script> 와 같은 태그를 통해 모든 스크립트를 불러왔다.
    • 이러한 방식은 이름 충돌, 복잡한 종속성, 파일 크기 문제 등의 이슈를 불러오게 되었다.
<head>
  <!-- 아래 스크립트들은 전역을 공유하게 된다. -->
  <script src="a.js"></script>
  <script src="b.js"></script>
  <script src="c.js"></script>
</head>

모듈이 있기 전의 해법

  • 모듈이 있기 전에도 글로벌 영역을 더럽히지 않기 위해 네임스페이스를 나누고자 하는 여러 노력이 있었다.

네임스페이스 객체 이용하기

  • 이런 객체를 선언하거나 이런 객체를 반환함으로써 스코프를 나눴다.
var MyNamespace = {
  someVariable: "value",
  someFunction: function () {
    // function code here
  },
};

IIFE 이용하기

  • 덩치가 큰 함수 하나를 즉시 실행시켜 이용하는 방식이다.
(function () {
  var privateVariable = "secret";

  function privateFunction() {
    // code goes here
  }

  privateFunction(privateVariable);
})();

모듈의 역사와 현재 JS 모듈 시스템 표준

  • 과거에 Common JS, AMD 와 같은 모듈 시스템이 존재했다.
  • 현재 표준이 된 것은 ES6 에 도입된 ES Modules 이다.

모듈 기본 개념

  • JS 모듈은 보통 1개의 .js 파일이 단위이다.
  • 자체 스코프가 있다.
  • 모듈을 가져오는 import 와 모듈을 내보내는 export 를 제공한다.

모듈 트리

  • 개발하다보면 각 모듈은 서로 의존하는 복잡한 관계를 갖는다.
  • 이 과정에서 '모듈 트리' 라고 하는 그래프가 형성된다.

영역과 모듈 캐싱

  • 자바스크립트 엔진은 영역 당 한 번만 모듈을 로드한다.
    • 영역이란 것은 브라우저 창(탭, 전체 창, iframe) 등을 말한다.
  • 한번 영역 에 모듈이 로드되면 캐싱되어 로드한 모듈을 계속 재사용한다.

모듈 지정자 (Module specifier)

import { example } from "./module.js";
  • 위 소스코드에서 "./module.js" 부분을 모듈 지정자라고 한다.
  • 모듈의 위치를 식별 (identifies the location) 하는 문자열을 모듈 지정자라고 한다.
  • 로컬의 모듈을 가져와 사용할 때는 반드시 확장자인 .js 를 붙여야 한다.
    • nodejs 환경 등에서 fs 같은 기본 모듈을 사용할 땐 필요 없다.
  • 모듈 시스템마다 사용하는 모듈 식별자의 형태가 약간씩 다를 수 있다.
  • 모듈 지정자는 리터럴 이어야 한다.
    • 변수같은 것이 오면 안된다.
    • 선언은 정적 양식이기 때문이다.
    • 최적화를 위해 JS 엔진은 코드를 실행하지 않고도 이를 해석할 수 있어야 한다.

브라우저에서 ESM 모듈을 사용하는 방법

  • 스크립트 태그에서 type="module" 을 속성으로 주면 된다.
  • 참고로 이 속성을 가지면 자동으로 defer 속성을 가진 태그와 동일하게 처리된다.
  • 만일 HTML 파싱과의 의존성이 없어 비동기적으로 불러오고 싶다면, async 속성을 추가해주면 된다.
<script src="main.js" type="module"></script>

Node.js 에서 ESM 모듈을 사용하는 방법

  • 기본 모듈 시스템이 Common JS 로 설정되어 있다.
  • package.json 파일의 내용을 약간 수정하면 된다.
  • 모듈을 import 할 때는 마찬가지로 기본 확장자가 없으므로 꼭 .js 혹은 .mjs 를 붙여주어야 한다.
    • 단, 내장 모듈이나 node_modules 에 있는 패키지를 임포트할 때는 필요 없다.
{
  "type": "module"
}
import { something } from "./module.js";
import fs from "fs"; // fs 는 내장 모듈

Common JS 를 동시에 사용하려면?

  • Common JS 형식의 모듈을 동시에 사용하는 것도 가능하다.
  • 다만, ESM 모듈처럼 사용하는 것은 불가능하고, 객체 네임스페이스 같은 형식으로 사용해야 한다.
    • CJS 모듈은 정적이 아니라 동적으로 작동하기 때문이다.
import mod from "./mod.cjs";
const { something } = mod;

모듈 레코드

  • 모듈 import, export 가 소스코드에 존재하면, 이 내용이 읽히고 JS 엔진 내부에 모듈 레코드라는 것이 만들어진다.

Import 모듈 레코드

  • Import 모듈 레코드에는 [[ModuleRequest]], [[ImportName]], [[LocalName]] 이 들어있다.
    • [[ModuleRequest]]: 모듈 지정자 (Module specifier) 문자열을 가진다. 어떤 모듈이 들어왔는지 식별한다.
    • [[ImportName]]: 이름처럼 import 되는 이름을 가진다.
    • [[LocalName]]: 로컬 식별자의 이름을 가진다. 별칭이 있다면 당연히 별칭을 갖는다.
  • 모듈 레코드는 모듈이 로드되기 위해 무엇이 필요한지를 JS 엔진에게 알려주는 것이다.

예제로 살펴보기

import v from "mod";
  • [[ModuleRequest]]: "mod"
  • [[ImportName]]: "default"
  • [[LocalName]]: "v"
import * as ns from "mod";
  • [[ModuleRequest]]: "mod"
  • [[ImportName]]: "*"
  • [[LocalName]]: "ns"
import { x as v } from "mod";
  • [[ModuleRequest]]: "mod"
  • [[ImportName]]: "x"
  • [[LocalName]]: "v"
import "mod";

사이드이펙트를 위한 import 에는 모듈 레코드가 생성되지 않는다.

Export 모듈 레코드

  • 이 모듈 레코드를 통해 모듈이 로드될 때 모듈에서 어떤 내용을 제공하는지를 알려준다.
  • Export 모듈 레코드에는 [[ExportName]], [[ModuleRequest]], [[ImportName]], [[LocalName]] 이 들어있다.
    • [[ExportName]]: export 이름이다.
    • [[ModuleRequest]]: export x from ... 을 이용해 바로 다시 내보내는 경우에만 생긴다.
    • [[ImportName]]: 마찬가지로 export x from ... 을 이용해 바로 다시 내보내는 경우에만 생긴다.
    • [[LocalName]]: export 하는 로컬 식별자의 이름이다. 단, 별칭을 쓰면 다를 수 있다.

예제로 살펴보기

export var v;
  • [[ExportName]]: "v"
  • [[ModuleRequest]]: null
  • [[ImportName]]: null
  • [[LocalName]]: "v"
export default function f() {}
  • [[ExportName]]: "default"
  • [[ModuleRequest]]: null
  • [[ImportName]]: null
  • [[LocalName]]: "f"
export default function () {}
  • [[ExportName]]: "default"
  • [[ModuleRequest]]: null
  • [[ImportName]]: null
  • [[LocalName]]: "default"
export { x as v } from "mod";
  • [[ExportName]]: "v"
  • [[ModuleRequest]]: "mod"
  • [[ImportName]]: x
  • [[LocalName]]: null

Import 는 간접 라이브 바인딩된다.

  • import 구문을 통해 가져온 변수는 사실 읽기전용으로 가져온 것이다.
    • 이를 간접 바인딩이라고 한다.
    • 값을 복사한 것이 아니다.
    • 해당 모듈 공간에서 읽기전용 참조만 가져오는 것이다.
  • 각 모듈은 모듈 환경 객체 라는 것을 가져 완벽히 분리된다.

예제로 알아보기

// mod.js
let c = 0;
export { c as counter };
export function increment() {
  ++c;
}
// entry.js
import { counter, increment as inc } from "./mod.js";
console.log(counter); // 0
inc();
console.log(counter); // 1
counter = 42; // Uncaught TypeError: Assignment to constant variable.
  • 읽기 전용 간접 바인딩이라 counter 에 직접 값을 할당하여 변경하면 변경이 불가능하다.
  • 다만 inc() 함수를 실행해 간접적으로 값을 바꾸는 것은 가능하다.
  • 겉보기엔 변수 값을 복사해온 것처럼 보이지만 사실 getter 를 가져온 개념이다.
  • 모듈로 가져온 참조의 descriptor 를 뜯어보면 아래와 같다.
    • configurablewritabletrue 인 이유는 JS 스펙상 false 라면 항상 동일한 값을 반환해야 한다는 특성이 있기 때문이다.
    • setter 에 의해 변환될 수 있기 때문에 true 여야 한다.

descriptor란?

descriptor 객체 예시

{
  "configurable": true,
  "enumerable": true,
  "value": 1,
  "writable": true
}

모듈을 읽어오는 3단계

  • 1단계: import 와 구문 분석: 모듈 소스코드의 importexport 를 분석하고 결정한다.
    • 이 과정에서 이전에 학습했던 모듈 레코드인 [[ModuleRequest]] 같은 것들이 사용된다.
  • 2단계: 인스턴스화 (instantiated): importexport 에 대한 모듈 환경 바인딩을 생성한다.
  • 3단계: 평가 (evaluation): 모듈의 코드를 실행한다.

1단계: import 와 구문 분석 (parsing)

  • 정적 분석이 수행된다.
  • 브라우저는 모듈 엔트리 파일의 내용을 읽고 모듈 레코드 를 생성한다.
  • 모듈 레코드에는 모듈을 아직 실행하지 않고 정적으로 파싱했을 때 알 수 있는 모든 정보가 들어간다.
    • [[ECMAScriptCode]]: 모듈 코드
    • [[RequestedModules]]: 코드에서 요청한 모듈들
    • [[ImportEntries]]: [[ModuleSpecifier]], [[ImportName]], [[LocalName]]
    • [[LocalExportEntries]]: 모듈에서 로컬 export 한 것들의 엔트리
    • [[IndirectExportEntries]]: import 직후 export 한 것들의 엔트리
  • 생성된 모듈 레코드모듈 맵 에 매핑된다.
  • 모듈 레코드 를 생성할 때는 매번 모듈 맵 에 해당 모듈 레코드 가 존재하는지 확인한다.
  • 모듈 레코드 는 일단 생성되면, JS 엔진에 의해 탐색되고 재사용된다.
  • 마침내 모든 모듈 레코드 정보가 완성되면, JS 엔진은 인스턴스화와 평가의 대상이 되는 모듈 트리를 결정할 수 있다.

시작지점인 entry.js 모듈 레코드 생성

entry.js, mod1.js, mod2.js 모듈 레코드 생성

모듈 트리 생성 (인스턴스화가 될 준비가 됨)

2단계: 인스턴스화 (instantiation)

  • JS 엔진은 각 모듈의 환경 객체와 최상위 바인딩을 만든다.
  • 인스턴스화는 최하위 모듈이 먼저 인스턴스화 되도록 깊이 우선 탐색을 이용해 진행된다.
  • 모듈 환경 객체와 바인딩이 생성되지만 해당 코드는 실행되지 않는다.
    • 호이스팅 가능한 선언이 처리되고 함수가 생성되지만 어휘 바인딩은 초기화되지 않은 상태로 남는다.
    • 함수 호출 시 환경을 만들고 모든 호이스트 가능한 작업을 수행하는 것과 같다.
  • JS 엔진은 모듈 레코드의 의존관계에 따라 내부 값이나 함수를 연결("wires")한다.
  • 연결 후에 Execution-ready 상태가 되면 평가(Evaluation)를 수행할 수 있다.

인스턴스화 이후

  • someStringmod1.js 에서 사용하는 로컬 변수이다.
    • 아직 초기화되지 않았다.

3단계: 평가 (evaluation)

  • 최상위 코드를 깊이 우선 순서(depth-first, post-order)로 실행하여 상태를 "평가됨(evaluated)" 으로 변경한다.
  • 이전에 초기화되지 않았던 바인딩 someString 도 이 단계에서 초기화된다.
  • 모듈의 최상위 코드는 단 1번씩 실행된다.
    • 사이드 이펙트가 있는 모듈의 경우 이 부분이 중요하다.
    • 그러나 사이드 이펙트는 없는 것이 모범사례다.
  • 평가에서 이뤄지는 각 단계를 정리하면 아래와 같다.
    • 실행 (Execution): 모듈의 코드가 실행된다. export 된 값이 메모리에 할당된다. import 된 바인딩이 연결된다. 코드에서 사용될 준비가 완료된다.
    • 사이드 이펙트 (Side Effects): 사이드이펙트가 있는 경우 수행된다.
    • 모듈 실행 완료 (Module Execution Complete): 모듈의 상태가 "평가됨(evaluated)" 으로 변경된다.

모듈의 순환 종속성과 TDZ

  • 모듈에는 순환 종속성이 걸릴 수 있다.
    • 순환 종속성이란 mod1.jsmod2.js 를 참조하고, mod2.jsmod1.js 를 참조하는 것이다.
    • 이 과정에서 변수를 export 한 경우, TDZ 에러가 발생할 수 있다.
    • 아래는 그 코드 예시이다.
  • 순환 종속성에 대한 이해가 있다면 TDZ 에러를 충분히 막을 수 있다.
    • 그러나 모듈에서 변수는 export 하지 않는 것이 좋다. getter 로도 충분히 똑같은 코드를 작성할 수 있다.

recursive-dependency.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="./entry.js" type="module"></script>
    <title>recursive dependency</title>
  </head>
  <body></body>
</html>

entry.js

import { func1 } from "./mod1.js";
import def, { func2 } from "./mod2.js";

console.log(`func1: ${func1()}`);
console.log(`func2: ${func2()}`);
console.log(`def: ${def()}`);

mod1.js

import def from "./mod2.js";

export const func1 = () => {
  return `${def()} func1`;
};

export const someString = "someString";

mod2.js

import { func1, someString } from "./mod1.js";

console.log(`someString: ${someString}`); // TDZ 에러

export const func2 = () => {
  return `${func1()} func2`;
};

export default () => {
  return "default";
};
 
반응형