PHP · 기본
웹으로 시작하는 PHP
클래스와 객체 - 생성자 승격과 readonly
클래스·속성·메서드, 생성자 승격, readonly, 정적 멤버, enum
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장까지는 값을 변수나 배열에 담고, 함수로 계산했다. 이 장에서는 관련 있는 값과 그 값을 다루는 동작을 하나로 묶는 클래스(class)를 배운다. 빵집 예약 주문에 나오는 상품, 주문 한 줄, 주문서를 클래스로 만들고, 잘못된 값이 끼어드는 일을 문법으로 막아 본다.
- 클래스로 객체(object)를 만들고 속성(property)과 메서드(method)를 정의한다.
- 생성자 승격(constructor promotion)으로 속성 선언과 대입을 한 번에 쓴다.
- readonly 로 생성 뒤에 바뀌지 않는 속성과 클래스를 만든다.
- 정적 멤버(static)와 클래스 상수로 객체 하나가 아닌 클래스 전체에 속한 값을 다룬다.
- enum 으로 정해진 선택지를 안전하게 표현한다.
문제 상황
빵집 사장님이 예약 주문을 받기 시작했다. 앞 장까지의 방식대로 상품을 연관 배열로 표현하면 다음과 같다.
$item = ['name' => '소금빵', 'price' => 3500, 'category' => 'bread'];
echo $item['prcie']; // 키 이름을 잘못 썼다
$item['price'] = '삼천오백'; // 가격에 문자열이 들어갔다
$item['category'] = 'Bread'; // 대소문자가 다르다
첫 줄의 오타는 경고(Warning)를 내고 null 을 돌려줄 뿐 프로그램을 멈추지 않는다. 둘째 줄처럼 가격이 문자열로 바뀌어도 배열은 아무 말이 없다. 분류를 문자열로 들고 다니면 'bread', 'Bread', '빵' 이 뒤섞이고, 어떤 값이 허용되는지는 코드 어디에도 적혀 있지 않다. 주문 코드가 수십 줄만 넘어가도 "이 배열에 무엇이 들어 있는가"를 매번 추적해야 한다.
클래스는 이 문제를 세 가지로 해결한다. 값의 이름과 타입을 한곳에 선언하고, 바꾸면 안 되는 값은 바꾸지 못하게 잠그며, 고를 수 있는 값의 목록은 enum 으로 고정한다.
클래스와 객체
클래스는 설계도이고 객체는 그 설계도로 만든 실물이다. 객체를 인스턴스(instance)라고도 부른다. 상품 클래스 하나로 소금빵, 딸기 케이크, 버터 쿠키 객체를 각각 만들 수 있고, 각 객체는 자기만의 값을 가진다.
속성과 메서드
클래스 안에 선언한 변수를 속성, 함수를 메서드라고 한다. 객체를 만들 때는 new 를 쓰고, 속성과 메서드에는 화살표 -> 로 접근한다. 메서드 안에서 "지금 이 객체"를 가리키는 이름이 $this 다.
final class Counter
{
public int $count = 0;
public function up(): void
{
$this->count++;
}
}
$c = new Counter();
$c->up();
$c->up();
echo $c->count; // 2
클래스 앞의 final 은 "이 클래스를 물려받아 확장하지 못한다"는 표시다. 이 책에서는 상속을 다루지 않으므로 기본으로 붙여 둔다.
속성 앞의 public, private 는 접근 범위를 정한다. 접근 범위에 따라 클래스 밖에서 할 수 있는 일이 달라진다.
| 선언 | 밖에서 읽기 | 밖에서 쓰기 | 어울리는 값 |
|---|---|---|---|
public | 가능 | 가능 | 자유롭게 바뀌어도 되는 값 |
private | 불가 | 불가 | 클래스 내부에서만 쓰는 상태 |
public readonly | 가능 | 불가 | 만든 뒤 바뀌지 않는 값 |
클래스 밖에서 private 속성을 읽거나 쓰면 오류(Error)가 나서 프로그램이 멈춘다. 예외를 다루는 방법은 뒤에서 배우므로, 지금은 "오류가 나면 실행이 멈추고 메시지가 나온다"로 이해하면 된다.
생성자 승격
생성자(constructor)는 new 로 객체를 만들 때 자동으로 실행되는 메서드이며 이름은 __construct 로 정해져 있다. 전통적인 방식은 속성을 선언하고, 생성자 인수를 받고, 다시 대입하는 세 단계를 거친다.
final class Product
{
public string $name;
public int $price;
public function __construct(string $name, int $price)
{
$this->name = $name;
$this->price = $price;
}
}
생성자 승격은 인수 앞에 접근 범위를 붙이면 그 인수가 곧 속성이 되는 문법이다. 위 코드는 다음과 같이 줄어든다.
final class Product
{
public function __construct(
public string $name,
public int $price,
) {
}
}
속성 선언과 대입 코드가 사라지고 이름과 타입은 한 번만 적힌다. 승격을 쓴 인수는 클래스 본문에 같은 이름의 속성을 다시 선언하면 안 된다. 그리고 new Product(name: '소금빵', price: 3500) 처럼 앞 장에서 배운 명명 인수도 그대로 쓸 수 있다.
readonly 와 정적 멤버
readonly: 만든 뒤에는 바꾸지 않는다
속성에 readonly 를 붙이면 값을 한 번 정한 뒤에는 바꿀 수 없다. 상품 이름이나 주문 번호처럼 만든 뒤 바뀌면 곤란한 값에 쓴다. 값을 바꾸고 싶다면 기존 객체를 고치지 않고, 값만 다른 새 객체를 만든다.
속성마다 붙이는 대신 클래스 전체에 readonly 를 붙일 수도 있다. 이러면 모든 속성이 readonly 가 되고, 나중에 속성을 임의로 덧붙이는 것도 막힌다. 다만 readonly 클래스에는 정적 속성을 둘 수 없다.
readonly 가 잠그는 것은 속성이 가리키는 값 자체다. 속성이 배열이면 배열을 통째로 바꾸거나 원소를 추가하는 것도 막힌다. 반면 속성이 다른 객체를 가리킨다면 그 객체 자신의 속성이 readonly 가 아닌 한 안쪽은 바뀔 수 있다.
정적 멤버와 클래스 상수
속성과 메서드는 보통 객체마다 따로 있다. static 을 붙이면 객체가 아닌 클래스에 하나만 있고, 모든 객체가 공유한다. 접근할 때는 :: 를 쓴다. 클래스 안에서는 self:: 로 자기 클래스를 가리킨다. 정적 메서드에는 "지금 이 객체"가 없으므로 $this 를 쓸 수 없다.
바뀌지 않는 값은 클래스 상수(const)로 둔다. 무료 배송 기준 금액처럼 프로그램 전체에서 하나의 뜻으로 쓰이는 값이 대표적이다. 상수에도 타입을 적을 수 있다.
public const int FREE_DELIVERY_FROM = 30000;
echo Order::FREE_DELIVERY_FROM; // 클래스 밖에서는 클래스이름::상수
enum: 정해진 선택지
enum(열거형)은 가능한 값의 목록을 직접 나열해 만드는 타입이다. 상품 분류는 빵, 케이크, 쿠키 중 하나여야 하므로 enum 이 알맞다. 각 항목은 case 로 선언하고 Category::Cake 처럼 쓴다. 이 항목 하나하나도 객체이며, 같은 항목은 언제나 같은 객체이므로 === 로 비교하면 된다.
enum 에 : string 을 붙이면 각 항목에 문자열 값을 연결할 수 있다. 이를 백킹 값(backing value)이라 하고, 저장하거나 요청으로 받은 문자열과 오갈 때 쓴다.
| 표현 | 돌려주는 것 | 없는 값일 때 |
|---|---|---|
Category::Cake->value | 백킹 값 'cake' | 해당 없음 |
Category::from('cake') | Category::Cake | 오류(ValueError)로 멈춤 |
Category::tryFrom('cake') | Category::Cake | null |
Category::cases() | 모든 항목의 배열 | 해당 없음 |
enum 은 메서드도 가질 수 있다. 항목마다 다른 값을 돌려주려면 앞에서 배운 match 를 쓴다. 이때 항목을 하나라도 빠뜨리면 실행 중 오류가 나므로, 새 항목을 추가하고 처리를 잊는 실수를 일찍 알아챌 수 있다.
완성 코드
하나의 파일로 상품, 주문 한 줄, 주문서를 만들고 두 건의 주문을 출력한다.
main.php
<?php
declare(strict_types=1);
enum Category: string
{
case Bread = 'bread';
case Cake = 'cake';
case Cookie = 'cookie';
public function label(): string
{
return match ($this) {
self::Bread => '빵',
self::Cake => '케이크',
self::Cookie => '쿠키',
};
}
}
final class Product
{
public function __construct(
public readonly string $name,
public readonly int $price,
public readonly Category $category,
) {
}
public function withPrice(int $price): self
{
return new self($this->name, $price, $this->category);
}
}
final readonly class OrderLine
{
public function __construct(
public Product $product,
public int $quantity = 1,
) {
}
public function subtotal(): int
{
return $this->product->price * $this->quantity;
}
}
final class Order
{
public const int FREE_DELIVERY_FROM = 30000;
public const int DELIVERY_FEE = 3000;
private static int $issued = 0;
public readonly int $number;
private array $lines = [];
public function __construct(public readonly string $customer)
{
self::$issued++;
$this->number = self::$issued;
}
public static function issued(): int
{
return self::$issued;
}
public function add(Product $product, int $quantity = 1): void
{
$this->lines[] = new OrderLine($product, $quantity);
}
public function lines(): array
{
return $this->lines;
}
public function itemsTotal(): int
{
$sum = 0;
foreach ($this->lines as $line) {
$sum += $line->subtotal();
}
return $sum;
}
public function deliveryFee(): int
{
return $this->itemsTotal() >= self::FREE_DELIVERY_FROM ? 0 : self::DELIVERY_FEE;
}
public function payable(): int
{
return $this->itemsTotal() + $this->deliveryFee();
}
}
function won(int $amount): string
{
return number_format($amount) . '원';
}
function printOrder(Order $order): void
{
echo "주문 #{$order->number} {$order->customer}", PHP_EOL;
foreach ($order->lines() as $line) {
echo "- {$line->product->name} x {$line->quantity} = ", won($line->subtotal()), PHP_EOL;
}
echo '상품 합계 ', won($order->itemsTotal()), ', 배송비 ', won($order->deliveryFee()), PHP_EOL;
echo '결제 금액 ', won($order->payable()), PHP_EOL;
}
$bread = new Product('소금빵', 3500, Category::Bread);
$cake = new Product(name: '딸기 케이크', price: 32000, category: Category::Cake);
$cookie = new Product('버터 쿠키', 4500, Category::Cookie);
$first = new Order('김하늘');
$first->add($bread, 4);
$first->add($cookie);
$first->add($cake);
$second = new Order('이서준');
$second->add($bread, 2);
printOrder($first);
echo PHP_EOL;
printOrder($second);
echo PHP_EOL;
$sale = $cake->withPrice(28000);
echo "{$cake->name}: ", won($cake->price), ' -> ', won($sale->price), PHP_EOL;
echo '같은 객체인가: ', $cake === $sale ? '예' : '아니오', PHP_EOL;
$labels = [];
foreach (Category::cases() as $category) {
$labels[] = $category->label();
}
echo '분류: ', implode(', ', $labels), PHP_EOL;
echo $cake->category->label(), ' / ', $cake->category->value, PHP_EOL;
echo Category::from('cookie')->label(), PHP_EOL;
echo Category::tryFrom('pizza')?->label() ?? '없는 분류', PHP_EOL;
echo '발급된 주문 수: ', Order::issued(), PHP_EOL;
echo '무료 배송 기준: ', won(Order::FREE_DELIVERY_FROM), PHP_EOL;
줄별 해설
enum Category: string은 세 항목과 백킹 값을 정의한다.label()안의match ($this)에서$this는 지금 호출된 항목 자신이다.Product는 생성자 승격과readonly를 함께 쓴다. 인수 세 개가 곧 속성 세 개이며, 생성 뒤에는 바꿀 수 없다.withPrice()는 자기 값을 고치지 않고 가격만 다른 새Product를 만들어 돌려준다. 클래스 안에서 자기 클래스를 만들 때는new self(...)를 쓴다.final readonly class OrderLine은 클래스에 readonly 를 붙였으므로 승격된 속성 두 개에 따로 붙이지 않아도 된다.$quantity = 1은 인수를 생략했을 때의 기본값이다.Order의const두 줄은 무료 배송 기준과 배송비를 정한다. 값을 바꿀 일이 생기면 이 두 줄만 고치면 된다.private static int $issued는 지금까지 만든 주문 수를 세는 카운터다. 객체마다 있는 값이 아니라 클래스에 하나만 있으므로self::$issued로 접근한다. 정적 속성 앞에는$가 붙는다는 점에 주의한다.public readonly int $number;는 선언만 하고 값을 넣지 않았다. 생성자 안에서 한 번 대입할 수 있고, 그 뒤에는 바뀌지 않는다. 카운터를 올린 뒤 그 값을 주문 번호로 쓰므로 주문마다 번호가 다르다.private array $lines는 클래스 밖에서 직접 건드리지 못한다. 줄을 추가하려면add()를 거쳐야 하고, 읽으려면lines()를 쓴다.lines()가 돌려주는 배열은 복사본이므로 그 배열을 고쳐도 주문서 안쪽은 변하지 않는다.public static function issued()는 정적 메서드다. 객체 없이Order::issued()로 부른다.deliveryFee()는 상품 합계가 기준 이상이면 0, 아니면 배송비를 돌려준다. 클래스 상수는 클래스 안에서self::로 쓴다.won()은number_format()으로 세 자리마다 쉼표를 찍고 "원"을 붙인다. 클래스가 아니라 일반 함수여도 클래스 객체를 인수로 받는 함수와 자연스럽게 함께 쓸 수 있다.printOrder()의 큰따옴표 문자열 안에서{$line->product->name}처럼 중괄호로 감싸면 객체의 속성을 문자열에 바로 넣을 수 있다.new Product(name: '딸기 케이크', ...)는 명명 인수로 생성자를 부른다. 나머지 두 상품은 위치 인수를 썼는데, 두 방식은 같은 결과를 낸다.$cake === $sale은 두 변수가 같은 객체를 가리키는지 묻는다. 내용이 비슷해도 서로 다른 객체이므로 결과는 거짓이다.Category::tryFrom('pizza')?->label()에서tryFrom은null을 돌려주고,?->는 앞이null이면 메서드를 부르지 않고null을 돌려준다. 그 결과를??가 기본 문구로 바꾼다.
실행 결과
$ php main.php
주문 #1 김하늘
- 소금빵 x 4 = 14,000원
- 버터 쿠키 x 1 = 4,500원
- 딸기 케이크 x 1 = 32,000원
상품 합계 50,500원, 배송비 0원
결제 금액 50,500원
주문 #2 이서준
- 소금빵 x 2 = 7,000원
상품 합계 7,000원, 배송비 3,000원
결제 금액 10,000원
딸기 케이크: 32,000원 -> 28,000원
같은 객체인가: 아니오
분류: 빵, 케이크, 쿠키
케이크 / cake
쿠키
없는 분류
발급된 주문 수: 2
무료 배송 기준: 30,000원
실무에서 자주 틀리는 것
readonly 속성에 직접 대입한다
할인가를 적용하려고 속성을 바로 고치면 실행 중에 멈춘다.
$cake->price = 28000;
// Error: Cannot modify readonly property Product::$price
값을 바꾸는 메서드를 만들어 새 객체를 돌려받는다.
$sale = $cake->withPrice(28000);
승격한 속성을 본문에서 다시 선언한다
승격을 쓰면서 예전 습관대로 속성을 한 번 더 적으면 프로그램이 시작되기 전에 실패한다.
final class Product
{
public string $name; // 중복
public function __construct(public string $name)
{
}
}
// Fatal error: Cannot redeclare Product::$name
승격한 인수는 본문에서 선언하지 않는다.
final class Product
{
public function __construct(public string $name)
{
}
}
정적 메서드에서 $this 를 쓴다
정적 메서드는 객체 없이 부르므로 $this 가 가리킬 대상이 없다.
public static function issued(): int
{
return $this->issued;
}
// Error: Using $this when not in object context
정적 속성은 self:: 로 접근한다.
public static function issued(): int
{
return self::$issued;
}
외부에서 받은 문자열을 from() 에 그대로 넘긴다
from() 은 목록에 없는 값을 받으면 오류로 멈춘다. 사용자가 보낸 값은 언제든 예상과 다를 수 있다.
$category = Category::from($input); // $input 이 'pizza' 이면 ValueError
tryFrom() 으로 받고 null 인 경우를 직접 처리한다.
$category = Category::tryFrom($input);
if ($category === null) {
echo '없는 분류다.';
}
한눈에 보기
| 문법 | 예 | 쓰임새 |
|---|---|---|
| 클래스와 객체 | new Product(...) | 설계도로 값이 다른 객체를 만든다 |
| 속성·메서드 접근 | $p->name, $p->withPrice(1) | 객체의 값을 읽고 동작을 부른다 |
| 생성자 승격 | public string $name (인수 자리) | 속성 선언과 대입을 한 번에 쓴다 |
| readonly | public readonly int $price | 생성 뒤 값이 바뀌지 않게 잠근다 |
| 정적 멤버 | self::$issued, Order::issued() | 클래스 전체가 공유하는 값과 동작 |
| 클래스 상수 | Order::FREE_DELIVERY_FROM | 바뀌지 않는 기준값에 이름을 붙인다 |
| enum | Category::Cake, tryFrom() | 허용되는 선택지를 타입으로 고정한다 |
연습 문제
Product에priceLabel(): string메서드를 추가해, 소금빵이면3,500원을 돌려주게 하라. 이 메서드는won()함수를 쓰지 않고number_format()을 직접 쓴다.- 받는 방법을 나타내는 enum
Pickup을 만들어라. 항목은Store(백킹 값'store')와Delivery(백킹 값'delivery')이고, 한글 이름을 돌려주는label()메서드를 가진다.'delivery'문자열에서 항목을 얻어 이름을 출력하는 코드도 써라. - 다음 코드는 왜 오류가 나는지 설명하고, 할인율을 바꾸는
withPercent()메서드로 고쳐라.final class Coupon { public function __construct(public readonly int $percent) { } } $coupon = new Coupon(10); $coupon->percent = 20; Product를 몇 개 만들었는지 세는 정적 메서드Product::count()를 추가하려 한다. 어떤 속성이 필요하고 어디에서 값을 올려야 하는가. 이 장의Product클래스에 그대로 넣어도 되는지도 답하라.
정답과 해설
- 메서드 안에서
$this->price를 읽는다.public function priceLabel(): string { return number_format($this->price) . '원'; }객체의 값을 가공해서 보여 주는 동작은 데이터 옆에 메서드로 두면 호출하는 쪽이 간단해진다.
- enum 과 사용 코드는 다음과 같다.
enum Pickup: string { case Store = 'store'; case Delivery = 'delivery'; public function label(): string { return match ($this) { self::Store => '매장 수령', self::Delivery => '배달', }; } } echo Pickup::from('delivery')->label(); // 배달입력값이 확실하지 않다면
from()대신tryFrom()을 쓰고null을 처리한다. percent가 readonly 이므로 객체를 만든 뒤에는 대입할 수 없다. 새 객체를 돌려주는 메서드를 추가한다.final class Coupon { public function __construct(public readonly int $percent) { } public function withPercent(int $percent): self { return new self($percent); } } $coupon = new Coupon(10); $changed = $coupon->withPercent(20);$coupon은 10 을 유지하고$changed만 20 을 가진다.private static int $created = 0;속성이 필요하고, 생성자 본문에서self::$created++;로 올린다. 메서드는return self::$created;를 돌려주는public static function count(): int다. 이 장의Product는 속성마다readonly를 붙였을 뿐 클래스 자체는 readonly 가 아니므로 정적 속성을 넣어도 된다. 만약final readonly class Product로 바꾸면 정적 속성을 둘 수 없으므로, 카운터는 다른 클래스로 옮겨야 한다.
공식 문서에서 각 문법의 정확한 규칙을 확인할 수 있다. 클래스와 객체는 PHP 매뉴얼의 클래스와 객체 항목, enum 은 열거형 항목을 참고한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.