require.js를 통한 모듈화 및 의존성 관리 (1)

zumgu_profie

시작하며…

최근 자바스크립트 모듈화 및 의존성관리에 관심이 생기게 되었습니다.

회사에서 맡아서 진행하는 프로젝트가 오래된 서비스이다 보니 아직 까지 모듈화 및 의존성 관리가 제대로 이루어 지고 있지 않고 있어 (html코드에 script 코드를 포함시키는 방식… 순서 꼬이면 난리가 나서 새로운 코드를 작성할때도 일단 다 가져다 쓰고 있습니다…) 코드를 유지보수하는데 점점 어려움을 겪고있어 계속 이런식으로 유지보수를 하는것은 옳은 방법이 아닌것 같다는 생각을 했습니다.

이번에 기존 기능을 angularJS로 포팅하는 작업을 진행할 예정인데, 이번 작업을 시작으로 의존성 관리 방식을 바꾸고 모듈화작업을 함께 진행해보고 싶어 (많이 늦었지만…) 공부를 시작하게 되었습니다.

모듈화 및 의존성 관리 방법으로 대표되는 2가지 방식이 있는데 (commonJS와 AMD…) 프론트앤드쪽 모듈화에서는 AMD 방식이 효율적이라는 의견이 많아 require.js에 대해서 공부를 시작했습니다.

제가 최종적으로 완성해보고 싶은것은 angularJS + require.js를 이용하여 간단한 웹애플리케이션을 만들어 보는것 입니다.

우선 이번 포스팅에서는 아래와 같은 내용을 정리해보려 합니다.

  1. 자바스크립 모듈화 및 의존성관리
  2. require.js를 이용한 모듈화 의존성 관리

자바스크립트 모듈화 및 의존성 관리

프론트앤드의 규모가 커짐에 따라서 모듈화 및 의존성관리가 절대적으로 필요하다는 것이 개인적인 생각입니다. (진짜 절실히 느끼는 중입니다…ㅠ^ㅠ)

첫번째로 의존성관리의 필요성에 대해 제가 겪었던 불편함에 대해서 말씀드리려 합니다.

[자바스크립트 의존성 관리의 필요성]

아래와 같은 코드가 있다고 해봅시다.

// a.js
var introduceMySelf = function (name) {
  console.log("My name is " + name);
};

//b.js
var name = '줌구';
introduceMySelf(name);
// include.html 파일

<script src='a.js'/>
<script src='b.js'/> <!-- a.js 정의된 introduceMySelf 펑션을 호출함. -->

a.js에서 정의된 introduceMySelf라는 펑션을 b.js에서 호출을 해주고 있습니다. 위 코드는 정상적으로 실행될 것이고, 결과로 ‘줌구’(참 좋은 이름인듯 싶습니다…)라는 이름을 console창에 표기할 것입니다.

그렇다면 include.html 파일에서 스크립트 호출순서를 아래와 같이 바꾸면 어떻게 될까요?

// include.html 파일
<script src='b.js'/> <!-- a.js 정의된 introduceMySelf 펑션을 호출함. -->
<script src='a.js'/>

결과는 introduceMySelf라는 펑션을 찾을 수 없다는 오류 메세지를 띄울 것입니다. 이것이 의존성 관리입니다.

위의 예제에서는 a.js와 b.js라는 두개의 스크립트 파일만 존재하기 때문에 그 필요성이 크게 와닿지 않겠지만. 만일 저러한 의존성이 여러개의 스크립트 파일에 섞여있고, 내가 처음부터 짠 코드가 아니라 인수인계를 받은 코드라면… 의존성 관리를 어떻게 해야할지 생각만해도 끔찍합니다.

[자바스크립트 모듈화의 필요성]

두번째로는 모듈화의 필요성입니다.

자바스크립트 코드를 작성할때, 전역변수를 남발해서 사용하게 되면 후에 유지보수를 할때 크나큰 문제를 겪게 됩니다.(다른 개발자가 정의해 놓은 전역변수의 이름과 동일한 이름의 전역변수를 내가 선언했고, 예상치 못한 오류가 발생하면… 주말에 전화올겁니다…)

그렇기 때문에 모듈화는 중요한 문제입니다. 일반적인 모듈화 패턴은 클로저 라는 패턴을 이용합니다.

var closure = (function () {
  var name = 'zumgu'; // private variable

  var that = this;
  var exports = {
      // setter
      setName : function (name) {
          that.name = name;
      },
      //getter
      getName : function () {
         return that.name;
      }
  };

  return exports;
})();

zumgu라는 변수에 접근하기 위해서는 setName(setter), getName(getter) 이라는 노출된 메소드를 통해서 접근해야 합니다.

위와 같은 방식으로 전역변수가 충돌되는 문제를 해결하는 방법이 있습니다.

지금까지 모듈화 및 의존성관리의 필요성에 대해서 말씀드렸는데요. 필요하다면 그렇게 해야하기 때문에! 지금부터 require.js에 대해 설명드리고 require.js를 통해서 모듈화 및 의존성관리를 해보려 합니다.

require.js는 무엇인가?

RequireJS는 JavaScript 파일과 모듈 로더이다.
RequireJS는 브라우저에 최적화되어 있지만 Rhino나 NodeJS등의 환경에서도 사용할 수 있다.
RequireJS 같은 모듈 로더를 사용하면 당신의 코드의 성능과 품질이 좋아질 것입니다.

require.js 공식페이지에서는 require.js 를 위와 같이 설명하고 있습니다.

require.js는 AMD(Asynchronous Module Definition)방식으로 대표되는 모듈화 및 의존성 관리를 돕는 모듈 로더입니다.

그럼 지금부터 당신의 코드의 성능과 품질을 향상 시킬 수 있는 require.js의 사용법에 대해서 설명하겠습니다.

[require.js를 이용한 모듈화 및 의존성 관리]

갑자기 스타크레프트…? 저는 학창시절 테란을 즐겨하던 유저였는데요. 제가 좋아하는 것을 이용해서 require.js를 공부하고 싶었고 제 나름대로 스타크레프트를 이용해서 require.js를 공부했습니다.

그래서 스타크레프트를 활용해서 require.js의 사용법을 설명하고자 합니다. (이해하시는데 도움이 되었으면 좋겠네요…)

starcraft

게임이 시작하면 커맨드센터가 있고 여기서 scv 5마리를 생산해서 4마리는 미네랄을 캐고 1마리는 가스통을 짓는 시나리오를 require.js로 구현해 보았습니다.

전반적이 프로젝트 구조는 아래와 같습니다. (npm을 이용하면 이해가 어려울 것 같아 require.js 라이브러리는 직접 다운로드해서 포함시키는 방식을 택했습니다.)

cf) 깃주소에서 이번에 설명한 프로젝트를 받으실 수 있습니다.

starcraft

lost_temple.html : require.js를 실행하기 위한 파일
require.js : require.js 라이브러리 파일
terran folder : define 모듈
resource folder : define 모듈
start_game.js : 시작파일

먼저 require.js를 시작하기 위해서는 아래와 같은 설정이 필요합니다. lost_temple.html 파일을 보겠습니다.

<!--lost_temple.html-->
<!doctype html>
<html>

<head>

</head>

<body>
    <h1>GG / GL</h1>
    <!--src 속성에 require.js 라이브러리를 추가해줍니다.-->
    <!--data-main 속성에 require.js가 처음 로드할 스크립트 파일을 추가해줍니다.-->
    <script type='text/javascript' data-main="./js/start_game.js" src="./js/require.js"></script>
</body>

</html>

위와 같이 require.js 파일을 로드해주는 부분에 data-main 속성을 설정해서 require.js가 로드된 후 바로 로드해서 실행해줄 JS 파일을 지정해 줄 수 있습니다. 위의 코드에서는 require.js 파일이 로드된 후 바로 js폴더 아래의 start_game.js라는 파일을 불러와서 실행하게 됩니다.

먼저 실행되는 start_game.js를 살펴보기 전에 require.js에서의 모듈정의와 정의한 모듈 코드를 실행하는 방법을 살펴봐야 할 것 같습니다.

require.js에서는 모듈정의는 define메소드를 통해하게 되고 이렇게 모듈을 정의한 코드는 require 메소드를 통해서 사용하게 됩니다. 그럼 좀 더 자세히 sample 코드를 통해 살펴 보겠습니다.

[define을 통한 모듈정의]

이번 샘플에서 제가 정의한 모듈은 크게 4가지 입니다. 위에 프로젝트구조를 보면 resource 폴더와 terran 폴더가 있습니다. resource 폴더에는 미네랄과 가스(스타크레프트에서 사용되는 자원)을 정의 했습니다. 그리고 terran 폴더에는 커멘드센터와 SCV를 정의했는데요.

모듈 정의할때 싱글톤(같은 객체 주소를 계속 참조)으로 정의해야 하는 경우가 있었고 그때그때 새로운 객체를 생성해 주도록 정의해야 하는 경우가 있었습니다.

예를들어 자원(미네랄, 가스)은 계속 같은 객체를 사용할 것이기 때문에 싱글톤으로 모듈의 정의했습니다.

코드를 보시면 아래와 같습니다.

// mineral.js
define(function () {
  // name과 type 변수는 접근할 수 없다.
  var name = '미네랄';
  var type = '자원';
  // exports 한 객체의 getMineral(getter), setMineral(setter) 를 통해 접근할 수 있다.
  var remain = 500;

  var exports = {
    getMineral : function () {
      return remain;
    },
    setMineral : function (mineral) {
      remain = mineral;
    }
  }

  return exports;
});

// gas.js
define(function () {
  // name과 type 변수는 접근할 수 없다.
  var name = '가스';
  var type = '자원';
  // exports 한 객체의 getGas(getter), setGas(setter) 를 통해 접근할 수 있다.
  var remain = 100;

  var exports = {
    getGas : function () {
      return remain;
    },
    setGas : function (gas) {
      remain = gas;
    }
  }

  return exports;
});

require.js 에서 모듈을 정의할때는 위와 같이 define이라는 글로벌 함수 를 사용합니다. 인자로는 함수 하나를 받게 되는데 이 함수가 반환하는 객체가 모듈이 되게 됩니다.(이 모듈은 위에서 설명한 closure 패턴과 같게 동작하기 때문에, exports한 객체만 외부에서 접근이 가능하며 그렇지 않은 것은 접근이 불가능합니다.)

또한 define 함수는 첫번째 인자로 다른 define 모듈을 사용할 수 있는 배열을 받을 수도 있습니다.

// command_center.js
// 첫번째 인자로 사용할 미리 정의된 모듈을 배열에 넣어준다.
// 두번째 인자의 콜백함수의 파라미터로 첫번째 인자에 정의한 모듈을 사용할 이름을 순서대로 넣어준다.
define(['terran/scv', 'mineral'],
      function (SCV, MineralModule) {
  var name = '커맨드센터';
  var type = '건물';

  var exports = {
    makeSCV : function () {
      var mineral = MineralModule.getMineral();
      if (mineral >= 50) {
        console.log('SCV 생산중...');
        console.log('남은 미네랄 : ' + mineral);
        console.log('SCV 생산 완료');
        MineralModule.setMineral(mineral - 50);

        return new SCV('SCV', '유닛');
      }
    }
  };

  return exports;
});

커멘드센터(command_center.js)는 미네랄 모듈과 SCV 모듈을 사용해야 하기 때문에 위와 같이 정의했습니다.

마지막으로 SCV 모듈은 매 SCV 마다 다른 객체를 가져야합니다. 따라서 class 형태로 모듈을 정의했습니다.

// scv.js
define(['mineral', 'gas'],
      function (MineralModule, GasModule) {

  function SCV(name, type) {
    this.name = name;
    this.type = type;
  };

  SCV.prototype.getMineral = function () {
    var mineral = MineralModule.getMineral();
    console.log('미네랄 캐는중...');
    MineralModule.setMineral(mineral + 8);
    console.log('남은 미네랄 : ' + mineral);
  };

  SCV.prototype.getGas = function () {
    var gas = GasModule.getGas();
    console.log('가스 캐는중...');
    GasModule.setGas(gas + 8);
    console.log('남은 가스 : ' + gas);
  };

  SCV.prototype.makeGasStation = function () {
    var mineral = MineralModule.getMineral();
      console.log('가스 스테이션 건설중...');
      MineralModule.setMineral(mineral - 50);
      console.log('남은 미네랄 : ' + mineral);
      console.log('가스 스테이션 건설완료');
  };

  return SCV;
});

[require 함수를 통한 모듈 사용]

자, 그럼 이제 제가 게임을 하기 위해 모듈들을 정의했으니 본격적으로 게임을 시작해야 할 것 같습니다.

startgame

게임을 시작합시다..! (GG / GL)

위의 lost_temple.html 에서 정의했듯이 처음 로드될 js 파일인 start_game.js 파일의 구성을 살펴 보겠습니다.

require.js 에서는 define 함수를 통해서 정의한 모듈들을 사용하기 위해서 require 함수를 사용합니다.

샘플코드를 보겠습니다.

// start_game.js

require.config({
  /*
    baseUrl : js 파일이 있는 기본 경로를 설정한다.
  */
  baseUrl : 'js', // js 라는 폴더를 기본 폴더로 설정

  /*
    paths : baseUrl을 기준으로 탐색하며, 특정 모듈에 대한 경로를 선언한다.
  */
  paths : {
    // mineral : js/resource/mineral.js
    'mineral' : 'resource/mineral',
    // gas : js/reosurce/gas.js
    'gas' : 'resource/gas'
  }
});

/*
  require 메소드의 첫번째 인자는 사용할 디펜던시를 관리하는 배열이다.
  두번째 인자는 디펜던시들이 로드된 뒤 호출될 콜백 함수이다. 콜백함수의 인자로 첫번째 인자로 선언한 디펜던시 모듈들을 사용할 파라미터를 순서대로 정의해준다.
  (첫번째 인자인 배열에 있는 디펜던시들이 로드 된 뒤에 두번째 인자인 콜백함수가 수행된다.)
*/
require(['terran/command_center'] , function (CommandCenter) {
  var scvs = [];

  for (var i = 0 ; i < 5 ; i++) {
    var scv = CommandCenter.makeSCV();
    scvs.push(scv);
  }

  for (var j = 0 ; j < 4 ; j++) {
    scvs[j].getMineral();
  }
  scvs[4].makeGasStation();
});

require 함수의 첫번째 인자로 배열을 받는데 define 함수를 통해 정의한 모듈을 넣어줍니다. 두번째 인자로 콜백함수를 넣어주는데 이는 첫번째 인자의 모듈들이 로드된 후에 실행되는 콜백함수입니다. 콜백함수의 파라미터로는 앞서 정의한 모듈들을 사용할 이름을 순서대로 넣어줍니다.

[환경설정]

require 함수위에 require.config라는 설명드리지 않은 코드가 있습니다.

require.config는 몇가지 설정을 통해서 require.js 를 사용할 사용자의 환경에 더욱 잘 조정해서 맞출 수 있습니다.

// start_game.js

require.config({
  /*
    baseUrl : js 파일이 있는 기본 경로를 설정한다. (설정이 없으면 data-main 속성에 지정된 파일의 경로가 baseUrl이 된다.)
  */
  baseUrl : 'js', // js 라는 폴더를 기본 폴더로 설정

  /*
    paths : baseUrl을 기준으로 탐색하며, 특정 모듈에 대한 경로를 선언한다.
  */
  paths : {
    // mineral : js/resource/mineral.js
    'mineral' : 'resource/mineral',
    // gas : js/reosurce/gas.js
    'gas' : 'resource/gas'
  }
});

주의해야 할 점은 require.js가 로딩된 뒤 정의한 모듈들을 로딩하기 전에 설정해줘야 합니다.

따라서 보통은 sample 코드와 같이 data-main 속성에 정의한 파일 (start_game.js) 의 require 함수 위에 정의합니다.

[Sample Code 동작 순서]

flow 마지막으로 코드실행 순서를 살펴 보려 합니다. 콘솔 창에 보듯이 커맨드센터(command_center.js)에서 scv를 5마리 생산 한고 있습니다.

앞서 말씀 드렸듯이 게임의 시작은 data-main 속성에 정의한 start_game.js 에서 시작되니 해당 코드를 살펴봐야 할 것 같습니다.

// start_game.js
/*
require.config 코드 생략
*/

require(['terran/command_center'] , function (CommandCenter) {
  var scvs = [];

  // scv 다섯마리를 생산합니다.
  for (var i = 0 ; i < 5 ; i++) {
    // 정의한 CommandCenter 모듈의 makeSCV 함수를 호출해서 scv를 리턴 받습니다.
    var scv = CommandCenter.makeSCV();
    // 배열에 생산한 scv를 담고 있습니다.
    scvs.push(scv);
  }

  for (var j = 0 ; j < 4 ; j++) {
    scvs[j].getMineral();
  }
  scvs[4].makeGasStation();
});

코드를 보니 CommandCenter 모듈의 makeSCV 메소드를 통해서 scv를 생산해주고 있습니다. SCV 모듈은 앞서 정의할때, 클래스 형태로 정의했습니다. 예상해보면 makeSCV는 SCV 모듈을 new 해서 리턴해줄 것 같은데… 확인해보겠습니다.

define(['terran/scv', 'mineral'],
      function (SCV, MineralModule) {
  var name = '커맨드센터';
  var type = '건물';

  var exports = {
    makeSCV : function () {
      var mineral = MineralModule.getMineral();
      if (mineral >= 50) {
        console.log('SCV 생산중...');
        console.log('남은 미네랄 : ' + mineral);
        console.log('SCV 생산 완료');
        MineralModule.setMineral(mineral - 50);
        // scv 모듈을 new 해서 리턴해줍니다.
        return new SCV('SCV', '유닛');
      }
    }
  };

  return exports;
});

예상했던데로 입니다! (제가 짠 코드이니 예상은 빗나가지 않을겁니다…)

커맨드 센터 모듈에서는 SCV 모듈과 mineral 모듈을 사용하는데 SCV 모듈은 새로운 객체를 생성하기 위해 사용되었고 미네랄 모듈은 미네랄의 잔량을 줄이기 위해 사용되었습니다.


// start_game.js
/*
require.config 코드 생략
*/
require(['terran/command_center'] , function (CommandCenter) {

  /*
  scv 생산 코드 생략
  */
  // 생산한 4마리의 scv 는 미네랄을 캡니다.
  for (var j = 0 ; j < 4 ; j++) {
    scvs[j].getMineral();
  }
  // 한마리는 가스통을 짓습니다.
  scvs[4].makeGasStation();
});

다음으로는 커맨드센터 모듈을 통해서 생산된 scv 5마리 중 4마리는 미네랄을 채취하고 한마리는 가스통을 짓고 있습니다.

scv 모듈의 해당 메소드의 역할을 보갰습니다.

define(['mineral', 'gas'],
      function (MineralModule, GasModule) {

  function SCV(name, type) {
    this.name = name;
    this.type = type;
  };

  // 미네랄 채취
  SCV.prototype.getMineral = function () {
    var mineral = MineralModule.getMineral();
    console.log('미네랄 캐는중...');
    MineralModule.setMineral(mineral + 8);
    console.log('남은 미네랄 : ' + mineral);
  };

  // 가스채취
  SCV.prototype.getGas = function () {
    var gas = GasModule.getGas();
    console.log('가스 캐는중...');
    GasModule.setGas(gas + 8);
    console.log('남은 가스 : ' + gas);
  };

  // 가스통 건설
  SCV.prototype.makeGasStation = function () {
    var mineral = MineralModule.getMineral();
      console.log('가스 스테이션 건설중...');
      MineralModule.setMineral(mineral - 50);
      console.log('남은 미네랄 : ' + mineral);
      console.log('가스 스테이션 건설완료');
  };

  return SCV;
});

scv 모듈을 보니 class 형태로 정의 되어있고 mineral 모듈을 사용하고 있습니다. getMineral 메소드에서는 미네랄 모듈의 잔량을 늘려주고 있고, makeGasStation 메소드에서는 미네랄 모듈의 잔량을 줄이고 있습니다.

마치며

지금 까지 자바스크립트의 모듈화 및 의존성 관리의 필요성에대해서 살펴보고 샘플코드를 통해서 require.js 를 사용해서 모듈화 및 의존성 관리를 해보았습니다.

너무 작은 규모의 샘플코드여서 그 필요성과 유용성이 적게 와닿은것 같다는 생각이듭니다.

그러나 프로젝트가 커질수록 모듈화와 의존성 관리는 정말정말정말 x100 필수적이라고 생각합니다.

다음 포스팅때는 angularJS 와 requireJS를 이용해서 간단한 프로젝트를 만들어 포스팅해 볼 생각입니다. (주제는 아직 정하지 못했습니다…)

이때는 좀 더 체계적으로 구현을 하여 좀 더 도움이 되는 포스팅이 될 수 있었으면 좋겠습니다.