PHP 개발자는 그 태생부터 죄에 속한 것과 같이 업을 쌓고 산다. 아무리 좋은 디자인과 아키텍처, 방법론으로 무장하고 있더라도 그 죄성은 쉽게 씻겨지지 않는다. 어디서든 PHP 개발자라는 얘길 하면 PHP: 잘못된 디자인의 프랙탈 링크를 받게 되고 공개 처형이 이뤄진다. 모던 PHP로 개발하면 된다지만 이전 PHP에 비해 그나마 모던한 것이지 다른 언어와 비교했을 때는 이제 시작한 수준에 불과하다. 개발과 아예 관련이 없는 모임이나 PHP 개발자 모임 외에는 PHP는 쉽고 편한 언어다, 같은 발언은 물론 대화에 PHP를 올리는 것 자체가 금기다. 언급 되더라도 마치 인종차별적 농담과 같이 지저분한 곳에만 사용된다.

어디 가서 PHP 얘기 꺼냈을 때

PHP를 새로 배우려고 하는 사람, 또는 2년 이하의 경력을 가진 사람은 이런 정신적 고통에 시달리지 말고 해방되길 바란다. 평생의 짐으로 껴앉고 살 필요 없이 더 멋진 언어를 선택하고 이 고통에서 벗어나자. 아래 내용도 더 읽을 필요가 없다.

하지만 3년 이상의 시간을 PHP와 함께 했다면 아무리 PHP가 최악이더라도 쉽게 벗어날 수 없다. 커리어를 이쪽으로 계속 쌓아온 사람이라면 마치 기차가 탈선하는 것과 같은 공포감을 느낄 수 밖에 없다. 그래도 갈아타는 것이 좋다. 3년은 크게 느껴지지만 100세 수명이라면 겨우 3%만 할애한 것이다. 물론 커리어 전환에서의 공포는 경력이다. 앞서 적은 것처럼 어디서도 PHP가 대접받지 못하기 때문에 그 전환에서 챙겨갈 수 있는 경력이 대체로 적다. (대부분의 경우, 신입 취급이다.) 경력을 인정 받지 못하면 자연스레 연봉이나 제반 사항이 발목을 잡는다. 그래서 떠나는 결정은 쉬운 일이 아니다. 내 경우는 호주에서 빨리 정착하기 위해 기존 경력을 살려야 했기에 여전히 PHP 개발자로 남아 있다. 새로운 언어를 배워 새 출발 하는 일은 쉽지 않지만 분명 가치 있는 일이고 나에게 있어서는 이후 과제 중 하나다.

반대로 다른 언어를 바꾸는 이득이 크지 않아서 계속 PHP를 사용할 것이라는 분들은 계속 이쪽 길을 가는 데 고민이 없다. 이득이 작다고 생각하는 사람이라면 PHP를 3년 이상 사용하면서 큰 문제를 느끼지 못해본 사람일 경우가 크다. 물론 언어에서 문제를 느끼지 못했다면 그냥 계속 사용하면 된다. 대체로 이런 케이스는 평생 쓴다. 가장 큰 문제는 이런 분들 중에 학습에 무딘 경우가 많아 잘못되고 오래된 지식을 경험이라는 이름으로 덮어서 오용하는 분이 꽤 있다. 이런 분들이 주로 코드의 정당성을 부여하기 위해서 페이스북이 PHP를 쓴다, 워드프레스가 점유율이 가장 높다는 등의 이야기를 끌어다가 쓴다.

페이스북이 PHP 쓴다고 말할 때


모르는 걸 아는 것은 좋은 일이지만 자신이 무엇을 아는지 알지 못하는 것은 병이다.1 PHP에서 문제를 한번도 느껴보지 못한 사람이라면 어떤 언어든 다른 프로그래밍 언어를 학습하자. 프로그래밍 언어는 다양한 문제를 위한 다양한 해법과도 같다. 각종 php 포럼에서 시시덕거리며 유물과 같은 코드 스니핏 공유하지 말고, 말도 안되는 코드를 블로그에 공유하지 말자. 사람보다 코드가 오래 간다. 그리고 다른 언어나 프레임워크를 비하하는 일은 제발 하지 말자. 본전도 못 찾을 뿐더러 정신승리만 남을 뿐이다. 그리고 제발 공부하자. 내가 대충 짠 코드가 다른 사람을 죽일 수 있다. PHP 코드가 레거시이기 이전에 개발하는 사람이 레거시면 어떡하나.

만약 앞에서 이야기한 모든 고통과 괴로움을 덮고서 PHP 개발을 계속 하려고 한다면 그나마 할 수 있는 조언이 몇 가지 있다. PSR 기반의 코딩 가이드, 네임스페이스 사용 등 모던 PHP라고 불리는 것들을 빠르게 도입하는 것이 그중 하나다. 기초는 PHP The Right Way 한국어판부터 시작하자. 패키지를 작성하는 방법이나 패키지 작성 체크리스트를 보고 모르는 부분이 있다면 심화 학습하자. 앞서 간략하게 설명한 글인 당신이 PHP 개발자라면 2016년 놓치지 말고 해야 할 것들을 봐도 된다. 실무에 빠르게 적용하고 싶다면 Laravel 튜토리얼을 살펴보자. PHP Storm과 같은 IDE를 사용하거나 에디터에서 제공되는 PHP를 위한 플러그인을 찾아 설치하는 것도 잊지 말자. 커뮤니티도 중요하다. 모던 PHP 사용자 모임에 가입해서 살펴보자.

PHP 글 더 읽기

  • 노자 도덕경 71장 지부지상 중 
  • 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의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

    Visual Studio Code에서 TypeScript을 사용하는 환경을 꾸리는 방법을 정리했다. vscode에 아직 기능이 많은 편은 아니지만 여러 편의 기능이 있어 환경을 구축하는데 활용했다. 물론 실무에서 사용할 땐 webpack이나 여타 task 관리도구를 통해 더 쉽게 사용할 수 있다.

    여기서 작성하게 될 파일 구조는 이렇다.

    .vscode
      tasks.json
    dist
      app.js
      app.js.map
    src
      HelloWorld.ts
    typings
      tsd.d.ts
      angularjs/
      jquery/
      ... tsd로 설치한 애들
    tsconfig.json -- 컴파일 설정
    tsd.json -- tsd로 설치한 인터페이스 정보
    

    먼저 typescript와 tsd를 설치한다.

    npm install typescript tsd -g
    

    tsd로 초기화하고 필요한 라이브러리의 인터페이스를 받는다. 물론 인터페이스만 받는 것이니 실제 라이브러리는 npm이나 bower로 받아서 따로 번들해야 한다.

    tsd init
    tsd install angularjs/angular --save
    

    tsd init 하면 tsd.json 파일이 생성되고 설치된 패키지의 메타 정보가 저장된다.

    tsconfig.json 파일을 추가하고 다음 내용으로 저장한다.

    {
        "compilerOptions": {
            "target": "ES5",
            "module": "commonjs",
            "sourceMap": true,
            "rootDir": "src",
            "out": "dist/app.js"
        }
    }
    

    추가적인 내용이 필요하다면 문서를 참고하거나 vscode의 인텔리센스를 활용해서 내용을 넣을 수 있다. 그냥 공부용이라면 그냥 다음과 같이 추가하는 것만으로도 기본값을 사용할 수 있어 정신건강에 도움이 된다.

    {}
    

    tsconfig.json에 대해서는 TypeScript에서 없이 쓰기 포스트에서 다룬 적이 있다.

    vscode에서 해당 디렉토리를 불러온 후 Shift + Cmd + P로 팔레트를 연 후, Tasks: Configure Task Runner를 실행한다. 그러면 .vscode/tasks.json이라는 파일이 생성되는데 vscode에서 task를 바로 실행할 수 있도록 도와주는 설정 파일이다. tsc를 사용해서 typescript를 컴파일하는 것 외에도 gulp나 webpack 등을 호출할 수 있도록 정리가 되어 있어 필요에 따라 주석을 제거하고 사용하면 된다.

    tsconfig.json에서 컴파일 할 설정을 이미 다 설정했기 때문에 args를 다음처럼 고쳐준다.

        // args is the HelloWorld program to compile.
        "args": [],
    

    이제 src/HelloWorld.ts를 작성한다.

    class Startup {
        public static main(): number {
            console.log("Hello World");
            return 0;
        }
    }
    

    저장한 후, Shift + Cmd + B 또는 팔레트에서 Run Build Task를 하면 설정한 것에 따라서 dist/app.jsdist/app.js.map이 생성되는 것을 확인할 수 있다.

    vscode의 build task로 gulp를 사용하고 싶다면 적당하게 gulpfile.js를 작성하고 tasks.json에서 taskName을 변경해주면 된다.

    gulp는 이렇게 설치해주고,

    npm install gulp -g
    npm install gulp gulp-typescript --save-dev
    

    gulpfile.js는 이렇게 작성한다. 앞서 작성한 tsconfig.json을 그대로 활용할 수 있다. 만약 다르게 처리하고 싶다면 gulp-typescript 문서를 참조한다.

    var gulp = require('gulp');
    var ts = require('gulp-typescript');
    
    var tsProject = ts.createProject('tsconfig.json');
    
    gulp.task('scripts', function () {
        var tsResult = tsProject.src()
            .pipe(ts(tsProject));
    
        return tsResult.js.pipe(gulp.dest('release'));
    });
    

    이제 .vscode/tasks.json을 열어 tsc를 주석 처리하고 gulp를 찾아 주석을 해제한 후, 위 gulpfiles.js의 task 이름에 맞게 taskName을 scripts로 변경한다. 저장 후 vscode의 Run Build Task를 실행하면 gulp로 빌드하는 것을 확인할 수 있다.

    추가로 gulp에서 watch를 사용하고 싶다면 다음과 같이 task를 작성하면 된다.

    gulp.task('watch', ['scripts'], function() {
        gulp.watch(tsProject.config.compilerOptions.rootDir + '/**/*.ts', ['scripts']);
    })
    

    실제로 TypeScript와 Angular1.x 환경을 구축할 때는 generator-gulp-angular 같은 제네레이터를 활용하면 깔끔하게 환경을 구축할 수 있다.

    호주에서도 중고나라 같은 gumtree.com.au라는 웹사이트가 있다. 출퇴근을 자전거로 해보고 싶어서 저렴한 자전거를 찾고 있는데 괜찮은 딜은 검트리에 올라오는 족족 팔리기 때문에 수시로 모니터링 하지 않는 한 저렴한 물건을 구하기가 쉽지 않다.

    주말 아침에 일어나서 검트리 페이지를 새로고침 하는 내 모습이 처량해서 이 작업을 자동화 하는 코드를 작성하게 되었다.

    • 지역, 키워드로 검색 페이지를 긁어온다
    • 각 판매글을 적절하게 파싱한다
    • 이전에 긁어온 글과 비교해서 새 글을 뽑는다
    • 새 글이 있으면 알림을 보낸다
    • 다음 비교를 위해 저장한다
    • 스케줄로 반복한다

    요즘 파이썬을 계속 보고 있지만 내 서버 인스턴스에 이미 설정이 있는 node로 작성하기로 결정했다. 데이터는 그리 크지 않고 단순히 비교용으로 사용하기 때문에 json 파일로 저장하기로 했다. 알림은 메일로 보낼까 하다가 이전부터 익히 들어온 텔레그램 API를 활용하기로 했다.

    어떤 라이브러리를 사용할지 찾아봤다.

    • cherrio node에서 사용할 수 있는 jQuery 구현
    • lodash 데이터 조작을 위한 유틸리티
    • request-promise 이름대로 request에 promise를 끼얹은 라이브러리

    텔레그램 API도 node-telegram-bot-api 같은 라이브러리가 있는데 작성할 때는 별 생각이 없어서 주소를 문서에서 가져다가 직접 호출했다. 추후에는 이 라이브러리로 변경해야겠다.

    스케쥴은 간단하게 crontab에서 5분 간격으로 호출하게 했다. 매 5분마다 페이지를 받아 JSON으로 파싱하고 비교한다.

    텔레그램은 몇 안되지만 html 태그를 포함해 메시지를 보내는 것을 지원한다. 그래서 이미지와 링크를 포함해서 다음 이미지처럼 메시지가 온다.

    봇 이름은 홈랜드에 나오는 캐리찡

    코드를 다 작성하고 느낀 점은,

    • 텔레그램은 처음 사용해봤는데 개발에 활용하기 정말 좋게 문서도 잘 정리되어 있고 기능도 깔끔했음. 나중에 또 기회를 만들어 더 사용해볼 것.
    • 패키지처럼 만드는데 익숙하지 않아서 코드가 많이 너저분. 깔끔하게 만드는 방법을 찾아보고 정리.
    • 파일 입출력에는 동기적으로 동작하는 함수를 사용. 아직 파일 입출력을 비동기적으로 처리하는 것이 익숙하지 않은데 살펴보고 정리할 것.
    • 판매글 비교하기 위한 lodash 코드를 작성하는데 문서를 꽤 오래 봐야 했었음. 문서를 좀 더 보고 유용한 함수를 정리.

    게으름 부려서 작성한 코드가 날 공부하게 한다. 얼마나 좋은 게으름인가! 부지런히 찾아보고 정리해야겠다. 코드는 github에서 확인할 수 있다.

    이상한모임에서 진행할 사이드 프로젝트에 Django를 사용하게 되었는데 제대로 살펴본 경험이 없어서 그런지 영 익숙해지질 않았다. 이전에 Django Girls 튜토리얼 – django로 블로그 만들기 포스트를 본 것이 생각나서 살펴보다가 튜토리얼까지 보게 되었다.

    이 포스트는 장고걸스 서울에서 번역된 장고 걸스 튜토리얼을 따라 진행하며 내가 필요한 부분만 정리했기 때문에 빠진 내용이 많다. 튜토리얼은 더 자세하게 세세한 부분까지 설명이 되어 있으니 만약 Django를 학습하려 한다면 꼭 튜토리얼을 살펴볼 것을 추천한다.


    Django 및 환경 설정하기

    $ python3 -m venv myvenv
    $ source myvenv/bin/activate
    $ pip install django==1.8
    

    프로젝트 시작하기

    django-admin startproject mysite .
    

    생성된 프로젝트 구조는 다음과 같다.

    djangogirls
    ├───manage.py # 사이트 관리 도구
    └───mysite
            settings.py  # 웹사이트 설정
            urls.py      # 라우팅, `urlresolver`를 위한 패턴 목록
            wsgi.py
            __init__.py
    

    기본 설정하기

    settings.py를 열어서 TIME_ZONE을 수정한다.

    TIME_ZONE = 'Asia/Seoul'
    

    정적 파일 경로를 추가하기 위해 파일 끝에 다음 내용을 추가한다.

    STATIC_URL = '/static/'
    STATIC_ROOT = os.path.join(BASE_DIR, 'static')
    

    데이터베이스는 기본값으로 sqlite3이 설정되어 있다. 데이터베이스 생성은 manage.py를 활용한다.

    $ python manage.py migrate
    

    서버를 실행해서 확인한다.

    $ python manage.py runserver 0:8000
    

    어플리케이션 생성하기

    $ python manage.py startapp blog
    

    mysite/settings.py를 열어 INSTALLED_APPS에 방금 생성한 어플리케이션을 추가한다.

    INSTALLED_APPS = (
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
        'blog',
    )
    

    블로그 글 모델 만들기

    from django.db import models
    from django.utils import timezone
    
    
    class Post(models.Model):
        author = models.ForeignKey('auth.User')
        title = models.CharField(max_length=200)
        text = models.TextField()
        created_date = models.DateTimeField(
                default=timezone.now)
        published_date = models.DateTimeField(
                blank=True, null=True)
    
        def publish(self):
            self.published_date = timezone.now()
            self.save()
    
        def __str__(self):
            return self.title
    
    

    여기서 사용할 수 있는 필드는 모델 필드 레퍼런스를 참조한다.

    생성한 모델을 사용하기 위해 마이그레이션 파일을 생성하고 마이그레이션을 수행한다.

    $ python manage.py makemigrations blog
    $ python manage.py migrate blog
    

    Django 관리자 사용하기

    새로 추가한 모델을 관리자 패널에서 접근하기 위해서 blog/admin.py에 다음 코드를 추가한다.

    from django.contrib import admin
    from .models import Post
    
    admin.site.register(Post)
    

    관리자 패널에 로그인하기 위한 아이디를 생성한다. 프롬프트에 따라 생성한 후 웹서버를 실행해서 관리자 패널(http://127.0.0.1:8000/admin/)에 접속한다.

    $ python manage.py createsuperuser
    $ python manage.py runserver 0:8000
    

    테스트를 위해 Posts에 글을 추가한다.

    Git 설정하기

    리포지터리를 초기화하고 커밋한다. 튜토리얼은 GitHub 사용하는 방법이 설명되어 있다. .gitignore은 아래처럼 작성할 수 있다.

    *.pyc
    __pycache__
    myvenv
    db.sqlite3
    .DS_Store
    

    PythonAnywhere 설정하기

    PythonAnywhere는 Python을 올려 사용할 수 있는 PaaS 서비스로 개발에 필요한 다양한 서비스를 제공한다.

    먼저 서비스에 가입해서 콘솔에 접속한다. GitHub에 올린 코드를 clone한 다음 가상환경을 설치하고 진행한다.

    $ git clone https://github.com/<your-github-username>/my-first-blog.git
    $ cd my-first-blog
    $ virtualenv --python=python3.4 myvenv
    $ source myvenv/bin/activate
    $ pip install django whitenoise
    

    whitenoise는 정적 파일을 CDN처럼 사용할 수 있도록 돕는 패키지다. 먼저 django의 관리자 도구로 모든 패키지에 포함된 정적 파일을 수집한다.

    $ python manage.py collectstatic
    

    데이터베이스와 관리자를 생성한다.

    $ python manage.py migrate
    $ python manage.py createsuperuser
    

    이제 PythonAnywhere 대시보드에서 web app을 추가하고 manual configuration을 python3.4로 설정한다. 가상환경 경로는 앞서 생성했던 /home/<your-username>/my-first-blog/myvenv/로 입력한다.

    WSGI 프로토콜도 사용할 수 있는데 Web > Code 섹션을 보면 WSGI 설정 파일 경로가 있다. 경로를 클릭해서 다음처럼 내용을 변경한다.

    import os
    import sys
    
    path = '/home/<your-username>/my-first-blog'
    if path not in sys.path:
        sys.path.append(path)
    
    os.environ['DJANGO_SETTINGS_MODULE'] = 'mysite.settings'
    
    from django.core.wsgi import get_wsgi_application
    from whitenoise.django import DjangoWhiteNoise
    application = DjangoWhiteNoise(get_wsgi_application())
    

    URL 작성하기

    django는 URL을 위해 정규표현식을 사용한다. 앞서 생성한 blog를 mysite로 다음처럼 불러온다.

    from django.conf.urls import include, url
    from django.contrib import admin
    
    urlpatterns = [
        url(r'^admin/', include(admin.site.urls)),
        url(r'', include('blog.urls')),
    ]
    

    blog/urls.py를 추가하고 다음 내용을 추가한다.

    from django.conf.urls import url
    from . import views
    
    urlpatterns = [
        url(r'^$', views.post_list, name='post_list'),
    ]
    

    View 작성하기

    blog/views.py를 열고 post_list를 생성한다.

    from django.shortcuts import render
    
    def post_list(request):
        return render(request, 'blog/post_list.html', {})
    

    템플릿 작성하기

    blog/templates/blog 밑에 post_list.html을 생성한다. 디렉토리 구조에 유의한다.

    Django ORM과 QuerySets

    Django에서의 모델 객체 목록을 QuerySets이라 하며 데이터를 정렬하거나 처리할 때 사용한다.

    콘솔에서 다음 명령어로 쉘에 접근한다.

    $ python manage.py shell
    

    모델은 먼저 불러온 다음에 쿼리셋을 사용할 수 있다.

    >>> from blog.models import Post
    >>> Post.objects.all()
    [<Post: Hello World>, <Post: Koala>]
    
    >>> from django.contrib.auth.models import User
    >>> User.objects.all()
    [<User: edward>]
    >>> me = User.objects.get(username='edward')
    
    >>> Post.objects.create(author=me, title='Goodbye my friend', text='bye')
    <Post: Goodbye my friend>
    >>> Post.objects.all()
    [<Post: Hello World>, <Post: Koala>, <Post: Goodbye my friend>]
    

    쿼리셋을 다음 방법으로 필터링 할 수 있다.

    >>> Post.objects.filter(author=me)
    [<Post: Hello World>, <Post: Koala>, <Post: Goodbye my friend>]
    >>> Post.objects.filter(title__contains='Koala')
    [<Post: Koala>]
    
    >>> from django.utils import timezone
    >>> Post.objects.filter(published_date__lte=timezone.now())
    []
    >>> post = Post.objects.get(title__contains="Goodbye")
    >>> post.publish()
    >>> Post.objects.filter(published_date__lte=timezone.now())
    [<Post: Goodbye my friend>]
    
    

    정렬도 가능하며 체이닝으로 한번에 호출하는 것도 가능하다.

    >>> Post.objects.order_by('created_date')
    [<Post: Hello World>, <Post: Koala>, <Post: Goodbye my friend>]
    >>> Post.objects.order_by('-created_date')
    [<Post: Goodbye my friend>, <Post: Koala>, <Post: Hello World>]
    >>> Post.objects.filter(published_date__lte=timezone.now()).order_by('published_date')
    

    템플릿에서 동적 데이터 활용하기

    blog/views.py를 수정한다.

    from django.shortcuts import render
    from django.utils import timezone
    from .models import Post
    
    
    def post_list(request):
        posts = Post.objects.filter(published_date__lte=timezone.now()).order_by('published_date')
        return render(request, 'blog/post_list.html', {'post': posts})
    

    blog/templates/blog/post_list.html을 수정한다.

    <div>
        <h1><a href="/">Django Girls Blog</a></h1>
    </div>
    
    {% for post in posts %}
        <div>
            <p>published: {{ post.published_date }}</p>
            <h1><a href="">{{ post.title }}</a></h1>
            <p>{{ post.text|linebreaks }}</p>
        </div>
    {% endfor %}
    

    CSS 추가하기

    정적 파일은 blog/static 폴더에 넣는다. blog/static/css/blog.css를 작성한다.

    h1 a {
        color: #FCA205;
    }
    

    템플릿에 적용한다.

    {% load staticfiles %}
    <!doctype html>
    <html lang="ko">
    <head>
        <meta charset="utf-8">
        <title>Django Girls Blog</title>
        <link rel="stylesheet" href="{% static 'css/blog.css' %}">
    </head>
    <body>
      <div>
          <h1><a href="/">Django Girls Blog</a></h1>
      </div>
    
      {% for post in posts %}
          <div>
              <p>published: {{ post.published_date }}</p>
              <h1><a href="">{{ post.title }}</a></h1>
              <p>{{ post.text|linebreaks }}</p>
          </div>
      {% endfor %}
    </body>
    </html>
    

    템플릿 확장하기

    base.html을 다음과 같이 작성한다.

    {% load staticfiles %}
    <!doctype html>
    <html lang="ko">
    <head>
        <meta charset="utf-8">
        <title>Django Girls Blog</title>
        <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
        <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
        <link rel="stylesheet" href="{% static 'css/blog.css' %}">
    </head>
    <body>
        <div class="page-header">
            <h1><a href="/">Django Girls Blog</a></h1>
        </div>
        <div class="content container">
            <div class="row">
                <div class="col-md-8">
                {% block content %}
                {% endblock %}
                </div>
            </div>
        </div>
    </body>
    </html>
    

    이제 post_list.html에서는 위 base 파일을 불러오고 content 블럭 안에 내용을 넣을 수 있다.

    {% extends 'blog/base.html' %}
    
    {% block content %}
        {% for post in posts %}
            <div class="post">
                <div class="date">
                    {{ post.published_date }}
                </div>
                <h1><a href="">{{ post.title }}</a></h1>
                <p>{{ post.text|linebreaks }}</p>
            </div>
        {% endfor %}
    {% endblock content %}
    

    post detail 페이지 만들기

    이제 블로그 포스트를 볼 수 있는 페이지를 만든다. blog/templates/blog/post_list.html의 링크를 수정한다.

    <h1><a href="{% url 'post_detail' pk=post.pk %}">{{ post.title }}</a></h1>
    

    blog/urls.py에 post_detail을 추가한다. 뒤에 입력하는 내용을 모두 pk 변수에 저장한다는 의미다. pk는 primary key를 뜻한다.

    from django.conf.urls import url
    from . import views
    
    urlpatterns = [
      url(r'^$', views.post_list, name='post_list'),
      url(r'^post/(?P<pk>[0-9]+)/$', views.post_detail, name='post_detail'),
    ]
    

    blog/views.py에 post_detail을 추가한다. 만약 키가 존재하지 않는다면 404 Not Found 페이지로 넘겨야 하는데 get_object_or_404 함수를 사용할 수 있다.

    from django.shortcuts import render, get_object_or_404
    
    # ...
    
    def post_detail(request, pk):
        post = get_object_or_404(Post, pk=pk)
        return render(request, 'blog/post_detail.html', {'post': post})
    

    이제 blog/templates/blog/post_detail.html을 생성한다.

    {% extends 'blog/base.html' %}
    
    {% block content %}
        <div class="post">
            {% if post.published_date %}
                <div class="date">
                    {{ post.published_date }}
                </div>
            {% endif %}
            <h1>{{ post.title }}</h1>
            <p>{{ post.text|linebreaks }}</p>
        </div>
    {% endblock %}
    

    모델 폼 사용하기

    Django는 간단하게 폼을 생성할 수 있는 기능을 제공한다. blog/forms.py를 생성한다.

    from django import forms
    
    from .models import Post
    
    
    class PostForm(forms.ModelForm):
        class Meta:
            model = Post
            fields = ('title', 'text',)
    

    blog/templates/blog/base.html에 이 폼에 접근하기 위한 링크를 추가한다.

    <a href="{% url 'post_new' %}" class="top-menu"><span class="glyphicon glyphicon-plus"></span></a>
    

    blog/urls.py에 규칙을 추가한다.

        url(r'^post/new/$', views.post_new, name='post_new'),
    

    blog/views.py에 form과 post_new 뷰를 추가한다.

    from django.shortcuts import redirect
    from .forms import PostForm
    
    #...
    
    def post_new(request):
        if request.method == "POST":
            form = PostForm(request.POST)
            if form.is_valid():
                post = form.save(commit=False)
                post.author = request.user
                post.published_date = timezone.now()
                post.save()
                return redirect('blog.views.post_detail', pk=post.pk)
        else:
            form = PostForm()
        return render(request, 'blog/post_edit.html', {'form': form})
    
    

    blog/templates/blog/post_edit.html을 추가한다.

    {% extends 'blog/base.html' %}
    
    {% block content %}
        <h1>New post</h1>
        <form method="POST" class="post-form">{% csrf_token %}
            {{ form.as_p }}
            <button type="submit" class="save btn btn-default">Save</button>
        </form>
    {% endblock %}
    

    수정 페이지 추가하기

    blog/templates/blog/post_detail.html에 다음 링크를 추가한다.

    <a class="btn btn-default" href="{% url 'post_edit' pk=post.pk %}"><span class="glyphicon glyphicon-pencil"></span></a>
    

    blog/urls.py에 다음 코드를 추가한다.

        url(r'^post/(?P<pk>[0-9]+)/edit/$', views.post_edit, name='post_edit'),
    

    blog/views.py에 post_edit 뷰를 추가한다.

    def post_edit(request, pk):
        post = get_object_or_404(Post, pk=pk)
        if request.method == "POST":
            form = PostForm(request.POST, instance=post)
            if form.is_valid():
                post = form.save(commit=False)
                post.author = request.user
                post.published_date = timezone.now()
                post.save()
                return redirect('blog.views.post_detail', pk=pk)
        else:
            form = PostForm(instance=post)
        return render(request, 'blog/post_edit.html', {'form': form})
    

    PostForm()에서 instance를 추가하는 것으로 내용을 미리 초기화한다.

    보안

    템플릿에서 사용자 권한이 있는 경우만 확인할 수 있도록 작성할 수 있다. blog/templates/blog/base.html에서 새 포스트 링크를 다음과 같이 변경한다.

    {% if user.is_authenticated %}
        <a href="{% url 'post_new' %}" class="top-menu"><span class="glyphicon glyphicon-plus"></span></a>
    {% endif %}
    

    이 튜토리얼 뒤엔 보안을 강화하고 새로운 기능을 추가하는 등의 심화편이 있다. Django Girls Tutorial: Extensions에서 그 내용을 볼 수 있다.

    대부분 개발은 폐쇄망에서 개발하거나 공개되어도 auth 등을 걸어둬 아무나 접속하지 못하는 환경이기 때문에 큰 문제가 없다. 하지만 가끔 크롤링 되지 말아야 할 사이트가 검색엔진에 크롤링 되는 경우가 종종 있다. robots.txt을 .gitignore에 넣어 각 환경에 맞게 파일을 분리해서 사용하는 경우도 있는데 제대로 설정이 되지 않아서 크롤링이 되는 경우도 있다. (누가 뭘 한 지는 모르겠지만.)

    이럴 때 nginx에 다음 설정을 추가하는 것으로 robots.txt 파일의 유무와 상관 없이 disallow 규칙을 반환하게 할 수 있다.

    location /robots.txt {
        return 200 "User-agent: *\nDisallow: /";
    }
    

    사소한 팁이긴 하지만 아직도 호스팅 환경을 FTP로 클라이언트와 공유하게 되는 경우가 많아 이런 문제가 종종 발생한다. (클라이언트의 엄마친구아들이 좀 안다고 들어와서 만져놓고 우리한텐 안만졌는데 고장났다고 하거나) 이렇게 서버 레벨에서 제어하는 것이 유용할 때가 있다.

    상속에 관한 포스트를 읽다가 레퍼런스로 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)도 적용 가능하다.

    Google Chrome은 2008년 말에 퍼블릭 베타로 처음 맛을 본 직후 군입대를 했다. 군자원(?)으로 사용한 것까지 치면 Chrome을 주 브라우저로 사용한지 벌써 6년이란 시간이 흘렀다. 개발에서 사용하는 도구 중 IE6 이후로 가장 오래 사용한 것 같다. (IE6는 7+년 사용했다. 암흑의 시간.) Chrome을 사용하는 기간동안 Webkit이 Blink로 전환되거나 표준과 관련된 갈등 등 여러 이슈가 있긴 했지만 최종 사용자 입장에서 큰 차이 없이 지속적으로 사용하고 있다. 물론 정치적 지지 차원공감해서 Mozilla Firefox를 사용하려고 했던 기간도 있었고, Safari로 전환해보려는 시도도 있었지만 번번이 실패하고 다시 Chrome으로 회귀하고 있다.

    편리하고 익숙한 Chrome이지만 좋은 컴퓨터에서도 무겁게 느껴질 때도 있는데, 그럴 때마다 다른 브라우저에 대한 경험도 쌓고픈 생각이 자연스럽게 따라오게 된다. 하지만 간단하지 않은 이유 중 가장 큰 부분은 개발자도구다. 이전에도 개발자도구와 관련된 글을 썼었지만, 매번 새롭게 업데이트 된 기능은 항상 똑똑함에 감탄하고 화려함에 감동까지 밀려온다.

    예전에 비해 메모리 문제는 개선되고 있긴 하지만, 부지런히 작업하고 있던 Chrome이 뜬금 없이 멈출 때가 있다. 지금 그렇게 굳어버려서 풀리는 것 기다리다가 떠나지 못하는 이유에 대해 한번 정리해보는 것도 좋지 않을까 싶어 떠나지 못하는 이유를 적어본다. 물론 Chrome에만 있는 기능이 아닐 가능성이 높다.

    탭 복원 기능

    Chrome에서 탭 복원 즉, 닫은 탭을 다시 여는 기능은 정말 강력한데, 종종 크래시로 탭을 날렸다는 이야기를 들으면 의아할 때가 있다. 탭을 잘못 닫았다면 Cmd + Shift + T를 누르면 된다. 창이 아예 닫히더라도 창이 다시 열리면서 그 창에 있던 모든 탭이 복원된다. 이건 크래시가 났어도 동작한다. Safari나 Firefox에도 있는 기능이거나 확장을 설치하면 되긴 하지만 Chrome처럼 크래시로 닫힌 창에 있는 탭까지 열어주는 기능을 찾지 못했다.

    다중 프로파일 지원

    Google Apps를 회사에서 사용한다면 개인 계정과 회사 계정을 분리된 세션에서 사용할 수 있다. 예전엔 이 기능이 없어서 시크릿(incognito) 모드를 활용했어야 했는데 각각의 계정을 Chrome에 등록하면 마치 별개의 브라우저를 운용하는 것처럼 사용할 수 있다. 브라우저 히스토리 등 모든 브라우저 기능이 각 계정에 맞춰 동작해서 편리하다.

    개발자 도구

    개발자 도구는 모든 브라우저가 각각 특색이 있어서 좋다 나쁘다를 얘기하긴 어려운 부분이다. 내가 유용하게 사용하는 부분은 다음과 같다. 다른 브라우저에서도 지원하는 기능이지만 Chrome이 익숙해서 그런지 Chrome에서의 동작 방식과 다르면 불편하게 느껴진다. 예전에 정리한 팁과 달라지거나 추가된 부분만 간단히 정리하면 아래와 같다.

    • 개발자 도구 위에 있는 탭 위치도 움직일 수 있게 개선됨 (드래그 하면 위치 변경)
    • Elements 패널에서 엘리먼트를 드래그 앤 드롭으로 위치 및 순서 변경할 수 있음
    • HEX, RGBA로 전환 가능한 컬러 픽커 지원, 값도 확인하기 편함
    • 각 엘리먼트에 대해서 Break on… 으로 하위 노드 또는 어트리뷰트 변경 추적 가능
    • JavaScript Console에서 Cmd + K로 버퍼 즉, 콘솔 내용을 지워줌 (쉘에서 그 기능처럼)
    • 가끔 JavaScript에서 break point를 걸고 싶을 때가 있는데 debugger;를 코드에 추가하면 그 위치에서 break되고 해당 코드를 확인할 수 있음
    • break 되었을 때 console에서 현재 스코프에 따라 값을 확인할 수 있음
    • console에서 객체값에 대해 오른쪽 클릭하면 global variable에 저장할 수 있음
    • 페이지 내에 있는 다른 frame의 console도 열 수 있음 (가끔 iframe에 써먹기 좋음)
    • Sources 탭에서 format 버튼으로 간단하게 minify한 코드도 정렬해서 볼 수 있음
    • Geolocation API 테스트를 위한 에뮬레이터를 내장
    • transition에 대해 animation 미리보기 지원
    • Network 탭에서 쉽게 스로트링 테스트 가능

    생각 나는 것만 적었는데 목록이 길어지고 있다. 잔잔하고 편리한 기능이 많다. Chrome의 프로파일링 기능도 엄청 강력한데 이건 따로 정리해볼 생각이다.

    다른 브라우저 개발자 도구에서 지원하는 기능 중에 탐나는 기능은 Firefox에서 XHR을 수정해서 전송할 수 있는 기능인데 크롬에서는 XHR을 재전송 하는 것은 가능하지만 XHR의 header를 수정하는 기능 등은 제공하지 않아 조금 아쉽다. (대신 Postman과 같은 도구를 활용하고 있다.)

    이런 이유로 Chrome을 쉽게 떠나지 못하고 있다. 한명의 Chrome fanboy로, 올해는 또 어떤 새로운 기능이 나올지 기대도 된다. 한편으로는 다른 브라우저도 좀 더 사용해보고 둘러봐야겠다는 생각도 든다.

    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 >}}

    PHP는 언어적인 지원은 물론, 환경이나 커뮤니티도 계속 발전하고 있다. 최근 프레임워크 운용 그룹(Framework Interop Group, FIG)에서 제안하는 PSR 문서를 보면 알 수 있듯, 표준화된 라이브러리를 만들기 위해 라이브러리/패키지 개발에 대한 합의도 활발하게 진행되고 있어서 예전 그 난장판이던 분위기와는 사뭇 다르다. PSR에서 다뤄지는 내용은 미래에 사용할 기능이 아니라 지금 현재 PHP에서 당장 활용할 수 있는 기술이다. 더이상 미룰 수 없고, 미뤄서도 안된다는 이야기다.

    구석기 PHP는 농담 짙은 표현이지만 이젠 마치 과거의 유물과도 같은 코드에 그만 집착하고 현대에 맞는 코드를 작성했으면 한다. 이 글은 예전 방식으로 PHP를 개발하고 있다면 자주 접했을 만 한 문제를 정리하고 있다. 이 글에서 PSR의 내용을 직접적으로 다루지는 않지만, PSR를 준수하는 것으로 여기서 말하는 현대적인 개발과 과거의 PHP 개발은 어떤 부분이 다르고, 어떻게 편리한지 확인해보자.

    구석기 PHP

    함수, 변수, 클래스의 전역적인 공해

    파일을 불러오고 나면 각각의 파일이 갖고 있던 경계(스코프)가 전역으로 확장되고 어디서나 사용할 수 있는 함수, 변수, 클래스가 만들어지게 된다.

    <?php 
    // lib/haruair/function.php
    function HelloWorld() {
      return "HelloWorld";
    }
    ?>
    <?php 
    // lib/wordpress/function.php
    function HelloWorld() {
      return "HelloWorld. I'm wordpress btw.";
    }
    ?>
    <?php
    // app.php
    include_once('./lib/haruair/helloWorld.php');
    include_once('./lib/wordpress/helloWorld.php');
    
    // PHP Fatal error:  Cannot redeclare HelloWorld()
    ?>
    

    같은 함수명이나 클래스명을 사용하면 다시 선언할 수 없는 문제가 발생한다. 특히 워드프레스 개발을 하다보면 플러그인 내에 동일한 함수나 클래스명을 사용하고 있어 이런 문제가 발생하는 경우가 자주 있다. 대부분 function_exists()와 같은 함수를 이용해 미리 확인하고 선언하는 방식으로 처리되어 있는데 여전히 좋은 방법은 아니다.

    그래도 함수나 클래스의 이름이 중복되는 경우에는 문제가 있는걸 바로 자각할 수 있지만, 동일한 명칭의 전역 변수가 있고 각각의 파일에서 그 변수를 활용하고 있다면 그 누구도 결과를 예상할 수 없게 된다. 이런 경우는 어느 하나의 이름을 모두 변경해야 하거나 언제 발생할지 모르는 에러를 감내해야 한다.

    기본으로 모든 파일 로드하기

    이전 방식의 개발에서는 다음과 같은 lib.php를 만들어 의존성을 갖는 모든 파일을 불러와 활용하는 경향이 있다. 이 파일 하나를 불러오면 각각 파일의 함수, 변수, 클래스를 모두 불러와서 사용할 수 있게 된다.

    <?php
    // lib.php
    include_once('./lib/A/Orders.php');
    include_once('./lib/B/Account.php');
    include_once('./lib/B/AccountManagement.php');
    include_once('./lib/B/AccountSomething.php');
    include_once('./lib/C/Report.php');
    include_once('./lib/E/Admin.php');
    ?>
    

    이런 개발 방식은 오랫동안 큰 대안 없이 활용되고 있고, 지금까지도 많은 코드에서 발견되는 방식이다. 각각 필요에 따라 include하는 경우도 있지만, 각각의 파일끼리도 의존성이 있는 경우도 많기 때문에 하나의 파일에서 모두 불러오는 형태로 많이 사용한다. 다음 코드를 살펴보자.

    <?php
    // some-page.php
    include_once( BASE_DIR . '/lib.php');
    
    function HelloWorld() {
      return speak("Hello World");
    }
    
    // `speak()` 함수가 어느 php 파일에서 나온지 알 수 없다.
    ?>
    

    IDE를 활용하면 쉽게 speak()가 선언된 부분을 찾을 수 있겠지만 코드만 봐서는 이 speak() 함수가 어디에서 나온 함수인지 알 수 없다. 이런 문제로 인해 대다수의 프레임워크나 CMS에서는 함수명에 접두사를 붙여 사용하는 등의 방식으로 해결했지만 함수를 호출할 때마다 접두사를 붙여 호출하는 일은 누가봐도 지저분한 일이다.

    특히 이런 방식으로 개발된 코드는 함수와 실제 로직과의 결합도가 높아서 코드를 재활용하기도 어렵다. 그 결합도를 낮추기 위한 시도로 변수에 함수명을 넣고 실행하는 방법도 활용되지만 여전히 코드가 장황하고 지저분해지는 경향이 크다.

    코딩 스타일의 차이

    함께 개발하는 개발자가 모두 동일한 코딩 스타일을 준수하는 것은 중요하다. 때로는 공개된 라이브러리를 활용하게 되는 경우도 있는데 이런 라이브러리가 동일한 컨벤션을 준수하고 있지 않는다면 자연스럽게 불편함을 겪게 된다.

    <?php
    include_once('./some_pdf_gen/lib.php');
    include_once('./someCalculatorLibrary/content/library/cal.php');
    
    include_once('./my/lib.php');
    
    $orders = array(
      new My_Product(112, 2.5, 2),
      new My_Product(2303, 30, 1),
      new My_Product(4923, 30, 2)
    );
    
    $pdf = new AcmeSomePdfGen();
    $calculation = new some_calculation($orders);
    $total = $calculation->get_total_price();
    
    $pdf->setTemplate("<div>Total: $total</div>");
    $pdf->download();
    ?>
    

    인위적으로 만든 예시지만 충분히 있을 법한 코드다. 일관성이 없는 코드는 개발자에게 고스란히 스트레스로 돌아온다.


    현대인의 PHP

    이제 앞서 본 코드와 어떻게 다른지 살펴볼 것이다. PHP 5.3 이후로 사용할 수 있게 된 namespace와 autoloader를 활용하는 것으로 지저분한 문제를 대부분 해결할 수 있다. 이 중요한 두 가지 기능을 사용하는데 있어 어떻게 사용하고 활용하는지 PSR 문서로 정리되어 있다. PSR을 모두 설명하고 있지 않지만 어떤 방식으로 문제를 해결하는지 확인하자.

    namespace 활용하기

    네임스페이스를 다음과 같이 선언하는 것으로 클래스를 네임스페이스 아래로 배정할 수 있게 된다.

    <?php // lib/haruair/helloWorld.php
    namespace Haruair;
    class HelloWorld {
      function say() {
        return "HelloWorld";
      }
    }
    ?>
    <?php // lib/wordpress/helloWorld.php
    namespace WordPress;
    class HelloWorld {
      function say() {
        return "HelloWorld. I'm wordpress btw.";
      }
    }
    ?>
    

    이러면 다음 코드와 같이 한 파일에서 사용하는데 전혀 문제가 없다.

    <?php // app.php
    include_once('./lib/haruair/helloWorld.php');
    include_once('./lib/wordpress/helloWorld.php');
    
    $haruair = new Haruair\HelloWorld();
    $wordpress = new WordPress\HelloWorld();
    
    echo $haruair->say(); // HelloWorld
    echo $wordpress->say(); // HelloWorld. I'm wordpress btw.
    ?>
    

    네임스페이스를 활용하면 Haruair\Order\ProductHaruair\Cart\Product가 동일한 Product라는 이름의 클래스라도 하나의 파일에서 두 클래스 모두 처리할 수 있게 된다.

    autoloader 활용하기

    php에서 미리 선언한 함수나 클래스를 사용하려면 당연하게 includerequire 같은 내장 함수를 활용했어야 했다. 하지만 spl_autoload_register 함수를 선언하면 파일을 필요로 할 때 불러오는 방식으로 구현할 수 있다. 다음 코드를 보자.

    <?php
    include_once('./src/haruair/event/ticket.php');
    include_once('./src/haruair/event/attendee.php');
    include_once('./src/haruair/event/coupon.php');
    
    $ticket = new Haruair\Event\Ticket;
    $attendee = new Haruair\Event\Attendee;
    $coupon = new Haruair\Event\Coupon;
    ?>
    

    이제 직접 include 하는 것이 아니라 autoloader를 활용해서 불러오도록 한다.

    <?php
    spl_autoload_register(function ($class) {
    
        // 프로젝트에 따른 네임스페이스 프리픽스
        $prefix = '';
    
        // 네임스페이스 프리픽스를 위한 기본 디렉토리 경로
        $base_dir = __DIR__ . '/src/';
    
        // 네임스페이스 프리픽스에 해당하는지 검사
        $len = strlen($prefix);
        if (strncmp($prefix, $class, $len) !== 0) {
            // 찾지 못했으므로 반환. 만약 다른 오토로드 함수가 등록되어 있다면 순차적으로 실행함.
            return;
        }
    
        // 네임스페이스를 제외한 상대 클래스명 찾기
        $relative_class = substr($class, $len);
    
        // 네임스페이스 프리픽스를 기본 디렉토리 경로로 치환, 네임스페이스 구분자를 디렉토리 구분자로
        // 전환하고 .php를 끝에 추가함
        $file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
    
        // 파일이 존재하면 불러옴
        if (file_exists($file)) {
            require $file;
        }
    });
    
    $ticket = new Haruair\Event\Ticket;
    $attendee = new Haruair\Event\Attendee;
    $coupon = new Haruair\Event\Coupon;
    // autoloader를 호출한다.
    ?>
    

    $ticketnew Haruair\Event\Ticket이 할당될 때, “Haruair\Event\Ticket” 문자열을 인자로 받는 spl_autoload_register 함수가 실행이 된다. 그래서 함수 내 정의된 방식대로 해당 문자열을 처리해 파일을 불러오게 된다. 이 예제 함수에서는 소문자로 전환한 후, 각각의 네임스페이스를 디렉토리 구조로 변환하고 끝에 .php를 붙여 해당 파일을 불러오는 식으로 작성되어 있다.

    여기서 사용된 함수는 약식 구현이고 모두가 공용으로 사용할 수 있도록 PSR에서 PSR-0, PSR-4로 표준화된 문서를 제공하고 있다. Composer를 사용한다면 더 간편하게 활용할 수 있다. PSR-4의 구현 예시도 참고하자.

    코딩 스타일 일치

    PSR-1 Basic Coding StandardPSR-2 Coding Style Guide를 통해 표준적인 문법을 문서화하고 있다. 이 두 문서를 따라 개발하면 자연스럽게 앞서 다룬 네임스페이스와 autoload를 활용할 수 있게 된다. 특히 composer로 내려받을 수 있는 패키지는 이 두 문서를 준수할 것을 권장하고 있으므로 새로운 코드나 라이브러리, 패키지를 추가하더라도 일관적인 코딩 스타일을 유지하는데 도움이 될 것이다.


    얕은 수준에서 비교한 글이지만 여기서 다룬 기능은 지금 당장에라도 활용할 수 있는 방법이다. 이 기법을 활용하는 것은 단순히 몇가지 기술을 배우는 것에 그치지 않고 더 나은 개발 기법을 학습하는데 도움이 된다. 아직 PSR이나 composer와 같은 도구가 생소하다면 이 글을 읽고서 꼭 살펴봤으면 좋겠다.

    더 읽을 거리

    색상을 바꿔요

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

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