[모던JS: Core] 모듈

KG·2021년 6월 1일
0

모던JS

목록 보기
24/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

모듈 (module)

개발하는 어플리케이션의 크기가 커지면 파일을 여러개로 분할해서 관리하는게 유지보수 측면에서 더 유리할 수 있다. 이때 분리되는 파일 각각을 모듈(module)이라고 부르는데, 대개 자바스크립트에서 모듈은 하나의 클래스 또는 특정 목적을 가진 여러개의 함수로 구성된 라이브러리 단위로 구성되는 경우가 많다.

자바스크립트가 처음 만들어졌을 때는 자바스크립트 자체의 크기도 작을뿐더러 기능 역시 단순한 경우가 많았기 때문에 별도로 모듈화하여 관리해야 할 필요가 적었다. 때문에 오랜 기간 자바스크립트는 별도의 모듈 시스템 표준 없이 성장해왔다.

그렇지만 프론트엔드와 백엔드가 분리되는 등 웹 생태계에서 자바스크립트의 중요도가 점점 커져갔고, 이로 인해 스크립트의 크기 역시 방대해져감과 동시에 다양한 기능을 담게 되었다. 때문에 자바스크립트 커뮤니티에서는 이러한 거대한 자바스크립트를 모듈화하여 관리하기 위한 여러 라이브러리를 제작하는 등 여러가지 시도를 하게 된다. 대표적으로 다음과 같은 모듈 시스템이 이러한 시도로 탄생하게 되었다.

  • AMD : 가장 오래된 모듈 시스템 중 하나로 require.js라는 라이브러리를 통해 처음 개발되었다.
  • CommonJS : Node 서버를 위해 만들어진 모듈 시스템
  • UMD : AMDCommonJS와 같은 다양한 모듈 시스템을 함께 사용하기 위해 탄생

이외에도 다양한 시도가 있는데, 이는 나중에 다룰 번들러(Bundler)의 역사와도 그 흐름이 이어져 아주 재미난 이야기가 있다. 관련해서 추후에 포스팅하게 된다면 링크를 추가로 걸도록 하겠다. 만약 이러한 흐름에 관심이 있다면 우선 해당 포스트를 참고하자. 관련하여 따로 정리할 필요가 없을 정도로 매우 잘 정리되어 있는 게시글이다.

이러한 모듈 시스템은 오래된 스크립트에서 간혹 발견할 수 있지만 오늘날 잘 사용되지 않는다. 왜냐하면 ES2015(ES6)에서 모듈 시스템이 표준으로 등재되었기 때문이다. 이후 관련 문법은 대부분 주요 브라우저와 Node.js 환경에서 지원하고 있다. 따라서 아래 모듈 시스템에서 설명되는 문법은 모두 ES6에서 도입된 자바스크립트 자체 문법이다.

1) 모듈이란

모듈은 단지 하나에 파일에 불과하다. 즉 스크립트 파일 하나가 모듈 하나로 구성될 수 있다. 이때 스크립트 파일은 단순히 변수 하나를 가지고 있을 수도 있고, 방대한 기능을 가지고 있는 클래스 하나를 담고 있을 수도 있으며, 복수로 구성된 여러 함수를 가지고 있을 수도 있다.

모듈에는 특수한 지시자 exportimport를 적용하면 다른 모듈을 불러오고 불러온 모듈에 있는 함수를 호출하는 것과 같은 기능 공유가 가능하다.

  • export : 변수나 함수 앞에 붙이면 해당 변수나 함수를 외부에서 접근 가능 (모듈 내보내기)
  • import : 외부 모듈의 기능을 가져올 수 있음 (모듈 가져오기)
// sayHi.js 스크립트 파일
export function sayHi(user) {
  console.log(`Hello ${user}`);
}
// main.js 스크립트 파일
import { sayHi } from './sayHi.js';

sayHi('KG');

두 코드 영역은 서로 분리된 스크립트 파일로 존재한다. 이때 sayHi.js파일에서는 export 지시자를 통해 함수를 내보내고 있는 것을 볼 수 있다. 그리고 이를 main.js 파일에서 import 지시자를 사용해 가져오고 있다. import 지시자는 from 키워드를 사용하여 상대 경로 기준으로 모듈을 가져올 수 있다. 이처럼 모듈별로 기능을 분리하여 스크립트 파일을 구현하고, 외부에서는 이 기능을 자유자재로 가져다 사용할 수 있는 방식이 기본적인 모듈 시스템이다.

브라우저에서 모듈이 동작하는 예시를 살펴보자. 모듈은 특수한 키워드 및 기능과 함께 사용되므로 <script> 태그에 추가적으로 type= "module"로 지정해주어야 한다. 이렇게 처리하면 브라우저가 해당 스크립트가 모듈이라는 것을 파악할 수 있다.

<!doctype html>
<script type="module">
  import { sayHi } from './sayHi.js';
  
  document.body.innerHTML = sayHi('KG');
</script>

모듈은 로컬 파일에서 동작하지 않고, HTTP 또는 HTTPS 프로토콜을 통해서만 동작한다. 따라서 모듈을 브라우저 상에서 사용하려면 로컬 웹 서버 static-server 또는 IDE에서 제공하는 라이브 서버 등을 이용해야 한다. 프론트엔드 프레임워크를 사용하는 경우 등 대부분 자체 개발 서버를 제공하는 경우가 많기 때문에 크게 신경쓸 필요는 없지만, HTTP(S) 프로토콜에서만 동작한다는 사실을 염두해두자.

2) 모듈의 핵심 기능

일반 스크립트와 모듈의 차이는 무엇일까? 목적으로는 개별 스크립트 파일 단위로 모듈화 구성하여 관리할 때 모듈을 사용한다고 설명했다. 그 외에 기능적으로 모듈이 일반 스크립트와 구분되는 독특한 점은 다음과 같다.

엄격 모드로 실행

모듈은 항상 엄격모드(use strict)로 실행된다. 따라서 선언되지 않은 변수에 값을 할당하는 등의 동작은 모듈에서는 에러를 발생한다.

모듈 레벨 스코프

모듈은 모두 각자만의 스코프를 가지고 있다. 따라서 모듈 내부에서 정의한 변수 또는 함수는 다른 스크립트에서 일반적으로는 접근할 수 없다. 이러한 특성 때문에 모듈로 스크립트 파일을 구성하면, 변수 네이밍의 중복 등과 관련한 문제를 쉽게 회피할 수 있다.

아래와 같이 두 개의 모듈 스크립트 파일이 있을 때, 일반적인 방법으로는 각자의 변수 또는 함수 접근이 불가하다.

// user.js
const user = 'KG';

// hello.js
console.log(user);	// Uncaught ReferenceError
<!doctype html>
<script tye="module" src='user.js' />
<script tye="module" src='hello.js' />

외부에서 해당 모듈에 선언된 변수 또는 함수에 접근이 가능케하려면 위에서 소개한 바와 같이 exportimport 지시자를 통해서만 가능하다.

즉 외부에 공개하려는 모듈은 export 되어야 하고, 공개된 모듈을 다른 모듈에서 사용하기 위해서는 import 되어야 한다. 위의 코드를 아래와 같이 수정하면 정상 동작한다.

// user.js
export const user = 'KG'

// hello.js
import { user } from './user.js';

document.body.innerHTML = user;

만약 브라우저 환경에서 부득이하게 전역변수를 만들어 각 모듈간에 export/import 없이 공유하게 하려면 이전에 언급한 바와 같이 window 객체에 명시적으로 값을 할당한 후 window 객체에 접근하는 방법이 있다. 하지만 이러한 방법은 정말 필요한 경우가 아닌 이상 권고하지 않는다.

단 한 번만 평가

이 역시 모듈 시스템이 가진 장점인데, 동일한 모듈이 여러 곳에서 사용된다고 할 지라도 모듈은 최초 호출 시 단 한 번만 실행된다. 실행 후 결과는 이 모듈을 가져가려는 모든 모듈에 동일하게 내보내진다.

이 같은 작동방식은 다음과 같은 결과를 초래한다. 최상위 레벨 모듈을 만들고, 해당 모듈에서는 초기화 또는 내부에서 쓰이는 데이터 구조를 구현하고 이를 내보내 재사용하고 싶을 때 사용하는 등의 쓰임이 가능하다.

// admin.js
export const admin = {
  name: 'KG'
};

// first.js
import { admin } from './admin.js';
admin.name = 'SJ';

// second.js
import { admin } from './admin.js';
console.log(admin.name);	// SJ

first.jssecond.js 모듈에서 각각 내보내진 admin 변수를 import 하고 있다. 그러나 모듈은 단 한 번 실행되고, 실행된 모듈은 필요한 모든 곳에 공유된다. 따라서 이를 가져온 각각의 모듈에서 admin은 모두 동일한 객체이다. 때문에 first.js에서 admin.name 속성을 SJ로 변경하더라도 이와 격리된 환경인 second.js에서 변경된 값을 확인할 수 있다.

이러한 특징은 주로 설정파일을 만들때 많이 응용한다. 최초로 실행되는 모듈의 객체 프로퍼티를 원하는대로 설정하면, 다른 모듈에서는 이 설정을 그대로 사용할 수 있기 때문이다.

import.meta

import.meta 객체는 현재 모듈에 대한 정보를 제공한다. 호스트 환경에 따라 제공하는 정보의 내용은 조금씩 다른데, 브라우저 환경에서는 스크립트의 URL 정보를 얻을 수 있다. 만약 HTML 내부에 있는 모듈이라면 브라우저 환경이기 때문에, 현재 실행 중인 웹페이지 URL 정보를 얻을 수 있다.

<script type='module'>
  console.log(import.meta.url);	// 페이지 URL...
</script>

this는 undefined

모듈 최상위 레벨에서 this는 일반 스크립트가 window 객체였던 것과는 달리 항상 undefined를 가리킨다. 이는 지극히 당연한 내용이기도 한데, 왜냐하면 모듈의 영역은 항상 엄격모드이기 때문이다.

일반 스크립트에서도 엄격모드를 적용하면 최상위 레벨의 thiswindow 객체가 아닌 undefined를 가리키는 것과 동일하다.

3) 브라우저 특정 기능

그 외 추가적으로 브라우저 환경에서 type="module"이 붙은 스크립트가 어떻게 일반 스크립트와 다른지 살펴보자.

지연 실행

모듈 스크립트는 항상 지연 실행된다. 외부 스크립트, 인라인 스크립트와 관계없이 항상 defer 속성을 붙인 것처럼 실행된다. 따라서 모듈 스크립트는 아래와 같은 특징을 가진다.

  • 외부 모듈 스크립트 <script type="module" src="...">를 다운로드 할 때 브라우저의 HTML 처리가 멈추지 않는다. 브라우저는 외부 모듈 스크립트와 기타리소스를 병렬적으로 불러온다.
  • 모듈 스크립트는 HTML 문서가 완전히 준비될 때까지 대기 상태에 있다가 HTML 문서가 완전히 만들어진 이후에 실행된다. 아무리 모듈의 크기가 작아서 HTML보다 빨리 불러온다고 하더라도 예외는 없다.
  • 스크립트의 상대적 순서가 유지되기에, 문서상 위쪽의 스크립트부터 차례로 실행된다.

이러한 특징 때문에 모듈 스크립트는 항상 완전한 HTML 페이지를 볼 수 있고 문서 내 요소에도 접근할 수 있다. 일반 스크립트의 경우에는 순차적으로 접근하기 때문에 만약 페이지 요소가 구성되기 전에 DOM에 대한 접근을 하는 경우에는 예상치 못한 결과가 나올 수 있다. 이러한 이유 때문에 <script>와 같이 외부 스크립트를 불러오는 태그를 대부분 가장 하단에 배치하거나, 또는 DOMContentLoaded와 같은 이벤트를 통해 HTML 문서에 접근하도록 한다. 하지만 모듈 스크립트에서는 이러한 처리를 굳이 할 필요가 없다.

<script type='module'>
  console.log(typeof button);
</script>

<script>
  console.log(typeof button);
</script>

<button id='button'>Button</button>

모듈 스크립트로 선언된 첫 번째 콘솔은 정상적으로 button에 접근할 수 있다. 모듈 스크립트는 지연 실행으로 인해 모든 HTML 문서가 렌더링 된 후 실행되기 때문에 이 시점에서는 이미 button이 존재하기 때문이다.

반면 일반 스크립트에서 선언된 콘솔은 undefined가 출력될 것이다. 이는 순차적으로 실행되는데 아래 <button>보다 먼저 선언되었기 때문에 이 시점에서는 button이 존재하지 않기 때문이다.

모듈을 사용할 땐 HTML 페이지가 완전히 나타난 후 모듈이 실행된다는 점을 유의해야 한다. 페이지 내 특정 기능이 모듈 스크립트에 의존적인 경우 모듈이 완전히 로딩되기 전 페이지가 먼저 사용자에게 노출되기 때문이다. 이때 노출되는 페이지는 텅 빈 화면일 수도, 아니면 레이아웃이 깨져있는 형태의 페이지일 수도 있다. 따라서 모듈 스크립트를 불러오는 동안 오버레이 처리 또는 로딩 인디케이터 등의 처리를 추가적으로 구현해 사용자경험을 고려해야 한다.

React와 같은 SPA 역시 모듈 시스템을 사용한다. Webpack이라는 번들러 도구를 사용해 모듈 시스템을 자체적으로 변환하기는 하지만, 이러한 특성 때문에 React로 빌드한 웹 페이지를 처음 띄울 때에는 항상 텅 비어있는 페이지가 출력된다. React에서 기본적으로 반환하는 HTML 문서는 텅 비어 있는 구조의 문서이고, HTML을 그리고 난 이후 관련 모듈 스크립트가 실행되기 때문이다.

인라인 스크립트의 비동기 처리

모듈이 아닌 일반 스크립트에서 async 속성은 외부 스크립트를 불러올 때만 유효하다. 앞에서 살펴보았듯이 async를 통해 우리는 비동기 처리를 할 수 있다. 때문에 이 경우 스크립트는 로딩이 끝나면 다른 스크립트 또는 HTML 문서가 처리되길 기다리지 않고 바로 실행될 수 있다.

그러나 모듈 스크립트에서는 async 속성을 인라인 스크립트에도 적용할 수 있다. 이 경우엔 위와 동일하게 HTML이 모두 처리되기까지 기다리지 않고 바로 실행된다. 이런 특징은 광고나 문서 레벨 이벤트 리스너, 카운터와 같이 어디에도 종속되지 않는 기능을 구현할 때 유용하게 사용할 수 있다.

<!-- 필요한 모듈(analytics.js)의 로드가 끝나면 -->
<!-- 문서나 다른 <script>가 로드되길 기다리지 않고 바로 실행-->
<script async type="module">
  import { counter } from './analytics.js'
  
  counter.count();
</script>

외부 스크립트

type="module"이 붙은 외부 모듈 스크립트엔 두 가지 큰 특징이 있다.

  1. src 속성이 동일한 외부 스크립트는 한 번만 실행된다.
<!-- my.js는 한 번만 로드 및 실행 -->
<script type="module" src="my.js" />
<script type="module" src="my.js" />
  1. 외부 사이트같이 다른 오리진(origin)에서 모듈 스크립트를 불러올 때 CORS 처리가 필요하다.
<!-- another-site.com이 Access-Control-Allow-Origin을 지원해야만 스크립트가 정상 실행 -->
<script type="module" src="http://another-site.com" />

경로가 없는 모듈은 금지

브라우저 환경에서 import는 반드시 상대 또는 절대경로가 from과 함께 지정되어야 한다. 경로가 없는 모듈은 허용되지 않는다.

다만 Node.js 환경이나 번들링 툴을 사용하는 경우에는 경로가 없더라도 해당 모듈을 찾을 수 없는 방법을 알기 때문에 경로 없이 사용이 가능하다.

import { sayHi } from 'sayHi';	// Error
// './sayHi.js'와 같이 경로 정보를 지정해주어야 한다.

// Webpack 번들러를 사용하는 React 또는 Node 환경에선
// 위와 같이 선언해도 에러없이 사용 가능할 수 있다.

호환을 위한 nomodule

구식 브라우저는 type="module"을 해석하지 못하기 때문에 모듈 타입 스크립트를 만나면 이를 무시하고 넘어간다. 이러한 경우를 대비하여 nomodule 속성을 사용할 수 있다.

<script type="module">
  console.log('모던 브라우저');
  alert(1);
</script>

<script nomodule>
  console.log('구식 브라우저');
  alert(1);
</script>

type="module"을 해석하지 못하는 구식 브라우저는 첫 스크립트를 건너뛴다. 그리고 두 번째 스크립트는 정상적으로 실행한다. 모던 브라우저는 nomodule 속성을 알아서 건너 뛰기 때문에 첫 스크립트만 실행한다. 만약 구식 브라우저를 고려해야 하는 경우에는 nomodule 속성을 알아두자.

4) 빌드툴

대부분의 브라우저 환경에서는 모듈을 단독으로 사용하는 경우는 흔치 않다. 번들러(Bundler)라고 불리우는 툴을 사용하는데, 대표적으로 Webpack이 있다. CRA CLI로 생성하는 React 역시 Webpack을 기본 번들러로 사용한다. 물론 Webpack 이외에도 Rollup, Parcel과 같은 다양한 번들러 도구가 있다. 각각 유사한 기능을 하지만 세부적으로 다양한 차이가 있다. 이와 관련된 포스팅은 위에서 건 링크 또는 아래 레퍼런스를 확인하자.

번들러를 사용하면 모듈 분해를 쉽게 통제할 수 있다. 추가적으로 경로가 없는 모듈이나 CSS, HTML 포맷의 모듈 역시 사용할 수 있다는 장점이 있다. 번들러의 주 역할은 다음과 같다.

  • HTML의 <script type="module">에 넣을 주요(main) 모듈을 선택
  • 주요 모듈에 의존하고 있는 모듈 분석을 시작으로 모듈 간 의존 관계 파악
  • 모듈 전체를 한데 모아 하나의 큰 파일 생성 (설정에 따라 여러 파일로 나눌 수 있고, 이렇게 나눠진 파일을 chunk라고 표현)
  • import 문이 번들러 내 함수로 대체 (기존 기능은 그대로 유지)
  • 코드 변형 및 최적화 수행
    • 도달 가능하지 않은 코드는 삭제
    • 내보내진 모듈 증 쓰임처가 없는 모듈 삭제(tree shaking)
    • console, debugger 같은 개발 관련 코드 삭제
    • 최신 자바스크립트 문법이 사용된 경우 바벨(Babel)을 사용해 동일 기능의 낮은 버전 자바스크립트로 변환
    • 공백 제거, 변수이름 줄이기 등의 minify 수행

번들링 도구를 사용하면 스크립트들은 설정에 따라 하나 또는 여러개의 파일로 번들링된다. 이때 번들링 전 스크립트에 있던 importexport는 번들링 자체 함수로 대체가 되기 때문에 type="module" 속성을 사용하지 않고도 동일한 기능을 수행하게끔 변형된다. 따라서 React로 빌드한 웹 페이지를 개발자도구로 통해 살펴보면 다음과 같이 일반 스크립트처럼 취급하고 있는 것을 확인할 수 있다.

<!-- 번들링 과정을 거친 스크립트 bundle.js -->
<script src="bundle.js" />

이처럼 번들링 과정을 거치면 외부에서 확인할 때 모듈이 일반 자바스크립트로 변환되어 보이지만, 내부적으로는 모듈 스크립트를 사용하는 것과 동일한 기능을 수행한다.

모듈 export & import

모듈을 내보내고 가져올 때 사용하는 exportimport 지시자는 다양한 방식으로 활용될 수 있다. 각각의 방식을 살펴보도록 하자.

1) 선언부 앞에 export 붙이기

변수나 함수, 클래스를 선언함과 동시에 export를 붙여서 내보낼 수 있다.

export let month = ["Jan", "Feb", "Mar"];

export const MODULES_YEAR = 2015;

export function sayHi(user) {
  console.log(`Hello ${user}`);
}

export class User {
  constructor(name) {
    this.name = name;
  }
}

2) 선언부와 떨어진 곳에 export 붙이기

선언부와 export가 분리되어도 내보내기가 가능하다.

function sayHi(user) {
  console.log(`Hello ${user}`);
}

function sayBye(user) {
  console.log(`Bye ${user}`);
}

// 두 함수를 동시에 내보냄
export { sayHi, sayBye };

3) import *

무언가를 가져올 때는 가져올 항목을 import { ... } 안에 적어서 가져올 수 있다. 이때 만약 가져올 항목이 많은 경우에는 import * as <obj> 형식으로 객체 형태로 들고 올 수 있다.

import { sayHi, sayBye } from './say.js';

sayHi('KG');
sayBye('KG');
import * as say from './say.js';

say.sayHi('KG');
say.sayBye('KG');

import * 방식이 더 깔끔해 보일 수 있겠지만 가급적 가져올 대상을 처음 방식처럼 구체적으로 명시하는 경우가 더 좋다.

Webpack과 같은 번들러 도구는 로딩 속도를 높이기 위해 모듈을 한 데 모으는 번들링 및 최적화를 동시에 수행한다. 이 과정에서 사용되지 않는 리소스는 제거하는데 import *를 사용해 객체로 들고오는 경우 사용하지 않는 기능이 있더라도 이를 제거하는 등의 최적화 과정을 수행하기 어렵다. 즉 tree-shaking 과정에 애로사항이 생기게 된다.

또한 어떤 항목을 가져올 지 명시하면 이름을 간결하게 사용할 수 있다는 장점과 어디서 어떤 것이 사용되는지 명확하기 때문에 코드 구조 파악이 용이하고 리팩토링 및 유지보수 역시 간편하다.

4) import as

as를 사용하면 이름을 바꿔서 모듈을 가져올 수 있다.

import { sayHi as hi, sayBye as bye } from './say.js';

hi('KG');
bye('KG');

5) export as

동일하게 export에도 as 키워드를 사용할 수 있다.

...
export { sayHi as hi, sayBye as bye };

6) export default

모듈은 크게 두 종류로 나눌 수 있다.

  1. 복수의 함수가 있는 라이브러리 형태의 모듈
  2. 개체 하나만 선언되어 있는 (주로 클래스 형태의) 모듈

보통 두 번째 방식으로 모듈을 구성하는 방식을 선호하는 경우가 많다. 때문에 자연스레 파일의 개수가 많아질 수 밖에 없는데, 모듈 이름을 잘 지어주고 폴더에 목록화를 해두어 잘 구성한다면 코드 탐색이 크게 어렵지는 않기에 큰 문제가 되지는 않는다.

모듈은 추가적으로 export default 문법을 지원하는데, 해당 모듈에 개체가 하나만 있다라는 사실을 명확하게 나타낼 수 있는 문법이다. 모듈 스크립트는 오직 하나의 export default만 가질 수 있기 때문이다.

export default class User {
  constructor(name) {
    this.name = name;
  }
}

// 또 다른 export default 선언 불가

default가 가지는 어원적 의미가 그렇듯이, export default 역시 기본사항으로 내보내는 항목을 의미한다. 보통 파일에는 하나의 export default 만을 두는 것이 관례적이다. 이렇게 default가 붙어 내보내진 모듈은 중괄호 없이 바로 모듈을 가져올 수 있다.

import User from './user.js';

new User('KG');

앞서 살펴보았던 그냥 export의 경우에는 중괄호와 함께 import 했다는 점이 가장 큰 차이점이다. 문법적으로 exportexport default를 섞어 쓰더라도 문제가 없다. 그러나 export default에 의미에 맞게 보통은 섞어 쓰지 않는 경우가 많다. 한 파일에는 하나 또는 여러개의 export가 있거나 아니면 하나의 export default가 있도록 구성하는 편이 좋다.

또한 export default는 파일 당 하나만 가질 수 있기 때문에 내보낼 개체에 별도의 이름이 없어도 잘 동작한다. 이는 해당 개체를 가져오려는 다른 모듈에서도 default로 지정된 개체가 무엇인지 알 수 있기 때문에 이름이 없어도 문제없다.

export default class {
  constructor() {}
}
export default function (user) {
  console.log(user);
}
export default ['JAN', 'FEB', 'MAR'];

default name

default 키워드는 기본 내보내기를 참조하는 용도로 종종 사용된다. 함수를 내보낼 때 아래와 같이 함수 선언부와 떨어진 곳에서도 default 키워드를 사용할 수 있다. 이때 해당 함수는 기본 내보내기로 설정된다.

function sayHi(user) {
  console.log(`Hello ${user}`);
}

export { sayHi as default }
// export default 를 함수 앞에 붙인 것과 동일

위에서 가급적 exportexport default를 같이 사용하는 것을 권하지 않는다고 했지만, 가끔씩 이 둘이 혼용되어 사용되는 경우도 있다.

export default class User {
  constructor(name) {
    this.name = name;
  }
}

export function sayHi(user) {
  console.log(`Hello ${user}`);
}

이는 컨벤션을 위해 암묵적으로 제약할 뿐 사실 문법적으로는 전혀 문제가 되지 않는다. 이때 외부 모듈에서 내보내진 항목을 가져오기 위해서는 다음과 같이 선언할 수 있다.

import { default as User, sayHi } from './user.js';

new User('KG');

또는 import *를 사용해서 객체 형태로 들고오는 방법도 가능하다. 이 경우 default 프로퍼티는 정확히 export default 항목을 가리킨다.

import * as user from './user.js';

let User = new user.default;
new User('KG');

export default 이름 관련 규칙

그냥 export를 해서 내보내는 경우엔 사용한 이름을 그대로 가져오기 때문에 관련 정보를 파악하기 용이하다. 그러나 별도로 as 키워드를 사용하여 이름을 명시적으로 바꾸지 않는 이상 내보냈을 때 쓴 이름과 가져올 때 쓰는 이름은 항상 동일해야 한다.

export class User {...}
// ---------------------
import { User } from './user.js';
import { MyUser } from './user.js';	// Error

그러나 export default의 경우에는 가져올 때 개발자 마음대로 이름을 지정해 줄 수 있다. 이 역시 default를 통해 하나의 객체가 반환된다는 것을 미리 알고 있기 때문에 가능하다.

export default class User {...}
// ---------------------
import User from './user.js';
import MyUser from './user.js';
// 어떠한 이름도 가능 (중괄호 없이 사용)

이처럼 export default로 내보내는 경우엔 중괄호 없이 가져온다는 점과, 개발자 임의로 이름을 지정할 수 있다는 점이 가장 큰 차이점이다.

그러나 이렇게 이름을 짓는 경우에는 같은 걸 가져오는데도 다른 이름을 부여하여 혼란이 생길 수 있다. 이런 문제를 예방하고 코드의 일관성을 유지하기 위해 보통 파일 이름과 동일한 이름을 사용한다. 또는 팀별로 별도의 내부 규칙을 설정했다면 그를 준수하도록 하자.

import User from './user.js';
import LoginForm from './loginForm.js';
import func from '/path/to/func.js';
...

7) 모듈 다시 내보내기

export ... from ... 문법을 사용하면 가져온 개체를 즉시 다시 내보내기(re-export)할 수 있다. 이름을 바꾸어 다시 내보내는 것과 동일하다.

export { sayHi } from './say.js'; 
// sayHi를 다시 내보내기

export { default as User } from './user.js';
// default export를 다시 내보내기

결국 내보내는 행위는 동일한데 왜 다시 내보내는 작업이 필요한지 의문이 들 수 있다. 다시 내보내기는 주로 외부에 공개할 모듈을 설정할 때 사용하는 기법 중 하나이다. 다음 예시를 살펴보자.

NPM을 통해 외부에 공개할 패키지를 만들고 있다고 가정하자. 이 패키지는 수많은 모듈로 구성되어 있는데, 몇몇 모듈은 외부에 공개할 기능을 가지고 있고, 몇몇 모듈은 이러한 기능 구현에 도움을 주는 헬퍼 역할을 담당하고 있다.

auth/
    index.js
    user.js
    helpers.js
    tests/
        login.js
    providers/
        github.js
        facebook.js
        ...

이때 패키지의 구조가 위와 같다고 할 때, 진입점 역할을 하는 주요 파일인 auth/index.js를 통해 기능을 외부에 노출시키면, 이 패키지를 사용하는 개발자들은 아래와 같은 코드로 해당 기능을 사용하게 될 것이다.

import { login, logout } from 'auth/index.js';

우리는 이 패키지를 사용하는 다른 개발자가 패키지 안에 있는 기타 다른 파일들을 건드려 내부 구조를 건드리는 불상사를 미연에 방지하고 싶다. 그러기 위해서는 공개할 것만 auth/index.js에 넣어 내보내기 하고 나머지는 숨기는 방법이 있을 것이다.

이때 내보낼 기능을 패키지 전반에 분산하여 구현하고, auth/index.js에서는 이 기능들을 가져오는 즉시 다시 내보내는 방식으로 위와 같은 이슈를 어느 정도 달성할 수 있다.

// auth/index.js

// login, logout을 가져와서 내보내기
import { login, logout } from './helpers.js';
export { login, logout };

// User를 가져와서 내보내기
import User from './user.js';
export { User };

auth/index.js 에서는 외부에서 구현이 완료된 기능을 그저 가져오고 이를 다시 내보내고 있다. 따라서 auth/index.js가 외부에 공개된다고 했을때 해당 기능을 구현하고 있는 내부 로직에 접근하기 힘들다. 이와 같은 상황에서 다시 내보내기를 사용할 수 있다. 앞서 살펴본 것과 같이 위의 코드는 다음과 같이 간단하게 변환이 가능하다.

// login, logout을 가져와서 바로 내보내기
export { login, logout } from './helpers.js';

// User를 가져와서 바로 내보내기
export { User } from './user.js';

export default 다시 내보내기

export default의 경우 다시 내보낼 때 주의해야 할 점이 몇 가지 있다. 다음과 같이 User 클래스를 기본 내보내기 하고 있다고 가정하자.

export default class User { ... }

이때 이를 다시 내보내기 위해서는 다음을 주의해야 한다.

  1. Userexport User from './user.js'로 다시 내보내면 문법 에러가 발생한다. 위에서 잠깐 언급이 되었던 export { default as User } 방식으로 사용해야 한다.

  2. export * from './user.js'를 사용해 모든 걸 한 번에 다시 내보내면 default는 무시되고, 기본 내보내기 항목만 다시 내보내기가 수행된다. 만약 두 가지를 동시에 다시 내보내고 싶다면 다음과 두 개를 동시에 같이 사용해야 한다.

export * from './user.js';	
// 기본 export 항목 다시 내보내기

export { default } from './user.js';
// default export 항목 다시 내보내기

이처럼 export default의 경우에는 해당 상황을 고려하여 처리해야 하기 때문에 보통 export default는 다시 내보내는 것을 선호하지 않는 경우도 있다.

importexport는 스크립트의 맨 위나 맨 아래에 올 수 있는데 이 둘에는 차이가 없다. 대개의 경우 편의상 맨 위에 배치하는 경우가 많다.

동적으로 모듈 가져오기

앞서 다룬 export/import문은 모두 정적인 방식이다. 문법이 단순한 대신 제약사항이 많다. 가장 대표적인 제약사항은 import문에 동적 매개변수를 사용할 수 없다는 것이다. 모듈의 경로에는 오직 원시 문자열만 들어갈 수 있기 때문에 함수 호출의 결과값을 경로로 사용하는 것은 불가하다.

import ... from getModulePath();
// 모듈 경로는 원시 문자열만 허용하기에 에러 발생

또 다른 대표적 제약사항은 런타임이나 조건부로 모듈을 불러올 수 없다는 점이다. 모듈 import/export 문은 항상 최상위 레벨에 존재해야 한다.

if (...) {
  import ...;	// 조건부로 모듈 가져오기 불가능
}

{
  import ...;	// 최상위 레벨이 아니므로 불가능
}

이러한 제약사항이 존재하는 이유는 import/export문이 코드 구조의 중심을 잡아주는 역할을 수행하기 때문이다. 코드 구조를 분석해 모듈을 한데 모아 번들링하고, 그 과정에서 tree-shaking을 통한 최적화가 수행되는데, 이는 코드 구조가 간단하고 고정되어 있을 때 가능하기 때문이다.

그럼에도 불구하고 동적으로 모듈을 불러오고 싶은 경우가 생길 수 있다. 대표적으로 지금 사용하지 않을 모듈의 경우에는 불러오지 않고, 해당 모듈을 사용하는 경우에서야 이 모듈을 불러오는 등의 작업이 필요하면 어떻게 처리해주어야 할까?

1) import() 표현식

import(module) 표현식은 모듈을 읽고, 이 모듈이 내보내는 것들을 모두 포함하는 객체를 담은 이행된 프라미스를 반환한다. 호출 역시 제약없이 어디에서나 가능하다. 따라서 코드 내 어디에든 동적으로 사용할 수 있다.

let modulePath = prompt('what module?');

import(modulePath)
  .then(obj => <모듈 객체>)
  .catch(err => <에러 처리>)

프라미스 객체가 반환되기 때문에 당연히 async 함수 안에서 await과 함께 사용이 가능하다.

// say.js

export function hi() {
  console.log('hi');
}

export default function hello() {
  console.log('hello');
}
// export default 항목은 다음과 같이
// default 키워드를 통해 가져와야 한다
async function load() {
  let obj = await import('./say.js');
  let { hi, default } = obj;
  let hello = default;
  
  hi();
  hello();
}

동적 import는 일반 스크립트에서도 동작하기 때문에 script type="module"을 필요로 하지 않는다.

또한 import()는 함수 호출과 문법이 유사해 보이지만 함수 호출이 아니다. 이는 super() 문법 처럼 괄호를 쓰는 특별한 문법 중 하나이다. 때문에 import()를 변수에 복사한다거나, call/apply 메서드를 적용하는 것은 불가능하다.

특히 React에서는 Lazy를 이용해 Component가 사용되는 시점에서 동적으로 모듈을 가져올 수 있도록 구현할 수 있다. 그러나 아직 React.lazy의 경우엔 서버 사이드 렌더링을 할 수 없다는 이슈가 있기 때문에, 별도의 라이브러리를 통해 동적 모듈 가져오기를 구현할 수도 있다. 대표적으로 많이 사용되는 것으로 Loadable Components가 있다. 이처럼 보통 Webpack의 도움을 받아 추가적으로 코드 스플리팅(Code Spliting)을 수행할 수 있다.

References

  1. https://ko.javascript.info/modules
  2. https://wormwlrm.github.io/2020/08/12/History-of-JavaScript-Modules-and-Bundlers.html
profile
개발잘하고싶다

0개의 댓글