지연 객체

지연 객체 (Lazy Objects)

지연 객체(lazy object)는 상태가 관찰되거나 수정될 때까지 초기화를 미루는 객체다. 의존성 주입(DI) 컴포넌트, ORM의 지연 엔티티, 지연 파싱 JSON 파서 등에서 유용하게 쓰인다. PHP 8.4에서 도입된 두 가지 전략(고스트 객체·프록시)의 생성과 수명주기를 설명한다.

출처: Lazy Objects

본문

지연 객체(lazy object) 란 상태가 관찰되거나 수정될 때까지 초기화가 미뤄지는 객체입니다. 사용 사례로는 필요한 경우에만 완전히 초기화된 지연 서비스(lazy services)를 제공하는 의존성 주입 컴포넌트, 접근할 때만 데이터베이스에서 스스로 채워지는(하이드레이션되는) 지연 엔티티를 제공하는 ORM, 또는 요소가 접근될 때까지 파싱을 미루는 JSON 파서를 들 수 있습니다.

두 가지 지연 객체 전략을 지원합니다: Ghost Objects(고스트 객체)Virtual Proxies(가상 프록시). 이하에서는 "lazy ghosts" 와 "lazy proxies" 라고 부르겠습니다. 두 전략 모두에서 지연 객체는 상태가 처음으로 관찰되거나 수정될 때 자동으로 호출되는 초기화 함수(initializer) 또는 팩토리(factory)에 연결됩니다. 추상화 관점에서 지연 고스트 객체는 지연이 아닌 객체와 구별할 수 없습니다. 즉 지연인지 모르고 사용할 수 있어, 지연(laziness)을 인식하지 못하는 코드에 전달해도 그대로 사용할 수 있습니다. 지연 프록시도 비슷하게 투명하지만, 프록시와 실제 인스턴스는 서로 다른 정체성(identity)을 가지므로 정체성을 사용할 때 주의해야 합니다.

Note: 버전 정보 지연 객체는 PHP 8.4 에서 도입되었습니다.

지연 객체 만들기

사용자 정의 클래스나 stdClass 클래스(다른 내장 클래스는 지원되지 않음)의 지연 인스턴스를 만들 수 있으며, 이 클래스들의 인스턴스를 리셋해서 지연 상태로 만들 수도 있습니다. 지연 객체를 만들기 위한 진입점은 ReflectionClass::newLazyGhost()ReflectionClass::newLazyProxy() 메서드입니다.

두 메서드 모두 객체가 초기화를 필요로 할 때 호출되는 함수를 받아들입니다. 이 함수가 기대하는 동작은 사용 중인 전략에 따라 달라지며, 각 메서드의 레퍼런스 문서에 설명되어 있습니다.

예제 #1 지연 고스트 만들기

<?php
class Example
{
    public function __construct(public int $prop)
    {
        echo __METHOD__, "\n";
    }
}

$reflector = new ReflectionClass(Example::class);
$lazyObject = $reflector->newLazyGhost(function (Example $object) {
    // Initialize object in-place
    $object->__construct(1);
});

var_dump($lazyObject);
var_dump(get_class($lazyObject));

// Triggers initialization
var_dump($lazyObject->prop);
?>

위 예제의 출력은 다음과 같습니다.

lazy ghost object(Example)#3 (0) {
  ["prop"]=>
  uninitialized(int)
}
string(7) "Example"
Example::__construct
int(1)

예제 #2 지연 프록시 만들기

<?php
class Example
{
    public function __construct(public int $prop)
    {
        echo __METHOD__, "\n";
    }
}

$reflector = new ReflectionClass(Example::class);
$lazyObject = $reflector->newLazyProxy(function (Example $object) {
    // Create and return the real instance
    return new Example(1);
});

var_dump($lazyObject);
var_dump(get_class($lazyObject));

// Triggers initialization
var_dump($lazyObject->prop);
?>

위 예제의 출력은 다음과 같습니다.

lazy proxy object(Example)#3 (0) {
  ["prop"]=>
  uninitialized(int)
}
string(7) "Example"
Example::__construct
int(1)

지연 객체의 프로퍼티에 대한 어떤 접근도(ReflectionProperty 를 통한 접근 포함) 초기화를 촉발합니다. 그러나 일부 프로퍼티는 미리 알려져 있어 접근 시 초기화를 촉발하면 안 될 수 있습니다.

예제 #3 프로퍼티를 미리(즉시) 초기화하기

<?php
class BlogPost
{
    public function __construct(
        public int $id,
        public string $title,
        public string $content,
    ) { }
}

$reflector = new ReflectionClass(BlogPost::class);

$post = $reflector->newLazyGhost(function ($post) {
    $data = fetch_from_store($post->id);
    $post->__construct($data['id'], $data['title'], $data['content']);
});

// Without this line, the following call to ReflectionProperty::setValue() would
// trigger initialization.
$reflector->getProperty('id')->skipLazyInitialization($post);
$reflector->getProperty('id')->setValue($post, 123);

// Alternatively, one can use this directly:
$reflector->getProperty('id')->setRawValueWithoutLazyInitialization($post, 123);

// The id property can be accessed without triggering initialization
var_dump($post->id);
?>

ReflectionProperty::skipLazyInitialization()ReflectionProperty::setRawValueWithoutLazyInitialization() 메서드는 프로퍼티에 접근할 때 지연 초기화를 우회하는 방법을 제공합니다.

지연 객체 전략에 대하여

지연 고스트(lazy ghosts)는 제자리에서(in-place) 초기화되는 객체로, 초기화된 뒤에는 결코 지연이 아니었던 객체와 구별할 수 없습니다. 이 전략은 객체의 인스턴스화와 초기화를 모두 우리가 통제할 때 적합하며, 둘 중 하나라도 다른 쪽에서 관리된다면 적합하지 않습니다.

지연 프록시(lazy proxies)는 초기화된 뒤 실제 인스턴스(real instance)에 대한 프록시로 동작합니다. 초기화된 지연 프록시에 대한 어떤 연산이든 실제 인스턴스로 전달(forwarding)됩니다. 실제 인스턴스의 생성을 다른 쪽에 위임할 수 있어, 지연 고스트가 적합하지 않은 경우에 유용합니다. 지연 프록시는 지연 고스트만큼 투명하지만, 프록시와 실제 인스턴스가 서로 다른 정체성을 갖기 때문에 정체성을 사용할 때는 주의가 필요합니다.

지연 객체의 생명주기

객체는 ReflectionClass::newLazyGhost()ReflectionClass::newLazyProxy() 를 사용해 인스턴스 생성 시점에 지연으로 만들 수 있고, ReflectionClass::resetAsLazyGhost()ReflectionClass::resetAsLazyProxy() 를 사용해 생성 이후에 지연으로 만들 수도 있습니다. 이후 지연 객체는 다음 연산 중 하나를 통해 초기화될 수 있습니다.

  • 자동 초기화를 촉발하는 방식으로 객체와 상호작용. "초기화 촉발(Initialization triggers)" 섹션 참고.
  • ReflectionProperty::skipLazyInitialization() 또는 ReflectionProperty::setRawValueWithoutLazyInitialization() 를 사용해 모든 프로퍼티를 non-lazy로 표시.
  • 명시적으로 ReflectionClass::initializeLazyObject() 또는 ReflectionClass::markLazyObjectAsInitialized() 호출.

지연 객체는 모든 프로퍼티가 non-lazy로 표시될 때 초기화되므로, 위 메서드들은 지연으로 표시할 수 있는 프로퍼티가 없는 경우 객체를 지연으로 표시하지 않습니다.

초기화 촉발 (Initialization Triggers)

지연 객체는 소비자에게 완전히 투명하도록 설계되었으므로, 객체의 상태를 관찰하거나 수정하는 일반적인 연산은 그 연산이 수행되기 전에 자동으로 초기화를 촉발합니다. 여기에는 다음 연산이 포함되지만 이에 국한되지는 않습니다.

  • 프로퍼티 읽기 또는 쓰기.
  • 프로퍼티가 설정되었는지 검사하거나 설정 해제(unset).
  • ReflectionProperty::getValue(), ReflectionProperty::getRawValue(), ReflectionProperty::setValue(), 또는 ReflectionProperty::setRawValue() 를 통한 프로퍼티 접근 또는 수정.
  • ReflectionObject::getProperties(), ReflectionObject::getProperty(), get_object_vars() 로 프로퍼티 나열.
  • IteratorIteratorAggregate 를 구현하지 않는 객체의 프로퍼티를 foreach 로 순회.
  • serialize(), json_encode() 등으로 객체 직렬화.
  • 객체 복제(Cloning).
  • 객체 상태에 접근하지 않는 메서드 호출은 초기화를 촉발하지 않습니다. 마찬가지로, 매직 메서드나 훅 함수를 호출하는 상호작용도 그 메서드나 함수가 객체 상태에 접근하지 않는다면 초기화를 촉발하지 않습니다.

촉발하지 않는 연산 (Non-Triggering Operations)

다음의 특정 메서드나 저수준 연산은 초기화를 촉발하지 않고 지연 객체에 접근하거나 수정할 수 있게 해 줍니다.

  • ReflectionProperty::skipLazyInitialization() 또는 ReflectionProperty::setRawValueWithoutLazyInitialization() 로 프로퍼티를 non-lazy로 표시.
  • get_mangled_object_vars() 를 사용하거나 객체를 array 로 캐스팅해 프로퍼티의 내부 표현을 가져오기.
  • ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE 가 설정된 상태에서 serialize() 사용 — 단 __serialize()__sleep() 가 초기화를 촉발하지 않는 경우에 한함.
  • ReflectionObject::__toString() 호출.
  • __debugInfo() 가 초기화를 촉발하지 않는 한, var_dump() 또는 debug_zval_dump() 사용.

초기화 순서 (Initialization Sequence)

이 섹션은 초기화가 촉발될 때 수행되는 연산의 순서를 사용 중인 전략에 따라 설명합니다.

고스트 객체 (Ghost Objects)

  1. 객체가 non-lazy로 표시됩니다.
  2. ReflectionProperty::skipLazyInitialization() 또는 ReflectionProperty::setRawValueWithoutLazyInitialization() 로 초기화되지 않은 프로퍼티들은, 있다면 기본값으로 설정됩니다. 이 단계에서 객체는 이미 초기화된 프로퍼티를 제외하면 ReflectionClass::newInstanceWithoutConstructor() 로 생성된 객체와 유사해집니다.
  3. 이어서 초기화 함수가 객체를 첫 번째 파라미터로 받아 호출됩니다. 이 함수는 객체 상태를 초기화할 것으로 기대되지만(필수는 아님), 반드시 null 또는 아무 값도 반환하지 않아야 합니다. 이 시점에서 객체는 더 이상 지연이 아니므로, 함수는 직접 프로퍼티에 접근할 수 있습니다.
  4. 초기화 후, 객체는 결코 지연이 아니었던 객체와 구별할 수 없게 됩니다.

프록시 객체 (Proxy Objects)

  1. 객체가 non-lazy로 표시됩니다.
  2. 고스트 객체와 달리, 이 단계에서는 객체의 프로퍼티가 수정되지 않습니다.
  3. 팩토리 함수가 객체를 첫 번째 파라미터로 받아 호출되며, 호환 가능한 클래스의 non-lazy 인스턴스를 반환해야 합니다(ReflectionClass::newLazyProxy() 참고).
  4. 반환된 인스턴스를 실제 인스턴스(real instance)라고 하며 프록시에 연결됩니다.
  5. 프록시의 프로퍼티 값은 마치 unset() 이 호출된 것처럼 폐기됩니다.
  6. 초기화 후, 프록시의 어떤 프로퍼티에 접근하든 실제 인스턴스의 해당 프로퍼티에 접근한 것과 같은 결과를 얻습니다. 프록시에 대한 모든 프로퍼티 접근은 선언된(dynamic)·동적(dynamic)·존재하지 않는 프로퍼티, 그리고 ReflectionProperty::skipLazyInitialization()ReflectionProperty::setRawValueWithoutLazyInitialization() 로 표시된 프로퍼티를 포함해 모두 실제 인스턴스로 전달됩니다.
  7. 프록시 객체 자체가 실제 인스턴스로 대체되거나 치환되지는 않습니다.
  8. 팩토리가 첫 번째 파라미터로 프록시를 받지만, 이를 수정할 것으로 기대되지는 않습니다(수정은 허용되지만 최종 초기화 단계에서 손실됩니다). 그러나 프록시는 초기화된 프로퍼티 값, 클래스, 객체 자체, 또는 그 정체성에 기반한 결정을 내리는 데 사용할 수 있습니다. 예를 들어 초기화 함수는 실제 인스턴스를 만들 때 초기화된 프로퍼티의 값을 사용할 수 있습니다.

공통 동작 (Common Behavior)

  • 초기화 함수 또는 팩토리 함수의 스코프와 $this 문맥은 그대로 유지되며, 일반적인 가시성(visibility) 제약이 적용됩니다.
  • 성공적인 초기화 후, 초기화 함수 또는 팩토리 함수는 객체에 더 이상 참조되지 않으며, 다른 참조가 없다면 해제될 수 있습니다.
  • 초기화 함수가 예외를 던지면, 객체 상태는 초기화 이전 상태로 되돌아가고 객체는 다시 지연으로 표시됩니다. 즉 객체 자체에 대한 모든 효과는 되돌려집니다. 다른 객체에 대한 효과 같은 부수 효과는 되돌려지지 않습니다. 이는 실패 시 부분적으로 초기화된 인스턴스가 노출되는 것을 방지합니다.

복제 (Cloning)

지연 객체를 복제하면 복제본이 만들어지기 전에 초기화가 촉발되어, 결과적으로 초기화된 객체가 만들어집니다.

프록시 객체의 경우 프록시와 그 실제 인스턴스가 모두 복제되며, 프록시의 복제본이 반환됩니다. __clone 메서드는 프록시가 아닌 실제 인스턴스에서 호출됩니다. 복제된 프록시와 실제 인스턴스는 초기화 시와 마찬가지로 연결되어 있으므로, 프록시 복제본에 대한 접근은 실제 인스턴스 복제본으로 전달됩니다.

이 동작은 복제본과 원본 객체가 별도의 상태를 유지하도록 보장합니다. 복제 이후 원본 객체나 그 초기화 함수의 상태가 바뀌어도 복제본에는 영향을 주지 않습니다. 실제 인스턴스만의 복제본을 반환하는 대신 프록시와 그 실제 인스턴스를 모두 복제하는 것은, 복제 연산이 항상 같은 클래스의 객체를 반환하도록 보장하기 위해서입니다.

소멸자 (Destructors)

지연 고스트의 경우, 소멸자는 객체가 초기화된 경우에만 호출됩니다. 프록시의 경우, 소멸자는 실제 인스턴스가 존재한다면 그 실제 인스턴스에서만 호출됩니다.

ReflectionClass::resetAsLazyGhost()ReflectionClass::resetAsLazyProxy() 메서드는 리셋되는 객체의 소멸자를 호출할 수 있습니다.

더 알아보기

  • ReflectionClass::newLazyGhost() — 지연 고스트 객체 생성
  • ReflectionClass::newLazyProxy() — 지연 프록시 객체 생성
  • ReflectionClass::resetAsLazyGhost() / resetAsLazyProxy() — 기존 객체를 지연으로 리셋
  • ReflectionProperty::skipLazyInitialization() / setRawValueWithoutLazyInitialization() — 지연 초기화 우회