파이썬을 사용하다보면 setup.py와 requirements.txt를 필연적으로 마주하게 된다. 처음 봤을 때는 이 둘의 용도가 비슷하게 느껴져서 마치 둘 중 하나를 골라야 하는지, 어떤 용도로 무엇을 써야 하는지 고민하게 된다. 같은 내용을 이상한모임 슬랙에서 물어봤었는데 Donald Stufft의 글 setup.py vs requirements.txtraccoonyy님이 소개해줬다. 이 두 도구를 사용하는 방식을 명확하게 잘 설명하는 글이라서 허락 받고 번역으로 옮겼다.


setup.py와 requirements.txt의 차이점과 사용 방법

setup.pyrequirements.txt의 역할에 대한 오해가 많다. 대부분의 사람들은 이 두 파일이 중복된 정보를 제공하고 있다고 생각한다. 심지어 이 “중복”을 다루기 위한 도구를 만들기까지 했다.

파이썬 라이브러리

이 글에서 이야기하는 파이썬 라이브러리는 타인이 사용할 수 있도록 개발하고 릴리스하는 코드를 의미한다. 다른 사람들이 만든 수많은 라이브러리는 PyPI에서 찾을 수 있을 것이다. 각각의 라이브러리가 제공될 때 문제 없이 배포될 수 있도록 패키지는 일련의 메타데이터를 포함하게 된다. 이 메타데이터에는 명칭, 버전, 의존성 등을 적게 된다. 라이브러리에 메타데이터를 작성할 수 있도록 다음과 같은 형식을 setup.py 파일에서 사용할 수 있다.

from setuptools import setup

setup(
    name="MyLibrary",
    version="1.0",
    install_requires=[
        "requests",
        "bcrypt",
    ],
    # ...
)

이 방식은 매우 단순해서 필요한 메타 데이터를 정의하기에 부족하지 않다. 하지만 이 명세에서는 이 의존성을 어디에서 가져와 해결해야 하는지에 대해서는 적혀있지 않다. 단순히 “requests”, “bcrypt”라고만 적혀있고 이 의존성이 위치하고 있는 URL도, 파일 경로도 존재하지 않는다. 어디서 의존 라이브러리를 가져와야 하는지 분명하지 않지만 이 특징은 매우 중요한 것이다. 이 특징을 지칭하는 특별한 용어가 있는 것은 아니지만 이 특징을 일종의 “추상 의존성(abstract dependencies)”라고 이야기할 수 있다. 이 의존성에는 의존 라이브러리의 명칭만 사용할 수 있고 선택적으로 버전 지정도 가능하다. 의존성 라이브러리를 사용하는 방식이 덕 타이핑(duck typing)과 같은 접근 방식을 사용한다고 생각해보자. 이 맥락에서 의존성을 바라보면 특정 라이브러리인 “requests”가 필요한 것이 아니라 “requests”처럼 보이는 라이브러리만 있으면 된다는 뜻이다.

파이썬 어플리케이션

여기서 이야기하는 파이썬 어플리케이션은 일반적으로 서버에 배포(deploy)하게 되는 부분을 뜻한다. 이 코드는 PyPI에 존재할 수도 있고 존재하지 않을 수도 있다. 하지만 어플리케이션에서는 재사용을 위해 작성한 부분은 라이브러리에 비해 그리 많지 않을 것이다. PyPI에 존재하는 어플리케이션은 일반적으로 배포를 위한 특정 설정 파일이 필요하다. 여기서는 “배포라는 측면에서의” 파이썬 어플리케이션을 중심으로 두고 살펴보려고 한다.

어플리케이션은 일반적으로 의존성 라이브러리에 종속되어 있으며 대부분은 복잡하게 많은 의존성을 갖고 있는 경우가 많다. 과거에는 이 어플리케이션이 어떤 라이브러리에 의존성이 있는지 확인할 수 없었다. 이렇게 배포(deploy)되는 특정 인스턴스는 라이브러리와 다르게 명칭이 없는 경우도 많고 다른 패키지와의 관계를 정의한 메타데이터도 갖고 있지 않는 경우도 많았다. 이런 상황에서 의존 라이브러리 정보를 저장할 수 있도록 pip의 requirements 파일을 생성하는 기능이 제공되게 되었다. 대부분의 requirements 파일은 다음과 같은 모습을 하고 있다.

# 이 플래그의 주소는 pip의 기본 설정이지만 관계를 명확하게 보여주기 위해 추가함
--index-url https://pypi.python.org/simple/

MyPackage==1.0
requests==1.2.0
bcrypt==1.0.2

이 파일에서는 각 의존성 라이브러리 목록을 정확한 버전 지정과 함께 확인할 수 있다. 라이브러리에서는 넓은 범위의 버전 지정을 사용하는 편이지만 어플리케이션은 아주 특정한 버전의 의존성을 갖는다. requests가 정확하게 어떤 버전이 설치되었는지는 큰 문제가 되지 않지만 이렇게 명확한 버전을 기입하면 로컬에서 테스트하거나 개발하는 환경에서도 프로덕션에 설치한 의존성과 정확히 동일한 버전을 사용할 수 있게 된다.

파일 첫 부분에 있는 --index-url https://pypi.python.org/simple/ 부분을 이미 눈치챘을 것이다. requirements.txt에는 PyPI를 사용하지 않는 경우를 제외하고는 일반적으로 인덱스 주소를 명시하지 않는 편이지만 이 첫 행이 requirements.txt에서 매우 중요하다. 이 내용 한 줄이 추상 의존성이었던 requests==1.2.0을 “구체적인” 의존성인 “https://pypi.python.org/simple/에 있는 requests 1.2.0″으로 처리하게 만든다. 즉, 더이상 덕 타이핑 형태로 의존성을 다루는 것이 아니라 isinstance() 함수로 직접 확인하는 방식과 동일한 패키징 방식이라고 할 수 있다.

추상 의존성 또는 구체적인 의존성에는 어떤 문제가 있을까?

여기까지 읽었다면 이렇게 생각할 수도 있다. setup.py는 재배포를 위한 파일이고 requirements.txt는 배포할 수 없는 것을 위한 파일이라고 했다. 그런데 이미 requirements.txt에 담긴 항목이 install_requires=[...]에 정의된 의존성과 동일할텐데 왜 이런 부분을 신경써야 할까? 이런 의문이 들 수 있을 것이다.

추상 의존과 구체적 의존을 분리하는 것은 중요하다. 의존성을 두 방식으로 분리해서 사용하면 PyPI를 미러링해서 사용하는 것이 가능하게 된다. 또한 같은 이유로 회사 스스로 사설(private) 패키지 색인을 구축해서 사용할 수 있는 것이다. 동일한 라이브러리를 가져와서 버그를 고치거나 새로운 기능을 더한 다음에 그 라이브러리를 의존성으로 사용하는 것도 가능하게 된다. 추상 의존성은 명칭, 버전 지정만 있고 이 의존성을 설치할 때 해당 패키지를 PyPI에서 받을지, Create.io에서 받을지, 아니면 자신의 파일 시스템에서 지정할 수 있기 때문이다. 라이브러리를 포크하고 코드를 변경했다 하더라도 라이브러리에 명칭과 버전 지정을 올바르게 했다면 이 라이브러리를 사용하는데 전혀 문제가 없을 것이다.

구체적인 의존성을 추상 의존성이 필요한 곳에서 사용했을 때는 문제가 발생하게 된다. 그 문제에 대한 극단적인 예시는 Go 언어에서 찾아볼 수 있다. go에서 사용하는 기본 패키지 관리자(go get)는 사용할 패키지를 다음 예제처럼 URL로 지정해서 받아오는 것이 가능하다.

import (
    "github.com/foo/bar"
)

이 코드에서 의존성을 특정 주소로 지정한 것을 볼 수 있다. 이제 이 라이브러리를 사용하다보니 “bar” 라이브러리에 존재하는 버그가 내 작업에 영향을 줘서 “bar” 라이브러리를 교체하려고 한다고 생각해보자. “bar” 라이브러리를 포크해서 문제를 수정했다면 이제 “bar” 라이브러리의 의존성이 명시된 코드를 변경해야 한다. 물론 지금 바로 수정할 수 있는 패키지라면 상관 없겠지만 5단계 깊숙히 존재하는 라이브러리의 의존성이라면 일이 커지게 된다. 단지 조금 다른 “bar”를 쓰기 위한 작업인데 다른 패키지를 최소 5개를 포크하고 내용을 수정해서 라이브러리를 갱신해야 하는 상황이 되고 말았다.

Setuptools의 잘못된 기능

Setuptools는 Go 예제와 비슷한 기능이 존재한다. 의존성 링크(dependency links) 라는 기능이며 다음 코드처럼 작성할 수 있다.

Setup

from setuptools import setup

setup(
    # ...
    dependency_links = [
        "http://packages.example.com/snapshots/",
        "http://example2.com/p/bar-1.0.tar.gz",
    ],
)

이 setuptools의 의존성 링크 “기능”은 의존성 라이브러리의 추상성을 지우고 강제로 기입(hardcode)하는 기능으로 이 의존성 패키지를 정확히 어디에서 찾을 수 있는지 url로 저장하게 된다. 이제 Go에서 살펴본 예제처럼 패키지를 조금 수정한 다음에 패키지를 다른 서버에 두고 그 서버에서 의존성을 가져오는 간단한 작업에도 dependency_links를 변경해야 한다. 사용하는 패키지의 모든 의존성 체인을 찾아다니며 이 주소를 수정해야 하는 상황이 되었다.

다시 사용할 수 있도록 만들기, 같은 일을 반복하지 않는 방법

“라이브러리”와 “어플리케이션”을 구분해서 생각하는 것은 각 코드를 다루는 좋은 방식이라고 할 수 있다. 하지만 라이브러리를 개발하다보면 언제든 _그 코드_가 어플리케이션처럼 될 때가 있다. 이제는 setup.py에 기록한 추상 의존성과 requirements.txt에 저장하게 되는 구체적 의존성으로 분리해서 의존성을 저장하고 관리해야 한다는 사실을 알게 되었다. 코드의 의존성을 정의할 수 있고 이 의존성을 받아오고 싶은 경로를 직접 지정할 수 있기 때문이다. 하지만 의존성 목록을 두 파일로 분리해서 관리하다보면 언젠가는 두 목록이 어긋나는 일이 필연적으로 나타난다. 이런 상황을 해결할 수 있도록 pip의 requirements 파일에서 다음 같은 기능을 제공한다. setup.py와 동일한 디렉토리 내에 아래 내용처럼 requirements 파일을 작성하자.

--index-url https://pypi.python.org/simple/

-e .

이렇게 파일을 작성하더라도 pip install -r requirements.txt 명령을 실행해보면 이전과 다르지 않게 동작하게 된다. 이 명령은 먼저 파일 경로 .에 위치한 라이브러리를 설치한다. 그리고 추상 의존성을 확인할 때 --index-url 설정의 경로를 참조해서 구체적인 의존성으로 전환하고 나머지 의존성을 마저 설치하게 된다.

이 방식을 사용하면 또 다른 강력한 기능을 활용할 수 있다. 만약 단위별로 나눠서 배포하고 있는 라이브러리가 둘 이상 있다고 생각해보자. 또는 공식적으로 릴리스하지 않은 기능을 별도의 부분 라이브러리로 분리해서 개발하고 있다고 생각해보자. 라이브러리가 분할되어 있다고 하더라도 이 라이브러리를 참조할 때는 최상위 라이브러리 명칭을 의존성에 입력하게 된다. 모든 라이브러리 의존성은 그대로 설치하면서 부분적으로는 개발 버전의 라이브러리를 설치하고 싶은 경우에는 다음처럼 requirements.txt에 개발 버전을 먼저 설치해서 개발 버전의 부분 라이브러리를 사용하는 것이 가능하다.

--index-url https://pypi.python.org/simple/

-e https://github.com/foo/bar.git#egg=bar
-e .

이 설정 파일은 먼저 “bar”라는 이름을 사용하고 있는 bar 라이브러리를 https://github.com/foo/bar.git 에서 받아 설치한 다음에 현재 로컬 패키지를 설치하게 된다. 여기서도 의존성을 조합하고 설치하기 위해 --index 옵션을 사용했다. 하지만 여기서는 “bar” 라이브러리 의존성을 github의 주소를 사용해서 먼저 설치했기 때문에 “bar” 의존성은 index로부터 설치하지 않고 github에 있는 개발 버전을 사용하는 것으로 계속 진행할 수 있게 된다.

이 포스트는 Yehuda Katz의 블로그 포스트에서 다룬 Gemfilegemspec에서 영감을 얻어 작성했다.


이 글에서 의존성의 관계를 추상적/구체적인 것으로 구분해서 보는 관점과 그 나눠서 다루는 방식에서 얻을 수 있는 이점이 명확하게 와닿았다.

추상과 대비해서 사용하는 concreate는 “구상”으로 번역하게 되는데 추상에 비해 익숙하지 않아서 구체적으로 번역했다. 만약 구상이 더 편한 용어라면 구체적 의존성을 구상 의존성으로 읽으면 도움이 되겠다.

Interfaces separated from the class implementation in separate projects?를 짧게 번역했다. 이 포스트는 cc-by-sa를 따른다.


인터페이스는 클래스 구현과 별도의 프로젝트로 분리해야 하나요?

Tomas Walek의 질문

현재 중간 규모의 프로젝트를 개발자 3명이서 6개월 넘게 진행하고 있다. 구체적인 구현에서 인터페이스를 분리하자는 결론에 이르렀다. 가장 먼저 인터페이스를 별도의 파일로 보관하기로 했다.

추가적으로 데이터를 더 분리하기 위해서 인터페이스 .CS 파일과 헬프 클래스 .CS 파일(이 인터페이스를 사용하는 퍼블릭 클래스나 enum 등)을 담은 프로젝트(CSPROJ)를 만들었다. 그리고 팩토리 패턴이나 구체적인 인터페이스 구현, 다른 “워커” 클래스 등을 별도의 프로젝트(CSPROJ)로 만들었다.

어떤 클래스든 인터페이스를 구현하는 개체를 생성하려면 그 자체만으로 구현하지 않고 인터페이스와 퍼블릭 클래스를 포함하는 첫 번째 프로젝트로 분리한 다음에 해당 프로젝트를 포함하는 방식으로 작성했다.

이 해결책은 큰 단점이 있다. 어셈블리 수가 2배로 늘게 된다는 점인데 모든 “일반” 프로젝트가 하나의 인터페이스 프로젝트와 하나의 구현 프로젝트를 포함하게 되기 때문이다.

당신의 추천은 무엇인가? 각각 프로젝트 자체에 인터페이스를 보관하는 것보다 인터페이스 만을 위한 별도의 프로젝트 하나를 갖는 것이 좋은 생각인가?


Wim Coenen의 답변

  1. **독자적 인터페이스 (Standalone interfaces)**는 프로젝트 나머지와 소통할 필요 없이 사용할 수 있도록 제공하는 목적에서 작성한다. 이런 인터페이스는 단일 항목으로 “인터페이스 어셈블리(interface assembly)”에 넣게 되고 프로젝트 내 모든 어셈블리가 참조할 것이다. ILogger, IFileSystem, IServiceLocator가 전형적인 예시다.
  2. **클래스 결합 인터페이스 (Class coupled interfaces)**는 오직 프로젝트 내의 클래스와 사용하는 맥락에서만 이해가 되는 경우다. 이 인터페이스는 의존성을 갖는 클래스와 동일한 어셈블리에 포함한다.

예를 들어보자. 도메인 모델이 Banana 클래스를 갖고 있다고 가정한다. 바나나를 IBananaRepository 인터페이스를 통해서 얻을 수 있다면 이 인터페이스는 바나나와 밀접하게 결합된 상황이다. 이 경우에는 바나나에 대해 알지 못하고서는 이 인터페이스를 구현을 한다거나 이 인터페이스를 사용하는 일이 불가능하다. 그러므로 이 인터페이스는 바나나 어셈블리와 함께 위치하는 것이 논리적이다.

앞 예제는 기술적인 결합이지만 논리적으로 결합하는 경우도 있다. 예를 들면, IFecesThrowingTarget 인터페이스는 Monkey 클래스에 기술적인 연결 고리로 선언되어 있지 않더라도 Monkey 클래스와 함께 사용하는 경우에만 유의미할 수 있다.

내 답변은 개념에 의존적이지 않으며 클래스가 약간 결합하는 정도는 괜찮다고 생각한다. 모든 구현을 인터페이스 뒤에 숨기는 일을 실수일 것이다. 의존성을 주입하거나 팩토리를 통해 인스턴스를 생성하지 않고 그냥 클래스를 “new 키워드로 생성”하는 것도 괜찮을 수도 있다.

2016-07-13 추가:

Vue.js 포럼에 한국어 사용자 카테고리가 추가되었고 해당 포럼에서 문서 한국어화를 진행한다고 한다. 이 문서 외 Vue.js에 관심이 있다면 해당 포럼을 확인해보자.


Vue.js 문서를 살펴보던 중에 Comparison with Other Frameworks 내용이 괜찮아서 짧게 번역했다. Vue.js의 문서답게 기승전vue.js 이긴 하지만 각각의 프레임워크가 어떤 특징이 있고 어떤 주요 이슈가 있는지 잘 정리되었다.


다른 프레임워크와 vue.js 비교

Angular

모두에게 적용될 만한 항목은 아니지만 Angular 대신에 Vue를 선택해야 할 이유가 몇 가지 있다.

  • Vue.js는 API나 디자인 측면에서 Angular에 비해 훨씬 단순하다. 대부분의 내용을 빠르게 배울 수 있어서 생산성이 좋다.
  • Vue.js는 더 유연하면서도 덜 의견지향적인 해결책을 제시한다. 이 특징은 모든 개발의 흐름을 Angular 방식에 맞춰 개발하는 접근 방식과 다르게 자신 스스로가 원하는 애플리케이션 구조를 사용할 수 있다. 이 라이브러리는 인터페이스 레이어(interface layer)에 해당하기 때문에 풍성한 SPA 기능 대신에 각 페이지에서 사용할 수 있는 가벼운 기능을 제공한다. 이러한 접근 방식은 다른 라이브러리와 조합해서 사용하는데 넉넉한 공간을 제공한다. 물론 구조적 결정에 대한 책임도 생긴다. 예를 들면 Vue.js 코어에서는 기본적으로 라우팅이나 ajax 기능이 포함되지 않는다. 그리고 애플리케이션을 만드는 대부분의 경우에 외부 모듈 번들러를 사용한다고 가정하고 있다. 이런 특징을 가장 중요한 차이로 볼 수 있다.
  • Angular는 각 스코프(scope) 사이에서 양방향 바인딩(two-way binding)을 사용한다. Vue도 명시적 양방향 바인딩을 지원하긴 하지만, 기본 설정은 컴포넌트(component) 간, 부모에서 자식으로 단방향 바인딩(one-way)으로 구성되어 있다. 대형 앱에서는 단방향 바인딩을 사용하면 데이터의 흐름을 만들기 더 쉽기 때문이다.
  • Vue.js는 디렉티브와 컴포넌트를 명확하게 분리한다. 디렉티브는 DOM 조작을 캡슐화한 기능이고 컴포넌트는 뷰 자신과 데이터 로직을 포함한 독립 단위를 뜻한다. Angular에서는 이 둘의 차이가 상당히 혼란스럽다.
  • Vue.js는 변경 확인(dirty checking)을 수행하지 않기 때문에 좋은 성능을 제공하고 매우 매우 쉽게 최적화 할 수 있다. Angular는 감시자(watcher)가 늘어날 때마다 느려진다. 매번 스코프가 변경될 때마다 모든 감시자가 평가를 다시 수행하기 때문이다. 게다가 이 평가 흐름(digest cycle)에서 감시자가 다른 갱신을 수행하게 되면 모든 데이터가 “안정화(stabilize)” 될 때까지 반복하게 된다. Angular 사용자는 이런 상황을 해결하기 위해서 종종 난해한 기법을 사용하기도 하고 어떤 상황에서는 너무 많은 감시자가 있다보니 아예 간단하게 최적화 할 방법이 존재하지 않을 때도 있다. Vue.js를 사용한다면 이런 상황으로 고통 받을 필요가 없다. 비동기 큐 형태의 옵저버 시스템을 구현해서 의존성을 투명하게 관리하기 때문이다. 의존 관계가 명시적으로 기록되지 않았다면 모든 변경에 대해 독립적으로 이벤트를 호출한다. 앞으로 필요하게 될 최적화에 대한 힌트를 준다면 v-for 목록의 track-by 파라미터를 확인해보자.

Angular 1의 문제를 해결하기 위해서 Angular2 와 Vue가 접근한 방식이 다소 비슷하다는 점은 흥미로운 사실이다.

React

React와 Vue.js는 반응형 & 조합 가능한 뷰 컴포넌트를 제공한다는 점에서 유사점을 공유한다. 물론 많은 차이점도 존재한다.

먼저 내부 구현이 근본적으로 다르다. 리액트의 렌더링은 가상 DOM에 의해 이뤄진다. 가상 DOM은 메모리에서 실제 DOM이 어떤 형태로 존재하는지 저장하는 방식이다. 상태가 변경되면 React는 가상 DOM 전체를 다시 생성한 다음에 DOM을 비교하고 변경된 정보를 실제 DOM에 반영한다.

가상 DOM 접근은 뷰가 어떤 상황에서든 값에 따라 동일하게 동작하는 함수적 접근 방식을 제공하고 있으며 정말 좋은 방법이라 할 수 있다. 전체 앱을 매 차례 다시 생성한다면 관찰자를 만들 필요도 없고 뷰는 항상 데이터와 동기화 되어 있다는 점을 명확하게 보증하기 때문이다. 게다가 이 접근 방식은 동형(isomorphic) 자바스크립트 애플리케이션에 대한 가능성도 열었다.

Vue.js는 실제 DOM을 템플릿으로 사용하고 데이터를 실제 노드에 참조해서 사용하고 있다. 즉, Vue.js의 환경은 DOM에서 표현하는 방식으로만 사용 가능하다는 제약이 있다. React가 가상 DOM을 사용해서 다른 것에 비해 빠르다고 생각할 수 있지만 이 일반적인 오해와는 반대로 직접 갱신(hot update)에 있어서는 Vue.js가 React에 비해 손수 최적화하지 않고도 훨씬 빠르게 동작한다. React를 사용하는 경우에는 shouldComponentUpdate을 모든 위치에 구현해야 하며 불변 데이터 구조를 사용해야 다시 렌더링하게 되는 경우에 완벽한 최적화를 수행할 수 있다.

API 단위에서 React(또는 JSX)의 문제는 함수를 렌더링하는데 종종 많은 로직이 동반되는 경우가 많고 결과적으로 인터페이스의 시각적인 표현보다는 그 자체로 작은 프로그램 조각이 되고 만다. 개발자 일부에게는 이런 특징이 이익으로 느껴지겠지만 나처럼 디자이너/개발자를 겸하는 사람에게는 템플릿을 만들어서 디자인과 CSS를 더 시각적으로 생각하는 방식이 훨씬 쉽게 느껴진다. JavaScript 로직이 섞인 JSX는 디자인에 코드를 적용하기 전까지 확인이 쉽지 않은 방식이다. Vue.js는 대조적으로 경량의 데이터 바인딩을 비용으로 지불하는 대신에 시각적으로 확인 가능한 템플릿을 제공하고 로직은 디렉티브와 필터를 사용해서 캡슐화 하는 방식을 사용한다.

React의 다른 문제는 DOM 갱신이 전적으로 가상 DOM에서 이뤄지기 때문에 DOM을 직접 제어하고 싶은 경우에 다소 까다롭다. (이론적으로 가능하긴 하지만 라이브러리의 방식에 반해서 작업 해야한다.) 애플리케이션에서 DOM 조작에 부차적인 제어가 필요한 경우에는 이런 제한적인 특성으로 인해 짜증나는 작업이 되고 만다. 특히 요구사항이 시간적 흐름에 따라 변화하는 애니메이션이 그렇다. 반면 이런 문제에서 Vue.js는 더 유연하기 때문에 큰 문제가 되질 않는다. FWA/Awwwards에서 수상한 사이트 다수가 Vue.js로 만들어진 이유가 거기에 있다.

아래는 몇 가지 살펴볼 만한 내용이다.

  • React 팀은 React를 모든 플랫폼 UI 개발에서 사용하려고 하는 야망을 갖고 있는 반면에 Vue는 웹을 위한 실용적인 해결책을 제공하는데 촛점을 두고 있다.
  • React는 함수적인 환경을 제공하고 있어서 함수형 프로그래밍 패턴과 아주 잘 맞는다. 이런 특징은 초보자나 주니어 개발자에게 큰 학습 장벽이 된다. Vue는 훨씬 간단하게 바로 사용할 수 있어서 더 효율적이다.
  • 대형 애플리케이션을 개발하는 경우에 React 커뮤니티에서는 상태 관리를 위한 해결책으로 Flux, Redux와 같은 혁신이 있었다. Vue 자체는 이런 문제를 크게 괘념치 않는데 (React 코어도 동일하다.) 비슷한 아키텍쳐라면 상태 관리 패턴은 쉽게 이식할 수 있는 개념이다. Vue 자체에서 사용할 수 있는 상태 관리 솔루션으로 Vuex가 있고, Redux를 Vue와 사용하는 것도 가능하다.
  • React 개발의 트랜드는 CSS까지 모든 것을 JavaScript에 집어넣는 분위기다. JS에 CSS를 넣는 많은 해결책이 존재하지만 각각 크고 작은 문제를 갖고 있다. 가장 중요한 문제는 표준 CSS 저작 경험을 벗어나고 있는데다 기존 CSS 커뮤니티가 일궈 놓은 작업을 이상하게 만드는데 지렛대 역할을 하고 있다. Vue의 단일 파일 컴포넌트는 컴포넌트 단위로 캡슐화된 CSS를 작성할 수 있고 선택에 따라서 전처리기를 선택하는 것도 가능하다.

Ember

Ember는 모든 기능을 제공하는 프레임워크로 아주 의견지향적으로 디자인되었다. 이 프레임워크는 많은 양의 컨벤션을 제공한다. 제공하는 모든 문법에 충분히 익숙해지면 아주 생산적으로 활용할 수 있다. 하지만 학습 곡선이 높은 데다 유연함이 고통을 준다. 의견지향적 프레임워크와 느슨한 의존성으로 묶인 여러 라이브러리 중 어느 것을 선택하느냐에 따라 얻거나 잃을 수 있는 부분이다. 후자를 선택하면 더 자유롭긴 하지만 구조적 결정을 내려야 하는 상황에 놓인다.

Vue.js 코어와 Ember의 템플릿, 개체 모델 레이어를 비교하는 것이 더 나을 것이다.

  • Vue는 눈에 거슬리지 않는 반응성을 일반 JavaScript 개체를 통해 제공하며 모든 프로퍼티가 자동으로 연산된다. Ember에서는 모든 Ember 개체로 감싸야 하며 연산 프로퍼티를 사용하려면 수동으로 의존성을 선언해야 한다.
  • Vue의 템플릿 문법은 JavaScript 표현식 전체를 사용할 수 있는 반면에 Handlebar 표현식과 헬퍼 문법은 다소 제한적이다.
  • 성능에 있어서 Ember가 2.0에서 Glimmer 엔진으로 변경했는데도 여전히 Vue가 빠르다. Vue는 자동으로 일괄 갱신을 수행하는 반면 Ember는 성능에 민감한 상황에서 실행 순환을 수동으로 관리해야 할 필요가 있다.

Polymer

Polymer는 Google이 지원하는 또 다른 프로젝트다. 사실 Vue.js도 이 라이브러리에서 영감을 받고 만들었다. Vue.js의 컴포넌트는 Polymer의 커스텀 엘리먼트와 느슨하게 비교되는데 이 두 기능은 아주 비슷한 개발 스타일을 제공한다. 가장 큰 차이점은 Polymer가 최신 웹컴포넌트 기능 위에서 개발되었고 기능이 제공되지 않는 브라우저에서 사용하기 위해서는 폴리필이 필수적으로 필요하다. (폴리필이라서 성능도 떨어진다.) 이 특징과 대조적으로 Vue.js는 IE9까지 어떤 기술 의존 없이도 잘 동작한다.

또한 polymer 1.0 팀은 성능을 챙기기 위해서 데이터 바인딩을 매우 제한적으로 가능하게 만들었다. 예를 들면, Polymer 템플릿의 표현식은 불린 부정(boolean negation)과 단일 메소드 호출만 지원한다. 또한 연산 프로퍼티의 구현이 아주 경직되어 있다.

마지막으로 프로덕션에 배포할 때는 Polymer 엘리먼트를 Polymer에 특화된 도구인 vulcanizer를 사용해서 번들링 해야한다. 이와 대조적으로 Vue 컴포넌트는 단일 파일로 Webpack 생태계가 제공하는 모든 기능을 사용할 수 있다. 이 특징 덕분에 Vue 컴포넌트에 ES6도 쉽게 적용할 수 있고 CSS 전처리기도 필요하다면 바로 사용할 수 있다.

Riot

Riot 2.0은 유사한 컴포넌트 기반 개발 모델을 제공한다. (Riot에서는 “tag”라고 부른다.) 이 모델은 작고 아름답게 디자인된 API를 제공한다. 내 생각에는 Riot과 Vue는 디자인 철학을 많이 공유하고 있다. Vue는 Riot에 비해 조금 무겁지만 Riot에 비해 중요한 잇점을 제공한다.

  • 참 조건부 렌더링 (Riot 렌더링은 브랜치에 있다면 모든 내용을 렌더링하고 단순히 보여주고 숨기는 기능만 수행한다.)
  • 더 강력한 라우터 (Riot의 라우팅 API는 지나치게 단순하다.)
  • 더 성숙한 도구 지원 (webpack + vue-loader를 확인한다.)
  • 트렌지션 효과 시스템 지원 (Riot에는 없다.)
  • 더 나은 성능 (Riot은 사실 가상 DOM보다 변경 확인(dirty checking)을 사용하고 있어서 Angular와 동일한 성능 이슈가 발생한다.)

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의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

상속에 관한 포스트를 읽다가 레퍼런스로 c2의 Expression Problem 페이지를 보게 되었는데 내용이 좋아 짧게 번역했다. 원문은 wiki로 작성되어 있으므로 자세한 내용이 궁금하다면 해당 페이지를 참고하자.


“표현 문제(Expression problem)”는 객체지향 프로그래밍과 함수형 프로그래밍 모두에서 정확하게 설명하기 어려운 쌍대문제(dual problem)다.

기본 문제는 간단한 예제로 설명할 수 있다. 사각형과 원을 포함한 모양을 표현하는 것과 그 크기를 구하는 것을 원한다.

함수형 프로그래밍에서는 다음 같은 데이터 타입으로 묘사할 수 있다.

type Shape = Squre of side
           | Circle of radius

그리고 크기를 구하는 area 함수를 다음처럼 하나 작성할 수 있다:

define area = fun x -> case x of
  Squre of side => (side * side)
| Circle of radius => (3.14 * radius * radius)

객체지향 프로그래밍에서는 다음과 같이 작성할 수 있다.

class Shape <: Object
  virtual fun area : () -> double

class Square <: Shape
  side : double
  area() = side * side

class Circle <: Shape
  radius : double
  area() = 3.14 * radius * radius

“표현 문제” 선언은 위와 같은 개체 또는 함수를 ‘확장’하려 할 때 발생한다.

  • 삼각형을 위해 triangle 모양을 추가하면,
    • 객체지향 프로그래밍의 접근 방식이 간편 (새 클래스를 추가하는 것으로 단순하게 해결)
    • 함수형 프로그래밍에서는 어려움 (area를 포함해 Shape를 받는 모든 함수를 수정해야 함)
  • 반면, 둘레를 측정하는 perimeter 함수를 추가할 때,
    • 함수형 프로그래밍에서는 쉬움 (새 함수 perimeter를 추가하면 끝)
    • 객체지향 프로그래밍에서는 어려움 (인터페이스가 변경되면 모든 클래스에 perimeter()를 작성해야 함)

이것이 표현 문제의 핵심이다. 표현 문제는 일반적으로 횡단 관심(cross-cutting concerns, 쉽게 사용하기 위해 모듈을 분리했을 때 그 모듈로 작성하기 어려운 요구사항이 발생하는 것)이라는 큰 문제 집합에서의 특정적인 예시에 해당한다. (여기서 횡단 관심은 여러 “모양의 집합”과 “모양의 기능”에서 발생한다.) 많은 언어에는 이런 표현 문제를 해결하기 위한 디자인을 포함한다. 열린 함수(새로운 패턴 매치를 추가할 수 있는 함수), 열린 데이터 타입(새로운 패턴으로 확장 가능한 데이터 타입), 멀티 메소드(‘열린’ 클래스에서 ‘열린’ 특징을 갖는 다형적 함수), 서술 호출(Predicate dispatch), 그 외에도 이 문제를 해결하기 위한 많은 접근 방식이 있다.

더 일반적인 해결책으로 관심사의 분리(Separation of concerns)도 적용 가능하다.

조은님과 강성진님의 포스트를 읽고 번역에 관한 회고를 간략하게나마 남긴다. 전문적으로 하는 번역은 아니였지만, 생각보다 많은 시간을 들인 일 중 하나였고, 그 결과로 올해 작성한 블로그 포스트 대부분이 번역글로 채워졌다. 원문의 길이도 다양했고 그 분야도 다양한 편이였는데 읽고 나서 유익하다 싶었던 글은 대부분 번역했던 것 같다.

올해 번역을 유독 많이 한 것은 여러 가지 이유가 있었다. 올해 초에 썼던 목표대로 경험을 공유한다는 생각으로 짧게라도 읽는 글을 정리한다는 느낌을 갖고 시작했다. 모국어로 사유를 확장할 수 있는 컨텐츠가 적다는 것은 슬픈 일이다. 한국어 구사자는, 특히 개발자라면 최신 정보를 알기 위해 사소한 글이라도 영어를 읽어야만 하는 상황에 자연스레 놓이게 된다. 그래서 내가 사소하게 읽는 글이라도 간단하게 국문으로 옮겨두면 나도 이해하는데 도움이 되고, 이 글이 필요한 다른 사람도 도움이 되지 않나 하는 생각을 하게 되었다. 또한 호주에서 지내며 영어 공부한다는 핑계로, 그리고 한국어로 긴 글을 별로 쓸 일이 없어 문장이 많이 서툴어지고 있던 점도 있어서 겸사겸사 번역에 시간을 쓰게 되었다. 영어도, 한국어도 잘 못하지만 처음부터 잘하는 사람 없다는 생각으로 무작정 시작했다.

몇 번역을 제외하고는 대부분 짧은 글이였다. 처음엔 짧은 글도 번역에 꽤 오랜 시간이 걸렸다. 그래도 반복적으로 하다보니 비슷한 표현도 많이 나오고, 내용도 쉬운 글을 위주로 번역했기 때문에 점점 번역에 걸리는 시간이 줄어드는 경험을 할 수 있었다. 짧은 글은 지치지 않고 끝낼 수 있어 자연스럽게 성취감과 자신감도 따라왔다. 공유에 따라 피드백도 바로바로 받을 수 있어서 꾸준하게 할 수 있는 좋은 동력이 되었다.

명세와 같이 중요한 문서나 깊은 통찰이 있는 글을 번역하는 일은 분명 멋진 작업이지만 별로 많이 하지 못했다. 아직 분량이 많아지면 겁이 나기도 하고 “공식적인” 느낌의 글을 옮기는 것은 묘하게 부담이 느껴진다. 그래도 짧은 글에서 점점 긴 글로, 더 깊이 있는 내용을 다루는 글을 번역해 점점 근육을 키워가는 것을 목표로 했고, 예전에 비해 글을 번역해야지 하는데 고민이 많이 줄었다는 점 등 그 목표를 잘 따른 한 해라고 생각한다.

좋은 번역이었나에 대해서는 답을 하기 어렵다. 시간을 들여 좋은 품질로 번역하는 것보다는 그냥 글을 읽는 것처럼 번역해 그 시간을 줄이는 것을 더 고려했었다. 또한 직역보다는 내가 이해하는 범위 내에서 의역을 많이 했다. 용어를 선택하는데 있어서 지나친 초월 번역이 되지 않도록 노력했고 트위터나 슬랙을 통해서 물어봤고, 또 그런 과정에서 많이 배울 수 있었다. 내년엔 필요할 때만 물어서 용어를 찾는 것이 아니라 용어집을 만들어 정리하고 번역 원칙에 따르는 것도 꼭 해봐야겠다. 또한 번역하는 과정에서 영어나 한국어나 실력이 평범한 수준이라 아쉬웠던 적이 한 두 번이 아니다. 내년에는 번역의 질이 높아질 수 있도록 시간도 충분히 투자하고, 또한 영어, 한국어 수준이 높아질 수 있도록 노력해야겠다.

가장 철저하게 따른 원칙은 저작권 준수다. 저작권이 명시되어 있다면 저작권에 따라 병기했고 따로 명시되지 않았다면 꼭 저자에게 메일로 문의해 명확한 허락을 받고 번역, 게시했다. 단 한 번도 이 원칙을 따르지 않은 경우가 없었는데 단 한 명도 안된다고 이야기 한 적이 없었으며 오히려 고맙다는 피드백도 받을 수 있었다. 메일을 주고 받는 과정에서도 저자와 다양한 이야기를 나눌 수 있어서 좋았고 콜드 메일을 보내는데 자신감도 생겼다.

내가 맨 처음 번역글을 작성할 때 찾았던 초보 번역자들에게 보내는 몇 가지 조언은 매번 번역에 어려움이 있거나 지침이 필요할 때, 거친 얘기를 들어서(번역이 왜 이렇게 구리냐, 이런 유치한 것도 번역하냐 등등) 마음이 힘들 때마다 꺼내 읽는 글이다. 특히 비웃는 자를 비웃어 주라는 이야기가 특히 힘이 되었다. 😀

누군가 이 글을 보고 새해 목표 목록에 번역하기를 추가한다면 참 기분 좋을 것 같다. 내년에도 좋은 글 많이 마주하고 번역할 수 있었으면 좋겠다.

더 읽을 거리

Todd Motto의 글 Directive to Directive communication with “require”를 번역한 글이다. 짧은 글이지만 디렉티브의 계층 관계에서 require를 활용해 값을 주고 받는 방법을 살펴볼 수 있다. 다른 디렉티브의 컨트롤러에 정의된 메소드를 어떻게 접근해서 사용할 수 있는지 탭 디렉티브를 작성하는 예제를 통해 설명한다.


디렉티브 간 소통은 여러 방법이 있지만, 계층 관계를 갖고 있는 디렉티브를 다룰 때는 디렉티브 컨트롤러를 활용해 서로 소통할 수 있다.

이 글에서는 탭 디랙티브를 작성한다. 탭을 추가하기 위한 다른 디랙티브의 함수를 활용하며, 디렉티브 정의 개체의 require 프로퍼티를 사용해 만들려고 한다.

HTML을 먼저 정의한다:

<tabs>
  <tab label="Tab 1">
    Tab 1 contents!
   </tab>
   <tab label="Tab 2">
    Tab 2 contents!
   </tab>
   <tab label="Tab 3">
    Tab 3 contents!
   </tab>
</tabs>

이 시점에서 tabstab 두 디렉티브를 만들 것을 예상할 수 있다. tabs를 먼저 만들면:

function tabs() {
  return {
    restrict: 'E',
    scope: {},
    transclude: true,
    controller: function () {
      this.tabs = [];
    },
    controllerAs: 'tabs',
    template: `
      <div class="tabs">
        <ul class="tabs__list"></ul>
        <div class="tabs__content" ng-transclude></div>
      </div>
    `
  };
}

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

tabs 디렉티브에서는 transclude를 사용해 각각의 tab을 전달하고 개별적으로 관리하도록 구성하고 있다.

tabs 컨트롤러 내에서 새로운 탭을 추가할 때 사용할 함수가 필요하다. 이 함수를 사용해 부모/호스트 디렉티브에 동적으로 탭을 추가할 수 있게 된다:

function tabs() {
  return {
    ...
    controller: function () {
      this.tabs = [];
      this.addTab = function addTab(tab) {
        this.tabs.push(tab);
      };
    },
    ...
  };
}

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

이제 컨트롤러에 addTab 메소드가 연결되었다. 하지만 탭을 어떻게 추가할 것인가? 자식 tab 디렉티브를 추가하고, 이 디렉티브가 컨트롤러의 기능으로서 필요로 한다:

function tab() {
  return {
    restrict: 'E',
    scope: {
      label: '@'
    },
    require: '^tabs',
    transclude: true,
    template: `
      <div class="tabs__content" ng-if="tab.selected">
        <div ng-transclude></div>
      </div>
    `,
    link: function ($scope, $element, $attrs) {

    }
  };
}

angular
  .module('app', [])
  .directive('tab', tab)
  .directive('tabs', tabs);

require: '^tabs'를 추가하는 것으로 부모로 tabs 디렉티브의 컨트롤러에 포함했으며 이제 link 함수를 통해 접근할 수 있게 되었다. link 함수의 4번째 인자인 $ctrl을 주입해서 작성한 컨트롤러의 참조를 받아오자:

function tab() {
  return {
    ...
    link: function ($scope, $element, $attrs, $ctrl) {

    }
  };
}

여기서 console.log($ctrl);을 넣어보면 다음과 비슷한 객체를 볼 수 있다:

{
  tabs: Array,
  addTab: function addTab(tab)
}

addTab 함수를 활용해서 새로운 탭을 생성할 때, 부모 디렉티브의 컨트롤러로 정보를 보낼 수 있게 되었다:

function tab() {
  return {
    ...
    link: function ($scope, $element, $attrs, $ctrl) {
      $scope.tab = {
        label: $scope.label,
        selected: false
      };
      $ctrl.addTab($scope.tab);
    }
  };
}

이제 새로운 tab 디렉티브를 사용할 때마다 이 $ctrl.addTab 함수를 호출하고 tabs 컨트롤러 내에 있는 this.tabs 배열에 디렉티브 정보를 전달한다.

3개의 탭이 존재한다면 $ctrl.addTab 함수가 3번 호출 될 것이고 배열은 3개의 값을 갖고 있게 된다. 그 후 배열을 반복해서 살펴보고 제목과 선택되어 있는 탭이 있는지 확인한다:

function tabs() {
  return {
    restrict: 'E',
    scope: {},
    transclude: true,
    controller: function () {
      this.tabs = [];
      this.addTab = function addTab(tab) {
        this.tabs.push(tab);
      };
      this.selectTab = function selectTab(index) {
        for (var i = 0; i < this.tabs.length; i++) {
          this.tabs[i].selected = false;
        }
        this.tabs[index].selected = true;
      };
    },
    controllerAs: 'tabs',
    template: `
      <div class="tabs">
        <ul class="tabs__list">
          <li ng-repeat="tab in tabs.tabs">
            <a href="" ng-bind="tab.label" ng-click="tabs.selectTab($index);"></a>
          </li>
        </ul>
        <div class="tabs__content" ng-transclude></div>
      </div>
    `
  };
}

selectTabtabs 컨트롤러에 추가된 것을 확인할 수 있을 것이다. 이 함수는 특정 탭의 컨텐츠를 보여주기 위해 초기 색인을 지정할 수 있게 한다. this.selectTab(0);를 호출하면 작성한 코드에 따라 배열의 인덱스를 확인해 첫번째 탭의 컨텐츠를 표시하게 된다.

Angular의 컴파일링 과정에 따라 controller는 가장 먼저 인스턴스가 생성되고, link 함수는 디렉티브가 컴파일되고 엘리먼트에 연결될 때 한 번 호출된다. 즉, 초기화 된 탭을 볼 수 있을 때, 디렉티브 컨트롤러를 $ctrl와 그 메소드를 사용하기 위해 주입되어야 한다:

function tabs() {
  return {
    ...
    link: function ($scope, $element, $attrs, $ctrl) {
      // 첫번째 탭을 가장 먼저 보여줌
      $ctrl.selectTab(0);
    },
  };
}

하지만 다음처럼 어트리뷰트로 초기 탭을 지정할 수 있다면, 개발자에게 더 많은 선택권을 제공할 수 있다:

<tabs active="2">
  <tab>...</tab>
  <tab>...</tab>
  <tab>...</tab>
</tabs>

이 코드는 배열 인덱스를 동적으로 2로 지정하며 배열에서 3번째 엘리먼트를 보여주게 된다. link 함수에서는 어트리뷰트의 존재를 $attrs가 포함하고 있는데 이 인덱스를 바로 지정하거나 $attrs.active가 존재하지 않거나 잘못된 값일 경우 (false으로 평가되니 어쨌든 이므로 안전하겠지만) 초기 인덱스를 다음처럼 폴백(fallback)으로 지정할 수 있다.

function tabs() {
  return {
    ...
    link: function ($scope, $element, $attrs, $ctrl) {
      // `active` 탭 또는 첫번째 탭을 지정
      $ctrl.selectTab($attrs.active || 0);
    },
  };
}

그리고 require를 이용해 새로운 tab 정보를 부모 디렉티브에 전달하는 라이브 데모는 아래에서 확인할 수 있다:

{{< //jsfiddle.net/toddmotto/4comjcdm/embedded/result,js,html >}}

Carl Danley의 글 The Revealing Module Pattern을 요약 번역한 글이다. Todd의 Angular 스타일 가이드를 읽는 중 factory를 노출식 모듈 패턴으로 작성하라는 얘기가 있어서 찾아봤다.


노출식 모듈 패턴 Revealing Module Pattern

이 패턴은 모듈 패턴과 같은 개념으로 public과 private 메소드에 초점을 둔 패턴. 모듈 패턴과 달리 명시적으로 노출하고 싶은 부분만 정해서 노출하는 방식. 일반적으로 객체 리터럴({...}) 형태로 반환한다.

장점

  • 개발자에게 깔끔한 접근 방법을 제공
  • private 데이터 제공
  • 전역 변수를 덜 더럽힘
  • 클로저를 통해 함수와 변수를 지역화
  • 스크립트 문법이 더 일관성 있음
  • 명시적으로 public 메소드와 변수를 제공해 명시성을 높임

단점

  • private 메소드 접근할 방법이 없음 (이런 메소드에 대한 테스트의 어려움을 이야기하기도 하지만 함수 무결성을 고려할 때 공개된 메소드만 테스트 하는게 맞음. 관련 없지만 기록용으로.)
  • private 메소드에 대해 함수 확장하는데 어려움이 있음
  • private 메소드를 참조하는 public 메소드를 수정하기 어려움

예제

var myModule = (function(window, undefined) {
  function myMethod() {
    console.log('myMethod');
  }

  function myOtherMethod() {
    console.log('myOtherMethod');
  }

  return {
    someMethod: myMethod,
    someOtherMethod: myOtherMethod
  };
})(window);

myModule.myMethod(); // Uncaught TypeError: myModule.myMethod is not a function
myModule.myOtherMethod(); // Uncaught TypeError: myModule.myOtherMethod is not a function
myModule.someMethod(); // console.log('myMethod');
myModule.someOtherMethod(); // console.log('myOtherMethod');

Host 객체(JS에 내장된 객체가 아닌 사용자가 직접 정의한 객체)로 반환하는 형태는 관리하기 까다롭고 상속과 같은 방법으로 확장하기 어려워서 개인적으로 선호하지 않는 편이다. 하지만 Angular의 factory와 같이, 일종의 스태틱 클래스에서는 잘 어울리는 접근 방식이다. 패턴은 상황에 맞게 적용해야 한다.

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);

웹사이트 설정

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

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