의존성 관리


자바스크립트는 서버 통신 없이 사용자 입력값의 유효성을 빠르게 확인하기 위해 만들어졌다. 초기의 자바스크립트는 간단한 작업을 위해 만들어졌지만, 현재 자바스크립트는 중요한 웹 기술 중 하나로 빠르게 발전하고 있다. 자바스크립트로 구현할 수 있는 기능이 많아질수록 코드는 더 늘어났고, 더 복잡해졌다. 점점 불어나는 코드를 기능이나 페이지 단위로 분리하는 것은 자연스러운 변화였지만, 아래 그림과 같이 복잡한 의존 관계를 피할수 없게 되었다.

module_dependency

이 가이드에서는 점점 복잡해지는 자바스크립트 코드의 의존성 관리 방법을 설명한다. 의존성 관리 방법을 설명하기 전에 모듈 단위 개발의 필요성과 자바스크립트 모듈화의 필수 개념인 AMD와 CommonJS 그리고 ES6 Module을 설명하고 이어서 자바스크립트 패키지 관리 도구인 npm을 사용한 의존성 관리 방법을 설명한다. 이미 자바스크립트 모듈에 대한 이해가 있다면 [npm을 통한 외부 모듈 의존성 관리]만 참고하여도 무방하다.

목차

모듈의 필요성

자바스크립트 코드의 복잡도를 낮추기 위해 여러 파일로 나누어 개발하지만, 단순히 여러 파일로 나눈다고 모듈화되지 않는다. 자바스크립트는 파일이 나뉘어도 모두 같은 전역 스코프를 사용하며, 의도치 않게 다른 파일에 영향을 줄 수 있다. 또한, 각 파일은 의존성에 맞게 순서대로 로딩되어야 하므로 파일 간의 의존성을 일일이 확인하기 번거롭다. 하지만 앞으로 설명할 자바스크립트 모듈 방식을 사용하면, 모듈의 독립된 스코프로 전역 스코프의 오염을 막을 뿐만 아니라 모듈 의존성을 코드로 작성할 수 있다. 따라서 복잡한 자바스크립트를 효율적으로 관리하기 위해서는 모듈 단위 개발을 해야 한다. 우선 모듈의 이해를 돕고자 전역 스코프와 모듈 스코프를 가볍게 설명하고 모듈을 알아보자.

전역 스코프

자바스크립트의 스코프는 크게 전역 스코프와 지역 스코프로 나뉜다. 전역 스코프의 전역 변수는 어디서든 접근 가능하고, 파일이 나눠져 있더라도 같은 전역 스코프를 사용한다. 아래의 예에서는 script 태그 내부에 자바스크립트를 작성했다. 이와 같이 작성하면 함수 내부에 선언한 변수들만 지역 스코프에 등록되고 나머지 함수와 변수들은 모두 전역 스코프에 등록된다.

<html>
  <head></head>
  <body>
    <div>
      <script>
      var myName = 'Kim'; // 전역 스코프
      function hello() {  // 전역 스코프
        // 지역 스코프 (함수 스코프)
      }
      </script>
    </div>
  </body>
</html>

이번에는 분리된 두 개의 자바스크립트 파일을 살펴보자. 아래 예에서 두 개의 자바스크립트 A.js와 B.js를 순서대로 로딩하면 두 파일은 같은 전역 스코프를 갖는다. 따라서 B.js에서 A.js에 선언된 name에 접근할 수 있다. 이처럼 자바스크립트는 파일을 분리해도 모두 같은 전역 스코프 위에서 실행되므로 전역 스코프를 공유한다.

// A.js
var name = 'foo';
function getName() {
  return name;
}
// B.js
function sayHello() {
  alert('Hello ' + name); // Hello foo
}
sayHello();

모듈 스코프

모듈 스코프는 전역과 분리된 모듈만의 독립된 스코프이다. 모듈 스코프에 선언된 변수나 함수는 외부에서 접근할 수 없고, 별도로 export한 변수와 함수만 외부에서 접근할 수 있다. 아래의 예는 앞서 설명한 A.js와 B.js 예제를 ES6 모듈 문법으로 모듈화한 것이다. 참고로 모듈은 script 태그에 module 타입을 설정하여 로드 할 수 있다. (예시 : <script type="module" src="./A.js">)

// A.js
const name = 'foo';

export function getName() {
  return name;
}
// B.js
import {getName} from 'A';

export function sayHello() {
  alert('Hello ' + getName()); // Hello foo
}

모듈로 로드한 A.js 에 정의된 name은 전역 스코프에 포함되지 않고, 모듈 스코프에 포함된다. 따라서 B.js에서 name에 바로 접근할 수 없고 exportgetName함수로만 name을 읽을 수 있다. 이처럼 외부에 노출시킬 함수와 변수를 지정할 수 있기 때문에 모듈 스코프를 사용하면 전역 스코프가 여러 변수로 오염되는 것을 막을 수 있다. 또한 코드 상에서 명시적으로 모듈을 가져오기 때문에 코드로 모듈간 의존성을 파악할 수 있다.

모듈 시스템

모듈 스코프를 사용하기 위해서는 ES6의 모듈을 사용해아 한다. ES6의 모듈이 나오기 전에는 CommonJSAMD에서 제안하는 모듈 정의 방법이 있었다. 이 장에서는 AMD와 CommonJS 그리고 ES6의 모듈에 대해서 알아보자.

AMD

AMD(Asynchronous Module Definition)는 비동기 방식으로 define 함수를 사용하여 모듈의 API와 의존성 관계를 정의한다. CommonJS 보다는 문법이 직관적이지 않다. AMD는 브라우저에서 바로 사용 가능하고 동적 로딩을 지원한다. AMD를 지원하는 대표적인 라이브러리는 RequireJS이다.

define(['jquery', 'lodash'], function($, _) {
  function privateFn() {};
  function publicFn() {};

  return {
    publicFn: publicFn
  };
});

CommonJS

CommonJS는 동기 방식으로 require 함수로 의존성 모듈을 가져오고 module.exports 객체로 모듈의 API를 정의한다. 아래의 예제에서 볼 수 있듯이 AMD 보다 문법이 직관적이다. CommonJS는 자바스크립트를 브라우저 이외 환경에서 사용하고자 만들어졌기 때문에 브라우저에서 바로 사용할 수 없다. 브라우저에서 CommonJS로 작성한 자바스크립트를 실행하기 위해서는 번들러로 변환과정을 거쳐야한다. 하지만 Node는 CommonJS를 사용하기 때문에 Node 기반의 서버나 도구를 개발할 때는 CommonJS을 바로 사용할 수 있다.

var $ = require('jquery');
var _ = require('lodash');

function privateFn() {};
function publicFn() {};

module.exports = {
  publicFn: publicFn
};

UMD

UMD(Universal Module Definition)는 다양한 모듈 방식을 모두 지원하는 일종의 코드 패턴이다. 조건문으로 AMD나 CommonJS를 지원하는지 확인하여 지원하는 모듈 방식의 코드를 사용할 수 있다. UMD을 직접 작성하는 일은 거의 드물며, 대부분 번들러에 의해 생성되는 코드를 사용한다.

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery', 'lodash'], factory);
  } else if (typeof exports === 'object') {
    // Node, CommonJS-like
    module.exports = factory(require('jquery'), require('lodash'));
  } else {
    // Browser globals (root is window)
    root.myModule = factory(root.jQuery, root._);
  }
}(this, function ($, _) {
  function privateFn() {};
  function publicFn() {};

  return {
    publicFn: publicFn
  }
}));

ES6 모듈

ES6에서는 모듈이 표준으로 정의되었고 모듈 정의를 위해 exportimport 키워드를 사용한다.

export / import

  • 모듈을 외부에 공개하기 위해서는 export 키워드를 사용한다. 함수, 변수, 클래스를 개별로 export할 수 있으며, 이러한 방식을 named export라고 한다.
// lib.js
export function sayHello() {
  console.log('Hello');
}
  • named export의 경우 모듈을 import할 때 아래와 같이 import 키워드와 중괄호 { }를 사용한다.
// index.js
import {sayHello} from './lib';

sayHello(); // Hello
  • 모듈을 import할 때 as 키워드로 별칭을 붙일 수 있다. 아래 예는 sayHellohi라는 별칭으로 가져온 것이다.
// index.js
import {sayHello as hi} from './lib';

hi(); // Hello
  • 모듈 전체를 import할 때는 *을 사용한다.
// index.js
import * as lib from './lib';

lib.sayHello(); // Hello
  • 모듈을 default export로 지정할 경우, import할 때 중괄호 { }를 사용하지 않아도 된다.
// lib.js
export default function sayHello() {
  console.log('Hello');
}
// index.js
import sayHello from './lib';

sayHello(); // Hello

정적 import

ES6 모듈은 아래와 같이 동적으로 import 혹은 export할 수 없다. import와 export할 대상을 실행 시점에 변경할 수 없기 때문에 번들링 시에 정적으로 import와 export 구문을 분석하고, 사용하지 않는 코드는 제외하는 최적화를 할 수 있다. 사용하지 않는 코드를 제외시키는 작업은 Webpack과 같은 번들링 도구에서 지원한다. (Webpack의 Tree Shaking)

// 동작하지 않는 코드
if (sum > 10) {
  import * from './big'; // SyntaxError
} else {
  import * from './small'; // SyntaxError
}

참고로 동적으로 import 할 수 있는 import() 구문은 현재 ECMAScript 스펙에 Draft 상태이고 크롬 63 버전 이후부터 사용할 수 있다.

지원 환경

브라우저 환경에서의 사용

ES6 모듈을 지원하는 브라우저는 아래와 같다.

  • 크롬 61 이상
  • 사파리 10.1 이상
  • 파이어폭스 54 이상 (dom.moduleScripts.enabled 설정 필요)
  • 엣지 16 이상 (15는 Experimental Javascript Feature 설정 필요)

ES6 모듈을 지원하지 않는 브라우저를 사용할 때는 트랜스파일러 및 번들러(Webpack, Rollup)를 사용하여, 해당 브라우저에 맞게 코드를 변환해야 한다. 번들러에 대한 자세한 내용은 [FE 가이드] 번들러에서 설명한다.

Node

Node에서는 CommonJS로 모듈을 지원하지만, 앞으로는 ES6 모듈도 도입할 예정이다. 따라서 기존에 사용 중인 CommonJS과 ES6 모듈을 구분하는 방법으로 .mjs 확장자 사용을 논의 중이다.

npm을 통한 외부 패키지 의존성 관리

만약 script 태그로 자바스크립트 파일들을 로드한다면, 필요한 패키지를 의존성에 맞게 일일이 나열해야 하고 각 패키지의 버전을 알맞게 관리해야 한다. 하지만 npm을 사용하면 이 모든 것들을 package.json으로 관리할 수 있다.

npm은 자바스크립트 패키지(모듈) 저장소이다. 누구나 npm에 자신이 만든 패키지를 공개할 수 있는데, 이때 패키지의 정보는 package.json라는 설정 파일에 기입해야 한다. package.json에는 패키지 이름과 버전 등의 기본적인 정보뿐만 아니라 해당 패키지의 의존성까지 기입해야 한다. 따라서 package.json을 통해서 패키지의 의존성을 확인할 수 있다. 또한 npm은 커맨드 라인 인터페이스(CLI)를 제공한다. CLI는 Node.js 설치할 때 자동으로 설치되고, npm 명령어로 패키지를 설치/삭제/업데이트 할 수 있다.

참고로 npm 이외에도 yarn이라는 패키지 매니저가 있다. yarn은 npm의 저장소를 사용하며 npm의 단점을 보완하고자 만들어졌다. yarn이 패키지를 관리하는 내부동작은 npm과 다르겠지만, package.json을 기준으로 의존성을 관리하는 것은 동일하다. 따라서 npm으로 만든 패키지에서 yarn을 사용할 수 있다.

npm CLI 명령

  • npm init : package.json을 생성한다.
  • npm install : package.json에 명시된 의존성 패키지들을 모두 설치한다.
  • npm install [패키지명] : 해당 패키지를 설치 후 package.json의 dependencies에 추가한다.
  • npm install [패키지명] -g : 해당 패키지를 전역으로 설치한다.
  • npm install [패키지명] --save-dev : 해당 패키지를 설치 후 package.json의 devDependencies에 의존성을 추가한다.
  • npm update : package.json의 dependenciesdevDependencies 패키지들을 모두 업데이트 후 package.json에 버전 정보를 갱신한다.
  • npm update [패키지명] : 해당 패키지를 업데이트 후 package.json에 버전 정보를 갱신한다.
  • npm update [패키지명] --no-save : 해당 패키지를 업데이트만 하고 package.json에 버전 정보를 갱신하지 않는다.
  • npm prune : package.json에 명시되지 않은 패키지를 모두 제거한다.

전역 설치 vs 지역 설치

패키지의 전역 설치와 지역 설치는 어떻게 다른지 알아보자.

  • 전역 설치

    • npm install 시에 -g 옵션을 지정하여 설치한다.
    • 전역으로 설치하는 패키지는 /usr/local/lib/node_modules 혹은 /usr/local/bin에 설치된다. (윈도우의 경우 c:\Users\%USERNAME%\AppData\Roaming\npm\node_modules 에 설치한다.)
    • 패키지를 여러 프로젝트에서 공통으로 사용할 경우에는 전역으로 설치하여 사용하는 것이 좋다.
  • 지역 설치

    • npm install 시에 별도의 옵션을 지정하지 않으면 지역으로 설치한다.
    • 지역으로 설치하는 패키지는 프로젝트 루트의 node_modules 폴더에 설치된다.
    • 지역으로 설치된 패키지는 해당 프로젝트에서만 사용할 수 있다.

package.json

패키지에 관련된 모든 정보를 저장하는 설정 파일로 아래와 같은 정보를 갖는다.

  • name, version, description : 패키지의 이름, 버전, 설명을 기입한다.
  • script : node_modules를 상대경로로 사용하는 간단한 스크립트를 등록한다. 예를 들어 빌드나 테스트를 실행하는 스크립트를 등록한다.
  • main : 패키지의 시작점이 되는 파일로, 해당 파일을 시작으로 의존성 패키지를 확인해 나아간다.
  • dependencies

    • 이 패키지를 실행하기 위해 필요한 의존성을 정의한다.
    • 해당 패키지가 동작하는 데 필요한 패키지이므로 배포나 번들 시에 포함된다.
    • npm install 시 의존성 있는 하위 패키지의 dependencies까지 모두 설치한다.
  • devDependencies

    • 이 패키지를 개발할 때 필요한 의존성을 정의한다.
    • 개발 단계에서만 필요한 패키지이므로 배포나 번들 시에 포함되지 않는다.

package.json 예시 (tui-tree의 package.json)

{
    "name": "tui-tree",
    "version": "3.5.0",
    "main": "dist/tui-tree",
    "scripts": {
        "test": "karma start --no-single-run",
        "test:ne": "KARMA_SERVER=ne karma start",
        "bundle": "webpack && webpack -p",
        "serve": "webpack-dev-server --inline --hot -d",
        "serve:ie8": "webpack-dev-server -d",
        "cpy-dist2doc": "mkdir -p doc/dist && cp -f -r dist doc",
        "doc": "jsdoc -c jsdoc.conf.json && npm run cpy-dist2doc"
    },
    "description": "TOAST UI Component: Tree",
    "repository": "https://github.com/nhn/tui.tree.git",
    "keywords": [
        "nhn",
        "tui",
        "component",
        "tree",
        "nhn cloud"
    ],
    "author": "NHN Cloud FE Development Lab <dl_javascript@nhn.com>",
    "license": "MIT",
    "devDependencies": {
        "css-loader": "^0.26.1",
        "eslint": "^4.5.0",
        "eslint-config-tui": "^1.0.1",
        "eslint-loader": "^1.6.1",
        "extract-text-webpack-plugin": "^1.0.1",
        "file-loader": "^0.11.2",
        "istanbul-instrumenter-loader": "^1.0.0",
        "jasmine-ajax": "^3.2.0",
        "jasmine-core": "^2.3.4",
        "jasmine-jquery": "^2.0.5",
        "jsdoc": "^3.5.4",
        "karma": "1.3.0",
        "karma-chrome-launcher": "^2.0.0",
        "karma-coverage": "^1.1.1",
        "karma-es5-shim": "^0.0.4",
        "karma-firefox-launcher": "^1.0.0",
        "karma-jasmine": "^1.0.2",
        "karma-jquery": "^0.2.2",
        "karma-junit-reporter": "^1.2.0",
        "karma-safari-launcher": "^1.0.0",
        "karma-sourcemap-loader": "^0.3.7",
        "karma-webdriver-launcher": "git+https://github.com/nhn/karma-webdriver-launcher.git#v1.1.0",
        "karma-webpack": "^1.8.0",
        "safe-umd-webpack-plugin": "0.0.2",
        "style-loader": "^0.13.1",
        "tui-jsdoc-template": "^1.1.0",
        "url-loader": "^0.5.7",
        "webpack": "^1.13.3",
        "webpack-dev-server": "^1.11.0"
    },
    "dependencies": {
        "tui-code-snippet": "^1.3.0",
        "tui-context-menu": "^2.0.0"
    }
}

package-lock.json (v5 ~)

같은 package.json으로 설치하더라도 설치 시점마다 nodemodules에 설치되는 패키지는 완벽히 같지 않을 수 있다. 왜냐하면, 설치 시점에서 의존 패키지가 업데이트되었을 수도 있기 때문이다. 완벽히 같은 nodemodules를 설치하기 위해서 npm 5부터 package-lock.json이 생겨났다.

npm install 시에 package-lock.json은 자동으로 생성되고 현재 설치된 패키지들의 버전과 의존성 관계를 모두 저장한다. 따라서 사용자가 개발 환경 그대로의 의존성 있는 패키지를 설치하여 사용할 수 있다. 이를 위해서는 사용하는 버전 관리 시스템에 package-lock.json을 함께 포함해야 한다.

package-lock.json 예시는 아래와 같다.

"jquery": {
    "version": "1.11.0",
    "resolved": "https://registry.npmjs.org/jquery/-/jquery-1.11.0.tgz",
    "integrity": "sha1-xnzu4ZtANlDWgq3POdXJAJgU2Uk=",
    "dev": true
}

의존성 탐색 시 우선순위

이번에는 의존성 패키지가 설치되는 위치와 패키지를 탐색하는 우선순서에 대해 구체적으로 설명한다.

node_modules 디렉터리

  • npm 을 통해 설치된 파일은 node_modules 디렉터리 내에 저장된다.
  • npm v3 이전까지는 하위 의존성을 무조건 하위 디렉터리에 저장한다.

    • node_modules/a/node_modules/b/node_modules/c
  • npm v3 부터 하위 의존성을 최대한 루트 디렉터리에 저장한다.

    • node_modules/a
    • node_modules/b
    • node_modules/c

require('jquery') 시에 파일 탐색 우선순위

  1. require를 한 소스의 폴더에 있는 jquery 파일
  2. require를 한 소스의 폴더에 있는 jquery.js 파일
  3. require를 한 소스의 폴더에 있는 jquery.json 파일
  4. require를 한 소스의 폴더에 있는 jquery.node 파일
  5. 해당 패키지 루트에 있는 node_modules/jquery 디렉터리 확인

    1. package.jsonmain에 정의된 파일
    2. index.js
    3. index.json
    4. index.node
  6. 해당 패키지의 상위 패키지 에서 node_modules/jquery 디렉터리 확인

의존성 버전 표기

npm은 semver라는 Versioning 규칙을 따른다. 따라서 package.json에서 의존성 패키지의 버전을 기입할 때나 새로운 버전의 패키지를 출시할 때의 버전은 semver 규칙을 따라야 한다. semver는 패키지의 버전으로 하위 호환성을 보장하는 지를 알려주기 때문에 패키지 의존성 관리에 도움을 준다.

Semantic Versioning은 MAJOR, MINOR, PATCH 버전으로 이루어져 있고 버전 표기는 MAJOR.MINOR.PATCH와 같이 작성한다. 각 버전에 대한 설명은 아래와 같다.

  • MAJOR 버전 : 하위 호환성을 보장하지 않는 API 변경이 발생하면 MAJOR 버전을 변경한다.
  • MINOR 버전 : 하위 호환성을 보장하는 API 및 기능이 추가되면 MINOR 버전을 변경한다.
  • PATCH 버전 : 하위 호환성을 보장하면서 버그가 수정된 것이면 PATCH 버전을 변경한다.

Tilde (~) 범위

  • MINOR 버전이 명시된 경우 PATCH 변경만 허용하고, MINOR 버전이 명시되지 않으면 MINOR 변경까지도 허용한다.

    • ~1.2.3 : >= 1.2.3 < 1.3.0
    • ~1.2 : >= 1.2.0 < 1.3.0 : 1.2.x
    • ~1 : >= 1.0.0 < 2.0.0 : 1.x

Caret (^) 범위

  • 하위 호환성이 보장되는 업데이트를 진행한다.

    • ^1.2.3 : >= 1.2.3 < 2.0.0
    • ^1.2 : >= 1.2.0 < 2.0.0
    • ^1 : >= 1.0.0 < 2.0.0
    • ^0.1.2 : >= 0.1.2 < 0.2.0 (예외: 버전이 1.0.0 미만인 경우 API 변경이 수시로 일어날 수 있으므로 Tilde처럼 동작한다.)

그 외

  • latest : 항상 최신 버전을 적용하는 것이다.
  • 임의의 숫자를 위해 x, X, * 사용할 수 있다.
  • 부등호, 등호를 사용 가능하다.

맺음말

자바스크립트의 모듈과 의존성 관리 방법을 알아보았다. 자바스크립트는 단순히 파일만 나눈다고 모듈화 되는 것이 아니기 때문에 모듈 단위의 개발로 독립된 모듈을 만들고, 모듈 간의 의존성 관리도 효율적으로 할 수 있다. 또한, 자바스크립트 패키지 관리 도구인 npm을 사용하면 의존성 있는 모듈을 체계적으로 관리할 수 있다. 모듈 단위 개발과 npm을 사용하여 자바스크립트 코드를 효율적으로 관리해보자.


이 문서는 NHN Cloud의 FE개발랩에서 작성하고 관리하는 공식 웹 프론트 개발 가이드이다. 가이드 적용 관련 문의나 문서의 오류, 개선 제안은 공식 문의 채널(dl_javascript@nhn.com)을 통해 할 수 있다.


Last Modified
2019. 05. 13
FE Development LabBack to list