Laravel의 $casts 속성은 큰 주목을 받지는 않지만, 데이터를 처리하는 방식을 결정짓는 조용한 편의 기능 중 하나입니다. 배열에 boolean이나 datetime 같은 내장 타입을 넣으면, Eloquent가 컨트롤러나 뷰에 값이 도달하기 전에 원시 데이터베이스 문자열을 더 친숙한 형태로 자동 변환해 줍니다. 덕분에 코드가 깔끔하게 유지됩니다. 하지만 해당 배열 안에서 직접 헬퍼 함수를 호출하려고 하면 프레임워크가 거부합니다. 'username' => 'encrypt_data()'와 같이 작성하면 작동하지 않습니다. Laravel은 내장 캐스트 키워드나 CastsAttributes 인터페이스를 구현하는 클래스를 기대합니다. 이러한 요구 사항이 존재하는 이유는 캐스팅 레이어가 Eloquent의 하이드레이션(hydration) 및 직렬화(serialization) 사이클 깊숙한 곳에서 실행되기 때문입니다. 런타임에 존재하지 않을 수도 있는 임의의 함수 문자열보다는, 명확한 계약(contract)을 가진 예측 가능한 객체가 필요합니다.
왜 $casts 배열에서 헬퍼 함수가 실패하는가
Eloquent가 모델을 로드할 때, 각 컬럼을 어떻게 변환할지 결정하기 위해 $casts 배열을 스캔합니다. 프레임워크는 string, integer, array, encrypted 등 특정 프리미티브(primitives)와 CastsAttributes를 구현하는 정규화된 클래스 이름(fully-qualified class names)을 인식합니다. 문자열을 코드로 평가하지는 않습니다. 따라서 'encrypt_data()'는 알 수 없는 캐스트 타입으로 취급되어 에러를 발생시킵니다. 설령 Laravel이 이를 파싱한다 하더라도, 모델 인스턴스, 속성 키, 현재 값, 그리고 주변 속성들을 올바른 순서로 함수에 전달할 신뢰할 수 있는 방법이 없습니다. 인터페이스는 바로 커스텀 로직과 Eloquent 내부 간의 이러한 핸드셰이크(handshake)를 표준화하기 위해 존재합니다.
이 간극을 메울 수 있는 두 가지 확실한 방법이 있습니다. 하나는 장기적인 재사용성에 유리하며, 다른 하나는 단일 모델 내에서 빠른 패치가 필요할 때 속도 면에서 유리합니다.
방법 1: 커스텀 캐스트 클래스 작성하기
동일한 변환이 여러 필드에 적용되거나 여러 모델에 걸쳐 있다면, 전용 캐스트 클래스를 만드는 것이 더 깔끔한 선택입니다. 별도의 파일에 존재하며, 독립적으로 유닛 테스트를 수행할 수 있고, 모델에서 반복적인 보일러플레이트(boilerplate) 코드를 제거할 수 있습니다.
먼저 app/Casts/CustomEncrypt.php를 생성합니다. 네임스페이스는 일반적으로 App\Casts와 같이 오토로딩 설정과 일치해야 합니다. 클래스는 반드시 Illuminate\Contracts\Database\Eloquent\CastsAttributes를 구현해야 하며, 이에 따라 get과 set 두 메서드를 정의해야 합니다.
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class CustomEncrypt implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
return decrypt_data($value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
return encrypt_data($value);
}
}
메서드 시그니처가 중요합니다. Eloquent는 각 메서드에 네 개의 인자를 전달합니다. $model은 데이터가 채워지거나 저장되는 인스턴스로, 로직이 다른 필드에 의존하는 경우 이를 검사할 수 있게 해줍니다. $key는 현재 캐스팅 중인 컬럼 이름입니다. $value는 get 시 데이터베이스에서 가져온 원시 문자열 또는 null이며, set 시에는 사용자가 제공한 값입니다. $attributes는 해당 행의 모든 컬럼이 담긴 원시 배열 전체입니다. 매번 네 개를 모두 사용할 필요는 없지만, 인터페이스에서 이를 요구합니다.
위의 get 메서드에서 decrypt_data($value)는 Eloquent가 데이터베이스에서 행을 가져온 후, 값이 모델에 전달되기 전에 실행됩니다. set 메서드에서 encrypt_data($value)는 INSERT 또는 UPDATE가 실행되기 전에 실행되어, 데이터베이스에 평문이 저장되지 않도록 보장합니다.
이를 연결하려면 모델의 $casts 배열 내에서 해당 클래스를 참조하십시오.
protected $casts = [
'username' => CustomEncrypt::class,
'password' => CustomEncrypt::class,
];
클래스 상수를 사용하므로 나머지는 오토로더가 처리합니다. 나중에 애플리케이션에서 암호화 방식을 변경해야 하는 경우, 파일 하나만 수정하면 매핑된 모든 필드의 동작이 즉시 변경됩니다. 많은 테이블에 걸쳐 민감한 데이터를 관리할 때 이러한 중앙 집중식 관리는 매우 강력합니다.
방법 2: Accessor와 Mutator 활용하기
때로는 한 곳에서만 중요한 변환을 위해 새로운 파일을 만들고 싶지 않을 때가 있습니다. Laravel의 Attribute 클래스를 사용하면 PHP 8+ 클로저(closure) 문법을 사용하여 모델에 직접 get 및 set 로직을 정의할 수 있습니다.
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function username(): Attribute
{
return Attribute::make(
get: fn ($value) => decrypt_data($value),
set: fn ($value) => encrypt_data($value),
);
}
여기서 메서드 이름은 대상이 되는 컬럼 또는 속성과 일치해야 합니다. Eloquent가 데이터베이스에서 username을 읽을 때, 원시 값을 get 클로저를 통해 전달합니다. 모델의 username 속성에 새 값을 할당하면, Eloquent가 쿼리를 생성하기 전에 set 클로저가 이를 암호화합니다.
이 방식은 캐스트 클래스 디렉토리를 통째로 만드는 것이 과하다고 느껴지는 프로토타입이나 레거시 모델에서 빛을 발합니다. 단점은 반복입니다. 나중에 email, phone, backup_code에도 동일한 처리가 필요하다고 결정하면, 여러 메서드나 모델에 클로저를 복사해야 합니다. 이러한 노이즈가 쌓이게 됩니다. 또한 전체 모델을 부팅하지 않고 유닛 테스트에서 로직을 재사용하는 것도 방해합니다.
두 방식 중 선택하기
재사용성, 테스트, 그리고 모델을 가볍게 유지하는 것이 중요하다면 커스텀 캐스트(custom cast) 클래스를 사용하세요. 이는 이 변환 과정이 일회성 편법(hack)이 아니라 애플리케이션의 핵심 개념(first-class concept)임을 다른 개발자들에게 알려주는 신호가 됩니다.
로직이 정말 국소적이고, 실험적이거나, 현재 스프린트 이후까지 유지될 가능성이 낮다면 액세서(accessor)를 사용하세요. 액세서를 사용하면 코드베이스 전체에 작은 파일들을 흩뿌리지 않고도 빠르게 작업할 수 있습니다. 다만, 동일한 패턴이 두세 번 반복되면 캐스트 클래스로 리팩터링할 준비를 해두어야 합니다.
유의해야 할 실무적인 세부 사항
커스텀 캐스트는 강력하지만, 주의 깊게 살펴보지 않으면 예상치 못한 동작을 유발할 수 있습니다. 첫째, get 메서드는 null을 포함하여 데이터베이스가 반환한 값을 그대로 받는다는 점을 기억하세요. 만약 decrypt_data가 null 입력을 허용하지 않는다면, 다음과 같이 방어 코드를 작성해야 합니다.
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
return is_null($value) ? null : decrypt_data($value);
}
둘째, 캐스트는 배열 및 JSON 직렬화 과정에서 실행됩니다. 모델을 API 리소스로 반환하거나 toArray()를 호출할 때도 캐스트의 get 로직이 적용됩니다. 이는 대개 의도한 동작이겠지만, 복호화된 값을 노출하면서 그 위에 추가적인 가시성 제어(visibility controls)를 적용해야 하는 경우에는 이 점을 반드시 기억해야 합니다.
셋째, 가장 중요한 점은 캐스팅이 데이터 조회 이후에 발생한다는 것입니다. 변환된 값으로는 쿼리를 수행할 수 없습니다. User::where('username', 'john_doe')->first()와 같은 쿼리는 문자열 'john_doe'를 데이터베이스로 직접 보냅니다. 이 값은 사용자의 복호화 로직을 거치지 않습니다. 만약 해당 컬럼이 저장 시 암호화(encrypted at rest)되어 있다면, 암호화된 암호문(ciphertext) 자체를 대상으로 검색하지 않는 한 해당 쿼리는 아무것도 찾지 못할 것입니다. 캐스트는 네이티브 데이터베이스 함수나 인덱싱된 평문 컬럼을 대체할 수 없으므로, 이에 맞춰 데이터베이스 액세스 패턴을 설계해야 합니다.
커스텀 캐스트 클래스는 작은 설정값들을 담아두기에도 매우 좋습니다. 암호화 모드나 포맷 문자열을 전달하는 등의 생성자 인자(constructor arguments)가 필요한 경우, Laravel은 'field' => CustomEncrypt::class . ':arg'와 같은 표현식을 통해 $casts 배열에서 이를 지원합니다. 다만 이는 여기서 설명한 기본적인 설정을 넘어선 단계입니다.
핵심 요약
$casts에 헬퍼 함수를 직접 넣지 못하게 하는 제약은 단순한 관료주의적 절차가 아닙니다. 이는 명시적이고, 테스트 가능하며, 재사용 가능한 코드를 작성하도록 유도하는 장치입니다. 커스텀 캐스트 클래스는 여기저기 흩어진 인라인 로직을 중복 없이 여러 모델에서 공유할 수 있는 신뢰할 수 있는 컴포넌트로 바꿔줍니다. 반면 액세서는 별도의 파일을 만드는 것이 형식적인 절차처럼 느껴지는 상황에서 빠르고 국소적인 수정을 가능하게 해줍니다. 이 두 가지를 모두 숙달하고 문제의 범위에 따라 적절히 선택한다면, 애플리케이션이 성장한 후에도 Eloquent 레이어의 가독성을 유지할 수 있을 것입니다.
