Devin.KR

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 가 잠그는 것은 속성이 가리키는 값 자체다. 속성이 배열이면 배열을 통째로 바꾸거나 원소를 추가하는 것도 막힌다. 반면 속성이 다른 객체를 가리킨다면 그 객체 자신의 속성이 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)이라 하고, 저장하거나 요청으로 받은 문자열과 오갈 때 쓴다.

enum 이 제공하는 기본 도구는 값을 항목으로, 항목을 값으로 바꾼다
표현돌려주는 것없는 값일 때
Category::Cake->value백킹 값 'cake'해당 없음
Category::from('cake')Category::Cake오류(ValueError)로 멈춤
Category::tryFrom('cake')Category::Cakenull
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 (인수 자리)속성 선언과 대입을 한 번에 쓴다
readonlypublic readonly int $price생성 뒤 값이 바뀌지 않게 잠근다
정적 멤버self::$issued, Order::issued()클래스 전체가 공유하는 값과 동작
클래스 상수Order::FREE_DELIVERY_FROM바뀌지 않는 기준값에 이름을 붙인다
enumCategory::Cake, tryFrom()허용되는 선택지를 타입으로 고정한다

연습 문제

  1. Product 에 priceLabel(): string 메서드를 추가해, 소금빵이면 3,500원 을 돌려주게 하라. 이 메서드는 won() 함수를 쓰지 않고 number_format() 을 직접 쓴다.
  2. 받는 방법을 나타내는 enum Pickup 을 만들어라. 항목은 Store(백킹 값 'store')와 Delivery(백킹 값 'delivery')이고, 한글 이름을 돌려주는 label() 메서드를 가진다. 'delivery' 문자열에서 항목을 얻어 이름을 출력하는 코드도 써라.
  3. 다음 코드는 왜 오류가 나는지 설명하고, 할인율을 바꾸는 withPercent() 메서드로 고쳐라.
    final class Coupon
    {
        public function __construct(public readonly int $percent)
        {
        }
    }
    
    $coupon = new Coupon(10);
    $coupon->percent = 20;
  4. Product 를 몇 개 만들었는지 세는 정적 메서드 Product::count() 를 추가하려 한다. 어떤 속성이 필요하고 어디에서 값을 올려야 하는가. 이 장의 Product 클래스에 그대로 넣어도 되는지도 답하라.

정답과 해설

  1. 메서드 안에서 $this->price 를 읽는다.
    public function priceLabel(): string
    {
        return number_format($this->price) . '원';
    }

    객체의 값을 가공해서 보여 주는 동작은 데이터 옆에 메서드로 두면 호출하는 쪽이 간단해진다.

  2. 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 을 처리한다.

  3. 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 을 가진다.

  4. 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

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.