PHP · 기본
웹으로 시작하는 PHP
인터페이스·트레이트·네임스페이스
인터페이스와 추상 클래스, 트레이트, 네임스페이스, 오토로드와 Composer 역할(개념만)
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 생성자 승격과 readonly로 값을 담는 클래스를 만들었다. 이 장에서는 클래스가 여러 개로 늘어났을 때 코드를 정리하는 도구를 다룬다. 인터페이스와 추상 클래스는 서로 다른 클래스를 같은 방식으로 다루게 해 준다. 트레이트는 관련 없는 클래스끼리 같은 코드를 나눠 쓰게 해 준다. 네임스페이스는 이름이 겹치지 않게 클래스에 주소를 붙이고, 오토로드는 그 주소를 보고 필요한 파일을 알아서 불러온다.
- 인터페이스로 "이런 메서드를 가진다"는 약속을 정하고, 여러 클래스가 그 약속을 따르게 한다.
- 추상 클래스로 공통 부분은 미리 만들고 자식 클래스가 채울 부분만 남겨 둔다.
- 트레이트로 상속 관계가 없는 클래스에 같은 메서드와 프로퍼티를 붙인다.
- 네임스페이스와
use문으로 클래스 이름을 구분하고 줄여 쓴다. - 오토로더가 클래스 이름을 파일 경로로 바꾸는 원리와, Composer가 그 일을 맡는 이유를 개념으로 이해한다.
문제 상황
빵집 사이트에 상품이 소금빵 하나뿐이었을 때는 클래스 하나로 충분했다. 그런데 케이크가 추가되었다. 케이크는 크기에 따라 가격이 달라진다. 이어서 선물 포장 서비스도 주문에 넣고 싶다는 요구가 나왔다. 선물 포장은 빵도 케이크도 아니다. 그래도 주문서에는 이름과 가격이 한 줄로 찍혀야 하고, 합계에도 들어가야 한다.
클래스마다 주문서 코드를 따로 짜면 상품 종류가 늘 때마다 주문 처리 코드를 고쳐야 한다. 빵과 케이크가 등록 기록을 남기는 코드를 각각 복사해 두면, 나중에 기록 형식을 바꿀 때 한쪽을 빼먹기 쉽다. 파일도 문제다. 클래스가 열 개가 되면 require 문이 파일 맨 위에 줄줄이 늘어나고, 순서가 틀리면 실행이 멈춘다. 게다가 다른 사람이 만든 코드에도 Order라는 클래스가 있다면 이름이 부딪친다.
이 장은 이 네 가지 불편을 차례로 해결한다. 해결한 결과는 파일 여러 개로 이루어진 작은 프로그램이며, php main.php 한 번으로 실행된다.
인터페이스와 추상 클래스
인터페이스: 할 수 있는 일의 약속
인터페이스(interface)는 메서드의 이름, 매개변수, 반환 타입만 적어 둔 목록이다. 메서드의 내용은 적지 않는다. 클래스가 implements로 인터페이스를 선언하면, 그 목록의 메서드를 모두 만들어야 한다. 하나라도 빠지면 프로그램이 실행되기 전에 오류로 멈춘다.
이 약속이 있으면 주문 처리 코드는 "이 물건이 빵인가 케이크인가"를 따지지 않아도 된다. "이름과 가격을 돌려주는 물건"이라는 사실만 알면 되기 때문이다. 매개변수 타입에 인터페이스 이름을 쓰면 그 약속을 지키는 객체는 무엇이든 받는다.
추상 클래스: 공통 부분과 빈칸
추상 클래스(abstract class)는 일부만 만들어 둔 클래스다. abstract를 붙이면 new로 직접 객체를 만들 수 없다. 자식 클래스가 물려받아 빈칸을 채운 뒤에야 객체가 된다. 빈칸은 abstract가 붙은 메서드이며, 이름과 타입만 적고 내용은 적지 않는다. 추상 클래스는 프로퍼티와 내용이 있는 메서드도 가질 수 있다. 이 점이 인터페이스와 다르다.
빵과 케이크는 이름과 기본 가격을 갖고, 분류 이름만 다르다. 이름과 가격 처리는 추상 클래스 Product에 한 번만 쓰고, 분류 이름은 자식이 채우게 한다. 추상 클래스를 직접 만들려고 하면 Cannot instantiate abstract class App\Bakery\Product라는 오류가 난다.
그림에서 점선은 인터페이스를 구현하는 관계이고, 실선은 상속이다. GiftWrap은 Product를 거치지 않고 Sellable을 직접 구현한다. 주문은 Sellable만 알면 되므로 셋을 같은 목록에 담을 수 있다.
어느 쪽을 쓸까
클래스는 부모를 하나만 가질 수 있지만 인터페이스는 여러 개 구현할 수 있다. 그래서 다른 클래스와 함께 쓸 약속은 인터페이스로 만든다. 같은 종류끼리 코드를 나눠 쓰고 싶을 때는 추상 클래스를 쓴다. 이 장의 예제는 두 가지를 함께 쓴다. Sellable이 약속을 정하고 Product가 그 약속의 공통 구현을 제공한다.
| 구분 | 프로퍼티 | 한 클래스에 적용 | 주된 용도 |
|---|---|---|---|
| 인터페이스 | 가질 수 없다(상수만 가능) | 여러 개 구현 | 할 수 있는 일의 약속 |
| 추상 클래스 | 가질 수 있다 | 부모 하나만 상속 | 공통 구현과 채울 빈칸 |
| 트레이트 | 가질 수 있다 | 여러 개 사용 | 관련 없는 클래스가 코드를 공유 |
트레이트
트레이트(trait)는 메서드와 프로퍼티를 묶어 둔 코드 조각이다. 클래스 본문 안에서 use 트레이트이름;이라고 쓰면, 그 조각이 클래스 안에 그대로 들어온 것처럼 동작한다. 상속과 달리 부모 자식 관계가 생기지 않으므로 서로 관련 없는 클래스도 같은 트레이트를 쓸 수 있다.
이 장에서는 "일어난 일을 문자열로 남기고 나중에 꺼내 보는" 기능을 StockLog 트레이트로 만든다. 트레이트는 new로 객체를 만들 수 없고, 클래스에 붙여서만 쓴다. 트레이트 안의 프로퍼티는 그 트레이트를 쓰는 클래스마다 따로 복사되므로, 두 객체의 기록이 섞이지 않는다.
한 가지 헷갈리기 쉬운 점이 있다. 클래스 본문 안의 use는 트레이트를 붙이는 문장이고, 파일 맨 위의 use는 다른 네임스페이스의 클래스 이름을 가져오는 문장이다. 이름은 같지만 하는 일이 다르다.
네임스페이스, 오토로드, Composer
네임스페이스
네임스페이스(namespace)는 클래스 이름 앞에 붙는 주소다. 파일 맨 위에 namespace App\Bakery;라고 쓰면 그 파일의 클래스는 App\Bakery\Cake처럼 완전한 이름을 갖는다. 다른 곳에 Cake라는 클래스가 있어도 주소가 다르므로 부딪치지 않는다. 파일에서 namespace는 declare 다음에 오는 첫 문장이어야 한다.
같은 네임스페이스 안에서는 짧은 이름만 써도 된다. 다른 네임스페이스의 클래스는 완전한 이름을 쓰거나, 파일 위에서 use App\Format\Won;처럼 가져온 뒤 Won으로 줄여 쓴다. 이름을 바꿔 가져오려면 use App\Format\Won as Money;처럼 as를 붙인다.
주의할 규칙이 하나 있다. 네임스페이스 안에서 number_format 같은 함수 이름은 못 찾으면 전역 함수로 넘어가서 찾는다. 하지만 클래스 이름은 그렇지 않다. PHP가 처음부터 갖고 있는 DateTime 같은 클래스도 앞에 \를 붙이거나 use로 가져와야 한다.
오토로드
클래스를 쓰려면 그 클래스가 정의된 파일이 먼저 실행되어 있어야 한다. 오토로드(autoload)는 아직 정의되지 않은 클래스를 만났을 때 PHP가 대신 호출해 주는 함수를 등록하는 기능이다. spl_autoload_register()에 함수를 넘기면 된다. 그 함수는 찾는 클래스의 완전한 이름을 문자열로 받고, 이름을 파일 경로로 바꾸어 require한다. 이 장에서는 "App\ 뒤의 이름을 src/ 아래 경로로 읽는다"는 규칙을 쓴다.
| 완전한 이름 | App\ 제거 후 | 파일 경로 |
|---|---|---|
| App\Bakery\Cake | Bakery\Cake | src/Bakery/Cake.php |
| App\Bakery\Sellable | Bakery\Sellable | src/Bakery/Sellable.php |
| App\Format\Won | Format\Won | src/Format/Won.php |
Composer의 역할
Composer는 PHP의 패키지 관리 도구다. 이 책에서는 설치하지 않고 개념만 이해한다. Composer가 하는 일은 크게 두 가지다. 하나는 남이 만든 라이브러리를 내려받아 vendor/ 폴더에 두는 것이다. 다른 하나는 위에서 우리가 직접 쓴 것과 같은 오토로더를 자동으로 만들어 주는 것이다. 프로젝트 폴더의 composer.json에 "이 접두사는 이 폴더"라고 적어 두면 된다.
{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}
이렇게 적고 composer dump-autoload를 실행하면 vendor/autoload.php가 생긴다. 프로그램 시작 부분에서 그 파일을 require 하면 오토로더가 등록된다. 이 규칙(PSR-4)은 이 장에서 직접 만든 규칙과 같은 방식이다. 이 장에서 손으로 오토로더를 쓰는 것은 그 안에서 벌어지는 일을 눈으로 확인하기 위해서다. 자세한 내용은 Composer 공식 사이트와 PSR-4 규격에서 확인할 수 있다. 네임스페이스와 인터페이스의 정확한 문법은 PHP 매뉴얼의 네임스페이스 항목을 참고한다.
완성 코드
작업 폴더 bakery를 만들고 아래 구조로 파일 아홉 개를 둔다.
bakery/
main.php
src/
Bakery/
Sellable.php
StockLog.php
Product.php
Bread.php
Cake.php
GiftWrap.php
Order.php
Format/
Won.php
src/Bakery/Sellable.php
<?php
declare(strict_types=1);
namespace App\Bakery;
interface Sellable
{
public function name(): string;
public function price(): int;
}
src/Bakery/StockLog.php
<?php
declare(strict_types=1);
namespace App\Bakery;
trait StockLog
{
private array $log = [];
public function record(string $message): void
{
$this->log[] = $message;
}
public function history(): array
{
return $this->log;
}
}
src/Bakery/Product.php
<?php
declare(strict_types=1);
namespace App\Bakery;
abstract class Product implements Sellable
{
use StockLog;
public function __construct(
protected readonly string $name,
protected readonly int $basePrice,
) {
$this->record("등록: {$name}");
}
abstract public function category(): string;
public function name(): string
{
return $this->name;
}
public function price(): int
{
return $this->basePrice;
}
public function label(): string
{
return "[{$this->category()}] {$this->name()}";
}
}
src/Bakery/Bread.php
<?php
declare(strict_types=1);
namespace App\Bakery;
final class Bread extends Product
{
public function category(): string
{
return '빵';
}
}
src/Bakery/Cake.php
<?php
declare(strict_types=1);
namespace App\Bakery;
final class Cake extends Product
{
public function __construct(
string $name,
int $basePrice,
private readonly int $layers,
) {
parent::__construct($name, $basePrice);
}
public function category(): string
{
return '케이크';
}
public function price(): int
{
return $this->basePrice + $this->layers * 2000;
}
}
src/Bakery/GiftWrap.php
<?php
declare(strict_types=1);
namespace App\Bakery;
final class GiftWrap implements Sellable
{
public function name(): string
{
return '선물 포장';
}
public function price(): int
{
return 1500;
}
}
src/Bakery/Order.php
<?php
declare(strict_types=1);
namespace App\Bakery;
use App\Format\Won;
final class Order
{
private array $items = [];
public function add(Sellable $item): void
{
$this->items[] = $item;
}
public function total(): int
{
$sum = 0;
foreach ($this->items as $item) {
$sum += $item->price();
}
return $sum;
}
public function receipt(): array
{
$lines = [];
foreach ($this->items as $item) {
$lines[] = $item->name() . ' ' . Won::format($item->price());
}
return $lines;
}
}
src/Format/Won.php
<?php
declare(strict_types=1);
namespace App\Format;
final class Won
{
public static function format(int $amount): string
{
return number_format($amount) . '원';
}
}
main.php
<?php
declare(strict_types=1);
use App\Bakery\Bread;
use App\Bakery\Cake;
use App\Bakery\GiftWrap;
use App\Bakery\Order;
use App\Bakery\Product;
use App\Bakery\Sellable;
use App\Format\Won;
spl_autoload_register(function (string $class): void {
$prefix = 'App\\';
if (str_starts_with($class, $prefix) === false) {
return;
}
$relative = substr($class, strlen($prefix));
$path = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require $path;
}
});
$bread = new Bread('소금빵', 3200);
$cake = new Cake('딸기 케이크', 28000, 1);
$wrap = new GiftWrap();
$order = new Order();
$order->add($bread);
$order->add($cake);
$order->add($wrap);
foreach ($order->receipt() as $line) {
echo $line, PHP_EOL;
}
echo '합계 ', Won::format($order->total()), PHP_EOL;
echo $bread->label(), PHP_EOL;
echo $cake->label(), PHP_EOL;
echo $cake::class, PHP_EOL;
$cake->record('입고 5개');
echo implode(' | ', $cake->history()), PHP_EOL;
echo '선물 포장은 Product 인가: ', ($wrap instanceof Product ? '예' : '아니오'), PHP_EOL;
echo '선물 포장은 Sellable 인가: ', ($wrap instanceof Sellable ? '예' : '아니오'), PHP_EOL;
줄별 해설
Sellable.php와 StockLog.php
interface Sellable에는 메서드의 머리만 있고 중괄호 본문이 없다. 이 두 줄이 "이름과 가격을 돌려준다"는 약속의 전부다. namespace App\Bakery;는 이 인터페이스의 완전한 이름을 App\Bakery\Sellable로 만든다.
trait StockLog에는 배열 프로퍼티 $log와 메서드 두 개가 있다. record()는 문자열을 배열 끝에 붙이고, history()는 쌓인 배열을 돌려준다. 이 파일만으로는 아무것도 실행되지 않는다. 클래스가 use StockLog;로 붙여야 동작한다.
Product.php
abstract class Product implements Sellable은 이 클래스가 추상 클래스이고 Sellable의 약속을 지킨다는 뜻이다. name()과 price()를 이 클래스에서 만들어 두었으므로 약속은 지켜진다. 본문 첫 줄의 use StockLog;는 트레이트를 붙이는 문장이다.
생성자는 앞 장에서 배운 승격을 쓴다. protected readonly는 자식 클래스에서는 읽을 수 있고 바깥에서는 접근할 수 없으며, 한 번 정한 뒤 바꿀 수 없다는 뜻이다. 생성자 본문에서 record()를 호출해 등록 기록을 남긴다. 이 메서드는 트레이트에서 온 것이다.
abstract public function category(): string;이 빈칸이다. 본문이 없고 자식이 반드시 채워야 한다. label()은 그 빈칸을 호출하는 공통 메서드다. 자식이 무엇을 채울지 몰라도 category()를 부를 수 있다는 점이 추상 클래스의 요점이다.
Bread.php와 Cake.php
final은 이 클래스를 다시 상속할 수 없게 막는다. Bread는 category()만 채운다. 생성자는 정의하지 않았으므로 부모 것을 그대로 쓴다.
Cake는 층수 $layers를 더 받는다. 자기 생성자를 만들면 부모 생성자는 자동으로 호출되지 않으므로 parent::__construct()로 직접 부른다. $name과 $basePrice에는 승격 표시를 붙이지 않았다. 부모가 이미 프로퍼티로 만들었기 때문이다. price()는 부모의 것을 다시 정의하여 층당 2000원을 더한다. 자식이 같은 이름의 메서드를 다시 쓰는 것을 재정의(override)라고 한다.
GiftWrap.php
GiftWrap은 Product와 무관하게 Sellable만 구현한다. 재고 기록도, 분류도 없다. 이름과 가격만 있으면 주문에 넣을 수 있다.
Order.php와 Won.php
Order는 Sellable 타입의 물건을 add()로 받는다. 빵, 케이크, 포장이 모두 이 타입 조건을 만족하므로 한 배열에 들어간다. total()과 receipt()는 name()과 price()만 부른다. 상품 종류가 늘어도 이 클래스는 바뀌지 않는다.
파일 위쪽 use App\Format\Won;은 다른 네임스페이스의 클래스를 짧은 이름으로 쓰겠다는 선언이다. Won::format()의 ::는 객체 없이 클래스에 속한 메서드(static 메서드)를 부르는 문법이다. Won 안의 number_format()은 네임스페이스 안에 없지만, 함수는 전역으로 넘어가서 찾기 때문에 \ 없이 쓸 수 있다. 이 함수는 천 단위 쉼표를 넣어 준다.
main.php
맨 위의 use 일곱 줄은 이 파일에서 쓸 클래스를 짧은 이름으로 가져온다. 이 줄들은 클래스를 불러오지 않는다. 이름만 연결해 둘 뿐이다.
spl_autoload_register()에 넘긴 함수가 오토로더다. 클래스 이름이 App\로 시작하지 않으면 우리와 상관없는 클래스이므로 바로 돌아간다. 시작하면 접두사를 잘라 내고, 남은 이름의 \를 /로 바꾸어 src/ 아래 경로를 만든다. 파일이 있으면 require한다. __DIR__은 main.php가 있는 폴더의 경로다.
new Bread(...)가 처음 실행될 때 Bread는 아직 정의되어 있지 않다. PHP가 오토로더를 호출하고, Bread.php가 실행되는 도중에 부모 Product와 인터페이스 Sellable이 필요해지면 오토로더가 다시 호출된다. 사람이 require 순서를 신경 쓰지 않아도 되는 이유다.
이후 세 물건을 주문에 담아 영수증 줄과 합계를 찍는다. $cake::class는 객체가 속한 클래스의 완전한 이름 문자열이다. 마지막 두 줄의 instanceof는 객체가 그 클래스 또는 인터페이스에 해당하는지 검사한다. GiftWrap은 Product가 아니지만 Sellable이다.
실행 결과
$ cd bakery
$ php main.php
소금빵 3,200원
딸기 케이크 30,000원
선물 포장 1,500원
합계 34,700원
[빵] 소금빵
[케이크] 딸기 케이크
App\Bakery\Cake
등록: 딸기 케이크 | 입고 5개
선물 포장은 Product 인가: 아니오
선물 포장은 Sellable 인가: 예
딸기 케이크의 가격은 기본 28,000원에 층당 2,000원 한 층을 더한 30,000원이다. 합계는 3,200 + 30,000 + 1,500 = 34,700원이다.
실무에서 자주 틀리는 것
인터페이스의 메서드를 빠뜨린다
인터페이스를 구현하겠다고 선언해 놓고 메서드 하나를 빠뜨리면, 클래스를 처음 불러오는 순간 Fatal error로 프로그램이 멈춘다. 오류 메시지에는 빠진 메서드 이름이 나온다.
// 틀린 코드
final class Muffin implements Sellable
{
public function name(): string
{
return '머핀';
}
}
// 고친 코드
final class Muffin implements Sellable
{
public function name(): string
{
return '머핀';
}
public function price(): int
{
return 2800;
}
}
네임스페이스 안에서 기본 제공 클래스 앞의 역슬래시를 빼먹는다
네임스페이스가 있는 파일에서 DateTime이라고만 쓰면 PHP는 App\Bakery\DateTime을 찾고, 없으므로 "Class not found" 오류를 낸다.
// 틀린 코드 (namespace App\Bakery; 아래에서)
$pickup = new DateTime('2026-01-05');
// 고친 코드 1: 전역 이름임을 표시
$pickup = new \DateTime('2026-01-05');
// 고친 코드 2: 파일 위에서 가져오기
use DateTime;
$pickup = new DateTime('2026-01-05');
파일 이름의 대소문자가 클래스 이름과 다르다
macOS는 기본 설정에서 대소문자를 구분하지 않아서 cake.php도 Cake.php처럼 열린다. 그러나 대부분의 Linux 서버는 구분한다. 내 컴퓨터에서 잘 돌던 코드가 서버에서만 클래스를 못 찾는 이유가 여기에 있다. 오토로더는 클래스 이름을 그대로 경로로 바꾸므로 파일 이름을 클래스 이름과 똑같이 맞춘다.
// 틀린 구성
src/Bakery/cake.php (클래스 이름은 Cake)
// 고친 구성
src/Bakery/Cake.php
트레이트를 상속하려 한다
트레이트는 부모 클래스가 아니다. extends 뒤에 쓰면 오류가 난다. 클래스 본문 안에서 use로 붙인다.
// 틀린 코드
final class Order extends StockLog
{
}
// 고친 코드
final class Order
{
use StockLog;
}
한눈에 보기
| 키워드 | 하는 일 | 이 장의 예 |
|---|---|---|
| interface / implements | 메서드 약속을 정하고 따르게 한다 | Sellable, GiftWrap |
| abstract | 직접 만들 수 없는 클래스, 자식이 채울 메서드 | Product, category() |
| extends / final | 상속한다 / 더 이상 상속하지 못하게 막는다 | Bread, Cake |
| trait / use (클래스 안) | 코드 조각을 만들고 클래스에 붙인다 | StockLog |
| namespace | 클래스에 주소를 붙인다 | App\Bakery |
| use (파일 위) | 다른 주소의 클래스를 짧은 이름으로 가져온다 | use App\Format\Won; |
| spl_autoload_register | 클래스를 처음 쓸 때 파일을 불러오는 함수를 등록한다 | main.php의 오토로더 |
| Composer | 패키지를 내려받고 오토로더를 만들어 준다 | 개념만 다룸 |
연습 문제
- 할인 쿠폰 클래스
Coupon을 만들려고 한다. 이름은 "쿠폰"이고 가격은 -500이다.Sellable을 구현하고Order에 담으면 합계가 어떻게 되는지 설명하고,Order클래스의 코드를 고쳐야 하는지 답하라. - 주문의 변경 기록을 남기고 싶어서
Order에도StockLog를 붙이려 한다. 어디에 어떤 문장을 쓰는가?Order와StockLog는 같은 네임스페이스에 있다. App\Format\Percent클래스를 새로 만들려 한다. 이 장의 오토로더가 찾을 수 있는 파일 경로를 쓰고,main.php에서 짧은 이름Percent로 쓰기 위해 필요한 문장을 써라.- 다음 한 줄을
main.php에 넣으면 어떤 일이 일어나는가?$p = new Product('빵', 1000);
정답과 해설
- 합계에서 500원이 빠진다.
Order::total()은 각 물건의price()를 더할 뿐이고, 음수도 그대로 더해지기 때문이다.Order는 고칠 필요가 없다.Order가Sellable타입만 요구하므로 약속을 지키는 새 클래스는 그대로 들어온다. 예를 들어final class Coupon implements Sellable에name()은 '쿠폰'을,price()는 -500을 돌려주게 만들면 된다. Order클래스 본문 안에use StockLog;를 쓴다. 같은 네임스페이스이므로 파일 위에use로 가져올 필요가 없다. 이 문장 하나로record()와history()를 쓸 수 있다.- 경로는
src/Format/Percent.php이고, 파일 안에는namespace App\Format;과class Percent가 있어야 한다.main.php의 맨 위에는use App\Format\Percent;를 쓴다. - 추상 클래스는 직접 객체를 만들 수 없으므로
Cannot instantiate abstract class App\Bakery\Product오류가 나면서 프로그램이 멈춘다.Bread나Cake처럼 빈칸을 채운 자식 클래스로 만들어야 한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.