PHpullh

PHP · 심층 가이드

PHP 패턴 완전 정리

Front Controller에서 시작해 미들웨어 파이프라인, 서비스 컨테이너, Repository와 데코레이터까지 PHP 프레임워크가 실제로 쓰는 구조를 직접 만들어 보며 이해합니다.

주제 18개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

PHP의 설계 패턴은 교과서보다 PSR 표준과 프레임워크 관행에 더 강하게 묶여 있습니다. 클래스 하나가 파일 하나에 대응하는 PSR-4 자동 로딩, 요청과 응답을 값 객체로 다루는 HTTP 메시지 규약, 그 사이를 지나가는 미들웨어 개념이 사실상 공용어입니다. 여기에 요청마다 프로세스가 새로 시작하는 실행 모델이 겹치면서, 부팅 비용을 줄이려는 지연 생성과 캐싱이 거의 모든 구조에 스며들어 있습니다. 패턴의 이름보다 이 제약을 먼저 이해하는 편이 빠릅니다.

Front Controller 기본이 출발점입니다. 모든 요청을 한 진입점으로 모아 두면 그 지점에서 Middleware 파이프라인으로 인증과 로깅 같은 횡단 관심사를 겹겹이 감쌀 수 있고, 최종 처리기를 고르는 일은 match 기반 라우터가 맡습니다. 객체를 누가 만드느냐는 문제는 Service Container 간단 구현과 Factory Method가 나눠 다루고, 데이터 접근은 Repository 패턴으로 경계를 그은 뒤 캐시 데코레이터를 씌워 원본 코드를 건드리지 않고 성능을 얻는 흐름을 보여 줍니다.

배포에서 터지는 대표적 사고가 PSR-4 자동 로딩 규칙과 관련이 있습니다. macOS 기본 파일시스템은 대소문자를 구분하지 않아 src/Http/Kernel.php를 src/http/Kernel.php로 적어도 로컬에서는 잘 돌아가지만, 대소문자를 구분하는 리눅스 서버에 올리는 순간 클래스를 찾지 못합니다. 네임스페이스와 디렉터리 이름을 글자 단위로 맞추십시오. 컨테이너 쪽에도 함정이 있습니다. 컨테이너 자체를 클래스 안으로 주입해 필요할 때마다 꺼내 쓰면 서비스 로케이터가 되어 의존성이 생성자에서 사라지고, 테스트에서 무엇을 대체해야 하는지 알 수 없게 됩니다.

01Front Controller 기본

요청 URI에서 경로만 뽑아 match로 응답을 고르는, 진입점 하나가 모든 요청을 받는 구조입니다. 파일마다 PHP를 두는 대신 index.php에서 분기할 때의 최소 형태입니다. 이 방식은 웹서버 rewrite 규칙이 존재하지 않는 경로까지 이 파일로 넘겨 줘야 비로소 성립합니다.

PHP code

<?php
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);

echo match ($path) {
    '/' => 'home',
    '/health' => 'ok',
    default => '404',
};
알아두면 좋은 점

REQUEST_METHOD까지 함께 봐야 같은 경로의 GET과 POST를 나눌 수 있습니다. 진입점을 하나로 모으면 세션 시작이나 오류 핸들러 등록 같은 준비를 한곳에 둘 수 있습니다.

자주 하는 실수

rewrite 규칙 없이 배포하면 요청이 이 파일에 닿지 못하고 웹서버가 404를 돌려줍니다. 로컬 내장 서버에서만 동작하고 운영에서 깨지는 전형적인 사례입니다.

02Repository 패턴

UserRepository 인터페이스 뒤에 배열을 순회하는 인메모리 구현을 두어 호출부가 저장 위치를 모르게 합니다. 나중에 DB 구현으로 갈아 끼우거나 테스트에서 가짜를 쓰려는 배치입니다. 반환 타입이 배열이라 컬럼 오타는 잡아 주지 못하니, 실무에서는 DTO를 돌려주는 편이 안전합니다.

PHP code

<?php
interface UserRepository {
    public function findById(int $id): ?array;
}

class InMemoryUserRepository implements UserRepository {
    public function __construct(private array $rows) {}
    public function findById(int $id): ?array {
        foreach ($this->rows as $row) {
            if ($row['id'] === $id) return $row;
        }
        return null;
    }
}

var_dump((new InMemoryUserRepository([['id' => 1]]))->findById(1));
알아두면 좋은 점

인터페이스를 도메인 쪽에 두고 구현을 인프라 쪽에 두면 의존 방향이 안쪽을 향합니다. 테스트에서는 이 인메모리 구현을 그대로 써서 DB 없이 검증을 끝낼 수 있습니다.

자주 하는 실수

저장소 인터페이스에 조건 조합마다 메서드가 늘어나면 결국 쿼리를 인터페이스에 새기게 됩니다. 조건이 많아지면 조회 전용 객체로 분리하는 편이 낫습니다.

03Service Container 간단 구현

id마다 팩토리 클로저를 등록해 두었다가 get()에서 실행해 값을 만듭니다. 의존성 생성 코드를 한곳에 모으는 가장 작은 컨테이너입니다. 결과를 저장하지 않는 구현이라 get()을 두 번 부르면 객체가 두 개 생기므로, 공유가 필요하면 캐시 배열을 따로 둬야 합니다.

PHP code

<?php
class Container {
    private array $factories = [];

    public function set(string $id, callable $factory): void { $this->factories[$id] = $factory; }
    public function get(string $id): mixed { return $this->factories[$id]($this); }
}

$container = new Container();
$container->set('config', fn () => ['env' => 'local']);
print_r($container->get('config'));
알아두면 좋은 점

PSR-11 인터페이스를 구현해 두면 다른 라이브러리가 이 컨테이너를 그대로 받아 씁니다. 팩토리에 컨테이너 자신을 넘겨 의존성을 이어 붙일 수 있는 것도 이 구조 덕분입니다.

자주 하는 실수

등록되지 않은 id를 넘기면 정의되지 않은 키 오류가 나므로 존재 확인과 명확한 예외가 필요합니다. 서비스 안에서 컨테이너를 직접 참조하면 의존성이 시그니처에서 사라집니다.

04Middleware 파이프라인

array_reduce가 미들웨어 배열을 역순으로 감싸, 각 단계가 요청에 키를 더하고 다음 단계를 부르는 클로저 하나를 만듭니다. 인증이나 로깅을 처리 앞뒤로 끼울 때의 골격입니다. 역순으로 접기 때문에 실제 실행 순서는 배열에 적어 둔 순서 그대로가 됩니다.

PHP code

<?php
$middlewares = [
    fn (array $request, callable $next) => $next($request + ['trace' => 'a']),
    fn (array $request, callable $next) => $next($request + ['auth' => true]),
];

$kernel = array_reduce(
    array_reverse($middlewares),
    fn (callable $next, callable $mw): callable => fn (array $request) => $mw($request, $next),
    fn (array $request): array => $request
);

print_r($kernel(['path' => '/']));
알아두면 좋은 점

각 단계가 $next를 부르지 않으면 뒤가 실행되지 않아 인증 실패 시 조기 응답을 만들 수 있습니다. 응답을 돌려받은 뒤 후처리를 넣을 수 있어 앞뒤가 모두 열립니다.

자주 하는 실수

$next를 부르면서 요청을 넘기지 않거나 반환값을 그대로 돌려주지 않으면 체인이 중간에서 끊깁니다. 오류 없이 응답만 비어 나와 원인을 찾기 어렵습니다.

05Strategy 패턴

DiscountStrategy를 구현한 TenPercent가 계산식 하나만 책임지고, 호출부는 인터페이스만 압니다. 할인 정책처럼 규칙이 계속 늘어나는 곳에 씁니다. 여기 쓰인 (int) 캐스팅은 반올림이 아니라 소수점을 버리므로 원 단위 금액이 1원 낮아질 수 있습니다.

PHP code

<?php
interface DiscountStrategy {
    public function apply(int $price): int;
}

class TenPercent implements DiscountStrategy {
    public function apply(int $price): int { return (int) ($price * 0.9); }
}

echo (new TenPercent())->apply(10000);
알아두면 좋은 점

전략을 enum이나 설정 키로 골라 컨테이너에서 꺼내면 정책을 추가할 때 호출부를 고치지 않아도 됩니다. 전략마다 테스트를 따로 붙일 수 있는 것도 이점입니다.

자주 하는 실수

float를 거친 금액을 (int)로 자르면 9999원이 되어야 할 값이 9998로 떨어질 수 있습니다. 반올림 규칙을 정하고 round()를 명시해야 합니다.

06Factory Method

NotifierFactory::make()가 타입 문자열을 받아 match로 알맞은 Notifier 구현을 돌려줍니다. 설정값이나 사용자 선택에 따라 구현이 갈릴 때 씁니다. match는 default가 없고 일치하는 갈래도 없으면 UnhandledMatchError를 던져, switch처럼 조용히 지나가지 않습니다.

PHP code

<?php
interface Notifier {
    public function send(string $message): string;
}

class EmailNotifier implements Notifier {
    public function send(string $message): string { return 'mail:' . $message; }
}

class NotifierFactory {
    public static function make(string $type): Notifier {
        return match ($type) {
            'email' => new EmailNotifier(),
            default => throw new InvalidArgumentException('unsupported'),
        };
    }
}

echo NotifierFactory::make('email')->send('hi');
알아두면 좋은 점

팩토리를 정적 메서드로 두면 간편하지만 의존성을 주입할 수 없습니다. 구현체마다 생성 인자가 다르면 인스턴스 팩토리로 만들어 컨테이너에 등록하는 편이 확장에 낫습니다.

자주 하는 실수

타입 문자열이 사용자 입력에서 그대로 오면 목록에 없는 값에서 UnhandledMatchError가 응답 전체를 죽입니다. 허용 목록으로 먼저 걸러 내야 합니다.

07Builder 패턴

select()와 where()가 각각 $this를 돌려주어 호출을 이어 붙일 수 있고 toSql()에서 조각을 합칩니다. 인자가 많은 쿼리를 단계적으로 조립할 때 쓰는 형태입니다. 조각을 들어온 순서대로 쌓기만 하므로 where()를 select()보다 먼저 부르면 깨진 SQL이 그대로 나옵니다.

PHP code

<?php
class QueryBuilder {
    private array $parts = [];

    public function select(string $table): self { $this->parts[] = 'SELECT * FROM ' . $table; return $this; }
    public function where(string $condition): self { $this->parts[] = 'WHERE ' . $condition; return $this; }
    public function toSql(): string { return implode(' ', $this->parts); }
}

echo (new QueryBuilder())->select('users')->where('active = 1')->toSql();
알아두면 좋은 점

메서드가 $this 대신 사본을 돌려주게 바꾸면 중간 상태를 재사용해도 서로 섞이지 않습니다. toSql 외에 바인딩할 파라미터 배열도 함께 돌려줘야 실제로 쓸 수 있습니다.

자주 하는 실수

조건 문자열을 그대로 이어 붙이는 구조라 사용자 입력이 where로 들어오면 SQL 인젝션이 됩니다. 값은 자리표시자로 두고 별도 배열로 전달해야 합니다.

08Observer 패턴

이벤트 이름별로 리스너 배열을 모아 두었다가 emit()에서 차례로 호출합니다. 주문 완료 뒤 메일 발송과 통계 적재를 본 로직에서 떼어낼 때 쓰입니다. 리스너는 같은 프로세스에서 동기로 실행되므로 느린 작업을 넣으면 응답 시간이 그만큼 길어지고, 예외도 호출부까지 올라옵니다.

PHP code

<?php
class EventBus {
    private array $listeners = [];

    public function on(string $event, callable $listener): void { $this->listeners[$event][] = $listener; }
    public function emit(string $event, mixed $payload): void {
        foreach ($this->listeners[$event] ?? [] as $listener) {
            $listener($payload);
        }
    }
}

$bus = new EventBus();
$bus->on('user.created', fn (array $user) => print($user['name']));
$bus->emit('user.created', ['name' => 'mida']);
알아두면 좋은 점

리스너를 우선순위와 함께 저장하면 실행 순서를 제어할 수 있고, PSR-14를 따르면 다른 라이브러리와 붙습니다. 이벤트 이름 대신 이벤트 클래스를 키로 쓰면 오타가 줄어듭니다.

자주 하는 실수

리스너 하나가 예외를 던지면 뒤 리스너들이 실행되지 않고 본 로직까지 실패합니다. 부수 작업이라면 각 리스너를 감싸 로그만 남기거나 큐로 넘겨야 합니다.

09Command 패턴

실행할 일을 handle() 하나로 표현하는 인터페이스와 그 구현이 전부인 최소 형태입니다. 큐에 넣거나 로그로 남길 작업을 객체로 다루려는 출발점이 됩니다. 실행 대상을 이렇게 통일해 두면 디스패처가 명령의 종류를 몰라도 받아서 처리할 수 있습니다.

PHP code

<?php
interface Command {
    public function handle(): string;
}

class SendMailCommand implements Command {
    public function handle(): string { return 'sent'; }
}

echo (new SendMailCommand())->handle();
알아두면 좋은 점

명령 객체를 직렬화 가능한 값만으로 채우면 그대로 큐에 실어 보낼 수 있습니다. 결과가 필요 없다면 반환 타입을 void로 두어 의도를 분명히 하는 편이 좋습니다.

자주 하는 실수

명령 안에서 곧바로 메일을 보내면 나중에 큐로 옮길 때 다시 뜯어고쳐야 합니다. 명령은 무엇을 할지만 담고 실제 수행은 핸들러에 두는 편이 옮기기 쉽습니다.

10DTO 매퍼

DB 행 배열을 받아 readonly UserDto로 옮기는 변환 함수 하나입니다. 배열이 애플리케이션 안쪽까지 흘러가는 것을 경계에서 끊을 때 넣습니다. 키가 빠져 있으면 이 지점에서 곧바로 오류가 나므로, 화면을 그리다가 undefined key를 만나는 것보다 원인을 찾기가 쉽습니다.

PHP code

<?php
readonly class UserDto {
    public function __construct(public int $id, public string $name) {}
}

function mapUser(array $row): UserDto {
    return new UserDto($row['id'], $row['name']);
}

print_r(mapUser(['id' => 1, 'name' => 'Kim']));
알아두면 좋은 점

매퍼를 한곳에 모아 두면 컬럼명이 바뀌었을 때 고칠 자리가 한 군데로 좁혀집니다. 널 허용 컬럼은 DTO 쪽 타입도 물음표를 붙여 맞춰야 변환 지점에서 걸립니다.

자주 하는 실수

행에 키가 없으면 경고와 함께 null이 넘어가고 타입 선언 덕에 그제야 오류가 납니다. SELECT *로 받아 오는 습관이 이런 어긋남을 늦게 드러나게 만듭니다.

11CQRS 식 명령/조회 분리

쓰기 의도인 CreatePost와 읽기 의도인 FindPostById를 별도 클래스로 나눈 이름 설계만 담겨 있습니다. 명령과 조회의 처리 경로가 달라질 때 이 분리가 출발점입니다. 코드가 하는 일은 클래스명 출력뿐이라, 실제 이득은 핸들러와 저장소를 갈라 놓은 다음에야 생깁니다.

PHP code

<?php
class CreatePost {
    public function __construct(public string $title) {}
}

class FindPostById {
    public function __construct(public int $id) {}
}

echo CreatePost::class . ' / ' . FindPostById::class;
알아두면 좋은 점

명령과 조회를 나누면 읽기 쪽만 캐시하거나 별도 읽기 모델을 두는 선택지가 생깁니다. 이름만 나눠 두어도 어떤 코드가 상태를 바꾸는지 한눈에 구분됩니다.

자주 하는 실수

규모가 작은 CRUD에 이 분리를 적용하면 클래스 수만 늘고 얻는 것이 없습니다. 읽기와 쓰기의 요구가 실제로 갈라진 뒤에 도입하는 편이 낫습니다.

12캐시 데코레이터

같은 인터페이스를 구현한 채 내부 게이트웨이를 감싸고, ??=로 첫 호출 결과만 프로퍼티에 남깁니다. 호출부를 고치지 않고 캐싱을 끼워 넣을 때의 방식입니다. 이 캐시는 객체가 살아 있는 동안만 유효해 요청이 끝나면 사라지므로, 요청 간 공유는 Redis 같은 외부 저장소가 필요합니다.

PHP code

<?php
interface ArticleGateway {
    public function latest(): array;
}

class CachedArticleGateway implements ArticleGateway {
    private ?array $cache = null;
    public function __construct(private ArticleGateway $inner) {}
    public function latest(): array {
        return $this->cache ??= $this->inner->latest();
    }
}

echo CachedArticleGateway::class;
알아두면 좋은 점

같은 인터페이스를 유지하므로 로깅이나 재시도 데코레이터를 겹겹이 쌓을 수 있습니다. 컨테이너에서 감싸는 순서만 정하면 호출부는 전혀 바뀌지 않습니다.

자주 하는 실수

캐시를 비울 수단이 없어 원본 데이터가 바뀌어도 이 객체가 살아 있는 동안 옛 결과를 계속 돌려줍니다. 무효화 정책을 함께 설계해야 합니다.

13Adapter 패턴

SmsAdapter가 SmsSender를 구현하면서 호출을 레거시 클라이언트의 push()로 넘겨 줍니다. 손댈 수 없는 외부 라이브러리를 내 인터페이스에 맞출 때 씁니다. 상속이 아니라 합성이라 레거시 클래스가 final이어도 감쌀 수 있고, 클라이언트를 교체해도 어댑터 한 곳만 고치면 됩니다.

PHP code

<?php
interface SmsSender {
    public function send(string $message): string;
}

class LegacySmsClient {
    public function push(string $body): string { return 'legacy:' . $body; }
}

class SmsAdapter implements SmsSender {
    public function __construct(private LegacySmsClient $client) {}
    public function send(string $message): string { return $this->client->push($message); }
}

echo (new SmsAdapter(new LegacySmsClient()))->send('hello');
알아두면 좋은 점

레거시 예외까지 자신의 인터페이스가 약속한 예외로 바꿔 주면 호출부가 외부 라이브러리 타입을 전혀 몰라도 됩니다. 인자와 반환 형태의 변환도 이 층에서 끝냅니다.

자주 하는 실수

어댑터에 조건 분기와 업무 규칙을 채워 넣으면 변환 계층이 아니라 또 하나의 서비스가 됩니다. 외부 클라이언트가 바뀔 때 고칠 자리가 다시 늘어납니다.

14간단한 템플릿 렌더러

데이터 배열을 돌면서 {{key}} 자리를 str_replace로 치환합니다. 메일 본문처럼 규칙이 단순한 문자열 조립에는 이 정도로 충분합니다. 값을 이스케이프하지 않으니 HTML에 그대로 넣으면 XSS가 생기고, 치환된 값 안에 또 다른 플레이스홀더가 있으면 다음 반복에서 다시 치환됩니다.

PHP code

<?php
function render(string $template, array $data): string {
    foreach ($data as $key => $value) {
        $template = str_replace('{{' . $key . '}}', (string) $value, $template);
    }
    return $template;
}

echo render('hello {{name}}', ['name' => 'php']);
알아두면 좋은 점

치환 대상을 미리 모아 배열로 한 번에 넘기면 반복 없이 처리되고 값끼리 다시 치환되는 일도 없습니다. 남은 자리표시자를 정규식으로 확인하면 오탈자를 잡을 수 있습니다.

자주 하는 실수

값 안에 또 다른 자리표시자가 들어 있으면 다음 반복에서 그것마저 치환되어 의도하지 않은 데이터가 새어 나올 수 있습니다. HTML 출력이라면 곧바로 XSS가 됩니다.

15PSR-4 자동 로딩 규칙

spl_autoload_register에 클래스명을 파일 경로로 바꾸는 규칙을 등록해, App 네임스페이스만 src 아래에서 찾습니다. Composer 없이 자동 로딩의 원리를 볼 때 쓰는 형태입니다. 등록된 로더는 클래스가 처음 필요해지는 순간에만 불리고, 접두어가 다르면 그냥 반환해 다음 로더에 차례를 넘깁니다.

PHP code

<?php
spl_autoload_register(function (string $class): void {
    $prefix = 'App\\';
    if (!str_starts_with($class, $prefix)) {
        return;
    }

    $relative = substr($class, strlen($prefix));
    $path = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';
    if (is_file($path)) {
        require $path;
    }
});
알아두면 좋은 점

네임스페이스 구분자는 파일 경로 구분자와 달라 변환이 반드시 필요합니다. 대소문자를 구분하는 파일시스템에서는 클래스명과 파일명이 정확히 같아야 로딩됩니다.

자주 하는 실수

담당하지 않는 접두어에서도 require를 부르면 파일을 찾지 못해 치명적 오류가 납니다. 자기 것이 아닌 이름은 그냥 반환해 다음 로더에 차례를 넘겨야 합니다.

16구성 객체로 설정 묶기

흩어져 있던 설정값을 env와 debug 필드를 가진 readonly 객체 하나로 묶었습니다. 배열 키 오타에서 오는 버그를 타입으로 막고 싶을 때 고릅니다. 배열은 없는 키를 읽어도 경고에 그치지만, 이 객체는 잘못된 프로퍼티 접근이 즉시 오류가 되고 IDE 자동완성도 따라옵니다.

PHP code

<?php
readonly class AppConfig {
    public function __construct(
        public string $env,
        public bool $debug
    ) {}
}

$config = new AppConfig('local', true);
echo $config->env;
알아두면 좋은 점

설정 객체를 만들 때 한 번 검증해 두면 이후 코드에서는 값이 유효하다고 믿을 수 있습니다. 환경 변수로부터 만드는 정적 팩토리를 두면 파싱 규칙도 한곳에 모입니다.

자주 하는 실수

설정 객체가 커질수록 관련 없는 서비스까지 전체를 주입받게 됩니다. 필요한 값 몇 개만 담은 작은 객체로 쪼개는 편이 의존 관계를 좁힙니다.

17match 기반 라우터

메서드와 경로를 배열로 묶어 match에 넘기고 그에 맞는 처리 이름을 돌려받습니다. 경로가 몇 개뿐인 작은 API를 라우터 없이 다룰 때 쓸 만합니다. match는 엄격 비교라 배열도 값과 순서가 모두 같아야 맞고, 대신 /users/{id} 같은 동적 경로는 표현할 수 없습니다.

PHP code

<?php
function route(string $method, string $path): string {
    return match ([$method, $path]) {
        ['GET', '/users'] => 'list users',
        ['POST', '/users'] => 'create user',
        default => '404',
    };
}

echo route('GET', '/users');
알아두면 좋은 점

배열을 대상으로 쓰면 값과 순서가 모두 같아야 하므로 메서드와 경로를 언제나 같은 순서로 넘겨야 합니다. 경로가 늘면 배열 맵과 정규식을 쓰는 라우터로 옮기는 편이 낫습니다.

자주 하는 실수

경로 끝에 슬래시가 하나 더 붙거나 대소문자가 다르면 곧장 404로 떨어집니다. 정규화 없이 이 비교만 두면 사용자에게는 원인 없는 오류로 보입니다.

18Hexagonal Port 인터페이스

CheckoutService는 PaymentPort만 알고 실제 결제 구현은 바깥에 둡니다. 도메인 로직을 결제사 SDK 변경에서 떼어 놓을 때의 배치입니다. 포트는 도메인 쪽에, 어댑터는 인프라 쪽에 둬야 하며 인터페이스를 SDK 패키지 안에 정의하면 의존 방향이 도로 뒤집힙니다.

PHP code

<?php
interface PaymentPort {
    public function charge(int $amount): string;
}

class CheckoutService {
    public function __construct(private PaymentPort $payment) {}
    public function checkout(int $amount): string { return $this->payment->charge($amount); }
}

echo CheckoutService::class;
알아두면 좋은 점

포트를 도메인 언어로 정의해 두면 결제사가 바뀌어도 어댑터만 새로 쓰면 됩니다. 테스트에서는 항상 성공하는 구현을 넣어 실제 결제 없이 주문 흐름을 검증할 수 있습니다.

자주 하는 실수

포트 인터페이스에 결제사 응답 구조를 그대로 노출하면 도메인이 다시 SDK에 묶입니다. 반환값은 내 코드가 정의한 타입이어야 합니다.

정리하며

  • 횡단 관심사는 컨트롤러에 넣지 말고 미들웨어 한 겹으로 분리해 순서를 통제합니다.
  • 컨테이너는 진입점에서만 해석하고 클래스에는 완성된 의존성만 주입합니다.
  • 네임스페이스와 디렉터리 대소문자를 정확히 맞춰 리눅스 배포 시 로딩 실패를 막습니다.
  • 캐싱이나 로깅은 기존 구현을 수정하는 대신 같은 인터페이스의 데코레이터로 덧씌웁니다.

더 깊이 들어가고 싶다면 PHP 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.