JavaScript 모듈의 진화 과정
React에서 별 생각없이 사용하는 import, export.
두 구문은 JavaScript의 시작부터 존재했을까요?
아닙니다. 많은 변화를 거쳐 지금에 이른 것이죠.
저는 오늘 JavaScript 모듈의 변천사에 대해 얘기해보고자 합니다.
모듈은 크게 세가지 부분으로 구성됩니다. 의존성(import), 코드, 내보내기(export)
모듈의 구성
의존성(import)
한 모듈이 다른 모듈을 필요로 할 때, 그 모듈을 의존성으로 가져올 수 있습니다. 예를 들어, React 컴포넌트를 만들고 싶을 때 React 모듈을 가져와야 합니다. lodash와 같은 라이브러리를 사용하고 싶다면, lodash 모듈을 가져와야 합니다.
코드
모듈의 실제 코드
내보내기(export)
내보내기는 모듈의 인터페이스입니다. 모듈에서 내보내는 것은 모듈을 import 하는 모든 사람에게 제공됩니다.
모듈 아키텍처를 따를 때 소프트웨어 디자인에서의 베네핏
Reusability
모듈은 모듈을 필요로 하는 다른 모듈에서 쉽게 import 하여 사용할 수 있어요. 더 나아가, 다른 프로그램에서도 유용한 모듈이라면 이를 패키지로 만들어 배포할 수도 있습니다. 패키지는 하나 이상의 모듈을 포함할 수 있고 NPM에 업로드되어 누구나 다운로드할 수 있어요.
Composability
모듈은 import와 export를 명확하게 정의하여 쉽게 구성할 수 있어요.
Leverage
NPM 레지스트리는 전세계에서 가장 큰 무료 재사용 모듈 컬렉션을 호스팅하고 있습니다.
Isolation
전체 시스템을 이해하는 것은 어렵죠. 하지만 소프트웨어는 작고 집중된 모듈로 구성되어 있기 때문에, 각 모듈을 개별적으로 생각하고, 만들고, 보수할 수 있습니다. 이러한 isolation 덕분에 여러 사람이 개별적으로 작업하면서도 서로의 작업을 방해하지 않을 수 있어요. 또한, 한 모듈이 고장나면 전체 앱을 살펴볼 필요없이 고장난 개별 모듈만 보수하면 됩니다.
Organization
모듈은 자연스러운 분리점을 제공합니다. 또한, 전역 네임스페이스에 의한 오염을 방지하고 네이밍 충돌을 피할 수 있어요.
처음부터 최신 모듈 방식에 대해 접한다면, 이 흐름에 대해 쉽게 이해할 수 없을 것입니다. 그러니 2010년 말로 돌아가보죠. AngularJS가 막 출시되었고 jQuery가 인기를 끌고 있습니다. 회사들은 JavaScript를 사용하여 복잡한 웹 앱을 구축하기 시작했고, 그 복잡성을 관리할 필요가 있었습니다 - 모듈을 통해.
코드 분리
첫 아이디어로, 그들은 모듈을 만들기 위해 코드를 파일 단위로 분리했습니다.
javascript// users.js var users = ["Tyler", "Sarah", "Dan"] function getUsers() { return users }
javascript// dom.js function addUserToDOM(name) { const node = document.createElement("li") const text = document.createTextNode(name) node.appendChild(text) document.getElementById("users").appendChild(node) } document.getElementById("submit").addEventListener("click", function() { var input = document.getElementById("input") addUserToDOM(input.value) input.value = "" }) var users = window.getUsers() for (var i = 0; i < users.length; i++) { addUserToDOM(users[i]) }
html// index.html <!DOCTYPE html> <html> <head> <title>Users</title> </head> <body> <h1>Users</h1> <ul id="users"></ul> <input id="input" type="text" placeholder="New User"></input> <button id="submit">Submit</button> <script src="users.js"></script> <script src="dom.js"></script> </body> </html>
잘 분리된 것 같은데, 모듈을 성공적으로 구현한 게 맞을까요? 전혀 그렇지 않습니다! 단지 코드의 위치가 분리되었을 뿐이에요. JavaScript에서 새로운 스코프를 만드는 유일한 방법은 함수입니다. 함수가 아닌 모든 변수는 전역 객체에 존재합니다. 콘솔에서 window 객체를 로깅하면, addUsers, users, getUsers, addUserToDOM에 접근할 수 있고, 심지어 변경도 할 수 있습니다. 코드를 그저 물리적으로만 분리했을 뿐인 거죠.
IIFE 모듈 패턴
(Immediately Invoked Function Expression)
APP 이라는 단일 객체를 노출시키고, 모든 메서드를 그 아래에 배치한다면 어떻게 될까요? 전역 네임스페이스의 오염을 방지할 수 있겠죠! 그리고 나머지 코드를 함수로 감싸서 앱의 다른 부분과 분리할 수 있을 겁니다.
javascript// app.js var APP = {} // users.js function usersWrapper () { var users = ["Tyler", "Sarah", "Dan"] function getUsers() { return users } APP.getUsers = getUsers } usersWrapper()
javascript// dom.js function domWrapper() { function addUserToDOM(name) { const node = document.createElement("li") const text = document.createTextNode(name) node.appendChild(text) document.getElementById("users") .appendChild(node) } document.getElementById("submit") .addEventListener("click", function() { var input = document.getElementById("input") addUserToDOM(input.value) input.value = "" }) var users = APP.getUsers() for (var i = 0; i < users.length; i++) { addUserToDOM(users[i]) } } domWrapper()
html// index.html <!DOCTYPE html> <html> <head> <title>Users</title> </head> <body> <h1>Users</h1> <ul id="users"></ul> <input id="input" type="text" placeholder="New User"></input> <button id="submit">Submit</button> <script src="app.js"></script> <script src="users.js"></script> <script src="dom.js"></script> </body> </html>
이제 window 객체를 살펴보면, 중요한 코드(예: users)가 더 이상 전역 네임스페이스에 존재하지 않음을 알 수 있습니다. 여기서 한 걸음 더 나아가 봅시다. 래퍼 함수를 제거할 수 있을까요? 위 코드를 보면 래퍼 함수를 정의한 후 즉시 그것을 호출하고 있습니다. 래퍼 함수에 이름을 붙인 유일한 이유는 즉시 호출하기 위해서였습니다. 이름 없는 함수를 즉시 호출할 수 있는 방법이 있을까요? 있습니다. 그리고 그것은 '즉시 호출 함수 표현식(IIFE)'이라고 불립니다.
javascript(function() { console.log('Pronounced IF-EE') })()
이렇게 사용한다면 래퍼 함수를 제거하고 글로벌 네임스페이스를 더 깨끗하게 유지할 수 있습니다.
javascript// users.js (function() { var users = ["Tyler", "Sarah", "Dan"] function getUsers() { return users } APP.getUsers = getUsers })()
javascript// dom.js (function() { function addUserToDOM(name) { const node = document.createElement("li") const text = document.createTextNode(name) node.appendChild(text) document.getElementById("users").appendChild(node) } document.getElementById("submit").addEventListener("click", function() { var input = document.getElementById("input") addUserToDOM(input.value) input.value = "" }) var users = APP.getUsers() for (var i = 0; i < users.length; i++) { addUserToDOM(users[i]) } })()
이제 window 객체를 보면, 우리가 거기다 추가한 것은 APP 객체밖에 없을 것입니다. 이는 앱이 제대로 동작하는 데 필요한 모든 메서드의 네임스페이스로 사용됩니다.
이 패턴을 IIFE 모듈 패턴이라고 부릅니다.
IIFE 모듈 패턴의 장점은 뭘까요? 가장 먼저, 전역 네임스페이스에 모든 것 우겨넣는 것을 피할 수 있습니다. 이는 변수 충돌을 방지하고 코드를 더 private하게 유지하는 데 도움이 됩니다. 물론 단점도 존재해요. 여전히 전역네임스페이스에는 APP이라는 항목이 하나만 있고, 다른 라이브러리가 동일한 네임스페이스를 사용한다면 문제가 될 수 있습니다. 또한 index.html 파일의 <script> 태그 순서가 중요해집니다. 현재 순서를 정확히 유지하지 않으면 앱이 동작하지 않습니다.
CommonJS
이제 IIFE 모듈 패턴의 장점을 이해했으므로, CommonJS 모듈 표준을 살펴보겠습니다. CommonJS는 JavaScript의 스코프 문제를 해결하기 위해, 각 모듈이 자체적인 네임스페이스에서 실행되는 것을 보장합니다.
모듈 표준
- 파일 기반
- 명시적 import
- 명시적 export
API를 살펴볼게요. 유일하게 필요한 API는 import 와 export 가 어떻게 생겼는지 정의하는 것이에요. export 부터 보겠습니다.
우선 모듈에 대한 모든 정보를 module 객체에 담을 수 있습니다. 그런 다음 export 하고 싶은 모든 것을 module.exports에 추가할 수 있습니다.
javascriptvar users = ["Tyler", "Sarah", "Dan"] function getUsers() { return users } module.exports.getUsers = getUsers
다른 방법으로도 작성할 수 있습니다.
javascriptvar users = ["Tyler", "Sarah", "Dan"] function getUsers() { return users } module.exports = { getUsers: getUsers }
여러 메서드를 exports 객체에 추가할 수 있습니다.
javascript// users.js var users = ["Tyler", "Sarah", "Dan"] module.exports = { getUsers: function() { return users }, sortUsers: function() { return users.sort() }, firstUser: function() { return users[0] } }
이제 import 하는 API를 정의해야겠죠? require라는 함수는 문자열 경로를 첫 번째 인수로 받아서 해당 경로에서 export 하는 것을 리턴합니다.
javascriptvar users = require('./users') users.getUsers() // ["Tyler", "Sarah", "Dan"] users.sortUsers() // ["Dan", "Sarah", "Tyler"] users.firstUser() // ["Tyler"]
이처럼 CommonJS 그룹은 JavaScript 스코프 문제를 해결하기 위해 모듈 형식을 정의했습니다.
하지만 CommonJS에는 두가지 문제가 있어요.
첫째, 브라우저가 이해하지 못합니다.
둘째, 모듈을 동기적으로 로드하므로 브라우저에서는 끔찍한 사용자 경험을 초래합니다.
물론 솔루션은 존재합니다. 우리는 그걸 '모듈 번들러'라고 부릅니다.
모듈 번들러
JavaScript 모듈 번들러는 코드베이스를 분석하고, 모든 export 와 import 를 살펴본 다음, 모든 모듈을 브라우저가 이해할 수 있는 단일 파일로 번들링합니다. 그런 다음 index.html 파일에 모든 스크립트를 포함시키고 그 순서를 신경쓰는 대신, 번들러가 만들어주는 bundle.js 파일을 통해 모듈을 관리합니다.
javascriptapp.js ---> | | users.js -> | Bundler | -> bundle.js dom.js ---> | |
ES Modules
JavaScript 표준 위원회(TC-39)는 CommonJS가 직면한 문제(모듈을 동기적으로 로드하는 것)를 해결하기 위해 require 함수 호출 대신 새로운 키워드를 정의했습니다.
그것이 바로 import와 export 입니다.
export
javascript// utils.js // 내보내지 않음 function once(fn, context) { var result; return function() { if (fn) { result = fn.apply(context || this, arguments); fn = null; } return result; }; } // 내보내기 export function first(arr) { return arr[0]; } // 내보내기 export function last(arr) { return arr[arr.length - 1]; }
first와 last를 가져오는 방법은 몇 가지가 있습니다.
javascriptimport * as utils from './utils'; utils.first([1, 2, 3]); // 1 utils.last([1, 2, 3]); // 3
javascriptimport { first } from './utils'; first([1, 2, 3]); // 1
export default 도 지정할 수 있습니다.
javascript// leftpad.js export default function leftpad(str, len, ch) { var pad = ''; while (true) { if (len & 1) pad += ch; len >>= 1; else break; } return pad + str; }
import는 이렇게 해요.
javascriptimport leftpad from './leftpad';
EX Module의 흥미로운 점은 이제 JavaScript에 네이티브로 지원되기 때문에 번들러를 사용하지 않고도 최신 브라우저에서 지원된다는 것이에요.
Tree Shaking
CommonJS 모듈과 ES 모듈의 차이점 중 하나는 트리 쉐이킹입니다. CommonJS에서는 모듈을 어디서든, 심지어 조건부로도 import 할 수 있어요.
javascriptif (pastTheFold === true) { require('./parallax'); }
ES 모듈은 정적이므로 import 구문은 항상 모듈의 최상위 레벨에 있어야 합니다. 조건부로 가져올 수는 없어요.
javascriptif (pastTheFold === true) { import './parallax'; // "import"와 "export"는 최상위 레벨에서만 사용할 수 있습니다. }
이러한 설계는 모듈을 정적으로 강제함으로써 로더가 모듈 트리를 정적으로 분석하고, 실제로 사용되는 코드를 파악하여 사용되지 않는 코드를 번들에서 제거할 수 있도록 해줘요. 이를 트리 쉐이킹 또는 Dead Code Elimination 이라고 합니다.
Dynamic Imports
때로는 코드 실행 중간까지 파일을 import할 필요가 없는 경우도 있죠. 이럴 때, Dynamin Imports를 사용해 코드의 어느 곳에서나 모듈을 가져올 수 있습니다.
ES 모듈은 비동기이므로, 모듈을 즉시 사용할 수 없습니다. 모듈이 로드될 때까지 기다려야 해요. 그렇기에 Dynamic Imports는 우리의 모듈로 resolve 되는 Promise 를 반환합니다.
javascriptasync function createFruit(fruitName) { try { const FruitClass = await import(`./${fruitName}.js`); } catch { console.error("Error getting fruit class module:", fruitName); } return new FruitClass(); }
위 글은 아래 '참고' 링크를 기반으로 작성했습니다.
참고
JavaScript Modules: From IIFEs to CommonJS to ES6 Modules ES Modules in Depth