ES6 In Depth: 모듈

ES6 In Depth 시리즈는 ECMAScript 표준 6번째 에디션(줄여서 ES6)을 통해 JavaScript 에 새로 추가된 요소들을 살펴보는 시리즈입니다.

2007년 제가 모질라 JavaScript 팀에서 일하기 시작했을 때, 이런 농담이 있었습니다. 통상적인 JavaScript 프로그램의 라인 길이는 몇 줄일까요? 정답은 한 줄이었습니다.

그 때는 구글맵이 시작된 지 2년 지났을 때였습니다. 그전까지, JavaScript 의 주된 용도는 폼(form) 값을 검증하는 것이었습니다. 확실히 통상적인 <input onchange=> 핸들러의 길이는 한 줄 정도였습니다.

많은 것이 변했습니다. 이제 JavaScript 프로젝트의 크기가 입이 딱 벌어질만큼 커졌습니다. 그리고 커뮤니티에 의해 대규모 프로젝트를 위한 도구들이 개발되었습니다. 모듈(module) 시스템은 우리에게 가장 필요한 기초적인 것들 중 하나였습니다. 모듈 시스템은 우리가 작성하는 코드를 여러 개의 파일과 디렉토리로 나누는 기능입니다. 나누어진 코드들은 필요한 경우 서로 참조할 수 있어야 합니다. 또 그렇게 나눠진 코드들을 효율적으로 읽어 들일 수 있어야 합니다. 자연스럽게, JavaScript 에 모듈 시스템이 도입 되었습니다. 사실, 이미 여러 개의 모듈 시스템들이 존재합니다. 또 여러개의 패키지 매니저들도 존재합니다. 패키지 매니저는 소프트웨어를 인스톨하고 의존관계를 관리는 도구입니다. ES6 에 이르러서야 모듈 시스템이 정식으로 도입된 것이 조금 늦었다고 생각될지도 모르겠습니다.

오늘은 ES6 가 기존 모듈 시스템에 무엇을 추가했는지, 그리고 미래에 나올 표준과 도구가 새로 도입된 모듈 시스템에 기반해도 좋을지 알아봅시다. 그전에 먼저, ES6 모듈 시스템이 어떻게 생겼는지 알아봅시다.

모듈 기초

ES6 모듈은 JS 코드를 담고 있는 파일입니다. module 같은 모듈을 위한 특별한 키워드는 존재하지 않습니다. 모듈은 보기에 그냥 일반 스크립트처럼 보입니다. 다른 점이 있다면 딱 2개입니다.

  • ES6 모듈은 모듈 안에서 "use strict"; 라고 적지 않아도 자동적으로 strict 모드로 처리됩니다.

  • 모듈 안에서 importexport 키워드를 사용할 수 있습니다.

먼저 export 에 대해 이야기해 봅시다. 모듈 안에 선언한 모든 것들은 기본적으로 해당 모듈 안에서만 참조 가능합니다. 만약 모듈 안에 선언한 항목을 외부에 공개하고 싶다면, 그래서 다른 모듈들이 이용할 수 있게 하고 싶다면, 해당 항목을 export 해야 합니다. export 하기 위한 몇 가지 방법이 마련되어 있습니다. 가장 간단한 방법은 export 키워드를 덧붙이는 것입니다.

// kittydar.js - 이미지에 존재하는 모든 고양이들의 위치 찾기.
// (Heather Arthur 가 정말로 이 라이브러리를 구현했음)
// (하지만 그녀는 모듈을 사용하지 않았음, 2013년에 작성했기 때문임)

export function detectCats(canvas, options) {
  var kittydar = new Kittydar(options);
  return kittydar.detectCats(canvas);
}

export class Kittydar {
  ... several methods doing image processing ...
}

// 이 함수는 export 되지 않음 (외부에 노출되지 않음).
function resizeCanvas() {
  ...
}
...

export 키워드는 모든 톱-레벨(top-level) function, class, var, let, const 항목에 덧붙일 수 있습니다.

모듈을 작성하기 위해 알아야 할 것은 정말 이것이 전부입니다! 모든 것을 IIFE 나 callback 에 넣을 필요가 없습니다. 그냥 개발을 진행하다가 필요에 따라 export 를 선언하면 됩니다. 해당 코드가 스크립트가 아니라 모듈이기 때문에 선언된 모든 것들은 모듈 스코프를 갖습니다. 글로벌 스코프가 아니기 때문에 다른 스크립트와 모듈에 노출되지 않습니다. 모듈의 공개 API 만 export 키워드로 선언하세요. 그게 전부입니다.

export 와 별개로, 모듈 안의 코드는 극히 일반적인 코드입니다. 모듈 안의 코드는 ObjectArray 같은 글로벌 심볼을 사용할 수 있습니다. 만약 당신의 모듈이 웹 브라우저에서 실행된다면, 모듈 안의 코드는 documentXMLHttpRequest 같은 객체도 사용할 수 있습니다.

다른 분리된 파일에서, 우리는 detectCats() 함수를 import 해서 사용할 수 있습니다.

// demo.js - Kittydar 데모 프로그램

import {detectCats} from "kittydar.js";

function go() {
    var canvas = document.getElementById("catpix");
    var cats = detectCats(canvas);
    drawRectangles(canvas, cats);
}

모듈에 있는 심볼을 여러 개 import 하려면, 다음과 같이 합니다.

import {detectCats, Kittydar} from "kittydar.js";

import 선언이 있는 모듈을 실행시키면, import 하는 모듈들이 먼저 로드됩니다. 그래서 모듈들 사이의 의존성 그래프 상에서 가장 깊이 있는 모듈부터 실행됩니다. 이때, 이미 실행된 모듈은 건너뛰기 때문에 사이클(cycles)이 발생하지 않습니다.

여기까지가 모듈에 대한 기초입니다. 정말 간단합니다. 😉

Export 리스트

공개할 항목마다 export 를 덧붙이지 않고, 중괄호를 이용해서 공개할 항목들의 목록을 한번에 선언할 수 있습니다.

export {detectCats, Kittydar};

// 여기에 `export` 키워드를 쓸 필요 없음
function detectCats(canvas, options) { ... }
class Kittydar { ... }

export 목록이 파일 맨 처음에 올 필요는 없습니다. export 목록은 모듈 파일의 어디에나 올 수 있습니다. 다만 톱-레벨(top-level) 스코프여야 합니다. export 목록이 여러개 올 수도 있습니다. 또 export 목록과 export 선언을 함께 사용할 수도 있습니다. 단 어떤 심볼도 한번만 export 되어야 합니다.

import 이름과 export 이름 바꾸기

가끔 우연찮게 import 한 이름이 다른 이름과 충돌할 수 있습니다. 그래서 ES6 는 import 이름을 바꿀 수 있게 합니다.

// suburbia.js

// 2개의 모듈이 모두 `flip` 이란 이름을 export 함.
// 2개 항목을 모두 import 하려면, 적어도 1개 항목의 이름을 바꿔야 함.
import {flip as flipOmelet} from "eggs.js";
import {flip as flipHouse} from "real-estate.js";
...

비슷하게, export 할 때도 이름을 바꿀 수 있습니다. 이 기능은 동일한 항목을 2개의 다른 이름으로 export 할 때 유용합니다 가끔 그럴 일이 생깁니다.

// unlicensed_nuclear_accelerator.js - drm 없는 미디어 스트리밍
// (가짜 라이브러리, 진짜였으면 좋겠음)

function v1() { ... }
function v2() { ... }

export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};

default export

새 표준은 기존의 CommonJS 그리고 AMD 모듈과 함께 사용할 수 있도록 설계됐습니다. 그래서 만약 당신이 Node 프로젝트를 가지고 있고 npm install lodash 를 실행한 상태라면, 당신의 ES6 코드는 Lodash 에 있는 개별 함수들을 import 할 수 있습니다.

import {each, map} from "lodash";

each([3, 2, 1], x => console.log(x));

그런데 우리는 each 라고 쓰는 것보다 _.each 라고 쓰는 것에 더 익숙합니다. 우리는 여전히 그런 식으로 코드를 짜고 싶습니다. 또 우리는 _ 를 함수처럼 쓰는 것도 원합니다. 왜냐하면 그것이 Lodash 를 쓰는 유용한 방식이기 때문입니다.

이를 위해, 우리는 조금 다른 문법을 사용해야 합니다. 모듈을 중괄호 없이 import 하는 것입니다.

import _ from "lodash";

이 코드는 import {default as _} from "lodash"; 코드를 축약한 것입니다. ES6 는 모든 CommonJS 와 AMD 모듈이 default export 를 갖는다고 취급합니다. 우리가 해당 모듈을 require() 로 가져올 때 얻는 객체, 즉 exports 객체 말입니다.

ES6 모듈은 여러 개의 항목을 export 할 수 있도록 설계되었습니다. 하지만 기존 CommonJS 모듈이 import 대상이라면, default export 객체만 가져오면 됩니다. 예를 들어, 유명한 colors 패키지는, 제가 아는 한, 이 글을 쓰는 시점까지 ES6 를 위해 어떤 특별한 조치도 하지 않았습니다. colors 패키지는 다른 npm 패키지들처럼 CommonJS 모듈들의 집합입니다. 그래서 우리는 colors 패키지를 지금 당장 ES6 코드에서 import 할 수 있습니다.

// `var colors = require("colors/safe");` 와 동일한 ES6 의 표현
import colors from "colors/safe";

만약 당신이 작성한 ES6 모듈에 default export 를 두고 싶다면, 쉽습니다. default export 는 전혀 어렵지 않습니다. default export 는 이름이 "default" 라는 것만 빼면 다른 export 와 동일합니다. 우리가 벌써 이야기한 이름 바꾸기 문법을 쓰면 됩니다.

let myObject = {
  field1: value1,
  field2: value2
};
export {myObject as default};

또는 아래처럼 축약해서 쓰세요. 이것이 더 보기 좋습니다.

export default {
  field1: value1,
  field2: value2
};

export default 키워드 다음에는 함수, 클래스, 객체 리터럴 등 모든 종류의 값이 올 수 있습니다.

모듈 객체

죄송합니다. 이야기가 길었습니다. 하지만 JavaScript 만 그런 것이 아닙니다. 이런저런 이유로, 모든 랭귀지들의 모듈 시스템들에는 소소하고 지루하고 잡다한 편의 기능들이 많이 존재합니다. 다행히, 이제 딱 하나만 남았습니다. 아니, 두 개 남았습니다.

import * as cows from "cows";

import * 를 실행하면, 모듈 네임스페이스 객체(module namespace object)가 import 됩니다. 모듈 네임스페이스 객체의 속성은 해당 모듈이 export 하는 항목들입니다. 그래서 만약 “cows” 모듈이 moo() 라는 이름의 함수를 export 하는 상황에서, 이런 방식으로 “cows” 를 import 하면, 우리는 cows.moo() 처럼 쓸 수 있습니다.

모듈 모으기

종종 패키지 메인 모듈의 역할은 패키지에 속한 다른 모든 모듈들을 import 해서 일관된 형태로 export 하는 것 뿐인 경우가 많습니다. 이런 류의 코드를 쉽게 작성하기 위해, import 와 export 를 한번에(all-in-one) 처리하는 축약법이 있습니다.

// world-foods.js - good stuff from all over

// "sri-lanka" 를 import 해서 그 중 일부를 re-export 함
export {Tea, Cinnamon} from "sri-lanka";

// "equatorial-guinea" 를 import 해서 그 중 일부를 re-export 함
export {Coffee, Cocoa} from "equatorial-guinea";

// "singapore" 를 import 해서 전부를 export 함
export * from "singapore";

각각의 export-from 구문은 import-from 구문 다음에 바로 이어서 export 구문이 온 것처럼 동작합니다. 실제로 import 구문을 쓰는 것과 달리, 이 구문은 스코프에 re-export 바인딩을 추가하지 않습니다. 그러니 만약 world-foods.jsTea 에 관해 조금이라도 코드를 작성할 계획이라면 이 축약법을 사용하지 마세요. 해당 변수가 존재하지 않는다는 사실을 알게 될 것입니다.

만약 “singapore” 에 의해 export 되는 항목의 이름이 다른 export 항목과 겹치면 에러가 발생합니다. 그래서 export * 구문은 주의해서 사용해야 합니다.

휴! 이제 문법에 대해 모두 이야기했습니다! 이제 재미있는 부분으로 넘어갑시다.

import 가 실제로 하는 일은 무엇일까요?

만약, 아무것도 없다… 라고 하면 믿으시겠습니까?

아, 당신은 쉽게 속는 사람이 아니라구요? 글쎄요, import 구문이 하는 일에 대해 표준이 언급하는 바가 거의 없다 라고 하면 믿으시겠습니까? 그리고 그게 최선의 선택이라면요?

ES6 는 모듈 로딩에 관한 상세한 사항을 전적으로 구현체에 맡겼습니다. 대신 모듈 실행에 대해서는 자세한 스펙을 만들었습니다.

간략히 표현하자면, 우리가 JS 엔진에게 어떤 모듈을 실행시키라고 하면, 다음과 같은 4 단계 작업이 일어납니다.

  1. 파싱(Parsing): 구현체가 모듈의 소스 코드를 읽고 문법에 오류가 없는지 체크합니다.

  2. 로딩(Loading): 구현체가 모든 import 모듈들을 (재귀적으로) 로드합니다. 아직 표준화가 이루어지지 않은 부분입니다.

  3. 링킹(Linking): 새로 로드되는 각 모듈에 대해, 구현체는 모듈 스코프를 생성하고 생성된 스코프를 모듈에 선언된 모든 바인딩들로 채웁니다. 여기에는 모듈이 import 하는 다른 모듈들의 바인딩들도 포함됩니다.

    그런데, 여기서 import {cake} from "paleo" 를 시도하는데, “paleo” 모듈에 cake 라는 이름으로 export 되는 항목이 존재하지 않으면 에러가 발생합니다. 이건 매우 나쁜 상황입니다. 왜냐하면 JS 코드 실행 직전까지 왔기 때문입니다. 그래서 케이크(cake)를 먹을 수 있는 상황이었습니다!

  4. 런타임(Run time): 마지막으로, 구현체는 새롭게 로드된 각 모듈의 본문을 실행시킵니다. 이 때가 되면, import 처리는 이미 종료된 상황입니다. 그래서 구현체의 구문 실행이 import 선언이 있는 곳에 다다르면… 아무 일도 일어나지 않았습니다!

보셨나요? 제가 “아무것도 없다”고 했죠? 저는 프로그래밍 랭귀지에 대해서는 거짓말을 하지 않습니다.

이제 우리는 모듈 시스템의 재미있는 부분에 다다랐습니다. 멋진 트릭이 있습니다. 모듈 시스템이 로딩 작업의 동작 양식을 규정하고 있지 않기 때문에, 그리고 우리가 모든 의존 관계를 소스 코드에 있는 import 선언들을 보고 미리 파악할 수 있기 때문에, ES6 로 구현한 코드를 컴파일 시점에 하나의 번들(bundle) 파일로 묶는 것이 가능합니다. 하나의 번들 파일로 묶으면 네트워크 전송에 유리합니다! webpack 같은 도구가 실제로 이런 일을 합니다.

이것이 중요한 이유는 네트워크를 통해 스크립트 파일들을 여러 개 로딩하자면 시간이 오래 걸리기 때문입니다. 스크립트 파일을 하나 가져올 때마다, 우리는 그 스크립트 파일에 여러 개의 import 선언들이 포함되어 있는 것을 발견하고 다시 여러 번의 로딩 작업을 시도해야 합니다. 고지식한 로더(loader)를 사용할 경우 네트워크를 무척 많이 왕복해야 할 것입니다. 하지만 webpack 을 사용하면, 오늘 당장 ES6 모듈 구문을 사용할 수 있을 뿐더러, 소프트웨어 엔지니어링 덕분에 실행 성능을 개선할 수 있습니다.

원래는 ES6 표준에서 모듈 로딩에 관한 스펙을 정의하려고 계획했었습니다. 최종 표준에 모듈 로딩 스펙이 포함되지 않은 이유 중 하나는 번들링(bundling) 기능을 어떻게 만들지에 대한 합의가 없었기 때문입니다. 저는 누가 좋은 방법을 생각해내면 좋겠습니다. 왜냐하면, 장차 알게 되겠지만, 모듈 로딩에도 표준화가 필요하기 때문입니다. 또, 번들링 역시 포기할 수 없는 좋은 기능이기 때문입니다.

Static vs. dynamic, or: 규칙과 규칙을 파괴하는 방법

동적(dynamic)인 랭귀지임에도 불구하고, JavaScript 는 놀라울 정도로 정적(static)인 모듈 시스템을 갖게 됐습니다.

  • 모든 importexport 구문은 모듈의 톱-레벨(top-level)에만 올 수 있습니다. 조건적으로 import 하거나 export 할 수 없으며, import 구문을 함수 스코프에서 사용할 수도 없습니다.

  • 모든 export 항목들은 명시적인 이름으로 export 되어야 합니다. 프로그램적으로 어떤 배열을 순회하면서 데이터에 따라 일련의 이름을 자동 생성해서 export 할 수 없습니다.

  • Module 객체들은 확장 불가능(frozen) 합니다. 폴리필 스타일로 모듈 객체에 새로운 기능을 추가할 수 없습니다.

  • 모듈의 모든 의존관계들은 초기에 로드되고, 파싱되고, 링크되어야 합니다. 모듈 코드는 그런 일들이 처리된 다음에 실행됩니다. import 구문으로 모듈을 늦게(lazily) 온디맨드(on demand)로 로드 시키는 문법은 존재하지 않습니다.

  • import 에러로부터 복구하는 방법이 없습니다. 하나의 앱(app)이 수백개의 모듈을 가질 수 있습니다. 그런데, 만약 하나의 모듈이라도 로드하거나 링크하는데 실패하면 어떤 코드도 실행되지 않습니다. 우리는 try/catch 블록으로 import 구문을 감싸서 실행할 수도 없습니다. (반면, 이 방식의 장점은 모듈 시스템이 완전히 정적이기 때문에, webpack 같은 도구가 그런 에러를 컴파일 시점에 발견할 수 있다는 것입니다.)

  • 어떤 모듈이 자신이 의존하는 다른 모듈들의 로딩 시점을 가로채서 어떤 코드가 실행되도록 할 수 없습니다. 모듈이 자신이 의존하는 모듈들의 로딩 방식을 제어할 수 없다는 의미입니다.

우리의 요구사항이 정적(static)이기만 하다면 ES6 모듈 시스템은 아주 훌륭합니다. 하지만 가끔은 특별한 요구사항이 있을 때가 있습니다. 그렇지 않습니까?

그래서 현존하는 모든 모듈 로딩 시스템들은 ES6 의 정적인 import/export 문법과 함께 사용할 수 있는 프로그램적인 API 를 제공합니다. 예를 들어, webpack 도 API 를 제공합니다. 그래서 API 를 이용한 “코드 나누기(code splitting)” 가 가능합니다. 즉, 일부 모듈들을 필요한 시점에 뒤늦게 로딩하는 것입니다. 같은 API 를 이용해서 앞서 나열한 정적인 규정들에서 벗어날 수 있습니다.

ES6 의 모듈 문법 은 매우 정적입니다. 정적이라는 특성이 나쁜 것은 아닙니다. 그런 특성 덕분에 강력한 컴파일 도구를 사용할 수 있습니다. 그리고 그런 정적인 문법을 설계할 때, 다른 풍부하고 동적인 로더 API 와 함께 사용할 것을 고려했습니다.

언제 ES6 module 을 사용할 수 있나요?

지금 당장 module 을 사용하려면, TraceurBabel 같은 컴파일러가 필요합니다. 이번 시리즈의 이전 글에서, Gastón I. Silva 가 Babel 과 Broccoli 를 사용하는 방법을 소개했습니다. Babel 과 Broccoli 를 이용하면 ES6 코드를 웹에서 사용할 수 있도록 컴파일할 수 있습니다. 그 기사를 기반으로 Gastón 이 ES6 module 을 사용하는 동작 가능한 예제를 만들었습니다. Axel Rauschmayer 의 글도 Babel 과 webpack 를 사용한 예제를 설명하고 있습니다.

ES6 모듈 시스템은 주로 Dave Herman 와 Sam Tobin-Hochstadt 가 설계했습니다. 그들은 수년간 모듈 시스템의 정적인 특성을 변호해왔습니다. (저를 포함한) 많은 사람들이 이의를 제기했거든요. Jon Coppeard 가 Firefox 에 모듈 시스템을 구현했습니다. JavaScript 로더(Loader) 표준에 관한 추가 작업은 현재 진행중입니다. HTML 에 <script type=module> 같은 구문을 추가하는 일이 일어날 것 같습니다.

이것이 ES6 입니다.

저는 이 일이 너무 재미있어서 끝내고 싶지 않습니다. 그래도 이제 한 회만 더하면 시리즈가 끝날 것입니다. ES6 표준의 자잘한 이야기로 한 회를 채울 수도 있을 것 같습니다. 아니면 미래에 일어날 일에 대해 이야기 할 수도 있을 것 같습니다. 다음 글에서 ES6 In Depth 의 충격적인 결말을 확인해주세요.

이 글은 Jason Orendorff 가 쓴 ES6 In Depth: Modules 의 한국어 번역본입니다.

작성자: ingeeKim

"누구에게나 평등하고 자유로운 웹"에 공감하는 직장인.

ingeeKim가 작성한 문서들…


2 댓글

  1. 김보아

    번역 정말 감사합니다.

    10월 10th, 2016 at 9:29 오전
  2. ingeeKim

    자주 들러주세요. 모질라 활동에 계속 관심 가져 주세요. 언젠라도 여력이 되시면 모질라 활동에 참여해주세요. ^^

    10월 10th, 2016 at 1:05 오후

댓글 쓰기