여전히 PHP가 천덕꾸러기라고 생각하는 사람도 많다. 하지만 다른 언어에서만 볼 수 있었던 좋은 도구와 라이브러리, 의존성 관리도 지원하기 시작했고, PSR을 기준으로 표준도 활발하게 논의되고 있어 예전의 PHP 개발과는 확실히 분위기가 다르다. 한국 내 커뮤니티에서 laravel, symfony와 같은 프레임워크를 쓰는 경우나 XE와 같이 이런 프레임워크를 기반으로 개발한 웹어플리케이션이 보이기 시작했지만 여전히 대부분 “Classic” PHP로 개발하고 있는 것은 분명 아쉬운 부분이다. 좋은 도구가 있는데도 아무도 활용하지 않는다면, 그럴수도 있지 하고 지나치기엔 너무나도 슬픈 일이다. 좋은 기능을 도입하지 않는 사람들이 바로 PHP를 천덕꾸러기로 방치하고 있는 사람들이다.

Classic PHP의 모습

PHP를 사용해 예전 방식으로만 개발하는데 수많은 이유가 있을 수 있다. 물론 여기서 얘기하려는 편리한 새 기능이 내년, 혹은 그 후에 추가될 기능이라면 관심을 뒤로 미뤄도 할 말이 없다. 하지만 autoloadnamespace 문법, composer를 사용하는 것 등은 지금 당장 사용할 수 있는 것이기 때문에 더이상 미룰 수 없고 또한 미뤄서는 안된다. 최신 기술이 아니라 이미 널리 사용되고 있고, 이제는 사용하지 않으면 안되는 기술이다. 지금 배워서 지금 사용해야 한다. 만약 지금 안쓰고 있다면 당신만 안쓰고 있는 것이다. 회사에서 사용하지 않고 있다면 먼저 배워서 알려줘라. 그만큼 중요하다.

그래서 PHP 개발자라면 2016년에는 놓치지 말고 해야 할 것들을 정리하려고 한다. 여기서 다루는 PHP 이야기는 먼 미래의 꿈이 아니라 현재 사용 가능하며, 또 해야만 하는 것들에 대한 이야기다. PHP 개발을 하고 있는데도 이 내용 중 하나라도 놓치고 있는게 있다면 꼭 알아보고 2016년엔 꼭 써먹어야 한다. 글 내내 중요하다는 이야기를 반복해서 하는 것은 정말 중요하기 때문이다. 그리고 이 글에서는 깊은 이야기를 다루진 않고 피상적인 부분만을 정말 간단하게 이야기하려고 노력했다. 이 포스트에 걸려있는 링크와 키워드로 더 깊은 내용을 찾아봤으면 좋겠다.

PHP 업그레이드 하기

PHP는 6 버전을 건너뛰고 7.0을 출시해서 현재 7.0이 최신 버전이다. PHP의 버전은 지속적으로 지원 패치를 제공하는 버전이 있고 보안 문제에 대해서만 패치를 제공하는 버전이 있다. PHP 버전 지원 페이지에서 지원 상황을 확인할 수 있다.

지금 사용하고 있는 PHP의 버전은 몇 버전인지 확인하자. 5.4를 사용하고 있다면 2015년 10월 이후로 보안 패치도 제공되지 않는, 유통기한 지난 버전을 사용하고 있는 것이다. 유통기한 지난 우유를 계속 마실 것인가? 만약 지금 사용하는 버전이 5.3이라면 이미 당신의 웹사이트는 그 누구도 안전하다고 말할 수 없다. 5.5 버전도 앞으로 6개월 후, 즉 2016년 7월이면 보안패치를 제공하지 않는다. 지금 적어도 7.0, 최소한 5.5로 변경해야 한다. 만약 레거시로 인해 업데이트 이후 문제가 발생한다고 방치하고 있다면, 사실 그 사이트는 이미 위험한 웹사이트다. 언제, 어느 순간에 DDoS 공격에 활용될 지 아무도 모른다. 악성코드 배포처로 활용되거나, 최악의 경우 내부의 데이터를 볼모로 협박 메일을 받을지도 모른다.

0순위가 되어야 할 보안 문제에도 의사결정권자가 마음을 움직이지 않고 오래된 버전을 고수한다면 속도가 더 빠르다는 점을 강조하자. 새버전의 PHP는 구버전에 비해 속도도 점점 빨라지고 메모리 사용량은 점점 줄어들고 있다. 5.6도 과거 버전에 비해 많이 빨라진 속도를 보여줬지만 7.0은 더 빨라졌다.

버전을 올리기만 하면 더 좋은 기능을 쓸 수 있는 것은 물론, 속도가 빨라지고 보안성이 높아진다. 이 단순한 일을 하지 않는건 게으름 외에는 답이 없다. 레거시가 걱정이라면 changelog를 찾아보고, 최소한 테스트라도 해보자. 서버호스팅을 사용하고 있다면 상위 버전의 PHP를 설치하고 웹호스팅을 사용하고 있다면 호스팅 업체에 문의하자. 아직도 5.3만 지원하는 호스팅이라면 당장 옮겨야 당신의 웹사이트가 안전하다.

Composer 사용하기

Composer는 PHP를 위한 의존성 관리 도구다. Python에서 pip, nodeJS에서 npm, Ruby에서 bundle, .Net에서 Nuget을 사용해본 적이 있다면 바로 그 역할을 하는 도구다. 리눅스를 사용해본 경험이 있다면, 필요한 도구를 “어디선가” 내려받는 apt-get이나 yum 같은 명령어를 최소한 복사-붙여넣기로 사용해봤을 것이다.

위로 든 예를 단 하나라도 써보지 않아 무슨 말을 하는지 모르겠다면, 내가 필요로 하는 기능의 PHP 라이브러리나 패키지를 스마트폰 앱스토어 같은 곳에서 다운로드 받는다고 생각해보자. 각각의 기능을 다운로드 받아 원하는 기능만 조합하는 방법으로 웹사이트, 웹서비스를 개발할 수 있다.

PHP 웹사이트에서 이메일을 보낼 때 mail() 함수로 보내고 있다면, 매번 지저분한 header를 직접 작성하고, HTML을 직접 변수에 넣어 보내는 지저분한 일을 해본 경험이 있을 것이다. 거기에 첨부파일도 넣어 보내본 경험이 있다면 얼마나 쉽게 난장판이 되는지 알 수 있다. composer를 사용한다면 이런 문제를 깔끔하게 해결할 수 있는 멋진 PHP 패키지를 설치해서 활용할 수 있다. nette/mail로 메일을 쉽게 구성하고, league/plates와 같은 깔끔한 템플릿 엔진을 단 한 줄의 설치 명령어로 바로 사용할 수 있게 된다.

Composer 로고

composer를 사용하라고 하는 이유는 단순히 이 도구를 사용하는 과정을 배우는 것으로도 더 나은 개발을 시작할 수 있는 좋은 출발점이 되기 때문이다. composer를 제대로 사용하기 위해서는 기본적으로 namespaceautoload에 대해 이해해야 한다. 더 나아가 객체지향과 같은 개발 페러다임을 이해하는데 좋은 시작점이 되고 의존성을 어떻게 관리하는지, 테스트를 어떻게 수행해야 하는지 등 현대적인 개발에 있어 필수적인 부분을 학습하는데 중요하다. 최근 작성되는 PHP와 관련된 글을 보면 기본적으로 composer를 사용하는 것으로 가정하고 작성되기 때문에 PHP 개발자에게 있어서 필수적으로 배워야 할 도구다.

PSR 준수하기

PHP 난개발로 인해 가장 고통 받았던 사람들은 다름 아닌 PHP 프레임워크/라이브러리 개발자다. 범용적인 기능으로 만들어도 자신의 라이브러리에서만 사용할 수 밖에 없던 이유는 공통된 규칙이 없기 때문이었다. 모두 각자의 방식대로 만드는게 일상이었던 PHP 환경에서, 프레임워크나 라이브러리를 만들던 사람들이 모여 프레임워크 운용 그룹(Framework Interop Group, FIG)을 만들었고, PHP의 표준적인 개발을 위한 PSR 문서를 만들었다.

PSR 문서는 PHP-FIG에서 확인할 수 있다. autoload, 인터페이스의 사용, 코딩 스타일 등 현재 수락된 문서와 진행중인 문서를 확인할 수 있다. 이 문서에서 제안하는 규칙을 따르는 것으로 같은 스타일의 코드를 유지하는데 도움이 된다. PHP 개발자를 채용할 때, “우리는 PSR을 준수해서 개발하고 있습니다.” 라는 한 마디로 어떤 스타일을 따르는지 설명할 수 있는 것이다.

앞서 언급한 composer도 PSR을 준수해서 만든 도구다. PSR에서 제시하는 방식대로 코드를 작성한다면 composer에서 다른 개발자가 작성한 코드를 내려받아 사용하는 것과 같이 당신의 라이브러리도 누구나 쉽게 사용할 수 있다. 모두에게 공개된 packagist는 서버 코드 또한 공개되어 있어서 사내 전용 packagist를 구성해 사용할 수도 있다. 이 모든 일이 PSR을 준수하고 composer를 사용하는 것으로 가능하다.

보너스: 현대적인 개발 패러다임 학습하기

PSR과 composer가 일궈놓은 환경은 이전까지 활용하기 어려웠던 디자인 패턴이나 개발 패러다임을 PHP에서 사용하도록 하는데 큰 도움을 주고 있다. Factory, Strategy와 같은 디자인 패턴의 활용, 단위 테스트나 행위 주도 테스트를 통한 개발, 서비스 코드 간의 의존적인 환경을 줄이기 위한 의존성 주입이나 ORM과 같은 데이터베이스 추상화 등은 더이상 다른 멋진 언어에서만 존재하는 것이 아니라 PHP에서도 현재 가능한 이야기다.

지금까지 대부분의 프레임워크는 자신들의 코드에 맞게 작성한, 그 프레임워크를 사용하지 않고서는 사용할 수 없는 코드만 제공해왔다면, 현대적인 PHP 개발에서는 누구든 쉽게 필요에 따라 꺼내서 쓸 수 있는 수많은 레고 블럭을 제공한다고 생각하면 된다. 개발 패러다임을 학습하는 것으로 이 수많은 패키지를 더 쉽게, 다시 활용할 수 있는 코드로 만드는데 도움이 된다. 다른 사람의 구현을 이해하는데도 도움이 되고 확장 가능하고 지속 가능한 코드를 작성하는데도 도움이 된다.

코드에서 문제가 발생할 때마다 print_r()exit(), 그리고 새로고침 키로 디버깅을 한 경험이 있을 것이다. 지금도 그렇게 개발하고 있어도 이해할 수 있다. 이제는 문제가 나타났을 때, 에러를 발생하고, 예외 처리를 하고, monolog/monolog와 같은 패키지로 깔끔하게 로그를 남겨 확인하면 된다. 복잡하고 크고 어려운 문제를 한번에 해소하려고 하는 것은 쉽지 않은 일이기 때문에 이런 작은 변화부터 시작되어야 한다. 다른 언어에서는 흔하게 사용하는 패러다임은 이미 많은 개발자가 편하게 활용할 수 있도록 수많은 패키지로 만들어 제공되고 있다. 배우고, 살펴보고, 활용하자.


이 글은 공상과학이 아니다. PHP 개발자라면서 여기서 다룬 이야기를 단 하나라도 이해하지 못했다면 정말로 반성하고 공부해야 한다. (취미로 하는 일이고, 집에 돈이 많다면 상관 안하겠지만.) 회사에서 PHP를 사용하는데 이런 이야기가 전혀 없었다면 사내 메일로 이 글을 뿌리고, 인트라넷에 공유하고, 출력해서 화장실 칸마다 붙이고, 당장에 스터디를 꾸려 배워야 한다. 이 글을 읽고 현대적인 PHP에 대해 공부하고 싶어졌다면 감사하게도 PHP The Right Way 한국어판이 있어 이 글에서 시작하는 것으로 충분하다. 좋은 커뮤니티도 학습에 있어 중요한 요소다. 모던 PHP 유저 모임에 가입해 공유되는 다양한 글을 읽어보고 세미나에도 참여해보자.

이 글을 읽은 PHP 개발자라면, 2016년엔 꼭 복붙된 PHP 코드와 include로 범벅된 PHP 코드에서 벗어나고, 더 나은 추상화와 질서정연한 코드 속에 즐겁게 개발할 수 있기를 기도한다.


더 읽을 거리

TypeScript를 사용한다면 다음과 같은 참조를 많이 봤을 것이다.

/// <reference path="../../typings/tsd.d.ts" />

TypeScript 1.5부터 추가된 tsconfig.json을 프로젝트에 넣으면 레퍼런스를 일일이 적지 않고도 알아서 인터페이스를 불러온다. 별다른 설정 없이 tsconfig.json을 생성하는 것으로도 모든 디렉토리를 기본값으로 참조하게 된다. 기본적인 파일은 다음 내용으로 작성하면 된다.

{}

추가적인 설정을 하고 싶다면 MS TypeScript의 tsconfig.json을 참고하자.

명시적으로 참조할 파일을 지정하고 싶다면 files 프로퍼티에 목록으로 작성한다. files에 명시적으로 파일명을 기록하면 명시적으로 기록되어 있는 ts 파일만 불러와서 컴파일하는 점에 유의해야 한다.

{
  "files" : [
    "./src/app.ts",
    "./typings/tsd.d.ts",
    "./typings/angularjs/angular.d.ts",
    "./typings/jquery/jquery.d.ts"
  ]
}

위 설정처럼 모두 나열해서 작성하면 해당 파일만 참조한다. 위 작성된 목록을 보면 알겠지만 아직 와일드카드(*)로 경로를 지정하는 방식이 지원되지 않고 있다.

Atom에서는 fileGlobs에 와일드카드로 경로를 지원하는데 아직 atom-typescript에서만 사용 가능한 기능이다. atom에서 fileGlobs를 와일드카드로 작성했다면, 자동으로 files의 목록이 갱신되는 것을 확인할 수 있다.

{
  "filesGlob": [
    "./**/*.ts",
    "!./node_modules/**/*.ts"
  ],
  "files": [ /* 이 부분은 atom-typescript에 의해 자동으로 생성된다. */
    "./globals.ts",
    "./linter.ts",
    "./main/atom/atomUtils.ts",
    "./main/atom/autoCompleteProvider.ts",
    "./worker/messages.ts",
    "./worker/parent.ts"
  ]
}

atom-typescript의 tsconfig.json를 보면 사용할 수 있는 추가적인 기능을 확인할 수 있다.

tsconfig.json은 원래 atom-typescript에서 사용하던 방식인데 TypeScript에 PR되어 일부 반영된 상태다. 아직 fileGlobs 구현이나 files, exclude에서 경로 단위로 설정하는 기능은 아직 구현이 없지만 PR은 열려있다고 하니 누군가 조만간 개선하지 않을까 생각이 든다 🙂

TypeScript는 MS에서 개발한 JavaScript 슈퍼셋 언어다. 이 TypeScript를 사용하면 정적 검사를 활용할 수 있어 개발에 많은 편의를 제공한다. 물론 기존에 있던 JavaScript 라이브러리에 대해서도 정적 검사를 수행하려면 해당 라이브러리도 정의 파일, 다시 말해 타입 검사를 위한 인터페이스를 제공해야 한다. 그래서 나온 프로젝트가 DefinitelyTyped인데 TypeScript의 타입 정의를 제공하는 리포지터리 서비스다. 사용하는 라이브러리의 인터페이스가 이 리포지터리에 등록되어 있다면 손쉽게 내려받아 그 정의를 사용할 수 있다.

이 서비스는 tsd라는 TypeScript 정의 관리 도구로 사용한다. tsd 페이지에서 설치 방법과 제공 정의 목록을 확인할 수 있다. tsd로 설치한 라이브러리는 별다른 설정이 없으면 tsd.json에서 메타 정보가 관리되고 해당 파일은 typings에 저장된다. tsd로 설치된 정의는 typings/tsd.d.ts 파일을 참조하는 것으로 활용할 수 있다.

설명이 필요 없을 정도로 사용 방법은 간단한데 몇 가지 혼동이 오는 부분이 있어서 적어보면, tsd install <package>로 설치를 하면 해당하는 패키지의 정의를 내려받는 것이지 실제 패키지를 내려 받는 것은 아니다. 해당 패키지는 jspm, npm, bower 또는 직접 내려받아야 한다. 패키지와 타입은 전혀 별개라는 점을 처음에 이해하지 못해서 고생을 했다.

그리고 tsd link라는 명령어가 생각처럼 동작하지 않아서 한참을 살펴보게 되었다. (설명을 너무 대충 본 탓.) 설명을 읽어보면 package.jsonbower.json에 정보가 있는 패키지를 DefinitelyTyped에서 자동으로 찾아 정의를 받아줄 것처럼 보이지만 실제로는 다음에 충족되는 패키지에 대해서만 동작한다.

  1. 해당 패키지에 대한 타입 인터페이스를 .d.ts와 함께 제공
  2. 해당 패키지의 configuration에서 "typescript": { "definition": "dist/foo.d.ts" } 형태로 해당 타입 인터페이스를 명시한 경우

이런 패키지를 bower나 npm을 통해 설치한 후, tsd link를 입력하면 typings/tsd.d.ts에 아래처럼 참조를 등록해준다. 위 두 가지에 충족되지 않으면 마법같이 연결되는 일은 일어나지 않는다 😛

/// <reference path="jquery/jquery.d.ts" />
/// <reference path="../bower_components/angular/angular.d.ts" />

tsd의 모든 명령어는 DefinitelyTyped/tsd에서 확인할 수 있다.

한 줄 요약

crt 인증서를 IIS에 등록하려면 openssl 사용해서 pfx로 변환해야 한다.

왜 안되나요

https를 위한 인증서를 SSL 제공 업체에서 받아 IIS 7.x에 설치하려고 했다. IIS Manager에서 서버 선택 후, Server Certificates로 들어가면 인증서를 추가할 수 있는데 Complete Certificate Request... 메뉴를 통해 추가하라고 대부분 안내되어 있다. 그렇게 추가하면 목록에 나오는데 다른 메뉴에 다녀오면 추가한 인증서가 사라진다. 추가할 때 에러가 나거나 해야하는데 그런 안내가 전혀 없다.

crt를 pfx로 변환하기

openssl을 사용할 수 있어야 한다. (win32용도 찾아보면 있다.) 필요한 파일은 발급된 인증서, 비밀키, 그리고 CA 인증서가 필요하다.

$ openssl pkcs12 -export -out newkey.pfx -inkey haruair.com.key -in haruair.com.crt -certfile SomeSecureServerCA.crt

생성 과정에서 비밀번호도 추가하게 되는데 IIS에 등록할 때 사용한다.

IIS에 등록하기

이렇게 생성한 newkey.pfx를 앞서 들어갔던 Server Certificates에서 import로 해당 pfx를 선택한다. 앞서 추가한 비밀번호도 입력하면 정상적으로 등록할 수 있다. 등록 완료하면 Sites에서 등록할 사이트를 선택하고 Edit Site 아래 Bindings...로 들어가 https 선택, IP는 All Unassigned(또는 필요한 주소로), 그리고 마지막에 추가한 SSL을 설정해주면 모든 과정이 끝난다.

AngularJS의 서비스 Services는 여러 코드에서 반복적으로 사용되는 코드를 분리할 때 사용하는 기능으로, 해당 서비스가 필요한 곳에 의존성을 주입해 활용할 수 있다. 서비스는 다음과 같은 특성이 있다.

  • 지연 초기화(Lazily instantiated): 의존성으로 주입하기 전까지는 초기화가 되지 않음.
  • 싱글턴(Singletons): 각각의 컴포넌트에서 하나의 인스턴스를 싱글턴으로 참조함.

AngularJS에서 서비스(Service)와 팩토리(factory)는 서로 상당한 유사성을 갖고 있기 때문에 쉽게 혼동할 수 있다. 특히 JavaScript의 유연한 타입으로 인해 라이브러리의 의도와는 다르게 그냥 동작하는 경우가 많다. 이 두 가지의 차이는 코드에서 확인할 수 있다. Angular의 코드를 보면 service는 factory를 사용해서 구현하고 있다.

  function service(name, constructor) {
    return factory(name, ['$injector', function($injector) {
      return $injector.instantiate(constructor);
    }]);
  }

위 코드를 보면 $injector.instaniate()에 생성자를 넣어 반환하는데 이 함수에서 Object.create()로 해당 생성자를 인스턴스화 한다. 이렇게 얻은 인스턴스를 factory에 넣어 나머지는 factory와 동일하게 처리하는 것을 확인할 수 있다. 그래서 라이브러리의 실제 의도와는 다른 구현도 문제 없이 구동될 수 있는 것이다.

Todd Motto의 AngularJS 스타일 가이드 중 Service and Factory을 살펴보면 이 구현의 차이를 다음과 같이 정리한다.

서비스와 팩토리

Angular의 모든 서비스는 싱글턴 패턴이다. .service()메소드와 .factory() 메소드의 차이는 객체를 생성하는 방법에서 차이가 있다.

서비스: 생성자 함수와 같이 동작하고 new 키워드를 사용해 인스턴스를 초기화 한다. 서비스는 퍼블릭 메소드와 변수를 위해 사용한다.

function SomeService () {
  this.someMethod = function () {
    // ...
  };
}
angular
  .module('app')
  .service('SomeService', SomeService);

팩토리: 비지니스 로직 또는 모듈 제공자로 사용한다. 객체나 클로저를 반환한다.

객체 참조에서 연결 및 갱신을 처리하는 방법으로 인해 노출식 모듈 패턴(Revealing module pattern) 대신 호스트 객체 형태로 반환한다.

function AnotherService () {
  var AnotherService = {};
  AnotherService.someValue = '';
  AnotherService.someMethod = function () {
    // ...
  };
  return AnotherService;
}
angular
  .module('app')
  .factory('AnotherService', AnotherService);

왜?: 노출식 모듈 패턴을 사용하면 초기값을 변경할 수 없는 경우가 있기 때문이다. 1


서비스와 팩토리에서 가장 두드러진 차이점을 꼽는다면, 서비스에서는 초기화 과정이 존재하기 때문에 자연스럽게 prototype 상속이 가능하다. 그래서 일반적으로 상속이 필요한 데이터 핸들링이나 모델링 등의 경우에는 서비스를 활용하고, helper나 정적 메소드와 같이 활용되는 경우는 팩토리로 구현을 많이 하는 것 같다.

물론 앞서 살펴본 것과 같이 둘은 아주 유연한 관계이기 때문에 서비스에서 일반 호스트 객체를 반환하면 팩토리와 다를 것이 없게 된다. 그래서 각각의 특징에 맞게 구현하기 위해 가이드라인을 준수하는게 바람직하다. 가이드라인을 따르지 않는다면 적어도 프로젝트 내에서 일정한 프로토콜을 준수할 수 있도록 합의가 필요하다.

서비스와 팩토리처럼 구현의 제한성이 있는 것이 싫다면 강력한 기능을 제공하는 프로바이더(Provider)를 사용할 수 있다. (factory는 provider를 쓴다.) AngularJS에서 흔히 사용하는 $http가 대표적이며 많은 기능이 프로바이더로 구현되어 있다.

  • 팩토리를 작성하는 방법을 설명하는 글을 보면 노출식 모듈 패턴을 활용하는 경우가 종종 있어서 왜? 부분이 추가된 것 같다. 이 패턴은 일부 구현(메소드, 변수)에 대해 외부에서 접근할 수 있는지 없는지 명시적으로 지정할 수 있다는 특징이 있는데 그 특징으로 외부에서 접근할 수 없는 코드에 대해서는 값을 변경할 방법이 없다. 그런 특징 때문에 가이드에서는 호스트 객체로 반환할 것을 권장하고 있다. 
  • 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);
    

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

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


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

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

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

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

    구조 Structure

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

    function fileUpload () {
      
      return {};
    
    }
    angular
      .module('app')
      .directive('fileUpload', fileUpload);
    
    적당한 위치에 정의를 작성했으니 파일 업로드 컴포넌트를 위한 기본적인 프로퍼티를 추가한다:
    
    function fileUpload () {
      
      return {
        restrict: 'E',
        scope: {},
        template: [
          '<div>',
          '</div>'
        ].join(''),
        controllerAs: 'vm',
        controller: function () {},
        link: function () {}
      };
    
    }
    angular
      .module('app')
      .directive('fileUpload', fileUpload);
    

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

    컨트롤러 Controller (presentational layer)

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

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

    /**
     * @name fileUpload
     * @desc <file-upload> Directive
     */
    function fileUpload () {
    
      /**
       * @name fileUploadCtrl
       * @desc File Upload Controller
       * @type {Function}
       */
      function fileUploadCtrl() {
    
      }
    
      /**
       * @name link
       * @desc File Upload Link
       * @type {Function}
       */
      function link() {
    
      }
    
      return {
        restrict: 'E',
        scope: {},
        template: [
          '<div>',
          '</div>'
        ].join(''),
        controllerAs: 'vm',
        controller: fileUploadCtrl
        link: link
      };
    
    }
    angular
      .module('app')
      .directive('fileUpload', fileUpload);
    

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

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

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

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

    /**
     * @name fileUpload
     * @desc <file-upload> Directive
     */
    function fileUpload () {
    
      /**
       * @name fileUploadCtrl
       * @desc File Upload Controller
       * @type {Function}
       */
      function fileUploadCtrl() {
    
        /**
         * @name files
         * @desc Contains all files passed in by the user
         * @type {Array}
         */
        var files = [];
    
        /**
         * @name uploadFiles
         * @desc Uploads our files
         * @type {Array}
         */
        function uploadFiles() {
    
        }
    
        // exports
        this.files = files;
        this.uploadFiles = uploadFiles;
    
      }
    
      /**
       * @name link
       * @desc File Upload Link
       * @type {Function}
       */
      function link() {
    
      }
    
      return {
        restrict: 'E',
        scope: {},
        template: [
          '<div>',
          '</div>'
        ].join(''),
        controllerAs: 'vm',
        controller: fileUploadCtrl
        link: link
      };
    
    }
    angular
      .module('app')
      .directive('fileUpload', fileUpload);
    

    템플릿 융화 Template integration

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

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

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

    /**
     * @name fileUploadCtrl
     * @desc File Upload Controller
     * @type {Function}
     */
    function fileUploadCtrl(UploadService) {
    
      /**
       * @name files
       * @desc Contains all files passed in by the user
       * @type {Array}
       */
      var files = [];
    
      /**
       * @name uploadFiles
       * @desc Uploads our files
       * @type {Array}
       */
      function uploadFiles(files) {
        // hand off our files to a Service
        UploadService
        .uploadFiles(files)
        .then(function (response) {
          // success, we could get our file Object back
          // and render it in the View for the user
          // maybe some ng-repeat with a list of files inside
        }, function (reason) {
          // error stuff if not handled globally
        })
      }
    
      // exports
      this.files = files;
      this.uploadFiles = uploadFiles;
    
    }
    

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

    서비스 Services (business logic layer)

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

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

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

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

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

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

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

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

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

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

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

    /**
     * @name link
     * @desc File Upload Link
     * @type {Function}
     */
    function link($scope, $element, $attrs) {
    
      /**
       * @name drop
       * @desc Drop zone element
       * @type {Element}
       */
      var drop = $element.find('.drop-zone')[0];
    
      /**
       * @name onDrop
       * @desc Callback on "drop" event
       * @type {Function}
       * @param {Event} e Event passed in to grab files from
       */
      function onDrop(e) {
        
      }
      
      // events
      drop.addEventListener('drop', onDrop, false);
    
    }
    

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

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

    /**
     * @name link
     * @desc File Upload Link
     * @type {Function}
     */
    function link($scope, $element, $attrs, $ctrl) {
    
      /**
       * @name drop
       * @desc Drop zone element
       * @type {Element}
       */
      var drop = $element.find('.drop-zone')[0];
    
      /**
       * @name onDrop
       * @desc Callback on "drop" event
       * @type {Function}
       * @param {Event} e Event passed in to grab files from
       */
      function onDrop(e) {
        if (e.dataTransfer && e.dataTransfer.files) {
          $ctrl.uploadFiles(e.dataTransfer.files);
        }
      }
      
      // events
      drop.addEventListener('drop', onDrop, false);
    
    }
    

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

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

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

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

    이제 모든 것이 갖춰졌다:

    /**
     * @name fileUpload
     * @desc <file-upload> Directive
     */
    function fileUpload () {
    
      /**
       * @name fileUploadCtrl
       * @desc File Upload Controller
       * @type {Function}
       */
      function fileUploadCtrl(UploadService) {
    
        /**
         * @name files
         * @desc Contains all files passed in by the user
         * @type {Array}
         */
        var files = [];
    
        /**
         * @name uploadFiles
         * @desc Uploads our files
         * @type {Array}
         */
        function uploadFiles(files) {
          // hand off our files to a Service
          UploadService
          .uploadFiles(files)
          .then(function (response) {
            // success, we could get our file Object back
            // and render it in the View for the user
            // maybe some ng-repeat with a list of files inside
          }, function (reason) {
            // error stuff if not handled globally
          })
        }
    
        // exports
        this.files = files;
        this.uploadFiles = uploadFiles;
    
      }
    
      /**
       * @name link
       * @desc File Upload Link
       * @type {Function}
       */
      function link($scope, $element, $attrs, $ctrl) {
    
        /**
         * @name drop
         * @desc Drop zone element
         * @type {Element}
         */
        var drop = $element.find('.drop-zone')[0];
    
        /**
         * @name onDrop
         * @desc Callback on "drop" event
         * @type {Function}
         * @param {Event} e Event passed in to grab files from
         */
        function onDrop(e) {
          if (e.dataTransfer && e.dataTransfer.files) {
            $ctrl.uploadFiles(e.dataTransfer.files);
            // force a $digest cycle
            $scope.$apply();
          }
        }
        
        // events
        drop.addEventListener('drop', onDrop, false);
    
      }
    
      return {
        restrict: 'E',
        scope: {},
        template: [
          '<div>',
            '<input type="file" ng-model="vm.files">',
            '<button type="button" ng-change="vm.uploadFiles(vm.files);">',
            '<div class="drop-zone">Drop your files here!</div>',
          '</div>'
        ].join(''),
        controllerAs: 'vm',
        controller: fileUploadCtrl
        link: link
      };
    
    }
    angular
      .module('app')
      .directive('fileUpload', fileUpload);
    

    정리, MVVM (Model-View-ViewModel)

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

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

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

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


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

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

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

    먼저 알아야 할 과거

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

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

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

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

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

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

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

    주입이 필요한 경우

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

    유행은?

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

    색상을 바꿔요

    눈에 편한 색상을 골라보세요 :)

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