리액트의 Quick start 페이지를 따라하면서 노트한 내용이다. js의 컨텍스트에서 이해할 수 있는 부분은 적지 않았다. 코드 스니핏도 간단히 알아볼 수 있게만 적어놔서 전체 내용이나 설명이 궁금하다면 본문을 확인하는게 좋겠다.

연습 환경 설치

node를 쓴지 오래되어서 업데이트부터 했다. nvm이 있어야 한다.

$ nvm ls-remote
$ nvm install v9.3.0 && nvm alias default v9.3.0
$ nvm use default
$ npm install -g yarn

npx를 이용해서 playground라는 이름으로 프로젝트를 생성한다. npx는 npm 5.2.0+부터 사용할 수 있다.

$ npx create-react-app playground
$ cd playground
$ yarn start

JSX

리액트는 마크업과 로직을 인위적으로 분리하지 않고 대신에 컴포넌트라는 단위를 만들어 약하게 결합하도록 만들었다. JSX를 꼭 사용할 필요는 없지만 시각적으로 더 편리하다.

리액트 엘리먼트는 ReactDOM.render()로 렌더링한다. root DOM 노드를 지정하면 그 내부의 모든 노드를 리액트가 관리한다.

const element = <h1>Hello, world</h1>;
ReactDOM.render(element, document.getElementById('root'));
function formatName(user) { /* ... * /}

const element = (
  <h1>
    Hello, {formatName(user)}!
  </h1>
);

// 함수형 컴포넌트
function HelloBlock() {
  return <div>Hello</div>;
}

// 클래스 컴포넌트
class GoodbyeBlock extends React.Component {
  render() {
    return <div>Good bye</div>;
  }
}

노드 트리를 직접 조회하지 않고 ReactDOM을 통해 비교한 후, 변경된 사항만 반영하기 때문에 DOM을 직접 읽고 조작하는 방식보다 간결하다.

props

컴포넌트는 자바스크립트 함수와 같아서 인자 입력(props)을 받고 리액트 엘리먼트를 반환한다.

const Greeting = (props) => <h1>Hello, {props.name}!</h1>;

function Greeting(props) {
  return (
    <h1>Hello, {props.name}!</h1>
  );
}

class Greeting extends React.Component {
  render () {
    return <h1>Hello, {this.props.name}!</h1>
  }
}

props는 엘리먼트의 어트리뷰트로 전달한다. expression은 {}로, 문자열은 ""로 보낸다.

const element = <Comment user={user} text="string value blarblar" />;

모든 리액트 컴포넌트는 props를 변경하지 않는 순수 함수처럼 동작해야 한다. 이 규칙을 깨지 않고 출력값을 변경하기 위해 state라는 개념이 있다.

State

State는 props과 비슷하지만 private이고 컴포넌트가 전적으로 제어한다. state는 클래스로 작성한 컴포넌트에서 사용할 수 있다.

이 State를 제어하기 위해 componentDidMount()componentWillUnmount()와 같은 생애주기 훅(hook)을 사용한다. State에 대한 지정은 setState() 메소드를 사용한다.

this.state = {} 형태는 오직 constructor() 내에서만 사용 가능하며 그 외에는 setState()를 사용해야 한다. 그러지 않으면 렌더링에 반영되지 않는다.

한번 갱신하는데 setState() 호출을 여러 차례 한다면 경쟁 상태가 될 수 있다. 대신 함수 형태로 전달하는 것이 가능하다.

this.setState((prevState, props) => ({
  conter: prevState.counter + props.increment
}));

setState로 전달한 개체는 this.state에 병합되는 방식으로 동작한다. 전달하지 않은 프로퍼티는 영향을 받지 않는다.

state는 컴포넌트에 속했기 때문에 외부에서는 어떻게 정의되어 있는지 알 수 없고 알 필요도 없다. 지역적, 캡슐화되어 있다고 이야기하는 이유.

컴포넌트가 stateful, stateless인지는 때마다 다르게 정의해서 사용할 수 있음.

이벤트 제어

return <a href="#" onClick={handleClick}>Click Me</a>;

(이제는 시멘틱웹 얘기 부질 없는 것입니까. 나 너무 오래된 사람인듯.)

handleClicke.preventDefault()를 명시적으로 사용해야 함. (return false 넣는거 싫어하는 사람이라서 이런 방식 좋음.) 이 e는 W3C 스펙에서의 그 SyntheticEvent인데 리액트에 맞게 랩핑되어 있다.

이벤트에 넘겨줄 때는 js 특성 상 컨텍스트를 명시적으로 지정해야 한다. 즉, this 바인딩을 잊지 말아야 한다.

constructor(props) {
  super(props);
  // ...
  this.handleClick = this.handleClick.bind(this);
}

인자를 전달할 때는,

<button onClick={this.deleteRow.bind(this, id)}>Delete This</button>
// 이러면 클릭했을 때 `deleteRow(id, e)`로 호출해준다.

이 귀찮음을 피하기 위한 대안 두 가지로 public class fields 문법을 사용하는 방식과 익명 함수를 사용해 스코프를 전달하는 방식을 제안하는데 전자는 아직 확정된 문법이 아니다. 후자는 그나마 깔끔하지만 렌더링 될 때마다 새 콜백이 생성된다. 대부분 괜찮지만 하위 컴포넌트로 전달되었을 때 불필요한 추가 랜더링이 계속 나타나서 성능에 영향을 줄 수 있다.

// 1. public class fields syntax (experimental)
class Button extends React.Component {
  handleClick = () => {
    console.log('this is', this);
  }
  // ...
}

// 2. arrow funtion as callback
class Button extends React.Component {
  render () {
    return <button onClick={(e) => this.handleClick(e)}>Click me</button>;
  }
}

조건부 렌더링

if 사용하면 된다! 인라인으로 사용하고 싶다면 { expression && <p>Elements</p> } 식으로 사용한다. if-else는 삼항연산자를 사용한다.

null을 반환하면 화면에 렌더링하지 않는다. 예시로 에러 메시지 표시 나왔다. 렌더링 안해도 생애주기 훅은 여전히 호출된다.

리스트와 키

const faces = ['?', '?', '?', '?', '?', '?', '?', '?'];
const listItems = faces.map((face, index) => (
  <li key={index}>
    {face}
  </li>
));

// 안정적인 id를 key로 사용하고 없으면 최후의 수단으로
// 배열 자체의 인덱스를 사용한다. 여기는 예시니까 그냥
// 인덱스를 사용했다.

const FaceList = () => <ul>{listItems}</ul>;

// or inline format
const FaceList = () => (
  <ul>
  {faces.map((face, index) => (
    <li key={index}>
      {face}
    </li>
  ))}
  </ul>
)

변화를 감지하고 반영하기 위해서는 id가 필요한데 key로 지정된 값을 활용한다. 또한 같은 계층에는 키가 유일해야 한다.

키는 리스트에서만 사용하고 각 항목 컴포넌트 정의에서 지정하지 않는다. 위에서 보면 li 역할하는 컴포넌트를 정의할 때 key를 지정하는게 아니라 listItems처럼 배열을 책임지는 컴포넌트에서 key를 지정해야 한다.

key는 prop처럼 작성하지만 실제로 해당 컴포넌트에 전달되진 않는다. 전달하려면 다른 이름의 prop을 명시적으로 지정해야 한다.

폼 Form

그냥 html 쓰듯 작성해도 문제 없다! 하지만 js가 있어야 미려한 기능을 만들 수 있는건 당연하고. 이럴 때는 controlled 컴포넌트를 만들어서 해결한다.

<input> 같은 엘리먼트는 상태를 스스로 관리한다. 리액트는 가변값을 state에 저장하고 setState()를 사용한다. 이 두가지를 하나로 합쳐 State를 “single source of truth”로 사용하고 사용자의 입력을 여기에 반영하는 식으로 만든다. 폼 엘리먼트를 리액트에서 관리하니까 controlled 컴포넌트라고 한다.

// bind 생략
handleChange(event) {
  this.setState({
    address: event.target.value
  });

  // 여러 input을 처리할 때는 computed property name 문법으로
  this.setState({
    [event.target.name]: event.target.value
  });
}
// ...
render() {
  return <input name="address" type="text" value={this.state.value} onChange={this.handleChange} />
}

input[type="file"]은 읽기 전용인데 이런 경우는 uncontrolled 컴포넌트라고 한다.

value에 직접 값을 전달하면 사용자가 값을 변경할 수 없다. 값을 전달한 이후에 다시 값을 변경할 수 있게 하려면 undefinednull을 다시 전달해야 한다.

state 위로 보내기 lifting state up

prop에 state를 조작할 수 있는 함수를 만들어서 전달하면 자식 노드에서도 부모 노드의 state를 간접적으로 조작할 수 있다. 이 방법을 “lifting state up” 라고 말한다.

아래는 NameInput에서 변경된 내용을 WelcomeBoard에서 전달받은 onNameChange를 사용해 갱신하는 방식이다.

class NameInput extends React.Component {
  handleChange(e) {
    this.props.onNameChange(e.target.value);
  }
  render() {
    return (
      <input type="text"
        value={this.props.name}
        onChange={this.handleChange.bind(this)} />
    )
  }
}

class WelcomeBoard extends React.Component {
  constructor(props) {
    super(props);
    this.state = {name: 'stranger'};
  }

  handleNameChange(name) {
    this.setState({
      name: name === '' ? 'stranger' : name,
    });
  }

  render() {
    return (
      <div>
        <p>Hello, {this.state.name}!</p>
        <NameInput
          name={this.state.name}
          onNameChange={this.handleNameChange.bind(this)} />
      </div>
    )
  }
}

구성 vs 상속

자식 노드로 어떤 것이 오게 될지 미리 알 수 없다. 그래서 특별한 prop으로 children이 존재한다.

function Box() {
  return <div>{props.children}</div>;
}

이제 다음과 같이 사용하면 알아서 내부 노드로 처리한다.

<Box>
  <NameInput />
  <NameInput />
  <NameInput />
</Box>

Containment의 예시로 SplitPane를 작성했다.

Specialization의 예시로 Dialog를 작성했다.

상속을 사용하는 경우는 적절한 유즈케이스가 없다고 한다. UI와 관련되지 않은 로직을 공유해야 할 경우라도 상속보다는 별도의 JS 모듈로 작성해서 import 하는 방식을 권장한다.

리액트 식으로 생각하기

리액트를 사용할 때 어떤 방식으로 접근해야 하는지 설명한다.

  1. Mock에서 시작. 프로토타이핑과 mock 데이터를 갖고 시작한다.
  2. UI를 나눠 컴포넌트 계층을 만든다.
    • 단일 책임 원칙
    • 데이터 구조에 맞게
  3. React에서 정적인 페이지로 작성한다.
    • state를 사용하지 않음
    • 데이터는 단방향으로
  4. UI state의 Representation을 모든 경우를 소화할 수 있으면서도 최소한으로 파악한다.
  5. state가 어디에 위치해야 하는지 파악한다.
    • owner 컴포넌트, 또는 상위 컴포넌트
    • 적당한 컴포넌트가 없다면 빈 컴포넌트라도 만들어서
  6. 데이터 흐름이 반대가 되야 하는 경우를 파악한다.
    • lifting state up

Todd Motto의 글 Mastering Angular dependency injection with @Inject, @Injectable, tokens and providers를 번역했다. Angular 내에서 의존성 처리를 위해 어떤 과정을 거치는지 내부적인 구조를 이해하는데 도움이 되었다.


Angular의 의존성 주입 이해하기 – @Inject, @Injectable, 토큰과 프로바이더

Angular의 프로바이더는 애플리케이션을 개발하는데 있어 핵심적이며 의존성을 주입할 수 있는 다양한 방식을 제공한다. 이 포스트는 @Inject()@Injectable() 데코레이터 뒤에서 일어나는 일을 살펴보고 사용하는 방법을 확인하려고 한다. 그런 후에 토큰과 프로바이더를 이해하고 Angular가 실제로 어떻게 의존성을 찾고 생성하는지 살펴본다. 또한 Ahead-of-Time 소스 코드도 설명할 것이다.

프로바이더 주입하기

Angular는 의존성 주입(DI)를 사용할 때 수많은 마법 같은 일이 일어난다. Angular 1.x에서는 문자열 토큰을 사용해서 세세한 의존성을 처리하는 간단한 접근 방식을 사용했다. 이 방법은 이미 알고 있을 것이다.

function SomeController($scope) {
  // $scope를 사용한다
}
SomeController.$inject = ['$scope'];

DI 어노테이션 처리에 대해 더 자세히 알고 싶다면 예전 포스트를 참고하자.

좋은 접근 방식이지만 한계점이 있었다. 애플리케이션을 만들 때는 다양한 모듈을 만드는 것이 자연스러운 과정이다. 필요에 따라서 기능 모듈이나 라이브러리처럼 외부 모듈을 불러오기도 한다. (예를 들면 ui-router) 다른 모듈이라 하더라도 컨트롤러/서비스 등 동일한 이름을 사용할 수 없고 만약 동일한 이름이 있다면 컴파일 차례에서 충돌이 발생하게 될 것이다. (의존성이 같은 명칭을 갖고 있다면 서로를 덮어 쓰려고 하기 때문에 충돌이 발생한다.)

다행스럽게도 Angular의 새로운 의존성 주입은 완전히 새로 작성했으며 더 강력하고 유연한 방식으로 의존성 주입을 처리한다.

새로운 의존성 주입 방식

서비스(프로바이더)를 컴포넌트/서비스에 주입하려고 한다면 필요로 하는 프로바이더를 생성자에 _타입 정의_로 작성할 수 있다.

import { Component } from '@angular/core';
import { Http } from '@angular/http';

@Component({
  selector: 'example-component',
  template: '<div>I am a component</div>'
})
class ExampleComponent {
  constructor(private http: Http) {
    // `this.http` 로 Http 프로바이더에 접근 가능.
  }
}

여기서 사용한 타입 정의는 Http (H가 대문자)며 Angular는 자동으로 마법같이 이 서비스를 http에 배정했다.

여기까지만 봐도 상당히 마법같이 동작하는 것을 알 수 있다. 타입 정의는 타입스크립트에서만 사용할 수 있는 기능이다. 이론적으로는 컴파일된 자바스크립트 코드가 브라우저에서 실행되기 전까지는 http가 실제로 어떤 파라미터인지 알 방법이 없다.

tsconfig.json 파일을 살펴보면 emitDecoratorMetadata 값이 true로 설정되어 있다. 타입 파라미터를 컴파일된 자바스크립트 출력물에서 데코레이터로 사용할 수 있도록 메타데이터를 같이 내보내게 된다.

실제로 컴파일된 코드는 어떤 식인지 살펴본다. (읽기 편하도록 ES6의 모듈 불러오는 코드는 그대로 두었다.)

import { Component } from '@angular/core';
import { Http } from '@angular/http';

var ExampleComponent = (function () {
  function ExampleComponent(http) {
    this.http = http;
  }
  return ExampleComponent;
}());
ExampleComponent = __decorate([
  Component({
    selector: 'example-component',
    template: '<div>I am a component</div>'
  }),
  __metadata('design:paramtypes', [Http])
], ExampleComponent);

컴파일된 코드를 보면 @angular/http로 제공된 Http 서비스가 http와 동일하다는 점을 이해하고 있다. 데코레이터로 클래스에 다음처럼 메타데이터를 추가하게 된다.

__metadata('design:paramtypes', [Http])

기본적으로 @Component 데코레이터는 일반 ES5로 변경되었고 추가적인 metadata가 제공되며 __decorate를 통해 배정되고 있다는 점을 확인할 수 있다. 이런 과정으로 Angular는 Http 토큰을 확인한 후 컴포넌트 constructor의 첫 인자로 전달하고 this.http에 배정하게 된다.

function ExampleComponent(http) {
  this.http = http;
}

이 동작 방식은 $inject의 방식과 비슷하다. 하지만 _문자열_로 처리되던 토큰이 클래스 형태로 다뤄진다는 점에서 다르다. 명명에 의한 충돌이 없고 더 강력하다.

아마도 “토큰”에 대한 컨셉을 들은 적이 있을 것이다. (또는 OpaqueToken) Angular는 이 접근 방법으로 프로바이더를 저장하고 사용한다. 사용하려는 프로바이더를 참조할 때 토큰을 열쇠로 사용하는 것이다. (위 코드에서는 불러온 Http가 프로바이더다.) 하지만 기존 키와는 다르게 개체, 클래스, 문자열 등 어떤 것이든 올 수 있다는 점에서 다르다.

@Inject()

@Inject()는 어디서 사용할 수 있을까? 컴포넌트를 앞서 작성한 방식과는 다르게 다음처럼 작성할 수 있다.

import { Component, Inject } from '@angular/core';
import { Http } from '@angular/http';

@Component({
  selector: 'example-component',
  template: '<div>I am a component</div>'
})
class ExampleComponent {
  constructor(@Inject(Http) private http) {
    // 여기서 `this.http`를 Http 프로바이더로 사용할 수 있음
  }
}

이 코드에서 @Inject는 해당 토큰을 직접 찾아 배정하는 방법이다. 소문자 http 인자 뒤에 타입을 작성하는 방법과는 반대로 말이다.

이 방식은 컴포넌트나 서비스가 많은 의존성을 필요로 할 때 엉망이 될 수 있다. (그리고 엉망이 될 것이다.) Angular에서는 메타데이터를 추가해 의존성을 해결할 수 있기 때문에 대부분의 경우는 @Inject가 필요하지 않다.

OpaqueToken를 사용하는 경우가 유일하게 @Inject를 사용해야 하는 상황이다. 이 경우에는 의존성 주입 프로바이더에서 접근하기 위해 비어 있는 고유 토큰으로 사용할 수 있다.

@Inject를 이 경우에 사용해야 하는 이유는 OpaqueToken을 파라미터의 _타입_으로 사용할 수 없기 때문이다. 다음처럼 작성하게 되면 동작하지 않을 것이다.

const myToken = new OpaqueToken('myValue');

@Component(...)
class ExampleComponent {
  constructor(private token: myToken) {}
}

myToken은 타입이 아닌 값이다. 즉, 이 경우에는 타입스크립트가 컴파일을 할 수 없다. 하지만 OpaqueToken과 함께 사용할 수 있는 @Inject는 다음처럼 유용하게 사용할 수 있다.

const myToken = new OpaqueToken('myValue');

@Component(...)
class ExampleComponent {
  constructor(@Inject(myToken) private token) {
    // `token` 프로바이더를 사용할 수 있음
  }
}

여기서 OpaqueToken을 더 깊이 다루지는 않는다. 하지만 주입할 토큰을 수동 처리할 때 필요한 @Inject를 어떻게 사용할 수 있는지 살펴봤다. 어떤 것이든 이제 토큰으로 사용할 수 있을 것이다. 다시 말하면 타입스크립트에서 정의한 “타입”으로 제한하지 않고 사용한다는 뜻이다.

@Injectable()

@Injectable()은 컴포넌트나 서비스에 의존성 주입을 위해 필수적인 데코레이터라는 점은 흔하게 나타나는 잘못된 믿음이다. 실제로는 현재 이슈가 있어서 @Injectable()을 필수로 사용하는 것이지 이 사실은 아마도 변경될 예정이다. (이 변경은 상당히 최신이라 한동안 반영되지 않을 수도 있고 아예 반영되지 않을 수도 있다.)

Angular 데코레이터를 사용하면 꾸며진 클래스(decorated class)를 Angular가 읽을 수 있는 양식의 메타데이터로 보관하게 된다. 이 메타데이터에는 의존성을 어떻게 찾고 주입하는지에 대한 정보가 들어있다.

하지만 Angular 데코레이터를 사용하더라도 Angular가 어떤 방법으로도 필요한 의존성 정보를 찾을 수 없는 클래스가 있다고 생각해보자. 이런 경우에 @Injectable()을 사용해야 한다.

서비스에서 프로바이더를 주입한다면 반드시 @Injectable()을 사용해야 한다. 프로바이더는 추가적인 기능이 없기 때문에 Angular에 필요한 메타데이터가 저장될 수 있도록 지정해야 한다.

즉, 서비스를 다음처럼 작성했다고 가정하자.

export class UserService {
  isAuthenticated(): boolean {
    return true;
  }
}

예로 든 컴포넌트는 어떤 프로바이더도 주입하지 않고 있기 때문에 데코레이터를 사용할 필요가 없다.

하지만 서비스가 의존성(Http)을 포함하고 있다면 다음처럼 작성하게 된다.

import { Http } from '@angular/http';

export class UserService {
  constructor(private http: Http) {}
  isAuthenticated(): Observable<boolean> {
    return this.http.get('/api/user').map((res) => res.json());
  }
}

이 코드는 Http 프로바이더의 메타데이터가 저장되지 않아서 Angular가 제대로 의존성을 해결하지 못할 것이다.

이런 문제는 간단히 @Injectable()로 풀 수 있다.

import { Injectable } from '@angular/core';
import { Http } from '@angular/http';

@Injectable()
export class UserService {
  constructor(private http: Http) {}
  isAuthenticated(): Observable<boolean> {
    return this.http.get('/api/user').map((res) => res.json());
  }
}

이제 Angular는 Http 토큰을 인식하고 http에 무엇을 주입해야 하는지 알게 되었다.

토큰과 의존성 주입

이제 어떻게 Angular가 주입하는지 알았으니 어떻게 의존성을 해결하고 인스턴스로 바꾸는지 배울 수 있다.

프로바이더 등록하기

NgModule 내에 어떻게 전형적인 서비스를 등록하는지 살펴보자.

import { NgModule } from '@angular/core';

import { AuthService } from './auth.service';

@NgModule({
  providers: [
    AuthService
  ]
})
class ExampleModule {}

위 코드는 짧은 문법으로 풀어서 작성하면 다음과 같다.

import { NgModule } from '@angular/core';

import { AuthService } from './auth.service';

@NgModule({
  providers: [
    {
      provide: AuthService,
      useClass: AuthService
    }
  ]
})
class ExampleModule {}

개체 내 provide 프로퍼티는 등록할 프로바이더를 위한 토큰이다. Angular가 AuthService 토큰에 어떤 내용이 있는지 useClass 값을 사용해서 살펴본다는 뜻이다.

이 방식은 장점이 여러가지 있다. 먼저 동일한 class명인 프로바이더가 있더라도 Angular에서 올바른 서비스를 참조하는데 전혀 문제가 없다. 둘째로 _토큰_이 동일하다면 기존에 존재하는 프로바이더를 다른 프로바이더로 덮어 쓸 수 있다는 점이다.

프로바이더 덮어쓰기

AuthService를 다음처럼 작성했다고 가정하자.

import { Injectable } from '@angular/core';
import { Http } from '@angular/http';

@Injectable()
export class AuthService {

  constructor(private http: Http) {}

  authenticateUser(username: string, password: string): Observable<boolean> {
    // returns true or false
    return this.http.post('/api/auth', { username, password });
  }

  getUsername(): Observable<string> {
    return this.http.post('/api/user');
  }

}

이 서비스를 애플리케이션 전체에 사용하고 있다고 상상해보자. 사용자가 로그인 하기 위한 양식도 만들었다.

import { Component } from '@angular/core';
import { AuthService } from './auth.service';

@Component({
  selector: 'auth-login',
  template: `
    <button (click)="login()">
      Login
    </button>
  `
})
export class LoginComponent {

  constructor(private authService: AuthService) {}

  login() {
    this.authService
      .authenticateUser('toddmotto', 'straightouttacompton')
      .subscribe((status: boolean) => {
        // 사용자가 로그인 후 해야 할 내용 작성
      });
  }

}

이제 사용자명을 표시하기 위해 사용자 정보를 서비스에서 가져와 사용할 수 있다.

@Component({
  selector: 'user-info',
  template: `
    <div>
      You are {% raw %}{{ username }}{% endraw %}!
    </div>
  `
})
class UserInfoComponent implements OnInit {

  username: string;

  constructor(private authService: AuthService) {}

  ngOnInit() {
    this.authService
      .getUsername()
      .subscribe((username: string) => this.username = username);
  }

}

이 내용을 AuthModule라는 이름 아래에 모듈로 묶을 수 있다.

import { NgModule } from '@angular/core';

import { AuthService } from './auth.service';

import { LoginComponent } from './login.component';
import { UserInfoComponent } from './user-info.component';

@NgModule({
  declarations: [
    LoginComponent,
    UserInfoComponent
  ],
  providers: [
    AuthService
  ]
})
export class AuthModule {}

각각의 컴포넌트를 다 뒤져서 새 프로바이더를 참조하도록 변경해야 한다. 토큰을 사용했다면 직접 다 수정하는 대신에 AuthService 토큰에 FacebookAuthService을 대신 사용하도록 얹는 것이 가능하다.

import { NgModule } from '@angular/core';

// totally made up
import { FacebookAuthService } from '@facebook/angular';

import { AuthService } from './auth.service';

import { LoginComponent } from './login.component';
import { UserInfoComponent } from './user-info.component';

@NgModule({
  declarations: [
    LoginComponent,
    UserInfoComponent
  ],
  providers: [
    {
      provide: AuthService,
      useClass: FacebookAuthService
    }
  ]
})
export class AuthModule {}

위 프로바이더 설정을 보면 단순히 useClass 프로퍼티에 다른 값으로 바꾼 것을 볼 수 있다. 이 방법으로 어플리케이션 내에서 AuthService를 사용하는 곳이라면 변경된 프로바이더가 적용된다.

이런 이유로 Angular에 프로바이더를 찾기 위한 용도에서 AuthService를 토큰으로 사용한다. 새로운 클래스인 FacebookAuthService로 교체한 대로 모든 컴포넌트는 이 클래스를 사용하게 될 것이다.

인젝터(injector) 이해하기

여기까지 읽었다면 토큰과 Angular의 의존성 주입 시스템을 이해했을 것이다. 다음 장에서는 더 상세하게 이해하기 위해 Angular에서 컴파일된 AoT 코드 안을 들여다 보려고 한다.

사전 컴파일된 코드

컴파일된 코드를 살펴보기 전에 사전 컴파일된 버전의 코드를 보려고 한다. 사전 컴파일은 무엇일까? 그 코드는 Ahead-of-Time 컴파일 이전에 우리가 작성한 코드다. 기본적으로 우리가 작성한 모든 코드는 사전 컴파일에 해당하며 Angular는 JiT을 사용해 브라우저에서 컴파일을 하는 것이 가능하다. 또는 더 효과적인 접근 방식으로 오프라인에서 컴파일(AoT)을 수행할 수 있다.

이제 어플리케이션을 다 만들었다고 생각하고 아래 NgModule 코드를 확인해보자.

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { Routes, RouterModule } from '@angular/router';
import { HttpModule } from '@angular/http';

import { AppComponent } from './app.component';

export const ROUTER_CONFIG: Routes = [
  { path: '', loadChildren: './home/home.module#HomeModule' },
  { path: 'about', loadChildren: './about/about.module#AboutModule' },
  { path: 'contact', loadChildren: './contact/contact.module#ContactModule' }
];

@NgModule({
  imports: [
    BrowserModule,
    HttpModule,
    RouterModule.forRoot(ROUTER_CONFIG),
  ],
  bootstrap: [
    AppComponent
  ],
  declarations: [
    AppComponent
  ]
})
export class AppModule {}

이 코드는 상당히 익숙하다. 루트 컴포넌트와 다른 모듈로 연결하는 몇 라우트가 포함되었다. 이 코드를 Angular가 컴파일 한다면 실제 코드는 어떤 모양일까?

Angular는 VM (가상 머신) 친화적 코드를 생성해서 가능한 한 성능 좋은 코드를 만들어 낸다. 환상적인 일이다. 컴파일된 코드를 열어보고 조금 더 상세하게 알아보자.

AppModuleInjector

Angular는 각 모듈을 위한 인젝터(주입자, injector)를 생성한다. 위 코드에서는 AppModule (데코레이트된 코드)를 사용해서 AppModuleInjector라는 이름의 인젝터를 생성한다.

생성된 AppModuleInjector를 확인해보자.

import { NgModuleInjector } from '@angular/core/src/linker/ng_module_factory';
import { CommonModule } from '@angular/common/src/common_module';
import { ApplicationModule, _localeFactory } from '@angular/core/src/application_module';
import { BrowserModule, errorHandler } from '@angular/platform-browser/src/browser';
import { RouterModule, ROUTER_FORROOT_GUARD } from '@angular/router/src/router_module';
import { NgLocaleLocalization, NgLocalization } from '@angular/common/src/localization';
import { ApplicationInitStatus, APP_INITIALIZER } from '@angular/core/src/application_init';
import { Testability, TestabilityRegistry } from '@angular/core/src/testability/testability';
import { HttpModule } from '@angular/http/src/http_module';
import { ApplicationRef, ApplicationRef_ } from '@angular/core/src/application_ref';
import { BrowserModule } from '@angular/platform-browser/src/browser';
import { Injector } from '@angular/core/src/di/injector';
import { LOCALE_ID } from '@angular/core/src/i18n/tokens';
import { RouterModule, provideForRootGuard } from '@angular/router/src/router_module';
import { Router } from '@angular/router/src/router';
import { NgZone } from '@angular/core/src/zone/ng_zone';
import { Console } from '@angular/core/src/console';
import { ROUTES } from '@angular/router/src/router_config_loader';
import { ErrorHandler } from '@angular/core/src/error_handler';

import { AppModule } from './app.module';
import { AppComponentNgFactory } from './app.component.ngfactory';

class AppModuleInjector extends NgModuleInjector<AppModule> {
  _CommonModule_0: CommonModule;
  _ApplicationModule_1: ApplicationModule;
  _BrowserModule_2: BrowserModule;
  _ROUTER_FORROOT_GUARD_3: any;
  _RouterModule_4: RouterModule;
  _HttpModule_5: HttpModule;
  _AppModule_6: AppModule;
  _ErrorHandler_7: any;
  _ApplicationInitStatus_8: ApplicationInitStatus;
  _Testability_9: Testability;
  _ApplicationRef__10: ApplicationRef_;
  __ApplicationRef_11: any;
  __ROUTES_12: any[];

  constructor(parent: Injector) {
    super(parent, [AppComponentNgFactory], [AppComponentNgFactory]);  
  }

  get _ApplicationRef_11(): any {
    if (this.__ApplicationRef_11 == null) { 
      this.__ApplicationRef_11 = this._ApplicationRef__10; 
    }
    return this.__ApplicationRef_11;
  }

  get _ROUTES_12(): any[] {
    if (this.__ROUTES_12 == null) { 
      this.__ROUTES_12 = [[
        {
          path: '', loadChildren: './home/home.module#HomeModule'
        },
        {
          path: 'about', loadChildren: './about/about.module#AboutModule'
        },
        {
          path: 'contact', loadChildren: './contact/contact.module#ContactModule'
        }
      ]]; 
    }
    return this.__ROUTES_12;
  }

  createInternal(): AppModule {
    this._CommonModule_0 = new CommonModule();
    this._ApplicationModule_1 = new ApplicationModule();
    this._BrowserModule_2 = new BrowserModule(this.parent.get(BrowserModule, (null as any)));
    this._ROUTER_FORROOT_GUARD_3 = provideForRootGuard(this.parent.get(Router, (null as any)));
    this._RouterModule_4 = new RouterModule(this._ROUTER_FORROOT_GUARD_3);
    this._HttpModule_5 = new HttpModule();
    this._AppModule_6 = new AppModule();
    this._ErrorHandler_7 = errorHandler();
    this._ApplicationInitStatus_8 = new ApplicationInitStatus(this.parent.get(APP_INITIALIZER, (null as any)));
    this._Testability_9 = new Testability(this.parent.get(NgZone));

    this._ApplicationRef__10 = new ApplicationRef_(
      this.parent.get(NgZone), 
      this.parent.get(Console), 
      this, 
      this._ErrorHandler_7, 
      this,
      this._ApplicationInitStatus_8,
      this.parent.get(TestabilityRegistry, (null as any)),
      this._Testability_9
    );
    return this._AppModule_6;
  }

  getInternal(token: any, notFoundResult: any): any {
    if (token === CommonModule) { return this._CommonModule_0; }
    if (token === ApplicationModule) { return this._ApplicationModule_1; }
    if (token === BrowserModule) { return this._BrowserModule_2; }
    if (token === ROUTER_FORROOT_GUARD) { return this._ROUTER_FORROOT_GUARD_3; }
    if (token === RouterModule) { return this._RouterModule_4; }
    if (token === HttpModule) { return this._HttpModule_5; }
    if (token === AppModule) { return this._AppModule_6; }
    if (token === ErrorHandler) { return this._ErrorHandler_7; }
    if (token === ApplicationInitStatus) { return this._ApplicationInitStatus_8; }
    if (token === Testability) { return this._Testability_9; }
    if (token === ApplicationRef_) { return this._ApplicationRef__10; }
    if (token === ApplicationRef) { return this._ApplicationRef_11; }
    if (token === ROUTES) { return this._ROUTES_12; }

    return notFoundResult;
  }

  destroyInternal(): void {
    this._ApplicationRef__10.ngOnDestroy();
  }
}

코드가 좀 이상하게 보일지 모르지만 (그리고 실제로 생성된 코드는 더 이상하게 보일 것이다) 이 코드에서 무슨 일이 실제로 일어나는지 확인하자.

실제 생성된 코드를 좀 더 읽기 쉽도록 불러오는(import) 부분을 고쳤다. 각 모듈에서는 와일드카드를 사용해서 동일 이름으로 충돌하는 일이 없도록 처리되어 있다.

HttpModule을 불러오는 부분을 보면 알 수 있을 것이다.

import * as import6 from '@angular/http/src/http_module';

이런 접근 방식으로 HttpModule을 직접 참조하는 대신 import6.HttpModule로 사용할 수 있다.

이 생성된 코드에서 살펴봐야 하는 부분은 세 가지다. 클래스에 있는 프로퍼티, 모듈이 어떻게 불려오는지, 그리고 의존성 주입 원리가 어떻게 동작하는지에 대해서다.

AppModuleInjector 프로퍼티

각 프로바이더와 의존성을 처리하기 위해서 AppModuleInjector프로퍼티가 생성되었다.

// ...
class AppModuleInjector extends NgModuleInjector<AppModule> {
  _CommonModule_0: CommonModule;
  _ApplicationModule_1: ApplicationModule;
  _BrowserModule_2: BrowserModule;
  // ...
}

위 코드는 컴파일된 출력의 일부다. 이 클래스에 선언된 세 가지 프로퍼티를 확인해보자.

  • CommonModule
  • ApplicationModule
  • BrowserModule

작성한 모듈에서는 BrowserModule만 사용했는데 CommonModuleApplicationModule은 어디서 온 것일까? 이 정보는 BrowserModule의해 추가된 부분으로 이 모듈을 사용하기 위해 직접 불러올 필요가 없도록 컴파일에 포함되었다.

또한 모듈의 모든 프로퍼티 끝에는 숫자가 붙어 있다. 와일드카드로 모듈을 불러온 것처럼 각 프로바이더 사이에서 나타날 수 있는 명칭으로 인한 잠재적 충돌을 피하기 위한 방법이다.

두 모듈이 동일한 이름의 서비스를 사용한다면 위 설명처럼 숫자를 붙이지 않고서는 같은 이름의 프로퍼티에 저장되어 잠재적인 오류의 원인이 될 것이다.

모듈 불러오기

컴파일을 수행하면 Angular는 각 프로바이더를 불러올 때 직접적인 경로를 사용하기 때문에 코드를 작성할 때는 다음처럼 작성해도 된다.

import { CommonModule } from '@angular/common';

AoT 컴파일된 버전은 다음과 같다.

import * as import5 from '@angular/common/src/common_module';

코드가 컴파일되고 번들로 묶이면 나무 흔들기를 할 수 있다는 장점이 있고 각 모듈에서 실제로 사용되는 부분만 포함하는 것이 가능하다.

의존성 주입

각 모듈은 각자의 의존성 주입을 처리하는데 만약 의존성을 찾지 못한다면 찾을 때까지 부모 모듈을 타고 올라간다. 계층을 다 탐색해도 찾지 못하면 그때 오류가 발생한다.

모든 의존성은 토큰을 통해 유일함을 확인하는데 이 접근 방식은 의존성을 등록할 때와 찾을 때 모두 사용된다.

의존성을 주입하는 방법은 createInternal를 사용하는 방법과 프로퍼티의 게터(getter)를 사용하는 방법 두 가지가 있다.

불려오는 모듈이나 밖으로 보내는 모듈은 createInternal과 함께 생성된다. 이 부분은 모듈이 인스턴스로 만들어지는 순간에 실행된다.

다음은 BrowserModuleHttpModule을 사용하는 경우다. 모듈을 사용하면 다음같은 코드가 생성된다.

class AppModuleInjector extends NgModuleInjector<AppModule> {
  _CommonModule_0: CommonModule;
  _ApplicationModule_1: ApplicationModule;
  _BrowserModule_2: BrowserModule;
  _HttpModule_5: HttpModule;
  _AppModule_6: AppModule;

  createInternal(): AppModule {
    this._CommonModule_0 = new CommonModule();
    this._ApplicationModule_1 = new ApplicationModule();
    this._BrowserModule_2 = new BrowserModule(this.parent.get(BrowserModule, (null as any)));
    this._HttpModule_5 = new HttpModule();
    this._AppModule_6 = new AppModule();
    // ...
    return this._AppModule_6;
  }
}

BrowserModule에는 두 외부 모듈인 CommonModuleApplicationModule이 생성되었고 불러온 모듈도 만들어졌다. 또한 실제 모듈인 AppModule도 생성되어 다른 모듈에서도 사용 가능하다.

다른 모든 프로바이더를 위해서는 생성을 한 후에 필요에 따라 클래스의 게터로 주입한다.

언제든 Angular에서 인젝터를 듣는다면 이 인젝터는 모듈에서 생성된 (컴파일된) 코드를 참조한다는 뜻이다.

Angular가 의존성을 확인할 때(예를 들면 constructor를 통해 주입할 때) 모듈 인젝터 속을 보고 찾고 찾지 못했다면 부모 모듈을 추적해서 계속 검색한다. 그래도 존재하지 않는다면 오류가 발생한다.

constructor에 타입 정의를 사용하면 Angular는 이 타입(여기서는 클래스)을 토큰으로 사용해 의존성을 찾는다. 그런 후에 이 토큰은 getInternal로 전달되는데 의존성이 존재한다면 해당 의존성의 인스턴스를 반환하게 된다. 소스 코드를 확인하자.

class AppModuleInjector extends NgModuleInjector<AppModule> {

  // new BrowserModule(this.parent.get(BrowserModule, (null as any)));
  _BrowserModule_2: BrowserModule;

  // new HttpModule()
  _HttpModule_5: HttpModule;

  // new AppModule()
  _AppModule_6: AppModule;

  getInternal(token: any, notFoundResult: any): any {
    if (token === BrowserModule) { return this._BrowserModule_2; }
    if (token === HttpModule) { return this._HttpModule_5; }
    if (token === AppModule) { return this._AppModule_6; }

    return notFoundResult;
  }
}

getInternal 메소드 안을 보면 Angular가 토큰을 단순한 if 문을 사용해서 확인하는 것을 볼 수 있다. 맞는 의존성을 찾는다면 프로바이더를 위해 연관된 프로퍼티를 반환하게 된다.

반면 notFoundResult를 반환하면 getInternal 메소드는 사용하지 않는다. Angular에서 필요한 의존성을 찾는 동안에 notFoundResultnull이 된다. 최상위 모듈까지 훑어도 의존성을 찾지 못했다면 오류가 발생한다.

정리하며

이 글이 @Inject@Injectable, 토큰과 프로바이더, 그리고 Angular가 AoT 컴파일을 통해 VM 친화적인 코드를 생성하는지에 대해 더 깊이 이해하는데 도움이 되었으면 좋겠다.

Todd Motto의 글 Angular constructor versus ngOnInit를 번역했다.


Angular의 constructor와 ngOnInit 차이점

Angular는 여러 생애주기 훅이 존재하지만 여전히 constructor도 있다. 이 글에서는 ngOnInit 생애주기 훅과 차이점을 확인한다. 이 차이는 Angular를 처음 시작할 때 혼란하게 만드는 근원이다.

constructor를 사용할 수 있는데도 생애주기 훅인 ngOnInit을 사용해야 할까?

차이점은 무엇인가

ES6의 constructor메소드 (여기서는 타입스크립트)는 Angular의 기능이 아니라 클래스 자체의 기능이다. constructor가 호출되는 시점은 Angular의 제어 바깥에 있다. 즉, Angular가 컴포넌트를 초기화 했는지 알기에는 적합하지 않은 위치다.

constructor를 살펴보자.

import { Component } from '@angular/core';

@Component({})
class ExampleComponent {
  // 이 부분은 Angular가 아닌
  // 자바스크립트에서 실행
  constructor() {
    console.log('Constructor initialised');
  }
}

// 생성자(constructor)를 내부적으로 호출
new ExampleComponent();

constructor를 호출하는 주체가 Angular가 아닌 자바스크립트 엔진이라는 점이 중요하다. 그런 이유로 ngOnInit(AngularJS에서는 $onInit) 생애주기 훅이 만들어졌다.

생애주기 훅을 추가하면서 Angular는 컴포넌트가 생성된 후에 설정을 마무리하기 위한 메소드를 한 차례 실행할 수 있게 되었다. 이름에서 알 수 있는 것처럼 컴포넌트의 _생애주기_를 다루는데 사용한다.

import { Component, OnInit } from '@angular/core';

@Component({})
class ExampleComponent implements OnInit {
  constructor() {}

  // Angular에서 필요에 맞게 호출
  ngOnInit() {
    console.log('ngOnInit fired');
  }
}

const instance = new ExampleComponent();

// 필요할 때 Angular에서 호출
instance.ngOnInit();

Constructor 용도

생애주기 훅을 사용해야 하는 경우도 있지만 constructor를 사용해야 적합한 시나리오도 있다. 이 생성자는 의존적인 코드를 컴포넌트에 전달하는 의존성 주입을 하기 위해서는 필수적으로 필요하다.

constructor는 자바스크립트 엔진에 의해 초기화 되는데 타입스크립트에서는 Angular에 의존성이 어느 프로퍼티에 적용되는지 직접 지정 안하고도 사용할 수 있다.

import { Component, ElementRef } from '@angular/core';
import { Router } from '@angular/router';

@Component({})
class ExampleComponent {
  constructor(
    private router: Router,
    private el: ElementRef
  ) {}
}

Angular의 의존성 주입은 여기서 더 읽을 수 있다.

위 코드는 Routerthis.router에 넣고 컴포넌트 클래스에서 접근할 수 있도록 한다.

ngOnInit

ngOnInit은 순수하게 Angular가 컴포넌트 초기화를 완료했다는 점을 전달하기 위해 존재한다.

이 단계는 컴포넌트에 프로퍼티를 지정하고 첫 변경 감지가 되는 범위까지 포함되어 있다. @Input() 데코레이터를 사용하는 경우를 예로 들 수 있다.

@Input() 프로퍼티는 ngOnInit 내에서 접근 가능하지만 constructor에서는 undefined를 반환하는 방식으로 디자인되어 있다.

import { Component, ElementRef, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';

@Component({})
class ExampleComponent implements OnInit {
  @Input()
  person: Person;

  constructor(
    private router: Router,
    private el: ElementRef
  ) {
    // undefined
    console.log(this.person);
  }

  ngOnInit() {
    this.el.nativeElement.style.display = 'none';
    // { name: 'Todd Motto', location: 'England, UK' }
    console.log(this.person);
  }
}

ngOnInit 생애주기 훅은 바인딩한 값을 읽을 수 있다고 보장할 수 있는 상황에서 호출된다.

Todd Motto의 글 Should you learn Angular 1.x or 2?를 번역했다.


Angular 1을 배워야 하나요 2를 배워야 하나요?

“Angular 1을 배워야 하나요 2를 배워야 하나요?”라는 질문은 정말 자주 받는다. 그 질문에 답하는 성격의 글로 도움과 안내가 될 수 있는 통찰을 줄 수 있었으면 한다. 이 질문은 누구도 쉽게 답할 수 없는데 바로 질문자에 따라 답이 다르기 때문이다. 내 생각을 정리해봤다.

흔한 질문

다음 같은 질문을 정말 자주 받는다.

  • “Angular를 새로 시작하는데 버전 1을 할까요 2를 할까요?”
  • “아직 Angular 2를 배우면 안되나요?”
  • “Angular 1과 2를 모두 배워야 하나요?”

먼저 기억해둬야 할 점은 이 질문에 “공식” 답변은 없다는 점이다. (물론 짧고 간결하게 답하면 어떤 도구든 가장 최신의 안정적인 버전을 선택하는 것이 바람직하다.) 하지만 어느 프레임워크를 배워야 할지, 어떤 프레임워크가 더 나은지 생각해본다면 몇 가지 큰 요인을 고려해야 할 것이다. 이 글에서는 그 질문에 대해 어떻게 스스로 답을 내릴지 고민할 수 있도록 몇 조언을 제공하려고 한다.

코드 기반과 팀

혼자서 일을 하든 팀으로 일을 하든 현재 일을 하고 있다면 질문에 답하기 위해 이런 생각을 해볼 수 있다.

먼저 AngularJS (1.x)는 프레임워크 세계에서 지배적인 포식자라고 할 수 있다. 그만큼 실무에서 가장 규모가 크고 현재 가장 많이 사용하는 프레임워크에 해당한다. 만약 AngularJS를 일에서 사용하고 있다면 전혀 문제가 아니다. 회사에서 고객을 위해 단일 프로덕트/어플리케이션을 개발한다면 고개를 숙여 프로젝트를 완성해 전달하는데 집중하고 프로젝트를 꾸준히 진행하길 바랄 것이다.

둘째로 코드 기반을 업그레이드하기 위해서 잠재적으로 여러 해의 수고가 들어간 코드를 다시 작성하고 싶은지 생각해봐야 한다. 이 질문에 답하기 위해서는 따져봐야 할 여러 요인이 있을 것이다. 2009년에 나온 AngularJS는 생산성을 극대화한 빠른 프레임워크긴 하지만 몇 가지 한계점이 존재하고 프로젝트의 생애를 잠재적으로 방해하는 요소가 될 수 있다. 말은 그렇지만 탈출 버튼을 누르고 당장에 Angular (v2+)로 넘어갈 만큼의 요소로 보기엔 어렵다.

어떻게 결정하든 상관 없이 이 고민은 “업그레이드”나 “마이그레이션”이 아니라 근본적으로 프레임워크를 교체하는 작업이다. 즉 완전히 다른 코드 기반으로 옮겨가게 된다는 것이다. (한번에 전부 옮기든, 점진적으로 옮기든 말이다.) CEO/CTO라면 견고하고 명확한 이유 없이는 사업에 영향을 주는 이런 결정을 쉽게 내리지 않을 것이다. 의사 결정권자는 고객에게 전달할 중점적 사안이 중요하지 버전 번호가 중요한 것이 아니다.

시나리오: 단일 상품 회사

GitHub에서 AngularJS를 사용한다고 가정해보자. 코드 기반은 아마 몇 년 정도 오래 되었을 것이다. 이제 코드 기반을 모바일에, 또는 데스크탑 클라이언트에도 배포하려고 한다. 이런 상황에서는 몇 가지 선택할 만한 경우가 있다. 단일 모바일 어플리케이션 (안드로이드, iOS)를 만드는 방법, 또한 네이티브 데스크탑 클라이언트를 만드는 방법이 있겠다. 이런 기술 차이는 더 큰 금전적 투자를 필요로 한다.

내 의견으로는 비지니스 목표를 달성하기 위해서 Angular로 옮겨 위에서 필요로 했던 모든 작업을 단일 프레임워크 내에서 수행할 수 있도록 권할 것이다. Angular를 사용하면 NativeScript를 사용해 네이티브 모바일 코드로 컴파일이 가능하며 모바일에 배포하기 위해 Ionic을 사용하거나 데스크탑 환경을 위해 Electron을 사용할 수 있을 것이다. 단일한 코드 기반에서 말이다.

하지만 한 걸음 물러나 다시 생각해봐야 한다. 무엇이 가장 중요한 웹 어플리케이션인가? 단일 페이지 앱(SPA)는 코드를 잘 나누고 작게 만들었다면, 제대로 된 성능 전략을 선택하고 사용자에게 컨텐츠를 전달하기 위한 가능한 가장 빠른 방법을 사용했다면 빠를 수도 있다. 하지만 더 빠르게도 가능하다. Angular는 Angular Universal을 사용해서 서버측 렌더링(SSR)이 가능하다. 이런 전략은 Angular 1.x에서 사용할 수 없다. 이 특징도 Angular를 배워야 할지 말아야 할지 결정할 때 참고할 중요한 부분이다.

시나리오: 다양한 프로덕트와 푸른 초원

내 경우에는 AngularJS로 작성된 단일 프로덕트 어플리케이션도 작업해봤고 그만큼 1.x를 사용하는 회사를 위해서 여러 프로젝트로 개발했었다. 그래서 두 경우 모두 경험해본 경력이 있다. 만약 대단한 클라이언트 10 곳과 10개의 Angular 1.x 앱이 있고 11번째 클라이언트가 푸른 초원에서 새로 시작하는 프로덕트를 제안했다고 하자. 무엇을 할 것인가?

이런 상황이라면 위에서 살펴봤던 이유들 때문에라도 미래를 보장받는 선택인 Angular를 고려할 것이다. Angular 2는 바닥부터 다시 작성되어 단일 방향 데이터 흐름과 컴포넌트 아키텍처와 같이 모범 사례를 적용하는데 집중했다. 이런 기능은 AngularJS에서도 사용할 수 있지만 가장 최신 버전에만 적용되어 있다. 즉, 기존에 존재하는 코드를 1.6+에서 사용하려면 코드 기반을 리팩토링하고 .component API를 사용해야 한다.

AngularJS가 언젠가 “중단(discontinued)”될 운명인건 알지만 그건 AngularJS 뿐만 아니라 모든 앱과 버전이 그러한 것 아닐까? 꼭 가장 최신에 가장 좋은 도구를 쓸 필요는 없지만 앞으로 3년 정도 어려운 시기 후에 Angular가 최종적으로 출시되면 투자할 가치가 있을 만큼 엄청난 힘이 있을 것이다.

만약 11번째 클라이언트가 당신에게 새로운 어플리케이션을 원한다면 시도해라. 하지만 그 전에 생명주기 훅, 상태 저장과 비저장(stateful and stateless) 컴포넌트와 단방향 데이터 흐름과 이벤트를 이해할 필요가 있다.

당신, 개인적으로

여기도 몇 가지 시나리오가 있으며 당신이 무엇을 하는지에 따라 다를 것이다. 하나의 답변으로 모든 상황에 딱 맞을 수는 없을 것이다.

시나리오: AngularJS를 사용해서 취업함

만약 AngularJS를 일에서 사용하고 있다면 아마도 이미 Angular를 둘러보고 문서를 살펴봤을 것이다. 그런 중에 이 괴물은 AngularJS나 기존에 알고 있는 MVC 패턴과는 사뭇 거리감이 있다는 것을 알게 되었을 것이다. 이 상황에서는 전적으로 본인에게 달렸다. Angular에 더 깊게 빠져들고 싶다면 도전해라. 그렇지 않아도 물론 괜찮다. 누군가에게 충고할 때 꼭 해라 하지 마라 하는건 별로 의미가 없다. 질문에 특별한 이유가 있는 경우가 아니고서는 말이다. (예를 들어, 서버에서 렌더링이 가능한가요? 아니면 이러이런 일을 할 수 있나요?) 이런 질문은 마치 “포르쉐를 사야 할까요 페라리를 사야 할까요?” 같고 답은 질문한 사람 머릿속에만 존재한다.

그렇다고 질문이 일을 벗어난 것은 아니다. Angular를 배우지 않고서는 사장에게 가서 Angular 사용하자고 할 수 없을 것이다. 자기 시간에 배워서 마음에 드는지 살펴보자. 그렇게 간단한 일이다.

시나리오: Angular를 처음 한다면

Angular를 전혀 본 적이 없다면 조금 어려운 질문이다. AngularJS가 갖고 있는 단일 시장 지배력과 Angular로 넘어가는 회사의 비율을 고려해보면 결국 둘 다 배워야 할 것이다. 만약 AngularJS를 .component() API로 배우고, 컴포넌트 기반 구조에서 “MVC 접근 방식으로” 어떻게 돌아가는지만 이해할 수 있다면 내일 당장이라도 AngularJS를 사용하는 회사에 취업할 수 있을 것이다.

“Angular만 하는” 직업을 찾고 있다면 위에서 언급했던 이유로 지금 당장은 조금 어려울 수 있다. 만약 Angular를 막 시작했다면 둘 다 배워야 할 것 같다. 하지만 앞서와 같이 이 결정은 자기 자신이 어떤 삶, 어떤 커리어를 선택하느냐에 기반하게 된다.

Angular가 급격하게 성장하고 있고 경이로운 성장 추이를 보여주고 있지만 새 이력서에 “Angular 2+”만 적어 놓고는 회사 문을 두드리기는 쉽지 않을 것이다. 대다수의 회사는 여전히, 앞으로 다년 간 AngularJS를 사용할 것이기 때문이다. 이런 경우에는 어떤 직업과 어떤 스킬을 원하는지, 어디에 취업하고 싶은지에 따라 결정할 필요가 있다. 이 “취업” 란에서는 최대한 일반적인 상황을 이야기하고 있다. 하지만 나처럼 자영업자를 하는 사람도 많을 것이다. 물론 그렇다고 이런 질문을 피할 수는 없다.

만약 자영업 엔지니어라면 더 땅을 파서 생계 유지에 집중하는 것이 당연하다. AngularJS에 대한 요청이 50회고 Angular 앱에 대한 요청은 한 번만 들어왔다면 어디에 더 시간을 집중해야 할까? AngularJS에 집중해야 할 것이다.

뒤집어서 새로운 일이든, 컨설팅이든, 무슨 일을 하든 50/50 비율로 요청을 받는 위치라면 둘 다 배워야 할 것이다. 개인적으로 아는 Angular 개발자는 대부분 AngularJS도 잘 알고 있었고 다 년 간 사용한 경험이 있었다.

시나리오: 다른 일로 취업함

아마도 React, Ember, Backbone, nockout 같은 프레임워크로 취업했지만 Angular를 고려하고 있는 경우일 것이다. 먼저 Angular 2가 무슨 이득이 있을 지 먼저 조사해볼 필요가 있다. Ahead-of-Time 컴파일은 브라우저에 배포하기 전 코드 크기를 극적으로 줄여서 앱을 전달 할 수 있는데 Angular를 살펴볼 때 주요하게 고려할 만한 부분이다.

마무리하며

빠르게 정리하자면 자신과 자신의 직업에 따라 답이 달라진다. Angular 직업은 많아질 것이고 AngularJS는 여전히 주변에 있을 거란 점에 의심하지 않는다. 사실 기업을 대상으로 한 AngularJS 지원은 더욱 높아질 것이다. (새 버전으로 마이그레이션 하기로 결정하기 전까지 말이다.)

요약하면 AngularJS를 사용하고 있다면 프로젝트 또는 회사의 미래 목표로 고려해보자. 만약 Angular를 처음 배우고 직업으로 삼고 싶다면 시장과 다니고 싶은 회사를 조사해보고 어떤 기술 스택을 요구하는지 살펴보자.

옳은 답은 없지만 이 글을 통해 조금이나마 고려에 도움이 되는 통찰이 생겼으면 좋겠다. 모두 잘 되길 바란다!

사이드 프로젝트에서 Express를 오랜 기간 사용했었는데 hapi 가 좋다는 얘기를 듣고는 hapi를 많이 사용해왔다. Hapi도 단순하긴 하지만 “설정만 넣으면 되는” 단순함이라서 설정에 들어가는 수고가 꽤 컸다. 최근에는 토이 프로젝트에서 API를 작성하는데 에러 발생 여부에 따라서 {"ok": true} 하나 넣어주는 작업에 오만가지 코드를 작성해야 했다. express와 다르게 미들웨어에서 request, response에 접근할 수 있는 포인트가 워낙에 많아 더 복잡하게 느껴졌다. 그러던 중 예전에 잠시 비교글로 봤던 koa를 살펴봤는데 지금 필요한 상황에 맞는 것 같아 koa로 다시 코드를 작성했고 마음에 드는 구석이 많아서 간단한 소개를 작성한다.

Koa는 ES2015의 문법 중 하나인 제너레이터를 적극적으로 활용하고 있는 웹 프레임워크다. 모든 요청과 처리를 제너레이터를 활용해 파이프라인을 만드는 것이 특징이며 그 덕분에 깔끔한 async 코드를 손쉽게 작성할 수 있다. Express 만큼은 아니더라도 다양한 라이브러리를 제공하고 있고, express의 라이브러리나 미들웨어도 thenify나 co로 변환해서 활용할 수 있을 만큼 확장성이 높다.

이 포스트는 제너레이터를 먼저 살펴보고, 제너레이터를 유용하게 사용할 수 있는 co를 살펴본 후, KoaJS를 간단하게 살펴보는 것으로 마무리한다.


제너레이터 Generator

다른 언어에도 이미 존재하고 있기 때문에 크게 특별한 기능은 아니지만 ES6에서의 구현을 간단히 정리하려고 한다.

일반적인 함수의 경우, 매 실행마다 같은 흐름으로 모든 코드를 실행하지만 Generator 함수는 실행 중간에서 값을 반환할 수 있고, 다른 작업을 처리한 후에 다시 그 위치에서 코드를 시작할 수 있다. 이 제너레이터는 반복 함수 iterator를 next()로 제공하고 결과를 value로, 진행 상황을 done으로 확인할 수 있다.

구구단을 제너레이터로 작성하면 다음과 같다.

function* nTimesTable(n) {
  for(var i = 1; i <= 9; i++) yield n * i;
}

제너레이터는 위와 같이 function* fnName(){} 식으로 *을 넣어 선언한다. 익명 함수의 경우도 function*(){} 식으로 선언한다.

이제 이터레이터(iterator)를 nineTimesTable에 반환 받는다.

var nineTimesTable = nTimesTable(9);

이터레이터는 next()를 통해 실행할 수 있다. 이 함수로 중단한 위치의 결과가 반환된다.

var result = nineTimesTable.next();
console.log(result); // { value: 9, done: false }
result = nineTimesTable.next();
console.log(result); // { value: 18, done: false }
result = nineTimesTable.next();
console.log(result); // { value: 27, done: false }

// keep calling...

result = nineTimesTable.next();
console.log(result); // { value: 72, done: false }
result = nineTimesTable.next();
console.log(result); // { value: 81, done: false }
result = nineTimesTable.next();
console.log(result); // { value: undefined, done: true }

매 반복 실행에서 value를 반환하지만 동시에 done으로 해당 함수가 yield 결과 없이 종료되었는지 확인할 수 있다. 마지막에 별도의 return 값이 없기 때문에 valueundefined가 된다.

이런 이터레이터의 반환 특징을 이용하면 다음과 같이 iterator를 호출하는 함수를 작성할 수 있다.

function caller(iter) {
  var result, value;
  while(result = iter.next()) {
    if(result.done) break;
    value = result.value || value;
  }
  return value;
}

var result = caller(nTimesTable(3));
console.log(result); // 27

donetrue를 반환할 때까지 해당 이터레이터를 실행해 결과값을 가져오는 caller를 작성했다. 만약 매 반복에서 특정 함수를 실행하고 싶다면 다음처럼 작성할 수 있다. 앞서 작성한 nTimesTable 함수가 더 많은 내용을 반환하도록 수정했다.

function * nTimesTable(n) {
  for(var i = 1; i <= 9; i++) yield { n: n, i: i, result: n * i };
}

function caller(iter, func) {
  var result, value;
  while(result = iter.next()) {
    if(result.done) break;
    value = result.value || value;
    if(func) func(value);
  }
  return value;
}

caller(nTimesTable(3), value => {
  console.log('%d x %d = %d', value.n, value.i, value.result);
});

앞서 작성한 caller는 제너레이터 내의 yield에 대해서는 처리를 하지 못한다. 제너레이터에서 이터레이터를 반환하고 진행을 중단했을 때 해당 이터레이터를 처리해서 다시 반환해야 한다. 결과를 넣고 다시 진행할 수 있도록 작성해야 하는 것이다.

function* getAnimalInCage() {
  yield "Wombat";
  yield "Koala";
  return "Kangaroo";
}

function* Cage() {
  var cageAnimals = getAnimalInCage();

  var first = yield cageAnimals;
  var second = yield cageAnimals;
  var third = yield cageAnimals;

  console.log(first, second, third);
}

Cage 제너레이터를 실행하면 yield를 3번 사용했기 때문에 최종 console.log가 출력하는 결과를 보기까지 4번에 걸쳐 실행된다.

var cage = Cage();
var firstStop = cage.next();
// {value: iterator, done: false}

첫 번째 yield 결과가 firstStop에 저장되었다. cageAnimals는 위에서 코드에서와 같이 getAnimalInCage 제너레이터가 생성한 이터레이터다. 이 이터레이터에 next() 메소드로 값을 받은 후, 그 값을 다시 first 변수에 다음과 같이 반환한다.

var firstAnimal = firstStop.value.next();
// firstAnimal: {value: "Wombat", done: false}
var secondStop = cage.next(firstAnimal.value);

next의 인자값으로 첫 결과인 Wombat을 넣었다. 이전에 멈췄던 위치인 첫 번째 yield로 돌아가 함수 내 first에는 Wombat이 저장된다. 나머지도 동일하게 진행된다.

var secondAnimal = secondStop.value.next();
// secondAnimal: { value: 'Koala', done: false }

var thirdStop = cage.next(secondAnimal.value);
var thirdAnimal = thirdStop.value.next();
// thirdAnimal: { value: 'Kangaroo', done: true }

var lastStop = cage.next(thirdAnimal.value);

// Wombat Koala Kangaroo

마지막 Kangaroo는 yield가 아닌 return이기 때문에 done이 true를 반환한다. 앞서 직접 호출해서 확인한 코드는 반환하는 값이나 호출하는 형태가 일정한 것을 볼 수 있다. 즉 재사용 가능한 형태로 만들 수 있다는 의미다.

다음은 catchEscapedAnimal()getTodaysZookeeper() 함수를 이용한 Zoo 제너레이터 예시다.

function catchEscapedAnimal() {
  return function(done) {
    setTimeout(function() {
      done(null, {name: 'Kuma', type: 'Bear'});
    }, 1000);
  };
}

function* getTodaysZookeeper() {
  yield {status: 'loading'};
  return {status: 'loaded', name: 'Edward'};
}

function* Zoo() {
  var animal = yield catchEscapedAnimal();
  var zookeeper = yield getTodaysZookeeper();

  console.log('%s catches by %s', animal.name, zookeeper.name);
}

catchEscapedAnimal()은 ajax를 사용하는 경우를 가정해서 setTimeout을 이용해 콜백을 호출하는 형태로 작성되었다. getTodaysZookeeper()는 일반적인 제너레이터 함수로 첫 호출에는 loading을, 두번째 호출에서 최종 값을 전송한다. Zoo도 앞에서 본 Cage처럼, 중간에 yield를 사용한다. 이 함수를 처리하기 위한 compose 함수는 다음과 같다.

function compose(iter, value, next) {
  var result = iter.next(value);
  if(result.done) return next ? next(value) : value;
  else if(typeof result.value == 'function') {
    return result.value(function(err, data) {
      if(err) throw err;
      compose(iter, data);
    });
  } else if(typeof result.value.next == 'function') {
    var _iter = iter;
    next = function(result){
      compose(_iter, result);
    };
    iter = result.value;
    result = iter.next();
  }
  return compose(iter, result.value, next);
}

compose 함수는 다음과 같은 경우의 수를 다룬다.

  • yield 된 값이 함수일 때, 호출 체인을 연결할 수 있도록 next 함수를 넘겨줌 (기존 callback 방식)
  • yield 된 값이 이터레이터일 때, 이터레이터가 done을 반환할 때까지 호출한 후 최종 값을 반환
  • 그 외의 결과를 반환할 때, 해당 값을 이터레이터에 넣고 다시 compose를 호출
  • 이터레이터가 종료(done == true)되었을 때, next 함수가 있다면 해당 함수로 호출을 진행하고 없으면 최종 값을 반환하고 종료

이 함수를 이용한 결과는 다음과 같다. setTimeout()에 의해 중간 지연이 진행되는 부분도 확인할 수 있다.

compose(Zoo());
// Kuma catches by Edward

제너레이터를 코루틴으로, co

나름 잘 동작하지만 흐름을 보기 위해서 만든 함수라서 허술한 부분이 많다. 이런 부분에서 사용할 수 있는 것이 바로 co다. co는 제너레이터를 코루틴처럼 사용할 수 있도록 돕는 라이브러리로 앞서 작성했던 compose 함수와 같은 역할을 한다.

var co = require('co');
co(Zoo());
// Kuma catches by Edward

이 라이브러리는 내부적으로 Promise 패턴을 사용하고 있어서 callback이든 Promise든 제너레이터든 모두 잘 처리한다. 실제로 제너레이터를 사용하고 싶다면 이 라이브러리를 사용하는 것이 큰 도움이 된다.

Koa

Koa는 앞서 이야기한 co 라이브러리를 기본적으로 적용하고 있는 HTTP 미들웨어 라이브러리로 경량에 간단한 기능을 제공하는 것을 특징으로 한다. 제너레이터를 기본적으로 사용할 수 있어서 앞서 배운 내용을 손쉽게 적용할 수 있다.

코드를 작성하기에 앞서 간단하게 koa를 설치한다.

$ npm install --save koa

Hello World를 작성하면 다음과 같다.

var koa = require('koa');
var app = koa();

app.use(function* () {
  this.body = {"message": "Hello World"};
});

app.listen(3000);

이제 http://localhost:3000에 접속하면 해당 json이 출력되는 것을 확인할 수 있다.

앞서 작성한 코드도 포함해보자.

var koa = require('koa');
var app = koa();

function catchEscapedAnimal() {
  return function(done) {
    setTimeout(function() {
      done(null, {name: 'Kuma', type: 'Bear'});
    }, 50);
  };
}

function* getTodaysZookeeper() {
  yield {status: 'loading'};
  return {status: 'loaded', name: 'Edward'};
}

function* Zoo() {
  var animal = yield catchEscapedAnimal();
  var zookeeper = yield getTodaysZookeeper();

  this.body = { message: animal.name + ' catches by ' + zookeeper.name };
}

app.use(Zoo);
app.listen(3000);

Koa의 모든 추가 기능은 미들웨어 구조로 제너레이터를 통해 작성하게 된다. callback은 물론 Promise 패턴도 더 깔끔하게 사용할 수 있다.

요청과 응답은 모두 this에 주입되서 전달되고 흐름은 첫 인자에 next를 추가해 제어할 수 있다. 요청에 대한 응답 내용이 있으면 ok를 추가해보자.

app.use(function* (next) {
  yield next;
  if(this.body) {
    this.body.ok = true;
  } else {
    this.body = { ok : false };
  }
});

다음과 같은 방식으로 토큰 검증도 가능하다.

app.use(function* (next) {
  var requestToken = this.request.get("Authorization");
  var accessToken = yield AccessTokensModel.findAccessTokenAsync(token);
  if(accessToken) {
    yield next;
  } else {
    this.body = { error: 'invalid_token' };
  }
});

세부적인 내용은 koa 웹페이지에서 다루고 있다. 단순하고 간편한 기능을 원한다면 꼭 살펴보자. 실제 사용하게 될 때는 koa-bodyparser, koa-router와 같은 패키지를 같이 사용하게 된다. 패키지 목록은 koa 위키에서 확인할 수 있다.

제너레이터도 충분히 편한 기능이지만 koa는 현재 await/async 문법을 지원하기 위한 다음 버전 개발이 진행되고 있다. 더 가독성도 높고 다른 언어에서 이미 구현되어 널리 사용되고 있는 문법이라 더 기대된다.


더 읽을 거리

EventEmitter는 Node.JS에 내장되어 있는 일종의 옵저버 패턴 구현이다. node 뿐만 아니라 대부분의 프레임워크나 라이브러리에서 이 구현을 쓰거나 유사한 구현을 활용하고 있는 경우가 많다. DOM Event Listener를 사용해본 경험이 있다면 사실 특별하게 새로운 기능은 아니지만, 요즘 이 패턴으로 작성된 라이브러리가 많고 특히 node 코어 라이브러리도 이 구현을 사용한 경우가 많아 살펴볼 만한 내용이다.

물론 Node 뿐만 아니라 front-end 환경에서도 Olical/EventEmitter와 같은 구현을 통해 손쉽게 활용할 수 있다.

이 글은 Node.js v5.5.0의 Events 문서를 기준으로 번역했고 버전에 따라 내용이 변경될 수 있다.


이벤트

Node.js의 코어 API 대부분은 관용적으로 비동기 이벤트 기반 아키텍처를 사용해서 만들어졌다. (“에미터 emitter”로 불리는) 어떤 종류의 객체를 이벤트 이름으로 정의된 특정 이벤트에 정기적으로 전달해 “리스너 listener”로 불리는 함수 객체를 실행한다.

예를 들어 net.Server 객체는 매번 사용자가 접속할 때마다 이벤트를 호출하고 fs.ReadStream은 파일을 열 때마다 이벤트를 호출한다. stream은 어떤 데이터든 데이터를 읽을 수 있는 상황이 되면 이벤트를 호출한다.

이벤트를 내보내는 모든 객체는 EventEmitter 클래스의 인스턴스다. 이 객체는 하나 이상의 함수를 이벤트로 사용할 수 있도록 이름을 넣어 추가하는 eventEmiter.on() 함수를 사용할 수 있다. 이벤트 이름은 일반적으로 카멜 케이스로 작성된 문자열이지만 JavaScript의 프로퍼티 키로 사용할 수 있는 모든 문자열을 사용할 수 있다.

EventEmitter 객체로 이벤트를 호출할 때, 해당 이벤트에 붙어 있는 모든 함수는 _동기적_으로 호출된다. 호출을 받은 리스너가 반환하는 결과는 어떤 값이든 _무시_되고 폐기된다.

다음은 EventEmitter 인스턴스를 단일 리스너와 함께 작성한 예다. eventEmitter.on() 메소드는 리스너를 등록하는데 사용한다. 그리고 eventEmitter.emit() 메소드를 통해 등록한 이벤트를 호출할 수 있다.

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});
myEmitter.emit('event');

어떤 객체든 상속을 통해 EventEmitter가 될 수 있다. 위에서 작성한 예는 util.inherits() 메소드를 사용했으며 이는 프로토타입으로 상속하는 방법으로 전통적인 Node.js 스타일이다. ES6 클래스 문법으로도 다음과 같이 사용할 수 있다:

const EventEmitter = require('events');

class MyEmitter extends EventEmitter {}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});
myEmitter.emit('event');

인자와 this를 리스너에 전달하기

eventEmitter.emit() 메소드는 인자로 받은 값을 리스너 함수로 전달한다. 이 과정에서 기억해야 할 부분이 있는데 일반적으로 EventEmitter를 통해 호출되는 리스너 함수 내에서는 this가 이 리스너 함수를 부착한 EventEmitter를 참조하도록 의도적으로 구현되어 있다.

const myEmitter = new MyEmitter();
myEmitter.on('event', function(a, b) {
  console.log(a, b, this);
    // Prints:
    //   a b MyEmitter {
    //     domain: null,
    //     _events: { event: [Function] },
    //     _eventsCount: 1,
    //     _maxListeners: undefined }
});
myEmitter.emit('event', 'a', 'b');

ES6의 Arrow 함수를 리스너로 사용하는 것은 가능하지만 이 기능의 명세대로 이 함수 내에서의 this는 더이상 EventEmitter 인스턴스를 참조하지 않는다:

const myEmitter = new MyEmitter();
myEmitter.on('event', (a, b) => {
  console.log(a, b, this);
    // Prints: a b {}
});
myEmitter.emit('event', 'a', 'b');

비동기 vs. 동기

EventListener는 모든 리스너를 등록한 순서대로 동기적으로 처리한다. 즉 이벤트를 적절한 순서로 처리하는 것을 보장해 경쟁 조건(race condition)이나 로직 오류를 피하는 것이 중요하다. 이 모든 것이 적절하게 구현되었을 때, setImmediate()이나 process.nextTick()메소드를 사용해 리스너 함수를 비동기도 동작하도록 전환할 수 있다.

const myEmitter = new MyEmitter();
myEmitter.on('event', (a, b) => {
  setImmediate(() => {
    console.log('this happens asynchronously');
  });
});
myEmitter.emit('event', 'a', 'b');

단 한 번만 동작하는 이벤트

eventEmitter.on() 메소드로 등록된 리스너는 이벤트 이름이 호출되는 매 횟수만큼 실행된다.

const myEmitter = new MyEmitter();
var m = 0;
myEmitter.on('event', () => {
  console.log(++m);
});
myEmitter.emit('event');
  // Prints: 1
myEmitter.emit('event');
  // Prints: 2

eventEmitter.once()메소드로 등록한 리스너는 호출한 직후 제거되어 다시 호출해도 실행되지 않는다.

const myEmitter = new MyEmitter();
var m = 0;
myEmitter.once('event', () => {
  console.log(++m);
});
myEmitter.emit('event');
  // Prints: 1
myEmitter.emit('event');
  // Ignored

오류 이벤트

EventEmitter 인스턴스에서 오류가 발생했을 때의 전형적인 동작은 'error' 이벤트를 호출하는 것이다. 이 경우는 Node.js에서 특별한 경우로 다뤄진다.

오류가 발생한 EventEmitter'error' 이벤트로 등록된 리스너가 하나도 없는 경우에는 오류가 던져지고(thrown) 스택 추적이 출력되며 Node.js의 프로세스가 종료된다.

const myEmitter = new MyEmitter();
myEmitter.emit('error', new Error('whoops!'));
  // Throws and crashes Node.js

Node.js 프로세스가 멈추는 것을 막기 위해서는 process.on('uncaughtException') 이벤트에 리스너를

등록하거나 domain 모듈을 사용할 수 있다. (하지만 domain 모듈은 더이상 사용하지 않는다(deprecated))

const myEmitter = new MyEmitter();

process.on('uncaughtException', (err) => {
  console.log('whoops! there was an error');
});

myEmitter.emit('error', new Error('whoops!'));
  // Prints: whoops! there was an error

개발자가 항상 'error' 이벤트에 리스너를 등록하는 것이 가장 좋은 방법이다:

const myEmitter = new MyEmitter();
myEmitter.on('error', (err) => {
  console.log('whoops! there was an error');
});
myEmitter.emit('error', new Error('whoops!'));
  // Prints: whoops! there was an error

클래스: EventEmitter

EventEmitter 클래스는 events 모듈에 의해서 정의되고 제공된다.

const EventEmitter = require('events');

모든 EventEmitters는 새로운 이벤트를 등록할 때마다 'newListner' 이벤트를 호출하고 리스너를 제거할 때마다 'removeListner'를 호출한다.

이벤트: ‘newListener’

  • event {String|Symbol} 이벤트명
  • listener {Function} 이벤트 처리 함수

EventEmitter 인스턴스는 인스턴스 자신의 'newListener' 이벤트를 리스너를 내부 리스너 배열에 추가하기 전에 호출한다.

'newListener' 이벤트에 리스너가 전달되기 위해 이벤트 명칭과 추가될 리스너의 참조가 전달된다.

실제로 리스너가 추가되기 전에 이 이벤트가 호출된다는 점으로 인해 다음과 같은 중대한 부작용이 나타날 수 있다. 어떤 추가적인 리스너든 동일한 명칭의 리스너를 'newListener' 콜백에서 먼저 등록한다면 추가하려는 해당 리스너가 실제로 등록되기 전에 이 함수가 먼저 추가될 것이다.

const myEmitter = new MyEmitter();
// Only do this once so we don't loop forever
myEmitter.once('newListener', (event, listener) => {
  if (event === 'event') {
    // Insert a new listener in front
    myEmitter.on('event', () => {
      console.log('B');
    });
  }
});
myEmitter.on('event', () => {
  console.log('A');
});
myEmitter.emit('event');
  // Prints:
  //   B
  //   A

이벤트: ‘removeListener’

  • event {String|Symbol} 이벤트명
  • listener {Function} 이벤트 처리 함수

'removeListener' 이벤트는 리스너가 _제거된 후_에 실행된다.

EventEmitter.listenerCount(emitter, event)

안정성: 0 – 추천 안함: emitter.listenerCount()을 대신 사용할 것.

인자로 넘긴 emitter에 해당 event가 리스너를 몇 개나 갖고 있는지 확인하는 클래스 메소드다.

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {});
myEmitter.on('event', () => {});
console.log(EventEmitter.listenerCount(myEmitter, 'event'));
  // Prints: 2

EventEmitter.defaultMaxListeners

기본값으로 한 이벤트에 최대 10개 리스너를 등록할 수 있다. 이 제한은 각각의 EventEmitter의 인스턴스에서 emitter.setMaxListeners(n) 메소드로 지정할 수 있다. 모든 EventEmitter 인스턴스의 기본값을 변경하려면 EventEmitter.defaultMaxListeners 프로퍼티를 사용할 수 있다.

EventEmitter.defaultMaxListeners 설정을 변경할 때는 모든 EventEmitter 인스턴스에게 영향을 주기 때문에 이 변경 이전에 만든 부분에 대해서도 주의해야 한다. 하지만 emitter.setMaxListeners(n)를 호출해서 설정한 값이 있다면 EventEmitter.defaultMaxListeners의 값보다 우선으로 적용된다.

참고로 이 값은 강제적인 제한이 아니다. EventEmitter 인스턴스는 제한된 값보다 더 많은 리스너를 추가할 수 있지만 EventEmitter 메모리 누수의 가능성이 있는 것으로 보고 stderr를 통해 경고를 보내 개발자가 문제를 인지할 수 있게 한다. 어떤 EventEmitteremitter.getMaxListeners()emitter.setmaxListeners() 메소드를 사용해 이 경고를 임시로 피할 수 있다:

emitter.setMaxListeners(emitter.getMaxListeners() + 1);
emitter.once('event', () => {
  // do stuff
  emitter.setMaxListeners(Math.max(emitter.getMaxListeners() - 1, 0));
});

emitter.addListener(event, listener)

emitter.on(event, listener)의 별칭이다.

emitter.emit(event[, arg1][, arg2][, …])

event에 등록된 리스너를 등록된 순서에 따라 동기적으로 호출한다. 제공되는 인자를 각각 리스너로 전달한다.

이벤트가 존재한다면 true, 그 외에는 false를 반환한다.

emitter.getMaxListeners()

현재 EventEmitter에 지정된 최대 리스너 수를 반환한다. 기본값은 EventEmitter.defaultMaxListeners이며 emitter.setMaxListeners(n)로 변경했을 경우에는 그 값을 반환한다.

emitter.listenerCount(event)

  • event {Value} The type of event
  • event {Value} 이벤트 이름

해당 event 이름에 등록되어 있는 리스너의 수를 반환한다.

emitter.listeners(event)

해당 event에 등록된 리스너 배열의 사본을 반환한다.

server.on('connection', (stream) => {
  console.log('someone connected!');
});
console.log(util.inspect(server.listeners('connection')));
  // Prints: [ [Function] ]

emitter.on(event, listener)

listener 함수를 지정한 event의 리스너 배열 가장 끝에 추가한다. listener가 이미 추가되어 있는 함수인지 확인하는 과정이 없다. 같은 조합의 eventlistener를 여러 차례 추가했다면 추가한 만큼 여러번 호출된다.

server.on('connection', (stream) => {
  console.log('someone connected!');
});

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이(chaining) 가능하다.

emitter.once(event, listener)

일회성 listener 함수를 event에 등록한다. 이 이벤트는 다음 차례 event가 호출될 때 한 번만 실행한 후 제거된다.

server.once('connection', (stream) => {
  console.log('Ah, we have our first user!');
});

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

emitter.removeAllListeners([event])

모든 리스너, 또는 지정한 event의 리스너를 제거한다.

코드 다른 곳에 추가되어 있는 리스너를 제거하는 것은 좋지 않은 방법이다. 특히 EventEmitter 인스턴스가 다른 컴포넌트나 모듈에서 생성되었을 때는 더 유의해야 한다. (예를 들어, 소켓이나 파일 스트림 등)

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

emitter.removeListener(event, listener)

특정 event에 등록되어 있는 특정 listener를 제거한다.

var callback = (stream) => {
  console.log('someone connected!');
};
server.on('connection', callback);
// ...
server.removeListener('connection', callback);

removeListener는 한 인스턴스 내에 존재하는 리스너 배열에서 해당 리스너를 하나 제거한다. 만약 한 리스너를 여러 차례 특정 event에 등록해서 배열 내 여러 개 존재한다면, 그 모든 리스너를 지우기 위해서는 removeListener를 여러 번 호출해야 한다.

이 메소드로 리스너가 하나 지워진 후에 등록되어 있는 리스너의 위치 인덱스가 변경되는데 리스너가 내부 배열로 관리되기 때문이다. 이 동작은 리스너가 호출되는 순서에는 영향을 주지 않지만 emitter.listeners() 메소드로 생성한 리스너 배열의 사본이 있다면 다시 생성해야 할 필요가 있다.

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

emitter.setMaxListeners(n)

EventEmitters의 기본값인 10보다 더 많은 리스너가 등록되어 있다면 경고가 출력된다. 이 함수는 어디에서 메모리 누수가 발생하는지 찾는데 유용하다. 명백하게도 모든 이벤트가 10개의 리스너 제한이 필요한 것은 아니다. emitter.setMaxListeners() 메소드를 사용하면 특정 EventEmitter 인스턴스에 대해 그 제한을 변경할 수 있다. Infinity (또는 ``)을 지정하면 리스너를 숫자 제한 없이 등록할 수 있다.

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

Todd Motto의 글 Exploring the Angular 1.5 .component() method를 번역한 글이다. 아직 1.5 beta 1이라서 아직 한참 출시 전이긴 하지만 이 글에서 확인할 수 있는 변화는 크게 달라질 것 같지 않다. 이 글 후반부에서 Component() 메소드 구현을 붙여놓은 부분이 있는데 하위 호환성을 지키면서 상위 기능을 개발하는 방식이 어떤가 생각하고 읽으면 더 재미있게 느껴지는 것 같다.


Angular 1.5의 새 기능, .component() 알아보기

Angular 1.5에서는 component()라는 헬퍼 메소드를 소개하고 있다. directive() 메소드를 사용한 정의에 비해 훨씬 간단하게 일반적인 기본 동작과 모범적인 예를 활용할 수 있게 지원한다. .component()를 사용하는 것으로 Angular 2의 스타일을 사용해 작성할 수 있으며 차후 버전에도 적합하다.

.directive()메소드와 .component() 메소드에서 사용하는 문법을 비교해보고 component()가 제공하는 멋진 추상을 살펴보자.

노트: Angular 1.5는 여전히 beta므로 이 버전의 출시를 눈여겨 보자.

.directive() 에서 .component() 로

이 문법 변경은 아주 간단하다:

// before
module.directive(name, fn);

// after
module.component(name, options);

name 인자는 컴포넌트로 정의하고 싶은 이름이며, options 인자에는 함수를 넣었던 1.4 그 이하 버전에 문법과 달리 이 컴포넌트에 대한 객체 형태의 정의가 들어간다.

간단한 counter 컴포넌트를 미리 만들었는데 Angular 1.4.x에서 사용한 문법을 .component() 메소드를 활용해 v1.5.0에 맞게 리팩토링 하는 과정을 보여주려고 한다.

.directive('counter', function counter() {
  return {
    scope: {},
    bindToController: {
      count: '='
    },
    controller: function () {
      function increment() {
        this.count++;
      }
      function decrement() {
        this.count--;
      }
      this.increment = increment;
      this.decrement = decrement;
    },
    controllerAs: 'counter',
    template: [
      '<div class="todo">',
        '<input type="text" ng-model="counter.count">',
        '<button type="button" ng-click="counter.decrement();">-</button>',
        '<button type="button" ng-click="counter.increment();">+</button>',
      '</div>'
    ].join('')
  };
});

1.4.x 디렉티브의 라이브 코드는 여기서 확인할 수 있다.

Angular 1.4 버전에서 만든 이 버전을 변경하며 그 변화를 살펴보기로 한다.

함수를 객체로, 메소드 이름의 변화

function 인자를 Object로 변경하는 것을 먼저 해보자. 그리고서 이름을 .directive()에서 .component()로 변경한다:

// before
.directive('counter', function counter() {
  return {
    
  };
});

// after
.component('counter', {
    
});

간단하고 좋다. .directive()에서 return {};가 필수적이었던 반면 .component()의 객체 사용은 훨씬 단순하게 보인다.

"scope"와 "bindToController"를 "bindings"로 변경

.directive() 메소드에서 scope 프로퍼티는 $scope를 고립할지 혹은 상속할지에 대한 정의를 위해 활용했는데 대부분의 경우 기본적으로 모든 스코프가 고립된 형태로 정의를 했다. 그래서 매번 디렉티브를 만들 때마다 과도하게 스코프를 매번 정의해야 하는 불편함이 있었다. bindToController가 소개된 후, 프로퍼티를 스코프에 넘기는지, 또는 컨트롤러에 바로 연결하는지를 명시적으로 선언할 수 있게 되었다.

.component()bindings 프로퍼티는 이런 반복적인 기초 작업을 제거했다. 컴포넌트가 고립된 스코프를 갖는다는 가정을 기본적으로 하고서, 간단하게 컴포넌트에 내려주고 싶은 데이터만 정의해주면 된다.

// before
.directive('counter', function counter() {
  return {
    scope: {},
    bindToController: {
      count: '='
    }
  };
});

// after
.component('counter', {
  bindings: {
    count: '='
  }
});

Controller와 controllerAs의 변경

controller의 정의는 변경된 바가 없지만 좀 더 똑똑해졌다.

컴포넌트에 컨트롤러를 그 자리에서 선언할 때는 이렇게 작성했을 것이다:

// 1.4
{
  ...
  controller: function () {}
  ...
}

컨트롤러를 다른 곳에서 정의했을 때는 이렇게 했을 것이다:

// 1.4
{
  ...
  controller: 'SomeCtrl'
  ...
}

controllerAs를 선언하고 싶을 때는, 새로운 프로퍼티를 추가해서 인스턴스의 별칭을 지정해야 했다.

// 1.4
{
  ...
  controller: 'SomeCtrl',
  controllerAs: 'something'
  ...
}

이 과정으로 template 내에서 컨트롤러의 인스턴스를 활용할 때 something.prop을 사용하는 것이 가능해졌다.

이제 .component()으로 변경되면서 controllerAs 프로퍼티를 센스있게 추측해서 자동으로 생성한다. 다음 코드에서 볼 수 있는 것처럼 사용 가능성이 있는 다음 세가지 이름을 자동으로 배정해준다:

// inside angular.js
controllerAs: identifierForController(options.controller) || options.controllerAs || name,

identifierForController 함수가 컨트롤러의 이름을 추측하는 방법은 다음과 같다:

// inside angular.js
var CNTRL_REG = /^(\S+)(\s+as\s+(\w+))?$/;
function identifierForController(controller, ident) {
  if (ident && isString(ident)) return ident;
  if (isString(controller)) {
    var match = CNTRL_REG.exec(controller);
    if (match) return match[3];
  }
}

이 함수로 .component()에서 다음과 같은 문법을 사용할 수 있게 된다:

// 1.5
{
  ...
  controller: 'SomeCtrl as something'
  ...
}

이 기능이 controllerAs 프로퍼티를 추가하지 않게 만든다... 하지만...

controllerAs 프로퍼티를 하위 호환성을 위해, 또는 디렉티브/컴포넌트를 작성하는 스타일을 유지하기 위해 계속 사용할 수도 있다.

세번째 선택지로는 그렇게 좋은 방법은 아니지만 controllerAs를 완벽하게 다 지워버리고 Angular가 자동으로 해당 컴포넌트의 이름을 별칭으로 사용하게 하는 방법을 사용 할 수 있다. 예를 들면:

.component('test', {
  controller: function () {
    this.testing = 123;
  }
});

여기서 controllerAs의 정의는 자동으로 test가 된다. 그래서 template에서 test.testing을 사용하면 123 값으로 반환할 것이다.

이 방법으로 controller를 추가하고 앞서 작성한 디렉티브를 컴포넌트로 변경하며 controllerAs 프로퍼티를 제거할 수 있다:

// before
.directive('counter', function counter() {
  return {
    scope: {},
    bindToController: {
      count: '='
    },
    controller: function () {
      function increment() {
        this.count++;
      }
      function decrement() {
        this.count--;
      }
      this.increment = increment;
      this.decrement = decrement;
    },
    controllerAs: 'counter'
  };
});

// after
.component('counter', {
  bindings: {
    count: '='
  },
  controller: function () {
    function increment() {
      this.count++;
    }
    function decrement() {
      this.count--;
    }
    this.increment = increment;
    this.decrement = decrement;
  }
});

변경된 방법으로 정의하고 사용하는 것이 훨씬 간단하다.

템플릿

template에도 적어둘 만한, 세밀한 변화가 있다. template 프로퍼티를 추가하고 어떤지 확인하자.

.component('counter', {
  bindings: {
    count: '='
  },
  controller: function () {
    function increment() {
      this.count++;
    }
    function decrement() {
      this.count--;
    }
    this.increment = increment;
    this.decrement = decrement;
  },
  template: [
    '<div class="todo">',
      '<input type="text" ng-model="counter.count">',
      '<button type="button" ng-click="counter.decrement();">-</button>',
      '<button type="button" ng-click="counter.increment();">+</button>',
    '</div>'
  ].join('')
});

template 프로퍼티는 이제 함수로 정의해서 $element$attrs를 주입하는 형태로 사용할 수 있다. 만약 template 프로퍼티에 함수를 넣으면 컴파일 할 수 있는 HTML 문자열을 반환해야 한다.

{
  ...
  template: function ($element, $attrs) {
    // access to $element and $attrs
    return [
      '<div class="todo">',
        '<input type="text" ng-model="counter.count">',
        '<button type="button" ng-click="counter.decrement();">-</button>',
        '<button type="button" ng-click="counter.increment();">+</button>',
      '</div>'
    ].join('')
  }
  ...
}

동작하는 예제를 확인하자. Angular 버전 v1.5.0-build.4376+sha.aff74ec 예제다:

여기까지 디렉티브를 컴포넌트로 변경하는 과정이었다. 여기서 끝내기 전에 살펴봐야 하는 변경점이 몇가지 더 있다.

끼워넣기가 가정되어 있음 Assumed transclusion

컴포넌트는 기본적으로 끼워넣기(Transclusion)를 가정하고 있는데, 다음 Angular 코드에 의해 설정된다:

// angular.js
{
  ...
  transclude: options.transclude === undefined ? true : options.transclude
  ...
}

이 기능을 끄고 싶다면 transclude: false로 정의하면 된다.

고립된 스코프 끄기

컴포넌트는 스코프 고립이 기본값이다. .component()에서 이 설정을 바꾸고 싶다면 간단하게 프로퍼티로 정의하면 된다:

.component('counter', {
  isolate: false
});

다음 Angular의 삼항 연산자에 따라서 자동으로 scope에 빈 객체를 넣게 된다. .directive()에서 상속하는 방식대로 사용하고 싶다면 고립된 스코프를 끄면 된다. 그러면 scope: true로 동작한다. 내부 코드는 다음과 같다:

{
  ...
  scope: options.isolate === false ? true : {}
  ...
}

비교를 위한 소스코드

이 글 내내 Angular 소스 코드 스니핏을 교차 레퍼런스로 활용했다. 코드를 보고 싶다면 여기에서 확인하거나 아래 코드를 확인해보면 좋겠다. 정말 좋은 추상화 구현이다:

component: function(name, options) {
  function factory($injector) {
    function makeInjectable(fn) {
      if (angular.isFunction(fn)) {
        return function(tElement, tAttrs) {
          return $injector.invoke(fn, this, {$element: tElement, $attrs: tAttrs});
        };
      } else {
        return fn;
      }
    }

    var template = (!options.template && !options.templateUrl ? '' : options.template);
    return {
      controller: options.controller || function() {},
      controllerAs: identifierForController(options.controller) || options.controllerAs || name,
      template: makeInjectable(template),
      templateUrl: makeInjectable(options.templateUrl),
      transclude: options.transclude === undefined ? true : options.transclude,
      scope: options.isolate === false ? true : {},
      bindToController: options.bindings || {},
      restrict: options.restrict || 'E'
    };
  }

  if (options.$canActivate) {
    factory.$canActivate = options.$canActivate;
  }
  if (options.$routeConfig) {
    factory.$routeConfig = options.$routeConfig;
  }
  factory.$inject = ['$injector'];

  return moduleInstance.directive(name, factory);
}

다시 말하지만 Angular 1.5는 아직 릴리즈되지 않았다. 그래서 이 글에서 사용한 API는 아마 조금은 달라질 수 있다.

Angular 2 로 업그레이드하기

.component()를 사용해서 작성하는 스타일은 추후 Angular 2 로 옮기는데 도움이 된다. Angular 2의 문법에서 컴포넌트는 ECMAScript 5와 새로운 템플릿 문법을 활용하고 있긴 하지만 말이다:

var Counter = ng
.Component({
  selector: 'counter',
  template: [
    '<div class="todo">',
      '<input type="text" [(ng-model)]="count">',
      '<button type="button" (click)="decrement();">-</button>',
      '<button type="button" (click)="increment();">+</button>',
    '</div>'
  ].join('')
})
.Class({
  constructor: function () {
    this.count = 0;
  },
  increment: function () {
    this.count++;
  },
  decrement: function () {
    this.count--;
  }
});

이 글은 Todd Motto의 글 No $scope soup, bindToController in AngularJS를 번역한 글이다.

Angular에서 controllerAs 문법을 사용한다면 자연스럽게 따라오는 디렉티브 프로퍼티인 bindToController에 관한 글이다. 기존 $scope와 어떤 방식이 다른지, 어떻게 작성하는 것이 좋은지 확인할 수 있다.


$scope은 이제 그만, Angular에서 bindToController 활용하기

소프트웨어 공학에서 네임스페이스, 코드 일관성, 적절한 디자인 패턴은 정말 중요한 문제다. Angular는 프론트엔드 엔지니어로 직면할 수 있는 수많은 문제를 정말 잘 해결했다.

디렉티브의 프로퍼티인 bindToController을 어떻게 사용하는지 설명하는 것으로 DOM-컨트롤러 네임스페이스를 정리하고, 코드의 일관성을 유지하는 방법과 함께 컨트롤러 객체를 생성하고 데이터를 다른 곳에서 사용하는데 더 편리한 디자인 패턴을 만드는 과정을 설명하려 한다.

그 전에 해야 할 일

bindToControllercontrollerAs 문법과 함께 사용해야 한다. 이 문법은 컨트롤러를 클래스 같은 객체로 다룰 수 있게 하는데 생성자처럼 초기화하는 과정에서 그 초기화를 통해 네임스페이스를 통제할 수 있게 된다. 다음 예를 살펴보자:

<div ng-controller="MainCtrl as vm">
  {% raw %}{{ vm.name }}{% endraw %}
</div>

controllerAs 문법이 없었던 예전에는 컨트롤러에 대해 고유한 네임스페이스가 제공되지 않았고 JavaScript 객체 프로퍼티가 붕 뜬 상태처럼 존재해 DOM 주변을 맴돌았는데 그로 인해 컨트롤러 내에서 코드 일관성을 유지하기가 힘들었다. 게다가 $parent로 인한 상속 문제도 지속적으로 발생했다. 이런 문제를 이 글에서 모두 해결하려고 하는데, 앞서 작성한 포스트(번역)에서도 그 문제를 자세히 확인할 수 있다.

문제점

컨트롤러를 controllerAs 문법으로 작성할 때 나타날 만한 문제는 컴포넌트를 클래스 같은 객체로 작성해야 하는 점, 그리고 상속된 데이터에 접근하기 위해 ("독립된 스코프"에서) $scope를 주입해야 하는 경우다. 간단한 예제로 시작하면:

// controller
function FooDirCtrl() {

  this.bar = {};
  this.doSomething = function doSomething(arg) {
    this.bar.foobar = arg;
  }.bind(this);

}

// directive
function fooDirective() {
  return {
    restrict: 'E',
    scope: {},
    controller: 'FooDirCtrl',
    controllerAs: 'vm',
    template: [
        // vm.name doesn't exist just yet!
        '<div><input ng-model="vm.name"></div>'
    ].join('')
  };
}

angular
  .module('app')
  .directive('fooDirective', fooDirective)
  .controller('FooDirCtrl', FooDirCtrl);

이제 "상속된" 스코프가 필요하다. 그래서 고립된 스코프인 scope: {}에 필요한 참조를 추가한다:

function fooDirective() {
  return {
    ...
    scope: {
      name: '='
    },
    ...
  };
}

여기까지면 됐다. 이제 $scope를 주입해야 한다. 새로 작성한 클래스 같은 객체에 $scope 객체를 주입하게 되면 더 나은 디자인 원칙을 적용하는데 더 어려운 상황에 놓인다. 하지만 여기서는 주입해야먄 한다.

더 지저분하게 만들어보자:

// controller
function FooDirCtrl($scope) {

  this.bar = {};
  this.doSomething = function doSomething(arg) {
    this.bar.foobar = arg;
    $scope.name = arg.prop; // reference the isolate property
  }.bind(this);

}

여기서 보면, 클래스 같은 객체 패턴을 사용해서 애써 새로운 디렉티브를 만들었는데 그 흥분을 $scope가 망쳐버렸다.

그 뿐만 아니라 앞서 작성한 가사 템플릿을 다시 보면 vm. 접두어를 만들었는데도 네임스페이스 없는 변수가 또 다시 떠돌게 된다:

<div>
  {% raw %}{{ name }}{% endraw %}
  <input type="text" ng-model="vm.username">
</div>

해결책

해결책에 앞서, Angular가 클래스 같은 객체 패턴을 시도한 것에 대해 부정적인 반응이 많았다. 디자인에 대해 알고 전력으로 만들었지만 모든게 완벽할 순 없다. 2버전에서 다시 쓴다고 해도 모든 상황에 완벽해질 수 없다. 이 포스트는 Angular의 나쁜 $scope 습관을 버리기 위한, 위대한 해결책을 다루고 있고, 더 나은 JavaScript 디자인에 가깝도록 작성하는데 최선을 다하고 있다.

bindToController 프로퍼티를 입력한다. 문서에서는 bindToController의 값을 true로 활성화하면 상속된 프로퍼티가 $scope 객체가 아닌 컨트롤러로 연결된다.

function fooDirective() {
  return {
    ...
    scope: {
      name: '='
    },
    bindToController: true,
    ...
  };
}

이 코드로 앞서 작성한 코드를 리팩토링할 수 있게 되었다. $scope를 제거하자:

// controller
function FooDirCtrl() {

  this.bar = {};
  this.doSomething = function doSomething(arg) {
    this.bar.foobar = arg;
    this.name = arg.prop; // reference the isolate property using `this`
  }.bind(this);

}

Angular 문서는 bindToController: true 대신 객체를 사용하는 것을 제안하지 않지만, Angular 소스 코드에서 이런 코드를 확인할 수 있다:

if (isObject(directive.bindToController)) {
  bindings.bindToController = parseIsolateBindings(directive.bindToController, directiveName, true);
}

bindToController에 객체가 온다면 앞서 본 형태의 상속과 달리 독립적인 바인딩을 사용하게 된다. 즉 scope: { name: '='} 예제를 더 명시적으로 컨트롤러에 바인딩하는 것으로 표현할 수 있다는 뜻이다. (내가 선호하는 문법이다.):

function fooDirective() {
  return {
    ...
    scope: {},
    bindToController: {
      name: '='
    },
    ...
  };
}

(역주. scope에 선언한 객체는 $scope에 바인딩되고, bindToController에 선언한 객체는 this에 바인딩 된다. bindToControllertrue로 값을 넣으면 scope에 선언한 객체가 scope 대신 bindToController에 선언한 객체처럼 처리된다. 즉, $scope와 this를 구분해서 써야 하는 상황이라면, 위와 같이 별도로 선언하는 방법이 필요하겠다.)

이제 JavaScript 해결책을 확인했다. 이 변화가 템플릿에 어떤 영향이 있는지 확인하자.

이전에, name$scope에 상속했을 때와 달리 컨트롤러 내에서 동일한 네임스페이스를 사용할 수 있다. 다시 기뻐하자! 이 방법으로 모든 코드가 일관적이고 좋은 가독성을 지니게 되었다. 마지막으로 vm. 접두어를 name 프로퍼티 앞에 적어 템플릿도 일관적이게 변경하자.

<div>
  {% raw %}{{ vm.name }}{% endraw %}
  <input type="text" ng-model="vm.username">
</div>

라이브 리펙토링 예제

실제로 동작해볼 수 있는 예제를 jsFiddle에 올렸다. 이 예제로 리펙토링 과정을 시연한다. (이 변화는 최근 Angular 1.2에서 1.4로 변경한 우리 팀에게 특히 좋았다.)

노트: 각 예제는 부모 컨트롤러에서 디렉티브로 양방향 고립 바인딩을 사용했고 입력창에 값을 변경해 부모에 반영되는지 확인할 수 있다.

첫 예제는, $scope 객체를 넘긴다. 템플릿과 컨트롤러 로직에서 $scopethis가 복잡한 상태로 그대로 두었다. 라이브 예제 1

angular
    .module('app', []);

// main.js
function MainCtrl() {
    this.name = 'Todd Motto';
}

angular
    .module('app')
    .controller('MainCtrl', MainCtrl);

// foo.js
function FooDirCtrl() {

}

function fooDirective() {
    
    function link($scope) {
        
    }
    
    return {
        restrict: 'E',
        scope: {
            name: '='
        },
        controller: 'FooDirCtrl',
        controllerAs: 'vm',
        template: [
            '<div><input ng-model="name"></div>'
        ].join(''),
        link: link
    };
}

angular
    .module('app')
    .directive('fooDirective', fooDirective)
    .controller('FooDirCtrl', FooDirCtrl);

두번째 예제는 $scopebindToController: true와 함께 리팩토링했다. 템플릿의 네임스페이스 문제도 this 객체 밑에 컨트롤러 로직의 일관성을 유지하는 것으로 해결했다. 라이브 예제 2

angular
    .module('app', []);

// main.js
function MainCtrl() {
    this.name = 'Todd Motto';
}

angular
    .module('app')
    .controller('MainCtrl', MainCtrl);

// foo.js
function FooDirCtrl() {

}

function fooDirective() {
    
    function link($scope) {
        
    }
    
    return {
        restrict: 'E',
        scope: {
            name: '='
        },
        controller: 'FooDirCtrl',
        controllerAs: 'vm',
        bindToController: true,
        template: [
            '<div><input ng-model="vm.name"></div>'
        ].join(''),
        link: link
    };
}

angular
    .module('app')
    .directive('fooDirective', fooDirective)
    .controller('FooDirCtrl', FooDirCtrl);

선호하는 세번째 예제로, bindToController: true를 객체로 사용하고, scope: {}로 프로퍼티를 변경하는 것으로 더 명확하게 작성했다. 두번째 예제와 결과적으로 같지만, 함께 작업하는 개발자를 위해 더 명확하게 작성하는 방법이다. 라이브 예제 3

angular
    .module('app', []);

// main.js
function MainCtrl() {
    this.name = 'Todd Motto';
}

angular
    .module('app')
    .controller('MainCtrl', MainCtrl);

// foo.js
function FooDirCtrl() {

}

function fooDirective() {
    
    function link($scope) {
        
    }
    
    return {
        restrict: 'E',
        scope: {},
        controller: 'FooDirCtrl',
        controllerAs: 'vm',
        bindToController: {
            name: '='
        },
        template: [
            '<div><input ng-model="vm.name"></div>'
        ].join(''),
        link: link
    };
}

angular
    .module('app')
    .directive('fooDirective', fooDirective)
    .controller('FooDirCtrl', FooDirCtrl);

이 글은 Todd Motto의 글 Killing it with Angular Directives; Structure and MVVM를 번역한 글이다.

Angular에서 디렉티브를 어떤 방식으로 사용해야 하는지 예제를 통해 설명하고 있다. Angular에서 각각 코드 사이의 관계를 분리하는 방식은 Angular만의 방식이 아닌 MVVM 패턴을 활용하고 있다. 디렉티브의 구조를 어떤 방식으로 작성하는지, linkcontroller, 그리고 외부에서 서비스를 주입하는 것으로 각각 로직을 어떻게 분리하는지 이해하는데 도움되는 글이다.


Angular 디렉티브 때려잡기: 구조와 MVVM

이 포스트에서는 Angular 1.x에서 어떻게 디렉티브(Directives)를 작성하는가에 대한 원칙을 설명하려고 한다. 디렉티브를 어떻게, 왜, 어디에서 사용해야 하는지에 대한 혼란이 많다. 하지만 이 개념을 한번만 이해하고 구분하면 아주 간단한 기능이다. 이 포스트는 중첩된 디렉티브 또는 부모 스코프에서 흐르는 데이터 흐름과 같은 내용을 다루지 않는다. 대신 디렉티브를 만들고 구조화하고, 관계를 분리하는데 가장 이상적인 방법과 함께, controllerlink 프로퍼티를 어떻게 올바르게 사용하는가에 대해 다루려고 한다.

기초적인 디렉티브, 구조, 그리고 Angular에서 사용하기 가장 좋은 방식으로 구조화하는 방법에 대해 다룬다. 디렉티브 작성에 대한 접근 방식을 보여주기 위해 모조 "파일 업로드" 디렉티브를 만들어보자.

노트, 이 코드는 실제로 동작하지 않으며, 효과적으로 디렉티브를 구조화 하는 방법에 대해 설명하려는 의도로 작성했다.

구조 Structure

AngularJS 스타일 가이드를 따라 자연스럽게 기초적인 디렉티브 정의를 작성하고 Angular의 .directive() 메소드에 넣는다:

function fileUpload () {
  
  return {};

}
angular
  .module('app')
  .directive('fileUpload', fileUpload);

적당한 위치에 정의를 작성했으니 파일 업로드 컴포넌트를 위한 기본적인 프로퍼티를 추가한다:

function fileUpload () {
  
  return {
    restrict: 'E',
    scope: {},
    template: [
      '<div>',
      '</div>'
    ].join(''),
    controllerAs: 'vm',
    controller: function () {},
    link: function () {}
  };

}
angular
  .module('app')
  .directive('fileUpload', fileUpload);

이 코드가 내가 기본적으로 필요로 하는 모든 요소가 포함된 "디렉티브 보일러플레이트"로, 이 코드를 기초로 동작하는 디렉티브를 만든다.

컨트롤러 Controller (presentational layer)

컨트롤러를 controller: fn처럼 객체에 바인딩하는 대신 (link에도 동일), controller 프로퍼티를 fileUpload 함수 정의 내에서 바인딩을 먼저 한 후, 객체에 연결해 반환받는 형태로 작성한다. 이 방식으로 작성하면 함수를 객체에 직접 작성하는 방식보다 함수를 정의하는 공간이 있어서 연관된 함수를 더 쉽게 찾고 이해할 수 있고, 함수에 주석을 작성하는데 더 나은 구조가 된다. 이 형태는 "엄격해 보이는 API"에 묶여 있는 느낌보다 평범한 JavaScript처럼 표현된다.

함수를 맨 위에 작성하고 주석 몇 개를 작성한다.

/**
 * @name fileUpload
 * @desc <file-upload> Directive
 */
function fileUpload () {

  /**
   * @name fileUploadCtrl
   * @desc File Upload Controller
   * @type {Function}
   */
  function fileUploadCtrl() {

  }

  /**
   * @name link
   * @desc File Upload Link
   * @type {Function}
   */
  function link() {

  }

  return {
    restrict: 'E',
    scope: {},
    template: [
      '<div>',
      '</div>'
    ].join(''),
    controllerAs: 'vm',
    controller: fileUploadCtrl
    link: link
  };

}
angular
  .module('app')
  .directive('fileUpload', fileUpload);

멋진가? 당연하다. controllerAs: 'vm'에서 볼 수 있듯 컨트롤러를 vm이란 별칭으로 지정했다. (뷰모델 ViewModel을 뜻한다.) 이렇게 컨트롤러를 뷰모델로 다루는 것은 "프리젠테이션 모델 Presentation Model" 디자인 패턴에 해당한다. 이 문법에 익숙하지 않다면 ControllerAs(번역)에 대해 먼저 읽어보자. $scope를 필수적으로 주입하는 방식 대신 컨트롤러 자체를 $scopevm 별칭에 바인딩하는 방식, 즉 $scope.vm을 생성하게 된다. $scope 대신 this 키워드를 사용하는 것으로 이 뷰-모델을 마치 컨트롤러 "클래스"인 것처럼 작성할 수 있다.

/**
 * @name fileUploadCtrl
 * @desc File Upload Controller
 * @type {Function}
 */
function fileUploadCtrl() {
  this.files = [];
  this.uploadFiles = function () {

  };
}

this를 사용하는 것이 $scope 쓰는 것보다 훨씬 낫게 보인다. $scope$on 이벤트나 $watch를 사용하는 경우에나 필요하다. 이런 방식으로 컨트롤러 클래스인 "뷰모델"을 조금 다르게 작성할 수 있다.

기본적인 함수 작성은 다 끝났다. 하지만 나는 "exports" 스타일을 더 선호하고, 모든 함수와 변수가 바인딩되고, 어떤 적절한 주석이든 작성할 수 있는 형태가 좋다. 이 모든 것을 염두해서 작성하면 다음과 같다:

/**
 * @name fileUpload
 * @desc <file-upload> Directive
 */
function fileUpload () {

  /**
   * @name fileUploadCtrl
   * @desc File Upload Controller
   * @type {Function}
   */
  function fileUploadCtrl() {

    /**
     * @name files
     * @desc Contains all files passed in by the user
     * @type {Array}
     */
    var files = [];

    /**
     * @name uploadFiles
     * @desc Uploads our files
     * @type {Array}
     */
    function uploadFiles() {

    }

    // exports
    this.files = files;
    this.uploadFiles = uploadFiles;

  }

  /**
   * @name link
   * @desc File Upload Link
   * @type {Function}
   */
  function link() {

  }

  return {
    restrict: 'E',
    scope: {},
    template: [
      '<div>',
      '</div>'
    ].join(''),
    controllerAs: 'vm',
    controller: fileUploadCtrl
    link: link
  };

}
angular
  .module('app')
  .directive('fileUpload', fileUpload);

템플릿 융화 Template integration

다음 순서로 <input type=file> 같이 파일 업로드를 위한 엘리먼트를 작성하고 모델에 연결해야 한다. 앞서 작성한 디렉티브에 ng-model 어트리뷰트와 값을 추가하자. 또한 ng-change와 함께 "업로드" 버튼을 추가하자. (그렇다. form이라면 ng-submit을 사용해야 하겠지만 간단하게 작성하기로 한다.)

// ...
template: [
  '<div>',
    '<input type="file" ng-model="vm.files">',
    '<button type="button" ng-change="vm.uploadFiles(vm.files);">',
  '</div>'
].join('')
// ...

이 컨트롤러에 어떻게 업로드를 다뤄야 하는지에 대한 주석을 작성하자. UploadService을 추가했고 (좋은 이름이다) 이 서비스의 의존성이 컨트롤러에 주입될 수 있도록 fileUploadCtrl에 매개변수로 추가했다.

/**
 * @name fileUploadCtrl
 * @desc File Upload Controller
 * @type {Function}
 */
function fileUploadCtrl(UploadService) {

  /**
   * @name files
   * @desc Contains all files passed in by the user
   * @type {Array}
   */
  var files = [];

  /**
   * @name uploadFiles
   * @desc Uploads our files
   * @type {Array}
   */
  function uploadFiles(files) {
    // hand off our files to a Service
    UploadService
    .uploadFiles(files)
    .then(function (response) {
      // success, we could get our file Object back
      // and render it in the View for the user
      // maybe some ng-repeat with a list of files inside
    }, function (reason) {
      // error stuff if not handled globally
    })
  }

  // exports
  this.files = files;
  this.uploadFiles = uploadFiles;

}

잠깐, 별로 많이 변경되지 않았다. 왜지? 왜 그런지 이유를 보자.

서비스 Services (business logic layer)

백엔드와 연결해서 파일을 업로드 하는 작업과 같이 API와 소통하는 무엇이든 절대, 절대 컨트롤러에 작성하지 않는다. 왜냐고? 관계를 분리하는 것이다. 물론 컨트롤러에 작성할 수도 있다. 하지만 컨트롤러를 뷰모델비지니스로직어쩌고가 아닌 뷰모델처럼 사용한다면 우리 삶을 너무나도 힘들게 만든다.

여기서 서비스를 위한 모조 코드를 작성하진 않을 것이지만 왜 추상화된 비지니스 로직을 컨트롤러에 넘겨야 하는가에 대해서 이해하는 것은 아주 중요하다. 디렉티브의 구조와 의존성을 관리에 용이하고 확장 가능하게 구축하기 위해서는 처음부터 고려를 해야한다.

서비스는 컨트롤러(뷰모델)가 사용자에게 데이터를 표현할 수 있도록 필요한 모델 데이터를 복제해서 제공할 수 있어야 한다.

디렉티브는 표현 로직 레이어(컨트롤러)나 비지니스 로직 레이어(서비스)가 다루지 못하는 환상적인 통로를 제공하는데 그건 바로 DOM 문서 객체 모델(Document Object Model)이다. 종종 DOM이 필요한데 Angular는 우리를 위해 준비를 해두었다.

여기서 작성한 파일 업로드 디렉티브는 흑마법 같은 드래그 드랍 없이는 완성되지 않은 것이나 마찬가지니 dragover, drop 같은 DOM 이벤트를 활용하자. 먼저 <div class="drop-zone">을 디렉티브에 추가하고 이 영역을 "드래그 드랍" 영역으로 제공한다.

// ...
template: [
  '<div>',
    '<input type="file" ng-model="vm.files">',
    '<button type="button" ng-change="vm.uploadFiles(vm.files);">',
    '<div class="drop-zone">Drop your files here!</div>',
  '</div>'
].join('')
// ...

이제 디렉티브와 묶어야 한다. link 함수는 여기서 유용하다. 이 함수에 $scope, $element, $attrs를 주입한다. (미안하지만 달러 표시로 프리픽스를 붙이는 것을 좋아한다. iAttrs을 보면 눈물이 앞을 가린다.)

이제 .drop-zone엘리먼트에 특별한 이벤트 리스너를 연결해야 한다. link 함수를 최대한 가볍게 만든다는 점을 명심하자. 여기서 $scope 인자는 정말 드물게 사용하는데 여러분도 그래야 한다.

엘리먼트에 이벤트 리스너를 추가한다:

/**
 * @name link
 * @desc File Upload Link
 * @type {Function}
 */
function link($scope, $element, $attrs) {
  var drop = $element.find('.drop-zone')[0];
  drop.addEventListener('dragenter', function(e) {
    // "dragenter"에 무언가 동작
  }, false);
  drop.addEventListener("dragleave", function(e) {
    // "dragleave"에 무언가 동작
  }, false);
  drop.addEventListener("dragover", function(e) {
    // "dragover"에 무언가 동작
  }, false);
  drop.addEventListener('drop', function(e) {
    // "drop"에 무언가 동작
  }, false);
}

다시 말하지만 나는 깔끔하게 보이는 것을 좋아하니까 주석과 추상성을 좀 더 다듬었다. dragenter, dragleave, dragover는 이 데모에서 필요 없으니 지운다.:

/**
 * @name link
 * @desc File Upload Link
 * @type {Function}
 */
function link($scope, $element, $attrs) {

  /**
   * @name drop
   * @desc Drop zone element
   * @type {Element}
   */
  var drop = $element.find('.drop-zone')[0];

  /**
   * @name onDrop
   * @desc Callback on "drop" event
   * @type {Function}
   * @param {Event} e Event passed in to grab files from
   */
  function onDrop(e) {
    
  }
  
  // events
  drop.addEventListener('drop', onDrop, false);

}

이벤트 리스너를 설정했고 e.dataTransfer.files에서 파일을 집어 업로드 API로 넘겨줄 수 있다. 하지만 같은 함수를 컨트롤러에 있는 uploadFiles 메소드를 사용하고 싶다.

디렉티브 안으로 컨트롤러를 넘겨줄 수 있는데, $ctrl이라는 짧고 귀요미인 별칭을 사용해서 디렉티브에서 컨트롤러에 접근할 수 있도록 만든다. (역주. link가 호출될 때 4번째 인자로 컨트롤러가 제공됨.)

/**
 * @name link
 * @desc File Upload Link
 * @type {Function}
 */
function link($scope, $element, $attrs, $ctrl) {

  /**
   * @name drop
   * @desc Drop zone element
   * @type {Element}
   */
  var drop = $element.find('.drop-zone')[0];

  /**
   * @name onDrop
   * @desc Callback on "drop" event
   * @type {Function}
   * @param {Event} e Event passed in to grab files from
   */
  function onDrop(e) {
    if (e.dataTransfer && e.dataTransfer.files) {
      $ctrl.uploadFiles(e.dataTransfer.files);
    }
  }
  
  // events
  drop.addEventListener('drop', onDrop, false);

}

대박! 컨트롤러의 uploadFiles 메소드를 사용해서 API로 파일을 넘기는데 코드를 다시 사용했다! 이런 방식으로 표현 로직에서의 변경을 그대로 반영할 수 있게 되었다. 앞서 언급한 것처럼 업로드된 파일을 사용자에게 보여줄 때에도 컨트롤러에서 모든 코드를 다시 사용하고 활용할 수 있을 것이다.

하지만 이건 아직 동작하지 않는다... 마법의 코드 $scope.$apply()를 잊었다:

/**
 * @name onDrop
 * @desc Callback on "drop" event
 * @type {Function}
 * @param {Event} e Event passed in to grab files from
 */
function onDrop(e) {
  if (e.dataTransfer && e.dataTransfer.files) {
    $ctrl.uploadFiles(e.dataTransfer.files);
    // force a $digest cycle
    $scope.$apply();
  }
}

파일이 업로드 된 후, $digest 사이클을 실행하도록 $scope.apply()를 추가한다. 파일을 업로드한 과정을 거치고 데이터가 변경된 후에 어플리케이션 또한 변경되도록 한다. 이 과정이 필요한 이유는 Angular 생태계 외부에서 존재하는 drop 이벤트 리스너를 활용했기 때문이다. 외부에 있어서 그 이벤트가 동작하는지, 무슨 일이 어떻게 일어났는지 알려야 할 필요가 있는 것이다.

이제 모든 것이 갖춰졌다:

/**
 * @name fileUpload
 * @desc <file-upload> Directive
 */
function fileUpload () {

  /**
   * @name fileUploadCtrl
   * @desc File Upload Controller
   * @type {Function}
   */
  function fileUploadCtrl(UploadService) {

    /**
     * @name files
     * @desc Contains all files passed in by the user
     * @type {Array}
     */
    var files = [];

    /**
     * @name uploadFiles
     * @desc Uploads our files
     * @type {Array}
     */
    function uploadFiles(files) {
      // hand off our files to a Service
      UploadService
      .uploadFiles(files)
      .then(function (response) {
        // success, we could get our file Object back
        // and render it in the View for the user
        // maybe some ng-repeat with a list of files inside
      }, function (reason) {
        // error stuff if not handled globally
      })
    }

    // exports
    this.files = files;
    this.uploadFiles = uploadFiles;

  }

  /**
   * @name link
   * @desc File Upload Link
   * @type {Function}
   */
  function link($scope, $element, $attrs, $ctrl) {

    /**
     * @name drop
     * @desc Drop zone element
     * @type {Element}
     */
    var drop = $element.find('.drop-zone')[0];

    /**
     * @name onDrop
     * @desc Callback on "drop" event
     * @type {Function}
     * @param {Event} e Event passed in to grab files from
     */
    function onDrop(e) {
      if (e.dataTransfer && e.dataTransfer.files) {
        $ctrl.uploadFiles(e.dataTransfer.files);
        // force a $digest cycle
        $scope.$apply();
      }
    }
    
    // events
    drop.addEventListener('drop', onDrop, false);

  }

  return {
    restrict: 'E',
    scope: {},
    template: [
      '<div>',
        '<input type="file" ng-model="vm.files">',
        '<button type="button" ng-change="vm.uploadFiles(vm.files);">',
        '<div class="drop-zone">Drop your files here!</div>',
      '</div>'
    ].join(''),
    controllerAs: 'vm',
    controller: fileUploadCtrl
    link: link
  };

}
angular
  .module('app')
  .directive('fileUpload', fileUpload);

정리, MVVM (Model-View-ViewModel)

이 접근은 컨트롤러를 뷰 모델로 사용하는 방식이며 link 함수를 DOM 조작에 활용함과 동시에 컨트롤러에 간단한 일을 전달하는 역할을 하도록 처리하는 역할을 한다. 이 접근은 함수 내부에 객체를 제공하는 등의 방법으로 중첩된 여러 계층의 코드를 작성하는 것과 같이 복잡한 방법을 사용하지 않고, 마치 코드 자체가 별도로 구성된 것 같이, 함수를 분리하고 다시 할당하는 방식으로 서로 의존적인 관계를 분리하는 데 더 적합하다.

의견이나 개선점은 GitHub 이슈로 남겨주기 바란다. Enjoy!

Johnpapa의 Do You Like Your Angular Controllers with or without Sugar?를 번역한 글이다. 원본 포스트는 CC BY 2.5 라이센스로 작성되어 있다.

그냥 읽을 때는 괜찮게 느껴졌는데 옮기고 나니 핵심적인 부분이 없는 감상문 느낌이라 아쉬웠다. 덕분에 다른 글도 번역하게 된 좋은 원동력(?)이 되었다. 1.2 이후로 소개된 Controller As에 대해 전통적인 방법과 어떻게 다른지에 대해 설명하고 있다.


Angular 컨트롤러를 작성하는 두가지 방법

Angular 문서만 읽고 왔더라도 $scope를 MVC의 C(컨트롤러)에서 미친듯이 사용하는 모습은 이상하게 보였을 것이다. $scope는 컨트롤러와 뷰 사이를 연결하는 풀과 같은 존재로 데이터 연결이 필요한 모든 경우를 돕는다. 최근 Angular 팀은 컨트롤러에서 $scope를 사용하는 새로운 방식을 공개했다. 이제 $scope(이 단어를 쓰면 전통적인 방식의 컨트롤러에서 쓰는걸 의미함)와 함께 this(Angular 팀과 내가 Controller-As로 사용하는 방식을 의미함)을 사용할 수 있게 되었다. 이 두 가지 기술에 대한 질문을 아주 많이 받았다. 모두가 선택을 좋아하고 동시에 그 선택에서 얻을 수 있는 것이 무엇인지 명확하게 알고 싶어한다. 그래서 Angular에서 컨트롤러를 생성할 때 사용할 수 있는 이 두 가지 방식($scope와 Controller As)에 대해 이야기하고 활용해보자.

전통적인 컨트롤러와 Controller As 모두 $scope를 갖고 있다. 이 점이 이해하는데 가장 중요하다. 어느 한 방식을 선택한다고 다른 장점을 포기하는 것이 아니다. 정말. 이 두가지 방법은 모두 사용된다.

먼저 알아야 할 과거

$scope는 "전통적인" 기법으로 "controller as"는 아주 최근에 나온 기술이다. (공식적으로 1.2.0 pre릴리스에서 나타나지만 불완전했음.) 둘 다 완벽하게 동작하기에 내가 줄 수 있는 지침은 둘 중 하나를 골라 일관되게 사용하라는 것이다. 하나의 앱에서 둘 다 섞어서 사용할 수 있지만, 일관적으로 사용해야 하는 이유는 놀라울 정도로 명확하다. 그러므로 하나를 고르고 주사위를 던져라. 가장 중요한 점은 일관성이다. 어느 것을 골라야 하나? 그 선택은 개발자에게 달렸다. $scope를 이용한 예가 훨씬 많지만 "controller as"도 흐름에 따라 잘 골라야 한다. 둘 중 어느 것이 더 나은가? 논쟁할 만한 주제다. 그렇다면 어떻게 골라야 할까?

"controller as"를 선호하면 숨기기 편하다

중개하는 역할을 하는 객체인 $scope를 사용하면 컨트롤러에서 사용하는 모든 맴버를 뷰에 공개하게 된다. this.*를 설정하는 것으로 컨트롤러에서 뷰에 공개하고 싶은 부분에 대해서만 노출하는 것이 가능하다. 물론 $scope를 사용해도 동일하게 쓸 수 있지만 표준 자바스크립트의 this를 사용하는 것을 선호한다. 종합적으로 보면 개인적인 선호에 따라 Controller As 기법을 더 선호한다. 다음과 같이 코드를 작성한다:

var vm = this;

vm.title = 'some title';
vm.saveData = function() { ... };

이 방식이 더 보기 쉽고 어떤 부분이 뷰에 노출되는지 쉽게 확인할 수 있다. "vm" 변수는 뷰모델(viewmodel)을 의미한다. 이 명칭은 단순하게 내 컨벤션이다. $scope를 사용할 때도 같은 방법을 쓸 수 있지만 $scope를 사용할 때는 그렇게 작성하지 않았다.

$scope.title = 'some title';
$scope.saveData = function() { ... };

결국 이 부분은 작성자에게 달려있다.

주입이 필요한 경우

$scope는 컨트롤러에 $scope를 주입할 필요가 있을 때 사용한다. 이 부분은 controller as 기법을 사용할 때는 필요 없는 부분이지만 몇가지 다른 이유에 의해 필요할 때가 존재한다. (가령 $broadcast가 필요하거나, watch를 사용할 필요가 있는데 컨트롤러 내에서 하는 것을 피하고 싶을 때.) 이 부분은 사실 Controller As 기법을 더 좋아하는 이유 중 하나다. $scope가 데이터 바인딩 등을 위해 정말 필요한 상황일 때만 명시적으로 선언하기 때문이다. broadcast 메시지를 듣기 위한 것도 한 예제다. watch는 다른 경우지만 컨트롤러 내에서 watch하고 싶지 않은 경우에 사용할 수 있다.

유행은?

명시적으로 $scope가 선언된 코드가 더 오래 사용한 방식이기 때문에 예제가 많다. 하지만 최근 예제는 Controller As를 사용한 경우가 많다. 이 예제를 원한다면 Visual Studio 플러그인인 SideWaffle을 사용할 수 있다. 이 두가지 기법 컨트롤러 모두를 지원한다. 설탕이 싫다면 전통적인 $scope 컨트롤러를 선택하라. 설탕을 원한다면 controller as 를 선택하라. Angular 팀은 이 두가지 선택지를 제공하고 있고 이 선택지 모두 마음에 든다. 개인적으로는 Controller As 기법이 마음에 든다. 이 두가지 방법 모두 데이터 바인딩을 할 수 있다. Controller As는 $scope와 개발하는데 더 편리하게 한다고 생각한다. 그러니 둘 중 어느 것을 선택하는가는 온전히 당신의 몫이다.

웹사이트 설정

웹페이지 색상을 선택하세요

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.