1/12/2024

Module

Build

빌드는 여러 모듈이 합쳐저 하나의 큰 최종 JavaScript 번들(bundle)이 만들어지는 과정이다. 이는 최종 파일로 운영을 위해 웹 서버에 배포한다. 다시말해, 기본적으로 운영 환경의 브라우저로 전송될 JavaScript 파일이다. 운영은 애플리케이션이 실세계에서 실제 사용자에 의해 사용되는 것을 의미한다. 빌드는 많이 복잡할 수 있지만 간단하게 두 단계만 알아본다

1번째 단계는 번들링으로 모든 모듈을 하나의 큰 파일로 집약한다. 사용되지 않는 코드를 제거하거나 코드를 압축하는 등 상당히 복잡한 과정이다. 번들링은 두 가지 큰 이유로 매우 중요하다. 1번째, 오래된 브라우저는 모듈을 전혀 지원하지 않기에 모듈에 있는 코드는 어떤 오래된 브라우저에서도 실행될 수 없다. 2번째 이유는 브라우저로 보내는 파일의 수를 줄이는 것이 성능에 더 좋으며 또한 번들링 단계에서 코드를 압축하는 것도 이점이 있다.

2번째 단계는 트랜스파일링(transpiling) 및 폴리필링(polyfilling)이라고 하는 작업을 수행한다. 이 작업은 기본적으로 모든 현대 JavaScript 구문과 기능을 오래된 ES5 구문으로 변환하여 더 오래된 브라우저도 코드를 이해할 수 있게 하는 것이다. 일반적으로 Babel이라는 툴을 사용하여 수행된다.

두 단계를 거치면 최종 JavaScript 번들이 생성되어 운영을 위한 서버에 배포할 준비가 된다. 이러한 단계를 직접 수행하지 않으며 대신에 특수 툴을 사용하여 빌드 과정을 대신에 구현한다. 가장 일반적으로 사용되는 빌드 툴을 WebPack과 Parcel이다.

WebPack과 Parcel을 JavaScript 번들러라고 부른다. 이름에서 알 수 있듯이 코드를 가져와서 JavaScript 번들로 변환하기 때문이다. WebPack이 더 인기가 있지만 설정이 정말 어려울 수 있고 혼란스러울 수 있는데 제대로 작동하려면 수동으로 구성해야 할 많은 것들이 있기 때문이다. 반면에 Parcel은 설정이 필요없는 번들러로 그냥 즉시 작동한다. 그래서 어떤 설정 코드도 작성할 필요가 없다. 개발 툴은 NPM에서도 사용 가능하다. 코드에 포함시키는 패키지처럼 NPM을 사용하여 툴을 다운로드하고 관리할 수 있다. 이러한 툴에는 라이브 서버, 번들러, 코드를 ES5로 다시 변환하는 Babel 등이 포함된다.


Module

모듈은 소프트웨어 개발의 매우 중요한 부분이다. 기본적으로 모듈은 프로젝트의 특정 부분의 구현 세부 사항을 캡슐화하는 재사용 가능한 코드 조각이다. 함수나 클래스와 비슷하게 들릴 수 있지만 차이점은 모듈이 보통 독립적인 파일이다. 즉, 보통 모듈을 별도의 파일로 생각한다.모듈은 항상 어떤 코드를 포함하지만 가져오거나(import) 내보낼 수(export) 있다. 내보낸다는 의미는 모듈에서 값을 내보낼 수 있다는 것으로 예를 들어 단순한 값이나 혹은 함수일 수 있다. 모듈에서 내보낸 모든 것을 공개 API라고 부른다. 클래스와 마찬가지로 공개 API를 노출시킬 수 있는데 모듈의 경우 공개 API는 모듈로 값을 가져와서 사용된다. 모듈에서 값을 내보낼 수 있는 것처럼 다른 모듈에서 값을 가져올 수도 있다. 가져온 모듈을 종속성이라고 부른다.

모듈을 사용하면 많은 이점을 가진다. 첫 번째 이점은 모듈은 소프트웨어를 구성하는 것을 쉽게 만든다. 모듈을 애플리케이션을 구성하는 작은 구성 요소로 생각할 수 있다. 구성 요소를 격리하면 각 모듈을 전체 코드 베이스를 생각할 필요없이 독립적으로 개발할 수 있기에 협업하기 쉽다. 코드를 쉽게 추상화하도록 만드는 것도 이점인데 모듈을 사용하여 저수준 코드를 구현하고 저수준 세부 사항을 신경 쓰지 않고 다른 모듈이 추상화를 가져와 사용할 수 있다. 모듈은 또한 자연스럽게 더 조직된 코드 베이스를 만든다. 왜냐하면 코드를 분리하고 격리하고 추상화하기 때문에 자동으로 코드를 조직하고 이해하기 쉽다. 마지막으로 모듈은 프로젝트에서 동일한 코드를 재사용할 수 있는 기회를 제공한다.


ES6

ES6 기준으로 JavaScript는 내장 모듈 시스템을 가진다. ES6 이전에 모듈은 있었지만 직접 구현하거나 외부 라이브러리를 사용해야 했다. ES6 모듈은 파일에 저장된 모듈이며 각 파일은 하나의 모듈이다. 그래서 정확히 파일 당 하나의 모듈이 있다. 스크립트도 파일이므로 둘의 차이점에 대해 알아본다.

첫 번째 차이점은 모듈에서 모든 최상위 변수의 영역이 모듈에 국한된다. 변수는 기본적으로 모듈에만 유효하다. 외부 모듈이 모듈 내의 값에 접근하는 유일한 방법은 해당 값을 내보내는 것이다. 만약 내보내지 않으면 외부에서는 해당 변수를 볼 수 없다. 스크립트의 경우에는 모든 최상위 변수는 항상 전역이며 이 때문에 전역 이름공간(namespace) 오염과 같은 문제를 발생시킬 수 있다. 다시말해, 여러 스크립트가 동일한 이름으로 변수를 선언하려고 하고 이 때문에 변수들이 충돌할 수 있다. 비공개 변수는 이러한 문제의 해결책으로 이 때문에 ES6 모듈이 이렇게 구현되었다.

두 번째 차이점은 ES 모듈은 항상 엄격한(strict) 모드에서 실행되지만 스크립트의 경우에는 기본적으로 해제된(sloppy) 모드에서 실행된다. 그래서 모듈에서는 수동으로 엄격한 모드를 선언할 필요가 없다.

세 번째 차이점은 ES 모듈에서 최상위 this 예약어는 항상 undefined로 설정된다. 반면 스크립트의 경우에는 전역 객체(window)를 가리킨다.

네 번째 차이점은 모듈에서 정말 특별한 점은 ES6 import 및 export 구문을 사용하여 모듈 간에 값을 내보내고 가져올 수 있다는 것이다. 일반적인 스크립트에서는 값을 가져오고 내보내는 것이 완전히 불가능하다. 가져오기와 내보내기에 대한 매우 중요한 점이 있는데 그것은 가져오기와 내보내기가 항상 최상위에서만 발생할 수 있다는 것이다. 또한 모든 가져오기는 끌어올려(hoist)진다. 그래서 코드의 어디에서나 값을 가져오고 있는 것처럼 가져오기 문이 파일의 맨 위로 이동하는 것처럼 동작한다. 실제로는 값을 가져오는 것이 항상 모듈에서 일어나는 첫 번째 일이다.

마지막으로 모듈 파일 자체를 다운로드하는 것은 항상 자동으로 비동기적으로 발생한다. 이는 HTML에서 로드되는 모듈, 다른 모듈을 가져와서 하나의 모듈로 가져오는 경우에도 마찬가지이다. 일반적인 스크립트는 반면에 script 태그의 async 혹은 defer 속성을 사용하지 않으면 기본적으로 블록킹 동기적으로 다운로드된다.


Import

1
2
3
4
5
6
// index.js
import { print } from './utils.js';
import { rand } from './math.js';
 
const number = rand(1103);
print(number);
cs
코드에서 math.js 모듈에서 rand 값을 가져오고 utils.js 모듈에서 print 값을 가져온다. 코드 조각이 실행될 때 1번째 단계는 해당 코드를 구문 분석(parse)하는 것이다. 구문 분석이란 기본적으로 코드를 실행하지 않고 코드를 읽는 것으로 이 때 가져오기가 끌어올려지는(hoist) 시점이다. 사실, 모듈 가져오기 과정은 주요 모듈의 코드가 실제로 실행되기 전에 발생한다.

코드에서 index.js 모듈이 print 모듈과 math 모듈을 동기적으로 가져온다. 다시말해, 가져온 모든 모듈이 다운로드되고 실행된 후에만 주요 모듈인 index.js 모듈이 실행된다는 것을 의미한다. 이는 오직 최상위 수준의 가져오기와 내보내기가 가능하기 때문에 일어난다. 왜냐하면 실행되어야 하는 코드 밖에서 값들을 내보내고 가져오기 때문에 엔진은 구문 분석 단계 동안 모든 가져오기와 내보내기를 알 수 있다. 즉, 코드가 실행되기 전에 여전히 코드를 읽는 동안이다.

함수 내에서 모듈을 가져올 수 있게 허용된다면 그 함수가 실행되기 전에 가져오기 코드가 실행되어야 한다. 그렇기 때문에 모듈을 비동기적으로 가져올 수 없다. 그래서 가져오는 모듈이 먼저 실행되어야 한다. 왜 모듈이 동기적으로 로드되는지 질문할 수 있는데 일반적으로 동기는 좋지 않기 때문이다. 
이에 대한 답은 동기적인 방식이 번들링과 죽은 코드 제거(실제로 필요하지 않은 코드를 삭제) 같은 작업을 하는데 가장 쉬운 방법이기 때문이다.

모듈 간의 모든 종속성을 실행 전에 알기에 WebPack과 Parcel과 같은 번들러들은 여러 모듈을 결합하고 코드를 제거할 수 있다. 본질적으로 이것이 실행되어야 하는 코드 외부에서만 가져오고 내보낼 수 있는 이유이다. 

파싱 이후 가져와야 하는 모듈이 무엇인지를 파악하면 모듈은 서버에서 다운로드된다. 다운로드는 실제로 비동기적으로 발생한다. 가져오기 연산 자체가 동기적으로 발생한다. 모듈이 도착하면 구문 분석되고 모듈의 내보내기는 index.js에서 가져오기와 연결된다. 예를 들어, utils 모듈은 print라는 함수를 내보내고 이 내보내기는 index.js 모듈의 print 가져오기와 연결된다. 중요한 점은 이러한 연결이 살아있는(live) 연결로 내보내진 값은 가져오기에 복사되지 않는다. 대신, 가져오기는 기본적으로 내보내기의 값에 대한 참조(reference)이다. 따라서, 값이 내보내는 모듈에서 변경되면, 동일한 값이 가져오는 모듈에서도 변경된다. 이는 ES6 모듈에만 해당되는 것이다. 다음으로 가져온 모듈의 코드가 실행된다. 이로써 모듈을 가져오는 과정이 종료된다.


ES6 Module Import & Export

ES 모듈에는 두 가지 유형의 내보내기가 있는데 명명 내보내기와 기본 내보내기이다. 명명 내보내기는 모듈에서 무언가를 내보내는 가장 간단한 방법인데 내보내고 싶은 것 앞에 export 예약어를 넣기만 하면 된다. 참고로 항상 기억해야 할 것은 내보내기의 종류와 관계없이 내보내기는 항상 최상위 수준에서 이루어져야 한다는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// script.js
// as로 가져오기 이름을 변경할 수 있다.
// import { addToCart, fighterName as name, height } from './cart';
// addToCart('서피스', 2);
// console.log(name, fighterHeight);
 
console.log('Importing module');
// 전역 스코프를 가지는 스크립트와 달리 모듈에서는 허용되지 않는다.
// console.log(cost);
 
// import * as Cart from './cart.js';
 
// cart.js 모듈은 마치 클래스에서 생성된 객체인 것처럼 공개 API를 내보낸다.
// Cart.addToCart('서피스', 2);
// console.log(Cart.fighterName);
 
// 기본 내보내기를 가져오며 이름이 없기 때문에 원하는 이름을 사용할 수 있다.
// 기본 내보내기와 명명 내보내기를 같이 사용할 수 있다.
// 일반적으로 같은 모듈에서 명명 내보내기 및 기본 내보내기를 혼합하지 않는 것이 관례이다.
import add, {
  cart /*, { addToCart, fighterName as name, height }*/,
from './cart.js';
 
add('서피스'2);
add('아이폰'1);
add('소니 카메라'3);
 
// 가져오기는 내보내기의 복사본이 아니라 실시간 연결이다.
// 즉, 이는 메모리에서 동일한 위치를 가리킨다는 것을 의미한다.
// 따라서, 빈 배열이 아니라 서피스, 아이폰, 소니 카메라가 요소로 포함되어 있다.
console.log(cart);
 
// cart.js
console.log('Exporting module');
 
// cart.js 모듈에서만 사용 가능하다.
const cost = 10;
export const cart = [];
 
// 명명 내보내기.
//if (true) {
export const addToCart = (item, quantity) => {
  cart.push({ item, quantity });
  console.log(`${item} ${quantity}개 장바구니 추가 완료.`);
};
//}
 
const fighterName = '알렉스 페레이라';
const fighterHeight = 193;
 
// as로 가져오기 이름을 변경할 수 있다.
export { fighterName, fighterHeight as height };
 
// 기본 내보내기(이름이 없다.).
export default addToCart = (item, quantity) => {
  cart.push({ item, quantity });
  console.log(`${item} ${quantity}개 장바구니 추가 완료.`);
};
cs

명명 내보내기를 사용하여 모듈에서 여러 항목을 내보낼 수 있다. 가져오기의 경우 명명 내보내기를 사용하면 모듈의 모든 가져오기를 한 번에 가져올 수도 있다. 별표는 모든 것을 의미하고 as 예약어로 원하는 이름을 지정할 수 있는데 관례적으로 클래스처럼 대문자로 시작한다. 모듈은 마치 클래스에서 생성된 객체인 것처럼 공개 API를 내보낸다.

기본 내보내기는 보통 한 모듈당 하나의 항목만 내보내고 싶을 때 사용한다. 기본 내보내기를 가져올 경우 이름이 없기 때문에 아무 이름이나 사용할 수 있다. 또한, 명명 내보내기와 기본 내보내기를 혼합하여 사용할 수 있는데 실제로는 사용하지 않는다.

따라서 선호되는 방식은 모듈당 하나의 기본 내보내기만 사용하는 것인데 중괄호를 사용할 필요도 없으며 따라서 더 쉽게 가져올 수 있다. 물론 이것이 언제나 따라야 하는 엄격한 규칙은 아니며 각 상황에 가장 적합한 방법을 선택할 수 있다.


Top-level Await

ES2022부터 await 예약어를 async 함수 바깥에서도 사용할 수 있는데 모듈 안에서는 가능하다. 이를 최상위(top-level) await라고 부른다. 이와 관련해서 매우 중요한 점은 최상위 await이 전체 모듈의 실행을 차단한다는 것이다. top-level await은 또한 최상위 await은 자신이 있는 모듈뿐만 아니라 이를 가져오는 모듈에서도 실행을 차단한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// blocking.js
console.log('fetch() 함수 시작');
await fetch('https://jsonplaceholder.typicode.com/users');
console.log('fetch() 함수 끝');
 
// top-level-await.js
// 가져오는 모듈 실행을 차단한다.
import * as Code from './blocking.js';
// console.log('fetch() 함수 시작');
 
// const response = await fetch('https://jsonplaceholder.typicode.com/users');
// const data = await response.json();
 
// console.log(data);
 
// // 최상위 await이 모듈 전체를 블록킹하기 때문에 데이터가 추출된 후 나중에 출력된다.
// console.log('fetch() 함수 끝');
 
const getLastUser = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const data = await response.json();
 
  console.log(data);
 
  return { name: data.at(-1).name, email: data.at(-1).email };
};
 
// 깔끔하지 않다!
const lastUser1 = getLastUser();
lastUser1.then((last) => {
  console.log(last);
});
 
const lastUser2 = await getLastUser();
console.log(lastUser2);
cs


Module Pattern

ES6 모듈 이전에는 어떻게 모듈 패턴을 구현했을까? 모듈 패턴의 주요 목표는 기능을 캡슐화하고 비공개 데이터를 가지며 API를 노출시키는 것이다. 이를 달성하는 가장 좋은 방법은 함수인데 왜냐하면 함수는 기본적으로 비공개 데이터를 제공하며 값을 반환할 수 있기 때문이다. 함수를 통해 모듈 패턴을 구현하려면 IIFE 즉, 즉시 호출되는 함수 표현식을 사용하는데 별도로 호출할 필요가 없으며 한 번만 호출되기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// IIFE
const OrderList = (function () {
  const orders = [];
  const shippingCost = 1000;
 
  const placeOrder = (item, quantity) => {
    orders.push({ item, quantity });
    console.log(`${item} ${quantity}개 주문 완료. 배송비는 ${shippingCost}원.`);
  };
 
  const cancelOrder = (item, quantity) => {
    console.log(`${item} ${quantity}개 주문 취소.`);
  };
 
  return {
    orders,
    placeOrder,
    cancelOrder,
  };
})();
 
OrderList.placeOrder('초코파이'4);
OrderList.placeOrder('나뚜루 녹차'3);
console.log(OrderList.shippingCost); // undefined
cs
IIFE는 한 번만 호출되며 모든 것을 객체에 반환하고 사라지는데 어떻게 orders 변수에 접근할 수 있고 함수를 호출할 수 있을까? 바로 클로저 덕분이다. 간단하게 말해서, 클로저로 함수는 생성되었던 곳에 존재했던 모든 변수에 접근할 수 있다. placeOrder 함수는 IIFE에서 생성되었으며 연결되어 있다. 즉, orders 변수를 포함한 전체 스코프이다. 

IIFE의 문제는 ES6 모듈과 같이 파일당 하나의 모듈을 원한다면 여러 개의 스크립트를 만들고 그것들을 모두 HTML 파일에 연결해야 한다. 그러면 HTML에서 선언하는 순서에 주의해야 하는 등의 문제들이 발생하게 되고 모든 변수들이 전역 범위에 존재한다. 게다가 모듈 번들러를 사용하여 함께 번들링할 수 없다. 이러한 제한 때문에 ES6에서 네이티브 모듈이 추가된 이유이다.


CommonJS

ES 모듈과 모듈 패턴 이외에도 다른 모듈 시스템이 있지만 네이티브 JavaScript가 아니었기에 외부 구현에 의존했다. 그 중 하나가 CommonJS 모듈이다. CommonJS 모듈은 NodeJS에서 대부분 사용되었다. 최근에 들어서 ES 모듈이 NodeJS에 구현되었다. NodeJS는 브라우저 밖에서 JavaScript를 실행할 수 있는 실행 환경으로 이로 인해 거의 모든 모듈이 npm 레포지토리에 있다. 따라서 모든 모듈들은 여전히 CommonJS 모듈 시스템을 사용하는데 npm은 원래 NodeJS를 위해 만들어졌기 때문에 CommonJS를 사용한다.

ES6 모듈과 마찬가지로 CommonJS에서는 하나의 파일이 하나의 모듈이다. 모듈에서 무언가를 내보낼 때 export 객체를 사용한다. 점을 사용하고 내보낼 항목의 이름을 적는다. export 객체는 브라우저에서 정의되어 있지 않지만 NodeJS에서 사용하는 객체이다. 모듈에서 무언가를 가져올 때 require() 함수를 호출한다. require() 함수도 export 객체처럼 브라우저에서는 정의되어 있지 않지만 CommonJS 사양의 일부로 정의되어 있기 때문에 NodeJS에서는 정의되어 있다.
1
2
3
4
5
6
7
// 내보내기
export.computeAge = function(birthyear) {
  console.log(`나이: ${new Date().getFullYear() - birthyear}`)
}
 
// 가져오기
const sayHi = require('./misc.js')
cs

update: 2024.02.23

댓글 없음:

댓글 쓰기