두 번째 ORM 없이 타입화된 Eloquent 경계

발행: (2026년 6월 8일 PM 03:10 GMT+9)
11 분 소요
원문: Dev.to

출처: Dev.to

대부분의 Laravel 팀은 Eloquent를 “고쳐야” 하는 것이 아니라, 원시 모델 상태가 실제 비즈니스 결정을 내리는 코드까지 너무 멀리 퍼지는 것을 멈춰야 합니다.

이것이 바로 이 논쟁의 실용적인 버전입니다.

Eloquent 주위에 타입이 지정된 객체를 두는 것은 큰 개선이 될 수 있지만, 경계 역할을 할 때만 그렇습니다. 패턴을 지나치게 적용하면 첫 번째 모델을 그림자처럼 복제하는 두 번째 객체 모델이 생깁니다. 그 순간 Laravel을 개선하고 있는 것이 아니라, 매핑 코드와 인지 부하, 그리고 모든 변경에 대한 마찰을 추가하는 병렬 ORM을 만들고 있는 것입니다.

따라서 올바른 질문은 “Eloquent를 타입이 지정된 객체로 교체해야 할까?”가 아니라, 언제부터 타입이 없는 Eloquent가 비용이 많이 드는가? 입니다.

그렇게 생각하면 마이그레이션 경로가 훨씬 명확해집니다. Eloquent가 영속성, 하이드레이션, 스코프, 관계, 그리고 쿼리 구성에 강점이 있는 부분은 그대로 두고, 형태가 복잡하거나 값이 비즈니스 의미를 담고 있거나 잘못된 조합이 쉽게 나타날 수 있는 부분에만 타입이 지정된 객체를 도입합니다.

이것이 실제로 효과가 있는 버전입니다.


핵심 권고

이 글에서 한 가지만 기억한다면, 불안정하거나 의미 있는 데이터 주변에만 타입이 지정된 경계를 추가하고, 모든 모델에 추가하지 말라는 것입니다.

보통 다음 네 경우 중 하나에 해당합니다.

  • 여러 부분에서 서로 다르게 해석되는 JSON 컬럼
  • 금액, 상태, 주소, 청구 설정 등 도메인 값
  • Eloquent에서 서비스, 잡, 혹은 외부 연동으로 넘어가는 데이터
  • 문자열 타입 상태가 이미 혼란이나 버그를 일으킨 코드 경로

그 외의 모든 것은 유용함이 입증될 때까지는 의심받아야 합니다.

많은 팀이 여기서 실수합니다. 좋은 타입 객체 예시를 보고 바로 이를 전체 아키텍처 규칙으로 일반화합니다. 그러다 보니 모든 모델에 FooData, FooView, FooState, FooRecord, FooMapper 같은 클래스를 만들게 됩니다. 앱은 “설계된” 느낌이 강해지지만 이해하기는 어려워집니다.

Laravel 코드베이스가 클래스를 많이 가졌다고 좋아지는 것이 아니라, 책임이 명확해지기 때문에 좋아지는 것입니다.


원시 Eloquent가 문제를 일으키는 이유

Eloquent는 의도적으로 관대합니다. 이것이 Laravel 팀이 빠르게 개발할 수 있는 이유 중 하나입니다. 속성은 문자열, 배열, JSON 블롭, 캐스트된 값, nullable 타임스탬프 등 데이터베이스가 허용하는 어떤 형태도 될 수 있습니다. 초기에는 이 유연성이 생산적으로 느껴집니다.

문제는 나중에, 보통 지루한 곳에서 나타납니다.

단순 JSON 블롭으로 시작한 필드가 청구나 권한에 중요한 역할을 하게 되고, 한때 두 가지 상태만 가졌던 문자열 컬럼이 이제는 여섯 가지 상태를 갖게 되며, 그 중 하나는 웹훅이 도착한 뒤에만 유효해집니다. 설정 배열이 컨트롤러, 큐 잡, 액션 클래스, API 변환기 등 여러 곳에서 읽히고 각각 약간씩 다른 기본값을 가정합니다.

이 시점에서 Eloquent 자체가 문제는 아닙니다. 문제는 저장 형태와 도메인 의미가 이제 서로 뒤섞여 있다는 점입니다.


배열 형태 로직의 숨은 비용

조기에 눈여겨봐야 할 코드 냄새는 다음과 같습니다:

if (($user->settings['plan'] ?? 'free') === 'pro' &&
    ($user->settings['trial_ends_at'] ?? null) !== null &&
    ($user->settings['cancel_at_period_end'] ?? false) === false) {
    // ...
}

Enter fullscreen mode

Exit fullscreen mode

이 한 줄은 최소 세 가지 일을 합니다.

  • 저장 형식 읽기
  • 기본값 적용
  • 비즈니스 의도 표현

또한 빠르게 스캔하기도 어렵습니다. 로직 자체는 복잡하지 않지만 형태가 시끄럽습니다. 시스템의 다섯 군데가 각각 이 로직을 구현한다면 유지보수 문제가 생깁니다.

핵심 문제는 스타일이 아니라 의미적 변질입니다. 한 곳은 plan 기본값을 free로 두고, 다른 곳은 plan이 없으면 무효라고 가정합니다. 한 경로는 trial_ends_at을 nullable 문자열로 읽고, 다른 경로는 Carbon 인스턴스로 파싱합니다. 결국 두 코드 경로가 조용히 서로 충돌하게 됩니다.

타입이 지정된 경계는 해석을 중앙 집중화함으로써 이를 해결합니다.


문자열은 저렴하지만, 그렇지 않을 때도 있다

Laravel 개발자는 상태 필드, 제공자 이름, 기능 플래그, 이벤트 타입, 모드 스위치 등에 원시 문자열을 사용하는 데 익숙합니다. 의미가 명확하고 로컬에만 존재한다면 괜찮습니다.

하지만 값이 프로세스 경계를 넘거나 워크플로를 제어하기 시작하면 더 이상 괜찮지 않습니다.

draft, published, archived, scheduled 같은 값들을 가진 원시 status 컬럼은 위험해 보이지 않습니다. 그러나 이 값들이 API 응답, 잡, 관리자 액션, 권한 제어 등에 사용되기 시작하면 약점이 드러납니다.

  • 오타가 런타임까지 허용된다.
  • 잘못된 전이가 일관되게 방지하기 어렵다.
  • IDE 리팩터링이 보호해 주지 못한다.
  • 비즈니스 규칙이 호출 지점마다 흩어져 있다.

타입이 지정된 객체, enum, 혹은 작은 값 객체는 여기서 코드 미학이 아니라 합법적인 상태 집합을 명시적으로 만드는 역할을 합니다.


좋은 타입 경계의 모습

좋은 타입 경계는 다음 중 하나 이상을 수행합니다.

  • 복잡한 입력 저장 형태를 정규화한다.
  • 작은 불변 조건을 강제한다.
  • 값과 함께 존재해야 할 행동을 노출한다.
  • 애플리케이션 나머지 부분에 예측 가능한 인터페이스를 제공한다.

컬럼을 1:1로 그대로 반영하기 위해 존재하는 것이 아닙니다.

이 구분은 사람들이 생각보다 더 중요합니다.


예시 1: JSON 컬럼을 둘러싼 타입드 설정

JSON 컬럼은 원시 데이터베이스 형태가 금방 퍼지는 가장 명확한 타입 객체 도입 지점 중 하나입니다.

예를 들어 users.subscription_settings 컬럼을 생각해 보세요. 초보적인 구현은 보통 다음과 같습니다:

$user->subscription_settings = [
    'plan' => 'pro',
    'cancel_at_period_end' => false,
    'trial_ends_at' => '2026-06-30T00:00:00Z',
];

Enter fullscreen mode

Exit fullscreen mode

겉보기엔 무해해 보입니다. 문제는 이 키들이 열 곳에서 각각 약간씩 다른 가정으로 읽히기 시작하면서 발생합니다.

더 나은 경계는 캐스트에서 반환되는 타입 객체입니다.

class SubscriptionSettings
{
    public function __construct(
        public string $plan,
        public bool $cancelAtPeriodEnd,
        public ?\DateTimeImmutable $trialEndsAt,
    ) {}

    public function toArray(): array
    {
        return [
            'plan' => $this->plan,
            'cancel_at_period_end' => $this->cancelAtPeriodEnd,
            'trial_ends_at' => $this->trialEndsAt?->format(DATE_ATOM),
        ];
    }

    public function isOnTrial(): bool
    {
        return $this->trialEndsAt !== null && $this->trialEndsAt > new \DateTimeImmutable();
    }

    public function isEnterprise(): bool
    {
        return $this->plan === 'enterprise';
    }
}

Enter fullscreen mode

Exit fullscreen mode

그 다음 이를 캐스트에 연결합니다.

class SubscriptionSettingsCast implements \Illuminate\Contracts\Database\Eloquent\CastsAttributes
{
    public function get($model, string $key, $value, array $attributes)
    {
        $data = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
        return new SubscriptionSettings(
            $data['plan'],
            $data['cancel_at_period_end'],
            isset($data['trial_ends_at']) ? new \DateTimeImmutable($data['trial_ends_at']) : null
        );
    }

    public function set($model, string $key, $value, array $attributes)
    {
        return json_encode($value->toArray(), JSON_THROW_ON_ERROR);
    }
}

Enter fullscreen mode

Exit fullscreen mode

그리고 모델에 캐스트를 지정합니다.

protected function casts(): array
{
    return [
        'subscription_settings' => \App\Casts\SubscriptionSettingsCast::class,
    ];
}

Enter fullscreen mode

Exit fullscreen mode

이 패턴은 모델 주변 코드를 개선하면서도 Eloquent가 사라진 것이 아니라

0 조회
Back to Blog

관련 글

더 보기 »