npm에 모듈을 등록하면서 일어날 수 있는 일들


들어가기에 앞서

TUI 그리드를 npm에 등록하면서 발생한 이슈들을 정리하였으며, 글에서 혼란스러울 수 있는 단어 몇 가지를 아래 정리해 두었으니 숙지하고 넘어가길 바란다.

  • TUI : Toast UI의 약자이며, NHN엔터테인먼트 FE개발랩에서 개발한 UI 제품들의 prefix로 사용된다. (예: TUI 그리드)
  • 모듈(Module) : 자바스크립트에서 독립적이고 재사용이 가능한 코드의 최소 단위를 말한다.
  • 디펜던시(Dependency) : 어떤 모듈에서 종속적으로 사용되는 외부 모듈에 해당한다.
  • 번들링(Bundling) : Webpack 또는 Browserify 등을 사용해 여러 모듈을 묶는 작업을 말한다. 이 과정을 통해서 만들어진 파일을 '번들 파일'이라고 한다.

npm 등록이란 무엇인가?

프론트엔드 개발에서 필요한 자바스크립트 프레임워크 및 라이브러리는 패키지 매니저를 통해 설치하고 사용할 수 있다. 패키지 매니저의 종류로는 bower, npm, yarn 등이 있고, nodejs로 개발하는 환경에서 모듈을 사용하기 위해서는 사용하고자 하는 모듈이 npm에 등록되어 있어야 한다. 더 자세히 말하면 내가 만든 모듈을 다른 모듈에서 사용할 수 있도록 npm 중앙 저장소에 배포(publish)하는 작업에 해당된다.

bower 등록과 다른점이 있는데, github 저장소를 참조하는 것이 아니라 내 로컬의 파일들이 그대로 배포된다는 것이다. 배포되는 순간 npm 중앙 저장소에 태그가 생성되므로, 모듈이 수정된 경우에는 반드시 버전 업데이트하여 재배포해야 한다. (이 내용은 태그 이슈 관련 목차에서 다시 다루겠다)

npm 등록 방법

npm 등록 방법은 간단하다. 단, nodejs가 설치되어 있어야 한다.

  1. npm에 등록하려는 모듈의 작업 폴더로 이동한다.
  2. npm 등록을 위해 계정을 추가하고, 이미 있는 경우에는 로그인한다.
$ npm adduser
$ npm login
  1. package.json 파일에 옵션을 설정한다. (아래는 npm 등록을 위한 필수 옵션이다)
{
    "name": "my-app",
    "version": "1.0.0",
    "main": "index",
    ...
}
  1. (선택) .npmignore 파일을 생성하고 npm에 배포하지 않으려는 파일을 추가한다. .npmignore 파일이 없으면 .gitignore 파일이 대신한다.
# directory
node_modules
src
lib
...
  1. npm 저장소에 배포한다.
$ npm publish
  1. 정상적으로 등록되었는지 확인한다.
  2. npmjs 확인 : https://www.npmjs.com/package/<package-name>
  3. 다른 모듈에서 설치 확인 :
$ npm install <package-name>
$ npm install <package-name>@<version>
$ npm install <package-name>@<tag>

이처럼 npm 등록은 명령어 몇 줄만으로 아주 간단히 해결되지만, 이미 만들어진 모듈의 특성에 따라 변수가 발생할 수 있다. 이제부터 npm 등록 작업 중 발생할 수 있는 이슈들에 대해서 이야기할 것이며, TUI 그리드 및 관련 TUI 컴포넌트들을 예로 설명하고자 한다. 또한 모듈에 대한 예제 코드들은 Webpack 1을 사용한 개발 환경 기준으로 작성된 점에 유의하라.

npm 등록 이슈 1라운드 : 번들링

이전 단락에서 package.json 파일에 추가한 옵션들을 기억하는가? 한 번이라도 package.json 파일을 생성하고 수정해본 사람이라면 main 옵션에 궁금증을 가지게 될 것이다. npm 문서에 따르면 main 옵션은 내 프로그램에서 시작점이 되는 모듈의 ID 라고 설명한다. 예를 들어 어떤 모듈의 패키지명이 'my-app'이고, 이 모듈을 다른 모듈에서 사용한다고 하면 require('my-app') 형태로 가져오게 된다. 이 때 require 함수가 반환하는 값은 exports 객체여야 하며, 이 객체를 반환하면서 모듈의 시작점이 되는 파일을 main 옵션 값으로 설정하게 된다.

다음은 사용 예이다.

// my-app/src/index.js
var app = {
  setName: function() {
    // ...
  },
  getName: function() {
    // ...
  }
};

module.exports = app;
// my-app/package.json
{
    "name": "my-app",
    "version": "1.0.0",
    "main": "src/index",
     ...
}
// other-app/src/main.js
var myApp = require("my-app");
var appName = myApp.getName();

console.log(appName);

번들 상태로 제공할 것인가 vs 말 것인가?

lodash처럼 npm에 등록된 모듈은 보통 번들되지 않은 상태로 제공된다. 필요에 따라 모듈은 CSS 또는 HTML 관련 로더를 포함해야 될 때가 있는데 이 경우 번들링 과정에서 문제가 발생한다. 예를 들어 'my-app' 모듈에서 템플릿 처리를 위해 handlebars를 사용한다면, 'my-app' 모듈을 사용하는 다른 모듈에서도 사용된 템플릿 엔진을 처리할 수 있어야 한다.  즉, 'my-app' 모듈에서 필요한 개발 디펜던시 모듈들을 'my-app'을 사용하는 다른 모듈에서도 가지고 있어야 한다.

// my-app/src/index.js
var tmpl = require("template/layout.hbs");
var app = {
  render: function() {
    this._element = tmpl();
  }
};

module.exports = app;
// other-app/src/main.js
var myApp = require("my-app");

myApp.render(); // executed error when this module is bunding!

문제를 해결할 수 있는 아주 단순한 방법으로는, 'my-app'을 사용하는 모듈에도 번들링 시 handlebars를 처리하는 플러그인을 추가하면 된다.

// other-app/package.json
{
    "name": "other-app",
    "devDependencies": {
        "handlebars": "4.0.6",
        "handlebars-loader": "^1.4.0",
         ...
    },
    ...
}

하지만 이게 최선의 방법일까? 이 방식대로라면 'my-app'을 사용해 개발을 할 때마다 개발에 필요한 디펜던시들을 외우고 있어야 될 것이다. 'my-app'에서 다른 템플릿 엔진으로 변경하는 일이라도 발생하면 모든 의존 모듈의 package.json 파일을 수정해야 한다. 그리고 번들되지 않은 상태의 모듈들을 모두 들고 있을 경우, 최종 번들링을 할 때 성능이 떨어질 수 있다는 단점이 있다.

해결책으로 번들 상태의 모듈 을 제공하는 것이다. jQuerypackage.json을 보면 main 옵션 값으로 소스 코드의 엔트리 파일이 아닌 번들 파일이 설정되어 있다.

대부분의 TUI 제품들은 CSS 로더를 포함하고 있어 같은 이슈가 발생하므로 번들 상태로 제공한다.

// tui-grid/package.json
{
    "main": "dist/grid" // set bundle file
    ...
}

require 함수 사용하기

첫 번째 이슈가 해결되었으니 TUI 그리드가 npm에 등록되었다는 가정하에 다른 모듈에서 사용이 가능한지 테스트 해보기로 했다. 그 전에 기존에 TUI 그리드를 사용하던 방식을 이해하고 넘어가야 한다. 현재 TUI 제품군들은 전역 범위(Global Scope)의 네임스페이스에 정의되고, 해당 네임스페이스로 접근해 사용하도록 디자인되어 있다. 다음은 'my-app' 모듈에서 TUI 그리드를 사용하는 예제 코드이며, 네임스페이스 형태로 가져와 사용하게 된다.

// my-app/src/main.js
var gridInstance = new tui.Grid(options);

gridInstance.getRows();
...

사실상 모듈 안에서 네임스페이스 사용은 권장하지 않는다. 이제 정말 TUI 그리드를 모듈답게 사용할 수 있도록 코드를 수정해 보았다. npm install 명령어를 통해 TUI 그리드를 설치하고 require 함수로 호출하면, 네임스페이스를 사용했을 때와 같이 잘 동작할까?

// my-app/src/main.js
var Grid = require('tui-grid');
var gridInstance = new Grid(options);

gridInstance.getRows();
...

질문에 대한 대답은 '아니오'다. 위에서도 잠깐 언급했듯이 require 함수를 호출했을 때 exports 모듈 객체가 반환되어야하나 현재 상황에서는 값이 비어있을 것이다. 두 번째 이슈가 발생했다. 이미 눈치챘겠지만 package.json에서 설정한 번들 파일에 문제가 있다. TUI 그리드 번들 파일에서 모듈이 실행되는 시작점으로 이동해보자. 네임스페이스만 정의되어 있을 뿐, exports 모듈 객체로 반환되는 코드는 그 어디에서도 찾을 수 없다.

// tui-grid/dist/grid.js
tui = window.tui = tui || {};

tui.Grid = View.extend({
  // ...
});

문제를 해결하기 위해 tui.Grid 네임스페이스에 그리드 객체를 정의하는 코드를 제거하고, exports 객체로 반환하도록 로직을 수정하였다. 이 방법도 정답은 아니다. 기존에 브라우저에서 네임스페이스로 접근해 사용하던 경우를 처리할 수 없기 때문이다. 결국 모듈을 네임스페이스로 가져올지 아니면 require 함수로 로드할지 상황에 따라 결정할 수 있는 방법이 필요하다.

이는 모듈을 정의할 때 UMD(Universal Module Definition)패턴을 사용하면 처리가 가능하다. 아래 코드는 UMD 패턴의 기본 구조이며, IIFE가 실행되는 시점에서 모듈이 노출될 형태를 결정하게 된다.

(function(root, factory) {
  if (typeof define === "function" && define.amd) {
    // AMD
    define([], factory);
  } else if (typeof module === "object" && module.exports) {
    // CommonJS
    module.exports = factory();
  } else {
    // Globals (root is window)
    root.MyApp = factory();
  }
})(this, function() {
  function MyApp() {}
  return MyApp;
});

tui.Grid 네임스페이스로 그리드 모듈을 정의하던 코드를 UMD 패턴으로 변경해보자. 일련의 과정들은 UMD 패턴 소스를 복붙 하는 대신 Webpack 옵션 설정으로 간단하게 처리할 수 있다. 먼저 libraryTarget 옵션을 사용하면, 모듈 노출 방식을 결정하고 번들링 시점에서 관련 코드들을 추가해준다. 그리고 UMD 패턴에서 사용자가 커스터마이징 가능한 부분이 있는데, 전역 범위에 할당될 변수 또는 네임스페이스명을 설정할 수 있다. library 옵션으로 설정하며, 문자열 대신 배열로 설정하면 네임스페이스로 정의할 수 있다.

// tui-grid/webpack.config.js
module.exports = {
    ...
    output: {
        library: ['tui', 'Grid'],
        libraryTarget: 'umd'
    }
    ...
};

위 옵션들을 설정하고 TUI 그리드를 다시 번들링하면, 번들 파일 상단에 UMD 처리 코드가 추가된 것을 볼 수 있다. Webpack이 처리한 방식에서 특이한 점으로 CommonJS2까지 처리하는 것인데 CommonJS, CommonJS2 이 둘의 차이점은 Webpack 이슈 내용을 참조하길 바란다.

이제 네임스페이스뿐만 아니라 다양한 형태로 TUI 그리드를 가져와 사용할 수 있게 되었다. require 함수를 호출해도 문제가 없다!

// tui-grid/dist/grid.js
(function webpackUniversalModuleDefinition(root, factory) {
  if (typeof exports === "object" && typeof module === "object")
    // CommonJS2
    module.exports = factory();
  else if (typeof define === "function" && define.amd)
    // AMD
    define([], factory);
  else if (typeof exports === "object")
    // CommonJS
    exports["Grid"] = factory();
  // Globals (root is window)
  else (root["tui"] = root["tui"] || {}), (root["tui"]["Grid"] = factory());
})(this, function() {
  // what this module returns is what your entry chunk returns
});

npm 등록 이슈 2라운드 : 디펜던시 처리

지금까지 외부 모듈에서 TUI 그리드를 사용하면서 발생한 문제들을 해결했다. 이번 단락에서는 반대의 입장에서 발생한 문제들을 처리해보고자 한다. TUI 그리드도 다른 모듈들을 사용하는 외부 모듈 이라는 것을 잊지말자. 이번에도 TUI 그리드에 대한 사전 지식이 필요한데, TUI 그리드는 다음과 같은 디펜던시들을 가진다. 이해하기 쉽게 그림으로 표현해 보았으며, 화살표는 어떤 모듈에서 시작해 의존적으로 사용하고 있는 모듈 을 가리킨다.

dependency

npm에 등록된 디펜던시 사용하기

npm 등록 작업을 거치기 전까지, 일부 디펜던시 모듈들은 TUI 그리드 소스 내부에서 네임스페이스로 사용되었다. 이유는 간단하다. 모듈이 npm에 등록되어 있지 않았거나, 디펜던시 모듈이 exports 객체를 반환하는 형태가 아니었기 때문이다. 다음 코드는 TUI 그리드 내부에서 디펜던시들이 사용된 예이다. underscore를 제외한 나머지 디펜던시들은 모두 네임스페이스로 사용되고 있다. require 함수를 사용하는 형태로 변경해보자.

// tui-grid/src/js/view/factory.js
var _ = require('underscore');
...
var ViewFactory = tui.util.defineClass({ // 'tui.util' is namespace of tui-code-snippet
    ...
    this.$el.focus(); // '$' is namespace of jQuery
    ...
    ...
    _.each(/**/);
    ...
    ...
    if (!tui.component.Datepicker) { /**/ } // 'tui.component.Datepicker' is namespace of tui-date-picker
    ...
});
...

먼저 jQuery를 변경하는 과정에서 버전 이슈가 발생하였다. 기존 TUI 제품군들은 jQuery 디펜던시가 있는 경우 1.8.3 버전부터 사용되었으나, npm에서 해당 버전이 디프리케이트 되어 버전 변경이 필요했다. 결정적으로 UMD 처리는 1.11.0 이상 버전부터 적용되어 있기 때문에 해당 버전으로 업데이트 후 작업을 진행할 수 있었다.

jquery_deprecated

다음은 npm에 TUI 그리드를 등록한 프로세스와 동일하게 TUI 컴포넌트와 코드 스니펫도 npm에 등록한다. 등록이 완료되면 TUI 그리드에서 정상 동작하도록 디펜던시 관련 코드들을 수정한다. 이제 전역 범위에 접근하지 않으면서 CommonJS 모듈 형태를 갖추게 되었다.

// tui-grid/src/js/view/factory.js
var _ = require('underscore');
var $ = require('jquery');
var snippet = require('tui-code-snippet');
var DatePicker = require('tui-date-picker');
...
var ViewFactory = snippet.defineClass({
    ...
    this.$el.focus();
    ...
    ...
    _.each(/**/);
    ...
    ...
    if (!DatePicker) { /**/ }
    ...
});

디펜던시 코드를 포함할 것인가 vs 말 것인가?

require 함수로 디펜던시 모듈을 가져올 때 주의할 사항으로 디펜던시 코드들이 번들 파일에 포함될 수 있다는 점이다. Webpack에서는 기본적으로 디펜던시 모듈 코드들을 모두 포함한 상태로 번들 파일을 생성하게 된다. 웹서비스 개발자의 입장이라면, 전체 코드를 1개 번들 파일로 묶어 사용할 수 있으므로 오히려 이점으로 느껴질 것이다. 하지만 TUI와 같이 동일한 디펜던시를 가지는 모듈을 여러 개 사용하는 경우에는 이야기가 달라진다.

앞서 보았던 그림에서 TUI 그리드 및 컴포넌트들은 모두 코드 스니펫에 의존적이다. 각 번들 파일에 코드 스니펫 코드를 포함하게 된다면 어마어마한 양의 중복 코드가 발생할 것이다. 결국 번들링 과정에서 디펜던시 모듈에 대한 코드들을 제외시켜야만 한다. 이는 Webpack의 externals 옵션으로 처리가 가능하며 libraryTarget 옵션과 같이 사용된다. externals에 명시된 모듈은 번들링 시점에서 번들 파일에 코드가 포함되지 않도록 변환되며, 이 때 libraryTarget 옵션에 설정된 모듈 로드 방식으로 변환된다.

다음과 같이 TUI 그리드 번들 파일에서 코드 스니펫을 제외하기 위해 옵션을 추가하고 번들링해 보았다.

// tui-grid/webpack.config.js
module.exports = {
    ...
    libraryTarget: 'umd',
    externals: 'tui-code-snippet'
    ...
};

번들 파일을 확인해보자. TUI 그리드는 UMD 패턴으로 노출되기 때문에, 디펜던시를 제외하는 로직도 CommonJS, AMD, 전역 변수로 조건 분기되어 추가된 것을 확인할 수 있다. 예를 들어 CommonJS 방식에서는 제외될 디펜던시 모듈이 require("tui-code-snippet")로 변환되며 factory 함수에 파라미터로 전달된다. 여기서 한 가지 이상한 부분이 있다. 전역 변수를 처리하는 조건에서, 파라미터로 사용된 root 객체의 프로퍼티명이 어딘가 어색하다. 이 상태라면 TUI 그리드는 전역의 window.tui-code-snippet에 정의되어 있어야 할 것이다. 문법적으로 맞지 않을 뿐더러, 코드 스니펫은 네임스페이스 tui.util로 사용되고 있었기 때문에 변경이 필요하다.

// tui-grid/dist/grid.js
(function webpackUniversalModuleDefinition(root, factory) {
    if(typeof exports === 'object' && typeof module === 'object') // CommonJS2
        module.exports = factory(require("tui-code-snippet"));
    else if(typeof define === 'function' && define.amd) // AMD
        define(["tui-code-snippet"], factory);
    else if(typeof exports === 'object') // CommonJS
        exports["Grid"] = factory(require("tui-code-snippet"));
    else // Globals (root is window)
        root["tui"] = root["tui"] || {}, root["tui"]["Grid"] = factory(root["tui-code-snippet"]);
})(this, function() {
    ...
});

libraryTarget 옵션이 umd인 경우, externals 옵션에서도 각 로더 형태별로 값을 다르게 설정할 수 있다. 이 때 root 값을 배열로 설정하면 네임스페이스로 처리할 수 있다. (안타깝게도 root만 따로 설정할 방법은 없다)

// tui-grid/webpack.config.js
module.exports = {
    ...
    libraryTarget: 'umd',
    externals: {
        'tui-code-snippet': {
            'commonjs': 'tui-code-snippet',
            'commonjs2': 'tui-code-snippet',
            'amd': 'tui-code-snippet',
            'root': ['tui', 'util']
        }
    }
    ...
};
// tui-grid/dist/grid.js
(function webpackUniversalModuleDefinition(root, factory) { // CommonJS2
    if(typeof exports === 'object' && typeof module === 'object')
        module.exports = factory(require("tui-code-snippet"));
    else if(typeof define === 'function' && define.amd) // AMD
        define(["tui-code-snippet"], factory);
    else if(typeof exports === 'object') // CommonJS
        exports["Grid"] = factory(require("tui-code-snippet"));
    else // Globals (root is window)
        root["tui"] = root["tui"] || {}, root["tui"]["Grid"] = factory(root["tui"]["util"]);
})(this, function() {
    ...
});

safe-umd-webpack-plugin

이제 모든 문제점들이 해결된 것 같다. TUI 그리드에서 코드 스니펫 외 나머지 디펜던시들도 externals 처리를 하고 최종 테스트를 진행하기로 했다. require 함수를 호출해 TUI 그리드를 가져오는 것도 문제가 없다. 마지막으로 <script> 태그를 사용해 브라우저에서 호출 테스트를 하는데 이런.. 사용하지 않는 디펜던시 모듈에 대한 네임스페이스가 없다 는 에러를 뱉어내는 것이 아닌가.

상황은 이러했다. 브라우저에서 TUI 그리드를 실행할 때, TUI 컴포넌트가 필요 없는 경우에도 스크립트 파일을 포함해야만 에러를 막을 수 있었다. 에러가 발생한 지점을 찾아 디버깅을 해보니, 번들 파일에서 externals 옵션을 통해 생성된 코드에 문제가 있었다. TUI 데이트피커 컴포넌트 스크립트 파일이 포함되어 있지 않으면 전역에도 네임스페이스 tui.component.Datepicker가 정의되어 있지 않으므로, 아래와 같이 파라미터로 호출될 때 에러를 발생하게 된다.

// tui-grid/dist/grid.js
(function webpackUniversalModuleDefinition(root, factory) {
    ...
    ...
    // error : 'Datepicker' is undefined
    root["tui"] = root["tui"] || {}, root["tui"]["Grid"] = factory(..., root["tui"]["component"]["Datepicker"], ...);
})
...

multi-depth 네임스페이스에 대한 예외 처리가 필요한데, Webpack으로 해결할 수 없어 FE 개발랩에서 직접 플러그인을 개발해 사용하기로 하였다. (Webpack 이슈 참조)

factory(
  root["tui"] &&
    root["tui"]["component"] &&
    root["tui"]["component"]["Datepicker"]
);

이 Webpack 플러그인이 safe-umd-webpack-plugin이며, 이로써 마지막 문제까지 해결할 수 있었다. multi-depth 네임스페이스 문제가 발생했을 때 꼭 사용해보길 바란다.

npm 등록 이슈 3라운드 : 태깅(tagging)

이번 단락은 번외 이슈 정도로 생각하면 좋을 것 같다. 도입부에서 언급한 내용으로 npm은 bower와 다르게 github 리포지터리를 참조하는 것이 아니라 npm 중앙 저장소에 저장된다고 했다. 그렇다면 npm에서 버전 관리는 어떻게 하는 것일까? 모듈의 package.json 파일에 명시된 version 옵션에 설정된 값을 따르게 되며, npm에 배포될 때 버전 앞에 prefix v가 붙은 형태로 npm 저장소에 태그가 생성된다.

npm version 명령어를 사용하면, package.json 파일에서 version 옵션 값이 업데이트 되면서 현재 변경점까지에 대한 git commit 로그를 생성한다. 또한 git 태그도 자동으로 생성해준다.

npm_version

Webpack, ESLint, React 등 이미 많은 nodejs 모듈들이 위 방식으로 버전 및 태그를 관리하고 있다. TUI 그리드는 브랜치 관리 이슈로 npm version 명령어를 사용하지 않으나, 태그 형태를 통일할 필요가 있어 git 태그 정책을 변경하게 되었다.

tags

마치며

TUI 그리드도 npm으로 받을 수 있게 해주세요!

TUI 그리드를 npm에 등록하는 작업은 이 한마디 요청으로부터 시작되었다. 사실 작업을 진행하기 전까지 아주 간단하고 쉬운 작업일 것이라고 생각했었다. npm 문서에서도 명령어와 옵션 몇 개만으로 등록 과정을 설명하고 있으니 말이다. 어쩌면 아주 간단하고 쉬운 작업은 새 모듈을 만드는 경우에서나 해당될 듯 하다. 이미 만들어진 모듈을 npm에 배포하는 것은 생각 이상으로 고려해야 될 사항들이 많았다. 위에서 설명했던 표면적인 문제점들로 인해 정책이 변경되기도 하고 메이저 버전 업데이트라는 상황까지 맞이해야만 했다. 몇 번의 고비는 있었지만 npm 등록 작업은 bower가 디프리케이트(Deprecated)되어 추후 bower에 등록된 모듈에 대한 지원이 어려워질 수 있다는 점, 그리고 nodejs 환경에서의 개발을 권장하고 TUI 제품군들을 사용할 수 있게 하기 위한 목적 때문에 언젠가는 꼭 진행했어야 할 중요한 일이었다. 이 세계에서 삽질 없이 얻어지는 결과는 없으리라. 부디 이 글이 비슷한 문제로 고민하고 있을 이들에게 도움이 되었으면 한다.

참고 링크