들어가며

결론부터 말하면, 상태·역할 같은 "닫힌 값의 집합"은 backed enum으로, 계층 사이를 오가는 데이터는 readonly DTO로, 값에 따른 분기는 match로 옮기면 define()과 매직 배열의 대부분이 사라집니다. 그리고 그 빈자리를 PHPStan이 지켜줍니다.

PHP를 26년째 만지고 있습니다. 잠시 개발에서 손을 떼다가 1년 전 Claude Code와 함께 다시 코드를 쓰기 시작했고, 지금은 AIvance의 여러 SaaS를 CodeIgniter 4 공통 기반(ci4-board) 위에서 혼자 운영합니다.

복귀하고 가장 먼저 부딪힌 건 내 손버릇이었습니다. CI3 시절 constants.php에 define('ROLE_ADMIN', 1)을 쌓고, 서비스 사이로 $data['user']['role'] 같은 연관 배열을 넘기던 습관입니다. 언어는 그사이 enum(8.1), readonly class(8.2), 그리고 8.4의 asymmetric visibility까지 왔는데 코드는 여전히 PHP 5 모양이었죠.

이 글은 그 습관을 걷어낸 기록입니다. 환경은 아래와 같습니다.

항목

버전 · 도구

프레임워크

CodeIgniter 4.7.x (4.6.0부터 PHP 8.4 정식 지원)

언어

PHP 8.4

정적 분석

PHPStan level 8

테스트

PHPUnit

개발 도구

PhpStorm + Claude Code

서버

Ubuntu

문제: define()과 매직 배열은 타입이 없다

define() 상수와 연관 배열의 공통 문제는 하나입니다. 값이 어느 집합에 속하는지, 어떤 키가 반드시 있는지를 언어가 모릅니다. 예전에 제가 쓰던 코드는 대략 이런 모양이었습니다.

// app/Config/Constants.php
define('ROLE_ADMIN', 1);
define('ROLE_MEMBER', 2);

define('CONV_PENDING', 'pending');
define('CONV_OPEN', 'open');
define('CONV_RESOLVED', 'resolved');

// 어딘가의 헬퍼
$roleLabels = [
    ROLE_ADMIN  => '관리자',
    ROLE_MEMBER => '일반회원',
];

// 서비스
public function create(array $data): array
{
    if (! isset($data['email'], $data['name'])) {
        throw new \InvalidArgumentException('필수값 누락');
    }
    $data['role'] ??= ROLE_MEMBER;
    // ...
    return ['id' => $id, 'email' => $data['email'], 'role' => $data['role']];
}

운영하면서 실제로 겪은 문제는 네 가지였습니다.

  • 아무 값이나 들어간다. $data['role'] = 99를 막는 것이 없습니다. ROLE_ADMIN은 그냥 int입니다.
  • 전역 네임스페이스가 더러워진다. 서비스가 늘수록 CONV_, ORDER_, PAY_ 접두사 관리가 능력이 됩니다.
  • 배열 키 오타는 런타임에서야 드러난다. $data['emial']은 IDE도 PHPStan도 잡지 못합니다.
  • 상태가 늘면 분기가 흩어진다. 라벨 배열, 색상 배열, 전이 규칙 switch가 파일마다 따로 있어, 상태 하나를 추가하면 여러 곳을 고쳐야 합니다.

AI가 코드를 쓰기 시작하면서 마지막 문제가 더 커졌습니다. Claude Code는 눈에 보이는 패턴을 따라 하는데, 상태 규칙이 흩어져 있으면 한 곳만 고치고 끝내는 일이 생깁니다.

backed enum: 상태와 역할을 타입으로

닫힌 값의 집합은 전부 app/Enums/ 아래 backed enum으로 옮겼습니다. DB에 저장하는 값이 있으니 순수 enum이 아닌 backed enum입니다. 가장 단순한 역할부터 봅니다.

namespace App\Enums;

enum UserRole: string
{
    case Admin  = 'admin';
    case Member = 'member';

    public function label(): string
    {
        return match ($this) {
            self::Admin  => '관리자',
            self::Member => '일반회원',
        };
    }
}

라벨 배열이 enum 안으로 들어왔고, UserRole 타입을 받는 함수에는 이제 두 값 외에 아무것도 들어오지 못합니다. 예전엔 정수 1·2를 썼지만, 로그와 DB를 사람이 읽을 수 있도록 string 백킹으로 바꿨습니다.

상태 머신을 enum 안에

진짜 효과를 본 곳은 웹 채팅 상담 솔루션의 상담 상태였습니다. 설계서에서 상태를 pending → open → snoozed / resolved 네 개로 고정하고 "이 외의 상태를 추가하지 말 것"을 규칙으로 적었는데, 그 규칙을 문서가 아니라 코드가 지키게 했습니다.

namespace App\Enums;

enum ConversationStatus: string
{
    case Pending  = 'pending';
    case Open     = 'open';
    case Snoozed  = 'snoozed';
    case Resolved = 'resolved';

    /** @return list<self> */
    public function allowedNext(): array
    {
        return match ($this) {
            self::Pending  => [self::Open],
            self::Open     => [self::Snoozed, self::Resolved],
            self::Snoozed  => [self::Open],
            self::Resolved => [self::Open],   // 방문자 재문의
        };
    }

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

    public function isActive(): bool
    {
        return $this !== self::Resolved;
    }

    /** @return list<string> DB ENUM·검증 규칙에 쓸 값 목록 */
    public static function values(): array
    {
        return array_column(self::cases(), 'value');
    }
}

서비스에서는 전이 검사 한 줄이면 됩니다.

if (! $conversation->status->canTransitionTo($next)) {
    throw InvalidStatusTransition::from($conversation->status, $next);
}

얻은 것은 세 가지입니다.

  • 규칙의 위치가 하나다. 라벨, 전이, 활성 여부가 모두 enum 파일 하나에 있습니다.
  • 상태를 추가하면 빠진 곳이 바로 드러난다. match ($this)에 새 case가 없으면 PHPStan이 분석 단계에서 잡고, 런타임에도 UnhandledMatchError가 납니다.
  • from() / tryFrom()이 검증기가 된다. 외부 입력은 tryFrom()으로 받고 null이면 422로 돌려보냅니다. DB에서 읽은 값처럼 신뢰하는 값은 from()으로 받아, 깨졌으면 ValueError로 바로 터지게 둡니다.

readonly DTO: 계층 사이의 배열을 걷어내기

컨트롤러 → 서비스 → 레파지토리로 내려가는 데이터는 final readonly class로 바꾸었습니다. 생성자 프로퍼티 승격과 readonly를 합치면 선언 몇 줄로 "필수 키가 항상 있고, 타입이 맞고, 아무도 중간에 바꾸지 않는" 구조체가 됩니다.

namespace App\DTO;

use App\Enums\UserRole;
use CodeIgniter\HTTP\IncomingRequest;

final readonly class CreateUserRequest
{
    public function __construct(
        public string $email,
        public string $name,
        public UserRole $role = UserRole::Member,
    ) {}

    /** 검증을 통과한 요청에서만 호출한다 */
    public static function fromRequest(IncomingRequest $request): self
    {
        $in = $request->getJSON(true);

        return new self(
            email: trim($in['email']),
            name:  trim($in['name']),
            role:  UserRole::tryFrom($in['role'] ?? '') ?? UserRole::Member,
        );
    }
}

응답 쪽도 같은 방식입니다. toArray()는 API 계약을 문서처럼 보여주는 자리가 됩니다.

final readonly class UserResponse
{
    public function __construct(
        public int $id,
        public string $email,
        public UserRole $role,
    ) {}

    /** @return array{id: int, email: string, role: string, role_label: string} */
    public function toArray(): array
    {
        return [
            'id'         => $this->id,
            'email'      => $this->email,
            'role'       => $this->role->value,
            'role_label' => $this->role->label(),
        ];
    }
}

그러면 컨트롤러는 "검증 → DTO → 서비스 → 응답" 네 줄로 얇아집니다.

final class UserController extends BaseApiController
{
    public function store(): ResponseInterface
    {
        if (! $this->validate(CreateUserRules::get())) {
            return $this->failValidationErrors($this->validator->getErrors());
        }

        $user = service('userService')->create(CreateUserRequest::fromRequest($this->request));

        return $this->respondCreated($user->toArray());
    }
}

여기서 하나 짚고 갈 점이 있습니다. 처음 CLAUDE.md 예시에는 __construct(private readonly UserService $userService) 같은 생성자 주입을 썼는데, CI4 컨트롤러는 프레임워크가 인자 없이 생성하므로 기본 상태에서는 생성자 주입이 동작하지 않습니다. 서비스는 Config\Services에 등록해 service()로 꺼내거나 initController()에서 주입합니다. 서비스·레파지토리 사이의 생성자 주입은 Services 팩토리에서 직접 조립하니 문제없습니다.

DTO에서 지킨 규칙은 다음과 같습니다.

  • 검증은 DTO 밖에서, 변환은 DTO 안에서. CI4 Validation이 형식을 보장하고, DTO는 그 값을 타입으로 옮기기만 합니다.
  • 변경은 새 인스턴스로. 값을 바꿔야 하면 withRole()처럼 새 객체를 돌려줍니다.
  • DTO에 비즈니스 로직을 넣지 않는다. 로직은 서비스, 규칙은 enum에 둡니다.

match: switch와 룩업 배열을 대신하기

match는 값을 돌려주는 표현식이고, 엄격 비교(===)를 하며, 어느 팔에도 안 맞으면 UnhandledMatchError를 던집니다. 이 세 가지 덕분에 switch와 "키 → 값" 룩업 배열을 대부분 대체했습니다.

// Before: 룩업 배열 — 없는 키는 조용히 null + Warning
$badge = ['pending' => 'gray', 'open' => 'blue', 'resolved' => 'green'][$status];

// After: enum 안의 match — 빠진 case는 PHPStan이 잡는다
public function badgeColor(): string
{
    return match ($this) {
        self::Pending  => 'gray',
        self::Open     => 'blue',
        self::Snoozed  => 'amber',
        self::Resolved => 'green',
    };
}

예외를 HTTP 상태 코드로 바꾸는 곳처럼, 타입으로 분기하는 자리에는 match (true)를 씁니다. API 에러 계약(RFC 9457 Problem Details)을 만드는 예외 핸들러가 대표적입니다.

$status = match (true) {
    $e instanceof ValidationException      => 422,
    $e instanceof InvalidStatusTransition  => 409,
    $e instanceof PageNotFoundException    => 404,
    $e instanceof AuthenticationException  => 401,
    default                                => 500,
};

쓰면서 정한 기준은 이렇습니다.

  • enum에 대한 match에는 default를 두지 않는다. default가 있으면 새 case를 추가해도 아무도 알려주지 않습니다. 빠진 팔을 에러로 만드는 것이 목적입니다.
  • 외부 값에 대한 match에는 default를 둔다. 예외 클래스처럼 열린 집합은 기본값이 있어야 합니다.
  • 팔 하나에 여러 줄 로직이 필요하면 match가 아니다. 메서드로 빼거나 다형성(인터페이스 + 구현체)으로 갑니다.

CI4와 붙이는 지점

enum과 DTO를 도입해도 Model·Validation·DB가 여전히 문자열을 주고받으면 반쪽짜리입니다. 이음매는 세 군데입니다.

1. Model/Entity 캐스팅: 4.7의 enum 캐스트

CI4 4.7에는 enum 캐스트 핸들러가 들어왔습니다(추가 PR #9752). DB에서 읽을 때 문자열을 enum으로, 저장할 때 enum을 backing 값으로 바꿔줍니다. 예전처럼 커스텀 캐스트 핸들러를 직접 만들 필요가 없어졌습니다.

class ConversationModel extends Model
{
    protected $table         = 'conversations';
    protected $returnType    = 'array';
    protected array $casts   = [
        'id'          => 'int',
        'assignee_id' => '?int',
        'status'      => 'enum[App\Enums\ConversationStatus]',
    ];
}

Entity를 쓴다면 Entity::$casts에 같은 표기를 씁니다. 저는 Entity 대신 배열을 받아 레파지토리에서 DTO로 옮기는 쪽을 택했습니다. 레파지토리 밖으로는 배열이 나가지 않게 하려는 것입니다.

2. Validation: enum에서 규칙을 만든다

in_list[pending,open,...]를 손으로 적으면 상태가 두 곳에 살게 됩니다. enum의 values()로 만들면 됩니다. 다만 Config\Validation의 프로퍼티 기본값에는 함수 호출을 넣을 수 없으므로, 규칙을 돌려주는 작은 클래스를 둡니다.

final class ChangeStatusRules
{
    /** @return array<string, string> */
    public static function get(): array
    {
        return [
            'status' => 'required|in_list[' . implode(',', ConversationStatus::values()) . ']',
        ];
    }
}

3. DB ENUM 컬럼과의 일치

conversations.status는 MySQL ENUM('pending','open','snoozed','resolved')입니다. PHP enum과 DB ENUM이 어긋나는 것을 막려고, 마이그레이션에서도 ConversationStatus::values()로 컬럼 정의를 만들고 테스트 하나로 두 목록이 같은지 확인합니다. 상태를 추가하면 enum·마이그레이션·테스트가 한 커밋에 들어가야 합니다.

PHP 8.4에서 알아둘 것

항목

내용

이 글에서의 선택

asymmetric visibility

public private(set) string $email처럼 읽기는 공개, 쓰기는 클래스 안에서만

값 객체는 readonly, 내부 상태가 바뀌는 도메인 객체에만 사용

property hooks

get/set 훅으로 getter·setter 대체. readonly 프로퍼티에는 훅을 붙일 수 없음

DTO에는 쓰지 않음

암묵적 nullable 폐기

function f(Foo $x = null)가 deprecated. ?Foo $x = null로 명시

업그레이드 때 가장 많이 고친 부분

new 괄호 생략 체이닝

new Foo()->bar() 가능

테스트 픽스처에서 주로 사용

CI4는 4.6.0부터 PHP 8.4를 정식 지원하지만, 8.4에서 타입 검사가 엄격해져 프레임워크 내부에서 TypeError가 보고된 사례도 있습니다. 업그레이드 전에 통합 테스트를 한 번 전부 돌려보는 것을 권합니다.

Claude Code에 규칙으로 고정하기

제가 코드를 바꿔도 Claude Code가 다음 세션에서 define()을 다시 쓰면 소용이 없습니다. 그래서 규칙을 두 겹으로 걸었습니다. CLAUDE.md는 "무엇을 써야 하는가"를 알려주고, PHPStan은 "어기면 머지되지 않는다"를 보장합니다.

1겹: CLAUDE.md의 DO / DON'T

CLAUDE.md에는 설명보다 짧은 예시가 잘 통합니다. 실제로 넣은 부분을 줄여 옮기면 이렇습니다.

## Coding Conventions — PHP 8.4

- 닫힌 값의 집합(상태, 역할, 타입)은 app/Enums/ 의 backed enum. define()·클래스 상수 목록 금지
- 라벨·색상·전이 규칙은 enum 메서드의 match 안에. enum을 대상으로 한 match에 default 금지
- 계층 간 데이터는 app/DTO/ 의 final readonly class. 서비스 시그니처에 array $data 금지
- 외부 입력 → Enum::tryFrom(), DB 값 → Model $casts 'enum[...]'
- 컨트롤러 생성자 주입 금지. service() 또는 initController() 사용

// ❌ DON'T
define('ROLE_ADMIN', 1);
public function create(array $data): array

// ✅ DO
enum UserRole: string { case Admin = 'admin'; ... }
public function create(CreateUserRequest $dto): UserResponse

2겹: PHPStan level 8

CLAUDE.md는 부탁이고, PHPStan은 문입니다. level 8에서 이번 작업과 직접 연결되는 검사는 세 가지입니다.

  • enum에 대한 match의 빠진 case를 보고합니다. case를 추가하고 메서드 하나를 잊으면 CI가 실패합니다.
  • readonly 프로퍼티 재할당을 분석 단계에서 잡습니다.
  • array 반환에 형태 선언이 없으면 경고가 나므로, 남아 있는 배열은 array{id: int, ...} 형태를 적거나 DTO로 바꾸게 됩니다.

여기에 Claude Code의 PostToolUse 훅에서 변경 파일만 PHPStan을 돌리고, 같은 검사를 GitHub Actions 셀프호스티드 러너에서 한 번 더 돌립니다. AI가 쓴 코드도 사람이 쓴 코드와 같은 문을 지나가게 하는 것이 핵심입니다.

돌아보며

한 줄로 요약하면, 문법을 바꾼 것이 아니라 규칙이 사는 자리를 한 곳으로 모은 것입니다. 그리고 그 자리를 PHPStan과 CI가 지키니, Claude Code가 코드를 쓸 때도 흔들리지 않습니다.

옮기는 순서

한 번에 다 바꾸려고 하면 diff가 커져서 리뷰가 안 됩니다. 저는 이 순서로 했고, 단계마다 PR을 나눴습니다.

  1. PHP 8.4 업그레이드만 먼저. 암묵적 nullable 같은 deprecation을 정리하고 통합 테스트를 전부 통과시킵니다. 기능 변경은 섞지 않습니다.
  2. enum부터. define()과 라벨 배열을 enum으로 옮기고, Model $casts와 Validation을 enum에 묶습니다. 값 자체는 그대로라 DB 변경이 없습니다.
  3. match로 분기 정리. 흩어진 switch와 룩업 배열을 enum 메서드로 모읍니다.
  4. DTO는 새 코드부터. 새 엔드포인트는 처음부터 DTO로 쓰고, 기존 엔드포인트는 손댄 때 바꿉니다.
  5. 마지막에 규칙 고정. CLAUDE.md에 DO/DON'T를 적고 PHPStan 레벨을 올립니다.

주의할 것

  • 모든 배열이 적은 아니다. 목록·집계 결과처럼 모양이 다양한 데이터는 array{...} 형태 선언으로 충분합니다. DTO는 계층을 넘나드는 데이터에 씁니다.
  • enum에 로직을 너무 많이 넣지 않는다. 상태에 따른 알림 발송 같은 부수 효과는 서비스 몸입니다. enum은 "무엇이 허용되는가"까지만 알게 합니다.
  • 프레임워크 관례와 어긋나는 습관을 들여오지 않는다. 컨트롤러 생성자 주입처럼 다른 프레임워크에서 당연한 패턴이 CI4에서는 그대로 동작하지 않습니다. AI가 제안한 패턴일수록 한 번 더 확인합니다.

26년 동안 쌓인 습관을 버리는 데 가장 큰 도움이 된 건 아이러니하게도 AI였습니다. 규칙을 적어주면 사람보다 더 엄격하게 따르고, 적어주지 않으면 내 옛날 코드를 똑같이 따라 하니까요. 결국 모던 PHP로의 전환은 내 코드보다 내 규칙을 먼저 바꾸는 일이었습니다.

참고