트레이트 – 09.트레이트 로컬 스코프

트레이트의 로컬 스코프에 대한 개념은 RFC 문서 nonbreakable traits에 잘 정리되어 있습니다. 그러나 RFC 문서에서는 트레이트 조합을 위한 use 문을 사용하지 않았고, 대신에 include 문으로 설명하고 있으며, 트레이트 로컬 스코프에 대한 적용 대상과 접근법도 현재의 PHP와 다르게 제안하고 있습니다.

PHP에 구현된 트레이트의 로컬 스코프와 관련된 접근법을 보면 기본적으로 복사·붙여넣기 메커니즘에 의하여, 컴파일할 때 트레이트에서 필요한 멤버를 복사하여 클래스에 붙여넣기(조합)하며, 붙여넣기된 멤버는 조합 클래스 스코프를 가지게 되므로, 런타임시에는 트레이트의 존재를 알 수 없습니다.

일부 메소드에 대하여 별칭을 부여하여 호출할 수는 있지만, 이러한 별칭의 메소드도 트레이트 메소드의 로컬 스코프를 접근하는 것이 아니라, 조합 클래스에 새로운 별칭의 메소드가 생성된 것과 같은, 즉 조합될 때 별칭의 새로운 메소드 이름으로 복사·붙여넣기된다는 개념을 가지고 있습니다. 따라서 새로운 이름을 부여했다고 해서 원래의 트레이트 메소드의 이름이 변경되는 것은 아닙니다.

(주의) RFC 문서 nonbreakable traits는 PHP로부터 받아들여지지 않은(declined) 문서임으로 현재 구현된 PHP와는 다르다는 것에 유의하시고 본 문서를 보시기 바랍니다.

메소드와 프로퍼티의 로컬 유지

RFC 문서 nonbreakable traits에서 제안하는 로컬 스코프

RFC 문서 nonbreakable traits에서 설명하는 트레이트의 로컬 스코프 접근 개념을 먼저 살펴 보겠습니다.

메소드·프로퍼티를 트레이트에 로컬로 유지하게 되면, 클래스 내부 또는 클래스에서 생성된 객체로부터 접근이 거부됩니다. 또한 다른 트레이트(included trait) 내에서 동일한 이름이 사용되거나 클래스(including class)에서 재정의 된 경우에도, 해당 트레이트 내에서 연결된 모든 로컬 메소드의 참조가 사라지지 않고 유지됩니다.

모든 트레이트를 자동으로 로컬에 포함시키기 때문에 별다른 조치를 취하지 않는다면 트레이트 메소드·프로퍼티를 사용할 방법이 없습니다. 따라서 트레이트는 클래스 스코프에 트레이트 메소드·프로퍼티 중 하나 이상을 포함시키는 경우에만 쓸모 있게 됩니다.

다음 코드 예제는 로컬 스코프가 어떻게 동작하는지 보여줍니다.

위 예제에서 트레이트 로컬 스코프와 관련하여 주의해서 보아야 할 부분은 $a->callPrintA1() 실행 결과가 ‘A1’이라는 사실입니다. 이것은 트레이트 내의 $this가 다른 트레이트와 구별되어 자신의 트레이트의 로컬 스코프를 참조하고 있다는 의미입니다.

RFC 문서 nonbreakable traits에 의하면 트레이트의 메소드와 프로퍼티가 로컬로 유지된다는 의미가 단지 물리적인 메모리 상의 존재 뿐만 아니라 트레이트 정의 내에서 로컬로 구현되고 있다는 것입니다.

PHP에서 구현된 로컬 스코프

그러나 현재의 PHP에 구현된 복사·붙여넣기 메커니즘에 의하면 트레이트 내의 $this는 조합 클래스의 스코프를 참조하기 때문에 $a->callPrintA1()의 실행결과는 ‘A2’가 되어야 합니다.

이것을 확인해 보기 위하여 위 예제를 현재 PHP에서 동작하도록 수정해서 실행해 보겠습니다. 이를 위해서는 include 문 대신에 use 문을 사용해야 하며, 정의된 프로퍼티의 호환성 문제, 즉 동일한 가시성과 초기값을 지정해야 합니다.

위의 실험 결과에 따르면, RFC 문서 nonbreakable traits에서 설명한 트레이트의 메소드·프로퍼티가 클래스 내에서 로컬로 유지된다는 개념이 현재 PHP에서 받아 들여지지 않았음을 보여줍니다.

PHP에서의 트레이트가 유연하게 코드를 재사용할 수 있도록 가능한 복잡성을 피하여 단순한 메커니즘을 제공하는 방향으로 구현되었으며, 이를 위하여 클래스 정의 내의 트레이트 로컬 스코프 개념을 받아들이지 않았습니다.

메소드와 프로퍼티의 클래스 스코프 배치

RFC 문서 nonbreakable traits에서 제안하는 클래스 스코프 배치

RFC 문서 nonbreakable traits에서 설명하는 트레이트의 로컬 스코프 개념에 따르면, 트레이트 메소드·프로퍼티를 클래스에 조합할 때의 개념도 현재 PHP와 다를 수 밖에 없습니다.

이어서 RFC 문서 nonbreakable traits에서 설명하는 트레이트를 조합할 때 발생하는 클래스 스코프의 개념에 대하여 살펴보겠습니다. 이 또한 현재 PHP에서 구현된 트레이트의 개념과는 차이를 보입니다.

트레이트 메소드·프로퍼티를 클래스 스코프에 배치할 때, 해당 메소드·프로퍼티는 클래스에 직접 복사·붙여넣기한 것처럼 동작하게 되는데, 다른 점이 있다면 트레이트 로컬 스코프의 규칙을 위반하지 않기 위하여, 클래스 내의 트레이트 로컬 스코프를 가진 모든 메소드·프로퍼티들은 클래스 스코프 내의 메소드·프로퍼티 대신 사용됩니다.

로컬 스코프의 코드 예제에서 볼 수 있듯이, callPrintA1에 대한 메소드 호출에는 printA에 대한 후속 호출이 있습니다. 트레이트의 printA 메소드는 로컬 스코프에 있었으므로 클래스 스코프의 메소드 대신 로컬 스코프의 메소드가 호출됩니다.

위와 같은 RFC 문서 nonbreakable traits에서 제안하는 내용에 의하면, 런타임에서 객체가 생성되고 이를 통해 동적으로 트레이트의 로컬 스코프에 접근하고 있습니다.

PHP에서 구현된 클래스 스코프 배치

이러한 RFC 문서 nonbreakable traits의 설명은 현재 PHP에서 구현된 트레이트 멤버에 대한 클래스 스코프와는 상당한 개념 차이가 보입니다.

현재 PHP의 트레이트에서는 컴파일할 때 트레이트 멤버에 대한 복사·붙여넣기 메커니즘에 의해 조합이 완료되어 클래스 스코프를 가지게 되며, 그 이후에는, 즉 런타임에서는 클래스 내에서 트레이트의 존재가 없어집니다. 다시 말씀드리면 런타임에서 객체를 통해 트레이트의 로컬 스코프에 접근할 수 없다는 것입니다.

트레이트 정적 멤버에서의 로컬 스코프

그러면 RFC 문서 nonbreakable traits에서 설명하는 트레이트 멤버의 로컬 스코프에 대한 개념을 현재 PHP의 트레이트에서는 적용할 수 없는 것일까요? 현재 PHP 트레이트에서 구현된 개념 상, 런타임에서는 불가능하겠지만 컴파일할 때 처리할 수 있다면 부분적으로 가능할 것입니다.

이러한 개념에 따라 트레이트 정적 멤버에서의 로컬 스코프를 실험해 보았습니다. 여기서 매직 상수 __CLASS__에 왜 트레이트 이름이 저장되었는지 등과 같은 지엽적인 문제는 접어두시고, 트레이트 정적 멤버의 로컬 스코프에 접근하고 있는 메커니즘에 집중하시기 바랍니다. (호환성 적용 기준이 PHP 버전 별로 다소 차이가 있어 아래 코드는 PHP 7.2 버전 이하에서는 치명적인 오류가 발생합니다.)

조합 클래스의 정적 구성

파생 클래스 Super에 기반 클래스 Base가 상속되고, 트레이트 Compose1, Compose2가 조합되었습니다. 복사·붙여넣기 메커니즘에 따라 트레이트 Compose1, Compose2이 조합 클래스 Super의 클래스 스코프에 배치되었음에도 불구하고 Compose1::showMy()과 Compose2::showMy()이 클래스 스코프에 있는 메소드·프로퍼티 대신에 트레이트 로컬 스코프에 있는 메소드·프로퍼티에 접근하고 있습니다.

PHP에서 구현된 트레이트 정적 멤버 로컬 스코프

위의 예제에서 트레이트 Compose1, Compose2의 로컬 스코프를 확인할 수 있었습니다. 그러나 정적 멤버라 하더라도 트레이트의 로컬 스코프가 클래스 내에서 구현된다는 것은 PHP의 트레이트 개념 상 논리의 모순이 있습니다.

왜냐하면 PHP의 트레이트는 컴파일할 때(compile time) 모든 프로퍼티와 메소드가 복사·붙여넣기 메커니즘에 의해 해당 클래스에 배치되며, 이 후 클래스 내에는 트레이트의 존재가 없기 때문입니다. 즉 컴파일 후에는 파생 클래스 Super의 클래스 정의가 아래와 같이 변경됩니다.

결국 트레이트 Compose1, Compose2의 정적 멤버들은 컴파일할 때 클래스에 배치된 후에는 클래스의 멤버에 의해 재정의(overriding)되어 클래스 내에서 그 존재가 사라지게 되는 것입니다. 그럼에도 불구하고 클래스에서는 Compose1::showMy(), Compose2::showMy()에 의해 트레이트 메소드에 접근할 수 있었습니다. 트레이트의 존재가 분명히 물리적으로 존재하는데 클래스 내에는 없습니다.

앞 글 ‘복사·붙여넣기 메커니즘’에서 아래와 같이 설명 드린 바 있습니다.

트레이트는 서로 다른 다양한 구성의 프로그램 내에서 필요한 만큼 클래스에 조합하여 자유롭게 재사용되어지는 것이기 때문에, 복사·붙여넣기가 되었다고 트레이트가 물리적으로 사라지는 것은 아닙니다. 관련하여 런타임에서는 트레이트를 구분할 수 없으나 컴파일 타임에서 트레이트 로컬 스코프와 관련하여 염두에 두어야 할 부분이 있으며, 이와 관련된 사항들은 ‘트레이트의 로컬 스코프’ 글에서 자세히 살펴보겠습니다.

트레이트는 오직 use 문을 통해 클래스와 연결되어지며, 이 후 플래트닝 속성(flattening property)으로 필터링하여 클래스에 배치하게 될 구성을 확정한 후, 최종적으로 확정된 부분을 선택(복사)하여 클래스에 배치(붙여넣기)하게 됩니다.

복사·붙여넣기 메커니즘

프로그램은 복수의 트레이트에 정의된 멤버들을 필요에 따라 취사 선택하여 클래스 내로 불러들여 재사용하게 됩니다. 이 과정에서 복사·붙여넣기하기 때문에 원래의 트레이트의 멤버들은 메모리 상에 그대로 남게 됩니다.

트레이트에 정의된 멤버들이 정적 멤버가 아니라면 컴파일할 때 원래의 트레이트 멤버들의 존재도 사라져서, 즉 메모리를 할당받지 못해 런타임시 존재를 확인할 수 없겠지만, 정적 멤버라면 컴파일할 때 메모리를 할당받게 되므로, 런타임시에도 이러한 정적 멤버에 접근할 수 있는 여지가 남아있는 것입니다.

이와 같이 컴파일 후에도 메모리 상에 남아있는 트레이트의 정적 멤버에 접근할 수 있는 방법이 바로 범위지정연산자(::)를 이용하는 것이며, Compose1::showMy(), Compose2::showMy()와 같이 트레이트 로컬 스코프에 있는 정적 멤버에 접근할 수 있습니다.

결국 컴파일 후에 존재하는 트레이트 정적 멤버는 클래스 스코프(class scope)가 아닌 전역 스코프(global scope)를 가지게 되며, 프로그램 내에서는 어디서나 자유롭게 접근할 수 있으며, 접근하는 위치와 관계없이 동일한 멤버에 접근하게 되는 것입니다.

트레이트의 로컬 스코프

클래스에 배치되는 트레이트 정적 멤버는 전역 스코프에 있는 멤버의 복사본입니다.

런타임시 트레이트 정적 멤버와 클래스의 관계

예제에서 클래스 Super1, Super2의 메소드 SetMy()에서 자신의 클래스 이름을 트레이트 Compose의 정적 프로퍼티 $my에 순서대로 저장한 후, 클래스 밖 전역 스코프에서 트레이트 정적 메소드 Compose::showMy()를 호출하여 정적 프로퍼티 $my의 값을 출력하였습니다. 이 모든 것이 전역 스코프에 있는 트레이트 정적 멤버에 대하여 값을 저장하고 출력하였기 때문에 트레이트 Compose의 이름, 클래스 Super1, Super2의 이름이 순서대로 출력되었습니다.

전역 스코프에 존재하는 트레이트에 정의할 수 있는 정적 멤버에는 정적 변수, 정적 프로퍼티, 정적 메소드가 있으며, 이러한 정적 멤버에 대한 내용은 ‘정적 멤버와 싱글턴 패턴’ 글에서 살펴보겠습니다.

트레이트 설계 개념과의 괴리

위에 있는 예제들에서 볼 때, 정적 멤버에 대하여 Compose1::showMy(), Compose2::showMy(), Compose::$my와 같이 ‘트레이트::정적멤버’ 형식으로 전역 스코프에서 접근하는 것이 당연하지 않냐고 생각하시는 분들은 ‘클래스::정적멤버’를 머리 속에 떠올렸을 것입니다. 하지만 런타임(runtime)시 ‘트레이트::멤버’ 형식으로 트레이트 로컬 스코프에 있는 멤버에 접근할 수 있는 것이 트레이트 설계 개념 상 정상적인가에 대하여 의문이 있었기 때문에 혹시 몰라서 실험을 하게 되었습니다.

트레이트의 사용 방법에 대하여는 PHP 매뉴얼을 보시면 되겠지만, 트레이트 구현에 적용된 설계 개념을 알기 위해서는 RFC 문서 horizontal reuse를 보셔야 합니다.

RFC 문서 horizontal reuse의 플래트닝 프로퍼티(Flattening Property) 섹션에서 “트레이트는 소스 파일 상에 존재하는 실제적인 코드일 뿐*1이라고 설명하였으며, 이어서 “트레이트는 소스 코드의 일부로서 시스템 구조를 정의하기 때문에 런타임(runtime) 개념이 없지만, …, 그러나 트레이트는 시스템의 런타임(runtime) 동작(behavior)에는 영향을 주지 않습니다.*2라고 설명하고 있습니다.

*1 Traits are only entities of the literal code written in your source files.

*2 Even though, there is no runtime notion of Traits, since they are part of the source code and thus, define the structure of the system, reflection about Traits still is possible, but they do not influence the runtime behavior of the system.

또 다른 섹션에서는 “트레이트는 런타임에 의미(semantics)를 추가하지 않고 클래스를 만드는 과정(process of building)에만 참여합니다.*3, “트레이트는 상태(state) 처리를 위한 어떤 프로비저닝(provisioning)도 제공하지 않습니다.*4, “트레이트는 매우 가볍고 스테이트리스(stateless)이며 매우 유연하게 구성할 수 있도록 해줍니다.*5라고 설명하고 있습니다.

*3 Traits do not add runtime semantics, they only take part in the process of building a class.

*4 Traits do not provide any provisioning for handling state.

*5 A Trait is an unit of behavioral reuse. It is very lightweight, stateless and allows for a very flexible composition of behavior into classes.

이러한 트레이트의 설계 개념으로 볼 때에는, 트레이트는 컴파일한 후에는 메모리 상에 남아있으면 안되며, 설사 메모리 상에 남아있다 하더라도 런타임(runtime)시 개발자에게 트레이트에 접근할 수 있는 경로를 제공해서는 안된다고 생각합니다.

즉, Compose1::showMy(), Compose2::showMy(), Compose::$my와 같이 런타임(runtime)시 ‘트레이트::멤버’ 형식으로 트레이트 로컬 스코프에 있는 멤버에 접근할 수 있다는 것은 트레이트 설계 개념에서 볼 때 좀 벗어나지 않았나 생각됩니다.

위와 같은 트레이트 정적 멤버는 컴파일할 때 클래스에 있는 use 문에 의해 클래스에 조합된 후, 트레이트의 본래의 목적을 달성하였으므로, 트레이트 개념 상 원래의 트레이트 정적 멤버는 소스 코드 상에서 사라져야, 즉 트레이트 로컬 스코프에 있는 트레이트 정적 멤버에 접근할 수 없어야 하는 것입니다. 컴파일할 때 위의 예제는 의미 상 아래와 같이 됩니다.

그런데 클래스를 정의하여 트레이트를 조합하지 않더라도, 즉 클래스가 없이 트레이트 만 존재하더라도 런타임(runtime)시 트레이트는 정상적으로 동작됩니다.

위의 예제를 볼 때, 트레이트 개념 상 런타임 시에는 존재하지 않아야 할 트레이트가 정적 멤버에 한하기는 하지만 클래스가 없어도 정상적으로 실행되고 있어서 개념 상 혼동을 줄 수 있습니다.

따라서 트레이트의 개념에 대하여 설명한 글들을 보실 때, 정적 멤버와 같이 트레이트의 개념과는 다소 괴리를 보이는 부분도 있다는 것을 감안하여 주시기 바랍니다.

답글 남기기