Earth from Mars – NASA: Climate Change: Vital Signs of the Planet을 번역했습니다.

이 사진은 NASA의 큐리오시티 로버가 화성의 표면에서 바라본 지구의 모습을 담고 있습니다. 화성의 밤하늘에서 어느 별보다도 밝게 빛나고 있습니다. 지구는 사진 가운데서 약간 좌측에 밝은 점이며 그 점 바로 아래에 있는 달도 보입니다. 2013년 8월 6일에 화성에 착륙한 큐리오시티는 그동안 화성에 보낸 탐사 차량 중 가장 크고 발전된 모델입니다. 주변의 지질 분석과 함께 미생물 생장에 적합한 환경을 갖고 있었는지 증거를 연구하고 있습니다.

연구원은 큐리오시티의 마스트 카메라(Mastcam) 좌측 카메라를 이용해 탐사 시작 529일, 일몰 80분 후 이 장면을 촬영했습니다. (지구 시간으로는 2014년 1월 31일). 이미지는 우주 방사선에 의한 현상을 제거하는 후처리 과정을 거쳤습니다. 사람이 화성에 서서 눈으로 직접 봤다면 지구와 달은 명확하게 분리되어 보이는 두 '별'로 보일 것입니다. 큐리오시티가 이 사진을 촬영했을 때 지구는 화성으로부터 9천 9백만 마일, 1억 6천만 킬로미터 떨어져 있었습니다.

이 사진은 "멀리서 본 지구" 사진 컬랙션에 추가되었습니다. 우리는 우주에서 어떤 공간에 살고 있는지에 대해 독특한 관점을 제공합니다.

크레딧: NASA Jet Propulsion Laboratory-Caltech/Malin Space Science Systems/Texas A&M University. 출처: NASA Jet Propulsion Laboratory.

Carlos Arguelles, Marko Ivanković, and Adam Bender의 Code Coverage Best Practices를 번역했습니다.


코드 커버리지 모범 사례

저희는 수 년 간 여러 대형 소프트웨어 회사에서 다양한 소프트웨어 테스팅 이니셔티브를 주도했습니다. 꾸준히 강조하는 영역 중 하나는 위험성을 진단하고 테스트의 부족한 부분을 찾아내기 위한 방법으로 코드 커버리지 데이터를 활용하라는 부분입니다. 하지만 코드 커버리지가 제공하는 가치에도 불구하고 논쟁에 불이 붙어 강한 논쟁으로 이어지기도 하고 양극화 양상도 나타나는 주제입니다. 큰 규모의 그룹에서 코드 커버리지를 언급할 때마다 끝없는 논의가 매번 이어지는 것을 볼 수 있었습니다. 이런 대화는 생산성을 향상하는 쪽으로 진행되기 보다는 각자의 방패 뒤로 숨어버리게 합니다. 이 문서는 다양한 의견을 가진 사람들 사이에서 공통의 목표를 제시할 수 있도록 돕고자 작성되었습니다. 커버리지 정보를 좀 더 실용적으로 접근하고 전진에 힘 쓸 수 있도록, 이 문서가 그 도구로 활용되었으면 합니다. 이 글에서는 코드 건강에 효과적으로 도움이 되는 코드 커버리지를 모범 사례를 통해 안내합니다.

  • 코드 커버리지는 개발자의 워크플로에 상당한 이점을 제공합니다. 코드 커버리지가 테스트 품질에 대한 완벽한 지표라고 볼 수는 없지만 논리적이고 객관적인 산업 표준 지표 중 하나로 무언가 조치를 취할 수 있는 정보를 함께 제공합니다. 코드 커버리지는 많은 인적 자원을 필요로 하는 것도 아니라서 모든 프로덕트에 범용적으로 적용할 수 있으며 대부분의 언어에서 사용 가능한 도구가 이미 존재합니다. 물론 코드 커버리지는 많은 정보를 단 하나의 숫자로 표시하기 때문에 손실적이고 간접적인 지표인 점을 이해해야 하며 어떤 문제를 판단하는 유일한 수치가 되어서는 안됩니다. 대신 다른 기법을 함께 활용해서 테스트에 좀 더 종합적인 판단을 내릴 때는 유익한 도움을 받을 수 있습니다.

  • 코드 커버리지가 문제를 줄이는지 아닌지는 아직 명확한 답이 없는, 열린 연구 과제지만 경험에 비춰보면 코드 커버리지를 높이려는 노력이 우수한 엔지니어링을 추구하는 문화로의 변화를 이끄는 경우를 봐 왔고 장기적으로는 문제를 많이 줄이는데 일조했습니다. 예를 들어 팀에 코드 커버리지에 우선 순위를 두면 테스트 자체를 1급 시민처럼 대우해서 테스트 가능성(testability)이 프로덕트 디자인에 더 깊숙히 반영됩니다. 그 결과로 팀은 더 적은 노력으로도 테스트 목표를 달성하게 됩니다. 이 모든 노력은 처음부터 더 고품질의 코드를 작성하는 노력(모듈로 더 분리하고, API에서 더 깔끔한 계약을 작성하고, 더 관리하기 쉬운 코드 리뷰를 수행하는 등)으로 이어집니다. 결과적으로 전반적인 코드 건강, 엔지니어링, 운영 우수성에 대해 신경쓰기 시작합니다.

  • 높은 코드 커버리지 퍼센트는 테스트 범위의 고품질을 보장한다는 의미가 아닙니다. 100%에 가까운 숫자를 만드는 일에만 집중한다면 비뚤어진 안정감을 쫒는 일과 같습니다. 그런 접근에서는 단순히 숫자를 올리기 위해서 가치 낮은 테스트를 양산하기 마련인데 관리해야 하는 테스트가 늘어나기 때문에 기술적 부채를 크게 만듭니다. 또한 테스트에 소모되는 자원까지 고려하면 심한 낭비로 볼 수 밖에 없습니다. 나쁜 코드가 테스트에서 잡히지 않고 프로덕션으로 넘어가게 되었다면 (a) 특정 경로의 코드가 테스트에서 확인되지 않았다는 의미로 이런 부분은 코드 커버리지 분석에서 쉽게 확인할 수 있는 테스트 격차입니다. (b) 또는 테스트가 특정 경계 상황(edge case)에서 제대로 이뤄지지 않은 경우인데 코드 커버리지에서는 테스트를 수행한 것으로 집계되기 때문에 코드 커버리지 분석 만으로 이 부분을 진단하기에는 아주 어렵거나 불가능에 가깝습니다. 코드 커버리지는 코드의 특정 행이나 브랜치가 의도대로 동작되는지 검사하는 도구가 아니라 단순히 테스트에서 실행이 되고 있는지만 보장합니다. 단순히 테스트를 복사/붙여넣기를 하거나 특정 숫자 값을 몇 넣는 것으로 커버리지를 높이는 일이 없도록 더욱 주의해야 합니다. 더 나은 기법이라면 확인하는 각각의 행에 적절한 테스트를 수행하고 실패하는 상황을 잘 검사하고 있는지 확인하기 위해 뮤테이션 테스트를 적용할 수 있습니다.

  • 하지만 낮은 코드 커버리지 수치는 매 자동화된 배포마다 프로덕트의 큰 부분에서 전혀 테스트가 이뤄지지 않는 상황이라는 점을 장담할 수 있습니다. 낮은 수치는 나쁜 코드를 프로덕션으로 내보낼 확률이 높다는 뜻이며 주의 깊게 확인해야 합니다. 실제로 대다수의 코드 커버리지 정보는 어떤 범위가 테스트 되고 있는가 하는 부분보다 어떤 범위가 테스트되지 않고 있는지를 강조합니다.

  • 모든 프로덕트에는 이런 "이상적인 코드 커버리지 수치가 나와야 한다" 같은 규칙은 없습니다. 어느 수준의 테스트를 요구하거나 필요로 하는가 하는 질문은 (a) 비지니스에 얼마나 영향을 미치고 어느 정도 임계가 보장되어야 하는지 (b) 코드에 얼마나 자주 변경이 이뤄지는지 (c) 코드의 생애가 얼마나 장기적인지, 복잡도가 어느 정도인지, 도메인 영역에서의 변수는 어느 정도인지에 따라 답변되어야 합니다. 모든 팀에서 코드 커버리지 몇 퍼센트 달성을 해야 한다고 강제할 수는 없습니다. 이런 비지니스 결정은 프로덕트의 해당 도메인 분야를 잘 이해하는 프로덕트 오너가 내려야 합니다. 코드 커버리지를 특정 퍼센트 달성하기 위해서는 테스트를 더 쉽게 수행할 수 있도록 인프라스트럭처에 대한 투자가 필요합니다. 예를 들면 개발자의 워크플로에 자연스럽게 녹아들 수 있는 도구를 제공하는 등의 방식이 필요합니다. 다만 엔지니어가 단순히 수치를 목표로 삼고 체크 박스 체크하는 것처럼 목표 이상의 커버리지를 달성하는 것만 집중해버리면 아무리 신중하게 코드를 작성 한들 결과적으로는 그다지 건강하지 않을 수 있습니다.

  • 일반적으로 프로덕트 대다수의 코드 커버리지는 평균 이하입니다. 우리는 전반적으로 코드 커버리지를 대폭 상향하는 것을 목표로 해야 합니다. "이상적인 코드 커버리지 수치"가 있는 것은 아니지만 구글에서는 일반적인 가이드라인으로 60%는 "용인되는 수준", 75%는 "칭찬할 만한 수준", 90%는 "모범적인 수준"으로 보고 있습니다. 하지만 전사적 수준에서 하향적인 강제는 하지 않으며 각각의 팀에서 비지니스 요구에 맞춰 얼마를 달성할지 정하도록 격려하고 있습니다.

  • 코드 커버리지 90%에서 95%가 되는 일에 집착하지 않아야 합니다. 코드 커버리지가 주는 이득은 지수적으로 증가하기 때문에 특정 수준을 넘으면 이득이 크지 않습니다. 하지만 30%에서 70%로 가는 일은 구체적인 계획을 짜서 수행하는 것이 바람직합니다. 또한 새로 작성하는 코드는 모두 그 수준을 맞춰서 작성해야 합니다.

  • 테스트에서 다뤄지지 않는 범위의 코드 또는 동작에서 발생할 위험성을 안고 갈 지 아닐지를 사람이 판단하는 점이 코드 커버리지 수치보다 더 중요합니다. 무엇을 테스트하지 않는지 하는 부분이 무엇을 테스트 하는지보다 더 중요한 부분입니다. 코드 리뷰 과정에서 실용적인 토론을 거쳐 어느 행의 코드가 테스트되지 않을지 합의하는 과정이 단순히 목표 숫자를 맞추는 일보다 더 중요합니다. 코드 커버리지를 코드 리뷰 과정에 내장하면 코드 리뷰가 더 빠르고 쉽게 진행됩니다. 모든 코드가 동일하게 주용한 것은 아닙니다. 예를 들면 디버그하는 로그 행을 테스트하는 일은 그다지 중요하지 않습니다. 그래서 개발자는 단순히 커버리지 숫자를 보는 것보다 코드 리뷰에 포함된 테스트로 강조되는 각각의 행을 확인해서 정말 중요한 코드가 테스트되고 있는지 체크해야 합니다.

  • 단순히 프로덕트가 코드 커버리지가 낮다고 해서 장기적으로 구체적이고 점진적인 향상을 할 수 없다는 의미가 아닙니다. 테스트가 별로 없고 테스트가 어려운 레거시 시스템을 인계 받았다면 개선할 힘도 안나고 어디서 시작해야 하는지 전혀 감이 안올 수도 있습니다. 하지만 최소한 '보이스카우트 원칙'을 적용할 수 있을 겁니다. (캠핑장은 처음 왔을 때보다 깨끗하게 해놓고 떠나라.) 시간이 흐르면 점진적으로 더 건강한 위치에 도착하게 될 겁니다.

  • 주기적으로 변경되는 코드는 꼭 테스트에 포함되어야 합니다. 프로젝트의 목표가 넓어서 90% 이상을 달성하는 일이 큰 의미가 없을 수 있습니다. 각 커밋 당 커버리지 목표를 99%로 잡으면 합리적이고 90%는 좋은 하위 임계점으로 볼 수 있습니다. 적어도 시간이 지날 때마다 더 나빠지는 것 만큼은 막아야 합니다.

  • 단위 테스트 코드 커버리지는 퍼즐 조각 하나에 불과합니다. 통합/시스템 테스트 코드 커버리지도 중요합니다. 또한 단위 테스트와 통합 테스트를 포함한 모든 파이프라인에서의 종합 커버리지 수치는 가장 중요합니다. 이 수치는 코드 전체에서 얼마나 많은 영역이 테스트 자동화에 포함되지 않았는지, 그리고 파이프라인을 통해서 프로덕션 환경으로 얼마나 많은 영역이 테스트 없이 보내지는지 알 수 있습니다. 하나 알아야 할 점은 단위 테스트는 실행된 코드와 평가된 코드 사이에서의 상관 관계가 높지만 통합 테스트나 E2E(end-to-end) 테스트에서의 일부 범위는 부수적일 가능성이 크며 의도되지 않은 테스트 범위일 수도 있습니다. 그렇기에 통합 테스트를 코드 커버리지로 같이 본다고 하더라도 그걸 유닛 테스트에서 다뤄지지 않은 범위도 검사가 되고 있구나 착각하는, 비뚤어진 안정감을 갖지 않도록 조심해야 합니다.

  • 코드 커버리지 표준에 미치지 못하면 배포가 되지 않도록 문을 잠궈야 합니다. 물론 팀에서 이런 배포 프로세스를 만들기 전에 충분한 토론을 거쳐서 모두가 납득할 수 있는 수준에서 결정해야 합니다. 하지만 이렇게 프로세스에 넣으면서 단순히 의례적으로 해야 하는 과정으로 체크박스처럼 만들어버리면 역효과가 날 수 있다는 점에 주의해야 합니다. ('목표 달성'에 대한 압박을 주면 절대 원하는 결과를 얻을 수 없습니다.) 여기에는 많은 기법이 존재합니다. 모든 코드에 대한 커버리지를 확인하는 방법과 새 코드에 대한 커버리지만 확인하는 방법도 있습니다. 코드 커버리지를 정량적인 특정 숫자를 기준으로 평가해서 막는 방법도 있고 이전 버전과 비교해서 그 변화량에 맞춰 막는 방법도 있습니다. 또는 특정 범위의 커버리지는 무시하거나 또는 그 범위에만 가중치를 둬서 평가할 수도 있습니다. 이 코드 커버리지에 대한 팀 내의 약속은 잘 지켜져야 합니다. 코드 커버리지를 낮추는 위반이 발생하면 코드가 체크인되지 않고 프로덕션에 도달할 수 없어야 합니다.

만약 구글의 커버리지 인프라스트럭처에 대해 더 알고 싶다면 "Coverage at Google"을 참고하세요. 여기에서 읽을 수 있습니다.

Oleg Kiselyov의 글, Subtyping, Subclassing, and Trouble with OOP를 번역했습니다.


OOP의 인터페이스는 정말 구현과 분리되나요?

구현과 추상을 분리하는 것은 좋은 디자인의 궁극적인 목표 중 하나입니다. 일반적으로 개체 지향 프로그래밍(Object-oriented programming)과 캡슐화(encapsulation)를 통해서 그런 분리를 구현할 수 있다고 주장하며 그로 인해 더 안정적인 코드가 가능하다고 이야기합니다. 최종적으로 프로그래밍 방법론을 진정으로 평가하기 위해서 봐야 할 부분은 생산성과 품질입니다. 이 글은 간단한 예제를 통해서 개체 지향 프로그래밍이 정말 구현을 인터페이스와 분리할 수 있는지 확인합니다. 서브클래스와 서브타입의 차이를 이 예제에서 확인할 수 있습니다. 이 글은 우수한 소프트웨어 공학을 따르는 것으로 시작합니다. 그러므로 좋은 결과가 나오지 않으면 썩 좋은 기분은 아니겠죠.

이 글에서는 좀 더 "실제적인" 예제를 다루는데 촛점을 두고 있어서 직접 실행하고 결과를 볼 수 있습니다. 다만 예제이기 때문에 특정 언어로 구현하기는 해야 해서 여기서는 C++를 사용했습니다. 하지만 다른 개체 지향 언어(자바, 파이썬 등)에서도 비슷한 코드와 유사한 결론을 내리게 될 겁니다.

Bag을 구현하는 일감을 받았다고 가정해봅니다. 이 Bag은 순서 없는 컬랙션으로 중복된 내용을 포함할 수 있습니다. (예시에서는 정수 integer를 사용합니다.) 다음과 같은 인터페이스를 따릅니다.

typedef int const * CollIterator; // 원시적이나 동작합니다
class CBag {
  public:
    int size(void) const;             // bag 안에 있는 엘리먼트의 수
    virtual void put(const int elem); // bag 안에 엘리먼트 넣기
    int count(const int elem) const;  // 제시한 엘리먼트가 bag 안에 몇 차례나
                                      // 나타나는지 확인
    virtual bool del(const int elem); // 제시한 엘리먼트를 bag에서 제거
                                      // 존재하지 않으면 false 반환
    CollIterator begin(void) const;   // 표준 열거자 인터페이스
    CollIterator end(void) const;

    CBag(void);
    virtual CBag * clone(void) const; // bag 복사
  private:
    // 구현 상세는 생략합니다
}

다음은 CBag의 내부 구현을 모르고도 작성할 수 있는 유용한 CBag의 연산자입니다. CBag의 공개 인터페이스만 갖고 다음 함수를 작성할 수 있습니다.

// 표준 "print-on" 연산자
ostream& operator << (ostream& os, const CBag& bag);

// 두 bag을 병합합니다.
// 서브클래스의 복잡함을 피하기 위해 반환 타입은 void로 지정합니다.
// (현재 예시에서는 부수적인 부분이기 때문입니다)
void operator += (CBag& to, const CBag& from);

// a가 b의 하위 bag인지 판단합니다.
bool operator <= (const CBag& a, const CBag& b);

inline bool operator >= (const CBag& a, const CBag& b)
{ return b <= a; }

// bag의 구조적 동치를 확인합니다.
// 만약 동일한 갯수의 동일 엘리먼트를 반환하면 동일한 백입니다.
inline bool operator == (const CBag& a, const CBag& b)
{ return a <= b && a >= b; }

강조하고 싶은 부분은 CBag의 세부적인 구현을 알아야 하는 기능 수를 최소가 되도록 패키지를 설계했다는 점입니다. 검증 코드에서는 모든 CBag 패키지에 있는 모든 함수와 메소드를 테스트했고 일반적인 무공변성(invariant)을 검증했습니다.

이제 Set 패키지를 만들어야 한다고 지시를 받았다고 칩시다. 상사가 설명하기를 set은 순서 없는 컬렉션으로 각 엘리먼트는 꼭 하나만 존재해야 한다고 합니다. 즉, 중복이 없는 bag을 구현하려고 합니다. 이제 CBag 패키지를 보면 몇 가지 추가적인 변경이 필요하다는 걸 알게 될겁니다. bag을 활용해 Set을 정의한다면 CBag의 코드를 몇 가지 제약과 함께 재사용해서 간단하게 작성 할 수 있을 것으로 판단했습니다.

class CSet : public CBag {
  public:
  bool memberof(const  int elem) const { return count(elem) > 0; }

  // CBag::put을 오버라이드 합니다
  void put(const int elem)
  { if (!memberof(elem)) CBag::put(elem); }

  CSet * clone(void) const
  { CSet * new_set = new CSet(); *new_set += *this; return new_set; }
  CSet(void) {}
}

CSet과 CBag을 섞어 쓸 수 있게 CSet이 정의되었습니다. 다시 말하면 set += bag;이나 bag += set;도 동작합니다. 이런 연산자는 잘 정의가 되어 있어서 set은 각 엘리먼트가 딱 하나만 있도록 숫자를 세고 있게 구현되어 있습니다. 예를 들어서 set += bag;은 bag에 있는 모든 엘리먼트 중 이미 없는 것만 set에 추가합니다. bag += set;은 다른 bag과 병합하는 것과 다르지 않습니다.

CSet의 모든 메소드를 검증하는 테스트를 작성할 수 있을겁니다. (새로 작성한 것이나 bag에서 상속받은 것을 말이죠.) 일반적인 속성도 검증할 수 있습니다. 예를 들면 a+=aa입니다.

제 패키지에는 다음처럼 함수를 정의하고 구현했습니다.

// 예시 함수. 3개의 bag (a, b, c)를 받아서 a+b가 c의 하위 bag인지 검사.
bool foo(const CBag& a, const CBag& b, const CBag& c)
{
  CBag & ab = *(a.clone()); // 먼저 다른 영향이 없도록 복제합니다.
  ab += b;                  // ab는 이제 a와 b의 병합 bag이 됩니다.
  bool result = ab <= c;
  delete &ab;
  return result;
}

이 코드는 회귀 테스트에서 검증되었습니다. set으로 동일하게 작성해도 동작하는 것을 확인할 수 있습니다.

이후에 ab 개체가 불필요하게 힙 영역을 잡아먹는 것을 발견했습니다. 이 비효율을 개선하기 위해 다음처럼 다시 작성했습니다.

bool foo(const CBag& a, const CBag& b, const CBag& c)
{
  CBag ab;
  ab += a;
  ab += b;
  bool result = ab <= c;
  return result;
}

원래의 foo()와 완전히 동일한 인터페이스를 갖고 있습니다. 코드는 거의 변경하지 않았습니다. CBag 패키지만 생각한다면 새로운 구현도 동일하게 동작합니다. 하지만 저는 누가 제 CBag 패키지를 가져다가 쓰고 있는지 전혀 모릅니다. 여기서는 foo()를 대상으로 회귀 테스트를 다시 구현했고 모든 결과가 정상으로 나왔습니다.

하지만 새로 구현한 foo()와 함께 코드를 돌리면 무언가 달라졌다는 점을 알게 될 겁니다! 직접 코드 전체를 받아서 확인해보세요. vCBag1vCBag2를 만들어서 foo() 함수의 첫 구현과 두 번째 구현을 대상으로 테스트를 검증해보세요. 두 테스트는 모두 성공함과 동시에 동일한 결과를 반환합니다. 이제 vCSet1vCSet2를 만들어서 CSet 패키지를 테스트합니다. foo() 테스트만 제외하고 모두 성공할 겁니다. 이상하게도 foo() 결과가 달라졌습니다. 어느 foo() 구현이 CSet에 맞는 답을 반환하고 있는지는 논의의 여지가 있습니다. 어떤 쪽이 맞는 답이든 간에 순수 함수 foo()가 동일한 인터페이스를 따르고 있다면 잘 동작하는 코드를 고장내는 일은 없어야 할겁니다. 무슨 일이 일어난 걸까요?

특히 이 문제는 두 구현이 모두 교과서적인 방식대로 이뤄졌기 때문에 더 심란합니다. 안전하게 타입을 확인하고 코드를 작성했습니다. 캐스팅도 하지 않았습니다. g++ (2.95.2) 컴파일러를 사용하면서 -W-Wall 플래그를 활성화해도 경고 하나 존재하지 않습니다. 평소에는 엄청 귀찮게 만드는 플래그인데도 말이죠. 고의적으로 고장내려고 CBag의 메소드를 수정하거나 한 것도 아닙니다. CBag의 무공변성을 유지하기도 했습니다. (필요에 따라서 약간 약화시킨 부분도 있지만요.) 실제 세계의 클래스라면 대수학의 속성보다 더 불분명한 형태로 작성될 겁니다. 여기서는 CBag과 CSet 모두 회귀 테스트를 작성했고 테스트를 통과했습니다. 여기서 인터페이스와 구현을 분리하려는 모든 노력이 실패로 돌아갔습니다. 프로그래밍 언어나 프로그래밍 방법론이 이 문제에 대한 책임이 조금이라도 있는 건 아닐까요?

서브타입과 서브클래스

CSet의 문제점은 CSet의 디자인이 리스코프 치환 원칙(Liskov substitution principle, LSP)를 위반했기 때문입니다. CSet은 CBag의 서브클래스로 선언되었습니다. 그러므로 C++ 컴파일러의 타입체커는 CSet 개체를 전달하거나 함수에서 CSet 참조를 수행할 때 CBag 개체나 참조도 문제 없이 통과시킵니다. 그러나 CSet은 CBag의 서브타입이 아닙니다. 이 부분은 아래에서 간단한 증명으로 살펴보겠습니다.

Bags와 Sets를 순수 으로 고려해서 어떤 상태나 고유한 동작을 수행하지 않는 형태로 만드는 것도 한 방식이 될 수 있습니다. 즉, 정수처럼 다룬다는 이야기죠. (이 문제 해결 글에서 다룹니다.) 또 다른 방식은 개체 지향 프로그래밍으로 개체에 상태와 동작을 캡슐화 하는 접근법입니다. 동작의 의미는 개체가 메시지를 받거나 응답을 보내고, 상태도 변경할 가능성도 있다는 뜻입니다. Bag과 Set의 관계는 제쳐두고 둘을 따로 생각해봅시다. 여기서는 논의를 조금 더 명확하게 하기 위해서 간결한 표기법을 활용하겠습니다.

Bag을 개체로 정의하고 두 메시지를 받는다고 가정합니다.

(send a-Bag 'put x)     ; x 엘리먼트를 bag에 넣습니다.
(send a-Bag 'count x)   ; x 엘리먼트가 몇 개 있는지 확인합니다.
                        ; 그 과정에서 상태를 변경하지 않습니다.

이제 Set도 비슷하게 정의합니다.

(send a-Set 'put x)     ; x 엘리먼트를 set에 (존재하지 않으면) 넣습니다.
(send a-Set 'count x)   ; x 엘리먼트가 set에 몇 개 있는지 확인합니다.
                        ; (항상 0 또는 1이 나옵니다.)

이제 함수를 생각해봅시다.

(define (fnb bag)
  (send bag 'put 5)
  (send bag 'put 5)
  (send bag 'count 5))

이 함수의 동작은 다음처럼 정리할 수 있습니다. "Bag이 하나 제공되면 두 엘리먼트를 추가하고 반환한다."

(+2 (send orig-bag 'count 5))

기술적으로는 fnb 함수에 Set 개체를 전달하는 것이 가능합니다. Bag이 putcount 메시지를 이해할 수 있는 것처럼 Set도 이해하기 떄문입니다. 하지만 fnb에 Set 개체를 넣으려고 하면 위에서 명시된 것처럼 함수의 사후 조건(post-condition)을 어기게 됩니다. 그러므로 set 개체를 bag 개체가 필요한 곳에 넣으면 어떤 프로그램에서 기대했던 동작이 달라지게 됩니다. 리스코프 치환 원칙(LSP)에 따르면 Bag을 Set으로 치환할 수 없고 Set은 Bag의 서브타입이 될 수 없습니다.

다음 함수를 고려해봅니다.

(define (fns set)
  (send set 'put 5)
  (send set 'count 5))

이 함수의 동작은 이렇습니다. "Set이 하나 제공되면 엘리먼트를 하나 추가하고 1을 반환한다." 이 함수에 bag을 전달하면 fns 함수는 1보다 큰 수를 반환할 수도 있습니다. (왜냐면 bag도 putcount를 구현하고 있기 때문입니다.)

그러므로 개체지향 관점에서 본다면 Bag과 Set은 어느 쪽의 서브타입도 아닙니다. 이게 이 문제에서 가장 중요한 부분입니다. Bag과 Set은 단지 닮았을 뿐입니다. Bag과 Set의 인터페이스와 구현은 그 유사성 때문에 서로를 서브타입으로 삼으려고 합니다. 다만 그렇게 서브타입으로 만드는 것으로 LSP를 위반하게 됩니다. 위에서처럼 눈에 잘 띄지 않는 오류를 마주할 각오를 해야만 할겁니다. 위에서 든 예제는 LSP를 의도적으로 어겨서 어떻게 교활한 오류를 만들어 내는지, 그리고 얼마나 찾아내는데 어려운지 보여줬습니다. Set과 Bag은 아주 비슷하면서도 간단한 타입으로 실무에서 만나게 될 코드보다 훨씬 단순한 예시입니다. OOP의 관점에서 봤을 때 LSP는 명확하게 들어나지 않는 부분인 것을 감안해야 합니다. 컴파일러가 이 문제를 지적해주리라고 기대하기는 어렵습니다. 회귀 테스트에도 의존할 수 없습니다. 수작업으로 직접 문제를 봐야만 알 수 있습니다.

서브타입과 불변성

누구는 이렇게 얘기할 수도 있습니다. "Set은 Bag이 아니지만 불변 Set은 불변 Bag입니다", 라고 말이죠. 하지만 그렇지도 않습니다. 불변성을 얘기한다고 하더라도 파생된 데이터 클래스를 서브타입으로 고려할 수는 없습니다. 앞서 예제와는 조금 다른 다음 코드를 살펴봅시다. 다시 C++ 코드로 보겠지만 다른 코드로 작성하더라도 이 예제는 동일할 겁니다.

class BagV {
  virtual BagV put(const int) const;
  int count(const int) const;
  // ... // 다른 유사한 const 멤버
}

class SetV {
  virtual SetV put(const int) const;
  int count(const int) const;
  // ... // 다른 유사한 const 멤버
}

BagV와 SetV의 인스턴스는 불변입니다. 하지만 각 클래스는 여전히 서로의 서브타입이 아닙니다. 다음과 같은 폴리모픽(polymorphic) 함수를 생각해봅시다.

template <typename T> int f(const T& t)
{ return t.put(1).count(1); }

BagV 인스턴스에서 다음 함수의 동작은 무공변적으로 표현할 수 있습니다.

f(bag) == 1 + bag.count(1)

만약 asetv = SetV().put(1)처럼 할당하고 f()에 전달하면 위의 무공변성을 어기게 됩니다. 정리하면 이렇습니다. LSP에 의해서 SetV는 BagV를 치환할 수 없습니다. 그러므로 SetV는 BagV가 아닙니다.

위 함수를 다시 정의하면 이렇습니다.

int fb(const BagV& bag) { return bag.put(1).count(1); }

물론 SetV 인스턴스를 지금도 이 함수에 넣을 수는 있습니다. 예를 들면 SetV를 BagV의 서브클래스로 만들거나 reinterpret_cast<const BagV&>(aSetV)식으로 집어넣을 수 있습니다. 이렇게 작성하면 오류가 발생하지는 않지만 fb()의 무공변성을 깨고 프로그램의 동작을 예측할 수 없는 방향으로 바꾸게 됩니다. "BagV는 SetV의 서브타입이 아니다", 라는 명제에도 유사한 논의가 가능합니다.

C++ 개체는 레코드 기반입니다. 서브클래스는 래코드를 확장하는 방법이며 부모 레코드의 일부를 수정할 가능성이 존재합니다. 이 일부 영역에 대해서는 수정이 가능하다는 명시적인 표시를 위해 virtual 키워드를 사용합니다. 이 맥락에서 보면, 변형을 방지하면서도 동작을 덮어 쓸 수 있게 했지만 동시에 서브클래스가 서브타입을 수반하게 만듭니다. 이게 B규칙이 존재하는 이유입니다.

하지만 개체의 상태를 불변성으로 선언하는 것만으로는 서브타입으로 파생되지 않도록 보장하기에 충분하지 않습니다. 개체는 부모를 직접 수정하지 않고도 부모의 동작을 덮어쓸 수 있습니다. 개체가 함수 클로저처럼 메시지를 받을 때 응답형으로 콜백 등의 핸들러가 있거나 프로토타입 기반의 개체지향 시스템에서는 부모 클래스를 수정하지 않고도 동작을 조작할 수 있습니다. 파생 개체가 기반 개체를 수정할 수 있다면 동작 덮어쓰기를 암묵적으로 허용하는 것이나 마찬가집니다. 예를 들어 A 개체가 내부에 B 개체를 저장해놓고 M 메시지를 받을 때마다 B 개체에 전달해주는 경우를 생각해봅시다. A 개체에서 파생한 C 개체가 그 내부 동작을 덮어쓴다면 여전히 M 메시지를 받으면서도 다른 형태로 동작하게 됩니다.

예를 들면 Scheme에서 순수 함수형 개체지향 시스템을 구현할 수 있습니다. 개체의 독자성, 상태, 동작, 상속과 다형성까지 지원하며 시스템 내 모든 것이 불변입니다. 하지만 여전히 BagV와 같은 것도 정의할 수 있으며 SetV를 put 메시지 핸들러를 덮어쓰는 방식으로 파생시켜 사용하는 것도 가능합니다. 다만 이런 접근 방식은 여기서도 좋지 않고 앞서 LSP를 어겼을 때 나타나는 문제와 유사합니다. 이 예시는 불변성 또한 개체 파생에서 나타나는 서브타이핑 문제에 자유롭지 않다는 점을 보여줍니다.

프로그래밍을 한다면 컴파일러는 빼놓을 수 없는 부분입니다. 항상 사용하지만 어떻게 내부적으로 구현되어 있는지는 잘 알기 어려울 수 있습니다. 이 글은 작은 컴파일러를 직접 만들어보는 과정을 통해서 현대적인 컴파일러가 어떤 방식으로 동작하는지 설명합니다. 적은 양의 코드지만 구조나 동작 원리를 이해하는 데에는 부족함이 없습니다. 더 자세히 알고 싶다면 찾아볼 수 있도록 각각의 키워드를 잘 알려주고 있어서 아주 유익합니다.

이 포스트는 jamiebuilds/the-super-tiny-compiler의 번역글입니다. 그리고 전체 코드는 the-super-tiny-compiler.js에서 확인할 수 있습니다.


아주 조그마한 컴파일러 만들기

오늘은 함께 컴파일러를 작성하려고 합니다. 하지만 그냥 아무 컴파일러가 아닌 엄청나게 작고 조그만 컴파일러를 만들겁니다! 컴파일러가 엄청 작은 나머지 파일에 있는 주석을 모두 지운다면 코드는 200여 줄만 남습니다.

여기서는 lisp 스타일의 함수 호출을 C 스타일의 함수 호출로 컴파일 하려고 합니다. 물론 이 스타일에 익숙하지 않을 수 있으니 짧게 설명하고 지나갈게요! 만약 두 함수 addsubtract가 각 스타일로 작성되었다고 하면 다음과 같습니다.

               LISP 스타일                 C 스타일
2 + 2          (add 2 2)                 add(2, 2)
4 - 2          (subtract 4 2)            subtract(4, 2)
2 + (4 - 2)    (add 2 (subtract 4 2))    add(2, subtract(4, 2))

간단하죠?

이게 바로 우리가 컴파일 할 내용입니다. 완벽한 LISP이나 C 문법은 아니긴 하지만 요즘 현대적인 컴파일러가 어떤 역할을 하고 있는지 대략적으로 보여주기엔 적당한 예제입니다.

대부분 컴파일러는 분석, 변환, 코드 생성 같은 단계를 거칩니다.

  1. 분석(parsing) 단계에서는 코드 그대로를 좀 더 추상화된 코드로 변환합니다.
  2. 변환(transformation) 단계는 이 추상화된 코드를 컴파일러가 하려는 작업에 용이하도록 조작합니다.
  3. 코드 생성(code generation) 단계는 이 변환된 코드 표현을 갖고서 새로운 코드 형태로 변환하는 일을 합니다.

컴파일 단계

분석 (Parsing)

분석 단계는 일반적으로 어휘 분석과 구문 분석 단계로 나눠집니다.

  1. 어휘 분석(Lexical Analysis) 단계는 코드를 더 작은 형태인 토큰(token) 단위로 나누는 작업을 합니다. 토크나이저(tokenizer) 또는 렉서(lexer)가 이 작업을 수행합니다.

    토큰은 배열 형태의 작은 개체로 한 조각의 문법을 담고 있습니다. 숫자나 꼬리표(labels), 구두법, 연산자 등 어떤 것이든 이렇게 저장됩니다.

  2. 구문 분석(Syntatic Analysis) 단계는 앞 단계에서 만든 토큰을 각각의 문법이나 서로 관계를 잘 표현하는 형태로 재구성하게 됩니다. 이 과정으로 만든 결과물을 중간 표현(intermediate representation) 또는 추상 구문 트리(Abstract Syntax Tree)이라고 말합니다.

    추상 구문 트리(줄여서 AST)는 깊숙하게 중첩된 형태의 개체로 존재합니다. 그 형태로 코드가 쉽게 동작할 수 있으며 동시에 많은 정보를 알려줍니다.

다음 구문을 봅시다.

(add 2 (subtract 4 2))

이 구문에서 생성한 토큰은 다음과 같은 모습입니다.

[
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'add'      },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'subtract' },
  { type: 'number', value: '4'        },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: ')'        },
  { type: 'paren',  value: ')'        },
]

그리고 추상 구문 트리(AST)는 이런 모습이 될 겁니다.

{
  type: 'Program',
  body: [{
    type: 'CallExpression',
    name: 'add',
    params: [{
      type: 'NumberLiteral',
      value: '2',
    }, {
      type: 'CallExpression',
      name: 'subtract',
      params: [{
        type: 'NumberLiteral',
        value: '4',
      }, {
        type: 'NumberLiteral',
        value: '2',
      }]
    }]
  }]
}

변환 (Transformation)

컴파일러의 다음 단계는 변환입니다. 다시 말하면 앞 단계에서 생성한 AST를 갖고서 변환 작업을 수행합니다. AST를 동일한 언어로 조작하거나 완전히 다른 언어로 번역할 수도 있습니다.

이제 이 AST를 어떻게 변환하는지 확인해봅시다.

AST를 보면 비슷하게 생긴 요소가 많은걸 알 수 있습니다. 각 개체마다 타입 속성(property)를 포함하고 있습니다. 각각 개체를 AST 노드라고 부릅니다. 이 각각의 노드는 여러 속성이 있으며 동시에 트리의 일부를 각자 정의하는 역할을 하고 있습니다.

"NumberLiteral" 노드를 상상해봅시다.

{
  type: 'NumberLiteral',
  value: '2',
}

또는 "CallExpression" 이라는 노드도 존재할 수 있죠.

  {
    type: 'CallExpression',
    name: 'subtract',
    params: [...여기에 중첩 노드가 위치합니다...],
  }

AST를 변환하면서 속성을 추가하거나 제거, 치환하는 식으로 노드를 조작할 수 있습니다. 그러면서 새로운 노드를 추가하거나 제거하거나 아니면 아예 AST를 그대로 두고 완전 새로운 트리를 만들어낼 수도 있습니다.

여기서는 새로운 언어로 변환하는 것이 목표이기 때문에 목표가 되는 언어에 딱 맞춰서 새로운 AST를 만들기로 합니다.

순회 (Traversal)

이 노드를 모두 탐색하려면 일일이 순회 할 필요가 있습니다. 이 순회 과정은 AST의 각 노드를 깊이 우선으로 탐색합니다.

{
  type: 'Program',
  body: [{
    type: 'CallExpression',
    name: 'add',
    params: [{
      type: 'NumberLiteral',
      value: '2'
    }, {
      type: 'CallExpression',
      name: 'subtract',
      params: [{
        type: 'NumberLiteral',
        value: '4'
      }, {
        type: 'NumberLiteral',
        value: '2'
      }]
    }]
  }]
}

이 AST라면 다음 같은 순서로 접근하게 됩니다.

  1. Program - AST의 가장 윗 단계에서 시작
  2. CallExpression (add) - Program의 첫 요소로 이동
  3. NumberLiteral (2) - CallExpression 속성의 첫 번째 요소로 이동
  4. CallExpression (subtract) - CallExpression 속성의 두 번째 요소로 이동
  5. NumberLiteral (4) - CallExpression 속성의 첫 번째 요소로 이동
  6. NumberLiteral (2) - CallExpression 속성의 두 번째 요소로 이동

만약 분리된 AST를 생성하는 것 대신에 AST를 직접 변환한다면 여기서 온갖 종류의 추상적 접근을 소개해야 합니다. 다만 여기서의 목적으로는 단순히 트리 내 각 노드를 일일이 보는, 방문하는 정도면 충분하겠습니다.

여기서 "방문하다(visiting)"이란 표현을 사용한건 이유가 있습니다. 바로 개체 구조의 요소를 대상으로 연산하게 되는데 거기서 사용하는 패턴이 비지터 패턴을 사용하기 때문입니다.

방문자(Visitors)

여기서 "방문자" 개체를 만드는데 이 개체에 각각 메소드로 다른 노드 타입을 처리하도록 하는게 기본 아이디어입니다.

var visitor = {
  NumberLiteral() {},
  CallExpression() {},
};

AST를 순회하면서 노드에 "입장"할 때면 그 노드 타입에 맞춰서 이 방문자 개체에 있는, 동일한 이름의 메소드를 호출할 겁니다.

이걸 유용하게 만들려면 해당 노드와 함께 부모 노드의 참조도 그 메소드에 전달해야 합니다.

var visitor = {
  NumberLiteral(node, parent) {},
  CallExpression(node, parent) {},
};

하지만 "퇴장"하는 경우에 무언가를 호출해야 하는 가능성도 있습니다. 앞서 트리 구조를 목록 형태로 다시 확인해봅시다.

  • Program
    • CallExpression
      • NumberLiteral
      • CallExpression
        • NumberLiteral
        • NumberLiteral

트리를 순회해서 가지(branch) 끝까지 내려가면 더이상 갈 곳이 없는 곳에 도달하게 됩니다. 각 가지 끝에 도달하면 그 가지에서 "퇴장"해야 합니다. 즉 트리를 타고 내려가면 각 노드에 "입장"해야 하고 다시 올라오면서 "퇴장"해야 하는 겁니다.

-> Program (입장)
  -> CallExpression (입장)
    -> Number Literal (입장)
    <- Number Literal (퇴장)
    -> Call Expression (입장)
        -> Number Literal (입장)
        <- Number Literal (퇴장)
        -> Number Literal (입장)
        <- Number Literal (퇴장)
    <- CallExpression (퇴장)
  <- CallExpression (퇴장)
<- Program (퇴장)

최종적으로 입장과 퇴장을 처리할 수 있는 방문자 개체의 모습은 다음과 같습니다.

var visitor = {
  NumberLiteral: {
    enter(node, parent) {},
    exit(node, parent) {},
  }
};

코드 생성 (Code Generation)

컴파일러 최종 단계는 코드 생성입니다. 컴파일러는 종종 변환 단계서 하는 작업과 겹치는 작업을 여기서 하게 되는데 대부분 코드 생선 단계에서는 AST를 가지고 문자열 같은 코드 형태로 출력하는 일을 하게 됩니다.

코드 생성기는 여러 다른 방식으로 동작하는데 어떤 컴파일러는 앞서 생성한 토큰을 재활용하기도 하고 또 다른 방식은 완전히 코드와 분리된 표현식을 생성해서 노드를 선형적으로 생성하기도 합니다. 하지만 여기서 얘기하자면 대부분은 동일한 AST를 생성하기 때문에 여기서도 그 방법에 집중하려고 합니다.

코드 생성기는 모든 다른 노드 타입을 어떻게 "출력"하는지 실질적으로 알고 있게 될 겁니다. 또한 중첩된 노드를 하나의 긴 문자열 코드로 전부 출력할 때까지 스스로를 재귀적으로 호출하도록 작성하려고 합니다.


여기까지! 컴파일러에 필요한 모든 부분을 확인했습니다.

물론 모든 컴파일러가 여기서 설명한 것처럼 완전 동일하게 동작하진 않을 겁니다. 컴파일러는 각각 다른 용도에 따라 쓰이기도 하고 여기서 설명보다 더 많은 단계로 동작하기도 합니다.

하지만 컴파일러 대부분에서 찾을 수 있는 고수준의 개념은 여기서 다 얘기했습니다. 이제 모든 내용을 설명했으니 가서 컴파일러를 직접 만들 수 있으시겠죠?

물론 농담입니다 :) 여기서 함께 작성해보도록 합시다!

코드 작성하기

토크나이저 (Tokenizer)

컴파일러의 가장 첫 단계인 분석에서 어휘 분석을 토크나이저로 시작합니다. 다음 코드 문자열을 갖고 토큰 배열 형태로 변환할 겁니다.

(add 2 (subtract 4 2))   =>   [{ type: 'paren', value: '(' }, ...]

이제 코드를 작성해봅시다.

// 여기서 문자열 형태로 코드를 받을 겁니다. 먼저 변수 둘을 준비합니다.
function tokenizer(input) {

  // `current` 변수는 커서처럼 코드에 어느 위치에 있는지 저장합니다.
  let current = 0;

  // `tokens`는 토큰을 보관할 배열입니다.
  let tokens = [];

  // 먼저 반복문 내에서 증가하는 `current` 변수를 검사하도록 `while` 반복문을
  // 만듭니다.
  //
  // 토큰이 어떤 길이가 되든 처리할 수 있도록 하기 위해서 이렇게 작성했습니다.
  // 즉 반복문을 한 번만 거치더라도 원하는 대로 커서의 위치를 변경하는 것이
  // 가능합니다.
  while (current < input.length) {

    // 먼저 `current` 위치에 존재하는 문자를 `input`에 저장합니다.
    let char = input[current];

    // 가장 먼저 열린 소괄호를 확인하려고 합니다. 이 부분은 나중에
    // `CallExpression`로 다뤄질 부분인데 일단 지금은 문자만 신경쓰도록
    // 합니다.
    //
    // 열린 소괄호가 있나요?
    if (char === '(') {

      // 있다면 `paren` 타입의 새 토큰을 만들어서 집어넣습니다.
      // 값으로 열린 소괄호를 넣습니다.
      tokens.push({
        type: 'paren',
        value: '(',
      });

      // 한 글자를 확인했으니 `current`를 증가해서 커서를 옮깁니다.
      current++;

      // 반복문을 다음 사이클로 넘어가기 위해 `continue`를 사용합니다. 
      continue;
    }

    // 다음으로 확인할 문자는 닫힌 소괄호입니다. 앞서 수행한 방식과 동일하게
    // 닫힌 소괄호를 확인하고, 새로운 토큰을 만들고, `current`를 옮기고,
    // `continue`로 넘어갑니다.
    if (char === ')') {
      tokens.push({
        type: 'paren',
        value: ')',
      });
      current++;
      continue;
    }

    // 다음 차례로 넘어갑니다. 이제 공백을 확인하려고 합니다. 이 과정이 조금
    // 흥미롭게 보일 수 있습니다. 문자 사이에 공백이 있는지 없는지는 중요하긴
    // 하지만 토큰으로 저장할 만큼 중요하진 않다는 부분인데요. 토큰으로 만들어도
    // 나중에 그 토큰을 버리는 일이나 하게 되기 때문에 그렇습니다.
    //
    // 그러니까 여기서는 단순히 공백이 존재하는지 확인만 합니다. 존재한다면
    // 커서를 다음 문자로 옮기고 반복문을 다음 사이클로 넘깁니다.
    let WHITESPACE = /\s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }

    // 다음 토큰 타입은 숫자입니다. 여기서는 앞서 본 방식과는 조금 다르게 처리
    // 하게 되는데요. 그 이유는 한 글자만 확인해서 숫자라면 그게 한 자리 숫자인지
    // 여러 자리 숫자인지 확인해서 일련의 숫자를 모두 하나의 토큰에 저장해야
    // 하기 때문입니다.
    //
    //   (add 123 456)
    //        ^^^ ^^^
    //        즉, 여기가 숫자 토큰 두 개로 처리가 되어야 합니다
    //
    // 먼저 숫자가 존재하는지 확인부터 합니다.
    let NUMBERS = /[0-9]/;
    if (NUMBERS.test(char)) {

      // `value` 변수를 만들어서 문자가 숫자라면 여기에 쌓도록 문자열로
      // 지정했습니다.
      let value = '';

      // 그런 후에 작은 반복문으로 그 이후에 나오는 문자를 하나씩 확인해서
      // 숫자가 아닌 글자가 나올 때까지 확인합니다. 확인 할 때마다 숫자가 나오면
      // 그 숫자는 `value` 변수에 붙여서 저장하고 `current`를 증가하며 다음
      // 문자를 검사하게 됩니다. 숫자가 아니라면 이 작은 반복문은 종료됩니다.
      while (NUMBERS.test(char)) {
        value += char;
        char = input[++current];
      }

      // 이 과정이 끝나면 `number` 토큰을 숫자와 함께 `tokens` 배열에 저장합니다.
      tokens.push({ type: 'number', value });

      // 그리고 반복문을 다음 사이클로 넘깁니다.
      continue;
    }

    // 이 언어의 문자열 처리를 위해서 쌍따옴표(")로 감싼 문자열을 검사합니다.
    //
    //   (concat "foo" "bar")
    //            ^^^   ^^^ 문자열 토큰 둘
    //
    // 먼저 열린 따옴표를 확인합니다.
    if (char === '"') {
      // 문자열 토큰을 만들기 위해 `value` 변수를 준비합니다.
      let value = '';

      // 먼저 열린 쌍따옴표를 건너 뜁니다.
      char = input[++current];

      // 그리고 각 문자를 다음 쌍따옴표가 나올 때까지 `value`에 저장하며
      // 커서를 계속 옮깁니다. 쌍따옴표가 나오면 멈춥니다.
      while (char !== '"') {
        value += char;
        char = input[++current];
      }

      // 닫는 쌍따옴표도 건너 뜁니다.
      char = input[++current];

      // 이제 `string` 토큰을 만들어서 `tokens` 배열에 저장합니다.
      tokens.push({ type: 'string', value });

      continue;
    }

    // 마지막 토큰 타입은 `name` 토큰입니다. 숫자 대신 이 일련의 문자는
    // lisp 문법에서 함수의 이름을 의미하게 됩니다.
    //
    //   (add 2 4)
    //    ^^^
    //    이름 토큰
    //
    let LETTERS = /[a-z]/i;
    if (LETTERS.test(char)) {
      let value = '';

      // 앞서 방법과 동일하게 반복문을 사용해서 `value` 값을 만듭니다.
      while (LETTERS.test(char)) {
        value += char;
        char = input[++current];
      }

      // 그리고 값을 `name` 타입 토큰으로 저장하고 반복문을 돌립니다.
      tokens.push({ type: 'name', value });

      continue;
    }

    // 최종적으로 앞에서 확인하지 못한 문자는 오류를 내고 거기서 종료해버립니다.
    throw new TypeError('I dont know what this character is: ' + char);
  }

  // 토큰 배열을 반환하며 `tokenizer`를 끝냅니다.
  return tokens;
}

파서 (Parser)

파서에서는 토큰이 담긴 배열을 AST로 변환하려고 합니다.

[{ type: 'paren', value: '(' }, ...]  =>  { type: 'Program', body: [...] }

코드를 작성해봅시다.

'use strict';
// 먼저 `tokens` 배열을 받는 `parser` 함수를 정의합니다.
function parser(tokens) {

  // 앞서 방법처럼 `current` 변수에 현재 위치를 저장할 겁니다.
  let current = 0;

  // 하지만 이번에는 `while` 반복문 대신에 재귀를 사용하려고 합니다. 그래서
  // `walk` 함수를 정의합니다.
  function walk() {

    // 이 함수에서 `current` 위치에 있는 토큰을 가져오는 것으로 작업을
    // 시작합니다.
    let token = tokens[current];

    // 각각의 토큰을 다른 코드 경로로 분리하려고 합니다. 먼저 `number`
    // 토큰부터 시작합니다.
    //
    // 먼저 `number` 토큰인지 검사부터 합니다.
    if (token.type === 'number') {

      // 숫자 토큰이면 `current`를 증가해서 다음 토큰으로 커서를 옮깁니다.
      current++;

      // 그리고 새 AST 노드인 `NumberLiteral`을 반환하면서 토큰에 담긴 값을
      // 이 노드에 저장합니다.
      return {
        type: 'NumberLiteral',
        value: token.value,
      };
    }

    // 문자열 토큰이 있다면 위에서 숫자 토큰을 처리했던 방식처럼
    // `StringLiteral` 노드를 만들어서 토큰의 값을 저장합니다.
    if (token.type === 'string') {
      current++;

      return {
        type: 'StringLiteral',
        value: token.value,
      };
    }

    // 다음은 CallExpessions를 확인할 차례입니다. 먼저 열린 소괄호를 확인
    // 하는 것으로 시작합니다.
    if (
      token.type === 'paren' &&
      token.value === '('
    ) {
      
      // AST에서는 괄호가 의미 없으므로 `current`를 증가해서 다음 토큰으로
      // 넘어갑니다.
      token = tokens[++current];

      // 이제 `CallExpression`이라는 기반 노드를 생성합니다. 그리고 현재 토큰
      // 값으로 이름을 지정합니다. 열린 괄호 뒤에 오는 이름이 바로 호출하려는
      // 함수의 이름이기 때문입니다. (예를 들어 `(add 2 3)`을 보면 `(` 뒤에
      // 바로 함수 이름이 나오는 걸 볼 수 있습니다.)
      let node = {
        type: 'CallExpression',
        name: token.value,
        params: [],
      };

      // 이제 이름 토큰 다음 토큰을 얻기 위해 `current`를 한번 더 옮깁니다.
      token = tokens[++current];

      // 이제는 닫힌 소괄호가 나올 때까지 각 토큰을 반복적으로 검사해서
      // `CallExpression`에 있는 `params`에 계속 넣으려고 합니다.
      //
      // 여기서부터 코드는 재귀로 동작합니다. 중첩된 노드를 직접 무한대로 열어서
      // 처리하는 것 대신에 재귀로 문제를 해결할 수 있습니다.
      //
      // 이 방식을 설명하기 위해 Lisp 코드를 다시 봅니다. 이제 `add` 함수를
      // 보면 하나의 숫자와 숫자가 포함된 `CallExpression`이 중첩되어 있는
      // 것을 확인할 수 있습니다.
      //
      //   (add 2 (subtract 4 2))
      //
      // 이 코드로 생성한 토큰을 보면 닫힌 소괄호가 여러 차례 나타난다는 점을
      // 확인할 수 있습니다.
      //
      //   [
      //     { type: 'paren',  value: '('        },
      //     { type: 'name',   value: 'add'      },
      //     { type: 'number', value: '2'        },
      //     { type: 'paren',  value: '('        },
      //     { type: 'name',   value: 'subtract' },
      //     { type: 'number', value: '4'        },
      //     { type: 'number', value: '2'        },
      //     { type: 'paren',  value: ')'        }, <<< 닫힌 소괄호
      //     { type: 'paren',  value: ')'        }, <<< 닫힌 소괄호
      //   ]
      //
      // `walk` 함수를 중첩해서 호출하는 방식으로 `current` 변수를 계속
      // 증가시키는데 이 방법으로 중첩된 `CallExpression`을 처리합니다.
      
      // 그런 이유로 `while` 반복문을 사용해서 계속 `walk` 함수를 호출하는데
      // `type`이 `'paren'`이고 `value`에 닫힌 소괄호가 나올 때까지만
      // 반복합니다.
      while (
        (token.type !== 'paren') ||
        (token.type === 'paren' && token.value !== ')')
      ) {
        // `walk` 함수를 호출해서 반환되는 `node`를 `node.params` 배열에
        // 추가합니다.
        node.params.push(walk());
        token = tokens[current];
      }

      // 최종적으로 `current`를 한 번 옮기는 것으로 닫는 소괄호를 건너 뜁니다.
      current++;

      // 그리고 노드를 반환합니다.
      return node;
    }

    // 만약 인식할 수 없는 토큰을 만나면 오류로 처리합니다.
    throw new TypeError(token.type);
  }

  // 이제 AST를 만드려고 합니다. 이 AST의 뿌리로 볼 수 있는 `Program`노드를
  // 다음처럼 작성합니다.
  let ast = {
    type: 'Program',
    body: [],
  };

  // 이제 `walk` 함수를 호출합니다. 호출해서 생성한 노드를 `ast.body`
  // 배열에 저장합니다.
  //
  // 여기서 반복문으로 이 호출을 수행하는 이유는 `CallExpression`이 중첩되지
  // 않고 다음처럼 나란히 존재할 경우도 있기 때문입니다.
  //
  //   (add 2 2)
  //   (subtract 4 2)
  //
  while (current < tokens.length) {
    ast.body.push(walk());
  }

  // 최종적으로 생성한 AST를 반환합니다.
  return ast;
}

트래버서 (Traverser, 순회자)

AST까지 만들었으니 방문자가 각 노드를 방문하는 작업을 해야 합니다. 매 노드를 방문하면서 노드의 타입과 일치하는 방문자의 메소드를 호출하는 코드를 작성해야 합니다.

traverse(ast, {
  Program: {
    enter(node, parent) {
      // ...
    },
    exit(node, parent) {
      // ...
    },
  },

  CallExpression: {
    enter(node, parent) {
      // ...
    },
    exit(node, parent) {
      // ...
    },
  },

  NumberLiteral: {
    enter(node, parent) {
      // ...
    },
    exit(node, parent) {
      // ...
    },
  },
});

이제 코드로 적어봅시다.

// 이제 AST와 방문자를 전달할 수 있는 순회 함수를 작성합니다.
// 내부에서는 두 함수를 정의합니다.
function traverser(ast, visitor) {

  // `traverseArray` 함수는 배열을 대상으로 `traverseNode` 함수를
  // 반복해서 실행합니다. 이 함수는 아래서 정의합니다.
  function traverseArray(array, parent) {
    array.forEach(child => {
      traverseNode(child, parent);
    });
  }

  // `traverseNode`는 `node`와 부모 노드인 `parent` 노드를 받습니다.
  // 그래서 이 둘을 방문자 메소드에 전달하게 됩니다.
  function traverseNode(node, parent) {

    // 방문자에 노드의 `type`과 일치하는 메소드가 있는지 확인합니다. 
    let methods = visitor[node.type];

    // 만약 그 메소드에 입장 할 때 실행할 내용이 있다면 `enter` 메소드를
    // `node`와 `parent`를 사용해서 실행합니다.
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }

    // 노드 타입에 따라 다른 방식으로 처리합니다. 
    switch (node.type) {

      // 최상위 레벨인 `Program`으로 시작합니다. 프로그램 노드는 body라는
      // 속성에 노드 배열을 보관하고 있습니다. 이 배열을 순회하며 확인하기
      // 위해 `traverseArray`를 호출합니다.
      //
      // (`traverseArray`는 `traveseNode`를 호출하니까 트리 전체를
      // 재귀적으로 순회하게 됩니다.)
      case 'Program':
        traverseArray(node.body, node);
        break;

      // 다음으로 `CallExpression`을 만나면 `params` 배열을 순회하도록
      // 코드를 작성합니다.
      case 'CallExpression':
        traverseArray(node.params, node);
        break;

      // `NumberLiteral`과 `StringLiteral`를 만나면 순회해서 확인할 자식
      // 노드가 없기 때문에 별도 처리 없이 끝냅니다.
      case 'NumberLiteral':
      case 'StringLiteral':
        break;

      // 알 수 없는 노드 타입을 만나면 오류로 처리합니다.
      default:
        throw new TypeError(node.type);
    }

    // 만약 해당 노드 타입에 `exit` 메소드, 즉 퇴장 메소드가 정의되어 있다면
    // 해당 메소드를 `node`, `parent`와 함께 호출합니다.
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }

  // 이제 모든 함수가 준비되었습니다. AST와 null을 `traverseNode` 함수에 넣어
  // 실행합니다. 왜 parent 자리가 null일까요? AST에서 가장 위에 있다면 이미 더
  // 이상 위로 올라갈 곳이 없기 때문입니다.
  traverseNode(ast, null);
}

트랜스포머 (transformer, 변환자)

다음은 트랜스포머입니다. 생성한 AST를 방문자와 함께 순회 함수로 호출하면 새로운 AST를 생성하게 됩니다.

----------------------------------------------------------------------------
  원본 AST                         |   변환된 AST
----------------------------------------------------------------------------
  {                                |   {
    type: 'Program',               |     type: 'Program',
    body: [{                       |     body: [{
      type: 'CallExpression',      |       type: 'ExpressionStatement',
      name: 'add',                 |       expression: {
      params: [{                   |         type: 'CallExpression',
        type: 'NumberLiteral',     |         callee: {
        value: '2'                 |           type: 'Identifier',
      }, {                         |           name: 'add'
        type: 'CallExpression',    |         },
        name: 'subtract',          |         arguments: [{
        params: [{                 |           type: 'NumberLiteral',
          type: 'NumberLiteral',   |           value: '2'
          value: '4'               |         }, {
        }, {                       |           type: 'CallExpression',
          type: 'NumberLiteral',   |           callee: {
          value: '2'               |             type: 'Identifier',
        }]                         |             name: 'subtract'
      }]                           |           },
    }]                             |           arguments: [{
  }                                |             type: 'NumberLiteral',
                                   |             value: '4'
---------------------------------- |           }, {
                                   |             type: 'NumberLiteral',
                                   |             value: '2'
                                   |           }]
 (미안하지만 변환된 쪽이 더 길어요..)      |         }
                                   |       }
                                   |     }]
                                   |   }
----------------------------------------------------------------------------

이제 AST를 받는 변환 함수를 작성합니다.

function transformer(ast) {

  // 먼저 `newAst`를 생성하는데 이전 AST와 같이 프로그램 노드로 시작합니다.
  let newAst = {
    type: 'Program',
    body: [],
  };

  // 여기서는 약간 변칙적인 방법을 사용하려고 하는데요. 여기서 `context`라는
  // 속성을 부모 노드에 만들고 새로운 노드를 여기에 추가하려고 합니다.
  // 일반적으로는 이 방법보다 더 나은 추상화가 필요하지만 지금 컴파일러를
  // 작성하는 목적에 맞게 최대한 단순하게 만들고 있습니다.
  //
  // 단순하게 이전 AST에서 새 AST를 참조하는 역할을 한다고 생각하면 됩니다.
  //
  ast._context = newAst.body;

  // AST와 방문자를 순회 함수에 넣어 호출하는 작업으로 시작합니다.
  traverser(ast, {

    // 첫 방문자 메소드는 `NumberLiteral`을 처리합니다.
    NumberLiteral: {
      // 입장할 때 호출하는 메소드입니다.
      enter(node, parent) {
        // `NumberLiteral` 이름으로 새 노드를 만들어 부모 컨텍스트에 추가합니다.
        parent._context.push({
          type: 'NumberLiteral',
          value: node.value,
        });
      },
    },

    // 다음으로 `StringLiteral`을 처리합니다.
    StringLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'StringLiteral',
          value: node.value,
        });
      },
    },

    // 이제 `CallExpression`을 처리합니다.
    CallExpression: {
      enter(node, parent) {

        // 중첩된 `Identifier`와 함께 `CallExpression` 노드를 생성합니다.
        let expression = {
          type: 'CallExpression',
          callee: {
            type: 'Identifier',
            name: node.name,
          },
          arguments: [],
        };

        // 다음으로 기존 `CallExpression` 노드에 새 `context`를 정의해서
        // `expression`의 인자를 참조하는데 사용합니다. 이제 여기에
        // 새 인자를 집어넣을 수 있습니다.
        node._context = expression.arguments;

        // 이제 부모 노드가 `CallExpression`인지 아닌지 확인합니다.
        // 아니라면...
        if (parent.type !== 'CallExpression') {

          // `CallExpression` 노드를 `ExpressionStatement`라는 노드로
          // 감쌉니다. 이렇게 처리하는 이유는 자바스크립트에서 최상위
          // `CallExpression`은 실제로 명령문으로 다뤄지기 때문입니다.
          expression = {
            type: 'ExpressionStatement',
            expression: expression,
          };
        }

        // 끝으로 (아마도 감싸져 있는) `CallExpression`을 부모 노드의
        // `context`에 넣으며 끝냅니다.
        parent._context.push(expression);
      },
    }
  });

  // 마지막으로 이 변환 함수에서 방금 새로 만든 AST를 반환합니다.
  return newAst;
}

코드 제너레이터 (Code generator, 코드 생성기)

이제 마지막 단계인 코드 생성기를 살펴봅니다.

이 코드 생성기는 함수 스스로를 재귀적으로 호출해서 트리에 있는 각 노드를 하나의 긴 문자열로 출력하게 됩니다.

function codeGenerator(node) {

  // 이제 각 `node`의 `type`으로 구분해 동작합니다.
  switch (node.type) {

    // `Program` 노드를 만났습니다. `body`에 있는 각 노드에 코드 생성 함수를
    // 맵핑해서 구동합니다. 그리고 각각의 결과를 개행 문자로 합칩니다.
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n');

    // `ExpressionStatement`를 만나면 중첩된 노드를 대상으로 코드 생성
    // 함수를 실행합니다. 그 결과에 세미콜론을 더해서 반환합니다.
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) +
        ';' // << (...왜냐하면 코드가 제대로 동작되려면 필요하니까요.)
      );

    // `CallExpression`에서는 `callee`를 출력하고 열린 괄호를 추가합니다.
    // 그리고 노드의 `arguments` 배열에 코드 생성 함수를 맵핑합니다.
    // 그렇게 생성한 각각의 결과를 쉼표로 합친 후에 닫힌 괄호를 더해 반환
    // 합니다.
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +
        '(' +
        node.arguments.map(codeGenerator)
          .join(', ') +
        ')'
      );

    // `Identifier`를 만나면 `node`의 이름을 반환합니다.
    case 'Identifier':
      return node.name;

    // `NumberLiteral`을 만나면 `node`의 값을 반환합니다.
    case 'NumberLiteral':
      return node.value;

    // `StringLiteral`을 만나면 `node`의 값을 쌍따옴표로 감싸서 반환합니다.
    case 'StringLiteral':
      return '"' + node.value + '"';

    // 만약 인식하지 못하는 노드라면 오류를 냅니다.
    default:
      throw new TypeError(node.type);
  }
}

컴파일러 (compiler)

드디어 끝났습니다! 이제 compiler 함수를 만듭니다. 지금까지 만든, 모든 함수를 하나의 함수로 묶습니다.

  1. 입력 => 토크나이저 => 토큰 묶음
  2. 토큰 묶음 => 파서 => 추상 구문 트리(AST)
  3. AST => 트랜스포머 => 새 AST
  4. 새 AST => 코드 생성기 => 출력

함수와 인자명으론 다음처럼 정리할 수 있습니다.

1. input  => tokenizer   => tokens
2. tokens => parser      => ast
3. ast    => transformer => newAst
4. newAst => generator   => output

이제 함수로 작성해볼까요?

function compiler(input) {
  let tokens = tokenizer(input);
  let ast    = parser(tokens);
  let newAst = transformer(ast);
  let output = codeGenerator(newAst);

  // 그리고 결과물을 반환합니다!
  return output;
}

모두 완성되었습니다! (테스트 코드도 확인해보세요.)

Jake Poznanski의 글 Debugging Behind the Iron Curtain을 번역했다.


세르게이는 소비에트 연방의 초기 컴퓨터 산업 전문가였습니다. 저는 지난 몇 년 간 그 사람과 함께 일한 덕분에 많이 배울 수 있었습니다. 함께 하는 시간 동안 임베디드 프로그래밍과 인생에 대해 어느 학교에서도 가르치지 못할 만큼 많이 배웠습니다. 가장 인상적인 가르침은 1986년 늦가을에 있던 이야기였습니다. 그 이야기는 세르게이가 가족과 함께 소비에트 연방에서 이주하게 되는 계기가 되기도 합니다.

PDP-11 마이크로 컴퓨터

1980년대, 세르게이는 SM-1800(PDP-11 소비에트 버전)에서 사용하는 소프트웨어를 개발하고 있었습니다. 스베르들롭스크는 당시 소비에트 연방의 주요 물류 센터가 자리 잡고 있었는데 이 인근 기차역에 이 미니컴퓨터가 설치되던 시기입니다. 새 시스템은 기차 차량과 화물을 의도한 목적지까지 어떻게 보낼 것인가를 디자인하는 일을 수행했습니다. 다만 무작위로 작업 수행에 실패하고 시스템이 충돌해버리는 지저분한 버그가 존재했습니다. 이 충돌은 모두가 집에 간 밤중에 꼭 나타났습니다. 오랜 시간 꼼꼼하게 조사했지만 컴퓨터는 다음날 수동으로 테스트하든 자동으로 테스트하든 상관 없이 전혀 문제가 나타나지 않았습니다. 이런 경우엔 경쟁 상태(race condition)나 아니면 다른 동시성 관련 버그인 것이 일반적입니다. 이런 경우는 해당 문제를 특정 상황에서만 재현할 수 있습니다. 매번 늦은 밤에 전화받는 일에 지쳐버린 세르게이는 이 문제를 밑바닥까지 파헤치기로 마음먹었습니다. 가장 먼저 한 일은 어떤 상태에서 이 문제가 발생하는지 파악하기 위해 충돌이 발생했을 때의 배차 상황을 확인하는 것이었습니다.

세르게이는 먼저 예기치 않게 발생한 모든 충돌 기록을 일자와 시간을 기준으로 달력에 표시했습니다. 당연히 어떤 패턴으로 문제가 나타나는지 명확하게 보였습니다. 며칠 간의 활동을 살펴보기만 해도 앞으로 언제 문제가 발생하는지 쉽게 예측할 수 있었습니다.

세르게이는 그렇게 기차역 컴퓨터가 언제 오작동하는지 알아냈습니다. 문제는 우크라이나 북부와 러시아 서부에서 인근에 있는 도살장에 가축이 도착했을 때만 나타났습니다. 세르게이는 이 사실이 이상하다고 느꼈습니다. 사실 이 지역 도살장은 더 가까이 있는 카자흐스탄 농장에서 가축을 공급받아왔었기 때문입니다.

아시다시피 1986년에 발생한 체르노빌 원전 사고로 인해 치명적인 수준의 방사선 뿜어져 나왔고 현재까지도 인근 지역은 거주가 불가능합니다. 이 방사능으로 인해 우크라이나 북부, 벨라루스, 러시아 서부 등 광범위한 지역이 오염되었었습니다. 세르게이는 도착 열차와 고농도의 방사선이 연관이 있다고 가설을 세우고 이 문제를 확인하기로 했습니다. 하지만 당시에는 개인이 방사선 측정기를 소지하는 것은 소비에트 정부에 의해 금지되어 있었습니다. 그래서 세르게이는 먼저 기차역에서 근무하는 군인 몇 명과 함께 술을 마셨습니다. 그렇게 보드카를 몇 잔을 마신 후에 한 군인을 설득할 수 있었습니다. 세르게이는 어떤 열차 차량이 수상한지 지목했고 군인과 함께 그 차량의 방사선을 측정했습니다. 그 차량에서는 정상 수치를 넘는, 매우 높은 방사선이 나오고 있는 것을 확인할 수 있었습니다.

단순히 운송되는 가축이 방사선에 심각하게 오염된 상태인 것뿐만 아니라 지나치게 높은 방사선량으로 인해 SM-1800의 메모리에서 비트 플립(bit-flipping)이 발생하고 있었던 것입니다. 컴퓨터가 기차선로 인근 건물에 설치되어 있었을 뿐인데 말이죠.

소비에트 연방에는 종종 심각한 기근이 있었고 정부 계획 하에 체르노빌 지역에서 생산한 가축으로 만든 육류를 그 외 지역의 육류와 섞는 방식으로 해결하려고 했습니다. 이 방식으로 육류의 평균 방사선 수치를 낮추는 동시에 귀한 자원을 낭비하지 않는 것이죠. 이 “발견”으로 세르게이는 당장 이민 서류를 꾸려 어디든 이민을 받아주는 곳으로 당장 떠났습니다. 시간이 지나 방사능 수치가 낮아지자 컴퓨터에서 발생한 충돌 문제는 저절로 고쳐졌습니다.

이 포스트는 usability.govUser Interface Design Basics를 번역한 글입니다.

사용자 인터페이스(User Interface, UI) 디자인은 사용자가 할 작업을 예측해서 인터페이스의 엘리먼트에 쉽게 접근하고 이해할 수 있는가, 그리고 손쉽게 사용할 수 있는가에 중점을 둡니다. UI는 상호작용 디자인, 시각 디자인, 정보 아키텍처의 개념을 통합하고 있습니다.

인터페이스 엘리먼트 선택

사용자는 이미 특정 방식으로 동작하는 인터페이스 엘리먼트에 익숙합니다. 그러므로 그 사용자 경험에 일관성이 있어 사용자가 예측할 수 있는 엘리먼트와 레이아웃을 선택하도록 합니다. 그런 선택은 사용자가 업무를 능률적이고 만족스럽게 완료할 수 있는데 도움 됩니다.

인터페이스 엘리먼트는 다음과 같지만, 이 목록이 전부는 아닙니다.

  • 입력 컨트롤: 버튼, 텍스트 필드, 체크박스, 라디오 버튼, 드롭다운 메뉴, 목록 박스, 토글, 날짜 필드
  • 탐색 컴포넌트: 브레드크럼, 슬라이더, 검색 필드, 페이지네이션, 태그, 아이콘
  • 정보성 컴포넌트: 툴팁, 아이콘, 프로그레스 바, 알림, 메시지 박스, 모달 윈도우
  • 컨테이너: 아코디언

내용을 표시할 때 여러 엘리먼트를 사용해야 적절한 예도 있습니다. 그런 상황에서는 균형을 찾는 것이 중요합니다. 예를 들어서 엘리먼트를 사용했을 때 공간을 절약할 수 있는 예도 있지만, 사용자에게 드랍박스 메뉴 또는 해당 엘리먼트에 무엇이 있는지 추측하도록 강제하기 때문에 정신적인 부담감을 줄 수도 있습니다.

인터페이스 디자인의 모범 사례

모든 디자인은 사용자를 아는 것에 근간을 둡니다. 그 이해는 사용자의 목표, 기술 수준, 선호도와 경향도 포함합니다. 사용자를 이해한 다음에는 인터페이스를 디자인할 때 다음 항목을 염두에 둬야 합니다.

  • 인터페이스를 단순하게 유지합니다. 모범적인 인터페이스는 사용자에게 거의 보이지 않습니다. 불필요한 엘리먼트는 피하고 명확한 언어로 레이블이나 안내를 넣어줍니다.
  • 일관성을 유지하고 일반 UI 엘리먼트를 사용합니다. 일반 엘리먼트를 UI에서 사용하면 사용자는 더 편하게 느끼고 더 빨리 적응할 수 있습니다. 언어, 레이아웃, 디자인을 아우르는 일정한 패턴을 만드는 일도 중요합니다. 이런 패턴은 사용자가 더 효율적으로 도구를 사용하는 데 도움이 됩니다. 사용자가 한번 사용법을 습득하면 같은 프로덕트의 다른 부분에서도 그 사용법을 동일한 방식으로 쓸 수 있어야 합니다.
  • 레이아웃의 목적이 명확해야 합니다. 항목과 페이지의 공간적 관계를 고려합니다. 그리고 중요도를 기준으로 페이지의 구조를 잡아야 합니다. 주의 깊게 배치된 항목은 사용자가 가장 중요한 정보 조각에 집중하는 데 도움이 됩니다. 또한 빠르게 살펴보는 일도 쉬워지고 가독성도 개선됩니다.
  • 전략적으로 색상과 텍스처를 선택합니다. 사용자가 각 항목에 집중하거나 집중하지 않도록 색, 빛, 대조나 텍스처를 활용할 수 있습니다.
  • 타이포그래피로 계층 구조를 만들고 명료성을 높입니다. 어떤 서체를 사용할지 유의합니다. 다양한 크기와 서체, 정렬 방식은 사용자가 쉽게 훑어보게 만들며 시인성과 가독성을 높일 수 있습니다.
  • 시스템이 지금 일어나는 일을 파악할 수 있게 합니다. 항상 사용자에게 위치, 동작, 상태의 변화나 오류를 알려줍니다. 다양한 UI 엘리먼트를 사용하며 다양한 상태와 상호작용하게 되는데 사용자가 현재 상황을 쉽게 파악할 수 있게 되면 진행 과정에서 사용자가 겪는 불편함을 완화하는 데 도움 됩니다.
  • 기본 설정에 대해 고려합니다. 사람들이 어떤 목표와 기대를 갖고 이 프로덕트를 사용하는지 주의깊게 생각하고 예측합니다. 이 과정에서 사용자의 수고를 덜어줄 수 있는 기본 설정을 만들 수 있습니다. 이 과정은 특히 폼 디자인에서 중요한데 일부 필드를 미리 선택된 항목으로 할지 아니면 직접 일일이 입력하도록 할지 등 사용자의 사용성을 고려해볼 수 있습니다.

참고 문헌

이 글은 April WenselTech has a Toxic Tone Problem — Let’s Fix It! 번역입니다.


2023년 5월 추가: 잇창명님이 이 글을 재번역해주셨습니다. 해당 글은 잇창명님 웹페이지에서 확인하실 수 있습니다.


기술 업계의 독성 말투 문제, 고칩시다!

의사소통에 관해서, 특히 엔지니어가 연관된 경우라면 기술 업계에서 독성 말투 문제가 존재합니다. 저는 이 문제를 지난날 동안 주변에서 겪었기 때문에 알기도 하지만, 저도 이런 순간을 마주했을 때는 그 문제에 기여를 했었기 때문입니다.

먼저 말투(tone)는 "누군가 단어를 말하거나 쓸 때 표현되는 태도"라는 의미에서, 독성(toxic)은 "조직의 자산 또는 구성원을 포함해서 그 조직에 위해를 주는 것"이란 의미에서 사용하고 있습니다.

소프트웨어 엔지니어는 일반적으로 뛰어난 커뮤니케이션 스킬을 갖고 있지 않다고 얘기합니다. 이런 경향은 여러 이유로 설명되기도 합니다. 전문 지식을 갖고 있기 때문에, 인간관계보다 컴퓨터와 더 많은 시간을 썼기 때문에, 전통적으로 성격이 프로그래밍에 가까워서 등으로 말이죠. 하지만 이런 대부분의 잠재적 이유는 문제점이 있으며 특히 마지막 이유는 고정관념이기도 합니다. 게다가 엔지니어가 이런 문제점을 핑계로 주변 사람들에게 멀쩡한 사람처럼 행동하지 않을 구실로 삼을 수 없다는 점입니다.

엔지니어의 부족한 커뮤니케이션은 새로운 현상이 아닙니다. 제럴드 와인버그는 1971년에 프로그래밍 심리학에서 다음처럼 적었습니다.

프로그래밍에서는 엄청나게 똑똑할지 몰라도 자신의 지적 능력을 사용해서 자신의 사회적 행동이나 대화 방식을 고칠 수 있을 정도로 똑똑하지 않을 수 있습니다.

이런 점은 단순히 팀 생산성을 망치는 일에 그치지 않습니다. 커뮤니케이션 문제가 기술을 공부하는 사람들을 낙담하게 하며 사회적 활동에 참여하는 기존 엔지니어의 의지까지도 꺾게 됩니다. 여기서 사회적 활동이란 스택오버플로우에 질문을 올리고 답변을 달거나 오픈 소스에 기여하는 활동을 예로 들 수 있습니다. 그러므로 프로그래밍을 둘러싼 커뮤니케이션을 향상하는 일은 더 포괄적인 개발 커뮤니티를 만드는 일에 필수적이라 할 수 있습니다.

이 문제를 지적하기 전에 먼저 이해를 제대로 하기 위해 살펴봐야 합니다. 저는 그동안에 이 말투 문제를 지켜보면서 세 가지 공통분모를 찾았습니다. 물론 이 사항은 완벽한 목록이라고 말할 순 없지만, 엔지니어만 이런 커뮤니케이션 문제를 겪는 사람이 아니라는 점을 확인할 수 있을 겁니다.

이런 독성 말투는 일반적으로 거들먹거리는 말투, 기계적 말투, 또는 비관적인 말투로 나타나기도 합니다. 어떤 경우에는 달콤한 독성 말투 비빔밥이라도 되는 것처럼 이 세 가지를 모두 섞어 놓은 때도 있습니다.

거들먹거리는 말투

어떤 기술을 습득하게 된다면 아마 주변 사람들에게는 부족한 전문 지식을 갖게 될 겁니다. 나쁘게도 새로운 기술을 습득한다고 해서 그 기술이 없는 사람과 효과적으로 의사소통하는 능력까지 함께 따라오지 않습니다. 다른 사람과 대화하게 될 때 당신이 아는 만큼 알지 못하는 사람에 공감하는 능력이 부족하다면 거들먹거리는 녀석으로 보일 뿐입니다.

지미 펄론이 세러데이 나이트 라이브에서 했던 닉 번즈: 당신 회사의 컴퓨터 담당자를 본 적이 있나요? 닉은 따지고 보면 허구의 IT 전문가며 실제 세계의 프로그래머가 아니긴 하지만 극에서 묘사된 그의 행동은 놀랍게도 기술 업계에서 만연하게 볼 수 있던 거들먹거리는 말투를 명확하게 보여주고 있습니다.

저는 동료들이 다른 엔지니어나 인터뷰 대상자를 "종이봉투 뚫고 나올 간단한 방법조차도 프로그래밍하지 못하는" 그런 "멍청이"라고 부르는 경우를 수 년간 들어왔습니다. 초급 엔지니어가 질문했다고 눈을 부라리는 경우도 봤습니다. 부트캠프 졸업생이나 스스로 공부해서 프로그래머가 된 사람들을 평가하는 지적도 들은 적이 있습니다.

또한 많은 엔지니어가 말하는 방식이라든지 이들이 마케팅, 영업, 제품과 고객 응대에서 일하는 직원에 대해 언급하는 경우를 봤을 겁니다. 엔지니어가 누군가를 "비개발자 (non-technical)"라고 언급했다면 그 사람들의 업무를 뭉개버리는 것이나 마찬가지입니다. (다른 얘기지만 저는 이 단어를 더는 사용하지 말아야 한다고 봅니다)

이런 거들먹거리는 태도는 항상 노골적으로 드러나지 않습니다. 다음 예제는 조금 교묘합니다. 누군가 스택오버플로우에 남긴 질문인데 온라인에서 한참 검색하기도 했지만, 이해에 어려움이 있어서 옵저버옵저버블 의 차이점이 무엇인지 물어봤습니다. 다음 답변은 100회 이상의 투표를 받은 답변입니다.

읽기 좋게 거들먹거리는 부분을 가져왔습니다.

난 이 내용을 어떻게 더 평범한 영어로 설명할 수 있을지 모르겠네요.

지금 위 내용에 정의가 있습니다. 딱 두 문장입니다. 10번 읽으면 더는 이해할 것도 없을 것 같은데요.

질문에 답변하는 사람은 용어의 차이가 명확해서 어떻게 이걸 이해하기 위해 더 "평범한 영어"로 설명해야 하는지 상상조차 하지 못하고 있습니다. 이 질문을 남긴 사람은 이 답변으로 어떤 기분이 들었을지 궁금하게 합니다. 질문자가 이 답변에서 응원을 받은 기분이거나 답을 읽고 더 자신감을 느끼게 되었을 거라고는 상상되질 않습니다. 이 문답이 충격적이라는 점을 트위터에서도 마주할 수 있었습니다.

운 좋게도 누군가 커뮤니티에서 답변을 수정할 수 있는 사람도 트위터 토론을 봤는지 거들먹거리는 서두를 지워서 유용한 답변으로 바꿔놨습니다. 커뮤니티 구성원 모두가 친절함에도 가치를 둬서 투표를 따라 줬더라면 얼마나 좋았을까요.

근본적으로 어떤 경우든 거들먹거리는 말투 뒤에 숨어 있는 태도는 "내 주변에 있는 사람들은 다 모르지만 나는 알고 있다. 내가 우월하기 때문에 그들을 굳이 존중할 필요가 없다."와 비슷할 겁니다.

이런 태도는 위험합니다. 죄와 벌에 나오는 라스콜니코프의 "초인" 콤플렉스를 연상하게 합니다. 이 사람은 스스로를 다수인간 위에 있는 법적 존재인 소수인간이라 여기고 다른 사람을 살해하기에 이릅니다.

물론 이처럼 극단적인 경우를 다루지는 않지만, 여전히 이런 사람은 우리 개발팀에 있는 것은 건강하지 않습니다. RailsBridge를 창업한 사라 메이는 이렇게 말했습니다.

직원의 보이지 않고 조용한 반사회적 행동에서 비용이 발생합니다. 그런 사람들의 비범한 생산성보다 정! 말! 큰 비용을 지출하게 합니다.

이에 대한 해결책은 무엇일까요?

평화적인 대안

먼저 대표직에 있다면 거만하거나 거들먹거리거나 공격적인 행동에 대해 보상하는 일을 중단해야 합니다. 개념을 진정으로 숙련했다면 경험이 적은 사람에게 더 간단한 용어를 사용해서 개념을 설명할 수 있어야 합니다. 이런 행동이 보상을 받아야 하는 행동입니다.

개인 수준에서는 무얼 할 수 있을까요?

동료가 도움을 받고 싶어서 하는 질문에서 본인이 잘 아는 분야라고 생각한다면 스스로 무언가 거들먹거리는 내용을 말할 수 있다는 점을 인지하고 있어야 합니다. 또한 내가 이런 답변을 누군가에게 들었을 때 어떤 기분이 들까 하고 스스로에게 물어봐야 합니다. 사용하는 단어가 듣는 상대의 기분을 상하게 하나요 아니면 좋게 하나요? 정말 그 사람보다 더 잘 알고 있는 게 맞나요? 더 알고 있다는 점이 상대에게 예의 없게 행동해야 한다는 의미인가요? 상대방도 당신이 모르는 부분을 더 잘 알 수도 있지 않을까요? 단순히 동일한 문제를 다른 관점에서 보고 있는 것은 아닐까요?

당신이 대화하고 있는 사람이 정말로 해당 주제에 대해 무지하다고 가정해봅시다. 상대방이 문제를 이해하는데 얼마나 어려움을 겪을지 상상해봅니다. 이해하는 동안 겪는 고통에 동정심을 갖고 그 어려움을 최소화할 수 있도록 하려면 어떻게 도울 수 있을지 살펴봅시다. 자신이 이 주제에 대해 처음으로 배우던 당시를 생각해본다면 어떻게 알려줘야 하는지 생각하는데 도움 될 겁니다. 어떤 점이 도움이 되었습니까? 다음을 고려해보세요.

  1. 어떤 질문들을 하고 있었고 가장 유용한 답변은 어떤 답변이었습니까?
  2. 어떤 자료가 이 문제를 이해하는데 있어 추가적인 관점을 제공할 수 있습니까?

또한 자기 자신에게 인정을 베풀기 바랍니다. 이 사람을 도와주는 일이 에너지를 소비하나요? 만약 그렇다면 좀 쉬는 것이 낫지 않을까요? 도와줄 수 있는 다른 사람은 없나요? 이 문제를 다루는 데 도움이 되는 연관 자료가 있나요? (주의하세요: "대신 구글링 해줄게" 링크를 보내는 일은 일반적으로 거들먹거리는 거나 다름없습니다. 상대방이 농담으로 받아들일 것이라는 확신이 있는 것이 아니라면 말입니다.)

스스로 이런 행동을 한다면 당신의 동기(motivation)를 확인해보세요. 사실, 이 지식에 대해 약간 불안전하게 느껴서 그런 행동을 보일 가능성이 있지 않나요? 자신의 자아를 방어하기 위해서 거들먹거려야겠다는 유혹을 받는 것은 아닌가요? 정말 이런 문제로 그렇다면 스스로를 채찍질하지 않기 바랍니다. 기술 산업에 있는 면접과 직원 보상 시스템은 주로 부풀려진 자아를 선호하기 때문에 아마도 이런 태도가 당신의 잘못만은 아닐 겁니다. 그러므로 조금의 자기 성찰로 무언가 공격적인 말을 하는 것을 방지할 수 있을 것입니다.

거들먹거리는 응답 대신에 연민이 있는 응답을 할 수 있다면 듣는 상대방도 기분 상하지 않아 당신과 함께 일하기 즐겁게 여길 것입니다. 올리비아 폭스 카반의 저서 The Carisma Myth(카리스마, 상대를 따뜻하게 사로잡는 힘)에서 이런 이야기가 나옵니다.

1886년, 한 여성이 두 명의 영국 총리 후보와 각각 저녁 식사를 했습니다. 이 여성이 저녁 식사에서 언론에 다음과 같이 말했습니다.

"글래드스턴 씨와 저녁 식사를 한 후에는 그가 영국에서 가장 현명한 사람이라고 생각했습니다. 디즈레일리 씨와 저녁 식사를 한 후에는 제가 영국에서 가장 현명한 사람이라는 생각이 들었습니다.

말할 필요도 없이 궁극적으로 디즈레일리가 선거에서 승리했습니다. 어떤 타입의 사람이 되고 싶은가요? 거들먹거리며 당신의 우월함을 자랑하는 사람이 되고 싶은가요? 아니면 다른 사람들의 가능성을 일깨우는 데 도움을 주는 사람이 되고 싶은가요? 단순하게, 당신은 어떤 타입의 사람과 일을 하고 싶은가요?

이 주제에 대해 더 알고 싶다면 책 The No A**hole Rule(또라이 직)을 확인해보세요.

기계적 말투

"안녕 패트, 네 사촌이 죽었다."

이 메시지는 제가 사랑하는 할머니가 제 유년 시절에 자동 응답기에 남긴 메시지입니다. 목소리에 억양도 없었고 요점을 전달하는데 감정적 수사 여구도 없이 단순히 효율적인 사실만 전달하는 말이었습니다. 저는 할머니의 직선적인 태도를 물려받았다고 생각합니다.

아쉽게도 모두가 감정 없는 직선적인 태도를 좋아하지 않습니다. 앞서 이야기한 것과 같이 저 또한 기술 분야의 독성 말투 문제에 기여를 했는데 이 로봇 같이 행동하는 문제가 바로 제 약점이기도 합니다.

엔지니어로서 컴퓨터를 다루면서 일반적으로 주의 깊게 사람의 감정을 고려하는 것처럼 행동하지 않습니다. 컴퓨터에게 매우 직선적으로 무엇을 할지 이야기하면 컴퓨터는 명령을 처리합니다. 아름답게 효율적이고 논리적인 흐름입니다. 아마 이런 특징이 많은 사람으로 하여금 엔지니어링에 첫눈에 반하고 빠지게 하는 그런 부분일 겁니다.

좋든 나쁘든 간에 컴퓨터와 사람은 동일하지 않습니다. 우리는 감정이 있습니다. 상대가 어떻게 받아들일지 고려하지 않고 사실을 직접 공유할 수 없습니다. 제 말은 물론 그렇게 직접 공유할 수 있긴 하겠지만 그러면 당신에게 "거칠다" 혹은 "직설적"이라는 이름표가 붙게 됩니다. (저를 믿으세요.) 인간에게 기계에 말하듯 한다면 사람들 대부분은 당신과 일하는 것을 좋아하지 않을 겁니다.

저는 제 할머니에게 감사하기도 합니다. 저는 이런 직선적인 태도가 엔지니어에게는 자산이 될 수 있다고 항상 생각하기 때문입니다. (사실 엔지니어뿐만 아니라 모두에게 말입니다.) 하지만 말을 듣는 청중을 주의 깊게 생각하는 일, 어떻게 말을 전달해야 하는지 그 모양을 다듬는 일이 중요하다는 점에 저는 동의하게 되었습니다.

중대한 순간에 웹사이트가 닫히게 되었다고 가정해봅시다. 지금 문제를 해결하고 있다는 확신을 보여주지 않고 직설적으로 이 소식만 전달한다면 아마 회사에서 가장 인기 있는 사람이 될 수는 없을 겁니다. 이런 종류의 뉴스도 기계적인 말투로 전달한다면 당신의 고객과 제대로 공감대가 형성될 수 있을지도 의문입니다. 또한 침착함을 유지하는 것도 중요하지만 최소한 고객이 어떤 영향을 미치는지 인지하는 것도 중요합니다.

기계적 말투는 피드백을 전달하는 과정에서도 문제입니다. 직접적인 피드백을 좋아한다고 여러 차례 언급한 디자이너와 함께 협업한다고 상상해봅시다. 이 디자이너는 지금 함께 작업하고 있는 애완동물 관리 앱의 가장 최근 목업(mockup)에 대해 조언을 구했습니다. 사용자의 애완동물 정보를 볼 수 있는 상세 페이지를 만들고 있었습니다. 이 목업에는 디자이너의 고양이, 닐에 대한 통계가 표시되고 있습니다. 페이지 상단에는 "당신" 이라는 제목이 붙어 있습니다. 대략 다음 같은 화면이지만 훨씬 멋지다고 생각해봅시다.

이 디자인을 본 후에 시나리오를 논리적으로 생각해보면 이렇게 말하게 될 겁니다. "이 화면의 제목은 애완동물에 대한 하위 페이지라면 적어도 '당신의 고양이' 또는 '당신의 애완동물' 아니면 '닐'이 되어야 하지 않나요?" 논리적이기도 하고 제목이 페이지 가장 위에 있으니 가장 먼저 보이기도 하니 이런 지적을 가장 먼저 할 수 있을 겁니다.

이제 디자이너와 좋은 관계가 있거나, 디자이너가 스스로 능력에 자신감이 있거나, 기분이 좋은 날이거나, 정말 앞서 말한 것처럼 직접적인 피드백을 좋아하는 사람이라면 디자이너는 오류를 지적한 점에 고마워하고 제목을 갱신하는데 동의할 겁니다. 아니면 왜 여기서는 이런 제목이 맞는지 기쁘게 설명할 수도 있겠습니다.

하지만 모든 상황이 잘 맞아서 돌아가지 않았다고 생각해봅시다. 먼저 디자이너가 그동안 디자인을 하면서 공들인 부분을 알아주지 않고 어떤 부분에서도 이 디자인의 장점을 언급하지 않으며 가장 먼저 문제를 딱 집어내서 그다지 기분이 좋지 않을 수도 있습니다.

스스로는 효율적이라고 생각할 수 있겠습니다. 왜 디자인에서 올바른 부분을 이야기하거나 잘못된 부분에 대해 조심해서 얘기하면서 시간을 낭비해야 하죠? 그냥 바뀌어야 하는 부분을 직접 집어 말해줘서 빠르게 고치는 게 뭐가 잘못인가요? 음, 사람들은 감정이 있고 그 감정은 생산성에 영향을 준다는 점이 문제입니다.

상대방의 강점을 인지하지 않고 상대의 잘못을 냉담하게 지적한다면 상대방은 위협으로 느낄 수도 있으며 생산성에 악영향을 줄 수 있습니다. 데이비드 록은 다음처럼 설명합니다.

위협적인 응답은 분석적 사고, 창의적인 통찰, 문제 해결력을 훼손합니다.

즉, 가장 효과적이라고 생각하는 방식이 고통스러운 감정의 원인이 되고 실제로 팀의 발목을 붙잡는 일이 됩니다.

코드 리뷰에도 동일합니다. 만약 누군가의 오류를 억양 없이 지적하는 일은 역효과를 낳아 상대방의 열정을 죽이고 성장해야겠다는 동기를 짓누르는 일이 될 수 있습니다.

평화적인 대안

단순히 충분하지 않다는 이유만 갖고 거만한 녀석이 되는 일을 멈추기 바랍니다. 대신 팀에 긍정적으로 협력하고 진정성을 갖고 지원하기 바랍니다. 긍정적인 감정으로 팀의 다른 사람들에게 어떻게 최고의 영감을 줄 수 있을지 고민하고, 팀원이 고통받지 않도록 하는 일까지 한 단계 앞서 나갈 필요가 있습니다.

기계에 대고 말하는 것이 아니라 사람과 대화한다는 사실을 잊지 마세요. 카렌 암스트롱은 다음처럼 경고했습니다.

"만약 자비로움과 공감이 제련되어 있지 않다면, 이성은 인간을 도덕적 공허로 이끌 수 있습니다."

피드백으로 돌아와서, 여기에 사용할 수 있는 하나의 기법은 진정성 있는 질문에 의지하는 방법입니다. 위에서 이야기한 애완동물 앱의 예제 피드백은 "하지 않나요?"로 끝나고 있습니다. 문장은 질문의 형식을 빌리고 있지만 평가하는 문구에 가깝습니다. "이 화면의 제목 선택에 관해 설명해주실 수 있을까요?" 정도가 더 나은 선택지가 되겠습니다.

또한 비판적인 피드백도 긍정적인 피드백과 함께 제련할 수 있습니다. 어떤 사람들은 "샌드위치" 기법으로 피드백을 주는 것을 좋아하기도 합니다. 먼저 칭찬할 점을 언급하고, 건설적인 비판을 제공한 후 또 다른 칭찬을 합니다. 또 어떤 사람들은 (저 자신을 포함해서) 투명하게 보고 요점만 말하길 원하는 경우도 있습니다.

저도 이 조언에 저항하는 데 참여할 수 있습니다. 제 경력 대부분 동안 이 조언에 저항했기 때문입니다. 어린 에이프릴은 "뭐, 사람들이 진실을 대하는 방법에 대해 배워야 할 뿐이라고" 같은 말을 했었습니다. 아마 제 그런 과거에서 저항하려는 이유가 있을 겁니다. 하지만 사람들과 강한 관계를 만들어가는 과정에서 서로 굳은 신뢰 관계(rapport)가 없는 사람과 직설적인 의사소통을 한다면 부적절할 수 있습니다. 저는 또한 진정으로 자아 없는 프로그래밍과 제품 개발도 염두에 두는데 이런 경우에는 피드백은 직접 줄 수 있을 겁니다. 이런 경우에는 (이론적으로) 자아가 없기 때문이죠.

그리고 사람 일은 모르는 법입니다. 직접적인 피드백을 줘도 잘 받는 누군가가 있다 하더라도 그 피드백을 받는 그 날에 그 사람의 개가 죽었다면 평소처럼 받지 못하고 무너질지도 모릅니다. 그래서 어느 때라도 소통하는 사람들의 감정에 당신의 말이 어떤 영향을 주는지 고려하지 않아도 되는 경우는 차라리 없다고 생각해야 맞습니다.

비관적 말투

마지막 독성 말투는 비관적 말투입니다. 먼저 저는 회의적인 입장도 어느 정도 팀에 존재하는 것이 건강하다고 생각하는 편이라서 그런 경우는 문제가 아닙니다.

문제는 거의 모든 창의적인 새 아이디어, 도구, 접근 방식에 대해서 비관적으로 대하는 경우인데 "다 본 적이 있다"고 말해서 씁쓸하게 만드는 시니어 엔지니어 같은 경우가 일반적입니다. 이런 건 정말 다 최악입니다.

"그거 동작 안 할 거야."

"그거 확장 안될 거야."

"그 새 도구는 그냥 예전 것만큼 별로야."

어떤 이유에서인지 어떤 엔지니어는 종종 전 USC 총장인 스티븐 샘플이 말한 "습관적 반대론자"에 속하기도 합니다. 그는 다음처럼 설명합니다.

"새로운 아이디어가 어떻게 동작 가능할지 상상하는 일보다 이들은 본능적으로 왜 안 되는가에 대한 온갖 이유를 생각합니다. 이 사람들은 이런 태도가, 나쁘거나 멍청한 아이디어에 쓰는 모든 사람의 시간을 아낀다고 굳게 믿고 있습니다. 하지만 이 사람들이 실제로 하는 일은 자유로운 생각에서 수확할 수 있는 창의력을 침식하는 일입니다."

엔지니어가 비관주의의 매력을 느끼는 이유가 두 번째 문장에서 정확하게 묘사되고 있습니다. 시간을 절약하는 것 말입니다. 당신의 비관주의적 예측이 옳다면 그들의 아이디어를 거부함으로 모든 사람들의 시간을 잘 절약하고 있을 것입니다.

하지만 이 일이 너무 자주 일어나면 엔지니어는 그 아이디어가 정말 실패할지 잘 모르는 상황에서도 작은 확신이라도 있으면 아이디어를 거절하게 됩니다. 모든 사실을 검증하기도 전에 판단을 내립니다. 아마 과거에 비슷한 접근 방식이 실패한 것을 봤을 수도 있습니다. 또는 수많은 불필요한 프로젝트 관리 도구를 지난 기간 동안 봐서 어떤 새로운 도구든 제대로 동작할 거란 상상을 하지 못할 수도 있습니다. 아니면 매번 위키를 도입하려고 시도했지만, 팀이 계속 관리하지 못했기 때문에 이번에도 별다르지 않을 것으로 생각할 수도 있습니다.

이 모든 경험은 엔지니어가 팀에게 제공할 수 있는 유용한 정보이긴 하지만, 그렇다고 해서 제안이 실패할 것이라는 결론에 즉각적으로 닿지는 않습니다. 과거의 데이터를 알려주면서 거기에 한숨과 부정적인 말투를 더할 필요는 분명 없습니다.

아마 과거 프로젝트에서 레일즈를 사용하면서 한 번의 나쁜 경험이 있을 수 있습니다. 그 한 번의 경험으로 나쁜 도구라고 말할 순 없습니다. 아마도 당시 상황에 맞지 않았거나 팀이 전체적으로 이해하고 있지 못했을 수도 있습니다.

오랜 기간에 걸쳐 많은 프로젝트의 실패를 봐 온 엔지니어라면 실패를 예측할 수 있습니다. 이런 엔지니어는 그들이 말하는 모든 것에 일반적인 부정론을 투영하기도 합니다. 팀을 막다른 골목에 들어가서 시간을 낭비하는 일을 구하기 위해 매번 반대하는 말을 하게 되면, 아마 팀의 의욕을 꺾고 달콤한 실험을 방해했을 가능성이 10배는 될 겁니다.

또한, 위험할 수도 있는 부분은 새로운 엔지니어가 이런 독한 기운을 뿜는 엔지니어를 경외하기도 한다는 점입니다. 이런 이유의 배경을 짐작해보면 모든 것에 불평하고 반대하는 것 보면 분명 모든 걸 다 알기 때문이라 생각하는 것으로 보입니다. 이 모든 부분이 악순환입니다.

Sidenote: 흥미롭게도 이런 엔지니어는 의외로 낙천적인 경향이 딱 한 주제에서 나타나는데 바로 작업을 완료하는데 걸리는 시간입니다. 이런 낙관주의적 경향의 주된 결과는 한심할 정도로 현실적이지 못한 시간 예측입니다.

평화적인 대안

엔지니어가 실패에 대한 가능성을 볼 수 있다면 유용할 겁니다. 이런 관점이 버그와 다운타임, 보안 문제를 방지하기 때문입니다. 하지만 성공의 가능성과 잠재적인 부정적 결과를 발견하는 능력에 균형을 찾는 일은 진정 주의해야 합니다.

그 이유는 숀 머피의 The Optimistic Workplace 설명에서 확인할 수 있습니다.

"두뇌는 기쁨과 같은 긍정적 감정에 열리게 될 때, 전체적인 연결성을 명확히 볼 수 있고 문제를 해결할 수 있는 선택지를 찾을 수 있습니다."

그리고 통계도 인용합니다.

"긍정적인 업무 환경에 있는 사람들은 부정적인 분위기에서 일하는 사람보다 10~30% 더 나은 결과를 냈습니다."

아이디어를 내리깎고 새 프로젝트의 실패를 예측하기 전에 스스로 물어보세요. 정말로 이 아이디어가 실패한다고 확신하나요? 볼테르가 확신에 대해 한 말을 기억하기 바랍니다.

"사기꾼만 확신에 차 있습니다. ... 의심이란 그다지 바람직한 상태가 아닙니다. 하지만 확신이란 얼토당토않은 상태인 것입니다."

확신하지 않으면서 왜 확신한 것처럼 말할까요? "그거 동작 안 할 거야"는 정말 당신의 응답으로 필요한 말일까요? 이런 대답 대신에 공손하게 어떤 걱정이 있는지, 과거에 팀에서 어떤 경험을 했는지 공유하는 것은 어떨까요?

만약 정말 그 비운의 아이디어가 실패한다고 확신 한다면 먼저 볼테르의 말을 다시 읽어보고, 자신의 의심을 나머지 팀원에게 의욕을 꺾지 않는 방법으로 어떻게 소통할지 고려하기 바랍니다. "맞아요, 그리고...?"로 그 응답을 시작할 수 있습니다. 예를 들면, "맞아요, 그 아이디어가 뛰어난 이유를 알겠어요. 그리고 이 아이디어는 이런 다른 맥락에서 더 좋은 이유가 있어요." 또는, "맞아요, 흥미로운 아이디어네요. 그리고 아마도 몇 달 후에 Y를 달성하고 나면 그 아이디어가 더 실현할 수 있겠네요." 식으로 답할 수 있을 겁니다.

여기에 자신에게 자비로움을 베풀어 도움을 구할 수 있는 시간이 또 있습니다. 다른 무언가로 인해 괴로워하고 있고 그 고통이 제안된 아이디어에 대해 반대하는 진짜 원인인가요? 그냥 피곤한가요? 먼저 본인에게 있는 개인적인 필요를 돌봐야 합니다. 그리고 그 아이디어를 여전히 반대하는지 살펴보기 바랍니다.

그리고 회사의 사명과 자기 자신의 사명을 다시 상기하는 일도 도움 됩니다. 왜 이 프로젝트에 지금 일하고 있죠? 무엇이 희망을 주나요? 그 희망을 새로운 아이디어에 대한 열린 마음으로 이어갈 수 있나요?

항상 모든 일이 완벽하게 잘 될 것이라고 가장할 필요는 없습니다. 하지만 새 아이디어에 대한 열린 태도는 진정으로 혁신적인 작업을 하기 위한 전제조건입니다. 또한 당신을 더 즐겁고 타인을 지지해줄 수 있는 동료로 만들 것입니다.

미래

"컴퓨터 프로그래밍은 인간 활동입니다. ... 하지만 아직도 ... 많은 사람들(많은 프로그래머들)은 프로그래밍을 인간 활동으로 전혀 고려하고 있지 않습니다."

제럴드 와인버그는 1971년에 이 글을 썼지만, 여전히 이런 인식이 존재합니다. 제 생각에는 엔지니어링에서의 커뮤니케이션이 주목받을 가치가 있습니다.

우리가 아름다운 소프트웨어 걸작을 만드는 일을 사랑하는, 뛰어난 엔지니어라면 그와 동시에 친절하고 자비로운 사람들로 서로를 돕고 말과 글로 의사소통을 할 때는 서로의 단어로 영감을 불어넣는 사람이 될 수 있지 않을까요?

저는 기술 업계의 더 밝은 미래를 그립니다. 모든 종류의 독성 말투를 버리고 대신에 긍정적이고, 겸손하며, 희망차고, 명확하며, 초대하는 것과 같은 말투를 받아들인 미래를 말입니다. 왜 초대하는 것과 같아야 하는가 하면 알다시피 우리가 만들고자 하는 것을 모두 만들려면 우리에게 더 많은 엔지니어와 기여자를 필요로 하기 때문입니다.

프란 앨런의 Coders at Work 인터뷰에서 말하는 엔지니어링처럼 말입니다.

"엔지니어링은 사회 전체를 위한, 변혁적인 분야입니다. 또한 우리가 하는 일에서 다양한 사람들의 참여 없이는, 우리 사회의 모든 측면에서 매력적이거나 유용하지 않은 결과를 얻게 됩니다."

그러므로 진보적인 관점에서, 불필요하고 자아 중심적인 부정성 때문에 다른 누군가가 겁먹지 않도록 조심합시다.


  • 2018-12-12: 직진적직선적으로 변경했습니다. 버블쓰님 피드백 감사드립니다.
  • 2018-12-12: 현명이라고 생각했습니다가장 현명한 사람이라는 생각이 들었습니다으로 변경했습니다. minieetea님 피드백 감사드립니다.
  • 2018-12-13: 잘못 걸린 링크를 수정했습니다. 정겨울님 피드백 감사드립니다.
  • 2018-12-13: 제럴드 와인버그의 인용을 수정했습니다. chiyodad님, lazygyu님, jinhoyim님 피드백 감사드립니다.
  • 2019-07-16: 지정지적으로 고쳤습니다. 아델라님 피드백 감사드립니다.

번역가 되는 법이란 제목이 눈에 확 들어와서 집었다. 내 번역 끈은 짧지만 조금씩이나마 글을 번역해서 올리는 입장에서 좀 더 전문성을 가져보는 것은 어떨까, 제목만 보고 그런 생각을 하며 이 책을 구입했다.

그동안 접했던 번역 도서는 번역을 어떻게 해야 하는가에 대한 내용이 주를 이뤘는데 이 책은 실무적 이야기가 중심이다. 출판 시장에서 번역가는 어떤 역할을 하고 있는지, 업무는 어떤 방식으로 진행되는지 전반적인 그림을 보는데 도움이 된다. 얇은 책에 판형도 작아서 읽는데 전혀 부담이 없는 분량이지만 업계에 오랜 기간 다양한 위치에서 경험을 쌓은 분답게 깊이가 느껴졌다.

잘 읽히지 않는 책에서만 번역가의 존재가 들어난다는 이야기부터, 번역가가 되기 위해서 어떤 배경과 자질을 갖춰야 하는지, 삶을 잘 배분해 규칙적으로 번역하는 번역가의 일상, 원서와 저작권, 출판사와 편집자의 역학 관계를 세세하게 보여주기도 한다. 의역과 직역에 대한 고민도 빼놓지 않았다. 번역을 하고 싶은 사람이든 하고 있는 사람이든 공감하고 읽을 부분이 참 많다.

아르바이트를 병행해야 할 수 밖에 없는 상황에서 어떤 아르바이트를 해야 하는지 지극히 현실적인 부분부터 그래도 번역에서 자유를 느낀다는 이야기는 내 스스로도 무슨 일을 할 때 이런 자유함을 느끼고 있는지 생각하게 했다.

저의 과거와 현재의 삶 그리고 그 삶 속에서 실천해 온 노력으로 새로운 창조물을 만들며 자유의 희열을 느끼게 해 주는 이 작업이 무엇보다 소중합니다. - 128쪽

무엇을 만들어내는 감각은 분야를 막론하고 사람을 일에 빠지게 하는 원동력이 되는 것 같다.

지난 달에 있던 Hacktoberfest에 참여하면서 Gatsby에 기여를 하게 되었습니다. 단일 리포지터리로 된 프로젝트는 처음 경험해서 코드가 반영되고 갱신되는 과정이 흥미로웠습니다. 단일 리포지터리를 사용하면 다중 리포지터리에 비해 어떤 장점이 있는지 여러 글을 읽어보게 되었는데 그 중 하나를 번역하게 되었습니다.

이 글은 Dan LuuAdvantages of monorepos 번역입니다.


단일 리포지터리(monorepo)의 좋은 점

다음 같은 대화를 자주 합니다.

누군가: 페이스북/구글에서 거대한 단일 리포지터리를 사용한다는 얘기 들었어? 뭐임 대체!

: 그래, 엄청 편하겠다. 그럴 것 같지 않아?

누군가: 엥 내가 들어본 얘기 중에 가장 황당한 얘기다. 페이스북이나 구글은 단일 리포지터리에 모든 코드를 넣는 게 얼마나 끔찍한 아이디어인지 모르는 건가?

: 내 생각에는 페이스북, 구글에서 일하는 엔지니어도 작은 크기 리포지터리가 익숙할 거야. (Junio Hamano도 구글에서 일하지 않나?) 그리고 여전히 단일 거대 리포지터리를 선호하는데 거기에는 이런 [이유]가 있어.

누군가: 흠, 그래, 꽤 괜찮은 것 같네. 내 생각엔 여전히 이상하긴 하지만 왜 이런 리포지터리를 원하는지도 이해할 수 있을 것 같다.

"[이유]"는 생각보다 꽤 깁니다. 그러니 같은 대화를 계속 반복하는 것보다 글로 작성해서 이유를 설명하려고 합니다.

단순화된 편성

다중 리포지터리를 사용한다면 일반적으로 프로젝트 당 리포지터리가 있거나 연관된 프로젝트를 기준으로 리포지터리가 있을 겁니다. 하지만 "프로젝트"가 무엇인가에 대한 정의는 어떤 팀 또는 회사에 있는가에 따라 달라질 겁니다. 이런 이유로 리포지터리를 합치거나 분리하게 되는데 이런 작업은 순수하게 부가적인 비용이 됩니다. 어떤 경우에 프로젝트를 분리하게 되는지 예를 들면 프로젝트가 너무 큰 경우, 또는 버전 관리 도구의 이력이 너무 많아 최적화가 필요한 경우에 프로젝트를 분리하게 됩니다.

단일 리포지터리를 사용하면 버전 관리 도구가 특정 방식으로 구조를 구성하도록 하는 것이 아니라 논리적으로 가장 일관적인 방법으로 프로젝트를 편성하고 묶을 수 있습니다. 또한 단일 리포지터리를 사용하면 의존성을 관리하는 부하를 줄일 수 있습니다.

편성 단순화의 부수 효과는 프로젝트 간 탐색을 더 쉽게 할 수 있다는 점입니다. 단일 리포지터리를 사용하면 기본적으로 파일 시스템에서 파일을 탐색한다고 말하는 것과 동일하게 모든 프로젝트를 탐색할 수 있습니다. 다중 리포지터리로 구성하면 대부분 두 계층으로 분리된 탐색이 필요합니다. 먼저 프로젝트 안을 탐색할 때는 파일 시스템에서 말하는 탐색이 필요하고, 메타 계층으로서 프로젝트 사이를 찾는 탐색도 필요합니다.

단일 리포지터리를 사용하면 나타나는 이 부수 효과에는 또 부수 효과가 있는데 아주 쉽게 개발 환경을 꾸리고 빌드, 테스트까지 구동할 수 있다는 점입니다. 다른 프로젝트를 탐색하기 위해서 cd를 사용할 수 있고 cd; make로도 당연히 가능하게 됩니다. 이런 구조가 동작하지 않는 것은 당연히 이상하게 보일 정도로 제대로 동작합니다. 동작하기 위해 필요한 어떤 툴링 작업이든 문제없이 완료됩니다.1 물론 기술적으로 다중 리포지터리를 사용하더라도 동일한 작업을 할 수 있지만 자연스럽지는 않습니다. 즉, 생각처럼 동작하지 않을 때가 종종 있다는 의미입니다.

단순화된 의존성

아마 이 사실은 말할 필요도 없겠지만 다중 리포지터리를 사용하면 서로 어떤 버전에 의존하고 있는지 정의하는 방법이 필요할 것입니다. 듣기에는 복잡하지 않은 문제라고 느낄지 몰라도 실무에서는 대부분의 해결책이 성가신 데다 많은 부하가 따릅니다.

단일 리포지터리를 사용하면 모든 프로젝트를 위한 단일 버전을 쉽게 만들 수 있습니다. 여러 프로젝트에 걸쳐서 원자적인 커밋(atomic cross-project commit)이 가능하기 때문에 리포지터리는 언제나 일관적인 상태를 유지할 수 있습니다. 즉, #X 커밋에 모든 프로젝트 빌드가 동작해야 합니다. 빌드 시스템에서는 여전히 의존성을 지정할 필요가 있지만 Makefile이나 bazel BUILD 파일을 사용할 때는 다른 것과 같이 버전 관리 내에서 확인할 수 있습니다. 그리고 단 하나의 버전 번호를 갖게 되는 것으로 Makefile이나 BUILD 파일, 또는 어떤 빌드 방법을 사용하더라도 특정 버전 번호를 지정할 필요가 없게 됩니다.

툴링(Tooling)

탐색과 의존성을 단순하게 하면 도구를 만드는 일도 훨씬 쉬워집니다. 도구를 만들며 각 리포지터리의 관계를 이해하도록 하는 방법 대신에 모든 파일이 리포지터리 내에 들어 있기 때문에 이런 도구는 (리포지터리 내에 의존성을 정의한 몇 파일을 포함해서) 그저 파일을 읽는 일만 하면 됩니다.

이 부분은 사소한 것처럼 느껴질 수 있겠지만 Christopher Van Arsdale의 예에서 빌드가 얼마나 단순하게 가능한지 볼 수 있습니다.

구글 내부의 빌드 시스템은 대형 모듈러 블록 코드를 사용해서 빌드를 환상적으로 쉽게 수행합니다. 크롤러가 필요해요? 여기에 몇 줄 추가합니다. RSS 파서가 필요해요? 몇 줄을 더 추가합니다. 대형 분산, 장애 허용 데이터 저장소? 물론 몇 줄을 더 추가합니다. 이렇게 만들어진 코드 블록과 서비스는 여러 프로젝트에서 공유되며 쉽게 통합할 수 있습니다. … 이처럼 레고(LEGO)처럼 조립 가능한 개발 프로세스는 오픈소스 세계에서 자주 일어나지 않습니다. … 이런 다양한 상태의 결과로 (추측하건대) 오픈소스에 복잡한 장벽이 생겨나고 지난 몇 년간 큰 변화가 없었습니다. 이런 이유에서 구글과 같이 쉽게 관리하는 경우와 그러지 못한 여러 오픈소스 프로젝트의 차이를 만들게 되었습니다.

Arsdale이 편하다고 말한 이 시스템은 오픈소스가 되기 전에 전 구글 엔지니어가 페이스북, 트위터에서도 동일한 혜택을 누리기 위해서 bazel의 자체 버전을 작성했습니다.

이론적으로는 단순히 단일 리포지터리 없이도 모든 의존성을 해결하고 빌드를 수행하는 빌드 시스템을 만들어 낼 수 있습니다. 하지만 더 큰 노력이 필요한 데다 이런 노력에도 불구하고 문제없이 돌아가는 시스템을 본 적이 없습니다. Maven과 sbt는 그런 점에서 꽤 잘 만들어지긴 했지만, 버전 의존성 문제를 추적하고 고치는데 많은 시간을 들이는 것 또한 일반적으로 많이 겪게 됩니다. rbenv나 virtualenv와 같은 시스템은 문제를 우회하려는 노력이지만 개발 환경을 급증하게 하는 결과를 만들었습니다. 단일 리포지터리에서 HEAD는 항상 일관적이고 유효한 버전을 가리키고 있으며 다중 리포지터리의 버전을 추적하는 노력을 완전히 제거하게 됩니다2.

단일 리포지터리를 운영하면서 빌드 시스템에만 이득이 있는 것이 아닙니다. 더 예를 들자면 프로젝트 경계를 넘어서 정적 분석을 수행하는 일에도 추가적인 노력이 필요하지 않습니다. 프로젝트 간의 통합 테스트나 코드 검색과 같이 많은 부분에서도 훨씬 단순해집니다.

프로젝트 간 변경

많은 리포지터리를 갖고 있다면 프로젝트 간 변경은 고통스럽습니다. 일반적으로 각각의 리포지터리를 걸쳐 정합성을 맞추기 위해 어마어마한 양의 수작업이 따르거나 또는 핵과 같은 스크립트를 작성해서 돌려야 합니다. 스크립트가 동작하더라도 프로젝트 간 버전 의존성을 올바르게 고치기 위한 부하도 발생합니다. 10개의 내부 프로젝트에 걸쳐 사용하고 있는 API를 리팩토링한다면 아마 하루종일 시간 쓰기 좋은 분량일 것입니다. 수천 개의 내부 프로젝트에서 사용하고 있는 API를 리팩토링하는 경우라면 꿈도 희망도 없습니다.

단일 리포지터리라면 API와 모든 호출자의 리팩토링은 커밋 하나로 해결할 수 있습니다. 항상 이런 사소한 작업이 있는 것은 아니지만 수많은 작은 리포지터리를 수정하는 방법보다 훨씬 쉽습니다.

대부분은 CVS, RCS, ClearCase와 같은 버전 관리 도구를 사용하는 일이 불합리하다고 생각하고 있습니다. 이런 버전 관리 도구는 여러 파일에 걸친 원자적 커밋을 할 수 없습니다. 그래서 커밋의 타임 스탬프와 커밋 메시지, 또는 "진짜" 원자적으로 커밋된 파일이 어떤 것인지 확인할 수 있는 메타 정보를 확인해야 합니다. SVN, hg, git 등과 같은 도구는 여러 파일 변경도 하나의 커밋에 넣을 수 있기 때문에 이 문제를 해결할 수 있습니다. 단일 리포지터리는 동일한 문제를 여러 프로젝트에 걸쳐 해결할 수 있습니다.

이 접근 방법의 유용성은 단순히 대규모의 API 리펙토링에만 국한되지 않습니다. David Turner는 트위터에서 여러 리포지터리를 단일 리포지터리로 옮기는 작업을 했습니다. 이 작업은 작은 일이지만 여러 프로젝트에 걸친 변경과 이 변경을 배포하는 과정에서 어떤 부하가 발생하는지 이야기합니다.

[프로젝트 A]를 갱신해야 합니다. 하지만 이 작업을 위해서는 내 동료가 이 프로젝트의 의존성인 [프로젝트 B]에서 고친 코드가 필요합니다. 이 동료는 [프로젝트 C]에서 변경된 내용이 필요합니다. 만약 A를 고쳐서 배포하려 한다고 해도 C의 배포를 기다려야 하고 B 배포도 기다려야 하는 상황입니다. 하지만 모든 것이 하나의 리포지터리에 있다면 내 동료가 코드를 변경해서 커밋 하자마자 내 코드 변경도 즉시 가능합니다.

물론 git 버전에 의해 모든 것이 연결되어 있다면 다중 리포지터리에서도 가능할 겁니다. 하지만 내 동료는 두 개의 커밋이 필요할지도 모릅니다. 이런 작업에는 버전을 하나 집어서 "안정화" (더 움직이지 않도록) 하고 싶은 욕망이 항상 생깁니다. 하나의 프로젝트라면 문제가 없겠지만 상호의존적인 복잡한 프로젝트에서는 이조차도 그다지 쉽지 않은 방법입니다.

[다른 방향에서 본다면] 의존하는 부분 이 강제로 갱신돼야 하는 상황인데 이 또한 단일 리포지터리의 장점이라고 할 수 있습니다.

이 접근 방법은 단순히 프로젝트 간 변경을 쉽게 수행할 수 있는 것만 아니라 변경을 추적하는 일도 쉽게 만듭니다. git bisect을 여러 리포지터리에서 수행한다면 분명 다른 도구를 이용해서 메타 정보를 읽는 방법을 배워야 할 것이고 대부분 프로젝트에서는 그냥 간단하게 이런 작업을 하지 않아버리고 맙니다. 만약 이런 일을 한다고 해도 하나의 도구만 써도 충분한 작업을 여러 도구에 걸쳐서 하게 될 겁니다.

Mercurial과 git은 멋집니다! 실화입니다

CVS 또는 SVN에서 git 또는 hg로 변경한 경우에 가장 일반적으로 들을 수 있는 이야기는 엄청나게 생산성이 좋아졌다는 얘기입니다. 이 점은 사실입니다. 하지만 대부분 이런 응답의 관점은 git과 hg가 더 발달한 여러 측면(예를 들면 더 나은 코드 머지를 지원한다던가)이 그 이유였지 작은 리포지터리가 더 낫기 때문인 것은 아니었습니다.

사실 트위터는 git을, 페이스북은 Mercurial을 대형 단일 리포지터리를 지원하기 위해 고쳐서 사용하고 있습니다.

단점

물론 단일 리포지터리에도 단점이 있습니다. 여기서는 단점을 적지 않을 생각인데 이미 충분히 많은 논의가 있었기 때문입니다. 단일 리포지터리는 다중 리포지터리에 비해 엄격하게 우월한 지위를 갖는 것은 아닙니다. 그렇다고 엄격하게 나쁜 것도 아닙니다. 제 관점에서는 누구든 꼭 단일 리포지터리로 옮겨야 한다고 얘기하지 않습니다. 단지 단일 리포지터리를 쓰는 일이 완전히 부당하지 않다는 얘기를 하고 싶었습니다. 구글, 페이스북, 트위터, 디지털오션과 엣시(Etsy)에서 수백, 수천, 수만 개의 작은 리포지터리를 운영하는 대신 단일 리포지터리 사용을 선호하는 점에는 분명 좋은 이유가 있어서 그럴 겁니다.

다른 논의

광범위한 토론에 함께 한 Kamal Marhubi, David Turner와 Leah Hanson에게 감사 말씀 전합니다. 이 토론에서 적어도 절반 이상의 아이디어가 나왔습니다. 또한 이 글에서 오타와 실수를 찾아 준 Leah Hanson, Mindy Preston, Chris Ball, Daniel Espeset, Joe Wilder, Nicolas Grilly, Giovanni Gherdovich, Paul Hammant, Simon Thulbourn에게 감사드립니다.

Footnotes

  1. 이 부분은 제가 일했던 하드웨어 회사에서도 맞는 말입니다. 저는 이 회사에서 NFS에서 동작하는 RCS를 버저닝하는 단일 리포지터리를 만들어서 사용했습니다. 물론 사람들이 중앙 리포지터리에서 파일을 수정할 수 있게 할 수 없으니 억지로 이 작업을 가능하게 누군가가 여러 스크립트를 작성했습니다. 이런 시스템을 추천하진 않습니다만 엄청나게 단일 리포지터리처럼 해킹해서 쓰더라도 단일 리포지터리의 장점을 누릴 수 있습니다.

  2. 적어도 최신 의존성을 관리하는 메커니즘이 있어야 할 것입니다. 물론 이런 작업은 구글에서 문제가 없는데 구글은 코드를 작성해도 많은 수의 직원이 그 코드에 의존하기 때문에 모든 외부 의존성을 단일 리포지터리에 넣어도 전체 직원 규모에서 보면 오히려 비용이 절감되기 때문입니다. 제가 보기에 작은 회사에서 이런 접근 방법에 이득을 얻기에는 너무 비용이 많이 든다고 생각합니다.

이 글은 Robin Wieruch8 things to learn in React before using Redux 번역입니다.


React에서 Redux 전에 배워야 할 8가지

상태 관리(State management)는 어렵습니다. React같은 뷰 라이브러리는 지역 컴포넌트 상태를 관리하는 일이 가능합니다. 하지만 이 상태는 특정 시점에서 확장해야 하는 일이 생깁니다. 리액트는 단순히 뷰 계층 라이브러리 입니다. 언젠가는 Redux와 같이 더 수준 높은 상태 관리 솔루션으로 넘어가는 결정을 하게 될 겁니다. 하지만 이 글에서는 Redux 열차에 올라타기 전에 React에서 알아야 하는 부분에 대해서 지적하고 싶습니다.

사람들은 간혹 React와 Redux를 함께 배웁니다. 하지만 거기에 문제점이 있습니다.

  • 지역 상태 (this.state)만 사용하는 경우에 왜 상태 관리에 확장 문제가 발생하는지 겪어보지 못합니다
    • 그래서 왜 Redux 같은 상태 관리 라이브러리가 필요한지 이해하지 못합니다
    • 그래서 너무 많은 보일러플레이트에 대해 불평합니다
  • React에서 지역 상태를 관리하는 방법을 배우지 못합니다
    • 그래서 모든 상태를 Redux에서 제공하는 상태 컨테이너에 담아두고 관리하려고 합니다
    • 그래서 지역 상태 관리를 전혀 사용하지 않게 됩니다

이런 문제점으로 인해서 React를 먼저 배우고 나중에 필요하다고 느낄 때 Redux를 배우도록 조언합니다. 확장 문제는 대형 애플리케이션에서만 나타납니다. 가끔 Redux를 사용하고 있으면서도 상태 관리 라이브러리가 필요하지 않은 경우가 있습니다. 책 The Road to learn React에서는 Redux와 같은 외부 의존성 없이 있는 그대로의 React로 애플리케이션을 만드는 방법을 설명합니다.

하지만 당신은 지금 Redux 열차에 올라타기로 결정했습니다. 그래서 이 글에서는 Redux를 쓰기 전에 React에서 알아야 할 내용을 살펴봅니다.

  • React에서의 지역 상태는 자연스럽다
  • React 함수형 지역 상태
  • React의 상태와 프로퍼티
  • React 상태 옮기기
  • React의 고차 컴포넌트
  • React의 Context API
  • React의 상태 컴포넌트
  • 컨테이너와 프레젠터 패턴
  • MobX 아니면 Redux?

React에서의 지역 상태는 자연스럽다

이미 언급했지만 가장 중요한 조언은 React를 먼저 학습하라는 점입니다. 컴포넌트에 지역 상태 즉, this.setState()this.state를 사용해서 생명을 불어 넣는 일을 피할 수는 없습니다. 이 방식에 익숙해져야 합니다.

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { counter: 0 };
  }

  render() {
    return (
      <div>
        Counter: {this.state.counter}

        <button
          type="button"
          onClick={() => this.setState({ counter: this.state.counter + 1 })}>
          Click!
        </button>
      </div>
    );
  }
}

React 컴포넌트는 초기 상태를 생성자(constructor)에서 정의하고 있습니다. 그런 후에 this.setState() 메소드를 사용해서 갱신할 수 있습니다. 상태 객체의 갱신은 얕은 병합(shallow merge)으로 수행됩니다. 그러므로 지역 상태 객체를 부분적으로 갱신하고도 상태 객체의 다른 프로퍼티는 손대지 않고 그대로 유지할 수 있습니다. 상태가 갱신된 후에는 컴포넌트가 다시 렌더링을 수행합니다. 앞에서 예로 든 코드에서는 this.state.counter의 갱신된 값을 보여줄 것입니다. 이 예제에서는 React의 단방향 데이터 흐름을 사용해 하나의 닫힌 루프(loop)를 작성했습니다.

React 함수형 지역 상태

this.setState() 메소드는 지역 상태를 비동기적으로 갱신합니다. 그러므로 언제 상태가 갱신되는지에 대해 의존해서는 안됩니다. 상태 갱신은 결과적으로 나타납니다. 대부분의 경우에는 이런 방식이 별 문제 없습니다.

하지만 컴포넌트의 다음 상태를 위해 연산을 하는데 현재 지역 상태에 의존한다고 가정해봅시다. 앞서 작성했던 예제에서는 다음처럼 작성했습니다.

this.setState({ counter: this.state.counter + 1 });

지역 상태(this.state.counter)는 연산에서 바로 그 시점의 상태로 사용했습니다. 그러므로 this.setState()를 사용해서 상태를 갱신하긴 했지만 지역 상태는 비동기 실행이 수행되기 전에 신선하지 않은 상태값을 사용해 연산하게 됩니다. 이런 점은 처음 보고 나서는 바로 파악하기 어렵습니다. 천 마디 말 보다 다음 코드를 보는게 더 빠를 것 같습니다.

this.setState({ counter: this.state.counter + 1 }); // this.state: { counter: 0 }
this.setState({ counter: this.state.counter + 1 }); // this.state: { counter: 0 }
this.setState({ counter: this.state.counter + 1 }); // this.state: { counter: 0 }

// 예상한 상태: { counter: 3 }
// 실제 갱신된 상태: { counter: 1 }

이 코드에서 확인할 수 있는 것처럼 지역 상태를 갱신할 때 현재 상태에 의존해서는 안됩니다. 이런 접근 방식은 버그를 만듭니다. 그래서 이런 상황에서는 다음과 같은 방식으로 지역 상태를 갱신합니다.

this.setState()에는 객체 대신 함수도 사용할 수 있습니다. 함수는 비동기적으로 this.setState()가 실행될 때, 함수 시그니처에 지역 상태를 전달합니다. 그래서 이 함수는 콜백 함수로 정확한 시점에 올바른 상태를 갖고 실행되기 때문에 문제 없이 사용할 수 있게 됩니다.

this.setState(previousState => ({ counter: previousState.counter + 1 }));

이 방법으로 this.setState()를 여전히 이용하면서도 객체 대신 함수를 사용해서 이전 상태를 활용할 수 있습니다.

추가적으로 프로퍼티(props)에 의존적인 갱신이 필요한 경우에도 이 접근 방식을 따라야 합니다. 비동기적 실행이 수행되기 이전에 부모 컴포넌트에서 받은 프로퍼티가 변경되어서 값이 이전 정보가 되는 경우가 있기 때문입니다. 그래서 this.setState()의 두 번째 인자로 프로퍼티가 전달됩니다.

this.setState((prevState, props) => ...);

이제 올바른 상태와 프로퍼티를 사용해서 상태를 갱신할 수 있게 됩니다.

this.setState((prevState, props) => ({ counter: prevState.counter + props.addition }));

객체 대신에 함수를 사용하면서 얻을 수 있는 또 다른 장점은 바로 상태를 갱신하는 방법을 격리된 상태에서 테스트 해볼 수 있다는 점입니다. 단순히 this.setState(fn)을 사용하는 함수를 추출한 다음에 독립적으로 둔 다음에 테스트가 가능하도록 작성할 수 있습니다. 이 함수는 입력으로 간단히 출력을 확인할 수 있는 순수 함수여야 합니다.

React의 상태와 프로퍼티

상태는 컴포넌트 안에서 관리됩니다. 이 상태는 다른 컴포넌트에 프로퍼티로 내려줄 수 있습니다. 이 컴포넌트는 프로퍼티를 사용하거나 더 깊히 자식 컴포넌트로 전달할 수 있습니다. 덧붙여 자식 컴포넌트는 부모 컴포넌트로부터 콜백 함수를 전달 받을 수 있습니다. 이렇게 전달 받은 함수를 사용하면 부모 컴포넌트의 지역 상태를 변경하는 일도 가능합니다. 기본적으로 프로퍼티는 컴포넌트 트리를 타고 내려갑니다. 상태는 하나의 컴포넌트에서 관리합니다. 하위 컴포넌트에서는 프로퍼티로 전달한 함수를 사용해서 상태를 관리하는 컴포넌트까지 거슬러 올라와 상태를 변경할 수 있습니다. 갱신된 상태는 프로퍼티로 다시 하위 컴포넌트로 전달됩니다.

컴포넌트는 전체적인 상태를 관리할 수 있으며 자식 컴포넌트에게 프로퍼티를 전달할 수 있습니다. 프로퍼티에 함수를 전달하는 방법으로 자식 컴폰넌트가 부모 컴포넌트의 상태를 변경할 수 있게 합니다.

하지만 자식 컴포넌트는 전달된 함수의 출처가 어디인지, 프로퍼티로 받은 함수가 어떤 동작을 하는지 알지 못합니다. 이 함수는 부모 컴포넌트의 상태를 변경할 수도 있지만 다른 일을 할 가능성도 있습니다. 자식 컴포넌트는 단순히 실행하는 역할을 합니다. 프로퍼티도 동일합니다. 컴포넌트는 받은 프로퍼티가 프로퍼티인지, 상태인지, 또는 부모 컴포넌트에서 파생된 프로퍼티인지 알 방법이 없습니다. 자식 컴포넌트는 그저 사용할 뿐입니다.

프로퍼티와 상태의 개념을 이해하는 일은 중요합니다. 컴포넌트 트리에서 사용하는 모든 속성은 프로퍼티와 상태로 (그리고 프로퍼티와 상태에서 파생된 속성으로) 나눌 수 있습니다. 무엇이든 상호작용이 필요한 경우에는 상태에 보관되어야 합니다. 그 외 나머지는 모두 프로퍼티 형식으로 전달합니다.

수준 높은 상태 관리 라이브러리를 사용하기 전에 컴포넌트 트리를 따라 프로퍼티를 보내본 적이 있어야 합니다. 가장 끝에 있는 자식 컴포넌트에서 특정 값을 사용하려고 중간 컴포넌트에서는 전혀 쓰지 않는, 수많은 프로퍼티를 전달하는 코드를 작성하면서 "분명 이보다 더 나은 방법이 있을 거야" 생각해본 적이 었어야 합니다.

React 상태 옮기기

이미 지역 상태 계층(local state layer)을 옮겼나요? 이 방식은 일반 React에서 지역 상태 관리를 확장하는데 가장 중요한 전략입니다. 상태 계층은 올릴 수도, 내릴 수도 있습니다.

다른 컴포넌트에서의 접근을 줄이기 위해 지역 상태 계층을 하위로 내릴 수 있습니다. 컴포넌트 A가 자식 컴포넌트로 B와 C를 갖고 있다고 상상해봅시다. B와 C는 A의 자식 컴포넌트로 동등합니다. 컴포넌트 A는 유일하게 지역 상태를 관리하며 자식 컴포넌트에 프로퍼티를 전달합니다. 덧붙여 B와 C에서 A의 상태를 변경할 수 있는 함수도 전달합니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |    Stateful    |
          |                |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       B        |    |        C       |
|                |    |                |
|                |    |                |
+----------------+    +----------------+

이제 컴포넌트 A의 지역 상태 절반은 컴포넌트 C에서 프로퍼티를 통해 쓰고 있으며 컴포넌트 B에서는 전혀 사용하고 있지 않습니다. 게다가 C는 A 컴포넌트에서 C에서만 사용하는 상태만 제어할 수 있는 함수를 프로퍼티로 전달했습니다. 여기서 볼 수 있는 것처럼 컴포넌트 A는 컴포넌트 C를 대신해서 상태를 관리하고 있습니다. 대부분의 경우에는 한 컴포넌트가 자식 컴포넌트의 모든 상태를 관리하는 일에 큰 문제가 없습니다. 하지만 컴포넌트 A와 컴포넌트 C 사이에 다른 컴포넌트가 추가된다고 생각해봅시다. 컴포넌트 A에서 컴포넌트 C에 전달해야 하는 프로퍼티를 컴포넌트 트리에 따라 전달합니다. 컴포넌트 A는 여전히 컴포넌트 C의 상태를 관리하고 있습니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |        +       |
|       B        |    |        |Props  |
|                |    |        v       |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |        +       |
                      |        |Props  |
                      |        v       |
                      |                |
                      +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |                |
                      +----------------+

이런 경우가 React의 상태를 아래로 내려야 하는 완벽한 경우입니다. 컴포넌트 A는 컴포넌트 C의 상태를 관리하고 있지만 이 상태 일부는 컴포넌트 C가 스스로 관리해도 문제가 없습니다. 즉, 각각의 상태에 대해 각 컴포넌트가 자율적으로 움직일 수 있습니다. 지역 상태 관리를 컴포넌트 C로 옮기면 더이상 컴포넌트 트리를 따라 프로퍼티를 전달하지 않아도 됩니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       B        |    |                |
|                |    |                |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |                |
                      |                |
                      |                |
                      +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |     Stateful   |
                      +----------------+

컴포넌트 A의 상태도 덩달아 깔끔해졌습니다. 이 컴포넌트는 필요에 따라 자신의 상태와 가장 가까운 자식 컴포넌트의 상태만 관리하게 됩니다.

React에서 상태 옮기기는 다른 방향, 즉 상태 위로 옮기기도 가능합니다. 부모 컴포넌트인 컴포넌트 A와 자식 컴포넌트인 컴포넌트 B, C로 다시 돌아와서 살펴봅니다. A, B, C 사이에 얼마나 많은 컴포넌트가 있는지 상관 없습니다. 하지만 이번에는 컴포넌트 C가 이미 자신의 상태를 관리하고 있습니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       B        |    |                |
|                |    |                |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |    Stateful    |
                      +----------------+

만약 컴포넌트 B가 C에서 관리하는 상태가 필요하다면 어떻게 해야 할까요? 이 상황에서는 공유할 수 없습니다. 상태는 프로퍼티 형태로 아래로만 넘겨줄 수 있기 때문인데요. 이런 이유에서 상태 계층을 위로 이동시킬 필요가 있습니다. 컴포넌트 C의 상태를 컴포넌트 B와 C가 공통으로 갖는 부모 컴포넌트의 위치로 올릴 수 있습니다. (여기서는 A가 해당되겠군요.) 만약 C가 관리하는 상태를 B에서 필요로 한다면 C는 상태 없는 컴포넌트가 됩니다. 상태는 A에서 관리되며 B와 C에 공유됩니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |        +       |
|       B        |    |        |Props  |
|                |    |        v       |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |                |
                      +----------------+

상태를 위로, 또는 아래로 옮기는 전략에서 단순 React를 사용할 때는 어떻게 상태 관리를 확장하는지 배울 수 있습니다. 더 많은 컴포넌트가 특정 상태에 관심을 가져야 하는 경우에은 상태에 접근해야 하는 컴포넌트 간의 공통 부모 컴포넌트까지 거슬러 올라가 상태를 둬야 합니다. 덧붙여 지역 상태 관리에서 충분히 관리할 수 있다면 컴포넌트는 필요한 만큼 상태를 관리하고 있기 때문입니다. 만약 컴포넌트 자체나 자식 컴포넌트에서 사용하지 않는 상태가 있다면 그 상태는 상태가 필요한 컴포넌트의 위치로 이동해야 합니다.

React의 상태 들어 올리기는 공식 문서에서 더 자세히 살펴볼 수 있습니다.

React의 고차 컴포넌트

고차 컴포넌트 (Higher order components, HOCs)는 React의 고급 패턴입니다. 이 패턴은 추상적인 기능이 필요할 때 사용할 수 있으며 여러 컴포넌트에서 선택적으로 기능이 필요할 때 활용할 수 있습니다. 고차 컴포넌트는 컴포넌트를 받아서 선택적 설정을 입력으로 받아 강화된 버전의 컴포넌트를 반환합니다. 이 기능은 JavaScript의 고차 함수 원칙인 함수를 반환하는 함수처럼 구현되었습니다.

만약 고차 컴포넌트가 익숙하지 않다면 React의 고차 컴포넌트 안내를 읽어보길 추천합니다. 이 글은 React의 고차 컴포넌트를 React의 조건부 렌더링의 용례와 함께 설명합니다.

고차 컴포넌트는 뒤에서 더 중요해지는데 Redux와 같은 라이브러리를 사용하게 되면 마주하게 되기 때문입니다. Redux 같은 라이브러리는 React의 뷰 계층(view layer)와 라이브러리의 상태 관리 계층과 "연결"하게 되며 이 과정에서 고차 컴포넌트를 사용해 처리하게 됩니다. (고차 컴포넌트로 이뤄지는 연결은 react-redux를 사용합니다.)

MobX와 같은 다른 상태 관리 라이브러리도 동일한 방식으로 적용합니다. 고차 컴포넌트는 라이브러리에서 제공하는 상태 관리 계층과 React의 뷰 계층을 붙이는데 사용합니다.

React의 Context API

React의 context API는 드물게 사용됩니다. 이 API를 사용하라 충고하지 않는 편인데 이 API는 안정적이지 않고 애플리케이션의 묵시적 복잡도(implicit complexity)를 높이기 때문입니다. 하지만 어떤 기능을 하는지 들어보면 왜 이런 기능이 있는지 충분히 이해할 수 있을 겁니다.

왜 이 기능을 알아야 할까요? React의 context는 컴포넌트 트리에서 속성을 묵시적으로 전달할 때 사용됩니다. 부모 컴포넌트에서 속성을 context로 선언하면 컴포넌트 트리 아래에 있는 자식 컴포넌트에서 활용할 수 있습니다. 명시적으로 각각의 컴포넌트 계층에 일일이 전달할 필요 없이 단순히 부모-자식 관계라면 부모 컴포넌트가 생성한 context를 자식 컴포넌트가 집어 사용할 수 있습니다. 모든 컴포넌트 트리에 걸쳐 언제든 꺼내서 쓸 수 있는, 보이지 않는 컨테이너가 존재합니다. 이 컨테이너 덕분에 컴포넌트에서 필요하지 않는 프로퍼티는 접근할 일이 없어지기 때문에 React에서 "프로퍼티 내려꽂기(props drilling)"라고 하는 일을 피할 수 있게 됩니다. 다시 원래 주제로 돌아와서 왜 이런 API를 알아야 할까요?

Redux나 MobX와 같은 세련된 상태 관리 라이브러리를 사용하다보면 어떤 시점에서 상태 관리 계층을 React 뷰 계층에 붙여야 하는 상황이 생깁니다. React의 고차 컴포넌트를 언급한 이유가 여기에 있습니다. 이 붙이는 과정을 통해 상태에 접근하고 수정할 수 있게 됩니다. 상태 자체는 일종의 상태 컨테이너 안에서 관리됩니다.

하지만 어떻게 모든 컴포넌트에서 이 상태 컨테이너에 접근할 수 있도록 붙일 수 있을까요? 이런 상황에서 React의 context를 사용할 수 있습니다. 최상위 컴포넌트 즉, React의 루트 컴포넌트(root component)에서 상태 컨테이너를 context로 지정합니다. 그래서 컴포넌트 트리에 있는 모든 컴포넌트에 명시적으로 전달하지 않으면서도 모두 접근할 수 있게 됩니다. 이 모든 과정은 React의 프로바이더 패턴으로 적용할 수 있습니다.

물론 이런 방식을 사용한다는 게 Redux 같은 라이브러리를 사용할 때마다 React의 context를 직접 제어해야 할 필요가 있다는 의미는 아닙니다. 이런 라이브러리는 이미 모든 컴포넌트에서 상태 컨테이너에 접근 가능하도록 모든 기능이 함께 제공되고 있습니다. 하지만 이 기능이 보이지 않는 곳에서 어떤 방식으로 동작하고 있는지 이해하게 된다면 여러 컴포넌트에서 상태를 제어하면서 도대체 이 상태 컨테이너는 어디서 오는 것일까 걱정할 필요도 없어지게 됩니다.

React의 상태 컴포넌트

React는 두 종류의 컴포넌트 선언이 존재합니다. ES6 클래스 컴포넌트와 함수형 상태 없는 컴포넌트(functional stateless component)입니다. 함수형 상태 없는 컴포넌트는 props를 인자로 받고 JSX를 반환하는 단순한 함수입니다. 이 함수는 어떤 상태도 갖지 않으며 React의 생애주기(lifecycle) 메소드에도 접근하지 않습니다. 이 컴포넌트는 이름 붙여진 그대로 상태가 없습니다.

function Counter({ counter }) {
  return (
    <div>
      {counter}
    </div>
  );
}

반면, ES6 클래스 컴포넌트는 지역 상태와 생애주기 메소드를 사용할 수 있습니다. 이 컴포넌트는 this.statethis.setState() 메소드에 접근 가능합니다. ES6 클래스 컴포넌트는 상태 컴포넌트로 사용할 수 있다는 의미입니다. 물론 이 컴포넌트가 꼭 지역 상태를 사용해야 한다는 뜻은 아니며 상태 없는 컴포넌트로도 작성할 수 있습니다. 일반적으로 상태가 없는 ES6 클래스 컴포넌트라면 생애주기 메소드를 사용하기 위해 클래스 형태로 작성한 경우입니다.

class FocusedInputField extends React.Component {
  constructor(props) {
    super(props);
  }

  componentDidMount() {
    this.input.focus();
  }

  render() {
    return (
      <input
        type="text"
        value={this.props.value}
        ref={node => this.input = node}
        onChange={event => this.props.onChange(event.target.value)}
      />
    );
  }
}

결론적으로 ES6 클래스 컴포넌트만 상태를 가질 수도 가지지 않을 수도 있습니다. 함수형 상태 없는 컴포넌트는 항상 상태가 없습니다.

덧붙여 고차 컴포넌트도 React 컴포넌트에 상태를 덧붙일 수 있습니다. 상태를 관리하기 위해 직접 고차 컴포넌트를 만들거나 recompose와 같은 라이브러리에서 제공하는 withState 고차 컴포넌트를 사용하는 것도 가능합니다.

import { withState } from `recompose`;

const enhance = withState('counter', 'setCounter', 0);

const Counter = enhance(({ counter, setCounter }) =>
  <div>
    Count: {counter}
    <button onClick={() => setCounter(n => n + 1)}>Increment</button>
    <button onClick={() => setCounter(n => n - 1)}>Decrement</button>
  </div>
);

고차 컴포넌트를 사용하면 어떤 컴포넌트에든 지역 상태를 추가할 수 있습니다.

컨테이너와 프레젠터 패턴

컨테이너와 프레젠터 패턴은 Dan Abramov의 블로그 포스트 이후 유명해졌습니다. 이 패턴에 익숙하지 않다면 지금이 살펴 볼 차례입니다. 컴포넌트를 컨테이너와 프레젠터로 구분합니다. 컨테이너 컴포넌트는 어떻게 동작하는가를, 프레젠터 컴포넌트는 어떻게 보이는가를 정의합니다. 컨테이너 컴포넌트는 ES6 클래스 컴포넌트로 구현되어 지역 상태를 관리합니다. 프레젠터 컴포넌트는 함수형 상태 없는 컴포넌트로 작성하여 프로퍼티로 받은 내용을 표현하고 부모 컴포넌트로부터 받은 함수 몇 가지를 실행하는 역할을 합니다.

Redux로 뛰어들기 전에 이 패턴 뒤에 있는 원칙을 이해할 필요가 있습니다. 상태 관리 라이브러리는 컴포넌트를 상태와 "연결"해줍니다. 상태 관리 계층과 연결된 컴포넌트를 **연결된 컴포넌트(connected component)**라는 용어로 부르기도 합니다. 이 컴포넌트는 어떻게 보이는가는 신경쓰지 않지만 어떻게 동작하는가에 집중하게 됩니다. 이런 컴포넌트가 바로 컨테이너 컴포넌트의 역할을 합니다.

MobX 아니면 Redux?

모든 상태 관리 라이브러리를 통틀어 Redux가 가장 유명하고 MobX는 살펴 볼 가치 있는 대안입니다. 두 라이브러리는 다른 철학과 프로그래밍 패러다임을 따라가고 있습니다.

둘 라이브러리 중 하나로 고르기 전에 이 글에서 설명한 내용에 대해 이해하고 있어야 합니다. 기본 React의 상태 관리를 확장하기 위해 다른 개념을 적용할 정도로 지역 상태 관리에 익숙해야 합니다. 미래에 규모가 커질 애플리케이션을 염두해서 상태 관리도 확장해야 한다는 점을 기억합시다. 상태 관리 위치를 변경하거나 React의 프로바이더 패턴을 활용해 context를 사용하는 것으로 이미 어느 정도 문제를 해결할 수 있어야 합니다.

Redux나 MobX를 사용하기로 결정했다면 Redux 또는 MobX: 혼란을 해결하려는 시도에서 더 심층적으로 다루고 있으니 읽어보기 바랍니다. 두 라이브러리를 비교하고 어떻게 적용할지 설명하고 있습니다. 아니면 React + Redux 학습 팁으로 바로 Redux를 시작할 수 있습니다.

이 글이 상태 관리 라이브러리를 사용하기 전에 어떤 역할을 하는지 이해하는데 도움이 되었으면 좋겠습니다. Redux와 MobX에 대해 더 궁금하다면 전자책/코스인 Taming the State in React를 확인해보세요.


2018-11-24: Rinae님의 피드백으로 프로퍼티 구멍내기를 프로퍼티 내려꽂기로 수정했습니다. 피드백 감사드립니다.

색상을 바꿔요

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

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