PHP · 심층 가이드
PHP 객체지향 완전 정리
생성자 프로퍼티 승격과 값 객체를 축으로 PHP의 클래스, 인터페이스, Trait, 매직 메서드를 정리해 프레임워크 코드가 왜 그런 모양인지 이해하게 만듭니다.
PHP의 객체 모델은 단일 상속에 인터페이스로 계약을 넓히고, 구현 공유는 trait으로 처리하는 구조입니다. 여기에 __get, __call, __invoke 같은 매직 메서드가 얹히면서 런타임에 동작을 바꿀 여지가 굉장히 넓어졌습니다. 초심자가 자주 흔들리는 지점이 이 자유도입니다. 상속으로 풀 문제, 인터페이스로 풀 문제, Trait으로 풀 문제, 매직 메서드로 풀 문제의 경계가 흐릿해 보이기 때문입니다. 실무 기준은 단순합니다. 타입이 필요하면 인터페이스, 코드 조각만 필요하면 Trait입니다.
생성자 프로퍼티 승격을 맨 앞에 둔 이유는 이 문법 하나로 클래스 골격이 절반으로 줄어들기 때문입니다. 이걸 익힌 상태에서 값 객체와 readonly 클래스를 보면 불변 객체를 만드는 비용이 거의 없다는 게 보입니다. 정적 팩토리 메서드는 생성자에 이름을 붙이는 기법으로 값 객체와 짝을 이룹니다. 뒤쪽의 IteratorAggregate 구현, JsonSerializable 구현, ArrayAccess 구현은 내 객체를 foreach, json_encode, 대괄호 접근이라는 기본 문법에 편입시키는 묶음입니다.
복제 동작은 반드시 확인하고 넘어가야 합니다. clone은 얕은 복사라 프로퍼티에 담긴 객체는 원본과 그대로 공유되고, 복제본의 내부 컬렉션을 수정하면 원본까지 바뀝니다. 깊은 복사가 필요하면 객체 복제와 __clone에서 직접 내부 객체를 다시 만들어 줘야 합니다. Trait도 마찬가지로, 같은 이름의 메서드를 가진 Trait 두 개를 함께 쓰면 치명적 오류가 나므로 insteadof와 as로 충돌을 명시해야 합니다. __get 과 __set 주의 깊게 쓰기는 매직 메서드가 정적 분석과 IDE 자동완성을 무력화한다는 대가를 함께 설명합니다.
01생성자 프로퍼티 승격
생성자 파라미터 앞에 public을 붙여 title과 author를 선언과 대입 없이 한 번에 만듭니다. 필드가 곧 생성자 인자인 DTO나 값 객체에서 반복 코드를 걷어낼 때 씁니다. 승격된 프로퍼티의 기본값 자리에는 상수 표현식만 올 수 있고, 이 문법은 생성자에서만 허용됩니다.
PHP code
<?php
class Article {
public function __construct(
public string $title,
public string $author
) {}
}
$article = new Article('PHP 8', 'mida');
echo $article->title;승격된 프로퍼티에는 readonly와 기본값을 함께 붙일 수 있고 어트리뷰트도 그 자리에 달 수 있습니다. 값 검증이 필요하면 생성자 본문에 코드를 더하면 됩니다.
같은 이름의 프로퍼티를 클래스 본문에 또 선언하면 중복 선언으로 치명적 오류가 납니다. 생성자에서만 쓰고 버릴 값이라면 승격하지 말고 일반 파라미터로 둡니다.
02상속 기본
Dog가 Animal을 확장하면서 speak()를 같은 시그니처로 덮어씁니다. 공통 동작을 부모에 두고 일부만 바꿔야 할 때의 기본 형태입니다. PHP는 다중 상속이 없어 부모를 하나만 고를 수 있고, 부모 구현을 지우지 않고 이어 쓰려면 parent::speak()를 직접 불러야 합니다.
PHP code
<?php
class Animal {
public function speak(): string {
return '...';
}
}
class Dog extends Animal {
public function speak(): string {
return 'woof';
}
}
echo (new Dog())->speak();재정의할 때 반환 타입은 더 좁게, 파라미터 타입은 더 넓게 바꿀 수 있습니다. 부모에 생성자가 있으면 자식 생성자에서 parent::__construct를 직접 불러야 초기화가 이뤄집니다.
상속을 코드 재사용 수단으로만 쓰면 부모가 바뀔 때마다 자식이 함께 깨집니다. 공통 기능을 나누는 것이 목적이라면 합성이나 trait 쪽이 결합을 덜 만듭니다.
03추상 클래스
Payment는 pay()의 시그니처만 선언하고 본문은 CardPayment가 채웁니다. 결제 수단처럼 골격은 같고 처리만 달라지는 계층에 맞습니다. 추상 메서드를 하나라도 남긴 클래스는 자신도 abstract여야 하며, 인터페이스와 달리 프로퍼티와 완성된 메서드를 함께 가질 수 있습니다.
PHP code
<?php
abstract class Payment {
abstract public function pay(int $amount): string;
}
class CardPayment extends Payment {
public function pay(int $amount): string {
return "paid {$amount}";
}
}
echo (new CardPayment())->pay(5000);추상 클래스에 생성자와 공통 헬퍼를 두고 달라지는 부분만 abstract로 남기면 템플릿 메서드 형태가 됩니다. 인스턴스로 만들 수 없을 뿐 정적 메서드는 그대로 호출됩니다.
자식이 시그니처를 바꿔 구현하면 클래스 로딩 자체가 실패합니다. 파라미터 이름만 다른 것은 허용되지만 이름 있는 인자를 쓰는 호출부가 깨집니다.
04인터페이스 구현
Logger가 info()의 계약만 정하고 EchoLogger가 실제 출력을 담당합니다. 로깅이나 저장소처럼 구현을 바꿔 끼울 자리에 둡니다. 인터페이스 메서드는 언제나 public이고 상태를 담을 수 없어서, 여러 클래스에 같은 코드를 나눠 주려면 trait이 따로 필요합니다.
PHP code
<?php
interface Logger {
public function info(string $message): void;
}
class EchoLogger implements Logger {
public function info(string $message): void {
echo $message;
}
}
(new EchoLogger())->info('boot');한 클래스가 인터페이스 여러 개를 동시에 구현할 수 있고 인터페이스끼리도 상속됩니다. 상수는 인터페이스에 둘 수 있어 구현체가 공유하는 값의 자리로 쓰입니다.
인터페이스에 메서드를 추가하면 이미 배포된 구현체가 한꺼번에 깨집니다. 외부에 공개한 인터페이스라면 새 인터페이스를 만들어 확장하는 편이 안전합니다.
05Trait 재사용
Timestampable이 가진 now() 구현이 use 한 줄로 Post 안에 복사됩니다. 상속 계층이 서로 다른 클래스들에 같은 보조 메서드를 넣을 때 쓰입니다. trait은 타입이 아니라 instanceof로 검사할 수 없고, 메서드 이름이 겹치면 insteadof로 충돌을 직접 풀어야 합니다.
PHP code
<?php
trait Timestampable {
public function now(): string {
return date('c');
}
}
class Post {
use Timestampable;
}
echo (new Post())->now();trait은 추상 메서드와 정적 프로퍼티도 담을 수 있고 as로 메서드 이름이나 가시성을 바꿀 수 있습니다. 정적 프로퍼티는 사용하는 클래스마다 별개로 존재합니다.
trait 메서드는 클래스에 복사되므로 클래스가 직접 정의한 같은 이름 메서드가 언제나 우선합니다. 동작이 바뀌지 않는 원인을 trait에서 찾다 시간을 버리게 됩니다.
06enum 에 메서드 넣기
문자열 백드 enum Plan에 limit()을 달아 요금제별 한도를 케이스 자신이 답하게 만듭니다. 상수와 switch로 흩어지던 분기를 한곳에 모을 때 적합합니다. 각 케이스는 하나뿐인 객체라 === 비교가 안전한 대신, enum 안에는 일반 프로퍼티를 둘 수 없습니다.
PHP code
<?php
enum Plan: string {
case Free = 'free';
case Pro = 'pro';
public function limit(): int {
return $this === self::Free ? 3 : 100;
}
}
echo Plan::Pro->limit();enum에는 상수와 정적 메서드, 인터페이스 구현까지 넣을 수 있어 케이스별 동작을 한 파일에 모을 수 있습니다. match ($this)를 쓰면 빠뜨린 케이스가 오류로 드러납니다.
케이스 이름이나 값을 DB에 그대로 저장했다면 나중에 이름을 바꾸는 순간 기존 데이터가 변환에서 실패합니다. 저장용 값과 표시용 라벨은 나눠 두는 편이 안전합니다.
07readonly 클래스
클래스 전체에 readonly를 붙여 amount와 currency가 생성 이후 재대입되지 않게 막습니다. 금액처럼 도중에 바뀌면 안 되는 데이터를 옮길 때 고릅니다. readonly는 바깥은 물론 클래스 내부에서의 두 번째 대입도 막고, 값이 배열이면 원소 추가마저 오류가 됩니다.
PHP code
<?php
readonly class Money {
public function __construct(
public int $amount,
public string $currency
) {}
}
$money = new Money(1000, 'KRW');
echo $money->currency;readonly 클래스는 모든 프로퍼티에 타입이 있어야 하고 동적 프로퍼티 추가도 막습니다. 값을 바꾼 사본이 필요하면 새 인스턴스를 돌려주는 with 계열 메서드를 두는 편이 흔합니다.
clone한 사본의 프로퍼티에 그대로 대입하면 오류가 납니다. 값 하나만 바꾼 객체가 필요하면 생성자를 통해 새로 만들어야 합니다.
08toString 매직 메서드
Version이 private 필드 두 개를 '8.3' 형태로 합쳐 주고, echo가 그 메서드를 자동으로 부릅니다. 로그나 URL에 객체를 그대로 끼워 넣고 싶을 때 붙입니다. 반환 타입은 반드시 string이어야 하며, 문자열 변환은 곳곳에서 일어나므로 여기서 예외를 던지지 않게 짜는 편이 안전합니다.
PHP code
<?php
class Version {
public function __construct(private int $major, private int $minor) {}
public function __toString(): string {
return $this->major . '.' . $this->minor;
}
}
echo new Version(8, 3);이 메서드가 있으면 문자열 타입 파라미터에도 객체를 그대로 넘길 수 있습니다. Stringable 인터페이스는 __toString이 있으면 자동으로 구현된 것으로 취급됩니다.
문자열 표현과 저장용 직렬화 형식을 같은 것으로 삼으면 나중에 표기를 바꿀 수 없게 됩니다. 로그에 객체를 그대로 끼워 넣다 내부 값이 노출되는 일도 생깁니다.
09생성자 주입
ReportService는 Clock 인터페이스만 알고, 실제 시각은 주입된 SystemClock이 제공합니다. 테스트에서 시간을 고정해야 할 때 이 구조가 필요합니다. 메서드 안에서 date()를 직접 부르면 결과가 실행 시각에 묶이지만, 주입해 두면 고정 시각 구현으로 갈아 끼우면 끝입니다.
PHP code
<?php
interface Clock {
public function now(): string;
}
class SystemClock implements Clock {
public function now(): string {
return date('c');
}
}
class ReportService {
public function __construct(private Clock $clock) {}
public function generate(): string {
return 'generated at ' . $this->clock->now();
}
}
echo (new ReportService(new SystemClock()))->generate();생성자로 받은 의존성을 private readonly로 두면 주입 이후 교체를 막을 수 있습니다. 컨테이너가 타입 힌트만 보고 구현체를 찾아 넣어 주는 것도 이 형태 덕분입니다.
생성자 안에서 구현체를 직접 new 하면 주입의 의미가 사라져 테스트에서 시간을 고정할 수 없습니다. 기본값을 주더라도 인자로 받을 길은 열어 둬야 합니다.
10값 객체
생성자 안에서 filter_var로 형식을 검사해, 잘못된 주소로는 Email 객체 자체가 만들어지지 않습니다. 검증을 경계에 한 번만 두고 안쪽에서 재검사를 없앨 때 쓰는 방식입니다. readonly라 값이 뒤에서 바뀔 수 없으므로 검사를 통과했다는 사실이 객체 수명 내내 유지됩니다.
PHP code
<?php
readonly class Email {
public function __construct(public string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('invalid email');
}
}
}
echo (new Email('team@example.com'))->value;값 객체에는 같은 값인지 견주는 비교 메서드를 함께 두는 편이 좋습니다. 생성자에서 던지는 예외 타입을 도메인 예외로 통일하면 상위 계층이 처리하기 쉬워집니다.
생성자에서 예외를 던지는 클래스를 목록 변환에 그대로 쓰면 잘못된 행 하나가 전체 변환을 멈춥니다. 부분 실패를 허용하려면 검증과 생성 단계를 나눠야 합니다.
11정적 팩토리 메서드
생성자를 private으로 닫고 issue()만 열어, 토큰 값이 random_bytes로만 생기도록 강제합니다. 생성 방법이 여러 갈래이거나 이름을 붙이고 싶을 때 new보다 낫습니다. self 반환은 정의된 클래스로 고정되니, 상속한 클래스에서도 제 타입이 나오게 하려면 static을 씁니다.
PHP code
<?php
class Token {
private function __construct(public string $value) {}
public static function issue(): self {
return new self(bin2hex(random_bytes(8)));
}
}
echo Token::issue()->value;정적 팩토리는 이름으로 의도를 드러내고 필요하면 캐시된 인스턴스를 돌려줄 수도 있습니다. 생성자가 private이면 상속도 막히므로 확장 계획이 있으면 protected로 둡니다.
random_bytes는 안전한 난수원을 얻지 못하면 예외를 던지므로 발급 경로에 처리가 필요합니다. rand나 uniqid로 대체하면 예측 가능한 토큰이 되어 보안이 무너집니다.
12private 생성자 싱글톤 스타일
??= 덕분에 첫 호출에서만 인스턴스를 만들고 이후에는 같은 객체가 돌아옵니다. 설정처럼 요청당 한 번만 읽으면 되는 값에 흔히 쓰입니다. 이 정적 상태는 요청이 끝나면 사라져 프로세스 간 캐시가 아니고, 초기화 수단이 없으면 테스트에서 전역 변수와 똑같은 문제를 만듭니다.
PHP code
<?php
class Config {
private static ?self $instance = null;
private function __construct(public array $values) {}
public static function instance(): self {
return self::$instance ??= new self(['env' => 'local']);
}
}
echo Config::instance()->values['env'];요청 단위 캐시로는 쓸모가 있지만 의존성이 코드에서 감춰진다는 점에서 컨테이너에 등록한 공유 인스턴스가 대개 더 낫습니다. 정적 프로퍼티는 클래스마다 하나만 존재합니다.
테스트마다 같은 인스턴스가 이어져 앞 테스트가 남긴 상태가 다음 테스트로 흘러갑니다. 초기화 수단이 없으면 실행 순서에 따라 결과가 달라지는 테스트가 됩니다.
13익명 클래스 어댑터
Cache를 구현하는 이름 없는 클래스를 그 자리에서 만들어 변수에 담았습니다. 테스트 스텁이나 일회용 어댑터처럼 파일을 새로 팔 가치가 없을 때 적합합니다. 익명 클래스는 이름을 지정할 수 없어 ::class가 엔진이 만든 내부 이름을 돌려주므로, 그 값을 설정 키로 쓰면 안 됩니다.
PHP code
<?php
interface Cache {
public function get(string $key): mixed;
}
$cache = new class implements Cache {
public function get(string $key): mixed {
return 'mock:' . $key;
}
};
echo $cache->get('user:1');익명 클래스도 생성자 인자를 받을 수 있어 감쌀 대상이나 고정 응답을 그 자리에서 주입할 수 있습니다. instanceof 검사는 구현한 인터페이스로 그대로 통과합니다.
컨테이너 정의나 캐시 키에 이 클래스의 이름을 쓰면 실행마다 이름이 달라져 맞지 않습니다. 여러 곳에서 참조할 구현이라면 이름 있는 클래스로 빼야 합니다.
14final 클래스와 메서드
AppKernel은 상속을 막고 boot()도 final로 재정의를 금지합니다. 커널처럼 확장 지점을 의도적으로 닫아 두고 싶을 때 선언합니다. final 클래스는 상속 기반 목 생성이 불가능하니, 테스트에서 바꿔 끼워야 하는 대상이라면 인터페이스를 두고 그것을 주입하는 편이 낫습니다.
PHP code
<?php
final class AppKernel {
final public function boot(): string {
return 'booted';
}
}
echo (new AppKernel())->boot();final을 기본으로 두고 필요할 때 푸는 편이 되돌리기 쉽습니다. private 메서드는 애초에 재정의될 수 없어 final을 붙일 필요가 없습니다.
final 클래스는 상속 기반 목 생성이 막혀 테스트에서 대역을 만들 수 없습니다. 확장 지점을 닫기 전에 그 타입을 바꿔 끼울 일이 있는지 먼저 봐야 합니다.
15IteratorAggregate 구현
getIterator()에서 yield from으로 내부 배열을 흘려보내, 컬렉션 객체를 foreach에 바로 넣을 수 있게 합니다. 배열을 감싸면서 순회는 그대로 두고 싶을 때 고릅니다. Iterator를 직접 구현하면 current와 next 등 다섯 메서드를 다 채워야 하지만 이 방식은 하나로 끝납니다.
PHP code
<?php
class TagCollection implements IteratorAggregate {
public function __construct(private array $tags) {}
public function getIterator(): Traversable {
yield from $this->tags;
}
}
foreach (new TagCollection(['php', 'api']) as $tag) {
echo $tag . PHP_EOL;
}Countable과 ArrayAccess를 함께 구현하면 배열처럼 다루면서 내부 표현은 감출 수 있습니다. getIterator는 제너레이터를 돌려줘도 되고 ArrayIterator를 돌려줘도 됩니다.
제너레이터를 돌려주면 foreach마다 새로 만들어지므로 순회 상태가 공유된다고 가정하면 안 됩니다. count()는 Countable을 구현하지 않으면 언제나 1을 돌려줍니다.
16JsonSerializable 구현
private 필드를 그대로 노출하는 대신, jsonSerialize()가 내보낼 배열을 직접 정해 json_encode에 넘깁니다. 내부 필드명과 API 응답 필드명을 분리할 때 씁니다. 이 인터페이스를 구현하지 않으면 json_encode는 public 프로퍼티만 담기 때문에 이 클래스는 빈 객체로 직렬화됩니다.
PHP code
<?php
class UserResource implements JsonSerializable {
public function __construct(private int $id, private string $name) {}
public function jsonSerialize(): array {
return ['id' => $this->id, 'name' => $this->name];
}
}
echo json_encode(new UserResource(1, 'mida'));돌려주는 값에 다른 JsonSerializable 객체가 들어 있으면 중첩해서 함께 직렬화됩니다. 목록으로 나가야 한다면 키가 0부터 이어진 배열을 돌려줘야 합니다.
내부 필드를 그대로 내보내면 응답 형식이 DB 스키마에 묶여 컬럼명을 바꿀 수 없게 됩니다. 비밀번호 해시 같은 값이 딸려 나가는 사고도 이 지점에서 생깁니다.
17ArrayAccess 구현
offsetExists, offsetGet, offsetSet, offsetUnset 네 개를 구현해 Bag을 대괄호 문법으로 다룹니다. 배열처럼 보이되 기본값이나 검증이 필요한 설정 저장소에 어울립니다. isset()은 offsetExists를 타지만 count()는 동작하지 않으므로, 그것까지 원하면 Countable을 함께 구현합니다.
PHP code
<?php
class Bag implements ArrayAccess {
private array $items = [];
public function offsetExists(mixed $offset): bool { return isset($this->items[$offset]); }
public function offsetGet(mixed $offset): mixed { return $this->items[$offset] ?? null; }
public function offsetSet(mixed $offset, mixed $value): void { $this->items[$offset] = $value; }
public function offsetUnset(mixed $offset): void { unset($this->items[$offset]); }
}
$bag = new Bag();
$bag['lang'] = 'php';
echo $bag['lang'];자주 쓰는 키는 대괄호 대신 전용 메서드를 따로 두는 편이 읽기 좋습니다. 없는 키를 읽을 때 null을 줄지 예외를 던질지는 미리 정해 두어야 합니다.
offsetGet이 null을 돌려주면 isset()이 타는 offsetExists와 결과가 어긋날 수 있습니다. $bag['a']['b'] 형태의 중첩 대입도 참조가 아니라 동작하지 않습니다.
18__invoke 호출 객체
Slugify 인스턴스를 함수처럼 호출하면 공백이 하이픈으로 바뀐 소문자 문자열이 나옵니다. 동작이 하나뿐인 클래스를 콜백 자리에 그대로 넘길 때 편합니다. __invoke가 있는 객체는 is_callable을 통과해 array_map에도 바로 들어가지만, 상태를 들고 있다는 점은 함수와 다릅니다.
PHP code
<?php
class Slugify {
public function __invoke(string $value): string {
return strtolower(str_replace(' ', '-', trim($value)));
}
}
$slugify = new Slugify();
echo $slugify('Hello PHP');__invoke를 가진 객체는 생성자로 의존성을 받을 수 있어 순수 함수보다 설정을 담기 좋습니다. 단일 동작 컨트롤러나 미들웨어를 이 형태로 두는 프레임워크가 많습니다.
이 객체를 콜백으로 여기저기 넘기면 안에 든 상태가 호출 순서에 따라 결과를 바꿀 수 있습니다. 함수처럼 보이지만 부작용을 갖는다는 점을 잊기 쉽습니다.
19__get 과 __set 주의 깊게 쓰기
선언되지 않은 프로퍼티에 접근할 때만 마법 메서드가 끼어들어 내부 $values 배열을 대신 읽고 씁니다. 스키마가 유동적인 메타데이터에는 쓸 만합니다. 대신 IDE 자동완성과 정적 분석이 이 이름들을 보지 못하고, 같은 이름의 프로퍼티가 실제로 선언돼 있으면 __get은 호출되지 않습니다.
PHP code
<?php
class MetaBag {
private array $values = [];
public function __set(string $name, mixed $value): void {
$this->values[$name] = $value;
}
public function __get(string $name): mixed {
return $this->values[$name] ?? null;
}
}
$bag = new MetaBag();
$bag->framework = 'Laravel';
echo $bag->framework;__isset과 __unset을 함께 구현하지 않으면 isset()과 unset()이 이 값들을 보지 못합니다. 정의되지 않은 메서드 호출을 받으려면 __call과 __callStatic이 따로 필요합니다.
__get과 __set은 편하지만 IDE 지원과 정적 분석이 약해지므로 정말 필요한 경우에만 쓰는 편이 좋습니다.
20Attribute 기반 컨트롤러 메타데이터
#[Middleware('auth')]로 클래스에 메타데이터를 달고, ReflectionClass가 그것을 읽어 실제 객체로 되살립니다. 라우팅이나 권한 정보를 클래스 옆에 두는 방식입니다. 어트리뷰트는 선언만으로는 아무것도 실행되지 않고 newInstance()를 부르는 순간에야 생성자가 돕니다.
PHP code
<?php
#[Attribute]
class Middleware {
public function __construct(public string $name) {}
}
#[Middleware('auth')]
class DashboardController {}
$ref = new ReflectionClass(DashboardController::class);
echo $ref->getAttributes(Middleware::class)[0]->newInstance()->name;어트리뷰트를 여러 개 달았다면 인자 없이 불러 전부 받을 수 있고, 메서드나 프로퍼티에 붙은 것은 ReflectionMethod 쪽에서 읽습니다. 리플렉션 결과는 캐시해 두는 편이 좋습니다.
어트리뷰트 인자에는 상수 표현식만 올 수 있어 런타임 값을 넣을 수 없습니다. 요청마다 리플렉션으로 읽으면 비용이 쌓이므로 라우팅 정보는 미리 만들어 두는 편이 낫습니다.
21객체 복제와 __clone
clone이 만든 사본에서 __clone()이 wheres의 키를 다시 매기고, 이후 추가한 조건은 원본에 영향을 주지 않습니다. 빌더를 여러 갈래로 분기시킬 때 쓰는 방법입니다. clone은 얕은 복사라 프로퍼티가 객체면 원본과 같은 인스턴스를 공유하므로 그때는 직접 복제해 줘야 합니다.
PHP code
<?php
class QueryBuilder {
public function __construct(public array $wheres = []) {}
public function __clone(): void {
$this->wheres = array_values($this->wheres);
}
}
$base = new QueryBuilder(['active = 1']);
$copy = clone $base;
$copy->wheres[] = 'role = "admin"';
print_r([$base->wheres, $copy->wheres]);__clone은 사본 쪽에서 실행되므로 여기서 내부 객체를 다시 clone하면 깊은 복사를 만들 수 있습니다. 메서드마다 사본을 돌려주면 불변 체이닝 빌더가 됩니다.
프로퍼티에 든 객체나 DB 핸들은 복제해도 원본과 같은 인스턴스를 가리켜 한쪽 변경이 양쪽에 보입니다. 배열 안에 객체가 들어 있는 경우도 마찬가지입니다.
정리하며
- 의존성은 생성자 프로퍼티 승격으로 선언해 필드 선언과 대입 중복을 없앱니다.
- 타입 계약이 목적이면 인터페이스, 코드 재사용이 목적이면 Trait으로 나눕니다.
- clone은 얕은 복사이므로 내부 객체가 있으면 __clone에서 직접 다시 만듭니다.
- json_encode 결과를 통제하려면 배열 변환 대신 JsonSerializable을 구현합니다.
더 깊이 들어가고 싶다면 PHP 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.