티스토리 뷰

Angular

Angular 1 + ES6

한장현 2016. 12. 7. 01:22

안녕하세요. 한장현입니다.


이번에 발표 기회가 생기면서, 이전에 발표했던 Angular 2 대신 다른 주제를 준비해봤습니다.

Angular 2는 한 번 다뤘고... 주최측에서 동영상도 마련을 해두었으니 같은 주제를 하면 재미 없을 것 같았어요 ㅎ


그래서 준비한 주제는 Angular 1 + ES6 입니다.

발표 내용을 기반으로 블로그에도 정리해봅니다.

SlideShare에 올려둔 것과 같이 보시면 더 좋습니다.




<Angular 1 + ES6>



오늘 언급할 라이브러리들


Angular 1


 Angular 1의 오늘자 최신 버전은 11월 24일에 발표된 1.6.0-rc-2 safety-insurance 입니다. Angular 2와 함께 SemVer를 따른다고 하지만... 뒤에 설명이 길게 붙는건 여전하네요. 안정 버전은 1.5.9 입니다.

 Angular 1를 쓰는 이유는 명확하죠.


- 마술 같은 2-way binding
 Angular 1만 양방향 바인딩을 지원하는 것은 아니지만, 양방향 바인딩은 Angular 1에서 손에 꼽을 수 있는 편리함입니다. View와 Controller의 로직이 많이 간략해집니다.

- HTML 표준을 기반으로 기능 확장
 디렉티브는 기존의 HTML element를 기반으로 커스텀 엘리먼트나 어트리뷰트를 확장하는 방식입니다. ES5에서 Angular 1만을 쓴다면 기존의 JS, CSS, HTML 안에서 확장이 가능합니다.

- 체계적인 컴포넌트 구성, Web Component로 가는 길
 디렉티브 외에도 컨트롤러나 싱글턴 서비스를 구현하여 컴포넌트로 사용할 수 있습니다. 이 방식은 최종적으로 Web Component 표준과 잘 맞물리죠.
 개인적으로 React에서 JSX 파일 포맷을 사용하는 것보다 기존에 있던 파일들 안에서 기능적으로 확장하는 방식이 마음에 듭니다.

- Front-end 전체를 커버하는 프레임워크

 타 프레임워크(예를 들면 React)와는 다르게, Angular만 가지고 프론트엔드의 원하는 기능을 모두 사용할 수 있습니다. 라우팅이나 AJAX, 스테이트 관리, 여기에 Angular Material을 추가하면 반응형 머티리얼 디자인이나 그래픽 폰트도 사용할 수 있습니다.

 타 프레임워크에서는 필요한 기능을 더하기 위해 여러가지 라이브러리를 가져와 함께 쓰는 경우를 볼 수 있는데, Angular의 통일성을 유지하는 방식이 더 낫다고 생각합니다.

- Google에서 관리, 개선

 Angular 1.0.0rc 마지막 버전이 나온 것이 2012년 10월 2일이네요. 시기적으로 꽤 오래되었고 처음부터 Google이 만든 것은 아니었지만, Google이 인수하면서 지속적으로 성능을 개선하고 유지관리 하고 있습니다. 1.4버전이 발표되면서 성능이 비약적으로 향상되기도 했고 1.5에서는 component가 도입되기도 했죠.

 믿을 만 한 회사에서 프레임워크를 유지보수하는 것은 프레임워크의 신뢰도에 큰 영향을 줍니다.

- 풍부한 사용자 삽질 경험

 Angular를 사용하다가 문제가 생기면 대부분 구글링으로 해결 가능합니다. 많은 사용자들에 의해 버그 리포팅이 되고 질문, 답변이 풍부한 것도 장점입니다.


ES6


 ECMAScript 2015도 발표된지 시간이 좀 지나면서 사용자 경험이 많이 쌓인 상태입니다. ES6에서 도입된 클래스와 상속, 모듈의 import / export, Arrow function과 같은 syntax sugar, Promise가 표준으로 도입되면서 ES5와는 사뭇 다른 새로운 아키텍처로 발전하고 있습니다.

 브라우저들도 계속해서 ES6를 지원하고 있으며 이후에 트랜스파일러가 없이도 돌아가는 모양을 취할 것 같습니다. 어찌됐건 표준이니까요.


Angular 1 + ES6

 이제 Angular1를 ES6로 써봅시다.

 Angular 1를 ES6로 쓰려는 시도는 개인 프로젝트를 진행하면서였습니다. MEAN 스택으로 개발을 하고 있었는데 node.js에 ES6를 사용하게 됐고, 어차피 ES6를 쓰는데 프론트 엔드도 ES6를 쓰지 않을 이유가 없다고 생각해서 시작하게 됐죠. 프론트엔드를 webpack으로 번들링하면서 브라우저가 지원하지 않는 문제도 없었구요. 나아가 테스트 케이스도 모두 ES6로 작성하고 있습니다.


스크립트 로드

 스크립트 로드하는 방식부터 ES5와는 크게 달라집니다.

 ES5에서는 html 안에서 script src로 angular를 들고 와야 하고 사용하는 모든 js파일을 불러와야 하지만, ES6에서는 webpack으로 번들링 하기 때문에 bundle.js 파일 하나만 추가합니다.

- ES5

<script src="angular.js">
<script src="index.js">

- ES6

<script src="bundle.js">


index.js

 인덱스 파일은 큰 차이가 없습니다. 마찬가지로 webpack을 사용하므로, import angular가 들어가네요.

- ES5

(function () {
    var ngApp = angular.module('angular1es5', []);
})();

- ES6

import angular from 'angular';
(() => {
    const ngApp = angular.module('angular1es6', []);
})();


이 때 webpack 설정은 이렇습니다. 저는 pug가 편해서 pug 로더도 추가했습니다. webpack.config.js 파일은 커맨드라인에서 webpack을 부를 때 사용되기 때문에 ES5로 작성해야 합니다.

- webpack.config.js

// this is ES5
var webpack = require('webpack');

module.exports = {
	entry : [
		'./index.js'
	],
	output : {
		path : './build',
		filename : 'bundle.js'
	},
	module : {
		loaders : [
			{
				test : /\.js$/,
				loader : 'babel?presets[]=es2015', // use inline preset config for multiple loader
				exclude : /node_modules/
			},
			{
				test : /\.pug$/,
				loader : 'pug-loader',
				exclude : /node_modules/
			}
		]
	},
	plugins : [
		// new webpack.optimize.UglifyJsPlugin({minimize: true})
	]
};


인덱스 파일은 이제 이렇게 되네요. 기본 루트 경로에 대한 라우팅을 HomeCtrl로 연결한 모습입니다.

- index.js

import angular from 'angular';
import ngRoute from 'angular-route';

import HomeCtrl from './view/homeCtrl';

(() => {
	console.log('main()');

	const ngApp = angular.module('angular1es6', ['ngRoute']);

	ngApp.config(($routeProvider, $locationProvider) => {
		console.log('this is angular config');

		$routeProvider
			.when('/', {
				template : require('./view/home.pug'),
				controller : 'HomeCtrl',
				controllerAs : 'Ctrl'
			})
			.otherwise({
				redirectTo : '/'
			});

		// need to angular.js routing
		$locationProvider.html5Mode({
			enabled : true,
			requireBase : false
		});
	});

	ngApp.controller('HomeCtrl', HomeCtrl);
})();

- homeCtrl.js

export default class HomeCtrl {
	constructor ($scope) {
		console.log('HomeCtrl.constructor()');
	}
}

- home.pug

p this is home


돌려보면 이런 화면이 나오네요



Angular 컴포넌트

 이제 기본 틀을 갖춘 상태에서 컴포넌트를 만들어봅시다. ES6를 그대로 활용하는 상태에서 컴포넌트를 생성하는 방법은 export를 어떤 방식으로 하는 지에 따라 세 가지로 나눌 수 있습니다.

 효율적인 관리를 위해 개별 파일로 만든 후 index.js에 추가해줍니다. 이 때 import도 필요하죠.


* function 사용

 필터는 특정 인자를 받아 필터링하는 동작이므로 함수로 구현합니다.

- index.js

ngApp.filter('uppercase', uppercase);

- uppercase.filter.js

const uppercase = () => {
	return (input) => {
		return input.toUpperCase();
	};
};

export default uppercase;



* Class 사용

 일반적인 라우트 컨트롤러는 클래스를 사용합니다.

- index.js

$routeProvider
	.when('/', {
		template : require('./view/home.pug'),
		controller : 'HomeCtrl',
		controllerAs : 'Ctrl'
	})

- homeCtrl.js

export default class HomeCtrl {
	constructor ($scope) {
		console.log('HomeCtrl.constructor()');

		this.$scope = $scope;

		this.count = 0;
	}

	select () {
		console.log('HomeCtrl.select()');

		return Promise.resolve()
			.then(() => {
				this.count++;

				this.$scope.$apply();
			});
	}
}

- home.pug

.ctrlRoot
	p this is home ctrl

	p count : {{ Ctrl.count }}

	button(ng-click="Ctrl.select()") Select


클래스를 쓰니까 가독성이 좋아지는 것 같네요 ㅎ. 기본적으로 controllerAs를 사용하기 때문에 $scope는 사용하지 않지만 Promise에 의한 스코프 컨텍스트 반영을 위해 $scope.$apply()를 사용하게 되고, $scope는 멤버로 가지고 있는 방식이 편했습니다.


 서비스도 클래스를 사용합니다. ES6의 성격으로 보면 클래스에 static을 붙인 함수를 사용해야 할 것 같은데 static을 붙이면 에러가 납니다. Angular 내부에서 충돌이 있는 것 같네요.

- index.js

ngApp.service('MyService', MyService);

- MyService.js

export default class MyService {
	constructor () {
		console.log('MyService');
	}

	testFnc () {
		console.log('MyService.testFnc()');
	}
}

- homeCtrl.js

export default class HomeCtrl {
	constructor (MyService) {
		this.MyService = MyService;
	}

	select () {
		this.MyService.testFnc();
	}
}


 신기했던 점은, 컨트롤러에서 서비스를 사용하기 위해 생성자에 MyService를 선언하면 Angular의 injector에 의해 의존성이 주입된다는 것이었습니다. 이것을 멤버 변수로 지정해두었다가 필요할 때 사용하면 됩니다.

 역시 서비스에서도 코드가 깔끔해진 느낌입니다.


* new Class 사용

 디렉티브는 클래스로 export 하고 new 로 등록해야 합니다. 인스턴스로 생성해서 등록하지 않으면 에러가 납니다. 그리고 기존에 객체로 지정하던 restrict나 template, scope와 같은 항목은 생성자에서 this로 선언합니다.

- index.js

ngApp.directive('myDirective', () => new MyDirective);

- MyDirective.js

export default class MyDirective {
	constructor () {
		console.log('MyDirective.constructor()');

		this.restrict = 'E';
		this.template = 'message : {{ this.msg }}';
		this.scope = {
			msg : '@'
		};
	}

	controller ($scope) {
		console.log($scope.msg);
	}
}


 ES5로 사용할 때와 마찬가지로 link(), compile(), controller() 함수도 Angular 라이프 싸이클 안에서 사용할 수 있습니다.


* Directive vs. Component

 Angular 1.5에서 component 객체가 추가되었습니다. 웹 컴포넌트 표준을 의식한 것처럼 element directive에 대한 컴포넌트를 위해 나온 객체인데, 간단하게 이야기하면 디렉티브와 다릅니다. component는 엘리먼트에 한정되어 있고 모델 바인딩 방식도 다릅니다.

 컴포넌트가 중심 내용이 아니니 컴포넌트는 다음에 기회가 있으면 다시 다루도록 하겠습니다.


Promise

 Promise는 써야죠... 이제 표준 객체로 들어왔으니 서드 파티 라이브러리를 사용하지 않아도 Promise를 사용할 수 있습니다.

 Promise에 대한 오해 중 하나가, 콜백 지옥을 벗어날 수 있게 해준다는 것인데, 이건 아니라고 봐야 합니다. 로직이 복잡한 것은 코드가 늘어지는 것은 어쩔 수 없습니다.

 Promise를 쓰는 장점은, 코드의 가독성 향상과 흐름을 좀 더 편하게 제어하는 것입니다. then()으로 연결된 체인 중 어느 하나에서 Promise.reject()가 돌아와도 catch()에서 받도록 코드를 작성하면 불필요한 코드를 타지 않도록 할 수 있습니다.

 다만, Angular에서는 reject 객체로 끝내지 않도록 해야 합니다. 문제는 없지만 콘솔에 에러를 표시하서든요 ㅎ

 일단 Promise를 사용하면 가독성이 아~~~주 좋아집니다~~ ㅎ 아래 왼쪽과 같은 코드는 좀 아니었어요...


* Webpack bundling

 이 프로젝트에서는 Webpack으로 번들링 하기 때문에 스타일도, 템플릿도 require를 이용하여 자연스럽게 불러옵니다. 편해요 ㅎ



Angular 1 + ES6 + BDD = Hell

 이제 Angular에서 제공하는 훌륭한 mock을 이용해서 BDD를 작성해 봅니다. 지금은 방법들을 찾았지만, BDD를 진행하면서 막히는 부분이 엄청 많았습니다..... 한 단계 나아갈 때마다 막히는 것들이 많았어요....

 테스트 프레임워크는 mocha, chai, karma, chrome을 사용합니다.


* BDD 시작

 기본 테스트 케이스부터 작성해봅니다.

describe('homeCtrl.test', () => { it('module import', () => { expect(true).to.be.true; }); });


 이 코드는 일단 Chrome에서는 돕니다. PhantomJS 에서는 돌지 않습니다. 다른 브라우저에서도 돌지 않을 수 있습니다. 원인은 브라우저에서 ES6를 지원하는지가 문제입니다.

 그리고 다음 코드는 Chrome에서도 돌지 않습니다.

import HomeCtrl from '../view/homeCtrl';

describe('homeCtrl.test', () => {
	it('module import', () => {
		console.log(HomeCtrl);
		expect(true).to.be.true;
		expect(HomeCtrl).to.be.ok;
	});
});

 마찬가지로 Chrome에서 import를 지원하지 않기 때문입니다. 트랜스파일러가 필요합니다.....

 처음에는 Webpack을 사용해서 돌려볼까 했는데, 그러면 어느 파일에서 에러가 났는지 찾기 어려울 것 같았습니다. 번들링과 karma를 따로 돌려야 하는 번거로움도 있었죠.

 이래 저래 며칠 동안 알아보다가 browserify와 babel-preprocessor를 이용하기로 합니다.


- karma.conf.js 일부

frameworks : ['browserify', 'mocha', 'chai'],
...
preprocessors : {
	'test/*.test.js' : ['browserify']
}


* module 선언, injection 불가

 기존에 module()로 선언하던 방식을 사용할 수 없습니다. 테스트용 인덱스 파일을 만들고 angular.injector()를 사용해야 합니다.

 angular.injector()에서 가져온 객체를 가지고 의존성도 가져올 수 있습니다.


- textIndex.js

let ngApp = angular.module('angular1es6', ['ngMock']);

- text file

const $injector, $httpBackend;

beforeEach(() => {
	$injector = angular.injector(['angular1es6']);
        $httpBackend = $injector.get('$httpBackend');
});


* ngMock vs. ngMockE2E

 한참 ngMockE2E를 사용하다가 문제가 발생했습니다. end-to-end 테스트는 ngMockE2E로도 가능하지만, $location을 사용할 수 없었습니다. 찾아보니 ngMock과 ngMockE2E는 angular-mocks 같은 파일에 있지만 동시에 사용할 수는 없고 조금 다릅니다. 자세한 내용을 읽어보면 기능에서도 차이가 나고, 지원하는 기능도  조금 다릅니다. ngMock을 사용하기로 합니다.


* Promise + http.flush()

 Promise를 사용하면서 http.flush()에도 문제가 생깁니다. 한 Promise 안에서 http 동작을 수행하고 flush()를 하면 문제가 없지만, 이 동작이 다른 Promise로 갈리게 되면 "No pending request to flush" 를 보게 됩니다. Promise가 생성되고 실행되는 타이밍과 flush 타이밍이 달라져서 발생하는 문제입니다.

 이 문제는 클라이언트 유틸에서 하나의 함수로 처리하면서 해결했습니다. 테스트에서는 ClientUtil에 http와 httpBackend를 지정해서 강제로 flush()를 시켜주고, 실제 사용에서는 http.post()만으로 동작하는 코드가 됩니다.

- testIndex.js

const $httpBackend = $injector.get('$httpBackend');
const $http = $injector.get('$http');

ClientUtil.http = $http;
ClientUtil.httpBackend = $httpBackend;

- 테스트 파일

static post (uri, param, headers) {
	// use new promise for flush()
	return new Promise((resolve, reject) => {
		this.http.post(uri, param, (headers ? { headers } : null))
			.then(result => {
				resolve(result);
			}, error => {
				console.error(error);
				reject(new ERROR(Constants.ERROR.COMMON.AJAX_FAILED, console.error, 'post()'));
			});

		if (this.httpBackend && this.httpBackend.flush) {
			this.httpBackend.flush();
		}
	});
}


* 여러 테스트 파일 동시 실행

 이렇게 테스트 파일을 만들기 시작하다가, karma 설정에서 여러 테스트 파일을 동시에 실행시키면 오류가 발생합니다. 개별로는 잘 돌던 코드가...

 이 문제는 각 테스트 파일에서 의존성을 가져온 인스턴스가 서로 달라서 충돌하는 것으로 확인했습니다. 위 testIndex.js 에서는 사실 이 문제가 해결된 버전인데, ClientUtil에서 http와 httpBackend 를 한 번 지정하고 재지정 없이 사용합니다.


* 생성자 안에서 Promise

 생성자 안에서 Promise를 사용하는 경우는 테스트 케이스에서 이 내용을 확인할 수 없습니다. 생성자가 결과를 반환하는 것이 아니기 때문에 테스트 파일에서 값을 받을 수 없고, 함수 종료 타이밍도 잡을 수 없습니다.

 이 때는 개별 함수로 나눠서 그 Promise 함수를 테스트하는 방식으로 해결했습니다.

export default class HomeCtrl {
	constructor () {
		console.log('HomeCtrl.constructor()');
		this.somethingPromise();
	}

	somethingPromise () {
		return Promise.resolve()
			.then(() => {
				// do something
			});
	}
}


* Promise + $scope.$apply()

 Promise의 실행 컨텍스트는 angular의 실행 싸이클과 살짝 달라서, Promise에서 컨텍스트 안의 값을 바꿔도 한 싸이클 늦게 view에 적용되는 것을 볼 수 있습니다. 이 때 angular의 컨텍스트에 값을 반영하기 위해 $scope를 주입하고 Promise가 끝날 때 $scope.$apply()를 실행해줘야 합니다. 함수 한 번 호출로 간단하게 해결할 수 있습니다 ㅎ

export default class HomeCtrl {
	constructor ($scope) {
		console.log('HomeCtrl.constructor()');
		this.$scope = $scope;
		this.count = 0;
	}

	select () {
		console.log('HomeCtrl.select()');
		return Promise.resolve().then(() => {
			this.count++;
			this.$scope.$apply();
		});
	}
}



결론


 지금까지 ES6를 사용해서 Angular 1 코드를 작성하는 것에 대해 알아봤습니다.

 Angular를 ES6로 작성해 본 최종 감상은, 아주 좋다!! 입니다 ㅎ

 Angular의 코드를 ES6로 작성하는 것 뿐이지만, 은근히 막히는 부분이 많았던 프로젝트였습니다. 하지만 한 번 길을 찾아두면 같은 작업을 할 때는 문제 없이 사용 가능했고, 무엇보다도 ES6의 특성에 맞는 아키텍처와 모듈 활용이 가능합니다.

 클래스를 사용하는 코드에서는 가독성도 더 좋아지는 것 같네요.

 백엔드와 프론트엔드 모든 코드를 ES6로 작성했으니 백엔드의 코드를 프론트엔드에 끌어 쓰는 것도 가능합니다. ES6와 webpack을 활용한 또 다른 장점이지요 ㅎ


 처음 틀을 잡는 것에 어려움이 있을까봐 미리 만들어 둔 기본 틀을 공유합니다. Angular 1.5.9로 작성되었습니다.

https://github.com/han41858/angular1-es6


감사합니다.


댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함