개발

문자열 상태값을 PHP backed enum 으로 바꾸기 — 오타·잘못된 DB 값·== 비교에서 달라지는 동작 실측

  • 4th October 2026
  • 7 min read

주문 상태를 문자열로 들고 다니는 코드는 흔하다. DB 컬럼에는 pending, paid, shipped, cancelled가 들어 있고, 코드에서는 상수로 묶어 비교한다.

final class Status
{
    const PENDING   = 'pending';
    const PAID      = 'paid';
    const SHIPPED   = 'shipped';
    const CANCELLED = 'cancelled';
}

function canShip(string $status): bool
{
    return $status === Status::PAID;
}

상수를 만들어 두어도 함수가 받는 것은 그냥 string이다. 어딘가에서 canShip('Paid')처럼 대문자 하나가 섞여 들어오면 오류 없이 false가 나오고, 결제된 주문이 배송 대상에서 조용히 빠진다. DB에 누가 손으로 refunded를 넣어 두었다면 그 행은 어느 분기에도 걸리지 않은 채 else로 흘러간다. 이 글에서는 이 문자열 상태값을 PHP 8.1의 backed enum으로 바꿨을 때 어디가 실제로 달라지는지, 그리고 바꾸는 과정에서 경고 없이 틀려지는 자리가 어디인지 PHP 8.5.4에서 재 본 결과를 적는다.

enum 으로 옮긴 모양

enum OrderStatus: string
{
    case Pending   = 'pending';
    case Paid      = 'paid';
    case Shipped   = 'shipped';
    case Cancelled = 'cancelled';

    const DEFAULT = self::Pending;

    public function label(): string
    {
        return match ($this) {
            self::Pending   => '결제 대기',
            self::Paid      => '결제 완료',
            self::Shipped   => '배송 중',
            self::Cancelled => '취소',
        };
    }
}

function canShip(OrderStatus $status): bool
{
    return $status === OrderStatus::Paid;
}

: string이 붙은 것이 backed enum이다. 각 케이스가 DB에 저장할 값을 하나씩 들고 있고, ->value로 꺼낸다. 케이스 이름(Paid)과 값(paid)이 따로 있다는 점을 기억해 두자. 뒤에서 직렬화할 때 이 차이가 드러난다.

enum 안에는 메서드와 상수를 둘 수 있고, 인터페이스도 구현할 수 있다. 상태별 라벨처럼 여기저기 흩어져 있던 switch를 한곳으로 모으기 좋다.

문자열이 들어오는 순간 멈춘다

함수 인자 타입이 OrderStatus가 되면 문자열은 아예 들어오지 못한다. strict_types를 선언하지 않은 파일에서도 마찬가지다. 스칼라 타입과 달리 enum으로는 자동 변환이 없다.

canShip('paid');
// TypeError: canShip(): Argument #1 ($status) must be of type OrderStatus, string given

문자열 버전에서는 'Paid'가 false로 조용히 지나갔다. enum 버전에서는 같은 실수가 그 줄에서 바로 멈춘다. 문자열은 이제 바깥에서 들어오는 경계, 즉 DB·요청·JSON을 읽는 자리에서만 enum으로 바꾸면 된다.

DB 값을 enum 으로: from 과 tryFrom

경계에서 쓰는 함수가 from()과 tryFrom()이다. 맞는 값이 들어오면 둘 다 같은 케이스를 돌려준다. 다른 값이 들어오면 from()은 예외를 던지고 tryFrom()은 null을 돌려준다. DB에 이런 행들이 있다고 하고 각각 넣어 봤다.

DB 값from()tryFrom()
'paid'OrderStatus::PaidOrderStatus::Paid
'Paid'ValueError: "Paid" is not a valid backing value for enum OrderStatusnull
'paid ' (뒤에 공백)ValueError: "paid " is not a valid backing value …null
'refunded'ValueError: "refunded" is not a valid backing value …null
NULLDeprecated 경고 후
ValueError: "0" is not a valid backing value …
Deprecated 경고 후 null

대소문자와 공백은 봐주지 않는다. 값이 정확히 같아야 한다. 오래 쓴 테이블에는 이런 값이 섞여 있기 마련이라, enum으로 옮기기 전에 SELECT status, COUNT(*) FROM orders GROUP BY status로 실제로 어떤 값이 들어 있는지 먼저 보는 편이 낫다.

마지막 줄은 주의할 만하다. nullable 컬럼에서 NULL을 from()에 그대로 넘기면 "null을 넘기는 것은 deprecated"라는 경고가 먼저 나오고, 이어서 나오는 예외 메시지에는 NULL이 아니라 "0"이 찍힌다. 로그만 보면 DB에 0이 들어 있는 줄 알게 된다. nullable 컬럼은 null을 먼저 따로 처리할 것.

$status = $row['status'] === null ? null : OrderStatus::from($row['status']);

어느 쪽을 쓸지는 "잘못된 값이 들어오면 어떻게 되어야 하는가"로 고른다. 내가 관리하는 DB에서 읽는 값이면 from()이 맞다. 이상한 값은 데이터가 깨졌다는 뜻이니 바로 드러나는 편이 낫다. 사용자 입력(쿼리스트링의 필터 값 등)이면 tryFrom()으로 받고 null일 때 400을 돌려주거나 기본값으로 바꾼다.

$filter = OrderStatus::tryFrom($_GET['status'] ?? '') ?? OrderStatus::DEFAULT;

match 에서 케이스를 빠뜨리면

라벨을 만드는 match를 다른 파일에 하나 더 썼는데, 거기에 Cancelled를 빠뜨렸다고 하자. 나중에 케이스를 추가하고 기존 match를 고치지 않은 경우도 같다. 빠진 케이스가 들어오면 이렇게 된다.

UnhandledMatchError: Unhandled match case OrderStatus::Cancelled

문자열 switch였다면 default로 흘러가거나 아무것도 돌려주지 않았을 자리다. 다만 이 오류는 그 값이 실제로 들어왔을 때 난다. 파일을 읽는 시점에 PHP가 빠진 케이스를 알려주지는 않는다. 케이스를 추가하면 match ($this)가 있는 곳을 모두 찾아 확인해야 하고, 그래서 match를 enum 안의 메서드로 모아 두는 편이 낫다. default =>를 붙이면 이 오류는 사라지지만, 빠진 케이스를 알려주는 장치도 같이 사라진다.

바꾸는 도중에 조용히 틀려지는 자리

마이그레이션에서 가장 위험한 것은 이 부분이다. 상태값이 문자열에서 enum 객체로 바뀌면, 남아 있는 옛 코드 중 일부는 오류를 내고 일부는 오류 없이 다른 결과를 낸다. $order->status가 OrderStatus::Paid가 된 뒤 옛 코드를 그대로 돌려 봤다.

옛 코드결과알아챌 수 있나
$status == 'paid'false경고 없음
$status === Status::PAIDfalse경고 없음
in_array('paid', [$status])false경고 없음
switch ($status) { case 'paid': … }case 'paid'에 안 걸림경고 없음
'상태: ' . $statusError: Object of class OrderStatus could not be converted to string바로 멈춤
sprintf('%s', $status)위와 같은 Error바로 멈춤
$counts[$status]++TypeError: Cannot access offset of type OrderStatus on array바로 멈춤
$stmt->execute([$status])Error: Object of class OrderStatus could not be converted to string바로 멈춤

문자열로 바꾸려는 코드는 시끄럽게 깨지니 걱정이 덜하다. 문제는 위쪽 네 줄이다. 비교하는 코드는 전부 조용히 false가 된다. enum은 __toString()을 가질 수 없어서 ==로 문자열과 비교해도 값을 꺼내 비교하지 않는다. 그 결과 "결제 완료 주문만 처리" 같은 분기가 아무 오류 없이 한 건도 처리하지 않게 된다.

그래서 옮길 때는 상태 상수와 문자열 리터럴로 검색하는 것부터 한다. Status::PAID, 'paid', "paid"가 나오는 줄을 전부 찾아서 === OrderStatus::Paid로 바꾸거나, 경계라면 ->value를 붙인다. 한 번에 다 바꾸기 어려우면 DB에서 읽는 자리(엔티티의 프로퍼티)는 문자열로 두고 status(): OrderStatus 같은 접근자를 추가해, 새 코드부터 enum을 쓰게 하는 방법도 있다.

경계에서 값이 나가는 방식

자리동작
JSONjson_encode(['status' => OrderStatus::Paid]) → {"status":"paid"}. backed enum은 value가 자동으로 나간다. : string 없는 순수 enum은 false(JSON_THROW_ON_ERROR면 JsonException: Non-backed enums have no default serialization)
PDO 바인딩$status는 Error, $status->value로 넘겨야 한다
배열 키$status->value를 키로 쓴다. enum 자체를 키로 쓰려면 SplObjectStorage나 WeakMap (둘 다 실측으로 동작)
serialize()E:16:"OrderStatus:Paid"; — 값이 아니라 케이스 이름이 저장된다
var_export()\OrderStatus::Paid

serialize() 줄이 함정이다. JSON에는 값(paid)이 들어가지만 PHP 직렬화에는 케이스 이름(Paid)이 들어간다. 세션이나 캐시에 enum이 든 객체를 직렬화해 두었는데 배포하면서 케이스 이름을 바꾸거나 지우면, 남아 있던 데이터를 읽을 때 이렇게 된다.

unserialize('E:20:"OrderStatus:Refunded";');
// Warning: unserialize(): Undefined constant OrderStatus::Refunded
// Warning: unserialize(): Error at offset 28 of 28 bytes
// → false

예외가 아니라 경고와 false다. 세션 핸들러에 따라 그 사용자의 세션이 통째로 비어 버린다. DB의 값은 그대로 두고 케이스 이름만 다듬는 리팩터링이 직렬화된 데이터에서는 호환을 깨는 변경이 된다.

비교와 정렬은 되지 않는다

각 케이스는 싱글턴이라 같은 케이스끼리는 ===가 true다. OrderStatus::from('paid') === OrderStatus::Paid도 true다. 반면 크기 비교는 의미가 없다. OrderStatus::Paid < OrderStatus::Shipped와 OrderStatus::Shipped < OrderStatus::Paid가 둘 다 false였다. 상태 사이에 "진행 순서"가 있다면 step(): int 같은 메서드를 만들어 그 숫자로 비교한다.

int 값 enum 과 strict_types

상태를 숫자로 저장했다면 enum Priority: int로 만든다. 이때 from()에 문자열 '2'를 넘기면 결과가 파일 선언에 따라 갈린다.

Priority::from('2')
strict_types 없음Priority::High
declare(strict_types=1)TypeError: Priority::from(): Argument #1 ($value) must be of type int, string given

DB 드라이버가 숫자를 문자열로 돌려주는 환경이라면 strict 파일에서는 (int)로 바꿔서 넘겨야 한다. 이번에 잰 PDO SQLite(PHP 8.5)는 정수 컬럼을 int로 돌려주었다. 쓰는 드라이버와 설정에서 한 번 확인할 것.

버전별로 추가된 것

버전추가된 것
PHP 8.1enum 도입. from·tryFrom·cases, 메서드·상수·인터페이스
PHP 8.2상수 식에서 ->value·->name 사용 — const DEFAULT_KEY = OrderStatus::Pending->value; (RFC)
PHP 8.3이름을 변수로 꺼내기 — OrderStatus::{$name} (php.watch)

8.2 이전에는 클래스 상수나 배열 키에 OrderStatus::Pending->value를 쓸 수 없어서 값 문자열을 다시 적어야 했다. 이 글의 실측은 모두 PHP 8.5.4에서 했고, 8.3에서 따로 돌려 보지는 못했다. 다만 위에서 쓴 기능은 모두 8.3 이하에서 들어온 것이다.

정리

  • 상태를 OrderStatus 타입으로 받으면, 문자열 오타는 strict_types 없이도 그 줄에서 TypeError로 멈춘다.
  • 문자열은 경계(DB·요청·JSON)에서만 enum으로 바꾼다. 직접 관리하는 데이터는 from(), 사용자 입력은 tryFrom()으로 받는다. NULL은 먼저 따로 처리한다.
  • 옮기는 도중에는 == 'paid'·in_array·switch가 경고 없이 false가 된다. 문자열 리터럴과 옛 상수로 검색해서 전부 고친다.
  • serialize()는 값이 아니라 케이스 이름을 저장한다. 세션·캐시가 남아 있으면 케이스 이름을 바꾸는 것도 호환을 깨는 변경이다.
  • match에서 케이스가 빠진 것은 실행할 때 UnhandledMatchError로 드러난다. default로 덮지 말 것.

함께 읽으면 좋은 글