http://programmingsummaries.tistory.com/m/post/229
AngularJS 기반 웹 어플리케이션, 어디서부터 시작해야 할까?
AngularJS를 기반으로 웹 어플리케이션을 개발할 때, AngularJS 사이트에서 제공해주는 문서들은 특정 API 활용 방법을 이해하거나 단순한 수준으로 시작하기에는 적합하지만 어떻게 수백에서 수천줄에 이르는 코드로 어플리케이션을 구성하고 발전시켜야하는지에 대해서는 명확한 가이드라인을 제공해주고 있지 않습니다. AngularJS를 기반으로 수차례의 크고 작은 개발을 진행하며 얻은 경험 지식과, 이를 기반으로 대규모 웹 어플리케이션 개발이 가능한 UI 프레임워크를 만드는 과정에서 고민하고 연구한 결과물을 정리해 보았습니다.
이 글은 대규모 웹 어플리케이션 개발에 있어 정답을 제시하는 글이 아니며, 대규모 웹 어플리케이션을 고려한 UI 프레임워크를 개발하는 과정에서 나온 산출물들 중 일부를 설명과 곁들여 공개하는 것입니다. 따라서 대규모 웹 어플리케이션 개발을 위한 인사이트를 얻는 방향으로 접근하시는 것이 좋습니다.
먼저는 파일과 디렉토리 구조를 어떻게 구성해야 하는지에서부터, 주요 자바스크립트 파일이 어떻게 구동되는지 하나하나 살펴보게 될 것입니다.
파일 및 디렉토리 구성
Brain Ford는 “Building Huuuuuge Apps with AngularJS” 라는 포스팅에서 대규모 어플리케이션을 개발할 때 중요한 것은 작고 집중되고 모듈화된 부분으로 개발해서 점진적으로 확장시켜나가는 것이 중요하다고 조언했습니다. 하지만 이렇게 대규모 어플리케이션을 어떠한 분류로 나누고 또 어떤 구조로 구성해야하는지에 대한 고민이 또다시 생기게 됩니다.
나누어진 부분들은 필연적으로 파일과 디렉토리로 분류되게 되는데, angular seed project 등에서도 권장하는 디렉토리 구조가 있고, 또 웹 상에서 검색해보면 다양한 구조로 파일과 디렉토리를 나누는 방법을 찾을 수 있습니다.
여기에서는 현재까지 여러 프로젝트에서 누적된 경험을 바탕으로, 모듈화된 AngularJS 프로젝트에 가장 적합한 파일과 디렉토리 구조를 소개하고자 합니다.
- 먼저 css와 img에는 각각 어플리케이션에서 공통적으로 사용되는 스타일시트와 이미지 데이터가 들어가게 됩니다.
- lib 폴더에는 angular, jquery 등 어플리케이션에서 사용되는 라이브러리들이 위치합니다.
- partials 폴더에는 ng-view에 뿌려줄 html template 파일들이 위치합니다.
- js 폴더에는 모듈화된 스크립트 파일들이 위치하게 됩니다.
js 폴더와 partials 폴더 안의 각 파일들은 반드시 한 가지의 내용만 담고 있는 것을 권장합니다. 예를 들어, 자바스크립트 파일 하나에는 오직 하나의 컨트롤러 혹은 오직 하나의 다이렉티브만 담겨있어야 한다는 것입니다. 프로젝트의 규모가 커져서 파일이 많아질수록, 더 많은 서브 디렉토리를 두어서 구조를 더욱 체계화하여 관리할 필요가 있습니다.
이러한 파일과 디렉토리 구조는 앞서 이야기한 작고 집중되고 모듈화된 부분으로 개발하려는 방향성을 고민하여 설계된 것입니다. 이렇게 모듈화된 구조를 통해 필요한 부분만 선별적으로 로딩할 수 있게되고 초기 로딩 속도에 크게 영향을 받는 UX 개선에 도움을 얻을 수 있습니다. 모듈화와 동적 로딩은 requireJS를 기반으로 구현되었으며, 이에 관련해서는 이 글에 자세히 설명되어 있습니다.
웹 어플리케이션 구조
전체적인 어플리케이션 구조는 위와 같습니다. document 전체에 대해 myApp 이라는 모듈이 부트스트래핑되고, html 태그에 CommonController 라는 컨트롤러가 자리합니다. CommonController에는 전체 메뉴와 같이 어플리케이션 전반에서 공통적으로 반복해서 사용되는 부분에 대한 컨트롤을 담당하게 됩니다.
ng-view directive가 있는 부분이 동적으로 로딩되는 부분으로, partials 폴더 아래에 있는 partial view 템플릿들을 가져와서 뿌려주게 됩니다. 이렇게 로딩되는 view 마다 각 view에 해당하는 컨트롤러 역시 동적으로 로딩되어 할당됩니다.
이러한 구조에 대한 이해를 바탕으로, 실제 어떤 흐름으로 웹 어플리케이션이 구동되며 모듈들이 관리되는지를 이어서 살펴보겠습니다.
웹 어플리케이션 흐름
웹 어플리케이션의 전체적인 큰 그림은 위와 같습니다.
index.html 파일을 열면 여기에 require.js 파일을 script 태그로 가져오게 됩니다. RequireJS 는 로드된 후 data-main 속성에 따라 가장 먼저 main.js 파일을 동적으로 불러와서 실행하게 됩니다.
main.js 파일은 RequireJS 모듈 형태로 선언되어 있는데, 디펜던시 선언부 및 디펜던시 로드 뒤 실행부로 나뉘어져 있습니다. 실행부에는 myApp Angular 모듈을 부트스트래핑하도록 되어 있지만, 그 전에 디펜던시들이 먼저 로드 되어야 하므로, angularJS, jQuery 등의 라이브러리와 app.js, route-config.js, routes.js 파일이 로드된 뒤에야 부트스트래핑이 진행되게 됩니다.
app.js 파일 역시 RequireJS 모듈 형태로 선언되어 있는데, 실행부에는 myApp 모듈 및 CommonController를 선언하는 내용으로 되어 있습니다. 이 내용 역시 바로 실행되지 않고 그 전에 디펜던시가 로드된 뒤 실행되게 됩니다. 따라서 디펜던시인 route-config.js 파일의 로드를 먼저 처리합니다.
route-config.js 파일 역시 RequireJS 모듈 형태로 동적으로 서비스, 다이렉티브, 필터 등을 로드할 때 필요한 스크립트들이 디펜던시로 등록되어 있습니다. 이 디펜던시들이 로드되면, AngularJS에서 경로(route) 설정 시 사용할 config 함수를 선언합니다.
route-config.js 파일의 처리가 끝나면 다시 역으로 app.js 파일의 실행부가 처리되고, 이어서 route-config.js 파일과 app.js 파일을 디펜던시로 갖고 있던 route.js 파일의 실행부가 처리됩니다. route.js 파일의 실행부에는 AngularJS의 경로 설정 로직이 있습니다.
여기까지 모든 디펜던시들의 처리가 끝나고 나면 다시 main.js 파일의 실행부가 처리되게 됩니다. 이 시점에서 myApp 모듈의 부트스트래핑이 작동되어 Angular Application 이 실행되게 됩니다.
이때, 입력된 route 에 따라서 해당 partial view template 파일과 controller 가 동적으로 로드됩니다.
다시 정리하면, RequireJS 를 통해 설정된 디펜던시들이 로드된 후 비로소 Angular Application이 시작되는 형태로, 또 각 route 마다 RequireJS가 template 과 controller 등을 동적으로 가져와서 추가해주는 방식으로 구현되어 있습니다.
그럼 이제, 각 스크립트 파일 안의 로직이 실제로 어떻게 동작하는지 구체적으로 살펴보겠습니다.
세부 흐름 분석
흐름의 시작은 index.html 파일입니다. require.js 에 대한 포스팅에서 이미 설명한 것과 크게 다르지 않은 구조로 설계되어 있습니다.
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를 살펴보도록 하겠습니다.
종종 현재 페이지에서 경로 변화가 있을때 이를 감지할 필요가 생긴다.
$watch 를 사용해서 어떤 변화가 생기는지 감시하고 있다가 변화가 생겼을 때 주소값을 분석해서 특정 처리를 해줄 수 있다.
사실 AngularJS에서 제공해주는 $routeParam 도 존재하는데, $routeParam의 경우 $routeProvider 를 사용할 때만 접근이 용이하기 때문에 사용에 제한이 많다.
아래와 같은 로직을 통해 해시 데이터의 변화를 감지하고, 전역객체를 사용하지 않고서도 비 AngularJS 관련 로직들과 값을 주고 받을 수 있다.
//[해시 데이터 변화 여부 감지] $scope.$watch( function () { return document.location.hash; }, function (parameter) { //해시 추출 var _hash = parameter.split('?'); //파라메터가 존재할 경우, if( _hash[1] ) { //해시 파라메터 제거 document.location.href = _hash[0]; //해시 파라메터에서 루트와 파라메터 분리 var _route = _hash[1].replace(/route=([^&]+).*/, "$1"); var _param = _hash[1].replace(/param=([^&]+).*/, "$1"); switch( _route ) { case 'test': //여기는 "http://주소#/해시?test=value" 와 같은 형태일 때 실행된다. break; } } } );
위의 로직을 활용하면, 기존 해시 이후에 ? 를 붙이고 다른 파라메터를 추가해도 감지가 가능해진다.
AngularJS를 활용해서 웹어플리케이션을 개발할 때 가장 많이 사용하게 되는 다이렉티브 중 하나가 ng-repeat 일 것이다.
간단하게 배열 안의 요소들을 알아서 출력해줄 수 있기 때문에 활용도가 높다.
하지만 단순한 사용법만 숙지한 상태에서 ng-repeat을 쓰게 되면, 복잡한 케이스를 만났을 때 많은 한계를 느끼게 된다.
AngularJS 사이트에서 제공하는 ng-repeat 에 대한 설명을 살펴보면, $index, $first 등 다소 특별한 속성들이 있는데 이 특별한 속성들을 사용하면 이러한 한계를 상당부분 해소할 수 있다.
$index – {number} – 현재 반복 요소의 오프셋 (0부터 length-1까지)
$first – {boolean} – 현재 반복 요소가 첫번째 요소이면 true
$middle – {boolean} – 현재 반복 요소가 처음도 끝도 아니면 true
$last – {boolean} – 현재 반복 요소가 마지막 요소이면 true
아래와 같은 조건을 가진 레이아웃을 생각해보자. ng-repeat 을 활용해서 구현하려면 어떻게 해야할까?
1. TH 태그를 활용해서 테이블 헤드가 왼편에 보여야 한다.
2. 입력 요소는 5개까지 가능하고 마지막 입력 요소에는 추가 버튼이 보여야 한다.
3. 5개까지 입력요소가 추가되면 추가 버튼은 감춰져야 한다.
4. 입력 요소는 삭제 버튼으로 삭제될 수 있고, 마지막 요소는 삭제되면 안되고 초기화 되어야 한다.
5. 각 입력 요소에서 검색 버튼을 누르면 해당 요소의 내용이 출력되어야 한다.
1번부터 쉽지 않아 보인다.
TH는 하나만 나와야 하는데 TR을 반복시키면 TH가 여러개 생겨버리게 된다. 어떻게 처리해야 할까?
$first 속성을 활용하면 생각보다 간단하게 처리해 줄 수 있다.
<tr data-ng-repeat="row in list"> <th rowspan="{{list.length}}" data-ng-show="$first">테이블 헤드</th>
th 태그의 rowspan 속성은 list의 길이로 잡아주고, 여러 개의 th가 생성되어도 첫번째 th만 보이도록 data-ng-show 를 활용하면 된다. $first 속성이 없었다면 훨씬 복잡해질 수 있는 부분을 쉽게 처리할 수 있었다.
2번과 3번은 어떻게 처리해야 할까? 요소를 추가할 때 5개까지만 추가되어야 하고 마지막 요소에만 추가 버튼이 보여야한다.
아래와 같이 $last 속성과 $index 속성을 활용하면 쉽게 구현할 수 있다.
<a href="javascript:;" data-ng-click="clickListener('btn_addrow')" data-ng-show="$last && ($index < 4)">행 추가</a>
각 반복 행마다 추가 버튼이 실제로는 존재하겠지만, data-ng-show를 활용해 $last 즉, 마지막인 경우와, $index < 4 인 경우에만 행 추가 버튼이 나타나는 것이다.
4번은 비교적 쉬워보인다. 하지만 $index 를 모른다면 한참은 더 어렵게 처리해야 한다.
먼저 삭제 버튼을 눌렀을 때 해당 요소의 $index 를 파라메터로 보낸다. $index를 통해 실제 배열에서의 위치를 알 수 있게 때문에 splice를 통해 간단하게 삭제해줄 수 있다.
<!-- HTML --> <a href="javascript:;" data-ng-click="clickListener('btn_deleterow', $index)">삭제</a> //JavaScript if( $scope.list.length > 1 ) { $scope.list.splice($index, 1); } else { $scope.list = [{ rowNm: "", rowLabel : "입력"}]; }
5번이 가장 쉽다. 현재 요소를 그냥 파라메터로 넘겨버리고 거기에서 값을 출력하게 처리하면 되기 때문이다.
결론
지금까지 살펴본 내용을 jsFiddle 로 만들어보았다. 직접 테스트해보고 수정해보면서 ng-repeat 과 특별한 속성들을 어떻게 활용할 수 있는지에 대한 인사이트를 조금이라도 얻기를 기대해본다.
이전에 Directive에 대해 포스팅했을 때, Directive의 재귀호출을 응용해서 만든 간단한 트리 메뉴 소스를 공개했었다.
이때의 소스는 Directive를 활용하기는 했지만, 불필요한 부분이 많았고, jqLite에 대한 의존성이 높았기 때문에 다소 아쉬운 부분이 있었다. 하지만 이번에는 AngularJS의 특성을 최대한 활용하였고, AngularJS 어플리케이션에서 쉽게 활용할 수 있도록 모듈화하여 GitHub을 통해 공개했다. (내친김에 http://ngmodules.org/에도 살짝 추가했다^^)
기존에는 다이렉티브 두 개를 활용해서 구현했었는데, 이번에는 하나의 다이렉티브로 처리했고 소스코드도 90줄에 불과할 정도로 간결하다. 용량도 closure compiler로 minified된 버전의 경우 1.36kb 정도이고, 개발용 소스도 2.5kb 정도로 작다.
Angular Treeview – github.com
jsFiddle
구현 초기 버전의 경우, 간단히 소스를 분석해보면 AngularJS 입문자들에게 상당한 도움이 될거라 생각되어 이번에 개발한 Angular Treeview 의 로직에 대해 설명해 보고자 한다.
실제 사용예
먼저 jsFiddle을 통해 간단하게 실제 동작을 살펴보자.
기본적인 트리뷰의 기능을 살펴볼 수 있다. 테스트용 JSON 데이터는 BBC 방송에서 제공하는 메뉴 구조를 가져왔다.
다른 일반적인 트리뷰와 마찬가지로 아이콘 부분을 클릭하면 트리가 펼쳐지거나 접히고, 글씨부분을 클릭하면 선택된다. 선택된 노드에 대한 정보는 상단의 Selected Node 란에 표시되도록 예제를 만들어보았다.
적용 방법
<div data-angular-treeview data-tree-model="treedata" data-node-id="id" data-node-label="label" data-node-children="children" > </div>
사용 방법은 DIV 등의 태그에 속성명으로 data-angular-treeview 라고 써줌으로써 인식한다. tree-model 에는 scope 상에 트리 변수명을 지정해준다. 여기에서는 $scope.treedata 변수에 트리 구조가 들어있다고 지정한 것이다. id는 해당 노드의 id인데, 현재까지는 사실상 크게 필요가 없는 속성이다. label은 실제로 트리뷰에 표시되는 내용이고, children은 자식 배열이 들이 있는 속성명이다.
실제 $scope.treedata에는 아래와 같은 형태로 데이터가 들어가 있을 것이다.
$scope.treedata = [ { label : "User", id : "role1", children : [ { label : "subUser1", id : "role11", children : [] }, { label : "subUser2", id : "role12", children : [ { label : "subUser2-1", id : "role121", children : [ { label : "subUser2-1-1", id : "role1211", children : [] }, { label : "subUser2-1-2", id : "role1212", children : [] } ]} ]} ]}, { label : "Admin", id : "role2", children : [] }, { label : "Guest", id : "role3", children : [] } ];
트리뷰를 사용하려면 현재의 Angular Module에 아래와 같이 인젝션을 해주어야 한다. 물론 자바스크립트 파일과 스타일시트 파일을 HTML 페이지 상에서 포함시켜주는 것은 기본이다.
angular.module('myApp', ['angularTreeview']);
트리 메뉴의 아이템을 클릭했을 때는 선택된 노드가 $scope.currentNode 에 저장된다. 따라서 클릭한 시점을 컨트롤러에서 인지하기 위해서는 $watch 메서드를 사용할 수도 있을 것이고, 아예 트리뷰 소스를 커스터마이즈해서 원하는 구현을 넣어줄 수도 있을 것이다. 아래는 $watch 메서드를 활용해서 컨트롤러에서 클릭한 시점에 선택된 노드의 정보를 콘솔 로그로 뿌려주는 로직이다.
$scope.$watch( 'currentNode', function( newObj, oldObj ) { if( $scope.currentNode && angular.isObject($scope.currentNode) ) { console.log( 'Node Selected!!' ); console.log( $scope.currentNode ); } }, false);
로직 분석
공개된 소스를 한번 살펴보자. 사용된 주요 개념은 AngularJS의 module과 directive, 재귀호출로 나눌 수 있다. module은 다른 AngularJS Application에 주입을 쉽게하기 위해 감쌌고, 대부분의 로직은 AngularJS 자체적인 Directive와 Custom Directive 등으로 짜여져 있다.
(function ( angular ) { 'use strict'; //AngularJS 모듈 angular.module( 'angularTreeview', [] ).directive( 'treeModel', function( $compile ) { return { restrict: 'A', //다이렉티브는 속성으로 인지한다. link: function ( scope, element, attrs ) { //트리 모델 var treeModel = attrs.treeModel; //노드 아이디 var nodeId = attrs.nodeId || 'id'; //노드 라벨 var nodeLabel = attrs.nodeLabel || 'label'; //자식 노드들 var nodeChildren = attrs.nodeChildren || 'children'; //트리 템플릿. 이 템플릿 내에 data-tree-model 다이렉티브가 다시 존재하는 형태로 재귀호출이 구현된다. var template = '<ul>' + '<li data-ng-repeat="node in ' + treeModel + '">' + '<i class="collapsed" data-ng-show="node.' + nodeChildren + '.length && node.collapsed" data-ng-click="selectNodeHead(node)"></i>' + '<i class="expanded" data-ng-show="node.' + nodeChildren + '.length && !node.collapsed" data-ng-click="selectNodeHead(nodet)"></i>' + '<i class="normal" data-ng-hide="node.' + nodeChildren + '.length"></i> ' + '<span data-ng-class="node.selected" data-ng-click="selectNodeLabel(node)">{{node.' + nodeLabel + '}}</span>' + '<div data-ng-hide="node.collapsed" data-tree-model="node.' + nodeChildren + '" data-node-id=' + nodeId + ' data-node-label=' + nodeLabel + ' data-node-children=' + nodeChildren + '></div>' + '</li>' + '</ul>'; //트리 모델명을 HTML 태그의 속성으로 입력했는지 체크 if( treeModel && treeModel.length ) { //data-angular-treeview 속성이 있으면 루트 노드이고, //루트 노드일 때에만 클릭 이벤트 처리 메서드를 선언한다. if( attrs.angularTreeview ) { //노드의 아이콘 부분(노드 헤드)을 클릭했을 때, scope.selectNodeHead = scope.selectNodeHead || function( selectedNode ){ //collapsed값을 토글해 준다. selectedNode.collapsed = !selectedNode.collapsed; }; //노드의 이름 부분(노드 라벨)을 클릭했을 때 scope.selectNodeLabel = scope.selectNodeLabel || function( selectedNode ){ //기존에 선택되어 있던 노드의 하이라이팅 클래스를 제거해주고, if( scope.currentNode && scope.currentNode.selected ) { scope.currentNode.selected = undefined; } //현재 선택한 노드에 하이라이팅 클래스를 설정해 준다. selectedNode.selected = 'selected' //현재 선택한 노드를 $scope.currentNode 에 넣어준다. scope.currentNode = selectedNode; }; } //템플릿을 렌더링해서 뿌려준다. element.html(null).append( $compile( template )( scope ) ); } } }; }); })( angular );
재귀호출의 핵심은 의외로 template 변수에 있다. 이 변수에는 화면에 뿌려줄 HTML 템플릿이 담겨져 있는데, 주석에도 설명한 것과 같이 이 템플릿 안에 다시 directive를 사용해서 재귀호출을 구현하고 있다. 물론 모든 자식 요소가 다 표현되고 나면 더이상 재귀호출이 일어나지 않는다.
클릭 이벤트에 대해 처리하는 메서드 등은 반복해서 선언할 필요가 없기 때문에 루트 노드를 생성할 때만 한번 선언된다. 트리가 펼쳐지고 사라지는 것 모두 AngularJS에서 자체적으로 제공해주는 directive인 ng-show 나 ng-hide를 활용했고 클릭 이벤트 역시 ng-click 을 사용했다. 또 트리 내용이 표시되는 것은 ng-repeat을 활용해 처리했다.
결론
전체적으로 AngularJS의 특성들만을 활용해서 구현했기 때문에 분석해본다면, AngularJS에 대한 이해가 한층 더 깊어질 수 있을 것이다. 앞으로 트리뷰에 필요한 기능들을 조금씩 추가해서 보다 완성도 있는 AngularJS 라이브러리가 되는 것을 기대해본다.
메타 태그를 아래와 같이 주었기 때문에 테스트용 브라우저인 IE9에서는 일반적으로 오류가 없었지만, 강제적으로 개발자 모드에서 문서모드를 IE7 등으로 바꾸었을 때 문제가 발생했다.
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1;text/html; charset=utf-8;" />
위와 같이 IE=Edge 옵션을 주어 메타 태그를 설정하면, 호환성 보기 버튼 자체가 사라지기 때문에 IE에서 항상 최신 렌더링 엔진으로 웹 어플리케이션이 렌더링된다. 하지만 강제적으로 문서 모드를 IE7으로 바꾸면 얘기가 틀려진다.
고객들이 어떤 브라우저를 사용할지 모르기 때문에 “IE 호환성 모드를 쓰지 마세요”라고 할 수도 없는 노릇이다.
이런 까닭에 문제 해결을 위해 구글링을 한 결과, JSON을 IE 구버전에서 제대로 지원하지 못하기 때문에 나타나는 문제였다. 이에 대한 해결책으로 json에 대한 대표적 polyfill인 json3.js 를 활용해서 문제를 해결할 수 있었다. json2.js 도 있지만, 이보다 몇가지 향상된 부분이 있는 라이브러리가 json3 라고 소개하고 있다.
<!--[if IE]> <script language="javascript" type="text/javascript" src="json3.min.js"></script> <![endif]-->
브라우저 모드와 문서 모드의 조합이 다양하기 때문에 특정한 IE 버전을 지정하지 않고 IE인 경우에는 json3.js 를 로드하게 해서 IE에서도 JSON을 지원하도록 설정했다. IE9에서 다양한 문서 모드와 브라우저 모드의 조합으로 테스트한 결과 큰 문제없이 정상동작하는 것을 확인할 수 있었다.
사실 구버전 IE를 고려할 때, 어떤 설정을 해야하는지 이미 AngularJS 사이트에 자세하게 소개되어 있었는데, 오늘의 고생은 이 부분을 꼼꼼히 읽지 못한 탓이 크다.
AngularJS를 IE에서 사용할 때 고려해야 할 부분
http://docs.angularjs.org/guide/ie