리액트의 Advanced guides 페이지를 따라하면서 노트한 내용이다. 가이드 쪽은 옴니버스 같은 기분이라서 반반으로 나눠 읽기로 했다. 기록하고 싶은 부분만 남겼기 때문에 자세한 내용은 각 페이지를 참고한다.

JSX in Depth

리액트 엘리먼트 타입 정의

JSX는 React.createElement(component, props, ...children)의 편의 문법이다. 그래서 JSX를 사용할 때는 스코프 내에 React가 꼭 필요하다.

다음처럼 점 표기법을 사용할 수 있다. 한번에 여러 컴포넌트 내보낼 때 편리하다.

const MyComponents = {
  DatePicker: function DatePicker(props) {
    return <div>Imagine a {props.color} datepicker here.</div>;
  }
}

function BlueDatePicker() {
  return <MyComponents.DatePicker color="blue" />;
}

사용자 정의 컴포넌트는 꼭 Capitalized 되어야 한다. 소문자로 된 컴포넌트라면 사용하기 전에 Capitalized 하는 방식으로 사용할 수 있다. 동적으로 사용할 때도 이런 방식으로 사용한다.

const components = {
  photo: PhotoStory,
  video: VideoStory
};

function Story(Props) {
  // Wrong
  return <components[props.storyType] story={props.story} />;

  // Correct
  const SpecificStory = components[props.storyType];
  return <SpecificStory story={props.story} />;
}

Props

아래는 각각 동일한 표현이다.

// 문자열 리터럴
<MyComponent message="hello world" />
<MyComponent message={'hello world'} />

// 문자열 리터럴은 HTML-unescaped로 처리됨
<MyComponent message="<3" />
<MyComponent message={'<3'} />

// Prop의 기본 값은 `True`
<MyComponent autocomplete />
<MyComponent autocomplete={true} />

Spread Attribute로 간편하게 표현할 수 있다.

<Greeting firstName="John" lastName="Dorian" nickName="Bambi" />

const props = {firstName: 'John', lastName: 'Dorian', nickName: 'Bambi'};
<Greeting {...props} />

const { nickName, ...other } = props;
const nick = nickName === 'Bambi' ? 'Newbie' : 'Scooter';
<button nickName={nick} {...other} />

자식 노드

문자열은 문자열로 처리되고 개행은 공백으로 처리된다.

render()에서 배열로 반환하면 합쳐서 렌더링한다.

JS 표현식도 자식 노드에 사용할 수 있다. 배열도 렌더링 하기 때문에 다음처럼 쓸 수 있다.

<ul>
  {todos.map((message) => <Item key={message} message={message} />)}
</ul>

children에 함수도 전달할 수 있다. Lifting state up이랑 비슷한 느낌이다. 세부 구현을 사용자에게 위임할 수 있을 것 같다.

function Repeat(props) {
  let items = [];
  for (let i = 0; i < props.numTimes; i++) {
    items.push(props.children(i));
  }
  return <div>{items}</div>;
}

function TodoList() {
  const todos = ['finish doc', 'submit pr', 'review'];
  return (
  <Repeat numTimes={10}>
    {(index) => <div key={index}>This is item {index} in the list</div>}
  </Repeat>
  );
}

Boolean, null, undefined는 화면에 렌더링하지 않는다.

// 조건부 표현
{showHeader && <Header />}

// false가 아닌 falsy한 값을 반환하는 경우에는 렌더링되는 점을 주의, 명확하게 boolean으로 반환할 것
{props.messages.length > 0 && <MessageList messages={props.messages} />}

Boolean, null, undefined를 표시하려면 {String(value)} 식으로 작성한다.

PropTypes로 타입 확인하기

정적 타입을 사용하지 않는다면 사용할 만한 검증 라이브러리다. 원래는 React에 포함되어 있다가 분리된 모양이다. 개발 모드에서만 값을 검사한다. 자세한 사용법은 prop-types 참고한다.

class Greeting extends React.Component {
  // ...
}

Greeting.propTypes = {
  name: PropTypes.string,
  nicknames: PropTypes.arrayOf(PropTypes.string),
  children: PropTypes.element.isRequired
};

Greeting.defaultProps = {
  name: 'Stranger'
};

클래스 프로퍼티 문법으로도 사용할 수 있다.

정적 타입 검사

FlowTypeScript를 설정하고 사용하는 방법을 설명한다.

코틀린도 js를 타겟 플랫폼으로 사용 가능하다고 한다. Kotlin Wrappers, create-react-kotlin-app을 참고한다.

Refs와 DOM

일반적으로 데이터 흐름은 props를 사용하게 되어 있지만 몇몇 경우에는 이런 방식에 적합하지 않다.

  • 커서 위치, 텍스트 선택, 미디어 재생
  • 애니메이션 처리
  • 서드파티 DOM 라이브러리와 연동

선언적으로 해결할 수 있는 부분에서는 ref를 쓰지 않는 것을 권한다. 예를 들면 Dialog 컴포넌트에 open(), close() 메소드를 만드는 것보다 isOpen prop을 넘겨주는 식으로 처리한다. 안되는걸 되게 하려고 ref를 쓸 수는 있지만 쓰기 전에 컴포넌트 위계를 보고 상태를 어디에 위치해야 하는지 잘 고려해야 한다. ref를 쓰는 방식보다 상위 계층에 상태가 위치하는게 더 적절하다면 Lifting State Up 방식을 적용해서 해결한다.

Ref는 DOM 컴포넌트와 클래스 컴포넌트에서만 사용할 수 있다. 컴포넌트 자체를 레퍼런스로 넘기 때문인데 함수형 컴포넌트 내에서 DOM 컴포넌트나 클래스 컴포넌트에는 사용할 수 있다.

마운트 될 때는 인자에 해당 엘리먼트를 전달하고 언마운트에는 null을 전달한다. 이 refcomponentDidMount, componentDidUpdate 전에 호출된다.

일반적으로 DOM 엘리먼트에 접근해야 할 일이 있을 때 많이 쓴다. ref={input => this.textInput = input}

class AutoFocusTextInput extends React.Component {
  componentDidMount() {
    this.textInput.focusTextInput();
  }

  render() {
    return <CustomTextInput
      ref={(input) => { this.textInput = input; }}/>;
  }
}

하위 엘리먼트의 DOM ref를 상위에서 사용하려면 props 체인을 따라서 함수를 내려주면 된다. 여기서 inputRef는 일반 prop을 정의해서 쓴 것이지 ref처럼 특별한 기능이 있는 prop이 아니다.

function CustomTextInput(props) {
  return <div><input ref={props.inputRef} /></div>;
}

function FormLayout(props) {
  return (
    <div>
      Name: <CustomTextInput inputRef={props.inputRef} />
    </div>
  );
}

class AwesomePage extends React.Component {
  render() {
    return <FormLayout inputRef={el => this.inputElement = el} />;
  }
}

가능하면 DOM을 노출해서 사용하지 않는 것이 좋다고 한다. 어쩔 수 없이 필요할 때만 사용하고 극단적으로는 findDOMNode()라는 흑마법도 존재한다고.

ref가 두 차례씩 호출되는 것(마운트 && 언마운트)은 null을 전달해서 기존에 연결된 레퍼런스를 지우는 역할도 겸하고 있기 때문이다. (DOM 레퍼런스를 냅두면 DOM은 해제되어도 GC가 지우지 않고 남겨둔다. 그래서 복잡한거 하지 않도록 간단한 함수 형태로만 소개하는 것 같다.)

Uncontrolled 컴포넌트

폼 데이터를 React 컴포넌트에서 다루는 controlled 컴포넌트와 반대로 DOM 자체에서 다루도록 하는 방식의 컴포넌트를 뜻한다.

DOM 엘리먼트를 사용하면 내장된 동작을 그대로 사용할 수 있는 특징이 있다. Controlled and uncontrolled form inputs in React don’t have to be complicated 글에서 비교 도표를 볼 수 있다.

<input defaultValue="Bob" type="text" ref={(input) => this.input = input} />
// checkbox, radio는 defaultChecked, 그 외는 defaultValue

input[type="file"]은 읽기 전용으로 항상 uncontrolled 컴포넌트다. ref를 사용해서 DOM을 직접 다룬다.

성능 최적화

프로덕션 빌드를 사용한다.

  • Create React App (이래서 다 이거 얘기하는듯)
  • 단일 파일로 빌드
  • Brunch -p 옵션 빌드
  • Browserify의 envify, uglifyify, uglify-js
  • Rollup의 replace, commonjs, uglify
  • webpack

크롬 개발자 도구의 성능 탭에서 컴포넌트를 프로파일링한다. 프로파일링 전에 크롬 확장과 React DevTool 끄는 것 잊지 않는다. 성능 테스트 과정 참조.

긴 목록은 한번에 로드하지 말고 동적으로 처리해야 성능이 좋다. react virtualized 같은 패키지가 있고 Virtualize, windowing가 검색 키워드.

React devtool에서 Highlight updates 기능으로 불필요하게 렌더링이 되는 지점을 찾아서 수정한다.

shouldComponentUpdate()의 반환값으로 렌더링 여부를 수동으로 제어할 수 있다. 이 부분을 직접 작성하는 것보다 React.PureComponent를 상속받는게 낫다. PureComponent는 기존 Component 구현에 prop과 state의 shallow 비교를 포함하고 있다. shouldComponentUpdate() 메소드의 동작 방식과 구현은 본문을 읽는다. 갱신이 필요하면 노드를 타고 올라가서 모두 갱신하게 만든다.

불필요한 갱신은 가변 데이터에서 주로 나타나기 때문에 불변 데이터를 사용하면 이 문제를 쉽게 피할 수 있다. Array.push()로 기존 배열을 조작하는 것보다 Array.concat(), [...words, 'new data']을 사용해서 원 데이터가 변형되지 않도록 한다. Immutable.js을 써도 된다.

ES6 없이 React

ES6 없이 쓸 일이 있을 때 읽는다.

JSX 없이 React

JSX 없이 쓸 일이 있을 때 읽는다.


가이드 나머지 다 보고 나면 몇 가지 먼저 만들려고 한다. 그리고나서 enzyme이랑 상태 관리하는 패키지 redux랑 mobx? 찾아서 볼 생각이다. 많은 분들이 열심히 쓰고 있어서 주워들은 것만 공부해도 좀 걸릴 것 같다.

리액트의 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

JavaScript에서 커링 currying 함수 작성하기를 다시 보다가 PHP로도 작성해봤다.

function curry($fn) {
    $arity = (new ReflectionFunction($fn))->getNumberOfParameters();

    return ($resolver = function (...$memory) use ($fn, $arity, &$resolver) {
        return function (...$args) use ($fn, $arity, $resolver, $memory) {
            $local = array_merge($memory, $args);
            $next = count($local) >= $arity ? $fn : $resolver;
            return $next(...$local);
        };
    })();
}

js 버전도 요즘 스타일로 작성하면 좀 더 간결할 것 같다. php와 js와의 차이점을 적어보면,

  • 함수에서 몇 개의 파라미터를 사용하는지 알아내기 위해 리플렉션을 사용했다.
  • js는 lexical scoping이지만 php에서는 스코프 내에서 사용할 컨텍스트를 명시적으로 적어줘야 한다.
  • 함수 내에서 named function을 선언할 수 있지만 이 함수에는 인자로 전달하지 않는 이상 스코프를 공유할 방법이 없다. 대신에 아직 선언되지 않은 변수명을 레퍼런스로 선언 내에 전달하고(&$resolver) $resolver에 익명 함수를 저장하는 것으로 스코프 내로 넣을 수 있다.
  • js에서는 배열도 passed by reference지만 php에서 배열은 passed by value다. 그래서 js 코드처럼 매번 slice 할 필요가 없다.

아래는 함수 작성하면서 사용한 테스트다.

$nullary = curry(function() { return 1; });
assert($nullary() === 1, 'nullary failed');

$unary = curry(function($a) { return $a; });
assert($unary(2) === 2, 'unary with one param failed');

$binary = curry(function($a, $b) { return $a + $b; });
assert($binary(2)(10) === 12, 'binary, one and one param failed case 1');
assert($binary(2)(20) === 22, 'binary, one and one param failed case 2');
assert($binary(2, 20) === 22, 'binary, two param failed');

$ternary = curry(function($a, $b, $c) { return $a + $b + $c; });
assert($ternary(2)(10)(4) === 16, 'ternary, one and one and one param failed');
assert($ternary(2, 20)(2) === 24, 'ternary, two and one param failed');
assert($ternary(4)(2, 20) === 26, 'ternary, one and two param failed case 1');
assert($ternary(4)(4, 20) === 28, 'ternary, one and two param failed case 2');
assert($ternary(4, 4, 20) === 28, 'ternary, three param failed');

function ternary($a, $b, $c) {
    return $a + $b + $c;
}

$namedTernary = curry('ternary');
assert($namedTernary(2)(10)(4) === 16, 'named ternary, one and one and one param failed');
assert($namedTernary(2, 20)(2) === 24, 'named ternary, two and one param failed');
assert($namedTernary(4)(2, 20) === 26, 'named ternary, one and two param failed case 1');
assert($namedTernary(4)(4, 20) === 28, 'named ternary, one and two param failed case 2');
assert($namedTernary(4, 4, 20) === 28, 'named ternary, three param failed');

ReactPHP의 child-process 패키지를 사용하면 손쉽게 pipe를 사용할 수 있다. 아래 명령을 코드로 전환한다고 생각해보자.

$ cat app.php | wc -l

수작업으로 proc_open 열어서 pipe를 받아 fread, fwrite 해도 되지만 코드가 복잡해진다. ReactPHP를 사용하면 Child Process에 구현된 pipe로 데이터를 전달할 수 있다.

먼저 필요한 의존성을 추가한다.

$ composer require react/event-loop react/child-process react/stream

예시 코드는 다음과 같다.

<?php

use React\EventLoop\Factory;
use React\ChildProcess\Process;
use React\Stream\ReadableResourceStream;
use React\Stream\WritableResourceStream;

// 이벤트 루프를 먼저 생성
$loop = Factory::create();

$catProc = new Process('cat hello.txt');
$wcProc = new Process('wc -l');

// 최종 결과를 표준출력으로 표시하기 위해 스트림을 추가
$stdout = new WritableResourceStream(STDOUT, $loop);

$catProc->start($loop);
$wcProc->start($loop);

// cat의 출력을 wc의 입력으로, wc의 출력을 표준 출력으로 pipe함
$catProc->stdout->pipe($wcProc->stdin);
$wcProc->stdout->pipe($stdout);

$loop->run();
?>

여기서의 pipe 예시는 간단해서 오히려 명령어를 직접 적는게 낫겠다.

<?php
// ...
$countProc = new Process('cat hello.txt | wc -l');
// ...

보다시피 위 예시는 매우 단순하다. 하지만 xmodem 프로토콜 등으로 전송하는 경우에는 파일 전송 명령과 터미널의 입출력을 서로 연결하는 식으로 작성해야 한다. 예전에는 이런 프로토콜이 터미널 도구 자체에 내장되어 있어서 쉽게 전송할 수 있었다고 한다. 다음처럼 입출력 스트림을 양쪽으로 pipe하면 전송이 가능하다.

<?php
// ...

$hose = new Process('hose 192.168.0.10 2000');
$sx = new Process('sx -k -v /foo/bar.bin');

$hose->start($loop);
$sx->start($loop);

$hose->stdout->on('data', function($chunk) use ($hose, $sx) {
    // 복사 초기화 명령을 전송
    $hose->stdin->write("\n");
    $hose->stdin->write("copy xmodem: flash:bar.bin\n");

    // 두 프로세스의 입출력을 연결
    $hose->stdout->pipe($sx->stdin);
    $sx->stdout->pipe($hose->stdin);
});

// xmodem에서는 stdin/stdout으로 파일을 전송하기 때문에 stderr에 현재 전송 상태를 출력함
$sx->stderr->on('data', function($chunk) {
    echo $chunk;
});

// 전송이 완료되면 해당 프로세스가 종료되고 메시지를 출력
$sx->on('exit', function($exitCode) {
    echo "Process exited with code {$exitCode}." . PHP_EOL;
});

$loop->run();

xmodem은 1977년에 개발되었다고 한다. 프로젝트에서 41년 된 파일 전송 프로토콜을 사용해보게 될 줄은 생각도 못했다.

설정 다시 하기 귀찮아서 Sublime Text 3 업데이트를 미루다가 실수로 눌러버렸다! 사용하는 에디터 중에는 여전히 sublime이 가장 반응속도가 빠르다. 대부분의 코드는 vscode나 vim에서 작성하지만 여전히 짧은 메모라든지 코드 스니핏/마사징은 sublime에서 하고 있다.

2에서 3으로 업데이트 한 후에 달라진 동작이 몇 가지 있다. 탭이 깔끔하게 untitled로 표시된다는 점이 가장 불편했다. 이 부분은 검색해보니 set_unsaved_view_name.py 라는 내장된 플러그인에서 동작을 수정해야 한다.

  1. Package Resource Viewer를 설치한다.
    1. cmd + shift + p으로 Command Palette를 연다.
    2. package control: install package 클릭한 후 package resource viewer를 입력해서 설치한다. (목록에 나오지 않는다면 Package control를 설치해야 한다.)
  2. 앞서 열었던 Command Palette를 다시 연다.
  3. open resource를 입력해서 PackageResourceViewer: Open Resource를 연다.
  4. Default -> set_unsaved_view_name.py 를 선택한다.

이제 해당 파일을 변경하면 된다. SetUnsavedViewName 클래스의 update_title 메소드에서 다음 내용을 찾는다.

        if syntax != 'Packages/Text/Plain text.tmLanguage':
            if cur_name:
                # Undo any previous name that was set
                view.settings().erase('auto_name')
                if cur_name == view_name:
                    view.set_name("")
            return

빈 행으로 설정하고 반환하는 것을 확인할 수 있다. 다음처럼 변경하고 저장한다.

        if syntax != 'Packages/Text/Plain text.tmLanguage':
            if cur_name:
                # Undo any previous name that was set
                view.settings().erase('auto_name')

변경 후에 반영이 되지 않는다면 Preferencesset_unsaved_view_name 항목이 false로 지정되지 않았는지 확인해야 한다. Preferencescmd + ,로 열어서 설정이 있는지 찾는다.

코드를 보니 의외로 수정하기 쉽게 되어 있길래 몇 가지 더 넣었다. #으로 시작하면 Markdown으로 변경하고 탭에는 #을 제거한 후, ✏️ 이모지를 붙였다.

        first_line = view.substr(line)

        first_line = first_line.strip(self.dropped_chars)

        # 추가한 부분
        if first_line[0:2] == "# ":
            first_line = "✏️ " + first_line[2:]
            # Markdown Extended를 사용하는 경우
            view.settings().set('syntax', 'Packages/Markdown Extended/Syntaxes/Markdown Extended.sublime-syntax')
            # 내장 Markdown을 사용하는 경우
            # view.settings().set('syntax', 'Packages/Markdown/Markdown.sublime-syntax')

        self.setting_name = True
        view.set_name(first_line)
        self.setting_name = False

참고로 Ctrl + `를 누르면 콘솔이 열리는데 코드 내에서 print() 한 내용도 확인할 수 있다.

TypeScript Handbook의 Namespaces and Modules를 번역했다.


용어에 대한 노트: 타입스크립트 1.5에서 기록해둘 만큼 중요한 명명법 변경이 있었습니다. “내부 모듈(Internal modules)”은 “네임스페이스”가 되었습니다. “외부 모듈(External modules)”은 이제 간단하게 “모듈(modules)”이 되어 ECMAScript 2015의 용어와 맞췄습니다. (module X {namespace X {와 동일하며 후자가 선호됩니다.)

개요

이 포스트는 타입스크립트에서 네임스페이스와 모듈을 사용해 코드를 조직하는 여러가지 방법을 설명합니다. 그리고 어떻게 네임스페이스와 모듈을 사용하고 일반적으로 겪을 수 있는 위험성에는 어떤 부분이 있는지 깊이 있는 주제도 살펴봅니다.

모듈에 대해 더 알고 싶다면 모듈 문서를 참고하세요. 네임스페이스에 대해 더 알고 싶다면 네임스페이스 문서를 참고하세요.

네임스페이스 사용하기

네임스페이스는 단순히 전역 네임스페이스에서의 자바스크립트 개체에 붙은 명칭입니다. 그래서 네임스페이스를 아주 단순한 구조로 사용할 수 있습니다. 여러 파일로 확장해서 작성할 수 있지만 --outFile로 합치는 것도 가능합니다. 네임스페이스는 웹 애플리케이션에서 코드를 구조화하기 좋은 방법이며 HTML 페이지 내에 모든 의존성이 <script> 태그로 포함됩니다.

이 접근 방법은 전역 네임스페이스 오염이 발생하기 때문에 특히 대형 어플리케이션의 경우는 컴포넌트의 의존성을 파악하기 쉽지 않습니다.

모듈 사용하기

모듈도 네임스페이스와 같이 코드와 선언을 모두 포함합니다. 주된 차이점은 모듈은 의존성을 _선언_한다는 점입니다.

또한 모듈은 의존성을 모듈 로더(module loader)를 통해 처리합니다. (예로 CommonJs/Require.js) 작은 크기의 JS 애플리케이션에서는 최적이라고 보기 어렵지만 대형 애플리케이션에서는 장기적인 관점에서 모듈성(modularity)과 유지 가능성(maintainability)에서 이득이 있습니다.

모듈은 코드를 더 쉽게 재사용할 수 있고 더 강하게 고립되어 있으며 번들링을 위해 더 나은 도구를 지원합니다.

Node.js 애플리케이션에서는 모듈을 사용하는 것이 기본적인 방법이며 코드를 구조화하는데 추천되는 접근 방식입니다.

ECMAScript 2015를 사용하면 모듈은 언어에서 제공하는 기능이며 호환 엔진 구현에서는 다 지원해야 합니다. 그러므로 새 프로젝트에서는 코드를 조직하는 방법으로 모듈을 사용하는 것을 추천합니다.

네임스페이스와 모듈의 위험성

여기서는 네임스페이스와 모듈을 사용했을 때 일반적으로 나타나는 다양한 위험에 대해 살펴보고 어떻게 피하는지 확인합니다.

/// <reference>를 사용한 모듈

모듈에서 import 문 대신에 /// <reference ... /> 문법을 사용하는 것이 가장 일반적인 실수입니다. 이 차이를 이해하려면 컴파일러가 import를 사용했을 떄 모듈을 위한 타입 정보를 어디서 가져오는지 이해해야 합니다. (예를 들면 import x from "..."; 또는 import x = require("...");에서의 "...".)

컴파일러는 .ts, .tsx를 먼저 찾은 후에 적절한 경로에 있는 .d.ts를 찾습니다. 특정 파일을 찾지 못한 경우 컴파일러는 _구현 없는 모듈 선언(ambient module declaration)_를 찾습니다. 이런 경우에는 .d.ts의 경로를 선언할 필요가 있습니다.

역자 주: ambient는 주변이란 의미인데 이 이슈의 설명을 기준으로 의역했습니다.

// myModules.d.ts
// .d.ts 파일 또는 모듈이 아닌 .ts 파일
declare module "SomeModule" {
  export function fn(): string;
}
// myOtherModule.ts
/// <reference path="myModules.d.ts" />
import * as m from "SomeModule";

reference 태그는 정의 없는 모듈의 선언에 필요한 선언 파일 위치를 지정할 수 있습니다.

이런 방법으로 여러 타입스크립트 예제에서 node.d.ts을 사용하는 것을 확인할 수 있습니다.

불필요한 네임스페이스

네임스페이스를 사용하는 프로그램을 모듈로 변경한다면 다음과 같은 형태가 되기 쉽습니다.

// shapes.ts
export namespace Shapes {
  export class Triangle { /* ... */ }
  export class Square { /* ... */ }
}

최상위 모듈을 Shapes로 두고 TriangleSquare를 감쌌는데 이런 방식을 사용할 이유가 없습니다. 이 모듈을 사용하는 입장에서는 혼란스럽고 짜증나는 방식입니다.

// shapeConsumer.ts
import * as shapes from "./shapes";
let t = new shapes.Shapes.Triangle(); // shapes.Shapes?

타입스크립트에서 모듈의 가장 중요한 기능은 다른 두 모듈이라면 절대 동일한 스코프에서 같은 명칭을 사용하지 않는다는 점입니다. 모듈을 사용할 때 직접 명칭에 배정하기 때문에 네임스페이스에서 심볼을 내보내며 일일이 감쌀 필요가 없기 때문입니다.

왜 모듈 내에서 네임스페이스를 사용할 필요가 없는지 고민하게 될지도 모르겠습니다. 네임스페이스는 일반적으로 구조의 논리적인 구분을 제공하고 명칭 충돌을 예방하기 위해 존재하는 개념입니다. 이미 모듈은 논리적으로 구분되어 있고 최상위 명칭은 코드를 불러오는 쪽에서 지정하기 때문에 개체를 내보내기 위해서 추가적인 모듈 계층을 더할 필요가 없는 것입니다.

이런 관점에서 작성한 예제는 다음과 같습니다.

// shapes.ts
export class Triangle { /* ... */ }
export class Square { /* ... */ }
// shapeConsumer.ts
import * as shapes from "./shapes";
let t = new shapes.Triangle();

모듈 사용의 트레이드오프

JS 파일과 모듈이 일대일 대응을 하는 것처럼 타입스크립트도 모듈 소스 파일과 생성된 JS 파일이 일대일로 대응합니다. 이 방식은 어떤 모듈 시스템을 사용하느냐에 따라서 여러 모듈 소스 파일을 합치는 작업이 불가능 할 수 있습니다. outFile 옵션을 commonjs 또는 umd인 경우 외에는 사용할 수 없습니다. 타입스크립트 1.8 이후 버전이라면 amd 또는 system에서도 사용 가능합니다.

얼마 전 이벤트 소싱 패턴에 대한 글을 작성했다. 글을 읽고나서 js로 간략하게 구현해봤던 내용을 글로 정리했다. 개념을 나눠 설명하기 위해 CQRS 부분은 다른 글을 통해 덧붙이려고 한다. 여기서 사용하는 구현은 프로덕션에서 사용하기에 부족한 점이 많기 때문에 개념 이해에 중점을 맞춰 코드를 보면 좋겠다. 여기서는 은행 계좌를 예시로 작성했으며 구현하는 부분은 다음과 같다.

  • AggregateRoot: 이벤트가 반영될 집합체
  • BankAccount: 집합체 구현
  • *Event: 각각의 이벤트
  • EventSourcingRepository: 이벤트를 사용해서 데이터를 다루는 리포지터리 클래스
  • InMemoryForTestingEventStore: 이벤트 저장소 동작을 확인하기 위한 클래스
  • EventStoreData: 이벤트 저장소에서 저장되는 데이터 개체 클래스

전체 코드는 gist에서 확인할 수 있으며 jsbin.com에서 테스트해볼 수 있다.


가장 먼저 집합체를 구현한다. 집합체는 일련의 이벤트를 투영할 수 있는 개체다. 은행 계좌를 열고, 닫고, 입출금을 한다면 그 정보의 집합체는 은행 계좌가 될 것이다. 먼저 AggregateRoot 클래스를 작성한다. 이 클래스는 이벤트를 받고 해당 메소드를 호출한다. js는 메서드 오버로딩이 없기 때문에 handle(event) 메소드가 apply{이벤트명} 메소드를 찾는다.

class AggregateRoot {
    apply(event) {
        this.handle(event)
        return this
    }

    handle(event) {
        var eventName = event.constructor.name
        var eventMethod = `apply${eventName}`

        if (! this[eventMethod]) {
            throw new TypeError(`${eventMethod} is not a function`)
        }

        this[eventMethod](event)
    }
}

은행계좌를 개설하는 이벤트를 작성한다.

class OpenedEvent {
    constructor(id: number, name: string) {
        this.id = id
        this.name = name
    }
}

은행 계좌 클래스를 추가한다. 이 클래스로 생성한 개체가 집합체가 되며 이벤트를 통해 갱신된다.

class BankAccount extends AggregateRoot {
    static open(id: number, name: string) {
        var bankAccount = new BankAccount
        bankAccount.apply(new OpenedEvent(id, name))
        return bankAccount
    }

    applyOpenedEvent(event) {
        this.id = event.id
        this.name = event.name

        this.closed = false
        this.balance = 0
    }
}

아래 예제에서 이벤트로 프로퍼티가 갱신되는 것을 확인할 수 있다.

var bankAccount = BankAccount.open(123456, 'Koala')
console.log(bankAccount.id, bankAccount.name) // 123456, 'Koala'

이제 여러 이벤트를 추가한다.

class WithdrawnEvent {
  constructor(id: number, amount: number) {
    this.id = id
    this.amount = amount
  }
}

class DepositedEvent {
  constructor(id: number, amount: number) {
    this.id = id
    this.amount = amount
  }
}

class ClosedEvent {
  constructor(id: number) {
    this.id = id
  }
}

메소드도 추가한다.

class BankAccount extends AggregateRoot {
    // ... 이전 코드
    withdraw(amount) {
        if (this.closed) {
            throw new Error(`${this.id} account is closed.`)
        }
        this.apply(new WithdrawnEvent(this.id, amount))
        return this
    }

    deposit(amount) {
        if (this.closed) {
            throw new Error(`${this.id} account is closed.`)
        }
        this.apply(new DepositedEvent(this.id, amount))
        return this
    }

    close() {
        if (!this.closed) {
            this.apply(new ClosedEvent(this.id))
        }
        return this
    }

    applyWithdrawnEvent(event) {
        this.balance -= event.amount
    }

    applyDepositedEvent(event) {
        this.balance += event.amount
    }

    applyClosedEvent(event) {
        this.closed = true
    }
}
var bankAccount = BankAccount.open(123456,  'Koala')
  .deposit(10000)
  .withdraw(1000)
  .deposit(3000)
  .close()

console.log(bankAccount.name, bankAccount.balance, bankAccount.closed ? 'closed' : 'opened')
// Koala 12000 closed

집합체를 사용해서 내부적으로는 이벤트를 통해 데이터가 갱신되고 있지만 개체의 일반적인 사용과 큰 차이가 없는 것을 확인할 수 있다. 각각의 메소드는 실제로 개체의 정보를 갱신하지 않고 이벤트를 생성하는 역할만 하며 이벤트를 적용하는 apply* 메소드에서만 실질적인 변화가 일어나고 있다.

정말 이 은행 계좌는 일련의 이벤트로 결과를 얻을 수 있을까? 다음 예를 보면 알 수 있다.

var events = [
    new OpenedEvent(123456, 'Koala'),
    new DepositedEvent(123456, 10000),
    new WithdrawnEvent(123456, 1000),
    new DepositedEvent(123456, 3000),
    new ClosedEvent(123456),
]

var bankAccount = new BankAccount
events.forEach(event => bankAccount.apply(event))

console.log(bankAccount.name, bankAccount.balance, bankAccount.closed ? 'closed' : 'opened')
// Koala 12000 closed

동일한 결과를 확인할 수 있다. 위 코드는 AggregateRoot에 다음처럼 추가한다.

class AggregateRoot {
    // ... 이전 코드
    initializeState(events) {
        events.forEach(event => this.apply(event))
    }
}

이제 일련의 이벤트를 다루고 저장할 수 있도록 리포지터리를 만든다.

class EventSourcingRepository {
    constructor(eventStore, aggregateType) {
        this.eventStore = eventStore
        this.aggregateType = aggregateType
    }

    load(id) {
        var events = this.eventStore.load(id)

        var aggregate = Object.create(this.aggregateType.prototype)
        aggregate.initializeState(events)
        return aggregate
    }

    save(aggregate) {
        var uncommittedEvents = aggregate.getUncommittedEvents()
        this.eventStore.append(uncommittedEvents)
    }
}

저장하지 않은 이벤트를 가져올 수 있도록 getUncommittedEvents()AggregateRoot에 구현한다. 또한 상태 초기화 시 저장하지 않은 이벤트로 다루지 않도록 initializeState 메소드도 변경한다.

class AggregateRoot {
    // ...
    uncommittedEvents = []

    getUncommittedEvents() {
        var events = this.uncommittedEvents
        this.uncommittedEvents = []
        return events
    }

    apply(event) {
        this.handle(event)
        this.uncommittedEvents.push(event)
        return this
    }

    initializeState(events) {
        events.forEach(event => this.handle(event))
    }
}

여기서는 예로 간단한 이벤트 저장소를 구현해서 사용한다. 이벤트를 저장하기 위한 구조로 EventStoreData를 다음처럼 작성한다.

class EventStoreData {
    constructor(rootId, event, createdAt) {
        this.rootId = rootId
        this.event = event
        this.createdAt = createdAt
    }
}

이벤트가 데이터베이스에 저장될 때 EventStoreData의 정의대로 저장된다고 생각해보자. 관계형 데이터베이스를 예로 든다면 이 클래스가 테이블의 스키마를 반영하고 있다고 볼 수 있다. 다음은 이 개체를 그대로 메모리에서 사용하는 예제 이벤트 저장소 클래스다.

class InMemoryForTestingEventStore {
    constructor(events) {
        this.data = events ? this.convertEvents(events) : []
    }

    load(rootId) {
        return this.data
            .filter(data => data.rootId === rootId)
            .map(data => data.event)
    }

    append(events) {
        var newData = this.convertEvents(events)
        this.data = this.data.concat(newData)
    }

    convertEvents(events) {
        return events.map(event => this.convertEventToData(event))
    }

    convertEventToData(event) {
        var createdAt = new Date().getTime()
        return new EventStoreData(event.id, event, createdAt)
    }
}

다음처럼 리포지터리로 저장하고 불러올 수 있게 되었다.

var eventStore = new InMemoryForTestingEventStore()
var repository = new EventSourcingRepository(eventStore, BankAccount)

var bankAccount = BankAccount.open(654321,  'Edward')
  .deposit(20000)
  .withdraw(1000)
  .withdraw(1000)

repository.save(bankAccount)
var loaded = repository.load(654321)
console.log(bankAccount.name, bankAccount.balance, bankAccount.closed ? 'closed' : 'opened')
// Edward 18000 opened

eventStore에 저장된 이벤트 저장소 데이터를 살펴보면 이 동작이 좀 더 와닿는다.

console.log(eventStore.data)
eventStore.data

앞에서도 말했지만 여기서 사용하는 구현은 프로덕션에서 사용하기에 부족한 점이 많다. 예를 들면 AggregateRoot에 최종 일관성을 확인하기 위한 version도 구현되어 있지 않고 EventData에 id도 정의되어 있지 않다. 패턴에 대해 이해가 되었다면 실제로 구현한 패키지를 확인하면 도움이 된다.

그리고 CQRS 패턴을 함께 사용하지 않는다면 이벤트소싱은 반쪽에 불과하다. 여기서 살펴본 이벤트소싱에 더해 CQRS를 적용하면 유연하고 강력한 아키텍처를 구성할 수 있다. CQRS 패턴은 다음 글에서 살펴보려고 한다.

최근 프로젝트에서 audit을 생성하는 코드를 작성하면서 이벤트 소싱 패턴을 찾아보게 되었다. 여러 포스트를 통해 접해본 내용이지만 실제로 구현해보지 않아서 크게 와닿지 않았었다. 특히 용어가 익숙하지 않았는데 읽으며 궁금해서 찾아봤던 순서대로 정리했다.


전통적으로 사용하는 CRUD 모델을 생각해보자. 데이터를 갱신하기 위해서는 데이터 저장소에서 해당 데이터를 가져오는 작업이 필요하다. 동시성 문제가 나타날 수도 있고 확장성을 낮추는 지점이 될 수도 있다. 이벤트소싱 패턴은 일련의 이벤트를 통해 데이터를 조작하는 접근 방식이다. 데이터에 영향을 주는 모든 동작을 이벤트라는 저수준의 데이터로 관리하는 것으로 여러 문제를 해결할 수 있다. 물론 단점도 여러가지 존재하므로 필요에 따라 적용해야 한다.

이벤트소싱 패턴도 성숙한 아키텍처이기 때문에 다양한 주제로 세분화되어 있고 각각의 키워드를 알아야 쉽게 찾아볼 수 있다. 예를 들면 이벤트를 저장하고 불러오는 방식 하나만으로도 큰 주제다. 특히 이 패턴을 제대로 쓰기 위해서는 CQRS를 빼놓을 수 없기 때문에 두 이야기가 뒤섞이기도 한다.

이벤트 소싱

앞서 적은 것처럼 이벤트소싱 패턴은 일련의 이벤트를 통해 데이터를 조작한다. 현재의 상태는 변화의 총합으로 표현할 수 있다. 이 패턴을 설명할 때는 쇼핑몰을 많이 예로 든다.

다음과 같이 일련의 이벤트가 있다고 생각해보자.

id root_id event
1 1 카트 생성함
2 1 상품1 추가함
3 1 상품2 추가함
4 1 상품2 제거함
5 1 배송정보 입력함

이 이벤트를 개체에 하나씩 적용한다면 최종적으로 생성된 카트에 상품2가 추가되어 있고 배송정보까지 입력된 개체를 얻을 수 있게 된다. 기존 CRUD 모델과 비교한다면 이미 정형화된 모델에 상태를 저장하는 과정에서 나타나는 문제를 해결할 수 있다. CRUD에서는 카트에 추가되었다가 제거된 상품 목록을 뽑고 싶다고 했을 때 별도로 그 특정한 상태를 저장하지 않는다면 어려운 작업이 될 것이다. 게다가 작업을 한다 하더라도 그 작업 이후의 데이터에 대해서만 볼 수 있는 한계점이 있다. 이처럼 정형화되지 않은 데이터인 이벤트를 저장하고 있다는 점에서 더욱 유연한 변화가 가능하다.

여기서 사용되는 개체는 도메인 모델이고 흔히 집합체(aggregate)로 불리며 root_idaggregateRoot로 삼아서 각 개체로 전환한다.

var cart = events.reduce((aggregate, event) => aggregate.apply(event), new CartAggregate);
console.log(cart.getItems()) // ['상품1']
console.log(cart.shippingInfoExists()) // true

내 경우에는 다음과 같은 궁금점이 생겼다.

  • 이벤트는 어떻게 데이터로 저장하고 복원하지?
  • 이벤트가 많이 쌓이면 느려지지 않나?
  • 데이터가 필요할 때마다 매번 전부 이벤트를 돌려봐야 한다면 번거롭지 않을까?
  • 이벤트는 어떻게 다시 재생하지?

이벤트 저장(Eventstore)

이벤트 저장에 사용할 수 있는 저장소는 크게 세 가지로 분류된다.

  • 이벤트 저장에 특화된 데이터 저장소 사용 (e.g. eventstore.org)
  • NoSQL을 사용
  • 관계형 데이터베이스 사용

이벤트는 데이터베이스에 직렬화(serialize)해서 저장하고 역직렬화(deserialize)해서 사용한다. 각 저장하는 방식은 전략에 따라 다른데 관계형 테이터베이스를 사용한다면 이벤트명(주로 이벤트 타입명)과 데이터(주로 payload) 등으로 분리해서 저장한다. 이벤트의 정규 구조가 단순하기 때문에 단순히 위에서 언급한 데이터베이스가 아니더라도 용도에 맞게 선택할 수 있다.

스냅샷

스냅샷은 이벤트 저장에서 사용할 수 있는 전략이다. 기준에 따라서 이벤트가 많이 쌓이면 중간에 스냅샷을 만들고 그 스냅샷 이후의 이벤트만 가져와서 사용하는 방식이다. 특화된 저장소라면 시스템이 알아서 처리해주지만 그 외에는 이 문제를 고민해서 저장 방식을 설계해야 한다.

명령과 조회 책임 분리

필요한 데이터를 얻기 위해 모든 이벤트를 반복해서 재생하는 일은 많은 자원을 필요로 한다. 일련의 이벤트를 여러 장의 필름이라고 생각한다면 현재의 상태란 이 여러 필름을 한 위치에 투영(projecting)해서 나타난 그림이라고 볼 수 있겠다. 매번 모든 필름을 겹쳐 투영하는 대신 현재의 상태를 어딘가 저장하고 있다면 좀 더 쉽게 데이터를 사용할 수 있을 것이다.

현재 상품1의 재고량을 파악하기 위해 모든 이벤트를 투영하는 대신 재고 테이블에서 상품1의 재고량을 바로 찾아보는 것이 훨씬 쉽다. 다시 말하면 저장은 이벤트로 하지만 조회는 투영된 데이터, 구체화(materialised)된 데이터를 대상으로 수행하고 싶은 것이다. 이런 맥락에서 자연스럽게 명령과 조회의 책임을 분리하는 패턴(Command and Query Responsibility Segregation, CQRS)을 적용하게 된다. 명령으로 이벤트를 쌓고 리드 모델에서 조회하는 것이다. 명령과 조회에서의 책임 분리는 이 아키텍처의 장점을 끌어올릴 수 있다.

위에서 예시로 든 테이블의 경우는 id를 increment id와 같이 지정했지만 CQRS에서는 무작정 생성해도 충돌을 피할 수 있는 만큼 큰 id(예로 128bit GUID)를 생성해서 사용한다.

리드 모델

그렇다면 리드 모델은 어떻게 최신 데이터를 계속 유지할 수 있을까? 새 이벤트는 이벤트 버스(event bus)를 통해 전파되는데 리드 모델에 변화를 투영하는 경우에도 이벤트 리스너로서 발행되는 이벤트를 관찰하고 있다가 리드 모델을 갱신해야 하는 이벤트가 발생하는 순간에 갱신할 수 있다.

이벤트에 의해 갱신되는 리드 모델은 자료를 모두 유실하더라도 이벤트를 모두 저장하고 있다면 모든 이벤트를 이벤트 버스에 보내는 것으로 다시 복구할 수 있게 된다. 구체화된 데이터가 자료의 원형이 아니라 이벤트가 자료의 원형이기 때문에 필요에 따라서 언제든 비정규화된 조회를 새로 생성하고 삭제하는 것이 가능하다.

하지만 이 방식을 적용하면 리드 모델을 비동기적으로 처리하기 때문에 최종 일관성(eventual consistency)을 유지하게 된다. 리드 모델 갱신이 빠르다면 다시 조회를 수행했을 때 최신의 자료를 볼 수 있겠지만 연산이 많거나 부하가 커서 갱신이 느려진다면 이벤트는 생성되었지만 리드 모델은 갱신되지 않아 일시적으로 이전 데이터를 조회하게 될 수 있다. 분산 환경에서는 흔하게 나타나는 문제로 요구사항과 충돌한다면 이런 부분에 대한 대책을 세워야 한다.


간략하게 JavaScript로 이벤트소싱 패턴을 구현해 글로 작성했다.

더 읽기

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 생애주기 훅은 바인딩한 값을 읽을 수 있다고 보장할 수 있는 상황에서 호출된다.

웹사이트 설정

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

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