잊지 않겠습니다.

Webpack을 이용한 angularJS 1 application 개발

기존 grunt를 이용한 angularjs application을 webpack을 사용하도록 변경하고자 합니다. 일단 webpack을 사용하게 되면 다음과 같은 장점을 가질 수 있습니다.

  1. npm을 이용한 외부 liberary의 관리
  2. es6 문법의 사용
  3. import/require의 사용 가능
  4. nodejs의 server code와 client code의 완벽 호환 가능

위 장점 이외에, 최근의 웹 개발 양상은 module화입니다. 모든 liberary들이 CommonJS 방식으로 module화가 되어가고 있는 시점에서, 추세에 따라가지 못하면 도태가 되어버리는 것 같은 압박감을 느끼기도 하지요.

환경

사용되는 application의 주요 library들은 다음과 같습니다.

  • jquerey
  • angularjs
  • lodash
  • restangular
  • kendo ui Pro

구조

기본적으로 webpack의 폴더구조는 maven의 구조와 비슷하게 가는 경향들이 있습니다. 다음과 같은 folder구조를 가지고 갈 것입니다.

.
├── dist
├── gulpfile.js
├── libs
│   └── kendo
├── LICENSE
├── node_modules
├── package.json
├── README.md
├── src
│   ├── app
│   └── public
├── webpack.config.js
├── webpack-test.sublime-project
└── webpack-test.sublime-workspace
  • dist: build된 file들이 위치할 folder입니다.
  • libs: bower/npm이 지원되지 않는 외부 library들이 위치한 folder입니다. 상기 application에서는 kendo가 포함됩니다.
  • src: 소스가 위치합니다.
    • public: public에 배포될 css, image, html 들이 위치할 folder입니다.
    • app: angularJs application이 위치할 folder입니다. app.js를 시작접으로 application이 구동될 것입니다.

여기서 app folder의 경우 다음과 같은 구조로 구성됩니다. (기본적으로 yo angular와 동일한 구성을 가지고 갑니다.)

├── controllers
│   ├── home.js
│   └── user.js
├── directives
│   └── nameButton.js
├── services
│   ├── BaseService.js
│   └── CodeService.js
├── app.js
└── routes.js

사용자 directive, controller, service를 모두 사용합니다.

packages.js

webpack은 기본적으로 bower를 사용하지 않습니다. 모든 js library들은 npm을 통해 설치되며 관리됩니다.

basic angularJS application (STEP01)

가장 기본적인 angularjs application을 작성해보도록 합니다. 다음과 같은 특징을 갖습니다.

  • /home, /user 라는 두개의 route를 갖습니다.

추후에 npm을 이용해서 module을 관리할 예정이기 때문에 처음 시작은 모두 cdn을 이용해서 처리하도록 합니다.

public안에 다음과 같이 구성합니다.

├── js
│   ├── controllers
│   │   ├── home.js
│   │   └── user.js
│   └── app.js
├── home.html
├── index.html
└── user.html

그리고, /home, /user의 url router를 다음과 같이 구성합니다.

// app.js
'use strict';

angular
  .module('app', ['ui.router'])
  .config(function ($stateProvider, $urlRouterProvider) {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController'
    });
  });

index.html

<!doctype html>
<html ng-app="app" lang="en">
<head>
  <meta charset="UTF-8">
  <title>Angular App</title>
</head>
<body>
  <button ui-sref="home">Home</button>
  <button ui-sref="user">User</button>
  <ui-view></ui-view>
</body>
  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.9/angular.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/angular-ui-router/1.0.0-alpha.4/angular-ui-router.min.js"></script>
  <script src="/js/app.js"></script>
  <script src="/js/controllers/home.js"></script>
  <script src="/js/controllers/user.js"></script>
</html>

home.html

<p>this is home.html</p>

user.html

<p>This is user.html</p>

이제 public folder내에서 http-server를 실행해보면 간단한 angularjs webapp 이 생성됩니다.

basic angularJS application + webpack (STEP02)

이제 webpack을 이용해서 angularjs application을 구성해보도록 하겠습니다.
먼저, public/js 안에 있는 모든 js 파일들을 모두 app folder로 옮깁니다.

.
├── app
│   ├── controllers
│   │   ├── home.js
│   │   └── user.js
│   └── app.js
└── public
    ├── home.html
    ├── index.html
    └── user.html

이런 구성을 가지게 됩니다.
이제 webpack.config.js 파일을 생성합니다.

'use strict';

const config = {
  entry: './src/app/app.js',
  output: {
    path: __dirname + '/dist',
    filename: 'app.bundle.js'
  },
  plugins: []
};
module.exports = config;

매우 기초적인 config 파일입니다. app.js 파일을 entry point로 지정하고, compile된 folder를 지정합니다. 이제 bundle된 파일을 사용하도록 index.html을 다음과 같이 수정합니다.

<!doctype html>
<html ng-app="app" lang="en">
<head>
  <meta charset="UTF-8">
  <title>Angular App</title>
</head>
<body>
  <button ui-sref="home">Home</button>
  <button ui-sref="user">User</button>
  <ui-view></ui-view>
  <script src="/app.bundle.js"></script>
</body>
</html>

그리고, webpack에서 각 controller, angular-ui-router의 import를 위해 app.js를 다음과 같이 수정합니다.

var angular = require('angular');
require('angular-ui-router');

angular
  .module('app', [ 'ui.router' ])
  .config(function ($stateProvider, $urlRouterProvider) {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController'
    });
  });

require('./controllers/home');
require('./controllers/user');

여기서 중요한 것은 require의 위치입니다. controller들의 등록은 최초 app의 등록이 마쳐진 이후에 등록되어야지 됩니다.

angularJS application (ES2015) + webpack (STEP03)

이제, angularJs application을 es2015 문법으로 구현해보도록 하겠습니다. 개인적으로 가장 욕심이 나는 부분입니다.

es2015 문법을 사용해서 compile 하기 위해서는 babel을 이용해야지 됩니다. babel loader와 babel 2015 loader를 npm을 이용해서 설치합니다.

npm install babel-loader --save-dev
npm install babel-preset-es2015 --save-dev

이제 webpack.config에 babel loader를 등록시켜줍니다. js파일은 모두 babel loader에 의해서 처리가 되도록 설정합니다.

'use strict';
const config = {
  entry: './src/app/app.js',
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'app.bundle.js'
  },
  module: {
    loaders: [{
      test: /\.js$/,
      loader: 'babel',
      query: {
        presets: ['es2015']
      },
      exclude: /node_modules/
    }]
  },
  devtool: 'inline-source-map',
  plugins: []
};
module.exports = config;

이제 app.js, home.js, user.js 파일을 es2015 문법에 맞게 수정을 합니다.

home.js

class HomeController {
  constructor() {
    this.name = 'HomeCtrl';
    console.log('HomeController.init');
  }

  getName() {
    return this.name;
  }

  changeName(name) {
    this.name = name;
    return name;
  }
}

module.exports = HomeController;

user.js

class UserController {
  constructor() {
    this.name = 'UserCtrl';
    console.log('UserController.init');
  }

  getName() {
    return this.name;
  }

  changeName(name) {
    this.name = name;
    return name;
  }
}

module.exports = UserController;

app.js

var angular = require('angular');
require('angular-ui-router');

angular
  .module('app', [ 'ui.router' ])
  .config(function ($stateProvider, $urlRouterProvider) {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController'
    });
  })
  .controller('HomeController', require('./controllers/home'))
  .controller('UserController', require('./controllers/user'));

app.js에서 angular에 대한 dependency만을 갖고, 각 controller들은 dependency에서 angular에서 자유로워지는 것을 볼 수 있습니다. 이제 webpack 명령어를 실행해보면 다음과 같은 결과를 볼 수 있습니다.

Hash: f32a9fcb0a0a1f4a0d08
Version: webpack 1.12.14
Time: 1498ms
        Asset     Size  Chunks             Chunk Names
app.bundle.js  3.88 MB       0  [emitted]  main
    + 6 hidden modules

외부 Library 들의 연결 (STEP04)

angularJs application을 개발하면 당연히 기존 library 들과 같이 사용해야지 되는 경우가 많습니다. 먼저 angular-ui-router의 경우에도 마찬가지지요.

대표적인 것들이 일단 lodash와 같은 utility library들이 있을것이고, angularjs에서 rest api 를 call 할때 사용하는 restangular 역시 주로 사용되는 library 입니다.

먼저 loadash에 대해서 알아보도록 하겠습니다. 저는 lodash에 대한 의존이 매우 높은 관계로 전역으로 등록시켜서 사용하고자 합니다.

npm을 통해서 lodash를 설치합니다.

npm install lodash --save

lodash의 경우에는 다른 기타 library들의 dependency가 심한편입니다. 또한 모든 controller에서 전역으로 사용하고싶은 욕심이 있습니다. 그렇다면 webpack에서 미리 등록을 시켜두면 좀 더 사용하기 편할 것 같습니다. browser 환경의 전역 scope로 미리 등록시켜두기 위해서는 webpack에서 제공하는 ProvidePlugin을 이용해서 등록을 시킵니다.

webpack.config.js를 다음과 같이 수정합니다.

const webpack = require('webpack');

const config = {
  entry: './src/app/app.js',
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'app.bundle.js'
  },
  module: {
    loaders: [{
      test: /\.js$/,
      loader: 'babel',
      query: {
        presets: ['es2015']
      },
      exclude: /node_modules/
    }]
  },
  devtool: 'inline-source-map',
  plugins: [
    new webpack.ProvidePlugin({
      'window._': 'lodash',
      '_': 'lodash'
    })
  ]
};
module.exports = config;

lodashwindow 전역과 local 모두에 등록을 시켰습니다. 이제 controller에서는 lodashrequire필요 없이 사용 가능합니다.

home.js

class HomeController {
  constructor() {
    this.name = 'HomeCtrl';
    console.log('HomeController.init');
    console.log(_.random(0, 5));
  }

  getName() {
    return this.name;
  }

  changeName(name) {
    this.name = name;
    return name;
  }
}

module.exports = HomeController;

이제 restangular를 추가할 차례입니다. npm을 이용해서 restangular를 설치하는 일련의 과정은 lodash와 동일합니다. 다만, restangular의 경우에는 angularjs module에 등록을 시켜줘야지 됩니다. app.js는 다음과 같이 변경되어야지 됩니다.

app.js

var angular = require('angular');
require('angular-ui-router');
require('restangular');

angular
  .module('app', [ 'ui.router', 'restangular' ])
  .config(($stateProvider, $urlRouterProvider) => {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController'
    });
  })
  .config(RestangularProvider => {
    console.log(RestangularProvider);
  })
  .controller('HomeController', require('./controllers/home'))
  .controller('UserController', require('./controllers/user'));

이제 controller에서는 생성자에서 Restangular를 inject해서 사용가능합니다.

home.js

class HomeController {
  constructor(Restangular) {
    this.Restangular = Restangular;
    this.name = 'HomeCtrl';
    console.log('HomeController.init');
    console.log(_.random(0, 5));

    this.Restangular.all('').get('data.json').then(result => {
      console.log(result);
    });
  }

  getName() {
    return this.name;
  }

  changeName(name) {
    this.name = name;
    return name;
  }
}

module.exports = HomeController;

Service, Directive, filter 추가 (Step 05)

angularJs의 주요 컨셉중 하나인 service, directive, filter의 경우, 기존 controller추가와 동일합니다. 다만 es2015 형식으로 코딩을 하는 경우, class를 이용해서 작성하는 것만 좀 유의해주면 될 것 같습니다.

userservice.js - service

class UserService {
  constructor(Restangular) {
    this.Restangular = Restangular;
  }

  getUserName() {
    return 'username';
  }

  getUserInfo() {
    this.Restangular.all('').get('info.json').then(info => {
      console.log(info);
    });
  }
}
module.exports = UserService;

userfilter.js - filter

function filteringUser () {
  return function (users) {
    return _.filter(users, function (user) {
      return user.name.indexOf('7') >= 0;
    });
  };
}
module.exports = filteringUser;

namedbutton.js - directive

function postLink(scope, element, attrs) {
  console.log(attrs);
  scope.ok = function () {
    alert(scope.name);
  };
}

function template(element, attrs) {
  console.log(attrs);
  return '<button ng-click="ok()">This is named button 확인</button>';
}

module.exports = function NameButtonDef() {
  return {
    restrict: 'E',
    scope: {
      name: '='
    },
    link: postLink,
    template: template
  };
};

app.js

var angular = require('angular');
require('angular-ui-router');
require('restangular');

angular
  .module('app', [ 'ui.router', 'restangular' ])
  .config(($stateProvider, $urlRouterProvider) => {
    $stateProvider.state('home', {
      url: '/home',
      templateUrl: 'home.html',
      controller: 'HomeController',
      controllerAs: 'model'
    })
    .state('user', {
      url: '/user',
      templateUrl: 'user.html',
      controller: 'UserController',
      controllerAs: 'model'
    });
  })
  .config(RestangularProvider => {
    console.log(RestangularProvider);
  })
  .controller('HomeController', require('./controllers/home'))
  .controller('UserController', require('./controllers/user'))
  .service('UserService', require('./services/userservice'))
  .filter('user', require('./filters/userfilter'))
  .directive('namedButton', require('./directives/namedbutton'));

controller와 별 차이가 없는 코드 패턴을 보입니다. 개인적으로는 최고의 장점이 service, directive, filter 코드 자체에 angular dependency가 없어지는 것이 장점인것 같습니다.

jqLite 대신 jquery 사용하기 (step06)

기본적으로 angular.element의 경우, jquery가 없는 경우에는 angularjs내부에 include 되어 있는 jqlite를 이용해서 element handling을 지원합니다. 그런데 angular.element의 경우 jquery selector보다 매우 기능이 빈약합니다. bootstrap도 사용할 가능성도 높고, 그냥 기본적으로 jquery를 포함해서 사용하게 되는 경우가 더 일반적입니다.

그럼 angular.element에서 jquery를 사용하도록 할려면 어떻게 해야지 될까요? 예전 방법대로라면 다음과 같이 해주면 됩니다.

<script src="jquery.js"></script>
<script src="angular.js"></script>

네. angular보다 jquery를 먼저 선언해주면 모든것이 해결되었습니다. 그럼 비슷한 구문으로 실행해보도록 하겠습니다.

    require('jquery');
    const angular = require('angular');
    console.log(angular.element('div'));

이러면 다음과 같은 에러가 발생합니다.

angular.js:13424 Error: [jqLite:nosel] Looking up elements via selectors is not supported by jqLite! See: http://docs.angularjs.org/api/angular.element
http://errors.angularjs.org/1.5.3/jqLite/nosel

네. jqLite를 사용하고 있지, jquery를 사용하고 있지 못하게 됩니다.

이런 경우에는 expose-loaderimport-loader를 이용해서 각 module에서 jquery등을 자동으로 내부내에서 import 시켜줘야지 됩니다.

npm을 이용해서 다음 module들을 설치합니다.

npm install imports-loader --dev-save
npm install expose-loader --dev-save

그리고 webpack.config.js의 module 부분을 다음과 같이 변경합니다.

  module: {
    loaders: [{
      test: /\/angular\.js$/,
      loader: 'imports?jQuery=jquery'
    }, {
      test: /\/jquery.js$/,
      loader: 'expose?jQuery'
    }, {
      test: /\.js$/,
      loader: 'babel',
      query: {
        presets: ['es2015']
      },
      exclude: /node_modules/
    }]
  },

코드 해석은 다음과 같습니다. jquery가 요청되는 경우 window.jQuery로 등록을 시키는 구문이 두번쩨 expose 구문입니다. 그리고 angular.js가 로드될 때, jQuery의 경우 jquery를 import 해서 사용하도록 강제하는 것입니다.

위의 두 구문은 매우 중요합니다. jquery 기반의 module들을 사용할 때, 안되는 이유의 대부분이 window.jQuery, window.$, window.jquery가 등록이 되지 않을 때 주로 나타나게 됩니다.

이제 저 구문을 넣어주고 webpack 을 실행해주면 다음과 같은 결과를 볼 수 있습니다.

[div#window-resizer-tooltip, div#feedly-mini, prevObject: jQuery.fn.init[1], context: document, selector: "div"]0: div#window-resizer-tooltip1: div#feedly-minicontext: documentlength: 2prevObject: jQuery.fn.init[1]selector: "div"__proto__: Object[0]

Summary

angularjs2가 나오는 이때에 유행에 어울리지 않는 angularjs와 최신 유행인 webpack간의 결합에 대해서 알아봤습니다.

위 소스는 모두 github에 올려두었고, step별로 tag를 달아두었습니다.
(https://github.com/xyzlast/webpack-angular1-step)

  1. angularjs1 application을 webpack으로 묶는 과정에 대해서 알아봤습니다.
  2. jquery 기반의 library들을 어떻게 결합할 수 있는지에 대해서 알아봤습니다.

css-loader등을 잘 설명하신 글이 있으니, 참고하셔서 보시면 좀 더 쉬울 것 같습니다.
(http://hyunseob.github.io/2016/04/03/webpack-practical-guide/index.html)


저작자 표시 동일 조건 변경 허락
신고
Posted by xyzlast Y2K
이번에는 Controller입니다. Controller는 view에서의 사용자의 action에 따른 scenario가 정해지게 됩니다. scenario에 따른 test code를 작성해주는 것이 가장 좋은 test code가 됩니다.
* page가 처음 load되면 list가 호출되어 기본 Data가 Loading된다. (AsService.list()가 호출)
* search button을 click시, list() method가 호출된다. (AsService.list()가 호출)
* startDate/endDate 값이 변경되면 list method가 호출된다.
* save button을 click시, confirm box가 표시되며, input값이 모두 정상적으로 입력되어 있지 않다면 error가 표시된다.

첫 case부터 시작해보도록 하겠습니다.

page가 처음 load되면 list() method가 호출된다.

page가 처음 load되는 것은 controller가 처음 생성되는 것을 의미합니다. 이는 beforeEach()에 대한 test code가 필요한 것을 의미합니다. list가 호출이 될 때, REST API를 호출하는 것을 가정하고 있기 때문에 REST API의 응답에 대한 mock response를 넣어주는 것이 필요합니다.

controller를 생성하기 위한 beforeEach와 createController 의 코드는 다음과 같이 구성될 수 있습니다.

  beforeEach(inject(function ($controller, $rootScope, _DateService_, $httpBackend) {
    httpBackend = $httpBackend;
    scope = $rootScope.$new();
    DateService = _DateService_;
    controller = $controller;
    OmAsListCtrl = createController();
  }));

  var createController = function() {
    var response = {"ok":true,"message":"api call completed","date":1413173458673,"data":[{"request":"김요청자","operators":"","requestPhoneNumber":"0212342341","receiptDate":"2014-08-27","name":"오피스를 더 좋게 해주세요.","receiptionPhoneNumber":"03121342134","location":"없음","id":"30221","receiption":"김접수자","departments":"관리팀,건축팀,관제팀,기계팀,소방팀","department":"-","status":"접수"},{"request":"박네임","requestPhoneNumber":"01015263748","departmentId":"0401","receiptDate":"2014-05-05","name":"13층 환기 안됨","receiptionPhoneNumber":"01056781234","location":"13층","id":"6","receiption":"이성명","departments":"관리팀,기계팀","department":"관리팀","status":"민원완료"}]};
    httpBackend.when('GET', /\/fms-api\/om\/as\/list?\W*/).respond(response);
    var ctrl = controller('OmAsListCtrl', {
      $scope: scope
    });
    scope.$digest();
    httpBackend.flush();
    expect(scope.items.length).not.toBe(0);
    return ctrl;
  };

scope.items의 갯수를 확인하고, 정확히 데이터가 matching되고 있는 것을 확인하면 load의 test code는 완료됩니다.

search button을 click시, list() method가 호출된다.

list button을 click하는 것은 $scope.list()가 호출되는 것을 의미합니다. 호출후에 items의 갯수를 확인해보는 것이 가장 쉬운 확인 방법입니다. test를 보다 쉽게 작성하기 위해서 list를 호출하기 전에 items를 초기화 시켜버린 후에 list를 호출한 후의 값을 비교해보면 확실히 알 수 있을것입니다.

  it('search button을 click시, list() method가 호출된다.', function() {
    scope.items = [];
    var response = {"ok":true,"message":"api call completed","date":1413173458673,"data":[{"request":"김요청자","operators":"","requestPhoneNumber":"0212342341","receiptDate":"2014-08-27","name":"오피스를 더 좋게 해주세요.","receiptionPhoneNumber":"03121342134","location":"없음","id":"30221","receiption":"김접수자","departments":"관리팀,건축팀,관제팀,기계팀,소방팀","department":"-","status":"접수"},{"request":"박네임","requestPhoneNumber":"01015263748","departmentId":"0401","receiptDate":"2014-05-05","name":"13층 환기 안됨","receiptionPhoneNumber":"01056781234","location":"13층","id":"6","receiption":"이성명","departments":"관리팀,기계팀","department":"관리팀","status":"민원완료"}]};
    httpBackend.when('GET', /\/fms-api\/om\/as\/list?\W*/).respond(response);
    scope.list();
    httpBackend.flush();
    expect(scope.items.length).not.toBe(0);
  });

startDate/endDate값이 변경되면 list method가 호출된다.

이는 spyOn을 이용하면 쉽게 처리가 가능합니다. 다음과 같은 test code를 작성할 수 있습니다.

  it('startDate가 변경되면, list가 호출되어야지 된다.', function() {
    spyOn(scope, 'list');
    scope.searchItem.startDate = '2015-05-01';
    scope.$digest();
    expect(scope.list).toHaveBeenCalled();
  });

  it('endDate가 변경되면, list가 호출되어야지 된다.', function() {
    spyOn(scope, 'list');
    scope.searchItem.endDate = '2015-05-01';
    scope.$digest();
    expect(scope.list).toHaveBeenCalled();
  });

save button을 click시, confirm이 표시되고, confirm true인 경우에 AsService.regist method가 호출된다.

confirm, alert은 browser가 제공하는 base dialog method입니다. 만약에 우리가 test code를 실행할때마다 dialog가 나오고, 그걸 수동으로 눌러줘야지 된다면 이는 매우 걸리적 거리는 일이 됩니다. 따라서, 이 method들을 override 시킬 필요가 있습니다. angularJS는 이를 위해서 $window를 제공하고 있습니다. 우리가 $window.confirm, $window.alert method를 override 시켜서 사용하면 confirm에서 항상 true만 선택하게 할 수 있습니다.

  beforeEach(inject(function ($controller, $rootScope, _$routeParams_, _$location_, _$window_) {
    routeParams = _$routeParams_;
    routeParams.id = '10';
    scope = $rootScope.$new();
    controller = $controller;
    window = _$window_;
    window.confirm = function(message) {
      console.log(message);
      return true;
    };
  }));

routeParam의 값에 따라 다른 동작을 하는 controller의 테스트

위 scenario에 없는 parameter값에 따라 다른 동작을 하는 controller를 test하는 방법입니다. 이는 기본적으로 $routeParam에 값을 설정시키고 나서 controller를 생성시켜주면 됩니다. 다음과 같은 test code pattern을 적용하면 됩니다.

  function createCtrl(ymd, type) {
    if(ymd) {
      routeParams.ymd = ymd;
    }
    if(type) {
      routeParams.type = type;
    }

    ctrl = controller('EmDailymeterListCtrl', {
      $scope: scope,
      $routeParams: routeParams,
      Dailymeterservice: service
    });
    if(ymd == null || type == 'list') {
      httpBackend.expectGET(/\/fms-api\/em\/dailymeter\/list?\W*/).respond(200, {});
    } else {
      httpBackend.expectGET(/\/fms-api\/em\/dailymeter\/new?\W*/).respond(200, {});
    }
    scope.$digest();
    httpBackend.flush();
    return ctrl;
  }


저작자 표시 동일 조건 변경 허락
신고
Posted by xyzlast Y2K

service (non-HTTP)

angularJS의 service code는 일반적으로 REST API에 대한 호출을 하고, 그 결과를 받는 것을 담당하지만, 값의 변경이나 특정값들의 display 형식으로의 변경등을 service로 만들어서, 코드의 응집력을 높일 수 있습니다. 섭씨온도를 화씨온도로 바꾸는 등의 계산 로직도 역시 이 영역에 들어가게 됩니다.

이러한 service code의 테스트는 다음 항목을 확인하면 됩니다.

input 값에 대하여 output 값이 예상대로 나왔는지. 
> toBe(value) 구문을 이용합니다.

기본적으로 matcher를 사용하는 것이 point입니다. 예상한 값을 기준으로 원하는 output이 정상적으로 나오는지를 확인하는 것이 좋습니다.

service (with HTTP)

REST API를 사용하는 service code의 테스트 방법에 대해서 알아보도록 하겠습니다. 일단 REST API를 호출한다면 외부의 HTTP resource를 이용하게 됩니다. 외부의 자원을 이용한다는 것은 그 외부의 자원에 대한 통제권은 우리가 개발한 application에는 없다는 말과 동일합니다. 따라서, 우리가 REST API를 이용하는 service에서 확인할 내용은 다음 두가지입니다.

* 원하는 URL로 정상적으로 호출하고 있는지?
* 전달하는 parameter가 API 호출 규약에 맞는지?

여기서 두가지의 역활이 나뉘게 됩니다. 먼저 원하는 URL로 호출하는 것은 당연히 service가 하는 일입니다. 그런데, 전달하는 parameter가 호출규약에 맞는지는 어떻게 확인하는 것이 좋을까요. 우리가 Spring을 이용한 개발을 진행하였을때를 생각해보면 쉽게 결론이 나옵니다. Validator객체를 만들어서 사용하는 것입니다.

따라서, REST API를 호출하는 service의 코드는 두개의 객체로 나뉘어지면 테스트 및 개발이 원활하게 됩니다. 이와 같은 형태는 마치 Spring MVC에서의 Controller 객체와 Validator 객체를 따로 개발하는 것과 동일한 패턴을 가지고 오게 됩니다. 객체지향이라는 것이 궁극적으로는 서로간에 할 일을 명확히 구분지어주고 그 분류대로 일을 진행하는 것이니까요.

먼저, 간단한 validator code는 다음과 같습니다.

'use strict';

/**
 * @ngdoc service
 * @name fmsmobilewebApp.ResultRegisterValidatorService
 * @description
 * # ResultRegisterValidatorService
 * Service in the fmsmobilewebApp.
 */
angular.module('fmsmobilewebApp').service('ResultRegisterValidatorService', function ResultRegisterValidatorService() {
  var self = this;

  self.validateStatus = function(data) {
    return angular.isDefined(data.status);
  };

  self.validateDepartment = function(data) {
    return angular.isDefined(data.departmentId);
  };

  self.validateOpResult = function(data) {
    var checked = angular.isDefined(data.opResult) &&
                  angular.isDefined(data.opResult.content) &&
                  angular.isDefined(data.opResult.startDate) &&
                  angular.isDefined(data.opResult.endDate);
    return checked;
  };
});

그리고 이에 대한 테스트 코드는 다음과 같습니다.

'use strict';

describe('Service: ResultRegisterValidatorService', function () {

  // load the service's module
  beforeEach(module('fmsmobilewebApp'));

  // instantiate service
  var service;
  beforeEach(inject(function (_ResultRegisterValidatorService_) {
    service = _ResultRegisterValidatorService_;
  }));

  it('validate for opResult', function() {
    var data = {};
    expect(service.validateOpResult(data)).toBe(false);
    data.opResult = {};
    expect(service.validateOpResult(data)).toBe(false);
    data.opResult.content = 'Content';
    expect(service.validateOpResult(data)).toBe(false);
    data.opResult.startDate = '2014-04-05 12:00';
    expect(service.validateOpResult(data)).toBe(false);
    data.opResult.endDate = '';
    expect(service.validateOpResult(data)).toBe(true);
  });

  it('validate for status', function() {
    var data = {};
    expect(service.validateStatus(data)).toBe(false);
    data.status = '0701';
    expect(service.validateStatus(data)).toBe(true);
  });

  it('validate for department', function() {
    var data = {};
    expect(service.validateDepartment(data)).toBe(false);
    data.departmentId = '0401';
    expect(service.validateDepartment(data)).toBe(true);
  });

});

Validator의 test code의 경우에는 기본적인 service code와 동일합니다. input에 따른 결과가 어떻게 나오는지만 확인하면 됩니다.

이제 가장 중요한 http에 대한 서비스 코드입니다. 우리는 test code를 작성하기 전에 REST API를 호출하는 행위에 대한 정의가 우선 필요합니다.
REST API를 호출하는 것은 다음과 같습니다.

  1. URL에 대한 HTTP request
  2. method에 따른 HTTP request
  3. HTTP response에 대한 결과값에 대한 parsing

결국은 우리가 확인할 것은 위 3가지입니다. 그리고 중점을 줘야지 되는 것을 따지자면 1, 2에 대한 확인입니다. 3에 대한 내용은 REST API의 호출 특성상, API 서버측에서 보내는 결과이기 때문에 그 결과값이 json format인 경우, 우리가 테스트를 해서 확인할 내용이 아닙니다. 우리는 그 결과값을 온전히 받아서 사용하는 client측에서만 생각하면 되기 때문에 1, 2번에 대한 내용만을 테스트한다면 우리가 원하는 test code를 작성할 수 있습니다.

angularJS는 angular-mock을 통해 $httpBackend 객체에 우리가 호출되는 URL, method를 확인할 수 있는 방법을 제공합니다. 일단 다음 상황을 가정해보도록 합니다.

GET /api/as/list

그리고, 이러한 url을 호출하는 API Service를 작성합니다.

function AsService(Restangular, AsServiceValidator) {
  var self = this;
  self.getList = function(status, department, func) {
    if(AsServiceValidator.checkStatus(status) && AsService.checkDepartment(department)) {
      Restangular.all('/api/as/list').get().then(function(jsonResult) {
        if(jsonResult.ok && func) {
          func(jsonResult);
        }
      });
      return true;
    } 
    return false;
  };
}

위 코드는 그 전에 이야기한 validator에 대한 체크 후, Restangular를 이용한 http call을 하고 있습니다. service에 대한 test code를 작성할 때, 반드시 $httpBackend에 대한 expect를 설정해야지 됩니다. 다음은 test code의 일부분입니다.

var httpBackend, service
beforeEach(inject(function($httpBackend, AsService) {
  httpBackend = $httpBackend;
  service = AsService;
}));

위 코드패턴을 이용해서 우리는 테스트의 대상이 되는 service와 $httpBackend를 얻어올 수 있습니다. 이제 우리가 원하는 API를 정상적으로 호출하고 있는지를 확인해보도록 하겠습니다.

it('as list REST API 호출', function() {
  httpBackend.expectGET(/\/api\/as\/list/\W*).respond('{ ok: true, data: null, message: data }');
  service.getList(status, department, function(jsonResult) {
    console.log(jsonResult);
  });
  httpBackend.flush();
});

REST API를 호출하는 service의 test code pattern은 다음과 같습니다.

  1. expectGET/expectPOST/expectDELETE/expectPUT method를 이용해서 request가 전달될 URL을 예상합니다.
  2. service를 호출합니다.
  3. httpBackend.flush() method를 호출합니다.

여기서 주의할점은 httpBackend.flush() 입니다. flush가 호출되게 되면, httpBackend는 httpRequest에 대한 response를 발생시킵니다. 만약에 expect에 정의되지 않은 http request가 발생되게 되면 exception을 발생하게 됩니다. REST API의 response를 명확히 알고 있다면, 그 결과값에 대한 처리 로직도 테스트가 가능합니다.

REST API service에 대한 test code작성방법에 대해서 다시 한번 정의해보도록 하겠습니다.

  1. input에 대한 validator의 분리가 필요 > validator 테스트 코드
  2. $httpBackend.expectXXX method를 이용한 REST API 호출 확인

이 두가지에 중점을 주면 이제 service code 역시 테스트가 가능하게 됩니다.


저작자 표시 동일 조건 변경 허락
신고
Posted by xyzlast Y2K
filter의 경우에는 배열에 대한 조건을 처리하고 있기 때문에, 최대한 가벼울 필요가 있는 객체이기도 합니다. 또한, non-HTTP service의 경우에는 값의 계산이나 validation에 대한 처리가 일반적입니다.

filter의 테스트는 다음을 확인하면 됩니다.

input array data의 filtering이 정상적으로 되었는지
> count를 이용하는 것이 일반적입니다.

다음은 간단한 filter code입니다.

angular.module('fmsmobilewebApp').filter('planFilter', function () {
  return function (plans, status) {
    if(status == '' || status == '0') {
      return plans;
    } else {
      var result = [];
      angular.forEach(plans, function(plan) {
        if(plan.statusId == status) {
          result.push(plan);
        }
      });
      return result;
    }
  };
});

위와 같은 filter code의 경우, filter의 input 값을 개발자가 제어를 해서 처리하면 간단히 해결됩니다. 다음과 같은 방법으로요.

describe('Filter: planFilter', function () {
  beforeEach(module('fmsmobilewebApp'));
  var planFilter;
  var plans;

  beforeEach(inject(function ($filter) {
    planFilter = $filter('planFilter');
    plans = [{"section":"정기","sectionId":"1801","type":"일반","mainBody":"대성","classification":"보고관리","priority":"1일이내","planDate":"2014-09-02 16:00","statusId":"0805","name":"A 보고관리 (주 2회)","id":"33913","department":"관리팀","orderDate":"2014-09-11","status":"완료"},{"section":"정기","sectionId":"1801","type":"일반","mainBody":"대성","classification":"보고관리","priority":"1일이내","planDate":"2014-09-05 16:00","statusId":"0805","name":"A 보고관리 (주 2회)","id":"33914","department":"관리팀","orderDate":"2014-09-11","status":"완료"}];
  }));

  it('not filtered (status is empty)', function() {
    var count = plans.length;
    expect(planFilter(plans, '').length).toBe(count);
    expect(planFilter(plans, '0').length).toBe(count);
  });

  it('filtered plans', function() {
    var count = 0;
    var status = '0805';
    angular.forEach(plans, function(plan) {
      if(plan.statusId == status) {
        count++;
      }
    });
    expect(count).toBeGreaterThan(0);
    expect(planFilter(plans, status).length).toBe(count);
  });

});


저작자 표시 동일 조건 변경 허락
신고
Posted by xyzlast Y2K
yo의 angular-generator를 이용한 angularJS web application을 테스트 하는 방법을 한번 정리해보도록 하겠습니다.

먼저, 테스트의 대상은 다음과 같습니다.

  • filter, service (non-HTTP)
  • service (HTTP)
  • directive
  • controller

angularJS로 만들어지는 객체들은 모두 test의 대상이 될 수 있습니다. 그렇지만, 저 순서가 조금 중요한 것이 기본적으로 service의 테스트 없이, controller의 테스트는 어렵습니다. 그리고 directive 역시 내부에서 service를 사용하고 있다면, service의 test가 우선이 되어야지 됩니다. 따라서, 위 순서와 비슷하게 dependency에 따른 test의 순서를 결정해보는 것이 좋습니다.

xUnit를 이용한 test와 마찬가지로, unit이라는 범위안에서 dependency가 외부 library(jquery, restangular etc..)등에만 의존이 되어 있는 객체들 부터 테스트를 진행하고, dependency가 내부 객체들에 의존되어 있다면, 그 객체들의 테스트가 우선되어야지 됩니다. 이러한 조건을 먼저 생각하는 것부터 테스트를 시작할 수 있습니다. 그리고, 이 순서는 일반적으로 위에 적어놓은 객체들의 순서로 진행되는 것이 일반적입니다.

환경 설정

karma-jasmine을 먼저 설치해야지 됩니다. 기본으로 설정되어 있는 jasmine test framework를 karma를 통해 실행하기 위한 기본 library입니다.

npm install karam-jasmine

설치후, 사용할 browser에 따른 launcher를 설치해야지 됩니다. 자신의 개발 환경에 맞는 launcher를 설치해주면 됩니다. window, linux, mac에 모두 설치가 가능한 web browser를 선택해주는 것이 개발환경을 구성하는 데 편합니다.

npm install karma-chrome-launcher
npm install karma-firefox-launcher
npm install karma-ie-launcher
npm install karma-safari-launcher

launcher의 경우, karam.conf.js 파일에 사용할 browser의 설정이 필요합니다.

browsers: ['Chrome']

karma로 테스트를 진행하기 위해서는 karma.conf.js 파일을 주의깊게 봐야지 됩니다. 테스트가 초기에 진행되지 않는 모든 이유는 바로 이 파일안에 있습니다. 먼저 확인해봐야지 될 것이 우리가 bower 또는 외부 상용 library들에 의존되는 application을 작성하고 있다는 것을 확인해야지 됩니다. 이는 모든 library들이 load 될 때에, 기본적인 library들이 먼저 loading 되어야지 되는 것을 의미합니다. 기본적인 library들을 loading하는 것을 karma에서는 files로 정의하고 있습니다. 또한, files에는 test의 target이 되는 library들과 unit test code역시 정의되어 있어야지 됩니다. 따라서, files는 다음과 같은 구성을 갖게 됩니다.

    files: [
      // bower components
      'app/bower_components/jquery/dist/jquery.js',
      'app/bower_components/angular/angular.js',
      'app/bower_components/angular-mocks/angular-mocks.js',
      'app/bower_components/angular-resource/angular-resource.js',
      'app/bower_components/angular-cookies/angular-cookies.js',
      'app/bower_components/angular-sanitize/angular-sanitize.js',
      'app/bower_components/angular-route/angular-route.js',
      'app/bower_components/angular-touch/angular-touch.min.js',
      'app/bower_components/lodash/dist/lodash.min.js',
      'app/bower_components/mobile-angular-ui/dist/js/mobile-angular-ui.min.js',
      'app/bower_components/restangular/dist/restangular.min.js',
      'app/bower_components/angular-local-storage/angular-local-storage.min.js',
      // 3rd party library
      'app/library/kendo/js/kendo.all.min.js',
      // Test target
      'app/scripts/*.js',
      'app/scripts/**/*.js',
      // Test code
      'test/mock/**/*.js',
      'test/spec/**/*.js'
    ],

특히 jquery의 경우에는 사용하고 있는 경우, 최상단에 넣어주는 것이 좋습니다. 많은 library들이 jquery에 의존적이며, angularJS의 경우에는 내장된 jqLite가 jquery가 있는 경우 jquery를 사용하도록 되어 있기 때문입니다.

이제 karma를 구동할 grunt에 대한 설정을 해보도록 하겠습니다. yo를 이용한 web application을 구성하는 경우, 다음 3가지의 application이 모든 동작을 제어하게 됩니다.

  • yeoman : code에 대한 scafolding 제공, code의 기본적인 구조 제공. template을 이용한 code generater라고 이해하면 쉽습니다.
  • bower : gradle, maven과 같은 library dependency 관리 툴입니다. 이제 jquery를 얻기 위해서 home page에 들어가서 다운 받을 필요가 없습니다.
  • grunt : build tool입니다. test의 진행 또는 webserver의 시작등을 모두 grunt를 통해서 진행합니다.

grunt는 Gruntfile.js 파일을 기반으로 동작합니다. 여기서 karma에 관련된 설정만 처리하면 됩니다. 큰 변경은 필요없고, 개발과 test를 동시에 자동으로 처리하도록 설정하면 개발이 매우 편합니다.

    // Test settings
    karma: {
      unit: {
        configFile: 'karma.conf.js',
        singleRun: false,
        authWatch: true
      }
    }
  • singleRun(default : true) : test가 한번만 진행됩니다. 테스트를 연속적으로 계속 진행할 수 있도록 true로 수정해주면 좋습니다.
  • authWatch(default : false) : 파일의 변경이 있는 경우, test를 다시 진행합니다. 충분히 빠릅니다. 약 90여개의 test code의 경우, 1sec내로 테스트를 진행할 수 있습니다.

이제 테스트를 진행할 수 있는 환경 구성이 모두 마쳐졌습니다. 먼저 jasmine에 대한 소개를 간략하게 하고, 각 객체의 test를 하는 방법에 대하여 알아보도록 하겠습니다.

jasmine

jasmine은 javascript의 test framework로서, xUnit의 역활을 맡게 됩니다.
jasmine의 소개는 다음 url에서 확인해보실 수 있습니다. http://jasmine.github.io/2.0/introduction.html

기본적으로 jasmine은 suites를 기반으로 동작합니다. suites는 descirbe로 선언이 되며, 이 test code가 어떤 test code인지를 서술하는 기능을 갖습니다.

describe('sample test code suit', function() {

});

xUnit에서 사용되는 @Test/[Test]는 it으로 정의됩니다.

describe('sample test code suit', function() {
  it('sample test code unit', function() {
      // Sample Test code
  });
});

xUnit의 @SetUp/[SetUp]은 beforeEach로 정의 됩니다.

describe('sample test code suit', function() {
  beforeEach('pre-run codes each test codes', function() {
      // SetUp code
  });
  it('sample test code unit', function() {
      // Sample Test code
  });
});

xUnit의 @TearDown/[TearDown]은 afterEach로 정의됩니다.

describe('sample test code suit', function() {
  beforeEach('pre-run codes each test codes', function() {
      // SetUp code
  });
  it('sample test code unit', function() {
      // Sample Test code
  });
  afterEach('after-run codes each test codes', function() {

  });
});

beforeEach, afterEach의 경우 여러개가 있어도 상관 없으며, 여러 코드 블럭이 있는 경우, 위에서부터 아래로 code가 실행되게 됩니다.

기본적으로 xUnit에서 사용되는 Test code의 개념을 그대로 들고 왔기 때문에, 사용하기가 매우 편한것을 알 수 있습니다. jasmine은 이제 test code의 기계적인 확인을 위한 여러 matcher 들을 제공합니다. 다음은 matcher들의 종류입니다.

matcher description
expect(target).toBe(value) value와 같은지를 확인합니다.
expect(target).toEqual(value) value와 같은지, 그리고 type이 동일한지를 비교합니다.
expect(target).toBeGreaterThan(value) value보다 값이 큰지를 비교합니다.
expect(target).toBeNull() target이 null인지 확인합니다.
expect(target).toBeDefined() target이 define되어 있는지 확인합니다.
expect(target).toBeTruthly() target이 true인지 확인합니다. 이는 if(target) 의 결과와 동일합니다.
expect(target).toBeFalsy() target이 false인지 확인합니다. 이는 if(!target) 의 결과와 동일합니다.
모든 matcher는 not을 가질 수 있습니다.
> expect(target).not.toBe(value);
> expect(target).not.toBeNull();

jasmine은 method에 대한 call 확인을 지원합니다. 하나의 method가 내부의 특정 method를 호출을 했는지, 호출의 input parameters들을 확인할 수 있습니다. 다음은 예시코드입니다.

  var obj = {
    method1 : function() {
      console.info('call method1');
      this.method2('test value');
    },
    method2 : function(value) {
      console.info(value);
    }
  };

  describe('obj test', function() {
    it('obj method2 called Test', function() {
      spyOn(obj, 'method2');
      obj.method1();
      expect(obj.method2).toHaveBeenCalled();
      expect(obj.method2).toHaveBeenCalledWith('test value');
    });
  });

spyOn 을 이용해서, 감시할 대상을 선정하고, method의 호출을 확인할 수 있습니다. spyOn을 이용한 method의 호출에 대한 matcher는 다음과 같습니다.

matcher description
expect(obj.method).toHaveBeenCalled() method가 호출이 되었는지 확인합니다.
expect(obj.method).toHaveBeenCalledWith(“args”) method가 특정 argument들이 전달되었는지 확인합니다.

저작자 표시 동일 조건 변경 허락
신고
Posted by xyzlast Y2K

angularJS를 이용한 single document web application을 개발 할 때, 내용에 대한 정리가 필요해서 한번 정리해봅니다.

angularJS MVVM 이해하기


(그림의 출처는 http://www.mimul.com 입니다.)

Spring MVC를 이용하고 있다면, MVC의 개념에 대해서는 잘 이해하고 있을겁니다. 개인적으로 javascript framework에서 이야기하는 MVVM이라는 것은 약간의 marketing 용어라는 느낌이 강하게 들기 때문에, 거의 기존의 MVC와 동일하게 생각하면 됩니다.

Model

데이터 관리 및 통신등의 역활을 담당합니다. 이는 Spring MVC를 이용한 개발시에 표현되는 @Service와 동일하게 생각해도 됩니다. Service와 Factory로 구성이 되며 이는 singleton 여부와 동일하게 생각하면 됩니다. Service는 singleton으로, factory는 prototype으로요.

주로 사용되는 것은 REST API를 호출하는 call layer로 구성이 되는 경우가 많습니다. 이에 대한 결과에 대한 caching과 logic을 처리하는 것이 주가 되는데 개인적으로 추천을 한다면 Restangular module을 사용하는 것이 보다 더 편하고 좋은 코드 가독성을 가지고 올 수 있습니다. (https://github.com/mgonto/restangular)

Controller

DOM event handling과 $scope를 이용한 DataBinding 역활을 담당합니다. Spring MVC에서의 Controller와 Model의 역활이 합쳐져 있다고 이해하시면 편합니다. @Controller : Controller class, Model: $scope 로 생각하시면 좋습니다. DataBinding과 DOM event handling을 모두 가지고 있기 때문에 $scope의 코드의 method 들은 최대한 간결하고 명확한 naming이 좋습니다.

View

HTML 영역입니다. View의 표현이 중복되는 것이 많아진다면 directive를 만들어서 처리를 하면 HTML의 재활용도를 높일 수 있습니다. 다만, 저같이 기억력이 안좋은 사람에게는 사용할 때마다 directive의 code를 다시 한번 확인해야지 되는 단점을 가질 수 도 있습니다.;

angularJS를 이용한 개발 시 유의점

Memory leak

이 상황은 single document application을 구성할 때, 가장 주의해야지 되는 내용입니다. 지금까지 javascript로 개발하는 상황과 많은 차이를 가지고 오는 부분이 이 memory leak 부분이라고 생각합니다. 이 부분에 대한 주의가 필요합니다. memory leak이 발생되는 상황임을 알 수 있는 방법은 chrome의 profile에서 memory snapshot을 얻어서 확인 할 수 있습니다. 일단 3가지 질문을 통해서 javascript의 memory leak이 발생되고 있는지를 확인 할 수 있습니다.

page가 너무 많은 memory를 사용하는 경우

너무나 많은 memory를 사용하고 있다면 chrome의 timeline memory view와 chrome task manager를 통해서 확인 할 수 있습니다. 경험적으로는 사용하지 않는 DOM을 계속해서 가지고 있는 경우. (unbind DOM, fragmented DOM)이 계속해서 생성이 되는 경우에 주로 이런 현상이 발생됩니다. 더 이상 필요없게 되는 DOM에 대한 event listener를 unbind 시키는 것이 매우! 중요합니다.

page가 memory leak에서 자유로운지

Chrome Profile에서 볼 수 있는 Object allocation tracker를 이용해서 javascript object의 할당을 확인해보는 것이 좋습니다. snapshot을 이용해서 GC가 일어날 때와 일어나지 않았을 때의 memory 차이를 알아보고 page가 이동이 된 후도 계속 남는 memory가 많은지를 확인해봐야지 됩니다.

Forcing GC가 자주 일어나는지

chrome timeline memory view를 이용하면 GC가 얼마나 자주 일어나는지를 확인할 수 있습니다. 이것이 자주 일어난다면 너무나 많은 객체들이 생성되고 있다는 것을 의미합니다. 객체를 너무나 많이 생성하고 있는 것에 대한 고민이 필요합니다.

위의 상황들중에서 가장 문제가 될 수 있는 것은 javascript의 fragmented DOM 또는 unbinded DOM에 대한 event listener로 인한 memory leak 입니다. 이는 jQuery UI를 angularJS single document application에서 사용하는 경우, 자주 발생할 수 있는데, 이는 각각의 component 들에 대한 event binding이 정상적으로 clear 되지 못해서 발생되는 문제입니다. 이 부분에 대해서는 다음 Blog post를 참조해보세요.

http://rinat.io/blog/angularjs-and-jquery-plugins-memory-leaks

이러한 unbinded DOM의 event listener를 해지하기 위해서는 $destroy event의 처리가 필요합니다.
다음과 같은 code snippet가 각 controller에서 위치해야지 됩니다. 이 부분은 사용되는 javascript UI framework 등에서 어떻게 memory에서 모든 객체들을 제거할 수 있는지에 대한 document를 참고하시는 것이 좋습니다. (jquery 기반의 UI component는 destroy() method를 갖는 것이 일반적입니다.)

$scope.on('$destroy', function() {
    var dateTimePicker = $scope.element('#dateTimePicker');
    if(dateTimePicker != null) {
        dateTimePicker = dateTimePicker.datetimepicker();
        dateTimePicker.destroy();
    }
});

또한, $destroy에서는 controller에서 사용되고 있는 모든 timer들의 해재역시 처리해주는 것이 좋습니다.

Directive의 올바른 이해와 사용

Directive를 올바르게 사용하는 것은 매우 중요한 문제입니다. 가장 우선적으로, Directive의 scope의 정의를 정확히 아는 것이 중요합니다. Controller의 $scope와 독립적인 scope가 생성 되나, 양방향 또는 단방향으로 controller와 통신을 하는 것이 가능합니다.

예를 들어, scope를 다음과 같이 선언할 수 있습니다.

    scope: {
      title: '@',
      parentCodeId: '@',
      data: '@',
      ngModel: '=',
      text: '=',
      name: '@',
      nullItem: '@',
      styleValue: '@'
    },

title, parentCodeId, data, name, nullItem, styleValue의 경우에는 attribute를 이용한 값 설정과 동일합니다. directive의 scope를 변경하는 것으로 controller가 변경되지 않습니다. 그렇지만, ngModel, text와 같이 ‘=’로 선언된 내용은 다릅니다. ‘=’로 선언된 내용은 directive를 통해서 수정된 값이 controller의 값을 변경시키게 됩니다.

이러한 양방향 DataBinding을 이용하게 되는 경우, Directive에서 event를 호출할 필요가 거의 없습니다. 최대한 event를 호출해서 처리하는 코드양을 줄이는 것이 속도면에서 유리합니다.

DataBinding의 처리

angularJS에서의 DataBinding은 $scope의 모든 변수에 대해서 이전의 값과 현재의 값을 비교하여, 값이 다른 경우 DOM을 갱신하는 방식입니다. $scope의 변수의 값을 비교하는 타이밍은 아래와 같습니다.

  • scope.$apply()가 호출될 때.
  • DOM event가 발생될 때. (onchange, onclick etc…)
  • http 응답이 발생된 경우. ($http, $resource)
  • url이 변경되는 경우
  • $timeout event가 발생되는 경우

이 부분에 대한 개선은 다음과 같이 처리하는 것이 좋습니다.

기본적인 angularJS module의 이용

jQuery와 angularJS를 혼용해서 사용하게 되는 경우가 많은데, 위와 같은 문제 때문에 최대한 angularJS의 모듈들을 사용하는 것이 좋습니다. 가장 좋은 것은 jQuery method를 사용하지 않는것이 좋습니다. 예를 들어 jQuery의 기본 $.ajax는 가장 자주 사용되는 http ajax 통신 method입니다. 그렇지만, 이 코드를 사용하는 경우, scope의 DOM update가 되지 않습니다. 따라서, $http service를 이용하는 것이 좋습니다.

또한, window의 경우에도 $window, document는 $document를 이용하는 것이 좋습니다. 그리고, WebSocket을 이용하는 경우도 역시 마찬가지의 문제가 발생됩니다. WebSocket의 promise를 반환하고 그 값을 얻어낼 때, $apply() method를 호출해주어야지 됩니다.

$watch 는 최대한 가볍게

$watch method는 잦은 호출이 발생될 수 있는 code입니다. 다음과 같은 처리가 반드시 필요합니다.

slow — Anything faster than 50ms is imperceptible to humans and thus can be considered as "instant".
limited — You can't really show more than about 2000 pieces of information to a human on a single page. Anything more than that is really bad UI, and humans can't process this anyway.

TIP

이 부분은 개인적으로 사용해보니 좋았던 TIP들입니다. 다른 분들은 다르게 느끼실 수 있는 부분입니다.

Parent Controller를 이용한 Controller common method의 공유

공통적으로 사용하고 싶은 method들을 MainController에 넣어두면 Controller를 작성하기가 매우 편해집니다. 예를 들어 back() 처리라던지, Confirm/Alert 과 같은 알람기능이나 resize에 대한 기본적인 처리와 같은것을 넣어두면 매우 유용하게 사용가능합니다. 이를 지원하기 위해서 기존 ng-view 상단에 ng-controller를 지정해서 처리해주면 됩니다.

    <div ng-controller="MainCtrl">
      <section ng-view="" onload="completedRender()"></section>
    </div>
angular.module('fmsPublicWebApp').controller('MainCtrl', function ($scope, $timeout, $window, Codeservice, $location) { 
    $scope.showGlobalMessage = function(type, title, message) {

    }
});
$http, $resource보다는 Restangular를 사용

bower를 이용해서 쉽게 추가 가능한 Restangular를 사용하면 좋은 가독성을 갖는 코드를 짜는 것이 가능합니다. 약간 옆으로 길어지는 코드 pattern이 생기긴 하지만, 직관적인 호출이라는 강점을 가질 수 있습니다.

jQuery Library를 사용할 때는 주의할것!

최대한 jquery에 의존하지 않고 사용하는 것이 더 편합니다. angularJS는 jqLite라는 jquery의 mini version을 가지고 있습니다. 기본적인 jQuery select와 같은 기능들은 대부분 가지고 있기 때문에 angular.element를 사용하는 것이 더 좋습니다.

그리고, jQuery UI component를 사용하게 되는 경우에는, directive로 새로 만들어서 사용하는 것을 강력 권장합니다. 위에서 이야기드린 것처럼, jQuery UI library의 destroy를 정확하게 호출하지 않는 경우에는 memory leak이라는 매우 미묘한 문제를 가지고 오게 됩니다. 개발이 한참 진행된 이후에 memory leak을 발견하게 되는 경우에는 찾기가 정말 힘들어질 수 있습니다.

$scope에는 외부에 노출되어야지 되는 변수 및 method만을 정의 한다.

$scope의 값은 angularjs의 dirty-search의 대상입니다. 불필요한 내부값 및 내부 method 들을 $scope에서 사용해서 굳이 성능저하를 만들 필요는 없습니다.

Summary

AngularJS는 정말로 재미있는 Framework입니다. 특히 single document application을 개발할 때, 그 장점을 더 살릴수 있습니다. 그렇지만, 다음 항목들에 대해서는 좀더 주의가 필요합니다.

  1. memory leak
  2. DataBinding의 이해
  3. 재사용 가능한 HTML과 Controller


저작자 표시 동일 조건 변경 허락
신고
Posted by xyzlast Y2K

Spring Security를 이용한 REST API를 개발할 때, 외부 사이트와의 데이터 연동을 위해 CORS를 적용하는 것도 매우 좋은 선택이다. 

일반적으로 Cross domain 문제를 해결하기 위해서, JSONP를 이용하는 경우가 많지만 이 방법은 두가지 이유때문에 개인적으로는 추천하지 않는다. 


1. GET method만을 이용 가능 - 데이터를 많이 보내야지 되는 경우가 발생할 수 있고, 무엇보다 REST한 API를 만드는데 제약사항이 발생하게 된다.

2. 인증 문제 - 인증을 받아서 처리를 해줘야지 되는 API의 경우에는 JSONP를 이용할 수 없다. 모든 정보를 API서버에만 위치하고, HTML + javascript로만 동작하는 web application을 작성하는 것이 목적이라면 인증을 처리하기 위해서라도 JSONP가 아닌 CORS를 적용해야지 된다. 


그러나 CORS 역시 많은 제약사항을 가지고 있다. 기본적인 제약사항들은 다음과 같다.  


1. GET, HEAD, POST 만 사용 가능하다.

2. POST의 경우에는 다음과 같은 조건이 경우에만 사용가능하다.

  1) content-type이 application/x-www.form-urlencoded, multipart/form-data, text/plain의 경우에만 사용 가능하다.

  2) customer Header가 설정이 된 경우에는 사용 불가하다. (X-Modified etc...)

3. Server에서 Access-Control-Allow-Origin 안에 허용여부를 결정해줘야지 된다. 


큰 제약사항은 위 3가지지만, 세부적으로는 preflight 문제가 발생하게 된다. preflight란, POST로 외부 site를 call 할때, OPTIONS method를 이용해서 URL에 접근이 가능한지를 다시 한번 확인하는 절차를 거치게 된다. 이때, 주의할 점이 WWW에서 제약한 사항은 분명히 content-type이 application/xml, text/xml의 경우에만 preflight가 발생한다고 되어있으나, firefox나 chrome의 경우에는 text/plain, application/x-www-form-urlencoded, multipart/form-data 모두에서 prefligh가 발생하게 된다. 


위 이론을 먼저 알고, Spring Security를 적용한 REST API Server를 구축하기 위해서는 다음과 같은 절차가 우선 필요하다.


1. Spring Security Form authentication endPoint의 변경

: Spring Security Form authentication은 인증되지 않은 Request가 접근한 경우, login page로 302 redirect를 발생시킨다. 이렇게 되면 API를 이용해서 로그인의 실패등을 확인하기 힘들기 때문에, 인증되지 않은 Request를 처리하는 방법을 달리 해줘야지 된다. 기본적으로 Digest Authentication 역시 www는 지원하기 때문에 Digest 인증 방식의 end point를 이용해서 인증되지 않은 request가 접근한 경우 302가 아니라 401(NotAuthenticated)를 반환할 수 있도록 Spring Configuration을 변경하도록 한다. 


2. CSRF disable

: 다른 domain에서의 API call이 발생하기 때문에 CSRF salt cookie값을 얻어내는 것은 불가능하다. 따라서, CSRF를 disable시켜야지 된다. (이 부분은 해결방법이 다른 것이 있는지 확인이 필요)


3. Login Processing을 재구현

: Spring Security를 이용한 Form Authentication의 경우, login success의 경우에도 마찬가지로 redirect가 발생하게 된다. AuthenticationSuccessHandler interface를 이용해서 변경시켜주거나, Login / Logout을 아애 새로 만들어주는 것이 필요하다. 개인적으로는 Login / Logout을 새로 만들어주는 것을 선호하는 편인데, Controller에 대한 Test code 역시 만들어주는 것이 가능하고, 좀더 깔끔해보이는 느낌이 든다.


4. CORS Filter의 적용

: 모든 response 에 Allow-Origin header를 삽입해주는 Filter 객체가 반드시 필요하다. 


Spring Security의 Form 인증을 새로 만들어주기 위해서는 다음과 같이 Controller를 만들 필요가 있다.


@Controller

public class LoginController {

    public static final String API_LOGIN = "/api/login";

    public static final String API_LOGOUT = "/api/logout";

    @Autowired

    private AuthenticationManager authenticationManager;


    @Autowired

    private UserService userService;


    @RequestMapping(value= API_LOGIN, method = {RequestMethod.GET, RequestMethod.OPTIONS})

    @ResponseBody

    @ResultDataFormat

    public Object getAuthenticationStatus() {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        if (auth != null && !auth.getName().equals("anonymousUser") && auth.isAuthenticated()) {

            User user = userService.findByUsername(auth.getName());

            return new LoginStatus(true, auth.getName(), user.getName());

        } else {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value= API_LOGIN, method = RequestMethod.POST)

    @ResponseBody

    @ResultDataFormat

    public Object login(@RequestParam("username") String username, @RequestParam("password") String password) {

        try {

            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);

            User user = userService.findByUsername(username);

            token.setDetails(user);

            Authentication auth = authenticationManager.authenticate(token);

            SecurityContextHolder.getContext().setAuthentication(auth);

            return new LoginStatus(auth.isAuthenticated(), user.getUsername(), user.getName());

        } catch (Exception e) {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value = API_LOGOUT)

    @ResponseBody

    @ResultDataFormat

    public Object logout(HttpServletRequest request) {

        HttpSession session = request.getSession(false);

        if(session != null) {

            session.invalidate();

        }

        SecurityContextHolder.clearContext();

        return new LoginStatus(false, null, null);

    }


    @Getter

    public class LoginStatus {

        private final boolean isAuthenticated;

        private final String username;

        private final String name;

        public LoginStatus(boolean loggedIn, String username, String name) {

            this.isAuthenticated = loggedIn;

            this.username = username;

            this.name = name;

        }

    }

}




다음에는 CORS Filter 적용 및 Spring Security Configuration을 이용해서 REST Server 구성을 더 해보도록 하겠다.




저작자 표시 비영리 변경 금지
신고
Posted by xyzlast Y2K


티스토리 툴바