index.html
<!doctype html>
<html lang="en" ng-controller="CommonController">
<head>
<meta charset="utf-8">
<title>My AngularJS App</title>
<link href="css/bootstrap.css" type="text/css" rel="stylesheet"/>
<link href="css/app.css" type="text/css" rel="stylesheet"/>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
<!--
꼭 필요한 필수 CSS는 위와 같이 고정해서 붙이고,
일부 페이지마다 필요한 CSS은 아래와 같이 컨트롤러에서 설정해서 로드한다. (IE8 에서도 정상동작)
http://plnkr.co/edit/KzjIMN
-->
<link ng-repeat="stylesheet in stylesheets" ng-href="{{stylesheet}}" type="text/css" rel="stylesheet" />
</head>
<body>
<div>
<a href="#/view1" class="btn">view1</a>
<a href="#/view2" class="btn">view2</a>
<a href="#/grid" class="btn">grid</a>
<a href="#/admin" class="btn" ng-show="isAdmin">admin</a>
</div>
<hr>
<div ng-view class="well well-small"></div>
<button ng-hide="isAdmin" ng-click="isAdmin=true;">Become admin</button>
<!--
이 data-main 속성에서 requireJS가 처음 로드해야할 JS를 설정한다.
아래와 같이 쓰면, js 폴더 아래에 main.js 파일을 열게 된다.
-->
<script data-main="js/main" src="lib/require/require.js"></script>
</body>
</html>
기본적으로 반드시 필요한 스타일시트는 link 태그로 먼저 입력하지만, 페이지마다 동적으로 필요한 스타일시트는 하단의 ng-repeat과 ng-href 를 활용해서 동적으로 로드될 수 있도록 설계되었습니다. IE8에서도 정상 동작하는 방식입니다.
하단의 require.js 파일을 로드해주는 부분에 data-main 속성을 설정해서 RequireJS가 로드된 후 바로 로드해서 실행해줄 JavaScript 파일을 지정해줄 수 있는데, 위의 예에서는 require.js 파일이 로드된 후에 바로 js 폴더 아래에 main.js 파일을 불러와서 실행하도록 되어 있습니다.
index.html 파일에서는 구체적으로 어떤 라이브러리들이 사용되는지 감추어짐으로써 전체적인 코드의 가독성도 높아지는 것을 볼 수 있습니다.
main.js
/*
user strict 명령은 엄격하게 JavaScript 룰을 적용하라는 의미이다.
일부 브라우저의 경우 use strict 명령을 통해 보다 빠르게 동작하는 경우도 존재하는 것 같다.
잘못된 부분에 대한 검증도 보다 엄격하게 동작한다.
하지만, 일부 라이브러리의 경우 use strict 명령을 사용하면 동작하지 않는 경우도 있으므로 주의해야 한다.
*/
'use strict';
//requireJS 기본 설정 부분
requirejs.config({
/*
baseUrl:
JavaScript 파일이 있는 기본 경로를 설정한다.
만약 data-main 속성이 사용되었다면, 그 경로가 baseUrl이 된다.
data-main 속성은 require.js를 위한 특별한 속성으로 require.js는 스크립트 로딩을 시작하기 위해 이 부분을 체크한다.
*/
baseUrl:'js',
/*
paths:
path는 baseUrl 아래에서 직접적으로 찾을 수 없는 모듈명들을 위해 경로를 매핑해주는 속성이다.
"/"로 시작하거나 "http" 등으로 시작하지 않으면, 기본적으로는 baseUrl에 상대적으로 설정하게 된다.
paths: {
"exam": "aaaa/bbbb"
}
의 형태로 설정한 뒤에, define에서 "exam/module" 로 불러오게 되면, 스크립트 태그에서는 실제로는 src="aaaa/bbbb/module.js" 로 잡을 것이다.
path는 또한 아래와 같이 특정 라이브러리 경로 선언을 위해 사용될 수 있는데, path 매핑 코드는 자동적으로 .js 확장자를 붙여서 모듈명을 매핑한다.
*/
paths:{
//뒤에 js 확장자는 생략한다.
'text': '../lib/require/text', //HTML 데이터를 가져올때 text! 프리픽스를 붙여준다.
'jquery': '../lib/jquery/jquery',
'jquery-ui': '../lib/jquery/jquery-ui-1.10.2.min',
'angular': '../lib/angular/angular',
'library': '../lib'
},
/*
shim:
AMD 형식을 지원하지 않는 라이브러리의 경우 아래와 같이 SHIM을 사용해서 모듈로 불러올 수 있다.
참고 : http://gregfranko.com/blog/require-dot-js-2-dot-0-shim-configuration/
*/
shim:{
'angular':{
deps:['jquery'],
exports:'angular'
},
'jquery-ui': {
deps: ['jquery']
},
'app':{
deps:['angular']
},
'routes':{
deps:['angular']
}
}
});
//requireJS를 활용하여 모듈 로드
requirejs( [
'text', //미리 선언해둔 path, css나 html을 로드하기 위한 requireJS 플러그인
'jquery', //미리 선언해둔 path, jQuery는 AMD를 지원하기 때문에 이렇게 로드해도 jQuery 또는 $로 호출할 수 있다.
'angular', //미리 선언해둔 path
'jquery-ui',
'app', //app.js
'routes' //routes.js
],
//디펜던시 로드뒤 콜백함수
function (text, $, angular) {
//이 함수는 위에 명시된 모든 디펜던시들이 다 로드된 뒤에 호출된다.
//주의해야할 것은, 디펜던시 로드 완료 시점이 페이지가 완전히 로드되기 전 일 수도 있다는 사실이다.
//페이지가 완전히 로드된 뒤에 실행
$(document).ready(function () {
//위의 디펜던시 중 myApp이 포함된 app.js가 로드된 이후에 아래가 수행된다.
//임의로 앵귤러 부트스트래핑을 수행한다.
angular.bootstrap(document, ['myApp']);
});
}
);
require.js 파일이 로드된 뒤 가장 처음 불러오는 파일인 main.js 스크립트는 크게 두 부분으로 나누어져 있습니다.
먼저는 RequireJS 의 환경을 설정하는 부분이고, 설정이 끝난 뒤에는 require 함수를 사용해서 디펜던시를 불러온 뒤 angular module을 부트스트래핑 하게 됩니다. 환경 설정에 대한 부분은 지난 require.js 에 대한 글에 충분하게 설명되어 있습니다.
앞서 흐름에 대해 살펴볼때 이야기했던 것처럼 angular module이 부트스트래핑 되는 것은 나열된 디펜던시들이 모두 로드된 시점이므로 모든 흐름의 끝에 실행됩니다.
나열된 디펜던시 중 ‘text’ 라는 것은 require.js 의 text plug-in 으로 스크립트 파일이 아닌 텍스트 형태의 파일을 동적으로 가져올 수 있도록 해주는 기능을 합니다.
app.js
'use strict';
//requireJS 모듈 선언 - [myApp 앵귤러 모듈]
define([
'angular', //앵귤러 모듈을 사용하기 위해 임포트
'route-config' //registers에 각 프로바이더를 제공하기 위해 임포트
],
/*
이 부분도 주의깊게 살펴봐야한다.
위의 디펜던시들이 모두 로드된 뒤에 아래의 콜백이 실행된다.
디펜던시들이 리턴하는 객체들을 콜백함수의 파라메터로 받게 되는데,
자세히보면 route-config와 같이 snake case로 된 파일명이,
파라메터로 받을 때는 routeConfig와 같이 camel case로 바뀌는 것을 볼 수 있다.
*/
//디펜던시 로드뒤 콜백함수
function (angular, routeConfig) {
//위의 디펜던시를 가져와서 콜백을 수행하게 되는데,
//리턴하는 내용이 실제 사용되는 부분이겠지?
//여기서는 myApp이라는 앵귤러 모듈을 리턴한다.
//모듈 선언
var app = angular.module('myApp', [], function ($provide, $compileProvider, $controllerProvider, $filterProvider) {
//부트스트랩 과정에서만 가져올 수 있는 프로바이더들을 각 registers와 연계될 수 있도록
routeConfig.setProvide($provide); //for services
routeConfig.setCompileProvider($compileProvider); //for directives
routeConfig.setControllerProvider($controllerProvider); //for controllers
routeConfig.setFilterProvider($filterProvider); //for filters
});
//공통 컨트롤러 설정 - 모든 컨트롤러에서 공통적으로 사용하는 부분들 선언
app.controller('CommonController', function($scope) {
//스타일시트 업데이트
$scope.$on('updateCSS', function(event, args) {
//파라메터로 받아온 스타일 시트 반영
$scope.stylesheets = args;
});
});
return app;
}
);
AngularJS나 jQuery 와 같은 라이브러리 외에 main.js의 디펜던시로 걸린 파일 중 하나가 app.js 파일입니다. 이 디펜던시 설정으로 인해 app.js 파일이 로드되어 실행 된 후에야 main.js 파일의 실행된다는 것은 앞서 언급한 부분입니다.
app.js 파일에도 역시 디펜던시 설정이 걸려 있는데, AngularJS의 route 설정을 위한 route-config.js 파일입니다. route-config.js 파일이 로드된 뒤에 app.js 에서는 myApp 모듈을 선언합니다. AngularJS는 모듈 선언시에만 접근할 수 있는 provider 들이 있는데 Lazy Loading을 위해서 이 provider 들을 별도로 저장해둡니다. 이 provider 들을 저장하는 부분이 route-config.js 에 구현되어 있기 때문에 route-config.js 파일이 디펜던시로 잡혀있는 것입니다.
또한 app.js 파일에는 앱 전체적으로 공통적으로 사용되는 CommonController가 선언되어 있습니다. 사실 각 partial view마다 또 컨트롤러가 존재하는데, 이 공통 컨트롤러에서는 partial view 외에 부분에서 사용되는 내용들이 위치하게 됩니다. 예를들어, 위의 소스에서는 동적으로 스타일 시트를 업데이트하는 로직이 들어 있는 것을 볼 수 있습니다. 이 외에도 앱 전체 메뉴를 설정하는 부분 등도 이 컨트롤러에 포함될 수 있습니다.
여기에서는 컨트롤러 하나만 myApp 모듈에 추가하고 있지만, 공통적으로 사용되는 directive가 존재한다면 이 app.js 파일에서 선언해서 추가해 주는 것도 가능합니다. 그 외에도 공통적으로 사용되고 동적으로 추가될 필요가 없는 value 값이나 service, filter 등도 여기에서 선언해두 추가해주는 것이 좋습니다.
route-config.js
//requireJS 모듈 선언
define([
//디펜던시가 걸려있으므로, 아래의 디펜던시가 먼저 로드된 뒤에 아래 콜백이 수행된다.
'registers/lazy-directives',
'registers/lazy-services',
'registers/lazy-filters'
],
//디펜던시 로드뒤 콜백함수
function (lazyDirectives, lazyServices, lazyFilters) {
var $controllerProvider; //컨트롤러 프로바이더를 받을 변수
//컨트롤러 프로바이더 설정 함수
function setControllerProvider(value) {
$controllerProvider = value;
}
//컴파일 프로바이더 설정 함수
function setCompileProvider(value) {
lazyDirectives.setCompileProvider(value);
}
//프로바이드 설정 함수
function setProvide(value) {
lazyServices.setProvide(value);
}
//필터 프로바이더 설정 함수
function setFilterProvider(value) {
lazyFilters.setFilterProvider(value);
}
/*
현재 시점에서 services는 오직 value 값을 정할때만 사용할 수 있다.
Services는 반드시 factory를 사용해야 한다.
$provide.value('a', 123);
$provide.factory('a', function() { return 123; });
$compileProvider.directive('directiveName', ...);
$filterProvider.register('filterName', ...);
*/
function config(templatePath, controllerPath, lazyResources) {
//컨트롤러 프로바이더가 존재하지 않으면 오류!
if (!$controllerProvider) {
throw new Error("$controllerProvider is not set!");
}
//변수 선언
var defer,
html,
routeDefinition = {};
//경로 템플릿 설정
routeDefinition.template = function () {
return html;
};
//경로 컨트롤러 설정
routeDefinition.controller = controllerPath.substring(controllerPath.lastIndexOf("/") + 1);
//경로
routeDefinition.resolve = {
delay: function ($q, $rootScope) {
//defer 가져오기
defer = $q.defer();
//html에 아무런 값이 없는 경우
if (!html) {
//템플릿 및 컨트롤러 디펜던시 설정
var dependencies = ["text!" + templatePath, controllerPath];
//리소스들 추가
if (lazyResources) {
dependencies = dependencies.concat(lazyResources.directives);
dependencies = dependencies.concat(lazyResources.services);
dependencies = dependencies.concat(lazyResources.filters);
}
//디펜던시들 가져오기
require(dependencies, function () {
//인디케이터
var indicator = 0;
//템플릿
var template = arguments[indicator++];
//컨트롤러
if( angular.isDefined(controllerPath) ) {
$controllerProvider.register(controllerPath.substring(controllerPath.lastIndexOf("/") + 1), arguments[indicator]);
indicator++;
}
if( angular.isDefined(lazyResources) ) {
//다이렉티브
if( angular.isDefined(lazyResources.directives) ) {
for(var i=0; i<lazyResources.directives.length; i++) {
lazyDirectives.register(arguments[indicator]);
indicator++;
}
}
//서비스(value)
if( angular.isDefined(lazyResources.services) ) {
for(var i=0; i<lazyResources.services.length; i++) {
lazyServices.register(arguments[indicator]);
indicator++;
}
}
//필터
if( angular.isDefined(lazyResources.filters) ) {
for(var i=0; i<lazyResources.filters.length; i++) {
lazyFilters.register(arguments[indicator]);
indicator++;
}
}
}
//딜레이 걸어놓기
html = template;
defer.resolve();
$rootScope.$apply();
})
}
else {
defer.resolve();
}
return defer.promise;
}
}
return routeDefinition;
}
return {
setControllerProvider: setControllerProvider,
setCompileProvider: setCompileProvider,
setProvide: setProvide,
setFilterProvider: setFilterProvider,
config: config
};
}
);
앞서 app.js 파일에 이 route-config.js 파일이 디펜던시로 잡혀있었기 때문에 route-config.js 파일이 먼저 실행되게 됩니다.
또 route-config.js 파일에는 각각 directive, service, filter 를 동적으로 등록시켜줄 때 사용되는 lazy-directives, lazy-services, lazy-filters 가 디펜던시로 잡혀있기 때문에 이 파일들이 로드된 뒤에 route-config.js 파일이 실행됩니다.
디펜던시가 로드된 뒤에는 route 설정과 관련된 함수들이 선언됩니다. 각각의 provider를 저장하는 함수들과 path에 따라 lazy-loading 을 구현하는 부분이 config 함수에 선언됩니다.
소스에 주석으로 설명이 되어 있지만 간단하게 전체 로직을 살펴보자면, 템플릿과 스크립트 파일 등을 파라메터로 받아서 차후에 호출이 들어올 경우 requireJS로 이들을 동적으로 가져와 등록하도록 예약하는 로직입니다.
route.js
'use strict';
define([
'app', //생성한 앵귤러 모듈에 루트를 등록하기 위해 임포트
'route-config' //루트를 등록하는 routeConfig를 사용하기 위해 임포트
],
function (app, routeConfig) {
//app은 생성한 myApp 앵귤러 모듈
return app.config(function ($routeProvider) {
//view1 경로 설정
$routeProvider.when('/view1', routeConfig.config('../partials/view1.html', 'controllers/first', {
directives: ['directives/version'],
services: [],
filters: ['filters/reverse']
}));
//view2 경로 설정
$routeProvider.when('/view2', routeConfig.config('../partials/view2.html', 'controllers/second', {
directives: ['directives/version'],
services: ['services/tester'],
filters: []
}));
//grid 경로 설정
$routeProvider.when('/grid', routeConfig.config('../partials/grid.html', 'controllers/grid'));
//admin 경로 설정
$routeProvider.when('/admin', routeConfig.config('../partials/admin.html', 'controllers/third'));
//기본 경로 설정
$routeProvider.otherwise({redirectTo:'/view1'});
});
});
route-config.js 파일과 app.js 파일을 디펜던시로 갖고 있던 route.js 파일의 실행부가 처리됩니다. route.js 파일의 실행부에는 AngularJS의 경로 설정 로직이 있습니다.
모듈의 config 메서드를 사용해서 각각의 경로를 설정해주게 되는데, 여기에서는 4개의 경로만 설정해주었습니다. 실제 프로젝트에서는 $http 서비스 등을 사용해서 메뉴 관련 데이터를 JSON으로 동적으로 받아와서 처리해주는 방법도 고민해볼 수 있습니다.
이렇게 route.js 파일도 로드 및 실행이 완료되고 나면 다시 main.js 파일의 콜백 함수 부분으로 돌아가게 되고 비로소 myApp 모듈이 부트스트래핑되며 Angular Application 이 실행됩니다.
동적으로 로딩되는 컨트롤러 예 – grid.js
'use strict';
define(['library/pqgrid/pqgrid.dev'], function () {
//컨트롤러 선언
function _controller($scope) {
//CSS 설정
$scope.$emit('updateCSS', ['lib/jquery/css/base/jquery-ui-1.10.2.min.css', 'lib/pqgrid/pqgrid.dev.css']);
/*
보여줄 더미 데이터 생성
*/
var array = [];
for(var i=0; i<100; i++) {
array[i] = [ "Task " + i, "5 days", Math.round(Math.random() * 100), "01/01/2009", "01/05/2009", (i % 5 == 0) + "" ];
}
/*
Paramquery Grid 설정
*/
$("div[pq-grid]").pqGrid({
width: 700,
height: 400,
editable: false,
title: "Basic Grid",
colModel: [
{ title: "Title", width: 100, dataType: "string" },
{ title: "Duration", width: 100, dataType: "string" },
{ title: "Complete", width: 50, dataType: "float", align: "right" },
{ title: "Start", width: 100, dataType: "string", align: "right" },
{ title: "Finish", width: 100, dataType: "string", align: "right" },
{ title: "Effort Driven", width: 100, dataType: "string", align: "right"}
],
dataModel: { data: array }
});
}
//생성한 컨트롤러 리턴
return _controller;
});
path 가 grid 일 경우 grid.js 파일이 컨트롤러로서 동적으로 로드됩니다.
RequireJS module 형태로 선언되어 있는데, 이 grid 메뉴의 화면에는 pqGrid를 사용하므로 디펜던시로 pqGrid 라이브러리를 넣고 있는 것을 볼 수 있습니다.
define(['library/pqgrid/pqgrid.dev'], function () {
위와 같이 define으로 모듈을 선언한 뒤에 첫 파라메터로 배열 형태로 필요한 디펜던시를 선언합니다.
디펜던시 로드가 완료되면 아래 콜백 함수가 실행되는데, 콜백함수에서는 Angular Controller 형태로 함수를 선언해서 이 함수를 리턴해주는 것을 볼 수 있습니다.
또 동적으로 CSS를 설정해주기 위해 컨트롤러 내부에 $emit 으로 추가하고자하는 css 의 경로를 보내게 됩니다. 이러한 방식으로 CSS를 동적으로 추가/제거 해줌으로써 너무 많은 CSS 추가로 인해 스타일이 충돌하는 것을 예방할 수 있습니다.
실제 구현 미리보기
여기까지 설명한 내용을 바탕으로 동적으로 템플릿, 컨트롤러, 다이렉티브, 서비스, 필터 등을 로드하는 프로젝트 샘플을 제작해보았습니다. 이 프로젝트 샘플은 아래 프로젝트 샘플 다운로드 부분에서 다운 받을 수 있는 링크를 얻을 수 있습니다.
기본 경로인 view1 path로 들어간 모습입니다. CSS, 컨트롤러, 필터, 템플릿 모두 동적으로 로드되어 반영된 것을 볼 수 있습니다.
view2 메뉴를 누르면 역시 마찬가지로 CSS, 컨트롤러, 다이렉티브, 템플릿 모두 동적으로 로드되어 반영된 것을 볼 수 있습니다. 기존의 CSS는 제거되고 새로운 CSS만 반영된 것 역시 확인할 수 있습니다.
grid 메뉴를 누르면 디펜던시 설정된 pqGrid 라이브러리를 동적으로 로드하여 실행되는 것을 확인할 수 있습니다.
프로젝트 샘플 다운로드 – GitHub
지금까지 설명한 내용을 바탕으로 제작된 프로젝트 샘플을 GitHub에서 받으실 수 있습니다. 직접 받아서 수정하고 실행해본다면 전체적인 구조를 이해하고 활용하는데 도움이 될 것입니다.
프로젝트 샘플 다운로드
결론
지금까지 AngularJS를 기반으로 대규모 웹 어플리케이션을 개발할 때 고민하게 되는 모듈화, 리소스의 동적로딩 등에 대해 살펴보았습니다. 또 이를 바탕으로 간단한 샘플 프로젝트를 작성했습니다.
앞서 말씀드린 것처럼 이 글은 대규모 웹 어플리케이션 개발에 있어 정답을 제시하는 글이 아닙니다. route 를 동적으로 추가하는 부분이나, breadcrumb을 관리하는 부분 등 실제 대규모 웹 어플리케이션을 개발할 때 필요한 부분들이 많이 빠져있기 때문입니다. 하지만 부족하나마 대규모 웹 어플리케이션을 개발하는데 있어 기준점은 될 수 있지 않을까 생각해봅니다.
부족한 설명과 부실한 예제였지만, 대규모 웹 어플리케이션 개발을 위한 안목을 얻을 수 있는 시간이었기를 기대합니다. 다음 포스팅에서는 대규모 웹 개발을 위해 좀더 개선된 형태의 seed project를 살펴보도록 하겠습니다.