본문 바로가기
프론트엔드

[AMD]RequireJS에 대하여

by ISA(류) 2022. 2. 22.

자바스크립트 모듈 시스템

js 모듈 시스템은 크게 3가지로 나뉜다.  Common JS와 AMD(Asynchronous Module Definition) 그리고 ES2015(ES6)이다. UMD(Universal Module Definition) 라는 것도 존재하긴 하지만 별로 중요하진 않으니 신경 쓸 필요는 없을거 같다.

요즘에는 노드에서는 거의 CommonJS를 많이 쓰고(애초에 브라우저 이외의 곳에서 js를 사용하기 위한 모듈 시스템) 브라우저 즉 프론트엔드의 경우 거의 ES6 모듈 시스템을 많이 사용한다. ES6 모듈 자체를 그대로 사용하기 보다는 webpack과 rollup 같은 번들러를 통해서 많이 사용되는 편이지만 ES6 방식을 주로 사용한다.

 

 

ES6가 나오기전에는 AMD 방식을 통해서 브라우저에서 자바스크립트 모듈 시스템을 관리하였는데 주요 특징을 꼽자면

 

  1. 모듈(자바스크립트)의 비동기 로딩 (html에서 script태그를 통해서 관리하는게 아닌 js로 script태그를 append하여서 로딩함)
  2. 클로저를 통한 전역 관리
  3. CommonJs에 비하여서 간단한 사용법

이런 특징으로 인해서 모듈 시스템을 아예 사용하지 않는 경우가 아니라면 AMD 방식을 많이 사용했고 해당 방식의 대표적이며 아직도 사용되는 곳이 보이는 오픈소스가 RequireJs다.

https://github.com/requirejs/requirejs

 

GitHub - requirejs/requirejs: A file and module loader for JavaScript

A file and module loader for JavaScript. Contribute to requirejs/requirejs development by creating an account on GitHub.

github.com

개인적으로는 웹팩을 통한 모듈 시스템이나, 노드에서는 CommonJs를 통한 모듈 시스템 그리고 http 환경에서 지원하는 type=module(ES6)을 통한 모듈 관리를 주로 사용해왔고, 많이 체험 했었는데, 브라우저에서 스태틱 페이지를 http server 그러니까 간단한 로컬 서버없이 사용하자니 AMD방식의 모듈로더가 필요했다.

 

일단 진행하는 사이드 프로젝트 자체가 바닐라 js라는 제약사항을 자체적으로 걸었다보니 RequireJS를 통한 모듈 관리를 하기보다는 직접 간단하게나마 모듈로더를 개발해보기로 했고, 그러기 위해서 RequireJS를 어느 정도 분석 할 필요가 있었는데 이건 그 과정에서 느낀바를 조금 정리해보는 내용이다.(서론이 너무길었나?)

 

RequireJs를 분석하며

RequireJs

해당 소스는 먼저 해당 Repo의 require.js라는 파일에서 확인 가능하다. 자바스크립트 모듈 시스템이 제대로 자리 잡기 전이라 그런지(애초에 Require JS 자체가 그 모듈 시스템중 하나이다.) 하나의 파일에 2145라인으로 모든 소스가 통합 되어 있다. 솔직히 이점이 해당 오픈소스를 분석하는데 가장 큰 난관이였다.

 

즉시 실행함수(클로저)를 통해서 전역의 오염은 최소화하고 있으며 전역으로 선언되어 있는 것들을 꼽자면 requirejs와 require, 그리고 define 변수가 있다. 해당 변수들은 각각 requirejs설정에 관한 내용과, 모듈로딩을 하는 내용, 모듈에서 해당 모듈을 클로저를 통해서 내보내는 내용이 담겨있다.

 

해당 오픈소스를 cdn방식 삽입한다면 자동으로 requirejs변수와 require,define 함수가 전역 선언 되는 것이다. 이를 통해서 require.js로 config를 설정하고 require로 config로 설정한 모듈을 불러들이고 define함수를 통해서 불러들인 스크립트 파일의 내부에서 해당 모듈의 의존성과 require 할 모듈 entry를 클로저 방식으로 내보낸다.

 

조금 더 쉽게 표현하자면 RequireJS 라는 모듈 시스템을 불러들여서 필요한 변수들을 전역 선언을 하고 그를 통해서 모듈이 될 스크립트파일 내부에 선언한 define 함수를 해당 스크립트를 비동기로 불러 왔을때 실행시켜서 해당 context를 저장하고 require를 통해서 그 내용물들을 불러들여서 쓰는거다. 이런 간단한 방식을 조금 더 디테일하게 오류도 잡고 여러가지 편의를 보면 RequireJS가 나온다.

 

모듈 컨텍스트

 

define

require는 저기서 저장된 context를 통해서 모듈을 구분해서 불러온다. RequireJS는 이 과정에서 클로저로 해당 컨텍스트 기억해서 모듈의 버전이 다른 문제라거나 그런 문제를 해결했다.

실제로도 브라우저 기준으로 해당 모듈의 script태그를 appendChild를 통해서 비동기적으로 로딩했다가 삭제하는 방식으로 모듈을 불러오고 있었고, 이것을 context를 기억하여서 단 한번만 불러오는 것으로 중복 초기화 되는 부분을 해결했더라, 전역 오염의 경우도 define하는 함수가 클로저 방식으로 모듈 entry를 가지고 있고 그것을 queue라고 명명 지어진 내부 자료구조에 저장했다가 require시킬때마다 해당하는 queue 내용 물을 넘겨주는 방식으로 처리하는 것이였지 실제로 불러오는 모듈 내부의 전역 선언된 부분들은 그대로 브라우저 전역변수로 선언되더라.

 

이 부분을 착각해서 엄청 헤매였다. AMD로 전역 오염을 방지한다는데 script 을 appendChild하는 순간 이미 해당 파일의 전역 선언들은 모두 브라우저에 선언되는데 어떻게 방지하는지 알 수 가 없어서 소스코드를 계속 분석하다가 도저히 해당 내용을 찾지 못해서 직접 RequireJS를 삽입하고 모듈을 불러다가 전역오염이 방지 되는지 안되는지를 확인해보니 실제로는 방지가 안되는걸 확인했다. (RequireJS에서 모듈을 불러오는 시점 require함수가 실행되는 시점이다.) 이를 통해서 AMD 방식의 경우 ES6와 달리 모듈 전체를 즉시실행함수로 한번 감싸서 클로저를 통한 전역오염을 방지하거나 하는 추가적인 처리가 필요하다는 사실을 알게되었다. (ES6나 노드의 경우 자동으로 조치가 된다.)여기서 새삼 ES6방식이 편리한 점을 느꼈다.

 

그외의 여러 편리한 기능이나, 브라우저 뿐 아니라 벡엔드 등에서도 그대로 사용 가능한 점 정도 있지만 가장 기본이 되는 부분은 그렇다. 본질적으로 그냥 js를 통해서 script 태그를 dom에 넣어서 load하고 그걸 클로저 방식으로 해당 스크립트 파일에서 실행시킨 함수로 임의의 자료구조에 저장했다가 불러쓰면 그게 브라우저에서의AMD 모듈 시스템이다.

 

개인적으로 만든 모듈로더의 경우 config와 require, define만을 간단히 구현했는데 코드는 아래와 같다.

class ModuleLoader {
    constructor() {
        this._config = {
            baseUrl: '/',
            paths: {},
            module: {}
        };
        this.isLoaded = {};
        this.load = {};
    }
    async require(modules, cb) {
        // 로딩된 모듈 가져와서 cb함수에 args로 넘기기
        const importModules = await this.waitModules(modules);
        await cb(...importModules);
    }
    async define(name, deps, module) { // 의존성 모듈이 없으면 deps가 module이 된다.
        const context = typeof deps === "function" ?
            deps :
            module;
        
        let importModules = [];
        if (typeof deps !== 'function') { // 의존성 모듈이 있을시 로드한다.
            importModules = await this.waitModules(deps);
        }

        // 모듈로드 완료
        this.isLoaded[name] = true;
        this._config.module[name] = context(...importModules);
    }
    config(config) {
        this._config = {
            ...this._config,
            ...config
        };
    }
    onScriptLoad(node, cb) {
        return node.addEventListener('load', (e) => {
            cb();
            this.removeEvent(e.target, 'load', cb);
        });
    }
    removeEvent(node, event, func) {
        node.removeEventListener(event, func);
    }
    async waitModules(modules) {
        const result = [];

        while (modules.length > 0) {
            const moduleName = modules.shift();
            if (!this.load[moduleName]) this.loadScript(moduleName);

            while (!this.isLoaded[moduleName]) { // 각 모듈의 로딩까지 기다리기
                await new Promise((resolve) => setTimeout(resolve, 7));
            };

            // 로딩 된 모듈 삽입
            result.push(this._config.module[moduleName]);
        };

        return result;
    }
    loadScript(moduleName) {
        const head = document.querySelector('head');
        const url = this._config["paths"][moduleName];

        if (!this.load[moduleName]) {
            const script = this.createScript(moduleName, url);

            this.onScriptLoad(script.node, () => {
                script.node.parentNode.removeChild(script.node);
            });

            this.load[moduleName] = true;
            head.appendChild(script.node);
        }
    }
    createScript(id, url) {
        const script = document.createElement('script');
        script.type = 'text/javascript';
        script.async = true;
        script.id = id;
        script.src = `${this._config.baseUrl}${url}`;

        return {
            id: id,
            node: script
        };
    }
}

https://github.com/yoonjonglyu/js-util

 

참고한 자료

https://d2.naver.com/helloworld/12864

 

반응형