테스팅 프레임워크

  • phpunit: 사실상 표준이라 볼 수 있는 php 테스팅 프레임워크.
  • behat: 행위주도 개발(Behavior-Driven Development) php 프레임워크. 사용자 스토리를 작성하고 테스트를 작성할 수 있음.
  • phpspec: BDD php 프레임워크. spec 기반.
  • pestphp: 내부적으론 phpunit이지만 더 간편한 문법으로 테스트를 작성할 수 있는 프레임워크.
  • codeception: 유닛 테스트, 기능 테스트, 인수 테스트(PhpBrowser 또는 WebDriver) 모두 가능한 프레임워크.

테스트 유틸리티

  • faker: 모의 데이터 생성을 돕는 라이브러리.
  • mockery: 모의 개체를 생성하는 php 프레임워크. 개체의 어떤 메소드를 호출하면 어떤 반환값을 반환하는 지 등을 지정해서 테스트 대역으로 활용할 수 있음.
  • Infection: 변조 테스트를 수행해서 각 경계값이 제대로 테스트되는지 확인하는 도구. 예를 들면 count($this->products) === 0 과 같은 코드를 count($this->products) > 0 등으로 변조해서 테스트를 통과하는지 실패하는지 확인하는 방식.
  • churn-php: 리팩토링 후보를 찾는데 도움 주는 도구. 얼마나 많은 커밋에서 해당 파일이 변경되었는지와 로직의 순환 복잡도를 기준으로 순위를 보여줌.
  • ParaTest: phpunit 병렬로 구동하는 도구.
  • roave/better-reflection: 내장 reflection API를 좀 더 사용하기 깔끔하게 만든 라이브러리.

리포트, 코드 분석 도구

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를 어겼을 때 나타나는 문제와 유사합니다. 이 예시는 불변성 또한 개체 파생에서 나타나는 서브타이핑 문제에 자유롭지 않다는 점을 보여줍니다.

PHP 8.1에 열거형이 추가되었습니다. 그동안 클래스와 클래스 상수를 사용해서 열거형처럼 사용했었는데 용도에 맞게 사용할 수 있는 타입이 생겼습니다.

Enumerations - php를 중점으로 번역했습니다.

열거형, "Enums"는 제한된 선택지를 정의할 수 있는 타입입니다. [...] 각 언어마다 다양한 구현이 있지만 PHP에서는 특별한 종류의 개체로 처리합니다. Enum 자체는 클래스지만 각각 케이스를 단일 인스턴스 개체로 다루는 것도 가능합니다. 즉, 개체를 사용하는 곳이라면 어디든 열거형 케이스를 적용할 수 있습니다. -- Enumerations overview - PHP

열거형 기초

열거형을 다음처럼 선언할 수 있습니다.

enum Suit
{
  case Hearts;
  case Diamonds;
  case Clubs;
  case Spades;
}

열거형 타입으로 Suit를 작성했고 4가지 허용된 값으로 Suit::Hearts, Suit::Diamonds, Suit::Clubs, Suit::Spades를 선언했습니다. 이 값을 직접 사용하거나 변수에 할당해서 사용하는 것도 가능합니다.

function pick_a_suit(Suit $s)
{
  // ...
}

pick_a_suit(Suit::Diamonds);

// 변수에 할당하는 것도 가능
$suit = Suit::Clubs;
pick_a_suit($suit);

pick_a_suit('Hearts');
// TypeError: pick(): Argument #1 ($suit) must
//   be of type Suit, string given...

각 케이스는 별도 정의가 없으면 스칼라 값으로 다뤄지지 않습니다. 내부적으로는 해당 이름의 싱글턴 개체가 존재하기 때문에 다음처럼 작성하는 것도 가능합니다.

$a = Suit::Spades;
$b = Suit::Spades;

$a === $b; // true
$a instanceof Suit; // true

여기서 Suit 타입의 케이스는 별도 데이터를 지정하지 않았기 때문에 "순수 케이스(Pure case)"로 불립니다. 순수 케이스만 포함된 열거형은 순수 열거형(Pure Enum)으로 부릅니다. 모든 순수 케이스는 해당 열거형 타입의 인스턴스로 구현되어 있으며 열거형 타입은 내부적으로는 클래스처럼 동작합니다.

모든 케이스는 읽기 전용 프로퍼티로 name이 존재하며 케이스 이름을 문자열로 반환합니다.

print Suit::Spades->name; // "Spades"

지원 열거형 (Backed enumerations)

위에서 본 열거형은 스칼라 값이 없는, 순수한 형태입니다. 하지만 데이터를 저장한다던지 직렬화 해야 하는 경우에는 열거형에 기본값이 있으면 더 유용하게 사용할 수 있습니다.

스칼라 값을 사용하는 열거형은 다음처럼 작성합니다.

enum Suit: string
{
  case Hearts = 'H';
  case Diamonds = 'D';
  case Clubs = 'C';
  case Spades = 'S';
}

여기서 케이스는 간단한 스칼라 값의 "지원을 받는" 케이스(backed case)입니다. 모든 케이스가 지원 케이스인 열거형을 지원 열거형(Backed Enum)이라고 합니다.

이 지원 열거형은 intstring과 함께 사용할 수 있습니다. 동시에 둘을 지원할 수는 없습니다. 즉, int|string은 안됩니다. 어느 타입이든 지정하면 모든 케이스에서 값이 존재해야 합니다. 즉, int로 지정한다고 하더라도 자동으로 값이 지정되지 않습니다. 또한 각각 케이스의 값은 열거형 내에서 유일해야 합니다.

지정된 값은 리터럴 또는 리터럴 표현식이어야 합니다. 상수와 상수 표현식은 지원되지 않습니다. 즉, 1 + 1은 값으로 지정할 수 있는 표현식이지만 1 + SOME_CONST는 불가능합니다.

지원 케이스도 value라는 읽기 전용 프로퍼티를 제공합니다. 정의할 때 지정한 값을 반환합니다.

print Suit::Clubs->value; // "C"

이 지원 열거형은 내부적으로 BackedEnum 인터페이스를 구현하고 있습니다. 이 인터페이스는 from(int|string): selftryFrom(int|string): ?self 메소드를 포함하고 있습니다. 이 메소드는 다음처럼 활용할 수 있습니다.

enum InvoiceState: string {
  case New = 'new';
  case Paid = 'paid';
  case Confirmed = 'confirmed';
  case Completed = 'completed';
  case Invalid = 'invalid';
}

$invoice = ['id' => 1, 'state' => 'new'];

print $invoice['state']; // 'new'

// 열거형에 정의하지 않은 값으로 테스트
$invoice['state'] = 'half-paid';

$state = InvoiceState::from($invoice['state']);
// Uncaught ValueError: "half-paid" is not a valid
//    backing value for enum "InvoiceState" in...

$state = InvoiceState::tryFrom($invoice['state'])
          ?? InvoiceState::Invalid;

print $state->value; // 'invalid'

이 두 함수를 직접 정의하려고 하면 오류가 발생하니 주의하세요.

열거형 메소드

열거형에도 메소드를 작성할 수 있으며 인터페이스를 구현하는 것도 가능합니다.

interface Colorful
{
  public function color(): string;
}

enum Suit implements Colorful
{
  case Hearts;
  case Diamonds;
  case Clubs;
  case Spades;

  // 클래스처럼 메소드 작성
  public function shape(): string
  {
    return 'Rectangle';
  }

  // Colorful 인터페이스를 구현
  public function color(): string
  {
    return match($this) {
      Suit::Hearts, Suit::Diamonds => 'Red',
      Suit::Clubs, Suit::Spades => 'Black',
    };
  }
}

function paint(Colorful $c) { /* ... */ }

paint(Suit::Clubs);

print Suit::Diamonds->shape(); // 'Rectangle'

유심히 봐야 할 부분은 메소드 내에서 사용한 $this입니다. 각 열거형 케이스는 내부적으로 인스턴스가 존재하기 때문에 호출된 케이스를 $this로 접근할 수 있게 됩니다. 문법의 모습은 정적 클래스와 유사하기만 할 뿐 맥락이 다르다는 점을 확인할 수 있습니다.

참고로 위 구현은 온전히 예시로 작성되었으며 실제라면 별도의 SuitColor 열거형으로 구현하는 게 바람직합니다.

메소드의 접근자는 public, private, protected 모두 가능하지만 열거형은 상속이 불가능하기 때문에 private과 protected 사이에 실질적인 차이는 없습니다.

열거형 정적 메소드

열거형에 정적 메소드를 정의할 수 있습니다. 아래 코드는 정적 메소드를 별도의 생성자처럼 사용하는 예제입니다.

enum Size
{
  case Small;
  case Medium;
  case Large;

  public static function fromLength(int $cm): static
  {
    return match(true) {
      $cm < 50 => static::Small,
      $cm < 100 => static::Medium,
      default => static::Large,
    };
  }
}

열거형 상수

열거형에 상수도 선언할 수 있습니다. 상수로 열거형 케이스를 지정하는 것도 가능합니다.

enum Size
{
  case Small;
  case Medium;
  case Large;

  // 열거형 케이스를 할당
  public const Huge = self::Large;

  // 이런 것도 그냥 할 수 있음
  private const Someone = 'hello';
}

트레이트 (traits)

클래스처럼 동작하기 때문에 트레이트를 사용할 수 있습니다. 다만 프로퍼티가 존재하는 트레이트는 오류가 발생합니다.

trait Rectangle
{
  public function shape(): string {
    return "Rectangle";
  }
}

enum Suit implements Colorful
{
  use Rectangle;

  // ...
}

열거형과 개체의 차이점

열거형은 클래스와 개체로 구현되어 있지만 모든 개체 관련 기능을 사용할 수는 없습니다. 특히 열거형은 상태를 가질 수 없습니다.

  • 생성자, 소멸자 사용 금지
  • 상속 미지원
  • 정적 또는 개체 프로퍼티 금지
  • 열거형 케이스를 복제(cloning)하는 행위 금지
  • __call, __callStatic, __invoke 이외 매직 메소드 금지

또 다음과 같은 특징이 있습니다.

  • __CLASS__, __FUNCTION__ 상수 사용 가능
  • ::class 매직 상수는 열거형과 열거형 케이스에 동일하게 사용할 수 있지만 둘 다 열거형의 클래스명을 반환
  • 접근자 사용 가능
  • 인터페이스 상속 가능
  • 어트리뷰트 사용 가능

값 목록

열거형은 내부적으로 UnitEnum 인터페이스를 구현하고 있으며 cases() 정적 메소드를 제공합니다. 열거형에 선언된 모든 케이스를 담은 배열을 반환합니다.

var_dump(Size::cases());
// [Size::Small, Size::Medium, Size::Large]

직렬화(Serialization)

열거형 직렬화는 개체 직렬화는 다른 방식으로 구현되어 있습니다. 특히 역직렬화 할 때는 기존 싱글톤 값을 그대로 사용할 수 있어서 다음과 같은 동작이 보장됩니다.

Suit::Hearts === unserialize(serialize(Suit::Hearts));
// true

print serialize(Suit::Hearts);
// 'E:11:"Suit::Hearts";'

순수 열거형은 JSON으로 직렬화 시 오류가 발생합니다. 지원 열거형은 표현하고 있는 스칼라 값만 남게 됩니다. 이런 기본 동작은 JsonSerializable 인터페이스를 구현하는 것으로 대체할 수 있습니다.

예제

제한적인 기본값 지정

enum SortOrder
{
  case ASC;
  case DESC;
}

function query(
  $fields,
  $filter,
  SortOder $order = SortOrder::ASC,
) {
  /* ... */
}

match()와 함께 활용하기

enum UserStatus: string
{
  case Pending = 'P';
  case Active = 'A';
  case Suspended = 'S';
  case CanceledByUser = 'C';

  public function label(): string
  {
    return match($this) {
      static::Pending => 'Pending',
      static::Active => 'Active',
      static::Suspended => 'Suspended',
      static::CanceledByUser => 'Canceled by user',
    };
  }
}

//...

foreach (UserStatus::cases() as $case) {
  printf(
    '<option value="%s">%s</option>\n',
    $case->value,
    $case->label(),
  );
}
/**
 * result:
 * <option value="P">Pending</option>
 * <option value="A">Active</option>
 * <option value="S">Suspended</option>
 * <option value="C">Canceled by user</option>
 */

포트와 어뎁터 아키텍처(ports and adapters architecture)는 육각형 아키텍처(hexagonal architecture)로도 불린다.

(육각형 아키텍처를 통해) UI나 데이터베이스 없이 동작하는 어플리케이션을 만듭니다. 그래서 어플리케이션을 자동화된 테스트를 반복해서 수행할 수 있고, 데이터베이스가 없을 때도 동작 가능하며, 사용자 없이도 애플리케이션을 연결할 수 있습니다.

외부와 어플리케이션, 도메인을 육각형 도식으로 명확하게 분리한다. 각 분리된 영역은 항구(port)를 통해 소통하는 구조를 따른다. 코드의 의존성을 "설정"하는 것으로 필요에 따라서, 재사용 할 수 있다는 점을 강조한다.

만들면서 배우는 클린 아키텍처의 예제 코드를 보면서 php로 작성했다. 어느 스터디 그룹에서 정리한 리포지터리에도 잘 정리되어 있어서 같이 보면 유익하다.

코드

다만 의존성 구조를 체크하는 테스트는 아직 옮기지 못했다. (Alistair의 글에서 보면 이 부분도 매우 중요하다고 언급한다.)

패키지 구조

./src
├── Account
│   ├── Adapter
│   │   ├── In
│   │   │   ├── Console
│   │   │   │   ├── BalanceConsoleCommand.php
│   │   │   │   └── SendConsoleCommand.php
│   │   │   └── Web
│   │   └── Out
│   │       └── Persistence
│   │           ├── AccountMapper.php
│   │           ├── AccountObjectEntity.php
│   │           ├── AccountObjectEntityRepository.php
│   │           ├── AccountPersistenceAdapter.php
│   │           ├── ActivityObjectEntity.php
│   │           └── ActivityObjectEntityRepository.php
│   ├── Application
│   │   ├── Port
│   │   │   ├── In
│   │   │   │   ├── GetAccountBalanceQuery.php (interface)
│   │   │   │   ├── SendMoneyCommand.php
│   │   │   │   └── SendMoneyUseCase.php (interface)
│   │   │   └── Out
│   │   │       ├── AccountLock.php (interface)
│   │   │       ├── LoadAccountPort.php (interface)
│   │   │       └── UpdateAccountStatePort.php (interface)
│   │   └── Service
│   │       ├── GetAccountBalanceService.php
│   │       ├── MoneyTransferProperties.php
│   │       ├── NoOpAccountLock.php
│   │       ├── SendMoneyService.php
│   │       └── ThresholdExceededException.php
│   └── Domain
│       ├── Account.php
│       ├── AccountId.php
│       ├── Activity.php
│       ├── ActivityId.php
│       ├── ActivityWindow.php
│       └── Money.php
├── Common
│   ├── ConsoleAdapter.php (interface)
│   ├── PersistenceAdapter.php (interface)
│   └── UseCase.php (interface)
└── Kernel.php

./tests
├── Account
│   ├── Adapter
│   │   ├── In
│   │   │   └── Console
│   │   │       ├── BalanceCommandTest.php
│   │   │       └── SendCommandTest.php
│   │   └── Out
│   │       └── Persistence
│   │           └── AccountPersistenceAdapterTest.php
│   ├── Application
│   │   └── Service
│   │       └── SendMoneyServiceTest.php
│   └── Domain
│       ├── AccountTest.php
│       ├── ActivityWindowTest.php
│       └── MoneyTest.php
├── DataFixtures
│   └── AppFixtures.php
├── Helpers
│   └── CommandTestTrait.php
├── TestData
│   ├── AccountBuilder.php
│   ├── AccountTestData.php
│   ├── ActivityBuilder.php
│   └── ActivityTestData.php
└── bootstrap.php

franzliedke/studio는 php 패키지를 개발할 때 로컬에 있는 패키지를 참조할 수 있도록 도와주는 composer 확장 도구다.

물론 composer에서도 composer.jsonrepositories 설정을 추가하는 것으로 로컬에 있는 패키지를 참조할 수 있다. 하지만 패키지를 배포할 때마다 이 부분을 다시 정리해야 하는 불편이 있다. 만약 경로가 포함된 상태로 배포가 된다면 해당 리포지터리를 참조할 수 없다고 아예 의존성 설치가 불가능해진다. studio는 이런 문제를 해결한다.

이 도구도 내부적으로는 repositories의 path 타입을 추가하는 방식으로 동작하지만 composer.json 파일은 직접 변경하지는 않으며 studio.json이라는 별도 파일을 생성한다.

설치

다음처럼 전역에 설치할 수 있지만 PATH에 ~/.composer/vendor/bin 경로가 추가되어 있어야 한다.

$ composer global require franzl/studio

또는 지역적으로 설치해서 vendor/bin/studio로 사용하는 것도 가능하다.

$ composer require --dev franzl/studio

사용

사용하려는 다른 패키지를 먼저 studio에 등록한다.

$ studio load path/to/some-package

사용하려는 패키지가 한 폴더 내에 모두 있는 경우에는 와일드카드 사용도 가능하다. packages 폴더에 모두 있다면 다음처럼 불러온다. (대신 따옴표를 잘 사용해야 한다.)

$ studio load 'path/to/packages/*'

이미 패키지가 추가되어 있는 경우에는 업데이트를 하면 된다. 패키지명이 my/some-package라고 한다면,

$ composer update my/some-package

새로 설치하는 경우라면 require를 사용한다. @dev는 가장 마지막 커밋을 참조하도록 dev-master를 사용하는 것과 동일한데 더 자세한 내용은 composer 문서를 참고하자.

$ composer require my/some-package @dev

더 이상 로컬 패키지를 사용하지 않으려면 경로를 지운다.

$ studio unload path/to/some-package

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

이 포스트는 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;
}

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

2019년 가을에 커뮤니티 컬리지를 다니기 시작해서 2021년 가을 학기를 마지막으로 졸업하고 편입을 하게 되었다. 여름 학기를 꽉 채워 들었던 덕분에 학기 하나를 빨리 끝내고 학기 시작 전까지 쉬게 되었다. 편입하게 되는 학교는 쿼터제로 운영되는 곳이라서 생각보다 학업이 비는 기간이 길어진 것이 조금 걱정이긴 하지만. 커뮤니티 컬리지와 편입 과정을 거치면서 알게 된 부분을 기록 삼아 남긴다.

첫 학기 시작. 늘 풀냄새 났다.
날 좋으면 멀리까지 보이는 계단에서. 항상 저 밑 주차장 밖에 자리가 없었다.
학교 뒤가 바로 산이라 겨울에 구경도 할 수 있었다.

학생으로 돌아가기

커뮤니티 컬리지에서 학기마다 수업을 듣고 학점을 쌓아서 전문 학사 학위 associate degree를 받거나 학과에 연계된 자격증을 취득하거나 4년제 대학에 편입하는 것으로 졸업하게 된다. 학교에 가게 되면 자신의 목표에 따라서 어드바이저랑 함께 학업 계획서를 짜게 된다. 앞서 말한 목표들은 서로 겹치는 부분도 많지만 미묘하게 안맞는 부분도 있기 때문에 자신이 원하는 것이 무엇인지 정확히 아는 게 중요하다. 이런 부분도 어드바이저가 모두 설명해준다. 나는 UC 계열 학교로 편입해서 학부 졸업을 하는 것을 목표로 커뮤니티 컬리지에 등록했다.

미국에서 학교를 다닌 적이 없어서 기초 과목부터 다 들어야 했고 편입 대비로 들어야 할 수업도 많았다. 학교 다닐 때야 학점 챙기느라 바빴고 그렇게 다니던 중에 판데믹이 시작됐다. 온라인으로 전환된 수업은 어색하긴 했지만 막히는 퇴근길에 껴있는 일이 없어서 오히려 시간 여유는 더 많았던 것 같다. 편입 기간에는 에세이랑 원서 준비하는 일에 바빴다. 원서를 모두 다 내고 나니까… 싱숭생숭하는 마음에 아무 일도 손에 잡히질 않았다. 그나마 다행인건 여름학기까지 꽉꽉 넣어 들은 덕분에 편입 결과 기다리는 동안은 조금 여유있게 지낼 수 있었다.

난 캘리포니아에서 다니긴 했지만 캘리포니아 내에서도 지역마다 (school district마다) 학교마다 다른 부분이 많다. 내가 경험한 부분에 대해서만 쓰는 것이라서 정책이 언제든 달라질 수 있고 지역/학교마다 전혀 다른 정책을 갖고 있을 수 있다. 대략적으로 참고만 하고 자세한건 학교 어드바이저와 꼭 상담하는 것이 중요하다. 학교에는 정말 도움 줄 사람이 많다. 편입 센터나 카운슬링 가서 궁금한건 꼭 물어보자.

비용

내 경우에는 비자 및 거주 문제가 해결된 상황이었고 in-state tuition으로 다닐 수 있었다. 그래서 학비는 정말 저렴한 편이었고 판데믹 동안 특별 지원금과 백신 지원금, 전 학생 대상으로 학교 스토어 지원금이 나오기도 했다. 따로 기록한 것은 아니라서 교보재나 교통, 생활비는 얼마나 들었는지 알 수 없는데 학교 웹페이지에서 확인할 수 있는 이력만 정리하면 다음과 같다.

Term Charges Units
Tuition - 2019 Fall $ 693.00 14
Tuition - 2020 Spring $ 879.00 18
Tuition - 2020 Summer $ 307.00 6
Tuition - 2020 Fall $ 784.00 16
Tuition - 2021 Spring $ 922.00 19
Tuition - 2021 Summer $ 358.00 7
Tuition - 2021 Fall $ 738.00 15
Parking - 2019 Fall $ 50.00
Parking - 2020 Spring $ 25.00
Campus Store grant - 2020 Spring $ (75.00)
Campus Store grant - 2021 Spring $ (100.00)
Campus Store grant - 2021 Fall $ (500.00)
Higher Education Emergency Relief Fund $ (1,500.00)
Vaccine Incentive Program $ (300.00)
Total $ 2,281.00

IGETC와 assist.org

캘리포니아에서 편입을 염두하고 커뮤니티 컬리지를 간다면 IGETC에 맞춰 학업 계획을 세우게 된다. IGETC는 Intersegmental General Education Transfer Curriculum의 줄임말로 교양 커리큘럼이다. 이 커리큘럼에 맞춰 수업을 들으면 IGETC Certificate을 받을 수 있는데 이 자격으로 편입 학교에 교양 수강 요건을 면제받을 수 있다. 그런데 이것도 학교/학과마다 다르기 때문에 어느 곳으로 편입하게 되는가가 중요하다. 일부만 해도 인정해주는 곳도 있고, 아예 인정 안해주는 곳도 있고, 인정 해주지만 추가로 더 들어야 할 수업이 있는 경우도 있다.

다음으로 중요한 부분은 지원하려는 학과의 요구 과목이 무엇인지 확인해야 한다. 이런 부분은 각 편입 학교의 어드미션 페이지를 찾아보면 자세하게 설명해준다. 캘리포니아 커뮤니티 컬리지에서 캘리포니아 내 대학으로 편입할 때 요구되는 수업이 무엇인지, 자신의 커뮤니티 컬리지에서 전공 학점으로 인정 되는 수업(articulated)은 어떤 수업인지 등 정보는 assist.org에서 찾을 수 있다. 이 웹페이지에서 연계된 과목으로 표시되어 있다면 전공이든 교양이든 편입되는 학교에서 별다른 문제 없이 해당 과목을 수강한 것으로 처리된다. 다니게 될 커뮤니티 컬리지를 정할 때 편입하려는 학교 학과에 최대한 연계 과목이 많은 곳을 고르는 것이 바람직하다. 일반적으로 편입하려는 학교와 같은 지역에 있는 커뮤니티 컬리지가 이 연계 과목이 많다고 하는데 정확한 정보는 저 웹사이트에서 확인하는 것이 좋다.

교양과목 선택

IGETC를 채우기 위해서 이런 저런 교양을 듣게 되는데 어떤 수업을 들어야 할지 고민된다면 다른 대학에 articulation이 존재하는 수업을 듣는 것이 바람직하다. 물론 IGETC를 받는 것에는 어느 수업을 들어도 큰 문제가 없겠지만 articulated 된 수업은 후속 강의를 듣는데 편리하다. 만약 연계가 되어있지 않다면 청원을 통해서 수업을 들은 것으로 인정 받아야 하는데 커리큘럼, 과제, 시험 본 것 등을 제출해야 하며 결과도 꽤 오래 걸릴 수도 있다.

특히 추가적인 GE 요구사항이 있는 학교에서는 연계된 과목을 자신의 커뮤니티 컬리지에서 찾을 수 있다면 그 수업을 듣는 것이 좋다. 연계된 과목은 교양도 모두 인정받을 수 있기 때문이다. 교양으로 경제 수업을 듣는다면 assist.org에서 경제학과 요강을 찾아보는 식으로 연계 과목을 확인할 수 있다. 예를 들면 UC San Diego에서는 학부 졸업 요건 중에 American History and Institutions (AHI)가 있는데 여기에 해당하는 수업이 무엇인지 찾아서 이 과목과 연계된 과목이 커뮤니티 컬리지에 있는지 찾아 듣는 것이다. 그러면 IGETC도 채우면서 편입 이후에 졸업 요건 준비에도 도움이 된다.

전문학사 학위

편입한다면 크게 중요하지 않을 순 있지만 하다보면 학위 취득 요건이 채워질 때도 있다. 예를 들면 편입하는 학과에서 요구하는 수학 수업이 커뮤니티 컬리지에서 가르치는 모든 수학 강의인 경우도 있다. 순차적으로 들어야 하는 수업도 있기 때문에 어쩔 수 없이 이런 저런 수업을 섞어서 들어야 하고 그러다보면 요건 채우게 돼서 학위를 받게 될 수도 있다.

내 경우에는 컴퓨터과학 편입하려고 준비하다보니 커뮤니티 컬리지에서 제공하는 모든 물리와 수학 강의를 들었어야 했었다. 그래서 졸업할 때 컴퓨터, 물리학, 수학 전문 학사를 받았다... 어디 가서 쓸 일은 없겠지만 처음 받아봐서 그래도 뭔가 수고한 기분이다!

마지막 학기는 마스크 쓰고 오프라인 수업해서 덥고 피곤했다

학교를 다니면서 느낀건 결국 나에게 가장 도움 되는 결정은 내 스스로 내려야 한다는 부분이다. 연계 수업을 미리 확인하고 학교를 골랐더라면 편입에 더 도움이 되었을까, IGETC가 필수가 아니었던 곳을 먼저 지원했어야 했을까, 선택의 기로에 서서 했던 결정이 옳은 결정이었는지. 그런 지나간 고민들은 편입 폭풍이 지나갈 때까지 나를 많이 흔들었는데 이제 모두 끝났으니까 뭐 어쩌겠어, 앞으로 올 것에 집중해야 한다.

그리고 미드 커뮤니티를 모니터 닳도록 봤기 때문에 커뮤니티가 얼마나 현실을 잘 반영하고 있는지 확인할 기회였다. 😏 물론 현실은... 그냥 공부하기 바빴다. 그나마 와닿는 것은 오프닝 가사 마지막 소절인데 One by one they all just fade away. 나도 그렇게 fade away 되었다.


학업에 전폭적인 지지를 해주는 민경 씨에게 늘 미안하고 고맙다. 열심히 해서 좋은 결실 맺었으면 좋겠다.

아무래도 개발에 관해서 많이 쓰다보니 마치 개발 블로그처럼 되어버려서, 개발 아닌 것을 쓰는데 이상한 죄책감 비스무리한 게 종종 괴롭혔다. 그래서 그동안 포스트를 쓰지 않는 대신에 이런저런 부수적인 페이지를 많이 늘리고 있었다. 텀블러처럼 작은 글을 올릴 공간을 부스러기라고 붙여놓고 올리기도 했다. 페이지가 옆으로만 늘어나고 관리되지 않는 기분이 들어서 모두 블로그 포스트로 변환했다. 모든 기록을 포스트로 남기고 분류를 잘 하는 쪽으로 바꾸기로 했다. 분류 태그도 정리했다. 차분한 분위기를 내고 싶어서 서체도, 색상도 잔잔하게 선택했다. 조금 정돈된 느낌도 들고.

다른 플랫폼도 많고 도구도 많은데 직접 쓰는 블로그에는 유독 집착 내지는 애착이 간다. 이렇게 가끔 페이지를 다듬으면서 또 열심히 써보자 다짐한다.

그렇다고 글을 잘 쓰는 것도 아닐 뿐더러 나중에 다시 보면 부끄러운 글도 분명 많다. 예전엔 부끄러운 글 많다고 폭파하는 일도 잦았는데 그런 모습도 결국 다 내 모습이었다. 되고싶은 모습이 있는 것도 좋고 그런 모습에 가까워지기 위해 노력하는 것도 좋지만 그렇다고 해서 지금 당장의 나를 가릴 필요는 없었다. 그런 마음가짐 이후에는 조금은 홀가분한 기분으로 글을 쓴다. 아무렴 뭐 어때, 그런 태도로 산다. 태도가 달라지니까 예전보다 마음에 여유가 생겼다.

물론 어려운 마음이 쏟아지는 일이 아예 없어진 것은 아니다. 어디서 숨어 있다가 피곤함이 몰려오는 날에는 함께 뛰쳐나와서 온 정신을 난장판으로 만드는 일도 종종 일어난다. 예전 같으면 그런 감정 충돌에 심하게 휘말렸다. 지금은 그 때와는 다른 나와 산다. 아무렴 뭐 어때, 그냥 그런 날도 있는 것이다.

잔잔하게 더 많은 글을 쓰면서 지내고 싶다.

어떤 프로그램이든 배열이나 목록과 같은 자료구조에서 조건에 맞는 요소(element)를 찾아 하위 집합을 만들어야 하는 경우가 있습니다. PHP에서는 배열(array)이 기본이 되는 자료구조 중 하나인데요. 이 배열을 대상으로 내장 함수인 array_filter()를 사용해서 조건에 맞는 요소만 골라내는 작업을 수행할 수 있습니다.

빠르게 callable 표현식/문법만 확인하고 싶다면 callable 정리 부분을 참고하세요.

배열 필터하기

먼저 공식 사이트에서 함수 시그니처를 확인합니다. 함수 시그니처에서는 어떤 타입의 값을 넣어야 하는지, 함수의 결과는 어떤 타입으로 반환되는지 확인할 수 있습니다.

array_filter(
  array $array,
  ?callable $callback = null,
  int $mode = 0
): array

함수 시그니처를 확인하셨나요? $array에는 대상이 되는 배열, $callback에는 배열 요소가 찾으려는 조건에 맞는지 검사하는 함수, 마지막 $mode는 검사하는 함수에 인자를 어떻게 입력하는지 정하는 플래그를 넣을 수 있습니다.

어떤 타입을 넣어야 하는가도 알 수 있습니다. $array는 array 타입, $callback callable 또는 null 타입, $mode는 정수형 타입을 넣을 수 있습니다. 함수의 반환 타입은 array 타입이고요. $callback$mode에는 각각 null과 0이 기본값으로 배정되어 있습니다.

예시로 다음 같은 배열이 있다고 생각해봅니다.

$nums = array(1, 2, 3, 4, 5, 6, 7);

이 배열에서 짝수인 숫자만 모아서 배열을 만들려고 합니다. 그렇다면 숫자 하나를 입력으로 받아서 짝수인지 아닌지 검사하는 함수를 먼저 생각해봅시다. 다음처럼 함수를 작성할 수 있습니다.

function is_even($number) {
  return $number % 2 === 0;
}

이제 대상이 되는 배열과 검사하는 함수를 array_filter()에 인자로 전달합니다. 그 결과로 짝수만 들어있는 배열이 반환됩니다.

$even_nums = array_filter($nums, 'is_even');

// 어떤 값이 있는지 `var_dump()` 함수로 검사합니다.
var_dump($even_nums);
// array(3) {
//   [1]=>
//   int(2)
//   [3]=>
//   int(4)
//   [5]=>
//   int(6)
// }

필요로 한 결과가 나왔지만 자세히 보면 흥미로운 부분이 있습니다. 두 번째 인자로 사용한 'is_even'은 문자열인데 어떻게 array_filter()가 함수로 인식한 것일까요?

문자열로 된 callable 타입

앞서 본 예시처럼 array_filter() 함수에는 사용자 정의 함수를 인자로 전달해야 합니다. 다만 이전 버전의 PHP에서는 함수를 직접 넣어서 전달할 수 있는 방법이 없었습니다. 대신에 그 해결책으로 callable 타입이 존재하게 되었는데 함수명을 문자열로 저장하면 그 함수를 호출할 수 있게 됩니다.

다음 함수가 있다고 가정해봅니다.

function sayHello() {
  echo "Hello!";
}

첫 예제는 함수를 직접 호출했습니다. 당연한 결과가 나옵니다.

// 1.
sayHello(); // "Hello!"

함수명을 문자열로 $a에 저장합니다. 함수처럼 다뤄볼까요?

// 2.
$a = 'sayHello';
$a(); // "Hello!"

함수가 호출됩니다. 저장 안하고 문자열을 그냥 호출하는 것도 가능할까요?

// 3.
'sayHello'(); // "Hello!"

// 4.
$b = 'Hello';
"say$b"(); // "Hello!"

문자열에 함수명이 저장되어 있으면 그 자체로 호출이 가능합니다. 이렇게 문자열에 호출할 수 있는 무언가가 있는 경우를 callable 타입으로 볼 수 있습니다. 물론 그 문자열로 저장된 함수가 실제로 존재해야겠죠? 저장된 문자열이 callable인지 아닌지는 is_callable() 함수로 검사할 수 있습니다.

var_dump(is_callable("sayHello"));  // true
var_dump(is_callable("sayWhaatt")); // false, 없는 함수

문자열로 된 callable 타입 덕분에 다른 함수에 어떤 함수를 호출해야 하는지 전달할 수 있게 되었습니다. 다시 원래 주제로 돌아가서 얘기하면 array_filter() 함수에 문자열로 조건 검사를 수행할 함수 이름만 전달해도 기대한 것처럼 동작하게 됩니다.

정적 클래스 메소드를 callable

앞에서는 단순한 예제라서 단순히 함수를 전달하는 것으로도 충분했습니다. 프로젝트가 좀 더 커져서 여러 필터가 필요한 상황을 생각해봅시다. 여러 필터를 함수로 관리하다보면 다른 배열을 대상으로 하는 비슷한 이름의 함수가 많아질 수 있습니다.

// 홀수를 검사하는 함수
function is_odd($num) {
  return $num % 2 === 1;
}

// 이상한 사람을 검사하는 함수
function is_odd($person) {
  return $person['is_odd'] === true;
}
// Fatal error:  Cannot redeclare is_odd() (previously declared ...)

이런 충돌을 피하기 위해서 긴 함수명을 선택할 수 있지만 깔끔해보이진 않습니다. (다른 영어 단어를 선택할 수도 있지만... 여기서는 같은 이름의 함수여야만 한다고 생각해봅시다. 의외로 그런 경우가 꽤 있거든요.)

function is_odd_number($num) {
  return $num % 2 === 1;
}

function is_odd_person($person) {
  return $person['is_odd'] === true;
}

이럴 때 정적 클래스 메소드를 사용하면 이런 함수를 좀 더 깔끔하게 관리할 수 있습니다. 조금 전통적인 방식 중 하나입니다.

class NumberFilter {
  public static function is_odd($num) {
    return  $num % 2 === 1;
  }
}

class PersonFilter {
  public static function is_odd($person) {
    return $person['is_odd'] === true;
  }
}

NumberFilter::is_odd(3); // true
PersonFilter::is_odd(['name' => 'Edward', 'is_odd' => false ])); // false

이런 함수도 callable로 호출 할 수 있을까요? 정적 클래스 메소드도 문자열 형태로 호출이 가능합니다. is_callable()로 확인해보고 array_filter()까지도 사용해봅시다.

var_dump(is_callable('NumberFilter::is_odd')); // true

// [$a, $b, ...] 은 array($a, $b, ...) 처럼 배열을 입력하는 간편 문법입니다.
$nums = [1, 2, 3, 4, 5, 6, 7];

$odd_nums = array_filter($nums, 'NumberFilter::is_odd');

// 어떤 값이 있는지 `var_dump()` 함수로 검사합니다.
var_dump($odd_nums);
// array(4) {
//   [0]=>
//   int(1)
//   [2]=>
//   int(3)
//   [4]=>
//   int(5)
//   [6]=>
//   int(7)
// }

클래스명을 문자열로 넣는다면 글자를 빼먹거나 잘못된 문자가 들어가서 의도와 다르게 동작할 수도 있습니다. 그나마 그런 문제를 해소하기 위해 특별 상수인 클래스 상수를 사용하기도 합니다.

is_callable(NumberFilter::class . '::is_odd');
// true, 'NumberFilter::is_odd'과 동일
array_filter($nums, NumberFilter::class . '::is_odd');

클래스 상수는 클래스명을 문자열로 반환합니다. 반환된 클래스명과 나머지 메소드명을 병합해서 위와 동일한 결과를 만들었습니다. 클래스 상수는 네임스페이스도 알아서 처리해주는 장점이 있습니다. 또한 callable이 실행될 때 해당 클래스가 코드에서 실제로 접근할 수 있는 것인지도 코드를 작성할 때 확인할 수 있습니다.

아직 조금 아쉬운 점도 있습니다. 정적 메소드 접근을 위한 '::'을 문자열로 적어야 한다는 점, 문자열을 합치는 과정도 필요하다는 부분인데요. PHP는 이런 불편함을 조금 덜 수 있도록 배열 형태의 callable을 지원합니다.

$a = 'NumberFilter::is_odd';           // 문자열 callable
$b = NumberFilter::class . '::is_odd'; // 클래스 상수를 활용한 문자열 callable
$c = [NumberFilter::class, 'is_odd'];  // 배열 callable

is_callable($a); // true
is_callable($b); // true
is_callable($c); // true

array_filter($nums, [NumberFilter::class, 'is_odd']);

이제 정적 클래스 메소드도 문제 없이 사용할 수 있게 되었습니다. 조금 더 까다로운 필터가 필요하다면 어떻게 해야 할까요? 검사하는 함수를 재사용 가능하게 만들 수 있을까요? 방금 살펴본 배열 형태의 callable을 활용하면 더 다채로운 형태로 구현할 수 있습니다.

개체(object)를 활용하는 callable

앞에서 callable은 배열 형태로도 사용할 수 있다는 점을 배웠습니다. 정적 클래스 메소드에서는 클래스명과 함수명을 배열에 넣는 방식으로 사용했습니다. 클래스명 대신에 개체를 넣으면 개체의 메소드를 활용할 수 있습니다. 코드를 살펴봅시다.

먼저 개체를 만들 클래스를 작성합니다.

class CompareWithFilter {
  protected $num;

  public function __construct($num) {
    $this->num = $num;
  }

  public function isSmallerThan($input) {
    return $input > $this->num;
  }
}

CompareWithFilter 클래스는 개체를 생성할 때 숫자를 받습니다. 이 숫자를 보관하고 있다가 isSmallerThan() 메소드를 비교할 숫자를 넣어 호출하면 보관된 숫자와 비교해서 결과를 반환합니다. 이 개체의 isSmallerThan() 메소드를 callable로 호출하려고 합니다. 앞서 본 배열의 형태로 전달하면 됩니다.

$nums = [1, 2, 3, 4, 5, 6, 7];

// 앞서 작성한 필터를 생성합니다
$five = new CompareWithFilter(5);

// 5보다 작은 숫자를 걸러냅니다
$filtered = array_filter($nums, [$five, 'isSmallerThan']);
// 5가 1보다 작다? -> false
// 5가 2보다 작다? -> false
// 5가 3보다 작다? -> false
// 5가 4보다 작다? -> false
// 5가 5보다 작다? -> false
// 5가 6보다 작다? -> true
// 5가 7보다 작다? -> true

var_dump($filtered);
// array(2) {
//   [5]=>
//   int(6)
//   [6]=>
//   int(7)
// }

개체를 활용하는 더 간단한 방법도 있을까요? 클래스에 __invoke() 매직 메소드를 선언하면 그 개체 자체를 호출할 수 있습니다.

class SmallerThan {
  protected $num;
  public function __construct($num) {
    $this->num = $num;
  }
  public function __invoke($input) {
    return  $input > $this->num;
  }
}

$two_is_smaller_than = new SmallerThan(2);
is_callable($two_is_smaller_than); // true
var_dump($two_is_smaller_than(3)); // true

위에서 확인할 수 있는 것처럼 이렇게 생성한 인스턴스도 callable 타입에 해당합니다. array_filter() 함수에서도 문제 없이 동작하는 것을 확인할 수 있습니다.

$nums = [1, 2, 3, 4, 5, 6, 7];
$five_is_smaller_than = new SmallerThan(5);
$filtered = array_filter($nums, $five_is_smaller_than);
// array(2) {
//   [5]=>
//   int(6)
//   [6]=>
//   int(7)
// }

그런데 PHP에는 익명 클래스도 존재합니다. 간단하게 사용할 클래스라면 익명 클래스를 활용할 수도 있습니다. 여기서 배운 __invoke() 매직 메소드를 사용하면 익명 클래스도 callable로 사용할 수 있습니다.

//@ PHP >= 7.0
// $num의 배수만 골라내는 클래스에 3으로 초기화하고 사용
array_filter($nums, new class(3) {
  protected $num;
  public function __construct($num) {
    $this->num = $num;
  }
  public function __invoke($input) {
    return $input % $this->num === 0;
  }
});
// array(2) {
//   [2]=>
//   int(3)
//   [5]=>
//   int(6)
// }

장황하게 보이지만 신기하게도 가능합니다.

그렇다면 반대로 callable을 아주 간단하게 작성할 방법은 없을까요? 용도가 유일해서 다른 곳에서 쓸 일이 없는 필터라면 명시적인 클래스나 함수로 선언하지 않는 것이 가장 깔끔할 겁니다.

클로저(Closure)를 callable로 활용하기

다른 곳에서 활용할 일이 없는 필터라면 익명 함수를 활용해도 간단하고 편리합니다.

//@ PHP >= 5.3
$is_even = function ($num) {
  return $num % 2 === 0;
};

var_dump($is_even(2)); // true
var_dump(is_callable($is_even)); // true

var_dump($is_even);
// object(Closure)#1 (1) {
//   ["parameter"]=>
//   array(1) {
//     ["$num"]=>
//     string(10) "<required>"
//   }
// }

위 결과처럼 익명 함수를 선언하면 클로저 개체로 반환됩니다. 이 클로저 클래스에는 __invoke() 메소드가 내장되어 있어서 앞서 본 예제와 같이 실행 가능한 개체로 동작합니다. 위에서는 변수에 할당했지만 아래처럼 바로 사용하는 것도 문제 없습니다.

$nums = [1, 2, 3, 4, 5, 6, 7];
// 짝수만 골라내는 익명 함수
$even_nums = array_filter($nums, function ($num) {
  return $num % 2 === 0;
});
var_dump($even_nums);
// array(3) {
//   [1]=>
//   int(2)
//   [3]=>
//   int(4)
//   [5]=>
//   int(6)
// }

앞서 작성한 숫자 비교는 이 익명 함수에서 어떻게 작성할 수 있을까요? 비교하려는 숫자를 함수 내에 명시하지 않고 외부에서 지정하는 것도 가능합니다. 익명 함수를 작성할 때, use 키워드로 바깥 스코프에 있는 변수를 사용할 수 있습니다.

$nums = [1, 2, 3, 4, 5, 6, 7];
$other_num = 5;

// `$other_num`보다 작은 숫자만 골라내기
$filtered = array_filter($nums, function ($num) use ($other_num) {
  return $num < $other_num;
});

var_dump($filtered);
// array(4) {
//   [0]=>
//   int(1)
//   [1]=>
//   int(2)
//   [2]=>
//   int(3)
//   [3]=>
//   int(4)
// }

화살표 함수를 사용하면 더 간단하게 작성할 수 있습니다. 화살표 함수는 익명 함수를 더 간결하게 작성할 수 있는 문법입니다.

//@ PHP >= 7.4
// `$other_num`보다 작은 숫자만 골라내기
$filtered = array_filter($nums, fn($num) => $num < $other_num);

이 화살표 함수는 앞서 작성한 익명 함수와 동일한 역할을 하면서도 더 간결합니다. 부모 스코프에 있는 변수도 별도 지정 없이 바로 사용할 수 있습니다.

CallableExpr(...) 문법으로 callable 날개 달기

마지막으로 살펴 볼 내용은 CallableExpr(...) 문법입니다. 이 문법을 사용하면 callable 문자열로 처리하는 동안 생기는 말썽을 해결할 수 있습니다. 하지만 이 문법을 보기 전에 먼저 Closure::fromCallable() 메소드를 확인합니다.

이 정적 메소드는 callable을 전달하면 Closure 개체로 감싸서 반환하는 래퍼 함수(wrapper function)입니다.

function sayHello() {
        echo "Hello!";
}       

//@ PHP >= 7.4
$actor = Closure::fromCallable('sayHello');
var_dump($actor);
// object(Closure)#1 (0) {
// }

$actor(); // Hello!

문자열로 되어 있는 callableClosure 인스턴스로 바꿔서 활용합니다. 이 메소드를 CallableExpr(...) 문법으로 사용할 수 있습니다.

$a = Closure::fromCallable('sayHello');
$b = sayHello(...); // 위 메소드 호출과 동일한 표현

var_dump($a == $b); // true

다음과 같은 방식으로 사용하게 됩니다.

$nums = [1, 2, 3, 4, 5, 6, 7];

function is_even($number) {
  return $number % 2 === 0;
}

//@ PHP >= 8.1
$even_nums = array_filter($nums, is_even(...));

var_dump($even_nums);
// array(3) {
//   [1]=>
//   int(2)
//   [3]=>
//   int(4)
//   [5]=>
//   int(6)
// }

이전에 문자열일 때는 존재하지 않는 함수명을 적을 수 있는 문제가 있었습니다. CallableExpr(...) 문법은 callable에 없는 메소드 등을 사용하는걸 방지하는데 도움이 됩니다. 또한 문자열이나 배열로 된 callable을 다루는 방식보다 이 문법은 좀 더 일관성이 있습니다.

이 문법은 어떤 callable이든 활용할 수 있습니다. 앞에서 살펴본 callable 예제를 이 문법으로 작성하면 다음과 같습니다.

// 1. 함수
// array_filter($nums, 'is_even');
array_filter($nums, is_even(...));

// 2. 정적 클래스 메소드
// array_filter($nums, 'NumberFilter::is_odd');
// array_filter($nums, NumberFilter::class . '::is_odd');
// array_filter($nums, [NumberFilter::class, 'is_odd']);
array_filter($nums, NumberFilter::is_odd(...));

// 3. 개체 메소드
// array_filter($nums, [$five, 'isSmallerThan']);
array_filter($nums, $five->isSmallerThan(...));

callable 정리

이 글에서 다룬 모든 callable 타입 표현을 정리합니다.

문자열 callable

$a = 'sayHello';
$b = 'Foo\Bar\SomeClass::filter';
$c = Foo\Bar\SomeClass::class . '::filter';

배열 callable

$a = [Foo\Bar\SomeClass::class, 'filter'];
$b = [$obj, 'methodName'];

__invoke() 매직 메소드가 있는 인스턴스

//@ PHP >= 5.3
$obj;

익명함수 (클로저) callable

//@ PHP >= 5.3
$a = function ($num) use ($other) { /* ... */ };
//@ PHP >= 7.4
$b = fn($num) => $num > $other; // 축약식 (화살표 함수)

__invoke() 매직 메소드가 있는 익명 클래스

//@ PHP >= 7.0
$a = new class () {
  public function __invoke() {
    /* ... */
  }
};

Closure::fromCallable()로 만든 클로저 callable

//@ PHP >= 7.4
$a = Closure::fromCallable('sayHello');

CallableExpr(...)로 만든 클로저 callable

//@ PHP >= 8.1
$a = sayHello(...);
$b = Foo\Bar\SomeClass::is_odd(...);
$c = $obj->methodName(...);

웹사이트 설정

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

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