이 가이드라인은 California Institute of Technology의 강의 자료인 C++ Operator Overloading Guidelines를 번역한 글로 C++에서 연산자를 오버로딩 할 때 유의해야 하는 부분을 잘 설명하고 있다.


C++ 연산자 오버로딩 가이드라인

사용자 정의 클래스를 사용할 때 연산자에 특별한 의미를 부여할 수 있다는 점은 C++의 멋진 기능 중 하나입니다. 이 기능을 연산자 오버로딩(operator overloading) 이라고 합니다. C++의 연산자 오버로딩은 클래스에 특별 멤버 함수를 다음과 같은 명명 규칙에 따라서 작성해 구현할 수 있습니다. + 연산자를 클래스에 오버로드 하는 것으로 예를 들면 operator+라는 이름의 멤버 함수를 클래스에 작성해서 제공할 수 있습니다.

다음은 사용자 정의 클래스에 일반적으로 오버로드하는 연산자 목록입니다.

  • = (할당 연산자, assignment operator)
  • + - * (이진 산술 연산자, binary arithmetic operators)
  • += -= *= (복합 할당 연산자, compound assignment operators)
  • == != (비교 연산자, comparison operators)

이 연산자를 구현하는데 있어 몇 가지 지침이 있습니다. 이 지침을 따르는 것은 매우 중요하며 각 지침은 꼭 버릇으로 만들기 바랍니다.

할당 연산자 =

할당 연산자는 다음과 같은 시그니처(signature)를 사용합니다.

class MyClass {
  public:
  ...
  MyClass & operator=(const MyClass &rhs);
  ...
}

MyClass a, b;
...
b = a;   // b.operator=(a); 와 동일함

= 연산자가 우측에 할당한 내용을 상수 참조로 받는 점을 확인할 수 있습니다. 이렇게 하는 이유는 명확한데 할당 연산자 왼쪽에 있는 내용을 바꾸고 싶은 것이지 할당 오른쪽은 변경을 원하지 않기 때문입니다.

또한 할당 연산자도 참조로 반환하는 점을 확인할 수 있습니다. 이 방식으로 **연산자 연결(operator chaining)**이 가능합니다. 원시 형식(primitive types)이 다음처럼 동작하는 것을 봤을 겁니다.

int a, b, c, d, e;

a = b = c = d = e = 42;

컴파일러는 이 코드를 다음처럼 해석합니다.

a = (b = (c = (d = (e = 42))));

다시 말하면 할당은 **우측 연관(right-associative)**입니다. 마지막 할당 연산이 먼저 평가되며 연쇄적인 할당에 따라 좌측으로 퍼져가게 됩니다. 특히,

  • e = 42는 42를 e에 할당하고 그 결과로 e를 반환합니다.
  • e의 값이 d에 할당되며 그 결과로 d를 반환합니다.
  • d의 값이 c에 할당되며 그 결과로 c를 반환합니다.
  • 기타 등등…

이제 연산자 연결을 지원하기 위해서 할당 연산자는 반드시 어떤 값을 반환해야 합니다. 반환되어야 하는 값은 할당의 좌측 을 참조합니다.

반환 참조에는 상수로 선언되지 않았음 을 확인할 수 있습니다. 이 점은 좀 혼란스러울 수 있는데 다음과 같은 기괴한 코드를 작성할 수 있기 때문입니다.

MyClass a, b, c;
...
(a = b) = c;  // 이건 뭐죠??

이건 코드를 처음 봤다면 아마 이런 상황을 방지하기 위해 operator=를 상수 참조로 반환하고 싶을지도 모릅니다. 하지만 이런 구문(statements)은 원시 형식과 함께 동작할겁니다. 그리고 더 나쁜 점은 어떤 도구는 이런 동작 방식에 의존하고 있다는 점입니다. 그러므로 operator=상수가 아닌 참조로 반환하는 것이 중요합니다. 경험적으로 “상수에 괜찮다면 사용자 정의 자료 형식에도 괜찮다”고 말할 수 있습니다.

그래서 가상의 MyClass 할당 연산자를 다음처럼 작성할 수 있습니다.

// 우측 할당에서 상수 참조를 받습니다.
// 좌측에 상수가 아닌 참조를 반환합니다.
MyClass& MyClass::operator=(const MyClass &rhs) {
    ...  // 할당 연산을 수행합니다!

    return *this;  // 자기 자신을 참조를 반환합니다.
}

기억하세요. this는 이 객체에 대한 포인터며 멤버 함수로 호출되는 대상입니다. a = ba.operator=(b)처럼 취급되는 방식을 보면 반환 객체 자체가 호출되는 함수라는 점이 더 와닿을 것입니다. 객체 a는 좌측에 해당합니다.

하지만 멤버 함수는 객체에 대한 포인터를 반환하는 것이 아니라 객체에 대한 참조를 반환해야 합니다. 그래서 *this를 반환했는데 이 코드는 무엇을 가리키고 있는지를 반환했지 포인터 자체를 반환한 것이 아닙니다. (C++에서는 인스턴스는 참조로 전환되고 또 그 역으로도 성립하는데 거의 자동으로 처리됩니다. 그래서 *this는 인스턴스지만 C++는 암시적으로 인스턴스에 대한 참조로 변환합니다.)

이제 할당 연산자에 가장 중요한 점이 하나 더 있습니다. 자기 할당(self-assignment)를 꼭 확인해야 합니다!

클래스가 자체적으로 메모리 할당을 하는 경우라면 특히 중요합니다. 할당 연산자가 일련의 연산을 수행할 때 일반적으로 다음처럼 동작합니다.

MyClass& MyClass::operator=(const MyClass &rhs) {
    // 1. MyClass가 내부적으로 갖고 있는 모든 메모리를 할당 해제합니다.
    // 2. rhs의 내용을 보관하기 위해 메모리에 할당합니다.
    // 3. rhs로부터 값을 인스턴스에 복사합니다.
    // 4. *this을 반환합니다.
}

이제 이렇게 작성하면 다음과 같은 일이 일어납니다.

MyClass mc;
...
mc = mc;     // 짜잔!

이 코드를 보면 프로그램에 대혼란을 불러온다는걸 볼 수 있을겁니다. mc는 좌측에도 있고 또한 우측에도 있기 때문에 가장 먼저 일어나는 일은 mc가 내부적으로 들고 있는 모든 메모리를 해제합니다. 하지만 여기서 값이 복사될 위치이기도 합니다. 우측도 mc가 있기 때문이죠! 이제 나머지 할당 연산자 내부를 완전히 다 엉망으로 만들고 맙니다.

이런 문제를 손쉽게 피하려면 자기 할당을 확인합니다. “두 인스턴스는 같나요?”라는 질문에 답하는 방법은 많지만 우리 용도를 생각해보면 객체 주소가 동일한지 확인하는 정도면 지금 용도에 맞습니다. 만약 주소가 동일하면 할당을 하지 않습니다. 주소가 다르면 할당을 수행합니다.

이제 올바르고 안전한 버전의 MyClass 할당 연산자를 생각하면 다음과 같습니다.

MyClass& MyClass::operator=(const MyClass &rhs) {
    // 자기 할당을 확인합니다.
    if (this == &rhs)      // 동일 객체?
        return *this;        // 맞네요. 그럼 할당을 건너뛰고 *this를 반환합니다.

    ... // 할당 해제, 새 공간을 할당하고 값을 복사합니다...

    return *this;
}

또는 간단하게 다음처럼 할 수 있습니다.

MyClass& MyClass::operator=(const MyClass &rhs) {

    // `rhs`가 `this`와 다를 때만 할당을 합니다.
    if (this != &rhs) {
        ... // 할당 해제, 새 공간을 할당하고 값을 복사합니다...
    }

    return *this;
}

위 비교에서 this는 호출되는 객체 포인터고 &rhs는 인자로 전달된 객체를 가리키는 포인터라는 점을 기억한다면 위 코드와 같은 검사로 자기 할당의 위험성을 회피할 수 있다는 점을 확인할 수 있을겁니다.

결론을 정리하면 할당 연산자를 위한 가이드라인은 다음과 같습니다.

  1. 인자는 상수 참조로 받습니다. (할당 우측)
  2. 좌측에 참조를 반환해서 안전하고 적절한 연산자 연결을 지원합니다. (*this를 반환하는 방법으로)
  3. 포인터를 비교해서 자기 할당을 확인합니다. (this&rhs)

복합 할당 연산자 += -= *=

산술 연산자에 대해 특별히 언급할 부분이 있는데 뒤에서 자세히 다루겠습니다. 이 연산자는 비구조(destructive) 연산자라는 점이 중요합니다. 바로 할당의 좌측 값이 갱신되거나 대체되기 때문입니다. 다음 예시를 확인합니다.

MyClass a, b;
...
a += b;    // a.operator+=(b) 와 동일함

이 경우에는 += 연산에 의해 값이 수정되었습니다.

어떻게 이 값이 변경 되었는가는 그렇게 중요하지 않습니다. 명백하게 MyClass가 이 연산자의 의미가 어떤 것인지 나타내고 있기 때문입니다.

이런 연산자의 멤버 함수 시그니처는 다음과 같아야 합니다.

MyClass & MyClass::operator+=(const MyClass &rhs) {
    ...
}

rhs가 상수 참조인 이유는 이미 앞에서 다뤘습니다. 그리고 이런 연산자의 구현 또한 직관적입니다.

하지만 연산자가 MyClass 참조를 반환하는 것을 볼 수 있으며 상수가 아닌 형태로 반환하고 있습니다. 이 말은 다음과 같은 코드도 가능하다는 의미입니다.

MyClass mc;
...
(mc += 5) += 3;

누가 이런 코드를 작성하는지 저에게 물어보지 않기 바랍니다. 하지만 다른 일반적인 할당 연산자와 같이 이런 원시 데이터 형식에서는 허용되야 하는 방식입니다. 사용자 정의 데이터 형식도 원시 데이터 형식에서 이런 연산자와 함께 동작하는 방식과 맥락이 맞는 동작을 제공해야 모든 코드가 기대한 대로 동작할 것입니다.

이런 작업은 매우 직관적입니다. 단지 복합 할당 연산자 구현을 작성하고 *this를 마지막으로 반환하도록 작성합니다. 다른 일반적인 할당 연산자처럼 말이죠. 그러면 다음과 같은 코드를 작성하게 될 것입니다.

MyClass & MyClass::operator+=(const MyClass &rhs) {
    ...   // 복합 할당 작업을 처리합니다.

    return *this;
}

마지막으로 일반적으로 복합 할당 연산자더라도 자기 할당 문제에 유의해야 합니다. 운좋게도 C++ 트랙 연구실에서 이 걱정을 할 필요는 없겠지만 클래스를 직접 작성하고 사용할 때마다 이 문제에 대해 항상 생각하고 있어야 합니다.

이진 산술 연산자 + - *

이진 산술 연산자는 연산의 양쪽을 모두 수정하지 않으며 두 인자로 만든 새로운 값을 반환한다는 점에서 흥미롭습니다. 아마 이 작업은 조금 추가적인 작업을 필요로 해서 짜증날 수 있겠지만 여기에 비밀이 있습니다.

복합 할당 연산자를 활용해서 이진 산술 연산자를 정의하세요.

방금 제가 당신이 숙제에 쓸 수많은 시간을 절약해줬습니다.

이미 += 연산자를 구현했고 이제 + 연산자를 구현하려고 합니다. 함수 시그니처는 다음과 같습니다.

// 인스턴스의 값을 다른 곳에 추가하고 결과와 함께 새 인스턴스를 반환합니다.
const MyClass MyClass::operator+(const MyClass &other) const {
    MyClass result = *this;     // 자신의 사본을 만듭니다. `MyClass result(*this);`와 같습니다.
    result += other;            // 다른 곳에서 사본에 더하기 위해 +=를 사용합니다.
    return result;              // 모두 끝났습니다!
}

간단하네요!

사실 명시적으로 모든 과정을 다 설명했지만 원한다면 이 모든 코드를 다음처럼 한 줄 구문으로 작성할 수 있습니다.

// 인스턴스의 값을 다른 곳에 추가하고 결과와 함께 새 인스턴스를 반환합니다.
const MyClass MyClass::operator+(const MyClass &other) const {
    return MyClass(*this) += other;
}

이 코드는 *this의 사본으로 이름 없는 MyClass 인스턴스를 생성합니다. 그리고 += 연산자는 이 임시 값에서 호출하고 반환합니다.

마지막 구문이 아직 이해가 되지 않는다면 앞서 단계를 풀어서 설명한 코드를 사용하기 바랍니다. 하지만 정확히 무슨 과정이 이뤄지는지 이해된다면 짧은 코드를 사용하세요.

+ 연산자가 상수 인스턴스를 반환한 것이지 상수 참조를 반환하지 않았다는 점 을 알 수 있을 겁니다. 상수 참조를 반환하지 않는 것으로 다음과 같은 이상한 구문을 작성하는 일을 막습니다.

MyClass a, b, c;
...
(a + b) = c;   // 엥...?

이 구문은 기본적으로 아무 일이 일어나지 않습니다. 하지만 + 연산자가 상수가 아닌 값을 반환하는 경우에는 컴파일이 됩니다! 반환 값을 상수 인스턴스로 한다면 이런 광기는 더이상 컴파일 되지 않을 것입니다.

이진 산술 연산자를 위한 지침을 정리하면 다음과 같습니다.

  1. 복합 할당 연산자를 처음부터 구현합니다. 그리고 이진 산순 연산자를 복합 할당 연산자를 사용해서 이진 산술 연살자를 정의합니다.
  2. 허용되지 말아야 할 쓸모 없고 혼란스러운 할당 연산을 방지하기 위해서 상수 인스턴스를 반환합니다.

비교 연산자 ==, !=

비교 연산자는 매우 간단합니다. 다음 함수 시그니처를 사용해서 ==를 먼저 정의합니다.

bool MyClass::operator==(const MyClass &other) const {
    ...  // 값을 비교하고 bool 형식으로 결과를 반환합니다.
}

구현 내부는 매우 명확하고 직관적입니다. bool 반환 값도 아주 분명합니다.

여기서 중요한 점은 != 연산자도 == 연산자를 사용해서 간편하게 정의할 수 있습니다. 다음과 같이 작성하세요.

bool MyClass::operator!=(const MyClass &other) const {
    return !(*this == other);
}

== 연산자를 구현하려고 고생해서 만든 코드를 그대로 재사용하는 방법입니다. 또한 ==!= 구현이 서로를 구현하고 있기 때문에 이 연산자 사이에서 불일치 문제가 발견될 가능성이 매우 낮습니다.


Updated Oct 23, 2007

Copyright (c) 2005-2007, California Institute of Technology.


더 읽을 거리

C. Titus Brown의 글 A framework for thinking about Open Source Sustainability?을 번역했다. 공유지의 비극과 그 해결책을 오픈소스 프로젝트에 적용해보는 글인데 오픈소스 거버넌스에서 기성 연구를 적용하는 글이라 더 흥미롭다.

오픈소스 지속가능성 체계적으로 생각해보기

공유자원 문제를 온라인 오픈 프로젝트에 적용할 수 있을까?

Nadia Eghbal의 아름다운 글인 “공유지의 비극”을 다시 읽었습니다. Elinor Ostrom의 공유자원 연구에 기반한 대안적 결과를 읽으며 여러 맥락에서 다양한 생각이 들었고 그 생각을 공유하려고 합니다.

Nadia는 오픈소스의 지속가능성 문제에 대해 탐구하고 있습니다. 중요한 오픈소스 소프트웨어가 상대적으로 적은 인원으로 관리되고 있으며 금전적 보장 방법이 많지 않은 상황이라 중대한 문제로 볼 수 있습니다. 이 문제의 규모나 범위를 따지면 누구와 대화하고 있냐에 따라 달라집니다. Python의 과학적 컴퓨팅 환경에 관한 충격적인 그림을 보면 numpy는 겨우 6명의 메인테이너가 유지하고 있는 점을 확인할 수 있습니다. Python에서 과학적 컴퓨팅 환경을 생각해보면 numpy에 상당히 의존적이기 때문에 메인테이너의 수가 적다는 이야기는 쉽지 않은 도전처럼 들립니다. 소프트웨어 개발에 관한 일반적인 도전 과제를 심층적으로 살펴보고 싶다면 “The Astropy problem”, Muna et al., 2016를 보도록 합니다.

(이후 논의에서는 과학적 소프트웨어에 치중되어 있지만 더 넓은 범위에도 적용할 수 있다고 생각합니다.)

저는 생물정보학(bioinformatics) 분야에서 일하고 있습니다. 이 분야는 다양한 조합의 소프트웨어 패키지를 사용하며 이런 패키지도 대학원생이나 포스트닥터, 학부에 계신 분이나 직원 등 다양한 사람이 관리하고 있습니다. 제 랩에서도 소프트웨어를 개발하고 있습니다. (주로 khmersourmash를 개발하며 그 외에도 여럿 있습니다.) 그리고 지난 해에는 지속적으로 소프트웨어를 유지하기 위한 방법도 개발했는데 주로 테스트와 지속적 통합을 중심으로 동작합니다. 하지만 항상 무언가 제대로 동작하지 않는 부분이 있습니다. 자동화된 관리에서 정말 사소한 상수인데도 그렇습니다. 운이 좋다고 해야할지 Jupyter와 같은 프로젝트와는 다르게 저희 소프트웨어를 사용하는 사람은 그리 많지 않아서 버그 리포트와 관리 문제가 주체 못할 정도로 쏟아지진 않습니다.

이미 언급했듯 오픈소스 소프트웨어를 관리하는 상수는 꽤 노력이 필요합니다. 매주 소프트웨어의 어떤 부분이든 작업하다보면 버그와 관련되어 있지 않는 경우가 드뭅니다. 아니면 새로운 기능을 추가하기 위해 뒤집어 엎어야 하는 부분이 많은 경우도 있습니다. 물론 랩에서 저나 다른 사람이 갑자기 연구나 코딩에 영감이 온다면 한동안 그런 문제를 마주할 일이 없긴 할겁니다.

학습에 있어서도 동일하게 노력이 필요합니다. 매년 2주짜리 과정분석 워크샵을 진행해 새로운 접근법, 소프트웨어, 자료 타입 등 발전에 필요한 학습을 합니다. 2010년에 배운 지식도 여전히 동작하긴 하지만 대부분 지독하게 썩었을 것이라 예상합니다.

다년간 여러 패키지를 관찰해본 제 경험에서는 활발하게 관리되지 않는 오픈 온라인 프로젝트(소프트웨어와 학습 자료를 포함)는 빠르게 부식한다는 확고한 결론에 도달했습니다. 또한 사람들이 많이 사용하는 프로젝트는 고쳐야 할 많은 버그와 문제를 항상 찾게 됩니다. 이 관리된다는 말에는 다른 사람들이 당신의 온라인 프로젝트를 _향상_하는데 실제로 도움을 주고 싶어서 GitHub에 리뷰 받아 머지되거나 거절되야 할 풀 리퀘스트를 제출하는 것을 포함하지 않았습니다. 또는 (정말 성공적이라면) 어떤 회사에서 프로젝트에 참여해 그들의 코드를 넣고 싶어할지도 모르겠지만 그런 회사도 포함하지 않았습니다.

프로젝트가 활발하게 관리되려면 지속적인 관심이 필요합니다. 이 관심에는 멋진 과학적 소프트웨어 패키지의 제멋대로 자란 생태계, 실제 메인테이너로 구성되어 있지만 상대적으로 작은 커뮤니티도 포함됩니다. 이 모든 것이 하나로 섞이면 과학계에서 마주하는 오픈소스 지속 가능성 문제로 이어집니다. 영웅적인 노력 없이는 이 모든 프로젝트를 유지할 수 있는 인력이 없습니다. 과학계에서의 소프트웨어 유지라는 명확한 커리어 패스가 존재하지 않는다면 오픈소스 유지보수를 위해 더 많은 사람을 찾는 일은 도덕적이지도, 지속 가능하지도 않다는 점이 명확합니다.

최근에 동료와 다른 오픈소스 프로젝트에 대해 브레인스토밍을 한 적이 있습니다. (뒤에 더 설명합니다.) 이 과정에서 이 문제를 공유자원 문제라는 틀 안에서 생각해보려고 했습니다. 프로젝트의 성공이란 지속가능성 문제와 맡닿아 있으며 공유 자원 프레임워크를 사용하면 그 지속가능성에 대해 판단할 수 있을 거라 생각하고 이 틀을 찾기로 했습니다.

공유 자원, 공유지의 비극, 지속가능성을 위한 디자인 원리

제가 알고 있는 공유자원 프레임워크는 Elinor Ostrom의 책, 공유지의 비극을 넘어 (원제: Governing the Commons)에서 왔습니다. 이 책에서는 아주 일반적인 공유자원 문제를 설명하고 공동체가 이런 문제를 어떤 방식으로 해결했는지 논의하고 있습니다.

개괄적으로, 60, 70년대 Elinor Ostrom과 동료는 “공유지의 비극”이라는 유명한 개념을 설명했습니다. 공유지의 비극은 공유자원이 이기적인 이유로 과도하게 이용되는 상황을 의미합니다. 이 문제는 피할 수 있습니다. 꼭 정부의 규제나 기업화만 이런 문제를 관리하는 방법이 아닙니다. 실제로 공동체 대다수는 공유자원을 지역적으로 관리하는 방식을 통해 문제를 해결했습니다. Ostrom과 다른 사람의 사례 연구에서 Ostrom은 공유 자원의 지속 가능성을 위한 8가지 “디자인 원칙”을 추출했습니다.

Nadia의 블로그 포스트에서 잘 설명하고 있고, 또한 읽기 좋게 정리되어 있는 위키피디아에 있는 8가지 디자인 원칙 링크를 남겨둡니다.

Ostrom은 이 연구에 대한 업적으로 2009년 노벨상을 수상했습니다.

오픈 온라인 프로젝트로 돌아가기

저와 동료들은 이 프레임워크를 디지털 자원인 오픈 온라인 프로젝트에 어떤 방식으로 적용할 수 있을까 고민했습니다. 디지털 자원은 물리적 자원과 달리 소비할 수 없고 누가 프로젝트의 소스 코드를 소비한다고 해서 다른 사람이 사용하지 못하는 것이 아니기 때문입니다.

대화 속에서 답을 찾았고 그 답은 노력(effort)이었습니다. 오픈 온라인 프로젝트의 공유 자원은 노력입니다.

기여자가 프로젝트에 새로운 기능을 추가하려면 무슨 일을 하게 되나요? 노력을 들입니다. 기여자가 버그를 보고할 때는? 역시 노력을 들입니다. 좋은 버그 리포트를 남길 때는? 노력을 들입니다. 문서를 작성할 때는? 기능을 테스트할 때는? 새 기능을 추천할 때는? 모든 과정에서 노력이 필요합니다.

하지만 이보다 더 깊은 주제가 있습니다. 새로운 기여자를 프로젝트로 끌어들이면 노력의 크기를 성장시킬 수 있습니다. 오픈 소스 프로젝트에 금전적 지원을 줄 새 투자자와 만나게 될 때, 프로젝트에 얼마나 많은 헌신적 노력이 있는가에 따라 그 지원의 규모가 증가하기도 합니다.

물론 전에 썼던 것과 같이 모든 기여가 노력으로 가치 있는 것은 아닙니다. 어떤 기여는 그 기여가 주는 가치보다 프로젝트에 더 많은 _비용 부담_을 주기도 합니다. 예를 들면 새로운 기능 제안, 나쁜 버그 리포트가 있습니다. 기여가 없는 커다란 기능 추가는 단순히 리뷰하고 거절하는 일에서도 핵심 프로젝트 메인테이너에게 엄청난 비용적 부담을 줄 수 있습니다. 이런 일은 유지보수 필요성이 낮은 일인데 유지보수가 프로젝트의 노력을 죄다 끌어가야만 하는 상황으로 뒤바뀔 수도 있습니다.

Fernando Perez가 #GCCBOSC에서 비슷한 점을 언급한 적이 있습니다. 바로 오픈소스 프로젝트에서 사람을 채용해가는 방식에 대한 지적입니다. 많은 회사가 오픈소스 커뮤니티에서 사람을 채용합니다. 단순히 생각해봐도 오픈소스 커뮤니티에서 이들을 교육하는데 들인 노력은 안중에 없이 발굴해가는 것은 문제입니다.

공유 자원의 지속가능성을 위한 8가지 디자인 원칙을 보고 “노력”을 공유 자원으로 정의한다면 오픈소스 프로젝트에 다음처럼 적용할 수 있습니다.

  1. 오픈소스 프로젝트에 누가 기여자인지 명확하게 정의합니다.
  2. 오픈 온라인 프로젝트에서의 노력은 프로젝트의 필요에 따라 지역적으로 적용됩니다.
  3. 오픈 소스 프로젝트는 기여하는 사람이 디자인 결정에 참여한다는 규칙을 따릅니다.
  4. 크게 기여하는 사람을 공식적으로 프로젝트에 참여하도록 해서 결정에 더 큰 역할을 맡을 수 있도록 합니다.
  5. 커뮤니티의 규칙을 위반한 기여자는 제재할 수 있는 범위를 둡니다.
  6. 충돌 대부분은 법률적으로 해결하기보다 프로젝트 내부적으로 해결합니다.
  7. 충돌 대부분은 가벼운 방법과 논의로 다룹니다.
  8. 오픈소스 기여자 대부분은 여러 프로젝트에 기여합니다. 예를 들면 Python 생태계에서는 같은 사람이 많은 프로젝트에 참여하고 있습니다. 이런 맥락에서 Python 생태계를 큰 규모의 공유 자원으로 여기고 많은 지역적 공유 자원으로 구성되어 있다는 점을 고려해야 합니다. 예를 들어 “핵심 CPython 개발”, “수치 연산/numpy 라이브러리” 등의 지역적 공유 자원이 존재합니다.

제 생각에는 많은 종류의 열린 공동체에 일반적으로 적용할 수 있는 항목이라고 생각합니다.

그렇다면 이게 무슨 의미일까요?

저와 동료가 이런 방식으로 생각하기 시작한 후로 이 관점에서 오픈소스 프로젝트와 온라인 공동체 자료를 보기 시작했습니다. 이 틀은 오픈소스의 지속 가능성을 생각하는데 아주 간단하고 좋은 프레임워크가 될 수 있습니다. “얼마나 열리면 지나치게 열린 것인가” 포스트는 정확히 이 생각에서 온 글입니다. 또한 당신의 프로젝트에 사람들이 더 모집되는 것을 긍적적으로 봐야하는지 설명합니다. 프로젝트에 노력을 쓸 수 있는 양이 늘어난다면 프로젝트 필요에 더 많은 노력을 사용할 수 있습니다. 이렇게 참여를 늘린다는 점에서 행동 강령과 기여자 가이드라인이 더 중요해집니다. 장기적으로 참여할 사람을 찾고 노력할 사람을 유지하는게 가능하게 됩니다.

이 관점 자체로는 어떤 문제도 해결하지 않습니다. 하지만 이 원칙은 정말 좋은 사례 연구와 함께 묶여 있으며 공유 자원에 대한 연구로 어떻게 공동체 자원을 지속 가능하게 관리하는지 심도있게 다루고 있습니다.

좀 더 세부적으로 다루자면 일반적인 오픈 온라인 프로젝트를 한다는 맥락에서 몇 가지 고려해야 할 점을 제안하고 있습니다.

먼저 오픈소스 프로젝트에 사용할 노력이 잠식되지 않도록 지켜내야 합니다. 프로젝트가 성공하려면 기여 잠재력이 가용 노력에 얼마나 영향을 주는지 평가해야 합니다. 이런 평가가 기술적 기여에는 이미 포함되어 있겠지만 (“기술적인 빚”이란 관점에서), 버그 리포트와 기술 제안에서도 고려되어야 합니다. (물론 프로젝트 대다수는 이런 점을 이미 고려하고 있습니다.)

둘째, 지속적으로 유지보수 할 필요가 있는 부분(코드, 문서, 설치 등)에 들어가는 비용도 노력이 들어가는 부분으로 계산해야 합니다. 새 기능을 기여하면서 이런 유지보수가 필요한 부분을 간과하는 경우가 있으므로 세심히 살펴야 합니다. 새 기여자가 계속 도움을 주려고 하나요? 유지보수하기 위한 노력을 들여 헌신하려고 하나요? 그렇지 않다면 이런 유지보수에 노력을 더하게 되는 기여는 파트너십 등을 통해서 미뤄야 합니다.

셋째로, 새 기여자를 교육하고 육성하는 방식은 장기적인 관점에서 가용 노력을 늘린다는 점을 염두해야 합니다. 하지만 기여자의 심리는 까다롭고 교육하고 육성한다고 해서 계속 이 프로젝트에 남아 있으리라는 예측은 그렇게 간단하지 않습니다. Python 코어 멘토 프로그램처럼 어떤 프로젝트는 뛰어난 인큐베이터를 갖고 있습니다. 이런 곳은 자신들의 노력을 새로운 기여자를 찾는 일에 사용하는데 관심있는 사람들이 운영합니다. 제가 생각하기에는 친근한 환경을 만들고 기여하고 싶은 사람들에게 갖는 기대감이 어떤 방향인지 잘 알려줄 수 있다면 새로운 기여자에게 도움이 될거라고 봅니다. 우리 유지보수 노력에 기여하고 싶은 사람에게 어떻게 하면 새로운 기능도 추가하고 버그도 개선할 수 있는지 기쁘게 알려줄 수 있다는 그런 마음가짐 말이죠. 장기적으로 보면 건강한 공동체가 즉, 건강한 프로젝트라고 할 수 있습니다.

넷째로, 모든 또는 대다수의 자원 제공자가 의사 결정에 참여하도록 허용하는 흥미로운 구조의 영향을 받습니다. 이 부분은 좀 더 세세하게 살펴봐야 하긴 합니다. 하지만 대략적으로 설명하면 프로젝트에서 어느 정도 수준의 투자와 기여가 어떤 실효적 수준에서 정책이나 원칙 수립 단계의 보상을 받게 되는지 정할 필요가 있습니다.

다섯째로, 자금 제공자의 기대치를 설정하고 투자를 얻는 과정에서 프로젝트의 성숙도를 측정하는 기준을 정의하는 일이 도움이 될 수 있습니다. 제 경험에 따르면 대부분의 자금 제공자는 _프로젝트 지속 가능성_를 도식화 하는 일을 가장 우선시 합니다. 위에서 얘기한 디자인 원칙(그리고 공유 자원에서 나온 사례 연구)을 프로젝트 성숙도의 기반으로 삼는다면 지속 가능성을 판단할 수 있게 됩니다. 프로젝트의 자금 지원 제안을 작성한다고 가정하면 어느 디자인 원칙과 연계된 부분을 향상하려고 하며 어떻게 지속 가능 프레임워크와 연결되는지 적용할 수 있습니다. 예를 들어 “지금 당장 새로운 기여자가 참여하는데 있어 우리 역량을 걱정하고 있습니다. 또한 회사로부터 큰 규모의 기여를 하겠다는 제안을 받고 있습니다. 저희는 프로젝트 관리 방식을 구축하고 기여자를 위한 안내를 향상해 새 기여자와 투자자에게 어떤 수준의 투자와 기여를 기대하고 있는지 명확하게 설명하려고 합니다.” 식으로 설명한다면 자금 제공자는 이런 설명에 만족할겁니다.

“노력”이라는 공유 자원 프레임워크가 오픈소스 프로젝트와 오픈 온라인 프로젝트에 일반적으로 정말 잘 맞나요?

좋은 질문입니다. 저는 공유 자원에 대해 해박하지 않고 앞으로도 읽어야 할 것이 정말 많습니다. 공유 자원 프레임워크와 부합하지 않는 부분도 보여서 좀 더 다듬어야 하는게 사실입니다. 하지만 오픈소스 프로젝트가 동작하는 방식과 잘 맞아 떨어진다는 게 제 직감입니다. 공유 자원과 같은 개념적인 틀은 제 관점을 다시 생각하게 하고 더 나은 그림에 맞춰볼 수 있게 하며 문제를 해결하는데 다른 결론에 닿게 할 지도 모르겠습니다. 이 예제는 앞서 언급했던 “얼마나 열리면 지나치게 열린 것인가” 포스트에서 찾아볼 수 있습니다.

제가 정말로 시도하고 싶은 일은 오픈 소스 프로젝트의 사례 연구에 참여하여 오픈소스 프로젝트가 실제 삶에서 어떻게 작동하는지 연구하며 이 프레임워크에 부합하는지, 혹은 부합하지 않는지 확인하는 일입니다. 몇 년 내로 안식년을 가지게 되는데 그 기회가 될지 모르겠습니다.

이 프레임워크에서 가장 좋아하는 점 하나는 오픈소스 프로젝트에서 자유 오픈소스 소프트웨어에서 큰 가치로 여겼던, 흐릿해진 목표와 분리해서 생각할 수 있게 되었다는 점입니다. “우리는 행복한 대가족이고 모든 부분이 함께 동작합니다!” 라는 구호는 메인테이너의 삶이 열린 기여에 의해 망가지는 상황을 마주하면서 희미해졌습니다. 오픈소스 프로젝트는 오늘날 세계를 구성하는 커다란 부분을 실제로 운영하고 있으며 지속 가능성을 포함한 어떤 관점에서도 공식적이지 않은 방식으로 운영했던 과거의 접근 방식으로는 더 이상 지속할 수 없다고 한 Fernado Perez의 이야기와 맥락이 닿습니다. 우리는 지속 가능한 오픈 프로젝트를 목표로 더 책임감을 갖고 더 현실적이고 냉철한 프레임워크를 만든다면 돈이 있는 투자자(그 도구를 사용하는 기술 회사나 학계 공동체)가 지속 가능성을 만드는 일을 도울 수 있을 겁니다. 이런 지속성 문제를 어떻게 해결할지 고민하는 것은 (예를 들어) 구글이 해야 할 일이 아니라 우리가 고민해서 그들에게 어떻게 우리를 도와야 하는지 이야기하고 그들이 우리와 함께 할 때 일을 해쳐나가는 것이 우리가 해야 할 일입니다. 그러나 지금 당장 중요한 프로젝트 대부분은 이런 접근이 어렵습니다. 그리고 어떤 경우도 간단하게 해결할 수 있는 문제가 아닙니다. 하지만 공유 자원이 이런 문제를 해결할 수 있는 접근법이 될 수 있습니다. 함께 생각해봅시다!

개인적으로 제가 참여했던 여러 오픈소스 프로젝트에서 어떤 노력을 했는지 다시 살펴보게 되어 흥미로웠습니다. 그 노력 중 하나도 지속적이지도, 지속되지도 않았습니다. 🙂 또한 그렇게 참여했던 프로젝트에서 Ostrom의 디자인 원칙에 맞지 않은 부분도 볼 수 있었습니다. 이 이야기는 다른 포스트에서 이어가도록 하겠습니다.

— 타이투스

특별히 Cameron Neylon과 Michael Nielsen에게 감사합니다. 이들은 Elinor Ostrom의 업적을 몇년 전에 알려줬습니다. Nadia Eghbal의 설명에도 감사를 전합니다. 주제에 대한 설명이 이 글의 재료가 되었고 앞으로도 같이 고민하며 전진했으면 합니다.

이 글을 쓰며 많은 이름을 언급했는데 Luiz Irber, Katy Huff, Katie Mack, Cory Doctorow, Jake VanderPlas, Tracy Teal, Fernando Perez, Michael Crusoe와 Matthew Turk에게 감사를 전합니다. #scifoo18과 #gccbosc에서도 같은 주제로 대화를 할 수 있었기에 SciFoo와 BOSC에도 감사합니다.

Buck Shlegeris의 My advice on studying algorithms를 번역했다.


알고리즘 학습에 대한 조언

소프트웨어공학 면접에서는 화이트보드 알고리즘 질문을 종종 냅니다. 이런 질문을 어떻게 공부해야 하는지 조언을 하려고 합니다. (저는 구글과 애플을 포함한 수많은 화이트보드 면접을 통과했습니다. 그리고 프로그래머가 이런 알고리즘 면접을 준비하도록 돕는 일이 제 직업의 일부입니다. 게다가 다양한 분야의 개발자를 대상으로 200회 이상의 기술 면접을 치뤘습니다.)

이 글은 Triplebyte이 아닌 제 자신으로서 쓰는 글입니다.

알고리즘 외에도 면접에 관한 여러 주제가 있습니다. 이런 주제는 Triplebyte의 포스트에서 잘 다루고 있습니다. 이 글에서 중요하게 다루려고 하는 내용은 Triplebyte의 포스트에서 2번 항목에 해당합니다.

배경: 왜 회사는 알고리즘 문제를 낼까요?

실생활에서 프로그래머가 이진트리 검색이나 그래프 탐색 알고리즘을 구현하는 시간은 거의 존재하지 않습니다. 그런데 왜 회사는 알고리즘에 대해 많은 질문을 낼까요?

이 질문을 존 왓슨과 코난 도일의 관점으로 해석할 수 있습니다. “회사가 알고리즘 문제를 내는 것이 왜 유용한가?”, 그리고 “어떤 회사가 알고리즘 질문을 하는 실제 일반적 원리가 무엇인가?”가 그 관점입니다.

먼저 알고리즘을 물어보는 이유를 설명하려고 합니다. 그리고 더 나아가 이 유행에 대한 냉소적인 입장에서 설명합니다.

먼저 직업 프로그래머 대다수가 아주 기초적인 일을 수행하지 못합니다. 예를 들어 고객 객체 목록이 있고 각 고객 객체에 구입 객체 배열이 존재합니다. 지난 주에 가장 많이 구입한 고객 다섯 명의 이름을 찾으려고 합니다. 제 예상에는 직업 프로그래머의 50%가 이 문제를 30분 이내에 풀지 못합니다. 이런 사람들을 실수로라도 채용하고 싶지 않을겁니다.

조금 덜 비관적으로 가정해봅시다. 프로그래밍 일을 위해 누군가 면접을 볼 때 어렵고 혼란스러운 문제를 잘 풀어낼 수 있는지 알아내려고 할겁니다. 모든 세세한 내용을 머리에 담고 있어야 풀 수 있는 것을 말이죠. 실생활에서 혼란스럽고 복잡한 문제가 존재하는 이유는 그 프로젝트를 몇 주 동안 봐야 할 만큼 큰 규모고 소프트웨어의 여러 부분을 동시에 고려해야 하기 때문입니다. 하지만 면접은 일반적으로 그렇게 깊은 프로그래밍 문제를 다룰 만큼 시간이 넉넉하지 않습니다. 그래서 규모가 크기 때문에 복잡한 문제를 물어보는 것보다 짧고 복잡한 질문을 물어보는 것입니다.

그럼 어떻게 복잡하지만 쉽게 설명할 수 있는 짧은 코딩 문제를 낼 수 있을까요? 그런 관점으로 생각해보면 제 생각에 여기서는 알고리즘이 좋은 선택입니다. 알고리즘은 컴퓨터 과학에서 대부분의 소프트웨어 엔지니어가 알고 있는 복잡한 분야며 쉽게 설명할 수 있고 구현하기 힘든 문제가 많이 존재합니다.

여기 조금 냉소적인 설명이 따라옵니다.

면접 프로세스는 이상할 정도로 끈적합니다. 엔지니어링팀은 팀의 기술 면접을 통과한 사람으로만 구성되어 있습니다. 그래서 모두가 면접이 옳은 방식이라고 믿게 되고 면접 과정이 소프트웨어 엔지니어링 능력을 측정하는데 매우 정확하다고 생각하게 됩니다. 그래서 회사에 알고리즘 면접 문화가 생기고 나면 그 이후로 바꾸기가 어려워집니다.

또한 모두가 알듯 구글은 10년 전에 놀라운 팀이 있었습니다. (지금은 그 당시보다 적습니다.) 그 당시에 구글은 알고리즘 면접 질문을 했습니다. 대부분의 회사가 자신들이 구글이 아니라는 점에 조금 불안했는지 (알다시피 최고의 지원자는 구글에 다 잃었으니까요), 이 회사도 구글 면접 과정을 따라하기 시작했습니다.

최악의 경우로 보면 알고리즘 면접은 기괴하고 못살게 구는 절차로 바뀌어 버립니다. 가끔 회사에서 무작위 난제를 내는 것이 위대한 지원자를 찾는 비밀 병기라고 단단히 착각해버려서 그런 생각을 바꾸는 일이 불가능한 경우가 있습니다.

종합해서 말하자면, 저는 이런 전통적이고 어려운 알고리즘 문제를 면접에서 안냈으면 합니다. 최악으로는 알고리즘 문제가 극단적으로 나쁜 면접 질문이 될겁니다. 제가 특히 싫어하는 질문은 이런 상황을 위해 따로 적어뒀습니다. 알고리즘 질문은 난제나 여러 통찰을 요구하는 경우에 특히 나쁜 질문입니다. (만약 알고리즘 면접 과정을 만들고 싶다면 언제든지 이메일을 보내시기 바랍니다. 이런 문제가 없는 질문을 하는 방법에 대해 더 자세한 의견을 드릴 수 있습니다.)

어떻게 공부하나요?

추가: Haseeb Qureshi의 블로그 포스트를 읽었는데 이 글에 동의합니다. 그리고 이 글이 좀 더 상세하다고 생각합니다. “일반 학습 전략”과 “프로그래밍 면접 공부 가이드” 부분을 읽으세요.

저는 알고리즘 문제에 답하려면 두 가지 다른 기술이 필요로 하다고 생각합니다. 첫째로 모든 대표적 알고리즘과 자료 구조 문제를 알아야 합니다. 둘째로는 부담되는 상황에서 알고리즘 논리를 화이트보드에 빠르게 풀어나갈 수 있어야 합니다. 이 두 주제를 나눠서 얘기해보려고 합니다.

표준 알고리즘 자료

회사의 시험을 준비하는데 있어 습득해야 하는 거의 필수적인 핵심 알고리즘 모음이 있습니다. 회사에서는 이런 목록에 들어있지 않은 질문은 하지 않으려고 합니다. 좋은 프로그래머 다수가 이 목록에 없는 질문에 대해서는 답을 모르기 때문이며 그래서 목록 외 질문을 냈다가 회사는 좋은 사람을 뽑는데 실패하게 됩니다.

알아야 할 자료구조는 다음과 같습니다.

  • list 구조: 배열, 동적 배열, 링크드 리스트(linked list)
  • set과 map 구조: 해시맵, 이진 검색 트리, 힙

여기서 언급한 자료구조는 필수 메소드가 어떻게 구현되어 있는지, 런타임은 어떻게 동작하는지 알아야 합니다. (list의 필수 메소드는 set, get, pushAtEnd, popAtEnd, insertByIndex, removeByIndex, set의 필수 메소드는 insert, remove, contains? 입니다.) 자료구조 구현을 어떻게 사용하는지 알아야 합니다. 예를 들면 getNearestElementTo(x) 메소드를 구현할 수 있어야 합니다. 이 메소드 즉, x와 가장 가까운 값을 찾는 구현을 하려면 이진 검색트리를 알아야 합니다.

이 문제를 해결하는데 이런 내용을 알아야 합니다.

  • 이진 검색트리 구현에 균형을 맞추는 코드가 필요하다는 점을 알아야 하지만 세부 내용은 몰라도 괜찮습니다. (선택 자료: 자기 균형 BST을 어떻게 구현하는지 빠르게 배우고 싶다면 이 트립을 참조하세요. 어떻게 레드블랙 트리가 동작하는지 이해하고 싶다면 좌편향 레드블랙 트리 또는 2-3-4 트리를 배우세요.)
  • 큐를 스택 두 개로 구현할 수 있다는 점을 알아야 합니다.

다음 알고리즘은 어떻게 구현하는지 알아야 합니다.

  • 그래프 알고리즘: 너비 우선 탐색(breadth first search), 깊이 우선 탐색(depth first search), 다익스트라 알고리즘 (dikstra’s algorithm)
  • 빠른 정렬 알고리즘 하나. 병합 정렬(mergesort) 또는 퀵 정렬(quicksort)
  • 배열에서 수행하는 이진 검색. 이 알고리즘은 제대로 작성하기 매우 까다롭고 대략적으로 알고리즘을 이해하고 있더라도 코드로 작성해볼 가치가 있습니다.

그리고 Big O 표기법도 대충이라도 편하게 사용할 수 있어야 합니다.

이 모든 내용을 어떻게 배워야 하나요? 제가 가장 좋아하는 자료는 Skiena의 Algorithm Design Manual입니다. 위에서 언급한 모든 내용을 챕터 26에서 다룹니다. 이 책을 좋아하는 이유는 저술 방식이 참여를 유도하고 각 부분에서 중요한 자료에 잘 초점을 맞추고 다루는데 이런 방식은 중요하다고 생각합니다. 이 책은 인터넷에서 무료로 찾을 수 있습니다. 이 책의 단점은 예제가 C로 작성되었다는 점인데 C를 읽지 못하는 개발자라면 접근성이 좋지 않습니다. 저는 챕터 16, 12는 꼭 읽어야 한다고 생각합니다. 이 부분은 인터뷰에서 나올 가능성이 극히 낮지만 필요 없다고 생각하는 부분이 진정 핵심적인 부분을 잘 보강한다고 생각하기 때문입니다.

이런 부분에 대략적인 설명을 보고 싶다면 Craking the Coding InterviewInterviewCake.com의 설명이 좋습니다.

저는 Skiena의 책이 극단적일 정도로 건조하고 딱딱한 유명 CLRS 교재보다 낫다고 생각합니다.

그래프 알고리즘에 대한 글을 쓴 적이 있는데 참고가 되었으면 좋겠습니다.

표준 알고리즘 기술

여기까지 인터뷰에 핵심적으로 필요한 부분을 확인했습니다. 이제 다른 종류의 프로그래밍 기술로 무엇을 테스트하는지 확인하고 제가 선호하는 학습 자료도 함께 확인합니다.

이런 기술에 있어서는 Cracking the Coding Interview(이하 CtCI) 책이 가장 유용합니다. 이 책에 대해서 작성한 글입니다.

알고리즘 면접 문제 중 가장 일반적이고 중점적으로 다뤄지는 요소는 다음과 같습니다.

  • 동적 프로그래밍: Skiena 책의 챕터 8 또는 CtCI에서 이 주제의 챕터에서 학습합니다.
  • 재귀: CtCI에 이 주제에 대한 멋진 챕터가 있습니다.
  • 유명 자료 구조를 반복(iterating)하는 문제: CtCI에서 각각의 자료 구조를 다룰 때 이 문제도 함께 다룹니다. 예를 들어 BST에서는 CtCI 트리 챕터를 참고할 수 있습니다.
  • 문제 해결을 위해 빠른 자료 구조를 조합하기: 이런 문제에 대한 예제는 이 글에서 확인할 수 있습니다.

CtCI에서 살펴볼 수 있는 많은 문제를 살펴보는 방법이 제가 드리는 가장 주요한 조언입니다. 이 문제에서 가장 중요하다고 생각하는 부분은 위 목록과 같습니다.

이런 부류의 문제를 어떻게 학습하는지에 대해 일반적인 생각은 이렇습니다. 제 생각엔 답안을 “훔쳐보는” 일은 그래도 괜찮다고 생각합니다. 면접 문제 푸는 일을 내던지고 아예 포기하는 것보다는 문제 풀다가 막히면 해결책을 보는 방법이 차라리 나은 접근이기 때문입니다.

알고리즘 면접에서 성공하기 위한 비기술적 측면

이런 질문은 실제로 부담되는 환경에서 답하는 연습을 해야 합니다. 진짜 사람이 질문하는 상황에서 말이죠. 이 부분에 대해서는 Triplebyte의 블로그 포스트에서 다루고 있고 2, 3, 7번을 읽어보기 바랍니다.

알고리즘과 자료구조에 대해 더 배우기

취업 목적 학습을 넘어서 본인을 위해 즐겁게 알고리즘과 자료구조를 배우고 싶다고 가정해봅시다. 어떻게 더 배워야 할까요?

가장 쉬운 방법은 위에서 필수로 배워야 한다고 한 핵심 자료 구조에 포함되지 않는 자료 구조 중 상대적으로 간단한 자료 구조를 학습하는 방법입니다. 트립, 스킵 리스트, 증강 이진검색트리, 서로소 집합 자료구조가 그 예로 모두 쉽게 이해할 수 있는 편이며 모두 멋진 알고리즘입니다.

자료구조 주제 중 이해하기 어렵지만 노력해서 이해하면 좋은 주제도 있습니다. 예를 들어 이 슬라이드에서는 이진트리와 2-3-4 트리를 설명합니다.

흥미로운 자료 구조를 배울 수 있는, 제가 좋아하는 자료는 다음과 같습니다.

  • Skiena의 챕터 12와 이후 챕터
  • 스탠포드의 멋진 강의인 CS166. 이 강의의 슬라이드는 멋지고 읽기 좋은 편입니다. 저는 여기서 다룬 문제가 즐거웠습니다. 자료구조와 더 놀고 싶다면 이 프로젝트 아이디어 핸드아웃을 추천합니다.
  • 저는 이런 작업처럼 그다지 어렵지 않은 자료 구조 문제를 아마추어 활동으로 재미삼아 한다는 점이 자랑스럽습니다. 이 문제를 풀기 위한 해결책으로 고급 자료구조에서 얻은 몇 아이디어를 적용했다는 점이 멋지지 않나 생각합니다.

자료

lazlojuly의 글 Node.js module.exports vs. exports을 번역했다.


node.js의 module.exports와 exports

(노트: 이 글은 Node.js 6.1.0 릴리즈 이후에 작성되었습니다.)

요약

  • module.exportsrequire() 함수를 사용했을 때 반환 받는 변수라고 생각해봅시다. 비어 있는 객체가 기본값이며 어떤 것으로도 자유롭게 변경할 수 있습니다.
  • exports 자체는 절대 반환되지 않습니다! exports는 단순히 module.exports를 참조하고 있습니다. 이 편리한 변수를 사용하면 모듈을 작성할 때 더 적은 양의 코드로 작성할 수 있습니다. 이 변수의 프로퍼티를 사용하는 방법도 안전하고 추천하는 방법입니다.
exports.method = function () { /* ... */ }
// vs.
module.exports.method = function () { /* ... */ }

간단한 모듈 예제

먼저 예제로 사용할 코드가 필요합니다. 간단한 계산기로 시작합니다.

// calculator.js

module.exports.add = (a, b) => a + b

다음처럼 사용할 수 있습니다.

// app-use-calculator.js

const calculator = require('./calculator.js')
console.log(calculator.add(2, 2) // 출력: 4

모듈 감싸기

Node.js는 require()로 모듈을 불러올 때 함수 래퍼(wrapper) 형태를 사용해 내부적으로 감싸서 호출합니다.

(function (exports, require, module, __filename, __dirname) {
  // calculator.js의 내용은 여기에 추가해 실행합니다.
  module.exports.add = (a, b) => a + b
});

module 객체

변수 **“module”**은 객체로 현재 모듈을 나타냅니다. 이 변수는 각 모듈에 지역적이며 비공개(private) 입니다. (모듈 코드에서만 접근할 수 있습니다.)

// calcualtor-printed.js

module.exports.add = (a, b) => a + b
console.log(module)

// 이 모듈을 require('./calculator-printed.js')로 호출하면 다음과 같은 결과를 볼 수 있습니다.
//
// Module {
//   id: '/Users/laz/repos/module-exports/calculator-printed.js',
//   exports: { add: [Function] },
//   parent: 
//     Module { ... }
//   filename: '/Users/laz/repos/module-exports/calculator-printed.js',
//   loaded: false,
//   children: [],
//   paths: [ ... ]
// }

module.exports

  • 객체 참조로 require() 호출을 하면 받는 값입니다.
  • Node.js에 의해 자동으로 생성됩니다.
  • 일반 JavaScript 객체를 참조합니다.
  • 또한 기본값은 비어 있습니다. (앞서 코드에서는 add() 메소드를 추가했습니다.)

다음 두 방법으로 module.exports를 사용합니다.

  1. 공개(public) 메소드를 붙여 사용합니다. (앞서 작성한 예제가 이 방법입니다.)
  2. 직접 작성한 객체나 함수로 대체하는 방식을 사용합니다.

왜 대체하는 방식으로 사용할까요? 대체하는 방식으로 사용하면 다른 클래스를 임의적으로 수정해 반환하는 것도 가능합니다. 다음 ES2015 예제를 보겠습니다.

// calculator-base.js

module.exports = class Calculator {
  add(a, b) {
    return a + b
  }
  substract(a, b) {
    return a - b
  }
}

이 calculator-base 예제에서는 클래스를 내보냈습니다. 다음 예제에서는 “Calcaulator” 클래스를 확장한 후, 클래스의 개체를 내보냅니다.

// calculator-advanced.js

const Calculator = require('./calculator-base.js')

class AdvancedCalculator extends Calculator {
  multiply(a, b) {
    return a * b
  }
  devide(a, b) {
    return a / b
  }
}

module.exports = new AdvancedCalculator()
// app-use-advanced-calculator.js

const calculator = require('calculator-advanced.js')

console.log(calculator.add(2, 2))      // 출력: 4
console.log(calculator.multiply(3, 3)) // 출력: 9

exports 별칭(alias)

  • exports는 편의 변수로 모듈 작성자가 코드를 덜 작성하도록 돕습니다.
  • 이 변수의 프로퍼티를 사용하는 방식은 안전하고 추천하는 방법입니다. (예를 들면 exports.add = function () { /* ... */ } 식으로)
  • exportsrequire() 함수에서 반환되지 않습니다. (module.exports는 반환하지만요!)

좋은 예제와 나쁜 예제를 확인합니다.

// calculator-exports-exmaples.js

// 좋음
module.exports = {
  add(a, b) { return a + b }
}

// 좋음
module.exports.subtract = (a, b) => a - b

// 가능함
exports = module.exports

// 위에서 작성한 코드보다 간단하고 짧은 코드
exports.multiply = (a, b) => a * b

// 나쁨, exports는 바깥으로 전달되지 않음
exports = {
  divide(a, b) { return a / b }
}

노트: module.exports를 함수나 객체로 대체하는 경우는 일반적인 접근법입니다. 이렇게 대체하면서도 여전히 exports 축약을 사용하고 싶다면 exports를 새로 대체할 객체를 가리키도록 설정해야 합니다. (위에서도 이런 방법을 사용헀습니다.)

exports = module.exports = {}
exports.method = function () { /* ... */ }

결론

변수명은 exports지만 실제로 내보내지 않는다는 사실은 좀 혼란스러울 수 있습니다. 막 node.js를 시작한 사람이라면 특히 그럴겁니다. 공식 문서에서도 이 부분이 조금 이상합니다.

exports와 module.exports의 관계가 마술처럼 느껴진다면 exports는 무시하고 module.exports만 사용하도록 지침을 드립니다.

제 경우엔 이런 코드가 마술이라고 생각하지 않습니다. 개발자라면 사용하는 플랫폼과 언어가 어떻게 동작하는지 깊게 이해하려는 노력을 항상 해야합니다. 이처럼 깊게 이해하는 과정에서 프로그래머는 값진 자신감과 지식을 얻으며 코드 품질, 시스템 구조와 생산성에 긍정적인 영향을 주게 됩니다.

제 글을 읽어주셔서 감사합니다. 의견이나 생각은 언제나 환영하니 덧글로 남겨주세요.

Stephan BoyerWhat are covariance and contravariance?을 번역한 글이다.


공변성과 반공변성은 무엇인가?

서브타이핑은 프로그래밍 언어 이론에서 까다로운 주제다. 공변성과 반공변성은 오해하기 쉬운 주제이기 때문에 까다롭다. 이 글에서는 이 용어를 설명하려고 한다.

이 글에서는 다음과 같은 표기법을 사용한다.

  • A <: BAB의 서브타입이라는 뜻이다.
  • A -> B는 함수 타입으로 함수의 인자 타입은 A며 반환 타입은 B라는 의미다.

동기부여 질문

다음과 같은 세 타입이 있다고 가정하자.

Greyhound <: Dog <: Animal

GreyhoundDog의 서브타입이고 DogAnimal의 서브타입이다. 서브타입은 일반적으로 추이적 관계(transitive)를 갖는다. 그래서 GreyhoundAnimal의 서브타입이라 할 수 있다.

여기서 질문이다. 다음 중 Dog -> Dog의 서브타입이 될 수 있는 경우는 어느 것일까?

  1. Greyhound -> Greyhound
  2. Greyhound -> Animal
  3. Animal -> Animal
  4. Animal -> Greyhound

이 질문에 어떻게 답할 수 있을까? Dog -> Dog 함수를 인자로 받는 f 함수를 살펴보자. 반환 타입에 대해서는 크게 생각하지 않는다. 구체적으로 적어보면 다음과 같다. f : (Dog -> Dog) -> String.

이제 f를 다른 함수인 g와 함께 호출해보자. g에는 위에서 나열했던 각각의 함수를 넣어서 어떤 일이 나타나는지 관찰한다.

g : Greyhound -> Greyhound로 가정하면 f(g)는 타입 안전(type safe)한가?

아니다. 함수 f는 인자 g를 사용하면서 Dog의 다른 서브타입, 예를 들면 GermanShepherd를 사용해서 호출할 수도 있기 때문이다.

g : Greyhound -> Animal로 가정하면 f(g)는 타입 안전한가?

아니다. 1과 동일한 이유다.

g : Animal -> Animal로 가정하면 f(g)는 타입 안전한가?

아니다. f에서 인자 g를 호출하면서 개가 어떻게 짖는지 그 반환 값을 얻으려고 할 수 있다. 하지만 모든 Animal이 짖는 것은 아니다.

g : Animal -> Greyhound로 가정하면 f(g)는 타입 안전한가?

그렇다. 이 경우는 안전하다. f 함수는 인자인 g를 호출할 때 어떤 종류의 Dog든 사용할 수 있다. 모든 DogAnimal이기 때문이다. 또한 반환값은 Dog로 가정할 수 있는데 모든 GreyhoundDog이기 때문이다.

무슨 일이 일어나고 있나요?

결과적으로 다음 경우에 안전하다.

(Animal -> Greyhound) <: (Dog -> Dog)

반환 타입은 간단하다. GreyhoundDog의 서브타입이다. 하지만 인자 타입은 반대다. AnimalDog의 수퍼타입이다!

이 독특한 동작 방식을 적당한 용어를 사용해서 설명한다. 함수 타입에서 반환 타입은 공변적(covariant) 이고, 인자 타입은 반공변적(contravariant) 이다. 반환 타입의 공변성은 A <: B(T -> A) <: (T -> B)로 적용된다는 뜻이다. (A<:의 좌측에, B는 우측에 남아 있다.) 인자 타입의 반공변성은 A <: B(B -> T) <: (A -> T)로 적용된다는 의미다. (AB가 위치를 바꾸게 된다.)

재미있는 사실 타입스크립트에서는 인자 타입이 이변적(bivariant), 즉 공변성과 반공변성을 동시에 지닌다. 뭔가 말이 안되는거 같겠지만 말이다. (TypeScript 2.6부터 --strictFunctionTypes 또는 --strict를 사용하면 이 문제를 해결할 수 있다.) Eiffel은 인자 타입을 반공변적이 아닌 공변적으로 잘못 구현했다.

다른 타입은?

또 다른 질문이다. List<Dog>List<Animal>의 서브타입이 될 수 있을까?

답변하기 좀 미묘하다. 만약 목록이 불변이면 맞다고 답할 수 있다. 하지만 가변적이라면 당연히 안전하지 않다!

이유를 생각해보자. 나는 List<Animal>이 필요한데 List<Dog>를 넘겨줬다고 해보자. 나는 당연히 List<Animal>을 갖고 있다고 생각하고 Cat을 집어넣었다. 이제 List<Dog>Cat이 들어있게 된다! 타입 시스템은 이런 동작을 허용하지 않을 것이다.

불변 목록의 타입이라면 타입 파라미터가 공변적일 수 있지만 가변 목록의 타입은 반드시 공변적이지도, 반공변적이지도 않아야(invariant) 한다.

재미있는 사실 자바에서의 배열은 가변적이면서도 공변적이다. 물론 부적절하다.


추가로, 원문의 덧글 중에 시각적으로 잘 설명하는 자료가 있었다.

Mike McQuaid의 Open Source Maintainers Owe You Nothing를 번역한 글이다. 이 번역은 CC-BY-NC-SA를 준수한다.


오픈소스 메인테이너는 당신에게 빚진 적 없다

이 포스트는 지난 10년간 오픈소스 커뮤니티에 참여한 경험과 8년간 Homebrew의 (현재 시점에서 누구보다도 가장 오래 참여한) 메인테이너로서 겪은 경험에 따라서 작성했다.

오픈소스 소프트웨어 메인테이너에게 번아웃은 큰 문제다. 이 문제는 피할 수 있다. 메인테이너는 오픈소스 프로젝트를 즐겁고 건강하게, 오랜 기간동안 생산적으로 참여할 수 있다. 어떻게 가능할까? 바로 다른 메인테이너, 기여자, 또는 소프트웨어를 사용하는 사용자에게 의무감을 갖지 않는 것이다. 이 프로젝트가 개인적으로 이득을 보고 있는 상황도 상관 없다. (예를 들어 자기 홍보 가치가 있다거나 기부를 받고 있는 상황에도.)

메인테이너가 의무를 갖지 않는다는 근거를 명시한 경우가 있는가? 실제로 존재한다. 오픈소스 라이센스 자체에 말이다. 먼저 깃헙에서 가장 인기있는 오픈소스 라이센스MIT 라이센스를 보자.

이 소프트웨어는 상품성, 특정 목적 적합성, 그리고 비침해에 대한 보증을 포함한 어떠한 형태의 보증도 명시적이나 묵시적으로 설정되지 않은 “있는 그대로의” 상태로 제공된다. 소프트웨어를 개발한 프로그래머나 저작권자는 어떠한 경우에도 소프트웨어나 소프트웨어의 사용 등의 행위와 관련하여 일어나는 어떤 요구사항이나 손해 및 기타 책임에 대해 계약상, 불법행위 또는 기타 이유로 인한 책임을 지지 않는다.

이 난해한 법률 용어를 평범한 문장으로 살펴보자. (참고로 나는 변호사가 아니다):

  • 지금 제공하는 소프트웨어는 전부 메인테이너가 제공한다는 점에 동의한 것이다 (즉, 버그를 포함한 모든 것을 뜻한다)
  • 메인테이너는 모든 사용자와 사용 사례에서 동작하는 것을 보장하지 않는다 (문서화되어 있더라도 말이다)
  • 메인테이너는 어떤 소프트웨어 사용에서든 발생하는 어떤 문제에도 법적인 책임이 없다. (당신이 입은 피해를 고치기 위해 돈을 지불하는 경우에도)
  • 소프트웨어를 어떤 방식으로든 사용하려면 위 내용에 대해 필수적으로 동의해야 한다.

이 내용은 단순히 MIT 라이센스만 그런 것이 아니다. BSD 2-Clause, GPLv3, MPL v2.0, Apache v2.0, Unlicense 그리고 거의 대부분의 다른 오픈소스 라이센스에도 법적 책임에 대한 한계를 두고 어떤 보증도 명시적으로 제공하지 않는다. 요약하면, 어떤 오픈소스 소프트웨어를 사용했고 이 소프트웨어가 실서버의 모든 데이터를 지워버렸다고 해도 어느 누구에게도 소송을 걸 수 없다. 소프트웨어를 사용하는 것으로 어떤 부정적 결과도 개인의 책임이라고 동의했기 때문이다.

하지만 실제로는 어떤 문제가 발생하면 메인테이너는 문제를 빠르게 해결해주기도 하고 마치 회사처럼 사과를 하기도 한다. 이 부분이 번아웃의 가장 큰 원인이다. 대다수의 오픈소스 소프트웨어는 자유 시간의 자원 봉사로 개발된다. 하지만 오픈소스 메인테이너와 사용자가 그 관계를 회사/고객 같은 관계로 생각한다면 이는 지속하기 어렵다.

대다수의 메인테이너가 오픈소스 소프트웨어에 참여하기 시작한 이유는 재미도 있고 자신의 문제를 해결하고 있기 때문이다. 재미 대신 말도 안되는 의무만 지속되는 상황에서, 돈도 못버는데 지나치게 시간을 쏟고 재미 없는 일만 증가한다면 그건 메인테이너를 갈아넣는 일이다. 이런 상황에서 논쟁이 있을 만한 결정을 내리거나 그런 과정에서 모욕적인 얘기를 들으면 그 친구나 가족은 분명 “오픈 소스가 이런 고통을 감내할 만큼 가치있는 일이냐”고 질문할 것이다.

이 문제를 어떻게 고칠까? 오픈소스 생태계에 있는 모두가 오픈소스의 법적 현실을 수용하는 것이 답이다. 이 답을 실제로 적용하면 이런 의미다.

  • 메인테이너: 프로젝트에서 더이상 즐거움을 느끼지 못한다면 더 이상 하지 않는다. 사용자가 프로젝트에 대해 이야기할 때 무례하게 군다면 접근을 막아버리고 그 사람들이 요청하는 것을 하지 않는다. 버그를 만들었다고 미안해 하지 않는다. 모든 소프트웨어에는 결함이 있기 마련이다. 세상에 무언가를 내놓고도 어떤 댓가도 바라지 않았다는 점에서 스스로를 자랑스러워 하자.
  • 기여자: 결정은 메인테이너가 내리도록 하고 모든 관련 기여 문서를 읽었는지 확인한다. 메인테이너는 프로젝트를 운영하는 사람이고 궁극적으로 그들의 단어가 남게 된다. 어떤 식으로 프로젝트가 동작하는지 (사실, 무엇이든지) 당신에게 가르치기 위해서 메인테이너가 존재하는 것이 아니다.
  • 사용자: 프로젝트에 이슈를 생성하거나, 풀리퀘스트를 열거나 덧글을 작성할 때는 사람들이 자신들의 시간을 써서 소프트웨어를 만들고 무료로 사용하게 했다는 점에 감사하는 마음을 담아야 한다. 불만이든 어떻게 할 수 없는 부정적인 것들은 본인의 몫이다. (적어도 보이는 곳에서 말하지 말아야 한다.) 본인 스스로에게 더 시간을 들여 문제를 풀지 않으면서 다른 사람에게 물어서 해결하려고만 한다면 다른 사람이 그 문제를 해결해줄 거라고 기대하지 말아라. 즉, 어떤 도움이든 물어보기 전에 모든 문서를 읽어보고 스스로 문제를 해결하려고 노력하라는 의미다.

이런 역할을 모두 지킨다면, 메인테이너가 실제로 있지도 않은 의무에 치여서 프로젝트가 접히는 경우가 더 적어질 것이다. 오픈소스 생태계에서 본인이 어떤 역할로 참여하고 있는지 잘 인지한다면 더 행복한 메인테이너, 기꺼이 돕는 기여자와 감사하는 사용자가 될 수 있을 것이다.

최근 emacs에 대한 유튜브를 보는데 전혀 IT쪽 일을 하지 않는 사람도 org-mode 때문에 emacs를 사용한다는 얘기를 듣고 문서를 찾아보게 되었다.

emacs는 아예 처음 사용해보는데 이 문서에 emacs 기능도 간략하게 설명하고 있어서 org-mode의 기능을 살펴보는데 불편함이 없었다. 각각 기능도 강력하고 일반 텍스트를 이렇게 멋지게 조작 가능한게 놀랍다. markdown이나 html로 내보내는 기능도 멋지다. 내보내는 포맷은 플러그인으로 추가도 가능하다. 익숙해지면 정말 유용할 것 같다.

이 번역 문서는 GNU 자유 문서 사용 허가서 버전 1.3 또는 그 이후의 버전이 적용된다. 코드 예제는 GNU GPL 버전 3 또는 이후 버전이 적용된다. 원본은 Org mode beginning at the basics 페이지에서 찾을 수 있다.


Org-mode 기초부터 시작하기

Org-mode는 공식 웹페이지에서 설명하는 것과 같이 노트와 할 일 목록을 관리하고 프로젝트 계획을 작성하거나 저작하는데 사용할 수 있는, 빠르고 효과적인 플레인 텍스트 시스템입니다. Emacs 22.2와 XEmacs 22.1부터 지원하기 시작했습니다. 아래 내용은 간단한 튜토리얼로 Emacs와 org-mode를 사용하는 방법을 설명합니다.

Emacs에 대해 알아야 할, 정말 최소한의 지식

무엇이든지 하고싶은 일을 하기 위해서 Emacs에서 필수적으로 알아야 할 최소 지식은 다른 애플리케이션에서 알아야 하는 양보다 많습니다. 하지만 이런 비교는 일반적인 장난감과 레고를 비교하는 것과 같습니다. 레고는 시작하기 어렵지만 (작은 플라스틱 조각이 가득한 상자에서 시작합니다) 장기적으로 봤을 때는 더 많은 것을 만들 수 있습니다.

Emacs는 단축키가 풍부합니다. 처음 시작할 때는 이런 특징에 짜증날지 모르지만 시간이 흐를수록 마우스를 점점 적게 사용하게 되고 실제로 더 빠르게 작업을 할 수 있게 될겁니다.

기본적인 모든 동작은 마우스를 사용할 수 있습니다. 파일을 열거나 저장하는 등의 작업은 메뉴에서 모두 가능한 동작입니다. 하지만 키보드에서 손을 때고 마우스를 잡는 방법보다 단축키를 사용하는 방식이 훨씬 빠르다는 것을 알게될 것입니다.

Emacs는 이중 단축키를 많이 사용합니다. 대부분의 애플리케이션처럼 Alt-F 나 Alt-S 대신에 Control-X Control-FControl-X Control-S 를 사용합니다. 처음에는 생산성에 반하는 것처럼 느껴질지 몰라도 금방 익숙해질 겁니다.

노트: 키 축약 표현

  • M – Alt (고대 키보드에서는 Meta 키였습니다.)
  • C – Control
  • S – Shift
  • C-x f – 이 표기는 Control x를 누른 후, 둘 다 손을 땐 다음에 f를 누른다는 의미입니다.

어느 버전의 Emacs를 사용해야 하나요?

어느 버전이든 다 똑같이 느껴진다면 XEmacs보다 Emacs를 선택하기 바랍니다. (이 말에 동의하지 못한다면 이미 이 문장을 넘겨도 될 만큼 알고 있다는 의미입니다.) 다음 링크가 도움이 될 겁니다.

리눅스에서는 패키지 매니저를 사용해서 Emacs를 설치합니다. 데비안이라면 다음처럼 설치합니다.

sudo apt-get install emacs

설정하기

Emacs를 시작할 때 가장 큰 고통은 바로 설정하기에 있습니다. 설정을 위한 메뉴도 없고 텍스트 파일을 수정해야 합니다. (메뉴가 있다고 얘기하긴 하는데 그건 그냥 순수한 사람들을 낚는 겁니다.) 설정 파일의 위치는 (심지어 이름까지도) 어떤 운영체제를 사용하느냐에 따라 다릅니다. 하지만 플랫폼에 상관없이 그 내용은 거의 일치합니다. 대다수 사람들은 동일한 설정 파일을 다른 운영체제서 사용합니다. 장기적으로 보면 최고의 선택이나 마찬가지죠!

설정 파일의 위치는 다음과 같습니다.

org-mode 시작하기

이 챕터에서 사용하는 새로운 단축키는 다음과 같습니다.

  • C-x s – 문서 저장하기
  • C-x f – 문서 열기

첫 org-mode 문서

이제 첫 org-mode 문서를 시작하기 위해서 알아야 할 모든 지식을 습득했습니다. Emacs를 시작합니다. 완전히 새로 설치한 Emacs라면 Emacs의 스플래시 화면을 볼 수 있을 겁니다. 이 화면에는 Emacs 튜토리얼과 여러 문서를 볼 수 있는 바로가기가 있지만 지금은 건너뛰고 다음으로 넘어갑니다.

C-x f 단축키를 사용해서 새 문서를 시작합니다. 이 단축키는 문서를 열기 위한 기능을 제공합니다. (Emacs에서는 버퍼라고 말합니다.) 여기에 *1.org*라고 입력합니다. 이제 새로운 빈 문서가 화면에 나타납니다.

이 문서를 저장하기 위해서 저장 아이콘을 누르거나 C-x s 단축키를 누르고 1.org를 입력합니다.

Emacs는 org-mode 문서를 편집하려고 한다는 점을 아직 이해하지 못합니다. 현재 문서에서 org-mode를 활성화하려면 다음 명령을 입력합니다.

M-x org-mode

이 명령으로 현재 문서에서 org-mode를 활성화 할 수 있습니다.

Emacs가 org-mode 문서를 인식하도록 다음 내용을 문서 상단 에 추가합니다.

MY PROJECT -*- mode: org -*-

여기에 사용한건 뺄셈 기호며 밑줄이 아닙니다. MY PROJECT는 문서의 제목이며 마음대로 지정할 수 있습니다.

이 한 줄의 내용이 이 문서에서 org-mode를 활성화합니다. 이 내용을 넣으면 파일 확장자와 상관없이 org-mode가 동작합니다.

org 파일에서 항상 org-mode를 활성화하려면 Emacs 설정을 수정해야 합니다. 수정하는 방법은 다음 섹션에서 설명합니다.

Emacs 설정 처음으로 수정하기

Emacs 설정 파일을 엽니다. Emacs에서 이 파일을 열기 위해서 C-x f 를 사용합니다. (설정 파일의 경로는 설정을 참고합니다.) 그리고 다음 내용을 추가합니다.

;; -*- mode: elisp -*-

;;스플래시 화면을 끔 (다시 켜려면 t를 0으로 변경)
(setq inhibit-splash-screen t)

;;문법 강조를 활성화
(global-font-lock-mode t)
(transient-mark-mode 1)

;;;;org-mode 설정
;;org-mode 활성화
(require 'org)
;;org-mode를 .org로 끝나는 파일에서 활성화
(add-to-list 'auto-mode-alist '("\\.org$" . org-mode))

Emacs를 재시작합니다.

노트: 앞에서 추가했던 mode 행은 1) Emacs의 설정에 지정한 org-mode 확장자와 다른 확장자를 사용하는 경우 (예로 myfile.txt) 2) auto-mode-alist 행을 설정에 추가하지 않은 경우에만 필요합니다.

목록과 노트 관리하기

이 장에서 사용하는 단축키입니다.

  • TAB / S-TAB – 접기/펴기
  • M-up/down – 제목행 위/아래로 이동하기
  • M-left/right – 제목행 수준 높이기/낮추기
  • M-RET – 새 제목행 추가하기
  • C-x s – 파일 저장하기
  • C-h t – Emacs 튜토리얼

이제 org-mode 문서를 사용할 수 있도록 설정한 Emacs가 있으니 이제 시작하기만 하면 됩니다. org-mode를 시작하는데 도움이 될 개요를 적는 것으로 시작합니다. 새 문서를 열고 (C-x b) 2.org를 입력한 다음에 아래 내용을 복사해서 붙여넣습니다.

#-*- mode: org -*-
#+STARTUP: showall

* org-mode 시작을 환영합니다
  Org-mode에 오신 것을 환영하고 감사드립니다. org에서 개요를 작성하기는 매우 간편합니다.
  그냥 텍스트거든요! 그저 입력하면 됩니다.

* 제목행은 하나 이상의 별 문자로 시작합니다.
  제목은 별 하나, 부제목은 별 두 개 방식으로 숫자를 늘려갑니다.

* 목록 작성하기
** 개요 이동하기
** 제목행 이동하기

파일을 2.org로 저장합니다. (C-x s) 저장하면 문법 강조가 켜져있어서 색상이 변경되는 것을 확인할 수 있습니다. Emacs가 org-mode에서 작업중인 것을 알고 있기 때문입니다.

이제 정말로 org-mode를 시작할 준비가 되었습니다!

목록 작업하기

목록은 브레인스토밍과 모든 항목을 관리하는데 뛰어난 방식입니다. 목록을 사용하면 기록을 하는 동안 큰 그림 그리기에 집중할 수 있도록 돕습니다.

가장 먼저 할 일은 접기입니다. 특히 문서가 길어질 때는 이 기능이 유용합니다. 예제 문서에서 첫 제목행인 이동합니다. (방향키를 사용하세요.) org-mode 시작을 환영합니다 에 커서를 위치한 후에 TAB 을 눌러봅니다. 그리고 S-TAB 도 사용해봅니다. Tab 은 현재 부분을 접거나 펼 수 있습니다. 시프트 키와 함께 누르면 전체 문서를 접고 펼 수 있습니다.

브레인스토밍의 기본적인 아이디어는 항목을 목록으로 적는 것입니다. 적은 후에 항목의 순서를 중요도 순서로 다시 정렬하고 싶을 것입니다. 제목행을 위로, 또는 아래로 이동하기 위해서 제목행에서 M-up/down 을 사용할 수 있습니다. 목록을 모두 접어서 제목만 보이는 상태는 전체 개요를 파악하는데 도움됩니다. 동시에 세부적인 내용도 잃어버리지 않고 잘 보존하고 있습니다.

다음으로 제목 계층을 올리거나 낮출 수 있습니다. 예를 들어 제목행은 하나 이상의 별 문자로 시작합니다. 행을 목록 작성하기 의 부제로 만들고 싶다면 제목의 위치를 아래로 이동한 후에 M-right 으로 계층을 낮출 수 있습니다.

마지막으로 새 제목행을 추가하기 위해서는 M-RET 을 사용합니다.

목록은 순서 없는 목록과 순서 있는 목록이 있습니다. 다음을 확인하세요.

** 반지의 제왕
   가장 좋아하는 장면 (순서대로)
   1. 로히림 전투
   2. 에오윈과 마술사왕의 싸움
      + 이 내용은 이미 책에서도 가장 좋아하는 장면
      + 미란다 오토를 정말 좋아함
   3. 레골라스에게 화살 맞는 피터 잭슨
      - DVD에만 있음
      화살 맞을 때 정말 웃긴 표정이었음.
   하지만 결과적으로 각각의 장면이 아니라 영화 전체가 좋았다.
   영화에서 중요했던 배우:
   - 일라이저 우드 :: 프로도 역
   - 숀 애스틴 :: 샘 역, 프로도 친구로 나옴. 여전히 그를 구니스에서 나온 미키 월쉬로
     잘 기억하고 있음.

순서 없는 목록은 -, + 또는 \*로 시작합니다. 순서 있는 목록은 숫자와 점으로 시작합니다. 설명은 ::이 붙습니다.

더 보기: 이 스크린캐스트 에서는 일반 목록의 몇 가지 기능을 설명합니다. 이 내용은 메뉴얼 에서도 볼 수 있습니다.

기록하기

내용을 작성할 때 쓸 수 있는 몇 가지 표준적인 마크업이 있습니다. 다음과 같은 마크업을 사용할 수 있습니다.

단어에 *굵게*, /기울임꼴/, _밑줄_, =코드=, ~요약~ 등의 마크업을 쓸 수 있다. 꼭 필요한 경우 +삭제선+도 가능하다.

다음처럼 표현됩니다.

단어에 굵게, 기울임꼴, 밑줄, 코드, 요약 등의 마크업을 쓸 수 있다. 꼭 필요한 경우 삭제선 도 가능하다.

여기까지 봤다면 Emacs 튜토리얼을 살펴보는 것도 좋습니다. C-h t 로 Emacs에 내장되어 있는 튜토리얼을실행할 수 있습니다. 튜토리얼은 몇 가지 Emacs 단축키와 함께 어떻게 문서를 이동하는지 알려줄 것입니다.

할 일 항목 사용하기

이 챕터에서 사용하는 새 단축키입니다.

  • S-left/right – 워크플로를 변경
  • C-c C-v – 현재 문서에 있는 할 일 목록 보기

기본 할 일 기능

org-mode를 사용하는 가장 큰 이유는 할 일을 관리하는데 사용하기 위해서 입니다. 먼저 할 일 기능을 사용하기 위해서는 특별히 해야 하는 작업 없이 TODO 키워드를 제목행에 추가하면 됩니다.

** TODO 비행기 구입하기

할일 목록을 빠르게 사용하려면 다음 단축키를 사용합니다.

  • S-left/right

이 단축키는 TODO 에서 DONE 까지, 그리고 빈 칸을 순환하며 상태를 변경합니다.

큰 문서를 작업하고 있고 문서 곳곳에 할 일 목록이 흩어져 있는 상황에서는 C-c / t 로 현재 할 일 항목만 남기고 나머지는 모두 접을 수 있습니다.

할 일 설정하기

  • 파일 내에서 설정하기

    Org-mode 파일에서는 워크플로 상태를 파일 시작 위치에서 설정할 수 있습니다. 다음과 같이 파일 시작부에 작성합니다.

    #+TODO: TODO IN-PROGRESS WAITING DONE
    

    이 행은 파일 상단에 위치해야 하며 상단과 #+TODO 행 사이에 빈 행이 없어야 합니다.

    새 워크플로를 활성화하기 위해서는 파일을 새로 열거나 파일 최상단에 #로 시작하는 행으로 이동한 다음에 C-c C-c 를 입력합니다.

    워크플로를 복사해서 테스트 파일인 1.org에 붙여놓고 차이를 확인해봅시다.

  • Emacs 설정 파일에서

    워크플로 상태를 모든 org 파일에 직접 추가하는 방법은 번거롭습니다. 어디서나 사용할 수 있도록 설정하려면 설정 파일에 추가합니다. 다음 내용은 (require 'org) 행 이후에 추가합니다.

    (setq org-todo-keywords
      '((sequence "TODO" "IN-PROGRESS" "WAITING" "DONE")))
    

    워크플로 상태를 활성화하기 위해서 Emacs를 재시작합니다.

아젠다

이 장에서 사용하는 단축키입니다.

  • C-c a – 아젠다
  • C-c [ – 아젠다 파일 목록에 문서를 추가
  • C-c ] – 아젠다 파일 목록에서 문서를 제거
  • C-c . – 일자 추가
  • C-u C-c . – 일자와 시각 추가
  • C-g – 하던 일을 멈추고 벗어남

아젠다(agenda)라는 단어의 기본적인 의미는 완료 해야 할 것 으로 라틴어인 agendum 에서 왔습니다. Org-mode는 다양한 종류의 아젠다, 일감 목록을 만들고 하나 또는 여러 org 문서에서 이런 일감을 수집하기 매우 좋습니다.

활성화된 모든 일감 목록 생성하기

1.org를 기본 아젠다 파일로 사용하고 나중에 Emacs의 설정 파일에서 어떻게 동작하는지 살펴보겠습니다.

1.org를 엽니다. C-c a 를 눌러서 아젠다를 엽니다. 아젠다는 다음처럼 나옵니다.

Press key for an agenda command
-------------------------------
a Agenda for the current week or day
t List of all TODO entries

위 내용은 일부 내용이고 실제로는 더 많은 내용이 출력될 것입니다.

아쉽게도 위 두 항목은 빈 목록을 보여줄 것입니다. (직접 눌러서 확인해볼 수 있습니다.) 그러므로 이 상태에서 C-g 를 눌러 빠져 나옵니다. 이제 1.org를 아젠다 파일로 추가하기 위해서 C-c [ 를 사용합니다. 이제 아젠다 메뉴로 가서 (C-c a) t 를 누르면 모든 할 일 항목 목록을 확인합니다.

할 일 항목 사용하기에서 설명한 방식대로 더 적절한 워크플로를 추가하는 과정을 했다면 DONE을 제외한 모든 항목이 모두 나타나는 것을 볼 수 있습니다.

이 과정은 여러 문서에서 반복할 수 있습니다. 아젠다는 할 일 전체 목록을 제공할 것입니다. 만약 문서를 아젠다 파일 목록에서 빼고 싶다면 C-c ] 를 사용하면 됩니다.

약속과 마감 일시

일반적으로 시간 설정이 필요한 일감은 달력에 표시합니다. org-mode는 이 방식도 지원합니다. 아젠다는 모든 할 일을 시간 기반 목록으로 볼 수 있습니다. 다음 내용을 참고하기 바랍니다.

1.org에 새 (부)제목을 프레드에게 전화하기 라고 추가합니다. (M-RET프레드에게 전화하기) 다 입력한 후에 C-c . 를 입력합니다. 이 명령을 입력하면 화면 밑에 일자 선택지가 표시됩니다. 직접 손으로 입력할 수도 있고 S-left/right 으로 선택할 일자를 변경할 수 있습니다. 만약 일자 외에 시간도 추가하고 싶다면 C-c . 대신에 C-u C-c . 를 입력합니다.

이제 아젠다 (C-c a)로 이동해서 a 를 누르면 아젠다 항목을 확인할 수 있습니다.

더 읽을 거리:

Emacs 설정 파일에서 아젠다 설정하기

C-c [ 을 사용해서 아젠다 목록에 추가한 후에 Emacs의 설정 파일을 확인하면 다음 내용을확인할 수 있습니다.

(custom-set-variables
  ;; custom-set-variables was added by Custom.
  ;; If you edit it by hand, you could mess it up, so be careful.
  ;; Your init file should contain only one such instance.
  ;; If there is more than one, they won't work right.
 '(org-agenda-files (quote ("~/Documents/Projects/org4beginners/2.org"
 "~/Documents/Projects/org4beginners/1.org"))))
(custom-set-faces
  ;; custom-set-faces was added by Custom.
  ;; If you edit it by hand, you could mess it up, so be careful.
  ;; Your init file should contain only one such instance.
  ;; If there is more than one, they won't work right.
 )

Emacs lisp의 세계에 오신 것을 환영합니다. Emacs가 설정 파일을 변경한 경우에 이런 방식으로 기록됩니다. (참고: Aquamacs 에서는 customizations.el이라는 별도 파일에 저장됩니다.)

여기서 중요한 내용은 중간에 있는데 (5, 6행) org-agenda-files 내용을 확인할 수 있습니다. 아젠다 목록을 만들기 위해 사용하는 아젠다 파일의 목록이 여기에 지정되어 있습니다. 지금은 일단 그대로 둡니다. 다음에 설정 파일을 살펴볼 일이 있다면 적어도 이게 무슨 기능을 하는지 알 수 있을 것입니다.

더 읽을 거리: 사용자 정의 아젠다 명령

GTD

이 챕터에서 사용하는 단축키입니다.

  • C-c C-c – 태그 추가

Getting things done 은 유명한 시간 관리 방법 중 하나로 구글에서 검색하면 1억 5천 여 항목에 이릅니다. 태그를 사용하면 org mode에서도 비슷한 방식으로 할 일 관리를 할 수 있습니다.

태그는 다른 종류의 할일 목록을 조직화하는데 사용합니다. 예를 들면 전화 일정, 읽을 책 목록, 장바구니 목록을 묶기 위해 씁니다.

태그를 추가하기 위해서 다음 설정을 문서 상단에 추가합니다.

#+TAGS: { @OFFICE(o) @HOME(h) } COMPUTER(c) PHONE(p) READING(r)

문서를 다시 불러오거나 #으로 시작하는 곳에서 C-c C-c 를 입력합니다.

이제 문서 어느 행에서든 하나 또는 그 이상의 태그를 등록할 수 있습니다. C-c C-c 를 누르면 다음과 같은 팝업이 나타납니다.

Inherited:
Current:
{ [o] @OFFICE     [h] @HOME    }
  [C] COMPUTER   [p] PHONE   [r] READING

문서 서두에 정의한 태그를 사용할 수 있는 바로가기입니다. 첫 두 태그는 (OFFICE와 HOME)은 상호배타적이라 둘 중 하나만 선택할 수 있지만 나머지는 자유롭게 추가할 수 있습니다.

GTD 설정의 좋은 예제로는 이 글을 참고하세요: Emacs에 Org-mode를 사용해서 GTD 구현하기

Emacs 설정 파일에 태그 추가하기

Emacs 설정 파일에 태그를 추가하려면 다음처럼 설정에 입력합니다.

(setq org-tag-alist '(("@work" . ?w) ("@home" . ?h) ("laptop" . ?l)))

설정 파일에서 상호배타적인 태그 그룹을 만들기 위해서는 메뉴얼 내용을 참고합니다.

이 설정은 문서 상단에 추가한 내용으로 덮어쓰는 것이 가능합니다. 그래서 각 문서에 개별적인 워크플로와 태그를 직접 설정해서 사용할 수 있습니다.

태그를 활발히 활용하는 예제는 여기서 확인할 수 있습니다.

내보내기

여기서 사용하는 단축키는 다음과 같습니다.

  • C-c C-e – 내보내기 메뉴

org-mode에서만 문서 작업을 한다면 큰 문제가 없습니다. 하지만 간혹 다른 포맷으로 문서를 내보내야 할 필요가 있습니다.

예를 들어 현재 문서를 내보내는데 html로 내보내려고 합니다. C-c C-e 를 누른 후에 h o 를 순서대로 누릅니다. 이 방법은 문서를 html로 내보낸 후, 그 내보낸 파일을 브라우저로 열게 됩니다.

더 읽을 거리: html 출판 튜토리얼을 보면 여기서 설명한 내용보다 더 상세하게 다룹니다. 이 방법으로 완전한 웹사이트를 출판하는 것도 가능합니다. 그리고 메뉴얼에서 html, latex, pdf 그리고 다른 포맷으로 내보내는 방법을 설명합니다.

org-mode에 능숙해지기

효율적인 도구를 사용해서 시간을 아끼려면 그 도구를 잘 알아야 합니다. org-mode를 잘 알기 위해서는 메뉴얼을 읽고 사용하는게 중요합니다. Org-mode는 문서화가 잘 되어 있습니다. 가장 빠르게 org-mode 문서를 Emacs에서 읽는 방법으로는 info browser를 사용할 수 있습니다.

이 창을 호출하기 위해서는 C-h i를 입력합니다. 그리고 링크 간 이동하기 위해서 TAB 키를 사용합니다.

info-browser를 이동할 때는 다음 키를 쓸 수 있습니다.

  • u – 위로(up)
  • n – 다음(next)
  • p – 이전(previous)

org-mode 메뉴얼 다음으로는 worg 웹사이트가 있습니다. 여러 재밌는 아이디어와 튜토리얼을 찾을 수 있습니다.

기능을 빠르게 참고할 수 있는 org-mode 치트시트와 emacs 치트시트가 있습니다. 이 두 문서는 귀찮은 단축키를 기억하는데 도움이 될 것입니다.

기초를 넘어서

Geek 유머에 "여기에 용이 있어요!"가 있습니다. 여기서부터는 org-mode를 자유롭지만 스스로 책임져야 하는 사용법을 설명합니다. 대부분 다음 내용은 실제로 정말 어렵거나 한 것은 아니지만 적어도 중요한 데이터는 백업을 해두시기 바랍니다. 만약 다음 내용에서 궁금한 부분이 있다면 메뉴얼과 질문 답변을 확인합니다. 또한 IRC (freenode의 #orgmode)에서 질문하는 것도 좋은 방법입니다.

TODO Quickly adding tasks with remember

(역주: 아직 내용이 존재하지 않는 항목입니다.)

최신 버전 org-mode 사용하기

여기서 사용하는 명령입니다.

  • M-x org-reload – 업데이트 후 org-mode를 다시 불러오기
  • M-x org-version – org-mode 버전 확인하기

Emacs가 업데이트 되는 속도보다 org-mode가 더 빠르게 개발되는 것을 아마 알 수 있을겁니다. 게다가 매일 org-mode의 개발 버전을 받아서 사용하는 것도 가능합니다.

어떻게 가능할까요?

  1. git 설치하기 org-mode 튜토리얼에서 다룰 부분은 아니지만 다음 내용을 참고하세요.
    sudo apt-get install git
  2. org-mode의 코드를 어디에 저장할지 결정합니다. 여기서는 ~/Build/Emacs/org-mode 에 저장하지만 어디 하나 별 차이가 없으니 편한 위치에 저장하기 바랍니다.
  3. org-mode의 최신 버전을 받습니다.
    mkdir ~/Build/Emacs
    cd ~/Build/Emacs
    git clone git://orgmode.org/org-mode.git
    cd org-mode && make && make doc
    
  4. Emacs-init 파일에 다음 내용을 추가합니다.
    (setq load-path (cons "~/Build/Emacs/org-mode/lisp" load-path))
    (setq load-path (cons "~/Build/Emacs/org-mode/contrib/lisp"
    load-path))
    

    (require 'org-install)

    중요! 일반 버전의 org-mode라면 다음을 사용합니다.

    :(require 'org) 다음을 사용한다면 위 행은 반드시 제거해야 합니다.

    :(require 'org-install)

  5. 최신 org-mode를 유지하려면 다음 명령을 사용합니다.
    cd ~/Build/Emacs/org-mode
    git pull && make clean && make && make doc
    
  6. org-mode를 다시 불러옵니다. M-x org-reload 또는 Emacs를 재시작하세요.

어떤 버전의 org-mode를 사용하고 있는지 확인하려면 M-x org-version 을 입력합니다.

Randall Degges의 포스트 To 30 Billion and Beyond를 번역했다. ipify를 만들고 확장하는 일련의 과정을 풀어 쓴 이야기다.

Thank you, Randall Degges for giving me the opportunity to translate this article. If you want to read the original, please check To 30 Billion and Beyond.


ipify: 300억 요청 처리, 그 너머로

Buzz Lightyear Charging Sketch

몇 년 전에 무료 웹서비스인 ipify를 만들었습니다. ipify는 무료로 사용할 수 있는, 고도로 확장 가능한 IP 주소 검색 서비스입니다. REST API를 호출하면 어느 퍼블릭 IP 주소를 사용하고 있는지 확인 할 수 있습니다.

ipify를 만든 당시에는 복잡한 인프라스트럭처 관리 소프트웨어를 만들고 있었고 클라우드 인스턴스에서 관리용 API를 사용하지 않고 동적으로 퍼블릭 IP 주소를 알아내야 했었습니다.

무료로 사용 가능한 역 IP 검색 서비스를 검색해봤지만 적합한 해결책을 찾지 못했습니다.

  • IP를 스크랩 할 수 있는 웹사이트가 있긴 했습니다. (하지만 별로 좋은 형태가 아니고 호스트에서 사용하기엔 불편합니다)
  • 비용을 청구하는 API도 있었습니다. (잌!)
  • 일간 조회 횟수를 제한하는 API도 있었습니다. (한꺼번에 많은 인스턴스를 관리해야 했기에 사용하기 두려웠습니다)
  • 제가 원하는 방식의 API도 있었지만, 오류가 발생하기도 하고 가끔 서비스가 내려가기도 하거나 질적으로 떨어지기도 했습니다. 어느 제공자를 dig로 살펴봤더니 전체 서비스가 단일 서버 (A 레코드)에서 동작하고 그 서버에서 요청이 직접 종료되었습니다. 세계 규모로 확장 가능하거나 고가용성의 서비스라고 말하기 어려웠습니다.
  • 보기엔 괜찮은 API도 있었지만, 지속해서 유지하기 위한 기부를 받으려고 했습니다. 사용하려는 API 서비스가 언제 죽을지 모를 상황이라니 마음 놓고 사용할 수 없었습니다.

이런 이유로 인해 작은 서비스를 직접 만들어서 내 문제와 가능한 한 많은 사람의 문제를 해결하고 싶었습니다. 더군다나 하나의 문자열을 반환하는 소프트웨어를 작성하는 일은 그렇게 엄청 어려운 일은 아니었습니다. 하지 말아야 할 이유가 있을까요?

가장 최악의 경우라고 상상해볼 수 있는 일은 기꺼해야 월 30달러가량 사용하고 공개 서비스라는 점을 염두에 두고 다루는 정도였습니다.

ipify 버전 0

ipify의 첫 코드는 상당히 단순했습니다. 50줄도 되지 않는 작은 코드를 Node로 작성했습니다. (당시에 많은 시간을 Node를 갖고 놀며 지냈습니다.) ipify 서비스가 문자열을 반환하는 일이 전부라는 전제에서는 Node를 사용할 가장 적합한 경우인 것을 알 수 있었습니다. 적은 CPU 사용량으로 수많은 요청을 처리하는 작업이죠.

API 서비스를 Node로 만들고 나서 간단한 정적 사이트를 프론트엔드로 붙여 아마존의 S3에 올렸습니다. 그리고 S3 버킷 앞에 아마존의 CDN 서비스인 CloudFront를 설정해서 페이지를 캐싱하는 것으로 엄청 빠르게 불러올 수 있도록 했습니다.

어떤 상상의 날개를 펼쳐봐도 저는 디자이너가 아니었습니다. 그래도 다행히 bootstrap을 사용해서 조금 나은 모습을 갖출 수 있었습니다. =)

모든 준비가 끝난 후에 간단하게 테스트를 수행했습니다. 모든 것이 준비되었다는 생각에 다음 단계인 배포로 넘어갔습니다.

Heroku 입성

Heroku Logo

저는 Heroku의 왕 팬입니다. (심지어 Heroku에 대한 도 썼습니다.) Heroku를 수년 간 사용하고 있고 개발 세계에 있어서 가장 저평가되고 있는 서비스 중 하나라고 생각합니다. 한 번도 사용해본 적이 없다면 지금 확인해보시길 바랍니다!

ipify를 확장 가능하고 고가용성으로, 그리고 저렴하게 운영하려면 Heroku 만큼 단순하고 좋은 선택지가 없다고 생각하고는 Heroku를 사용하기로 결정했습니다.

ipify를 Heroku에 1~2분 정도 걸려 배포하고 단일 dyno(웹서버)로 실행한 후에 제한적으로 테스트를 수행했습니다. 여전히 잘 동작하니 스스로 뿌듯한 기분이 들었습니다.

Heroku에 익숙하지 않다면 ipify 인프라스트럭처가 어떻게 동작하는지 살펴봅시다.

  • Heroku는 ipify 웹서비스를 512M 램과 제한된 CPU인 작은 dyno (웹서버)에서 구동합니다.
  • 만약 프로세스가 충돌하거나 어떤 심각한 문제가 발생한다면 Heroku는 자동으로 서비스를 재시작합니다.
  • Heroku는 앱으로 들어오는 모든 요청을 로드 벨런스를 거친 후 요청을 처리하기 위해 dyno (웹서버)로 전달합니다.

이런 설정이 좋은 이유는:

  • 고가용성: Heroku의 로드벨런서, 제 dyno, 모든 것이 고가용성입니다.
  • 유지보수도, 관리 설정도, 어떤 배포 코드도 필요하질 않습니다. 100% 자동입니다.
  • 저렴합니다. 단일 서버를 위해 월 ~7달러가량 냅니다.
  • 빠릅니다. 헤로쿠는 아마존 웹서비스(AWS) 상에서 돌아가며 모든 인프라스트럭처는 세계에서 가장 인기있는 클라우드 호스트 위치인 AWS 미동부 (버지니아)에서 운영됩니다. 이 의미는 지리적으로 US 동부 해안에서 운영된다는 점인데 물 건너 유럽이나 나머지 US 지역에서 그다지 멀지 않다는 의미입니다. 세계 대부분 지역의 사용자가 서비스를 사용한다고 해도 그렇게 오래 걸리지는 않을 겁니다.

지금까지는 상당히 괜찮았습니다. 만들고, 설정하고, 테스트하고, 프로덕션으로 옮기기까지 1일 이하의 노력으로 끝낼 수 있었습니다.

그리고나서 ipify를 제 인프라스트럭처 관리 코드와 통합하는 것으로 원래 해결하려던 문제를 처리했습니다.

한 달 정도 모든 것이 잘 흘러갔습니다. 몇 가지 문제를 알아차리기 전까지는 말이죠.

인기… 헐?

Spartan Warrior Sketch

ipify를 홍보한 적이 없지만 “ip address api”를 구글에서 검색하면 꽤 상단에 걸리게 되었습니다. SEO와 문구 수정에 사용한 수년 간의 노력이 빛을 발한 것 같습니다.

그동안 ipify가 구글 검색 결과에서 상당히 높은 순위로 노출된 덕분에 수천 명의 사용자가 생겼습니다. 서비스가 많이 노출되면서 몇 이슈가 나타나기 시작했습니다.

제 Heroku 로드 벨런서가 경고를 보내기 시작했습니다. Node 서버가 들어오는 요청을 충분히 빠르게 처리하지 못하고 있다는 내용이었습니다. 결과적으로 다음과 같은 일이 나타났습니다.

  • 너무 많은 사용자가 ipify에 API 요청을 보내고 있습니다
  • 제 Node 서버가 요청에 대해 느리게 응답해서 지연이 늘고 있었습니다
  • Heroku의 로드벨런서가 이 문제를 알아차리고 Node 서버에 보내기 전에 요청을 버퍼에 저장했다가 보내기 시작했습니다.
  • Node 서버가 빠르게 요청을 처리하지 못한 탓에 로드벨런서에서 사용자에게 503을 전달하고 요청을 끝내버렸습니다.

그다지 좋은 모습이 아닙니다.

이 문제를 해결하기 위해 한 일은 간단했습니다. Heroku에 dyno를 더 추가했습니다. 이 방식으로 수용량을 두 배로 늘렸고 모든 서비스가 부드럽게 동작하기 시작했습니다. 그 이면엔 두 “프로덕션” dyno를 구동한 탓에 거의 50달러 가량을 지불해야 했습니다. 첫 dyno 이후엔 일반 요금인 dyno당 월 25달러를 지불해야 했습니다.

서비스가 이 정도로 인기 있다면 월 50달러를 내는 것도 나쁘지 않다고 생각해서 지불하기로 결정하고 다시 안정적인 상황으로 돌아올 수 있었습니다.

하지만… 그렇게 말처럼 쉬운 문제가 아니었습니다.

한주도 채 지나기 전에 Heroku에서 이전과 동일한 경고를 받았습니다. 사용량을 보니 트래픽은 두 배로 늘었고 ipify는 더 많은 사용량을 보였습니다.

저는 또 다른 dyno를 추가하고 월 75달러를 지불하게 되었습니다. 그래도 좀 더 살펴보기로 했습니다. 저는 짠돌이라서 월 50달러 이상 쓰는 건 기쁜 일이 아니었습니다.

수사

L Sketch

수사를 시작하면서 가장 먼저 살펴본 것은 실제로 ipify가 초 당 얼마나 많은 요청을 처리하는가(requests per second, rps)를 살펴보는 일이었습니다. 숫자를 보고 상당히 놀랐습니다. 낮았거든요.

제 기억에는 ipify는 10rps 정도였습니다. 이렇게 작은 숫자를 처리할 수 없다면 뭔가 코드가 문제라고 생각했습니다. 만약 10rps를 작은 웹 서버 두 개를 사용하는데도 처리하지 못하는 상태라면 끔찍하게 잘못된 것이 분명했습니다.

처음 알아차린 것은 단일 Node 프로세스로 처리하고 있다는 점이었습니다. Node의 클러스터 모듈을 사용해서 쉽게 고칠 수 있었습니다. 이제 각각의 CPU 코어에서 하나의 프로세스를 구동할 수 있게 되었습니다. 효과적으로 dyno에서 두 배의 처리량을 다룰 수 있게 되었습니다.

하지만 20 rps는 여전히 작은 숫자로 느껴져서 좀 더 깊이 파헤쳐보기로 했습니다. Heroku에서 로드 테스트를 수행하기보다 제 랩탑에서 로컬로 테스트를 수행했습니다.

제 랩탑은 512M 램의 작은 Heroku dyno보다 훨씬 강력하기 때문에 더 처리량이 많을 것으로 생각했습니다.

ab 도구를 사용해서 테스트를 수행했고 놀랄 수 밖에 없었습니다. 훨씬 좋은 사양의 제 랩탑에서도 30 rps 정도 처리하지 못하는 것입니다. (제 랩탑은 리눅스로 구동되고 있었고 ab는 효율적으로 동작하는 환경이었습니다.) 그래서 기초적인 분석을 시작했고 Node에서 단순한 문자열 조작 동작을 하는데 많은 시간을 사용한다는 것을 확인할 수 있었습니다. (X-Forwarded-For 헤더에서 IP 주소를 추출하고 다듬는 일이었습니다) 여러 가지 실험을 해봤지만, 이 한계를 뛰어넘을 만큼 좋은 성능을 내는 것은 불가능했습니다.

이 시점에서 ipify 서비스는 두 dyno를 통해서도 겨우 20 RPS 정도 처리할 수 있었습니다. ipify는 월 ~5200만 요청을 처리하고 있었습니다. 그다지 인상적이지 않았습니다.

이 서비스를 Go로 다시 작성하기로 했습니다. (Go는 몇 달 전부터 사용하기 시작했습니다) 동일한 Go 서버를 작성했을 때 성능 측면에서 더 나은지 확인해보고 싶었습니다.

ipify 버전 1

Warrior Sketch

ipify를 Go로 다시 작성하는 일은 짧은 (그리고 즐거운) 실험이었습니다.

이 과정에서 Gorilla/mux, Martini와 httprouter 같은 다양한 Go 라우팅 스택을 사용해볼 수 있었습니다. 이 세 가지 라우팅 도구를 사용해보고 비교해본 결과 httprouter가 다른 둘에 비교해 비약적으로 성능이 좋은 것을 확인했습니다.

제 랩탑에서 Go 서버는 초당 ~2,500 요청을 처리할 수 있었습니다. 엄청난 향상이었습니다. 또한, 메모리 사용량도 엄청 적어서 5M 남짓을 사용했습니다.

Go와 사랑에 빠진 저는 즉시 행동으로 옮겼습니다. Go 기반 ipify 서비스를 Heroku에 배포했습니다.

결과는 환상적이었습니다. 단일 dyno에서 2천 RPS 정도를 처리할 수 있었습니다. 이런 변화는 월 25달러 수준으로 월 ~52억 요청을 처리할 수 있게 되었습니다.

며칠 후에 경험 있는 Go 개발자와 대화를 한 후, 문자열을 처리하는 기능 몇 가지를 다시 작성했고 그 덕분에 추가로 ~1천 RPS 정도를 확보할 수 있었습니다. 이 시점에 dyno 당 월 ~77억 요청을 처리하고 있었습니다. (조금 더 많거나 적었습니다.)

저는 말할 것도 없이 흥분했습니다.

더 큰 유명세

Tyrael Sketch

짧은 시간이긴 하지만 호스팅 비용을 줄일 수 있었습니다. 대략 2달 정도 지난 후에 ipify는 또 다른 문제를 경험하게 되었습니다.

엄청난 비율로 성장을 지속하고 있었습니다. 이즈음에 ipify에 대해 Google Alerts를 설정해뒀는데 그래서 사람들이 이 서비스에 대해 언급하면 바로 알 수 있었습니다.

점점 많은 사람이 ipify를 개인 프로젝트와 업무에서 사용한다는 점을 확인할 수 있었습니다. 그리고 몇 회사에서는 자신들의 프로덕트에 포함해도 되느냐는 질문을 받기 시작했습니다. (대형 스마트 TV 공급자, 다양한 미디어 대행사와 IoT 업체 등.)

ipify가 월간 150억 요청을 처리할 때쯤 호스팅 비용으로 월 50달러를 사용하고 있었습니다.

하지만 이런 상황은 오래 지속되지 않았습니다.

ipify의 트래픽은 급격히 성장하는걸 확인할 수 있었습니다. 아마 몇 달 후에는 수 십 억의 월간 요청을 더 받게 될 겁니다.

또한, 급작스러운 트래픽에 의한 문제도 겪기 시작했습니다. ipify가 짧은 시간에 엄청난 양의 트래픽을 받게 되면 순식간에 죽는 경우가 있었습니다. 아마 이 트래픽은 부트스트랩 스크립트, 스케쥴 작업(cron job) 그리고 다른 비슷한 방식의 작업으로 발생한 것이라 짐작하고 있습니다.

뒤늦게는 백신 업체에서 ipify를 차단하기 시작했다는 얘기도 들을 수 있었습니다. 루트킷이나 바이러스, 지저분한 소프트웨어가 ipify를 사용하기 시작한 탓입니다. 공격자는 공격 대상을 공격하기 전에 ipify를 사용해서 피해자의 퍼블릭 IP 주소를 얻어내는 등의 악의적인 용도로 사용했습니다. 이런 사용자도 분명 대규모의 급작스러운 트래픽에 책임이 있다고 가정했습니다.

이런 악의적인 사용자를 좋아하지도 않고 그 일을 돕는데 돈을 쓰고 싶지도 않았지만 ipify는 중립적으로 사용하고 싶은 사람은 아무나 사용할 수 있도록 그대로 운영하기로 결정했습니다. 개발자 서비스인데 어떤 사람만 사용하도록 고르고 선택하는 것은 탐탁치 않았습니다. 모든걸 단순하게 유지하기로 했습니다.

이런 결정에도 여전히 급작스러운 트래픽 문제는 해결해야 했습니다. 이런 트래픽은 쉽게 처리하기 어려운데 우선적으로 두 선택지가 있었습니다.

  • 추가적인 dyno를 운영해 비용을 지불하고 항상 급작스러운 트래픽에 대비하는 방법, 또는
  • Adept와 같은 자동 확장 도구를 사용해서 트래픽 패턴에 따라 dyno를 생성하고 제거하는 방법

결국엔 첫 번째를 선택했습니다. 단순히 Adept의 서비스를 사용해서 추가적인 비용을 지출하고 싶지 않았기 때문입니다. (물론 이 서비스를 사용해본 적이 있고 엄청나게 멋진 서비스입니다.) 이 즈음에 월간 150달러를 지불했고 ipify는 250억 월간 요청을 처리했습니다.

이렇게 최근까지 왔습니다.

ipify 300억 요청 도달

Buzz Lightyear Proud Sketch

지난 몇 달 동안 ipify는 새로운 기록을 냈고 월간 300억 요청을 몇 차례 넘었습니다. 이렇게 새로운 기록을 경신하는 일은 지켜보는 즐거움이 있습니다.

오늘날 ipify는 2천에서 2만 RPS를 유지하고 있습니다. (이 수치는 항상 절대 일관적이질 않았습니다.) 트래픽은 항상 달라졌고 사용량은 정기적으로 높아졌는데 트래픽 패턴에서 의미를 찾는 걸 완전히 포기했습니다. 평균 응답 시간은 트래픽 패턴에 따라 1~20ms 사이를 유지했습니다.

현재 서비스는 트래픽과 여러 요인에 따라 월간 150~200달러로 운영되고 있습니다. 월간 200달러를 소비한다고 가정하고 수치를 계산해보면 ipify는 각 요청을 0.000000007달러로 운영하고 있습니다. 충격적으로 낮은 비용이죠.

만약 이 서비스를 Lambda에서 돌린다고 가정하고 요금을 계산해본다면 월간 1,243.582달러 (연산) + 6,000달러 (요청) = ~ 7,243.58달러 정도를 사용하게 될 겁니다. 참고로 이건 간단한 입계산입니다. ipify의 수치를 Lambda 요금 계산기에 입력해서 얻은 결과입니다.

결론적으로 ipify에 들어가는 비용에 _엄청나게 만족_하고 있습니다. 간단한 용도를 제공하는 짠돌이 서비스죠.

ipify의 미래

이제 미래를 생각해보게 되었습니다. ipify는 지속해서 성장하고 있습니다. 서비스는 ipv6 지원 (Heroku가 현재 지원하지 않아요 🙁), 더 나은 웹디자인, IP 주소에 대한 다른 메타데이터 등 여러 가지 요청을 받고 있습니다.

최근 다른 프로젝트 덕분에 엄청나게 바쁜 탓에 ipify의 소유권을 제 좋은 친구인 https://www.whoisxmlapi.com에 넘겼습니다.

조나단과 그의 팀은 가치 있고 흥미로운 개발자 API 서비스를 만드는데 제격인 포트폴리오를 갖고 있습니다.

제가 지속해서 돕고 있긴 하지만 조나단과 그의 팀은 현재 ipify의 새로운 기능을 구현하고 있습니다. 제가 기대하는 좋은 변화를 반영하기 위해 열심히 작업하고 있습니다. (더 나은 UI와 데이터 엔드포인트를 포함해서 말입니다.)

저는 앞으로도 꾸준히 성장할 ipify가 기대됩니다.

저에게 질문이나 궁금한 점이 있다면 이메일로 남겨주세요.

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


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

개요

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

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

네임스페이스 사용하기

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

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

모듈 사용하기

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

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

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

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

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

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

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

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

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

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

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

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

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

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

불필요한 네임스페이스

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

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

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

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

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

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

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

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

모듈 사용의 트레이드오프

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

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


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

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

프로바이더 주입하기

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

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

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

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

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

새로운 의존성 주입 방식

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@Inject()

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

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

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

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

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

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

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

const myToken = new OpaqueToken('myValue');

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

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

const myToken = new OpaqueToken('myValue');

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

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

@Injectable()

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

토큰과 의존성 주입

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

프로바이더 등록하기

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

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

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

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

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

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

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

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

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

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

프로바이더 덮어쓰기

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

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

@Injectable()
export class AuthService {

  constructor(private http: Http) {}

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

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

}

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

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

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

  constructor(private authService: AuthService) {}

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

}

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

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

  username: string;

  constructor(private authService: AuthService) {}

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

}

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

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

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

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

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

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

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

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

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

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

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

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

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

인젝터(injector) 이해하기

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

사전 컴파일된 코드

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

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

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

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

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

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

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

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

AppModuleInjector

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

생성된 AppModuleInjector를 확인해보자.

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

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

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

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

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

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

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

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

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

    return notFoundResult;
  }

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

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

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

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

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

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

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

AppModuleInjector 프로퍼티

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

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

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

  • CommonModule
  • ApplicationModule
  • BrowserModule

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

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

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

모듈 불러오기

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

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

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

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

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

의존성 주입

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

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

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

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

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

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

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

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

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

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

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

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

class AppModuleInjector extends NgModuleInjector<AppModule> {

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

  // new HttpModule()
  _HttpModule_5: HttpModule;

  // new AppModule()
  _AppModule_6: AppModule;

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

    return notFoundResult;
  }
}

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

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

정리하며

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

웹사이트 설정

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

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