Devin.KR

PHP · 심화

설계와 보안으로 깊어지는 PHP

열거형과 값 객체 - 잘못된 상태를 못 만들게

backed enum 과 메서드, enum 의 tryFrom, readonly 클래스, 값 객체 동등성, 생성 시 검증

개발자KR · 원고 갱신

이 장에서 배우는 것

수강 신청 시스템에서 가장 자주 틀어지는 곳은 알고리즘이 아니다. 상태를 나타내는 문자열의 철자, 음수로 들어온 금액, 하이픈이 섞인 전화번호처럼 값이 잘못된 채로 시스템 깊숙이 들어가는 일이 더 흔하다. 이 장에서는 그런 값을 만들어지는 순간에 막는 두 가지 도구를 다룬다. 하나는 정해진 선택지를 타입으로 만드는 열거형(enumeration)이고, 다른 하나는 값 자체를 객체로 감싸 규칙을 안에 넣는 값 객체(value object)다. 앞 장에서 다룬 엄격 모드와 타입 선언이 "이 자리에 어떤 타입이 오는가"를 지켜 주었다면, 이 장은 "그 타입 안에서 어떤 값이 허용되는가"를 지킨다.

  • backed enum 에 메서드를 붙여 상태별 규칙을 한곳에 모을 수 있다.
  • from() 과 tryFrom() 의 차이를 알고 외부 입력에는 tryFrom() 을 쓸 수 있다.
  • readonly 클래스로 생성 후 바뀌지 않는 객체를 만들 수 있다.
  • 생성자에서 값을 검증해 잘못된 객체가 존재하지 못하게 할 수 있다.
  • 값 객체의 동등성을 ==, ===, equals() 로 구분해 설명할 수 있다.

문제 상황

동네 학원의 수강 신청 코드가 다음처럼 작성되어 있다고 하자. 기본서의 방식대로 상태는 문자열, 수강료는 정수, 전화번호는 문자열이다.

$enrollment = [
    'name'   => '김하늘',
    'phone'  => '010-1234-5678',
    'fee'    => 45000,
    'status' => 'confirmd',   // 오타
];

if ($enrollment['status'] === 'confirmed') {
    // 오타 때문에 이 블록은 실행되지 않는다. 오류도 나지 않는다.
}

이 코드에는 세 가지 문제가 있다. 첫째, 'confirmd' 같은 오타는 문법 오류가 아니므로 PHP 가 아무 말 없이 지나간다. 문제는 한참 뒤, 확정 목록에 수강생이 빠져 있다는 문의로 드러난다. 둘째, fee 가 정수이기만 하면 -45000 도 통과한다. 셋째, 같은 전화번호가 010-1234-5678, 01012345678, 010 1234 5678 로 저장되면 중복 신청을 찾을 때 서로 다른 값으로 취급된다.

검증 코드를 곳곳에 흩어 놓으면 어디선가 하나는 빠진다. 접근을 뒤집는 편이 낫다. 잘못된 값은 아예 만들어지지 않게 하고, 만들어진 값은 바뀌지 않게 한다. 그러면 그 타입의 값을 받은 함수는 다시 검사하지 않아도 된다.

열거형: 정해진 선택지를 타입으로

열거형은 가능한 값의 목록을 닫힌 집합으로 선언한다. 수강 신청 상태는 대기, 확정, 취소 셋뿐이므로 다음처럼 쓸 수 있다.

enum EnrollmentStatus: string
{
    case Pending = 'pending';
    case Confirmed = 'confirmed';
    case Cancelled = 'cancelled';
}

: string 을 붙인 열거형을 backed enum 이라 한다. 각 case 는 이름(name)과 함께 저장·전송에 쓸 값(value)을 가진다. 데이터베이스 열이나 요청 파라미터에는 문자열 'confirmed' 가 오가고, 프로그램 안에서는 EnrollmentStatus::Confirmed 가 오간다. 값이 정수라면 : int 를 쓴다. 값 없이 enum Suit { ... } 로 선언하는 pure enum 도 있지만, 바깥 세계와 주고받아야 하는 상태에는 backed enum 이 맞다.

case 는 클래스 인스턴스와 비슷하지만 항상 하나만 존재하는 싱글턴이다. 그래서 === 로 비교해도 되고, 오타를 내면 EnrollmentStatus::Confirmd 는 곧바로 오류가 된다. 매개변수에 EnrollmentStatus $status 를 선언하면 문자열이 들어오는 것도 엄격 모드에서 막힌다.

메서드로 규칙을 붙인다

열거형은 메서드와 상수를 가질 수 있다. 다만 인스턴스 상태, 즉 변하는 프로퍼티는 가질 수 없다. 상태마다 달라지는 규칙은 match 로 쓰면 읽기 쉽다.

public function label(): string
{
    return match ($this) {
        self::Pending => '대기',
        self::Confirmed => '확정',
        self::Cancelled => '취소',
    };
}

match 는 모든 경우를 다루지 않으면 UnhandledMatchError 를 던진다. case 를 하나 추가하고 이 메서드를 고치지 않았다면 조용히 틀린 값을 내는 대신 즉시 실패한다. 상태마다 라벨이나 허용 규칙이 열거형 안에 모여 있으면, "상태가 늘면 어디를 고쳐야 하는가"의 답이 파일 하나로 좁혀진다.

상태 전이 규칙도 같은 방식으로 담는다. 대기에서는 확정이나 취소로, 확정에서는 취소로만 갈 수 있고, 취소된 신청은 되살릴 수 없다고 하자. 그림 1이 이 규칙이다.

수강 신청 상태는 대기에서 확정이나 취소로만, 확정에서 취소로만 이동하고 취소에서는 나갈 길이 없다.

이 규칙을 nextStatuses() 메서드 하나에 적어 두면 화면, 배치 작업, API 가 모두 같은 규칙을 쓴다. 호출하는 쪽은 canMoveTo() 로 묻기만 한다.

from 과 tryFrom

바깥에서 들어온 문자열을 열거형으로 바꾸는 정적 메서드가 둘 있다.

backed enum 이 기본으로 제공하는 정적 메서드
메서드없는 값일 때반환 타입쓰는 자리
from($v)ValueError 를 던진다열거형이미 검증된 내부 값
tryFrom($v)null 을 반환한다열거형 또는 null요청 파라미터 같은 외부 입력
cases()해당 없음case 의 배열선택 목록 만들기

외부 입력은 틀릴 수 있는 것이 정상이므로 tryFrom() 으로 받고 null 이면 사용자에게 안내하는 편이 자연스럽다. 두 메서드 모두 대소문자를 구분하며 공백을 다듬어 주지 않는다. 'CONFIRMED' 는 null 이다. 정규화가 필요하면 호출하기 전에 직접 한다. 자세한 사양은 PHP 매뉴얼의 열거형 항목에서 확인할 수 있다.

값 객체와 readonly 클래스

값 객체는 "무엇인가"가 아니라 "얼마인가"로 정체성이 정해지는 객체다. 30,000원은 어느 30,000원이든 똑같다. 반대로 수강 신청 건은 같은 이름과 금액이어도 다른 신청일 수 있다. 값 객체는 세 가지 성질을 갖는다.

  • 만들어질 때 규칙을 검사해서, 존재하는 객체는 항상 유효하다.
  • 만들어진 뒤에는 바뀌지 않는다(immutable). 바꾸고 싶으면 새 객체를 돌려준다.
  • 같은지 여부를 속성 값으로 판단한다.

PHP 8.2 부터 클래스 앞에 readonly 를 붙이면 모든 프로퍼티가 읽기 전용이 된다. 한 번 초기화된 뒤에는 클래스 안에서도 다시 쓸 수 없다. 이 선언에는 제약이 있다. 모든 프로퍼티에 타입이 있어야 하고, 정적 프로퍼티는 둘 수 없다.

final readonly class Money
{
    public function __construct(public int $amount)
    {
        if ($amount < 0) {
            throw new InvalidArgumentException("금액은 0 이상이어야 한다: {$amount}");
        }
    }
}

생성자 승격(constructor promotion)으로 프로퍼티를 선언하고, 본문에서 검증한다. 검증에 실패하면 예외가 던져지므로 객체가 만들어지지 않는다. 음수 Money 는 프로그램 어디에도 존재할 수 없다. 이 클래스를 받는 함수는 금액이 음수인지 다시 확인할 필요가 없다. 예외를 어떻게 체계적으로 설계하는지는 이후 장에서 다루고, 여기서는 내장 예외를 쓴다.

바꾸는 대신 새로 만든다

불변 객체에서 "수량을 3배로" 같은 연산은 기존 객체를 고치지 않고 새 객체를 반환한다. 이를 흔히 with 방식이라 부른다. 원본을 참조하던 다른 코드가 영향을 받지 않으므로 값이 어디서 바뀌었는지 추적할 일이 사라진다. PHP 8.4 에서는 readonly 프로퍼티를 clone 한 뒤 고칠 수 없으므로, 새 객체는 new self(...) 로 만든다.

동등성: ==, ===, equals()

같은 값을 가진 두 객체를 비교하는 방법이 세 가지 있고 결과가 다르다.

값이 같은 두 Money 객체는 ==로는 같고 ===로는 다르며, equals 메서드로 비교 규칙을 명시할 수 있다.
두 Money(30000) 객체를 비교하는 세 방법
비교 방법기준결과비고
$a == $b같은 클래스, 모든 프로퍼티 값이 같은가true프로퍼티가 늘면 비교 범위도 따라 늘어난다
$a === $b같은 객체 인스턴스인가false값 객체에는 맞지 않는다
$a->equals($b)클래스가 정한 규칙true정규화 후 비교 같은 규칙을 담을 수 있다

== 는 단순한 값 객체에서는 맞게 동작하지만 의도가 코드에 드러나지 않는다. equals() 를 두면 "무엇을 같다고 볼 것인가"가 클래스 안에 문서처럼 남는다. 예를 들어 전화번호는 010-1234-5678 과 01012345678 을 같은 번호로 봐야 한다. 이는 생성자에서 숫자만 남기도록 정규화하면 해결된다. 정규화된 digits 하나만 비교하면 되기 때문이다.

열거형 case 는 인스턴스가 하나뿐이므로 === 가 곧 동등성이다. 열거형은 별도 equals() 가 필요 없다.

언제 무엇을 쓰는가

값의 종류에 따라 고르는 표현 방식
값의 성격알맞은 표현예이유
목록이 닫혀 있다backed enum신청 상태, 요일목록 밖의 값이 타입에서 막힌다
범위나 형식 규칙이 있다readonly 값 객체금액, 전화번호생성 시 검증, 불변
구분되는 개별 건이다일반 객체 또는 엔터티수강 신청 건값이 같아도 다른 건일 수 있다

완성 코드

다음 코드를 main.php 로 저장한다. 열거형 하나, 값 객체 둘, 그리고 이 둘을 모아 쓰는 Enrollment 로 구성했다. Enrollment 도 불변이며, 상태를 바꾸면 새 객체를 돌려준다.

<?php

declare(strict_types=1);

enum EnrollmentStatus: string
{
    case Pending = 'pending';
    case Confirmed = 'confirmed';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match ($this) {
            self::Pending => '대기',
            self::Confirmed => '확정',
            self::Cancelled => '취소',
        };
    }

    /** @return list<self> */
    public function nextStatuses(): array
    {
        return match ($this) {
            self::Pending => [self::Confirmed, self::Cancelled],
            self::Confirmed => [self::Cancelled],
            self::Cancelled => [],
        };
    }

    public function canMoveTo(self $next): bool
    {
        return in_array($next, $this->nextStatuses(), true);
    }
}

final readonly class Money
{
    public function __construct(public int $amount)
    {
        if ($amount < 0) {
            throw new InvalidArgumentException("금액은 0 이상이어야 한다: {$amount}");
        }
    }

    public function times(int $count): self
    {
        return new self($this->amount * $count);
    }

    public function equals(self $other): bool
    {
        return $this->amount === $other->amount;
    }

    public function format(): string
    {
        return number_format($this->amount) . '원';
    }
}

final readonly class PhoneNumber
{
    public string $digits;

    public function __construct(string $input)
    {
        $digits = preg_replace('/[\s-]+/', '', $input);
        if ($digits === null || preg_match('/^01[016789]\d{7,8}$/', $digits) !== 1) {
            throw new InvalidArgumentException("올바른 휴대전화 번호가 아니다: {$input}");
        }
        $this->digits = $digits;
    }

    public static function tryFrom(string $input): ?self
    {
        try {
            return new self($input);
        } catch (InvalidArgumentException) {
            return null;
        }
    }

    public function equals(self $other): bool
    {
        return $this->digits === $other->digits;
    }

    public function format(): string
    {
        $middle = strlen($this->digits) === 11 ? 4 : 3;

        return substr($this->digits, 0, 3)
            . '-' . substr($this->digits, 3, $middle)
            . '-' . substr($this->digits, 3 + $middle);
    }
}

final readonly class Enrollment
{
    public function __construct(
        public string $studentName,
        public PhoneNumber $phone,
        public Money $fee,
        public EnrollmentStatus $status = EnrollmentStatus::Pending,
    ) {
        if (trim($studentName) === '') {
            throw new InvalidArgumentException('수강생 이름이 비어 있다');
        }
    }

    public function moveTo(EnrollmentStatus $next): self
    {
        if (!$this->status->canMoveTo($next)) {
            throw new DomainException(
                "상태를 바꿀 수 없다: {$this->status->label()} → {$next->label()}"
            );
        }

        return new self($this->studentName, $this->phone, $this->fee, $next);
    }
}

echo "[1] 문자열 → 열거형\n";
foreach (['confirmed', 'CONFIRMED', 'done'] as $raw) {
    $status = EnrollmentStatus::tryFrom($raw);
    echo "  {$raw} => ", $status?->label() ?? '알 수 없음', "\n";
}
try {
    EnrollmentStatus::from('done');
} catch (ValueError $e) {
    echo '  from(done) => ', $e::class, "\n";
}

echo "[2] 모든 case\n";
foreach (EnrollmentStatus::cases() as $case) {
    echo "  {$case->name} = {$case->value} ({$case->label()})\n";
}

echo "[3] 상태 전이\n";
$pairs = [
    [EnrollmentStatus::Pending, EnrollmentStatus::Confirmed],
    [EnrollmentStatus::Confirmed, EnrollmentStatus::Pending],
    [EnrollmentStatus::Cancelled, EnrollmentStatus::Confirmed],
];
foreach ($pairs as [$from, $to]) {
    echo "  {$from->label()} → {$to->label()}: ", $from->canMoveTo($to) ? '가능' : '불가', "\n";
}

echo "[4] Money\n";
$a = new Money(30000);
$b = new Money(30000);
echo '  == : ', var_export($a == $b, true), "\n";
echo '  === : ', var_export($a === $b, true), "\n";
echo '  equals : ', var_export($a->equals($b), true), "\n";
echo '  3회 합계: ', $a->times(3)->format(), "\n";
try {
    new Money(-1);
} catch (InvalidArgumentException $e) {
    echo '  생성 거부: ', $e->getMessage(), "\n";
}
try {
    $a->amount = 1;
} catch (Error $e) {
    echo '  수정 거부: ', $e::class, "\n";
}

echo "[5] PhoneNumber\n";
$p1 = new PhoneNumber('010-1234-5678');
$p2 = new PhoneNumber('01012345678');
echo "  표기: {$p1->format()}\n";
echo '  같은 번호: ', $p1->equals($p2) ? '예' : '아니오', "\n";
$bad = PhoneNumber::tryFrom('12-345');
echo '  잘못된 입력: ', $bad === null ? 'null' : $bad->format(), "\n";

echo "[6] Enrollment\n";
$enrollment = new Enrollment('김하늘', $p1, new Money(45000));
$confirmed = $enrollment->moveTo(EnrollmentStatus::Confirmed);
echo "  원본: {$enrollment->status->label()}, 새 객체: {$confirmed->status->label()}\n";
echo "  수강료: {$confirmed->fee->format()}\n";
try {
    $confirmed->moveTo(EnrollmentStatus::Pending);
} catch (DomainException $e) {
    echo '  전이 거부: ', $e->getMessage(), "\n";
}

줄별 해설

열거형 선언. enum EnrollmentStatus: string 은 세 case 와 각각의 문자열 값을 정한다. label() 은 match ($this) 로 case 마다 한글 라벨을 돌려준다. nextStatuses() 는 현재 상태에서 갈 수 있는 상태의 목록을 돌려주고, 취소는 빈 배열이므로 어디로도 갈 수 없다. canMoveTo() 는 in_array 의 세 번째 인자에 true 를 주어 엄격 비교로 목록을 확인한다. case 는 싱글턴이라 엄격 비교가 정확하다.

Money. final readonly class 는 상속을 막고 모든 프로퍼티를 읽기 전용으로 만든다. 생성자의 public int $amount 는 프로퍼티 선언과 대입을 한 번에 한다. 본문의 음수 검사가 실패하면 예외가 나므로 음수를 가진 객체는 생기지 않는다. times() 는 자기 자신을 고치지 않고 새 Money 를 만들며, 새 객체도 같은 생성자를 거쳐 검증된다. format() 의 number_format 은 세 자리마다 쉼표를 넣는다.

PhoneNumber. 생성자 매개변수는 승격하지 않았다. 입력 문자열과 저장할 값이 다르기 때문이다. preg_replace 로 공백과 하이픈을 지우고, preg_match 로 010 등으로 시작하는 10~11자리 숫자인지 확인한다. 이 함수들은 오류 시 null 이나 false 를 돌려줄 수 있어 null 검사와 !== 1 비교를 함께 썼다. 정규화된 값은 $digits 에 한 번만 대입한다. tryFrom() 은 열거형의 이름을 본뜬 정적 메서드로, 예외를 잡아 null 로 바꾼다. format() 은 11자리면 3-4-4, 10자리면 3-3-4 로 나눈다.

Enrollment. 상태에 기본값 EnrollmentStatus::Pending 을 주어, 새 신청은 항상 대기로 시작한다. 이름이 비어 있으면 거부한다. moveTo() 는 열거형에게 전이가 허용되는지 묻고, 허용되지 않으면 DomainException 을 던진다. 허용되면 상태만 바꾼 새 Enrollment 를 반환한다.

실행 부분. [1] 은 tryFrom() 의 결과를 ?-> 와 ?? 로 처리한다. $status?->label() 은 $status 가 null 이면 호출하지 않고 null 이 되며, 그때 ?? 가 대체 문구를 쓴다. 마지막 줄은 from() 이 ValueError 를 던지는 것을 보인다. 메시지 문구는 PHP 버전에 따라 조금 달라질 수 있어 클래스 이름만 출력했다. [3] 은 배열 구조 분해로 쌍을 꺼낸다. [4] 는 같은 값의 두 객체를 세 방식으로 비교하고, 음수 생성과 프로퍼티 수정이 막히는 것을 보인다. 읽기 전용 프로퍼티를 바깥에서 쓰려는 시도는 Error 로 나타난다. [6] 은 원본 신청이 그대로 대기이고, 새 객체만 확정이 되는 것을 보인다.

실행 결과

$ php main.php
[1] 문자열 → 열거형
  confirmed => 확정
  CONFIRMED => 알 수 없음
  done => 알 수 없음
  from(done) => ValueError
[2] 모든 case
  Pending = pending (대기)
  Confirmed = confirmed (확정)
  Cancelled = cancelled (취소)
[3] 상태 전이
  대기 → 확정: 가능
  확정 → 대기: 불가
  취소 → 확정: 불가
[4] Money
  == : true
  === : false
  equals : true
  3회 합계: 90,000원
  생성 거부: 금액은 0 이상이어야 한다: -1
  수정 거부: Error
[5] PhoneNumber
  표기: 010-1234-5678
  같은 번호: 예
  잘못된 입력: null
[6] Enrollment
  원본: 대기, 새 객체: 확정
  수강료: 45,000원
  전이 거부: 상태를 바꿀 수 없다: 확정 → 대기

실무에서 자주 틀리는 것

열거형과 문자열을 비교한다

열거형으로 바꾼 뒤에도 옛 습관대로 문자열과 비교하면 항상 거짓이다. 오류도 나지 않아 찾기 어렵다.

// 틀린 코드: status 는 열거형이므로 문자열과 같을 수 없다
if ($enrollment->status === 'confirmed') {
    echo '확정';
}

// 고친 코드: case 와 비교하거나, 값이 필요하면 ->value 를 쓴다
if ($enrollment->status === EnrollmentStatus::Confirmed) {
    echo '확정';
}

외부 입력에 from() 을 쓴다

from() 은 잘못된 값에 예외를 던진다. 요청 파라미터에 쓰면 사용자가 주소창을 고치는 것만으로 서버 오류가 난다.

// 틀린 코드: ?status=done 이 들어오면 ValueError 로 중단된다
$status = EnrollmentStatus::from($_GET['status']);

// 고친 코드: tryFrom 으로 받고 null 을 처리한다
$status = EnrollmentStatus::tryFrom(strtolower(trim($_GET['status'] ?? '')));
if ($status === null) {
    echo '알 수 없는 상태다';
}

값 객체의 프로퍼티를 바꿀 수 있게 둔다

생성자에서 검증해도 프로퍼티가 쓰기 가능하면 검증은 한 번뿐이다. 이후에 규칙을 깨는 값을 넣을 수 있다.

// 틀린 코드: 생성 후 대입으로 검증을 우회한다
final class LooseMoney
{
    public function __construct(public int $amount)
    {
        if ($amount < 0) {
            throw new InvalidArgumentException('음수 금액');
        }
    }
}
$m = new LooseMoney(1000);
$m->amount = -5;   // 아무 오류 없이 통과한다

// 고친 코드: readonly 로 선언한다
final readonly class Money
{
    public function __construct(public int $amount) { /* 검증 */ }
}

readonly 안에 바뀌는 객체를 넣는다

readonly 는 프로퍼티에 다른 값을 다시 대입하는 것만 막는다. 프로퍼티가 가리키는 객체 내부는 막지 않는다.

// 틀린 코드: DateTime 은 가변이라 내부가 바뀐다
final readonly class Session
{
    public function __construct(public DateTime $startsAt) {}
}
$s = new Session(new DateTime('2026-03-02 19:00'));
$s->startsAt->modify('+1 day');   // 읽기 전용인데도 시작 시각이 바뀐다

// 고친 코드: 불변 날짜 클래스를 쓴다
final readonly class Session
{
    public function __construct(public DateTimeImmutable $startsAt) {}
}

한눈에 보기

이 장에서 쓴 문법과 효과 요약
항목문법효과주의
backed enumenum E: string { case A = 'a'; }정해진 선택지를 타입으로 만든다인스턴스 상태를 가질 수 없다
열거형 메서드match ($this)상태별 규칙을 한곳에 모은다case 추가 시 match 를 함께 고친다
외부 입력 변환E::tryFrom($v)없는 값이면 null대소문자·공백을 직접 정규화한다
readonly 클래스final readonly class생성 후 변경을 막는다내부의 가변 객체는 막지 못한다
생성 시 검증생성자에서 예외잘못된 객체가 존재하지 못한다복사본도 생성자를 거치게 한다
동등성equals()같음의 기준을 코드로 남긴다=== 는 인스턴스 동일성이다

연습 문제

  1. EnrollmentStatus 에 대기자 명단 상태 Waitlisted (값 'waitlisted')를 추가하려 한다. 대기자는 확정이나 취소로만 갈 수 있다. 어느 메서드를 어떻게 고쳐야 하는지 쓰고, 고치지 않았을 때 어떤 일이 생기는지 설명하라.
  2. 월요일부터 일요일까지를 1~7 의 정수 값으로 갖는 backed enum Weekday 를 만들고, label() 메서드(월~일 한 글자)를 붙여라. Weekday::tryFrom(8) 의 결과도 적어라.
  3. 0 이상 100 이하의 정수만 허용하는 값 객체 Percent 를 readonly 클래스로 만들고, equals() 를 붙여라. 101 을 넣으면 InvalidArgumentException 이 나야 한다.
  4. 다음 식의 결과(true/false)를 쓰고 이유를 설명하라.
    EnrollmentStatus::from('pending') === EnrollmentStatus::Pending
    new Money(100) === new Money(100)

정답과 해설

1번. case 를 추가한 뒤 label() 과 nextStatuses() 의 match 에 Waitlisted 분기를 넣는다. 전이 규칙은 self::Waitlisted => [self::Confirmed, self::Cancelled] 이고, 대기 상태가 대기자로 갈 수 있게 할지는 업무 규칙에 따라 Pending 분기에도 반영한다. 고치지 않으면 Waitlisted 로 label() 을 호출하는 순간 UnhandledMatchError 가 난다. 조용히 잘못된 값이 나가는 것보다 즉시 실패하는 쪽이 낫다.

2번.

enum Weekday: int
{
    case Mon = 1;
    case Tue = 2;
    case Wed = 3;
    case Thu = 4;
    case Fri = 5;
    case Sat = 6;
    case Sun = 7;

    public function label(): string
    {
        return match ($this) {
            self::Mon => '월',
            self::Tue => '화',
            self::Wed => '수',
            self::Thu => '목',
            self::Fri => '금',
            self::Sat => '토',
            self::Sun => '일',
        };
    }
}

Weekday::tryFrom(8) 은 목록에 없는 값이므로 null 이다. 엄격 모드에서 backed 값이 int 인 열거형에 문자열 '1' 을 넘기면 TypeError 가 나므로, 요청 파라미터는 정수로 바꾼 뒤 넘긴다.

3번.

final readonly class Percent
{
    public function __construct(public int $value)
    {
        if ($value < 0 || $value > 100) {
            throw new InvalidArgumentException("0~100 범위여야 한다: {$value}");
        }
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }
}

검사는 생성자 한 곳에만 있고, readonly 라서 이후에 범위를 벗어난 값으로 바꿀 방법이 없다.

4번. 첫 식은 true 다. 열거형 case 는 인스턴스가 하나뿐이어서 from() 이 돌려준 것과 직접 쓴 것이 같은 객체다. 둘째 식은 false 다. new 를 두 번 호출했으므로 서로 다른 인스턴스이고, === 는 인스턴스 동일성을 본다. 값이 같은지 보려면 == 나 equals() 를 쓴다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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