Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

Contents

Grunt 소개

Grunt는 자바스크립트 기반의 애플리케이션의 빌드와 배포를 자동화 하기 위해서 사용하는 툴이다. Grunt를 이용해서 minification, compilation, unit testing, linting 등의 작업을 수행 할 수 있다. C/C++ 언어에서 사용하는 Makefile과 비슷한 역할을 하는 Gruntfile의 설정 내용을 읽어서, 빌드작업을 수행한다.

자바스크립트와 JSON을 이용해서 Gruntfile을 만들 수 있기 때문에, 노드(node)를 경험해본 적이 있다면 쉽게 빌드룰을 만들 수 있다.

다루는 이유

node 기반 프로젝트를 관리해야 할 일이 생겼다. 이 노드 애플리케이션은 배포 목적에 따라서, 서로 다른 형태로 빌드가 되야 한다. 예컨데,
  • 공개 소프트웨어로 배포 할 경우 : 상용에서 제공하는 기능들을 제외하고 배포해야 한다.
  • 상용 소프트웨어로 배포 할 경우 : 대부분의 기능들을 포함한다. 이 경우 주요 자바스크립트 파일들은 바이너리 코드 형태로 컴파일 해서 배포해야 한다.
  • standalong 인지 server 타입인지에 따라서 빌드가 달라진다.
  • AWS에 배포하는지, 애저에 배포하는지 혹은 (테스트나 개발용도로) PC에 배포하는지에 따라서 빌드가 달라진다.
이들 다양한 환경에 능동적으로 대응 할 수 있는 빌드 환경을 만들기 위해서 Grunt를 사용 하기로 했다. 사용 하기로 한 것 까지는 좋은데, node도 제대로 해본적이 없으니 처음 부터 공부하는 수 밖에 없는 처지가 됐다. 그래 이번 기회에 Grunt를 공부해 보기로 했다.
  • Grunt를 이용해서, minification, compilation, linting 등의 작업을 자동화 한다.
  • 다양한 환경에 간단하게 배포 할 수 있는 빌드 환경을 구축한다.
  • Jenkins와 같은 CI 툴과 연동해서 아름다운 개발 환경을 만들기 위한 아이디어를 만든다.

Grunt 사용

빌드 테스트 환경 및 시나리오

  • 운영체제 : 우분투 리눅스 15.10
  • nodejs : v0.10.25. Grunt를 사용하려면 node가 필요하다.
  • Foundation : Grunt를 테스트 하는데 사용한다. 굳이 돌아가는 node 애플리케이션을 가지고 테스트 할 필요는 없다. 그래서 css와 자바스크립트 파일을 가지고 있는 Foundation css 프레임워크를 가지고 테스트 하기로 했다. minification, complation, concat 같은 핵심 기능을 테스트하기에 충분하다. 파운데이션은 http://foundation.zurb.com/develop/download.html 에서 다운로드 할 수 있다.
foundation이라는 디렉토리를 만들고 여기에 foundation 압축을 풀었다. 파일 구조는 대략 아래와 같다.
--+---+--- index.html
  |   | 
  |   +--- img/ -+- 이미지 파일들
  |   |
  |   +--- js/ --+- foundation : foundation에서 제공하는 자바스크립트 파일
  |   |          |
  |   |          +- vendor : jquery와 같은 외부 자바스크립트 파일
  |   |
  |   +--- css/ --- css 파일들
  |
--+---+--- Build : 빌드 디렉토리 

빌드 결과물은 Build 디렉토리 밑으로 복사한다. Build는 직접 만들었다.

Grunt 설치

Node.js 버전 0.8.0 이상이 필요하다. npm을 이용해서 grunt를 설치한다.
# npm install -g grunt
# npm install -g grunt-cli

기본 사용 법

concat와 uglify를 이용한 예제로 사용방법을 간단히 살펴보고, 응용에 들어간다.

foundation 디렉토리로 이동 한 다음, Gruntfile.js파일을 만든다. concat와 uglify를 하기로 했다.
  • concat : 여러 개의 자바스크립트를 하나로 만든다. CSS 파일에 대해서도 concat를 수행한다. 로딩 시간을 줄일 수 있다.
  • uglify : 코드에 있는 공백과 주석을 제거하고, 변수명과 함수명을 짧은 문자열로 치환 한다. 선언만 하고 사용하지 않는 변수들도 정리해 준다. 성능향상과 난독화를 위해 사용한다.
Grunt는 플러그인 형식으로 기능을 확장할 수 있다. concat, uglify 역시 플러그인으로 지원한다. 먼저 플러그인을 설치하자.
# npm install grunt-contrib-uglify --save-dev
# npm install grunt-contrib-concat --save-dev

Gruntfile.js를 만든다. Grunt는 task 단위로 작동한다. 위 예제는 concat와 uglify 두 개의 task를 사용하고 있는데, 이들 테스크는 플러그인 형식으로 제공이 된다. 플러그인은 grunt.loadNpmTasks 메서드로 먼저 로딩을 해야지만 사용 할 수 있다.

grunt.initConfig에는 task의 설정이 들어간다.
  • concat : "js/foundation" 디렉토리에 있는 모든 js 파일을 연결해서 빌드디렉토리에 복사한다.
  • uglify : 빌드 디렉토리에 있는 foundation.min.js 파일을 읽어서 uglify적용된 파일을 만든다.
로딩한 테스크들을 grunt.registerTask 메서드로 등록 하면 사용 할 수 있다. registerTask는 아래와 같이 구성된다.
grunt.registerTask('Alias', ['Task-1', 'Task-2',...])
  • Alias : 테스크에 대한 별칭을 설정할 수 있다.
  • 실행할 테스크 목록 : 하나 이상의 테스크를 등록 할 수 있다.
터미널에서 grunt의 실행 옵션으로 Alias 혹은 테스크를 입력하면 된다. Alias를 사용하면, Alias가 가리키는 모든 테스트들을 수행한다. 테스크를 입력할 경우, 해당 테스크만 수행한다.
# grunt default
Running "concat:basic" (concat) task
File ../Build/release/js/foundation.min.js created.

Running "uglify:basic" (uglify) task
>> 1 file created.

Done, without errors.
concat:basic과 uglify:basic이 실행된다. grunt에 아무런 인자를 주지 않고 실행 할 경우 "default"를 기본 수행한다.

Alias 대신 개별 테스크를 실행해 보자.
# grunt concat
Running "concat:basic" (concat) task
File ../Build/release/js/foundation.min.js created.

Done, without errors.

제품 별 빌드 설정

나는 애플리케이션을 product와 develop 두 개의 버전을 만들기로 했다. 이들은 서로 다른 빌드 설정을 가질 것이다. 각 태스크가 하나 이상의 설정을 가질 수 있다는 점을 이용해서, product와 develop 두개의 설정을 만들기로 했다.
concat: {
    // 정식 제품을 위한 빌드
    product: {
        src: ['js/foundation/*.js'],
        dest: '<%= dir.target %>js/foundation.min.js'

    },
    // 개발을 위한 빌드
    develop: {
        src: ['js/foundation/foundation.dropdown.js'],
        dest: '<%= dir.target %>js/foundation.min.js'
    }
},
uglify: {
    product: {
       ...
    },
    develop: {
       ...
    }
}

...
grunt.registerTalk('production': ['concat:product', 'uglify:product'])
grunt.registerTalk('develop': ['concat:develop', 'uglify:develop'])

# grunt production        // production을 위한 빌드
# grunt develop           // develop를 위한 빌드 
# grount concat:product

빌드 계획

빌드 계획은 개발과 운영 정책을 어떻게 가져갈지에 따라서 달라진다.
  • Dev, Staging, OP 3개의 단계를 거친다.
    • Dev를 위해서 빌드 할 경우 uglify, concat등은 필요없다. 디버깅 코드들과 각종 라이브러리들을 포함한다.
    • Staging : 배포 전 단계로 uglify, concat를 포함 하며, 운영(OP)와 거의 동일한 빌드룰이 적용된다.
  • Enterprise, Community
    • Enterprise : 비용을 지불한 버전. 모든 기능을 포함한다.
    • Community : 비용을 지불하지 않고 자유롭게 사용할 수 있는 버전이다. Enterprise에서 몇 개 기능이 빠진다.
프로젝트는 git으로 관리를 한다. 하나의 trunk를 가진다. 기능 별 브랜치, 핫픽스 브랜치를 포함하는 관리는 오픈소스에는 적당할 지 모르지만 기업형 제품을 만들기 위해서는 쓸데 없이 복잡하다.

하나의 trunk를 가져가기 위해서는 빌드 마스터가 개입을 해야 하는데, 개발조직을 제어 할 수 있는 조직에서는 크게 문제될게 없다. 이 방식은 OP가 다양해 질 경우 문제가 발생 할 수 있다. 예를들어 차기 버전의 사전 체험을 위한 베타서비스가 OP에 추가된다고 가정해 보자. 이 경우 기능이 추가/삭제 뿐만 아니라 디자인의 변경, 기존 기능의 개선등을 포함하기 때문에 하나의 브랜치 만으로는 운영이 어려워 질 수 있다. 서비스 운영 정책에 따른 설계가 필요한 시점이다.

빌드 파일 관리

수백개의 디렉토리 수백개의 파일을 Gruntfile 안에서 관리 할 수는 없는 노릇이다. 방법을 살펴보자.

먼저 프로파일 디렉토리를 만드는 방법이 있다. Apache에서 모듈을 로딩 할 때 사용하는 방법이다. 각 환경에 맞는 디렉토리를 만들고 필요로 하는 파일들을 링크해서 관리 한다. git을 포함한 대부분의 SCM이 심볼릭 링크를 지원하니 적용에 문제는 없을 거다.
---+-- js --+--- 모든 js 파일들
            |
            +--- Enterprise : 기업 환경 용 파일 link
            |
            +--- Community  : 커뮤니티용 파일 link
직접 링크를 관리 해야하기 때문에, 파일의 양이 많아지면 관리하기 힘들어 질 수 있다는 문제점이 있다.

위 문제는 프로파일 디렉토리에 "빌드에서 제거할 파일들의 링크"를 관리 하는 식으로 해결 할 수 있다. 프로파일 디렉토리에 링크를 추가하기 전까지는 빌드에 포함되므로 관리하기가 쉽다. 하지만 빌드에 추가 할 코드를 관리하는게 직관적이다. 애초에 기능들을 플러그인으로 관리하는 등, 소프트웨어 구조 설계를 잘 잡고 시작하는게 좋다.

빌드 설정 파일을 관리하는 방법도 있다. 이 파일의 내용은 아래와 같을 거다.
# cat build.json
{
  'file': {
    'product': ['file-1', 'file-2', ...],
    'beta': ['file-1', 'file-2', ...]
  }
}
빌드 파일을 읽어서 적용하도록 Gruntfile을 수정했다.
module.exports = function(grunt) {
  var fs=require('fs')
  var config = JSON.parse(fs.readFileSync('build.json', 'utf8'))
  ...
  grunt.initConfig({
    concat: {
      // 정식 제품을 위한 빌드
      product: {
        src: config.file.product 
        dest: ['../Build/js/foundation.min.js']
      },
      ...
    }
  })
}

빌드 후 테스트

개발을 위해서 빌드 할 경우 전체를 빌드하면 많은 시간이 걸릴 것이다. 이 경우 추가/수정된 파일만 업데이트 하면, 빌드와 테스트간의 간격을 줄일 수 있을 것이다. grunt의 watch플러그인을 이용해서 특정디렉토리와 파일에 대한 변경을 모니터링 할 수 있다.

js/foundation 밑에 있는 파일이 변경되면, 빌드 디렉토리로 파일을 복사하도록 Grunt 파일을 수정했다. whatch:js 태스크는 "js/foundation/*.js"를 모니터링한다. 변경 혹은 추가되는 파일이 있으면, copy:update.js 태스크를 수행해서 변경된 파일을 빌드 디렉토리에 복사한다.

Html및 자바스크립트 코드 빌드

product와 develop 각 배포 환경에 따라서 코드가 달라질 수도 있다. 보통 전처리문을 이용해서 처리하는데, grunt는 이를 위한 몇 개의 플러그인을 제공한다. 나는 "preprocess" 플러그인을 이용해서 처리하기로 했다.

테스트를 위한 HTML 코드를 하나 만들었다.
<html>
    <head>
<!-- @if NODE_ENV='product' -->
        <script src="some/production/lib/like/analytics.js">
<!-- @endif -->
    </head>
    <body>
<!-- @ifdef DEBUG -->
    <h1>디버깅 모드</h1>
<!-- @include debug_message.txt -->
<!-- @endif -->
    </body>
</html>

Gruntfile을 수정했다.
preprocess: {
    html: {
        src: 'test.html',
        dest: '<%= localConfig.target %>test.html',
        options: {
            context: {
                NODE_ENV: 'product',
            }
        }
    }
}
....
grunt.loadNpmTasks('grunt-preprocess')
grunt.registerTask('htmlrebuild', ['preprocess:html'])

grunt를 실행한 결과 만들어진 html 문서다.
# grunt htmlrebuild
<html>
    <head>
<script src="some/production/lib/like/analytics.js">
    </head>
    <body>
    </body>
</html>

enterprise 버전과 community 버전의 경우 메뉴 구성이 완전히 다를 수 있다. include를 이용해서 환경 변화에 따른 기능의 분리를 할 수 있다.
<html>
	<head>
<!-- @if NODE_ENV='product' -->
<script src="some/production/lib/like/analytics.js">
<!-- @endif -->

	</head>
	<body>
<!-- @if NODE_ENV='product' -->
<!-- @include product_function.html -->
<!-- @endif -->

<!-- @if NODE_ENV='community' -->
<!-- @include community_function.html -->
<!-- @endif -->
	</body>
</html>

위 html에 맞게 Gruntfile을 수정했다.
preprocess: {
    'html.product': {
        expand: true,
        src: 'test.html',
        dest: '<%= localConfig.target %>',
        options: {
            context: {
                NODE_ENV: 'product',
            }
        }
    },
    'html.community': {
        expand: true,
        src: 'test.html',
        dest: '<%= localConfig.target %>',
        options: {
            context: {
                NODE_ENV: 'community',
            }
        }
    }
}

....
grunt.registerTask('product', ['preprocess:html.product']);
grunt.registerTask('community', ['preprocess:html.community']);

product로 빌드한 결과
# grunt product
# cat Build/release/test.html
<html>
	<head>
<script src="some/production/lib/like/analytics.js">

	</head>
	<body>
<h1> 프러덕트 </h1>
<ul>
	<li>프러덕트 메뉴 - 1</li>
	<li>프러덕트 메뉴 - 2</li>
</ul>
	</body>
</html>

community로 빌드한 결과
<html>
	<head>

	</head>
	<body>

<h1> 커뮤니티 </h1>
<ul>
	<li>커뮤니티 메뉴 - 1</li>
	<li>커뮤니티 메뉴 - 2</li>
</ul>

	</body>
</html>

자바스크립트, C, CSS, Java 코드에도 사용 할 수 있다.
normalFunction();
//@if NODE_ENV
superExpensiveDebugFunction()
//@endif

Gruntfile의 관리

지금까지 만들어진 gruntfile의 모습이다.

이 코드는 잘 작동한다. 하지만 관리해야 하는 코드의 양이 늘어나면, 그에 따라 gruntfile도 복잡해지고 지저분해 질 것이다. 배포 환경이 다양 할 경우 500라인을 넘어가는 gruntfile이 만들어지기도 한다. 코드의 양이 늘어날 때를 대비해서 gruntfile을 단순화 하기로 했다.

grunt.loadNpmTasks 없애기

플러그인의 갯수가 늘어나면, grunt.loadNpmTasks도 그에 따라 늘어난다. load-grunt-tasks 를 이용해서, 불필요한 태스크 로딩 줄을 없앨 수 있다.
# npm install --save-dev load-grunt-tasks

적용 전
....
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadNpmTasks('grunt-contrib-copy');
grunt.loadNpmTasks('grunt-preprocess');

적용 후. 아래 한 줄로 끝.
module.exports = function(grunt) {
    require('load-grunt-tasks')(grunt);
....

task 파일의 분리 관리

Gruntfile이 지저분해 지는 가장 큰 이유는 grunt.initConfig 때문이다. 각 태스크를 다른 파일들로 분리하면, 관리가 편해질 것이다. 이를 위해서 load-grunt-config를 설치했다.
# npm install --save-dev load-grunt-config

grunt 디렉토리를 만들고 여기에 태스크를 두면 된다. concat의 경우 concat.js파일을 만들면 된다.
# cat grunt/concat.js 
module.exports = {
	product: {
		src: ['js/foundation/*.js'],
		dest: '<%= localConfig.target %>js/foundation.min.js'
	},
	develop: {
		src: ['js/foundation/foundation.dropdown.js'],
		dest: '<%= localConfig.target %>js/foundation.min_beta.js'
	}
};

이런 식으로 모든 태스크를 grunt 디렉토리밑으로 이동한다. 정리가 끝난 Gruntfile 이다.
module.exports = function(grunt) {
    var localConfig = {
        target: '../Build/release/'
    };

    require('load-grunt-config')(grunt, {
        data: {
            localConfig: localConfig
        }
    });

    grunt.registerTask('default', ['concat']);
    grunt.registerTask('devwatcher', ['watch:js']);
    grunt.registerTask('product', ['preprocess:html.product']);
    grunt.registerTask('community', ['preprocess:html.community']);
};

Alias 파일 분리

load-grunt-config를 이용하면 alias파일을 분리 할 수도 있다. grunt 디렉토리 밑에 aliases파일을 만들고 여기에 alias를 설정하면 된다. js, json, yaml, coffee등 다양한 포멧을 지원한다.

예제에 있는 alias들을 aliases.js에 등록해보자.
# cat grunt/aliases.js
module.exports = {
  'default': {
    description: 'Concat sample',
    tsks: ['concat']
  },
  'product': {
    description: 'Product version build',
    tasks: ['preprocess:html.product']
  },
  // ....
}

기타 유용한 플러그인 들

  • grunt-newer : 변경된 코드에 대한 로컬 캐시를 유지한다. 변경된 것들에 대해서만 task를 실행 할 수 있다.
  • clean : 필요 없는 코드들을 정리 할 때 사용한다.
  • time-grunt : 각 태스크를 수행하는데 걸린 시간을 출력한다. 빌드 최적화에 유용하게 사용 할 수 있다.