프로퍼티 훅

프로퍼티 훅 (Property Hooks)

프로퍼티 훅은 프로퍼티의 읽기(get)와 쓰기(set) 동작을 가로채고 재정의하는 기능이에요. PHP 8.4에서 도입되었으며, get/set 메서드를 일일이 만들지 않아도 되게 해 줘요.

출처: Property Hooks

본문

프로퍼티 훅(다른 언어에서는 "프로퍼티 접근자(property accessors)"라고도 불러요)은 프로퍼티의 읽기와 쓰기 동작을 가로채고 재정의하는 방법이에요. 이 기능은 두 가지 목적을 가져요.

  • get/set 메서드 없이 프로퍼티를 직접 사용할 수 있게 하면서, 미래에 추가 동작을 붙일 여지를 남겨둬요. 이 덕분에 훅을 쓰지 않더라도 대부분의 상용구(boilerplate) get/set 메서드가 불필요해져요.
  • 값을 직접 저장하지 않고도 객체를 설명하는 프로퍼티를 만들 수 있게 해 줘요.

비정적(non-static) 프로퍼티에는 get과 set 두 가지 훅이 있어요. 각각 프로퍼티의 읽기와 쓰기 동작을 재정의해요. 훅은 타입 지정 프로퍼티와 타입이 없는 프로퍼티 모두에 사용할 수 있어요.

프로퍼티는 "backed(저장형)" 또는 "virtual(가상)"일 수 있어요. backed 프로퍼티는 실제로 값을 저장하는 프로퍼티예요. 훅이 없는 프로퍼티는 모두 backed예요. virtual 프로퍼티는 훅이 있고 그 훅이 프로퍼티 자체와 상호작용하지 않는 프로퍼티예요. 이 경우 훅은 사실상 메서드와 같고, 객체는 그 프로퍼티의 값을 저장하는 데 공간을 쓰지 않아요.

프로퍼티 훅은 readonly 프로퍼티와 호환되지 않아요. get이나 set 연산에 대한 접근을 제한하면서 동작도 바꿔야 한다면 비대칭 프로퍼티 가시성(asymmetric property visibility)을 사용하세요.

Note:

버전 정보 프로퍼티 훅은 PHP 8.4에서 도입되었어요.

기본 훅 문법 (Basic Hook Syntax)

훅을 선언하는 일반적인 문법은 다음과 같아요.

Example #1 프로퍼티 훅 (전체 버전)

<?php
class Example
{
    private bool $modified = false;

    public string $foo = 'default value' {
        get {
            if ($this->modified) {
                return $this->foo . ' (modified)';
            }
            return $this->foo;
        }
        set(string $value) {
            $this->foo = strtolower($value);
            $this->modified = true;
        }
    }
}

$example = new Example();
$example->foo = 'changed';
print $example->foo;
?>

$foo 프로퍼티는 세미콜론이 아니라 {}로 끝나요. 이것이 훅이 있다는 표시예요. get과 set 훅이 모두 정의되어 있지만, 둘 중 하나만 정의해도 괜찮아요. 두 훅 모두 임의의 코드를 담을 수 있는 {} 본문을 가져요. set 훅은 추가로 들어오는 값의 타입과 이름을 메서드와 같은 문법으로 지정할 수 있어요. 타입은 프로퍼티의 타입과 같거나 그것에 공변적(contravariant, 더 넓은)이어야 해요. 예를 들어 string 타입 프로퍼티는 string|Stringable을 받는 set 훅을 가질 수 있지만, array만 받는 set 훅은 가질 수 없어요.

두 훅 중 적어도 하나가 프로퍼티 자체인 $this->foo를 참조해요. 즉 이 프로퍼티는 "backed"가 돼요. $example->foo = 'changed'를 호출하면, 주어진 문자열은 먼저 소문자로 변환된 다음 backing 값에 저장돼요. 프로퍼티를 읽을 때는 이전에 저장된 값에 추가 텍스트를 조건부로 붙일 수 있어요.

흔한 경우를 처리하기 위한 축약 문법 변형도 여럿 있어요. get 훅이 단일 표현식이라면 {}를 생략하고 화살표 표현식으로 바꿀 수 있어요.

Example #2 프로퍼티 get 표현식

이 예제는 앞선 예제와 동일해요.

<?php
class Example
{
    private bool $modified = false;

    public string $foo = 'default value' {
        get => $this->foo . ($this->modified ? ' (modified)' : '');

        set(string $value) {
            $this->foo = strtolower($value);
            $this->modified = true;
        }
    }
}
?>

set 훅의 매개변수 타입이 프로퍼티 타입과 같다면(보통 그렇죠) 생략할 수 있어요. 그 경우 설정할 값에 자동으로 $value라는 이름이 부여돼요.

Example #3 프로퍼티 set 기본값

이 예제는 앞선 예제와 동일해요.

<?php
class Example
{
    private bool $modified = false;

    public string $foo = 'default value' {
        get => $this->foo . ($this->modified ? ' (modified)' : '');

        set {
            $this->foo = strtolower($value);
            $this->modified = true;
        }
    }
}
?>

set 훅이 전달받은 값을 수정한 버전만 설정한다면 화살표 표현식으로 단순화할 수도 있어요. 표현식이 평가한 값이 backing 값으로 설정돼요.

Example #4 프로퍼티 set 표현식

<?php
class Example
{
    private bool $modified = false;

    public string $foo = 'default value' {
        get => $this->foo . ($this->modified ? ' (modified)' : '');
        set => strtolower($value);
    }
}
?>

이 예제는 앞선 예제와 완전히 동일하지는 않아요. $this->modified를 수정하지 않기 때문이에요. set 훅 본문에 여러 문장이 필요하면 중괄호 버전을 사용하세요.

프로퍼티는 상황에 따라 0개, 1개, 또는 두 훅을 모두 구현할 수 있어요. 모든 축약 버전은 서로 독립적이에요. 즉 짧은 get과 긴 set, 명시적 타입을 가진 짧은 set 등을 조합하는 것이 모두 유효해요.

backed 프로퍼티에서 get이나 set 훅을 생략하면 기본 읽기/쓰기 동작이 사용돼요.

Note:

훅은 생성자 프로퍼티 승격(constructor property promotion)을 사용할 때도 정의할 수 있어요. 다만 그렇게 할 때 생성자에 전달되는 값은 set 훅이 허용하는 것과 무관하게, 프로퍼티와 연결된 타입과 일치해야 해요. 다음을 살펴봐요.

<?php
class Example
{
    public function __construct(
        public private(set) DateTimeInterface $created {
            set (string|DateTimeInterface $value) {
                if (is_string($value)) {
                    $value = new DateTimeImmutable($value);
                }
                $this->created = $value;
            }
        },
    ) {
    }
?>

내부적으로 엔진은 이 코드를 다음과 같이 분해해요.

<?php
class Example
{
    public private(set) DateTimeInterface $created {
        set (string|DateTimeInterface $value) {
            if (is_string($value)) {
                $value = new DateTimeImmutable($value);
            }
            $this->created = $value;
        }
    }

    public function __construct(
        DateTimeInterface $created,
    ) {
        $this->created = $created;
    }
}

생성자 밖에서 프로퍼티를 설정하려는 시도는 string이나 DateTimeInterface 값을 허용하지만, 생성자는 DateTimeInterface만 허용해요. 프로퍼티의 정의된 타입(DateTimeInterface)이 set 훅이 허용하는 것과 무관하게 생성자 시그니처의 매개변수 타입으로 사용되기 때문이에요. 생성자에서 이런 동작이 필요하다면 생성자 프로퍼티 승격을 사용할 수 없어요.

가상 프로퍼티 (Virtual properties)

가상 프로퍼티는 backing 값이 없는 프로퍼티예요. get 훅도 set 훅도 정확한 문법으로 프로퍼티 자체를 참조하지 않으면 그 프로퍼티는 가상이에요. 즉 $foo라는 프로퍼티의 훅이 $this->foo를 포함하면 backed가 되지만, 다음은 backed 프로퍼티가 아니며 오류가 발생해요.

Example #5 잘못된 가상 프로퍼티

<?php
class Example
{
    public string $foo {
        get {
            $temp = __PROPERTY__;
            return $this->$temp; // Doesn't refer to $this->foo, so it doesn't count.
        }
    }
}
?>

가상 프로퍼티의 경우 훅을 생략하면 그 연산은 존재하지 않는 것이고, 사용하려 하면 오류가 발생해요. 가상 프로퍼티는 객체에서 메모리 공간을 차지하지 않아요. 가상 프로퍼티는 두 프로퍼티의 조합 같은 "파생(derived)" 프로퍼티에 잘 어울려요.

Example #6 가상 프로퍼티

<?php
class Rectangle
{
    // A virtual property.
    public int $area {
        get => $this->h * $this->w;
    }

    public function __construct(public int $h, public int $w) {}
}

$s = new Rectangle(4, 5);
print $s->area; // prints 20
$s->area = 30; // Error, as there is no set operation defined.
?>

가상 프로퍼티에 get과 set 훅을 모두 정의하는 것도 허용돼요.

스코프 (Scoping)

모든 훅은 수정되는 객체의 스코프에서 동작해요. 즉 객체의 모든 public, private, protected 메서드뿐 아니라 모든 public, private, protected 프로퍼티에도 접근할 수 있어요. 그 프로퍼티가 자신만의 훅을 가진 프로퍼티라도요. 훅 안에서 다른 프로퍼티에 접근한다고 해서 그 프로퍼티에 정의된 훅을 우회하지는 않아요. 가장 주목할 만한 함의는, 단순하지 않은 훅이 원한다면 임의로 복잡한 메서드를 하위 호출(sub-call)할 수 있다는 거예요.

Example #7 훅에서 메서드 호출하기

<?php
class Person {
    public string $phone {
        set => $this->sanitizePhone($value);
    }

    private function sanitizePhone(string $value): string {
        $value = ltrim($value, '+');
        $value = ltrim($value, '1');

        if (!preg_match('/\d\d\d\-\d\d\d\-\d\d\d\d/', $value)) {
            throw new \InvalidArgumentException();
        }
        return $value;
    }
}
?>

참조 (References)

훅이 있으면 프로퍼티의 읽기/쓰기 과정을 가로채기 때문에, 프로퍼티에 대한 참조를 얻거나 $this->arrayProp['key'] = 'value'; 같은 간접 수정 시 문제가 생겨요. 참조로 값을 수정하려는 시도는 set 훅이 정의되어 있다면 그것을 우회하기 때문이에요.

훅이 정의된 프로퍼티에 대한 참조를 얻는 게 필요한 드문 경우에는, get 훅 앞에 &를 붙여 참조로 반환하게 할 수 있어요. 같은 프로퍼티에 get과 &get을 둘 다 정의하는 것은 문법 오류예요. backed 프로퍼티에 &get과 set 훅을 둘 다 정의하는 것은 허용되지 않아요. 위에서 언급했듯이 참조로 반환된 값에 쓰기를 하면 set 훅을 우회하기 때문이에요. 가상 프로퍼티에서는 두 훅 사이에 공유할 필연적인 공통 값이 없으므로 둘 다 정의해도 돼요.

배열 프로퍼티의 인덱스에 쓰는 것도 암시적 참조를 수반해요. 그래서 훅이 정의된 backed 배열 프로퍼티에 쓰는 것은 &get 훅만 정의한 경우에만 허용돼요. 가상 프로퍼티에서 get 또는 &get 중 어느 쪽에서 반환된 배열에 쓰는 것은 합법적이지만, 그것이 객체에 어떤 영향을 미치는지는 훅 구현에 달려 있어요. 배열 프로퍼티 전체를 덮어쓰는 것은 문제없고 다른 프로퍼티와 똑같이 동작해요. 배열의 요소를 다루는 것에만 특별한 주의가 필요해요.

상속 (Inheritance)

Final 훅

훅은 final로 선언할 수도 있는데, 그 경우 재정의할 수 없어요.

Example #8 Final 훅

<?php
class User
{
    public string $username {
        final set => strtolower($value);
    }
}

class Manager extends User
{
    public string $username {
        // This is allowed
        get => strtoupper($this->username);

        // But this is NOT allowed, because set is final in the parent.
        set => strtoupper($value);
    }
}
?>

프로퍼티 자체도 final로 선언할 수 있어요. final 프로퍼티는 자식 클래스가 어떤 식으로든 다시 선언할 수 없어서, 훅을 바꾸거나 접근을 넓히는 것도 막아요. final로 선언된 프로퍼티에 훅을 final로 선언하는 것은 중복이라서 조용히 무시돼요. 이것은 final 메서드와 같은 동작이에요.

자식 클래스는 프로퍼티를 다시 정의하면서 재정의하려는 훅만 바꿔서 개별 훅을 정의하거나 재정의할 수 있어요. 자식 클래스는 훅이 없던 프로퍼티에 훅을 추가할 수도 있어요. 이것은 본질적으로 훅이 메서드인 것과 같아요.

Example #9 훅 상속

<?php
class Point
{
    public int $x;
    public int $y;
}

class PositivePoint extends Point
{
    public int $x {
        set {
            if ($value < 0) {
                throw new \InvalidArgumentException('Too small');
            }
            $this->x = $value;
        }
    }
}
?>

각 훅은 서로 독립적으로 부모 구현을 재정의해요. 자식 클래스가 훅을 추가하면 프로퍼티에 설정된 기본값은 제거되고 다시 선언해야 해요. 이것은 훅이 없는 프로퍼티의 상속이 동작하는 방식과 일치해요.

부모 훅 접근 (Accessing parent hooks)

자식 클래스의 훅은 parent::$prop 키워드 뒤에 원하는 훅을 붙여 부모 클래스의 프로퍼티에 접근할 수 있어요. 예를 들어 parent::$propName::get()처럼요. 이것은 "부모 클래스에 정의된 프로퍼티에 접근하고, 그 get 연산을 실행한다"고 읽을 수 있어요 (또는 상황에 따라 set 연산). 이렇게 접근하지 않으면 부모 클래스의 훅은 무시돼요. 이 동작은 모든 메서드가 동작하는 방식과 일치해요. 이것은 또한 부모 클래스의 저장소(있다면)에 접근하는 방법을 제공해요. 부모 프로퍼티에 훅이 없다면 기본 get/set 동작이 사용돼요. 훅은 자신의 프로퍼티에서 자기 부모 외의 다른 훅에 접근할 수 없어요.

위 예제는 다음과 같이 다시 쓸 수 있는데, 그러면 미래에 Point 클래스가 문제없이 자체 set 훅을 추가할 수 있게 돼요 (앞의 예제에서 부모 클래스에 추가된 훅은 자식에서 무시됐어요).

Example #10 부모 훅 접근 (set)

<?php
class Point
{
    public int $x;
    public int $y;
}

class PositivePoint extends Point
{
    public int $x {
        set {
            if ($value < 0) {
                throw new \InvalidArgumentException('Too small');
            }
            parent::$x::set($value);
        }
    }
}
?>

get 훅만 재정의하는 예는 다음과 같아요.

Example #11 부모 훅 접근 (get)

<?php
class Strings
{
    public string $val;
}

class CaseFoldingStrings extends Strings
{
    public bool $uppercase = true;

    public string $val {
        get => $this->uppercase
            ? strtoupper(parent::$val::get())
            : strtolower(parent::$val::get());
    }
}
?>

직렬화 (Serialization)

PHP에는 객체를 직렬화하는 여러 방법이 있어요. 공개용이거나 디버깅 목적이거나요. 훅의 동작은 사용 사례에 따라 달라져요. 어떤 경우에는 프로퍼티의 원시 backing 값을 사용해 훅을 우회하고, 다른 경우에는 다른 일반적인 읽기/쓰기 동작처럼 프로퍼티를 훅 "통해" 읽거나 써요.

  • var_dump(): 원시 값을 사용
  • serialize(): 원시 값을 사용
  • unserialize(): 원시 값을 사용
  • __serialize()/__unserialize(): 커스텀 로직, get/set 훅을 사용
  • 배열 캐스팅(Array casting): 원시 값을 사용
  • var_export(): get 훅을 사용
  • json_encode(): get 훅을 사용
  • JsonSerializable: 커스텀 로직, get 훅을 사용
  • get_object_vars(): get 훅을 사용
  • get_mangled_object_vars(): 원시 값을 사용

더 알아보기

  • 비대칭 프로퍼티 가시성과 readonly 등은 클래스와 객체(Classes and Objects) 장의 각 페이지를 참고하세요.