본문 바로가기

프로그래밍 공부내용/자바스크립트(JS)

JS와 import에 대하여... ESM과 CommonJS

요즘 토스 면접을 준비하느라 전에 봤던 토스 영상들을 부쩍 다시 찾아보게 된다.

박서진님의 '내 import 문이 그렇게 이상했나요?' 를 보고,

전에 발표했던 yarn berry를 바탕으로 import와 모듈에 관하여 정리하고자 한다.

 

모듈이 뭔데요

모듈은 쉽게 말해서 코드 뭉치라고 보면 된다.

function add(a,b){
    return a + b;
}

와 같은 코드가 필요하다고 하자.

매번 필요할때마다 이 코드를 복사붙여넣기 해서 쓰는 것은 너무 비효율 적이기 때문에 우리는 모듈로 쓴다.

어떻게 쓰는건데

쓰는건 어렵지 않다.
ESM과 CommonJS의 등장 이전의 상황부터 보자

// 박서진님 발표 예제입니다.

<script src='https://cdn.com/jquery.js'></script>
<script src='https://cdn.com/lodash.js'></script>
<script>
    jquery(document).ready(function(){...}
</script>

우리는 jquery를 불러쓰려면 저런 방식으로 써야 했다. html 문서 안에 script 태그를 통해서 저렇게 매번 불러와서 사용해야 했다.
다행히 코드를 항상 붙여넣기를 하진 않고 저렇게 url을 통해 제공되는 source code(라이브러리)를 불러 쓰긴 했었다.

아마 저것보다도 이전에는 직접 작성해야 하지 않았나 싶다.

저렇게 불러왔을 때에는 전역 변수 문제가 생길 수 있다.

만약 jquery와 lodash에서 같은 변수명을 사용한다면 문제가 생긴다.

CommonJS 의 등장

이걸 해결하기 위해서 이제 CommonJS가 등장하게 된다.
위의 코드를 CommonJS는 이렇게 쓴다.

const jQuery = require('jQuery');
const lodash = require('lodash');

jQuery(document).ready(function(){...});

훨씬 좋아지지 않았는가?

이렇게 모듈 시스템이 도입되면서 '파일단위'로 개발하기 쉬워졌고, 라이브러리의 재사용이 쉬워졌다.

우리는 아직도 CommonJS를 쓰고있다.

엥? 그렇다고? 나는 코드에 import export 를 사용하고 있는데? 라고 할 수 있다.

하지만 생각해보면 우리는 TS, babel을 통해서 코드를 트랜스파일링 해서 사용한다.

당신이 CRA로 프로젝트를 만들었다면 webpack이 감춰져있겠지만, webpack을 쓰던 다른 것을 쓰던 우리는 babel이나 ts를 변환해서 사용하게 된다.

쉽게 확인할 수 있는 방법은 dist폴더에 배포를 시킨후에 코드를 한번 뜯어보아라. require로 당신이 선언한 모듈을 불러오는 것을 볼 수 있다.

import React from 'react';

이 구문은 아래와 같이 트랜스파일된다.

const React = require('react');

위의 사진은 Babel Playground에서 어떻게 변환해주는지 직접 확인한 사진이다.

그러니까.. 사실 우리는 import를 쓰고 있지만 사실 코드는 CommonJS로 사용되고 있었던 것이다.

CommonJS의 문제

CommonJS가 문제가 없다면 참 좋을 텐데 문제가 있다.

 

1. CommonJS는 언어 표준이 아니다.

따라서 deno와 같이 commonJS를 사용하지 않는 런타임에서는 사용할 수 없다.


2. 정적분석이 어렵다.

만약 이렇게 사용한다면 자바스크립트는 기본적으로 컴파일 언어가 아니기 때문에 컴파일 시점에 REACT를 불러와야 할지 안할지 파악하기가 쉽지 않다.

if(SOME_CONDITION){ React = require('react') }

     우리는 TreeShaking이라는 과정을 통해서 불필요한 코드를 제거한다. 왜냐면 불필요한 코드가 섞여 있으면 쓰잘데기         없이 크고 느린 코드가 생길 수 있기 때문이다.
      근데 이렇게 어떤 코드가 쓰일지 안 쓰일지 파악하기 힘들면(rollup에서 rollup/plugin-commonjs 등을 쓸 수 있다곤 하         지만 글쎄다..) TreeShaking을 잘 못해서 성능상 좋지 않다.

 

      해당 내용은 이 글 이 정리를 잘 해 놨다.


3. 비동기 모듈 정의 불가능

let isInitialized = false;
export.initialize = async function initialize(){
    if(initialized)}{
        throw new Error('이미 initialized 됐습니다.');
    }
      await connoctToDB();
     isInitialized = true;
}
...

와 같이 비동기 처리를 하려면 먼저 initailized 상태를 선언한 후 사용해야 한다.

4. require 함수의 재정의
require을 통해서 정의해보자

const REACT = require('react'); console.log('typeof react is : ', REACT);

자바스크립트의 가장 큰 특징 중 하나를 따라서 뭐든지 오브젝트로 저장한다.
오브젝트로 선언됐기 때문에 쉽게 수정이 가능하다.

const defaultRequire = global.require;
const myRequire = (request: string) => {...}

global.require = myRequire;

처럼 require을 재정의하거나 require 결과를 재정의하는것이 가능하다.

이런 문제를 해결하기 위해서 드디어 ESM이 등장한다.

ECMAScript Modules (ESM)를 써보자

이미 다들 알고 있듯 사용법이 아주 쉽다.

//선언하는 파일
export function add(a,b){
  return a+b
}

//사용하는 파일
import {add} from './add.js';
console.log(add(1,2));

로 쓸 수 있다.

 

자 아까와 비교해보자

1. 언어표준이다.

ECMA Script 에서 정해놓은 표준방식이다. deno등에서도 사용가능하다.


2. 정적분석이 가능하다.
CommonJS와의 가장 큰 차이는 '키워드로 작동'하고 정적이라는 것이다.

//wrong
if(SOME_CONDITION){
    import React from 'react';
}

//wrong
import Something from Condition ? 'anything1': 'anything2'

//wrong
const myImport = import;
myImport React from 'react';

등과 같이 조건으로 import를 해올 수 없다.(3항 연산을 이용하거나 변수에 할당하는 것도 불가능하다.)

 

3.비동기 모듈이 쉽다.

top level await라는 걸 사용하면 쉽게 정의할 수 있다.
이거 한 줄이면 아까 했던 initialize가 다 해결된다.

const db = await connetToDB();

4. 재정의가 불가능하다.

2번에서 봤듯이 키워드이기 때문에 재정의를 할 수 없다.

 

그래서 어떻게 쓰나요?

아까 TS나 babel을 쓰면 알아서 require로 변환된다는데 그래서.. 어떻게 쓰는걸까?
사실 ESM을 썼을 때 문제가 있다.

 

바로 호환성이다.

 

import로 정적으로 생성한 모듈을 require로 변환하기는 쉽다.
반대로 require로 생성한 모듈을 import로 옮기는 것은 어렵다.
(박서진님은 require를 동기, import를 비동기로 표현하셨다. 나는 동적, 정적으로 이해하는게 더 맞는 것 같다.)

'ky'라는 모듈은 ESM으로 작성 된 모듈인데.. require하면 "require() of ES Moudle" 이라는 에러를 준다.

 

해결방법은 "우리의 패키지를 ESM으로 만드는 것" 이다!!

//package.json
type:'module'

을 추가해주면 된다. 만약 선언안하면 default값으로 CommonJS로 암시적으로 선언된다.


JS는 가까운 package의 설정을 따라가기 때문에 이렇게 불러오면 모든 JS를 ESM방식으로 읽어오게 된다.

특정 양식만 require, 혹은 import를 따로 적용하고 싶다면 확장자를 js가 아닌 cjs, mjs로 바꿔주면 된다.
Yarn berry를 사용하면서 cjs가 무슨 확장자인가 했더니 CommonJS파일의 js확장자가 cjs라고 한다.

(ESM꺼는 mjs라는 게 있다)

여기서 끝이 아니다

1. 확장자 명시

//wrong
import {Component} from './MyComponent'
//correct
import {Component} from './MyComponent.js'

 

require는 아래와 같이 사용할 수 있다.

const {Component} = require('./MyComponent');

 

 

로 사용하면 "./MyComponent","./MyComponent.js","./MyComponent.node","./MyComponent/index.js",...등등 과 같이 모든 확장자를 검색한다.

node.js를 만드신 Ryan Dahl분도 이런 구조로 개발하신걸 후회한다고 한다.

ESM에서는 import시에 확장자가 정확히 명시돼야 한다.

우리의 코드가 지금까지는 TS, Babel등에 의해서 require로 옮겨져서 문제가 없었지만 앞으로 ESM을 쓴다면 확장자를 명시해줘야 한다.


2. TS와 호환성

TS가 원하는 방향은 사실 주석처럼 사용하는 것이다. 타입 주석만 제거해주면 js로 바로 사용가능한 파일을 만들고 싶은게 방향성인 듯 하다.
그래서 사실은

import {add} from './add.ts'


가 아니라 아래와 같이

import {add} from './add.js'

로 써야한다.

 

반대로 ESM에서는 제대로 된 확장자를 써야하기 때문에 둘이 원하는 방향성에 차이가 좀 있는 듯 하다.

여기에 관해서는 아직 이슈들이 많다고 한다. 하지만 해결해야 할 문제임에는 틀림이 없다.

 

3. subpath import 문제

만약 nextjs안에 app이라는 모듈 하나만 가지고 오고 싶을 때

import ('next/app')

을 사용하면 오류를 준다. 왜냐? 확장자가 없으니까!

아래와 같이 해결할 수 있긴 하다.

import ('next/app.js')

근데 이렇게 쓰는게 라이브러리 개발자의 의도일까? 확장자나 파일까지 알아야 하는 것보다는 그냥 app만 불러다 쓰기를 원할 것이다.

//package.json

"exports":{
    "./app":{
        "import": './app.js'
    }
}

로 사용하면 subpath를 직접 지정해줘서 해결해 줄 수 있다.

 

4. require의 동작을 바꾸는 라이브러리 들

Jest, TSnode, YarnBerry등이 해당된다.
.ts file을 require하면 알아서 js로 변환 후 가져오거나,
yarn berry의 pnp도 .cjs에 정의된 경로로 .zip파일(해당 패키지 모듈 node_modules가 아닌 압축파일)

이걸 대응하기 위해서 Loaders API를 통해서 해결하고 있다고는 하는데 Stable하지 않단다.

 

결론

package.json에 모듈을 선언해주고, 확장자를 정확히 써주자.
ESM과 CommonJS는 여기서는 간단히 썼지만 역사가 좀 길다.
한번쯤 알아보면 좋기 때문에 찾아보길 권장한다.