Laravel Pennant

번역일: 2026년 6월 25일

Laravel Pennant

소개

Laravel Pennant는 군더더기 없이 간결하게 설계된 경량 피처 플래그(feature flag) 패키지입니다. 피처 플래그를 활용하면 새로운 기능을 단계적으로 안전하게 배포하거나, 새 UI 디자인을 A/B 테스트하거나, 트렁크 기반 개발(trunk-based development) 전략을 보완하는 등 다양한 방식으로 활용할 수 있습니다.

설치

Composer로 Pennant를 프로젝트에 설치합니다.

composer require laravel/pennant

설치 후 vendor:publish Artisan 명령어로 Pennant의 설정 파일과 마이그레이션 파일을 퍼블리시합니다.

php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"

마지막으로 데이터베이스 마이그레이션을 실행합니다. 이 과정에서 Pennant의 database 드라이버가 사용하는 features 테이블이 생성됩니다.

php artisan migrate

설정

퍼블리시 후 설정 파일은 config/pennant.php에 위치합니다. 이 파일에서 Pennant가 피처 플래그 값을 저장할 때 사용할 기본 스토리지 드라이버를 지정할 수 있습니다.

Pennant는 두 가지 스토리지 드라이버를 기본 제공합니다.

  • array: 피처 값을 인메모리 배열에 저장합니다. 요청이 끝나면 사라지는 휘발성 저장소입니다.
  • database: 피처 값을 관계형 데이터베이스에 영속적으로 저장합니다. Pennant의 기본 드라이버입니다.

피처 정의하기

피처를 정의할 때는 Feature 파사드의 define 메서드를 사용합니다. 피처 이름과 함께, 피처의 초기 값을 결정할 클로저를 전달합니다.

피처 정의는 보통 서비스 프로바이더의 boot 메서드 안에서 합니다. 클로저는 피처 확인의 기준이 되는 "스코프"를 인자로 받습니다. 가장 일반적인 스코프는 현재 인증된 사용자입니다. 아래 예시는 새 API를 점진적으로 배포하는 피처를 정의합니다.

<?php namespace App\Providers; use App\Models\User; use Illuminate\Support\Lottery; use Illuminate\Support\ServiceProvider; use Laravel\Pennant\Feature; class AppServiceProvider extends ServiceProvider { /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Feature::define('new-api', fn (User $user) => match (true) { $user->isInternalTeamMember() => true, // 내부 팀원: 항상 활성화 $user->isHighTrafficCustomer() => false, // 고트래픽 고객: 비활성화 default => Lottery::odds(1 / 100), // 그 외: 1% 확률로 활성화 }); } }

이 규칙을 정리하면 다음과 같습니다.

  • 내부 팀원은 항상 새 API를 사용합니다.
  • 고트래픽 고객은 새 API를 사용하지 않습니다.
  • 그 외 사용자는 1% 확률로 새 API가 활성화됩니다.

특정 사용자에 대해 new-api 피처가 처음 확인되면, 클로저 실행 결과가 스토리지 드라이버에 저장됩니다. 같은 사용자에 대해 다시 확인할 때는 저장된 값을 그대로 사용하며 클로저는 다시 실행되지 않습니다.

피처 정의가 Lottery 인스턴스만 반환하는 단순한 경우라면, 클로저를 생략할 수 있습니다.

Feature::define('site-redesign', Lottery::odds(1, 1000));

클래스 기반 피처

클로저 대신 클래스로 피처를 정의할 수도 있습니다. 클래스 기반 피처는 서비스 프로바이더에 별도로 등록할 필요가 없으며, pennant:feature Artisan 명령어로 생성합니다. 생성된 클래스는 기본적으로 app/Features 디렉터리에 위치합니다.

php artisan pennant:feature NewApi

피처 클래스에는 resolve 메서드만 정의하면 됩니다. 이 메서드가 주어진 스코프에 대한 피처의 초기 값을 결정합니다.

<?php namespace App\Features; use Illuminate\Support\Lottery; class NewApi { /** * 피처의 초기 값을 결정합니다. */ public function resolve(User $user): mixed { return match (true) { $user->isInternalTeamMember() => true, $user->isHighTrafficCustomer() => false, default => Lottery::odds(1 / 100), }; } }

NOTE

피처 클래스는 서비스 컨테이너를 통해 해석되므로, 필요한 경우 생성자에 의존성을 주입할 수 있습니다.

저장되는 피처 이름 커스터마이징

기본적으로 Pennant는 피처 클래스의 완전한 클래스명(FQCN)을 저장 키로 사용합니다. 애플리케이션 내부 구조와 저장 키를 분리하고 싶다면 피처 클래스에 $name 프로퍼티를 선언하세요. 이 값이 클래스명 대신 저장됩니다.

<?php namespace App\Features; class NewApi { /** * 저장될 피처 이름. * * @var string */ public $name = 'new-api'; // ... }

피처 확인하기

피처가 활성 상태인지 확인하려면 Feature 파사드의 active 메서드를 사용합니다. 기본적으로 현재 인증된 사용자를 기준으로 확인합니다.

<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Http\Response; use Laravel\Pennant\Feature; class PodcastController { /** * 리소스 목록을 반환합니다. */ public function index(Request $request): Response { return Feature::active('new-api') ? $this->resolveNewApiResponse($request) : $this->resolveLegacyApiResponse($request); } // ... }

특정 사용자나 다른 스코프를 기준으로 확인하려면 for 메서드를 사용하세요.

return Feature::for($user)->active('new-api') ? $this->resolveNewApiResponse($request) : $this->resolveLegacyApiResponse($request);

Pennant는 피처 활성화 여부를 다양한 방법으로 확인할 수 있는 편의 메서드를 제공합니다.

// 지정한 피처가 모두 활성화되어 있는지 확인... Feature::allAreActive(['new-api', 'site-redesign']); // 지정한 피처 중 하나라도 활성화되어 있는지 확인... Feature::someAreActive(['new-api', 'site-redesign']); // 피처가 비활성화 상태인지 확인... Feature::inactive('new-api'); // 지정한 피처가 모두 비활성화되어 있는지 확인... Feature::allAreInactive(['new-api', 'site-redesign']); // 지정한 피처 중 하나라도 비활성화되어 있는지 확인... Feature::someAreInactive(['new-api', 'site-redesign']);

NOTE

Artisan 명령어나 큐 Job처럼 HTTP 컨텍스트 밖에서 Pennant를 사용할 때는, 스코프를 명시적으로 지정하거나 인증·비인증 상황 모두를 고려한 기본 스코프를 설정하는 것이 좋습니다.

클래스 기반 피처 확인하기

클래스 기반 피처를 확인할 때는 클래스명을 전달합니다.

<?php namespace App\Http\Controllers; use App\Features\NewApi; use Illuminate\Http\Request; use Illuminate\Http\Response; use Laravel\Pennant\Feature; class PodcastController { /** * 리소스 목록을 반환합니다. */ public function index(Request $request): Response { return Feature::active(NewApi::class) ? $this->resolveNewApiResponse($request) : $this->resolveLegacyApiResponse($request); } // ... }

조건부 실행

when 메서드를 사용하면 피처가 활성화된 경우 첫 번째 클로저를, 비활성화된 경우 두 번째 클로저를 실행할 수 있습니다.

<?php namespace App\Http\Controllers; use App\Features\NewApi; use Illuminate\Http\Request; use Illuminate\Http\Response; use Laravel\Pennant\Feature; class PodcastController { /** * 리소스 목록을 반환합니다. */ public function index(Request $request): Response { return Feature::when(NewApi::class, fn () => $this->resolveNewApiResponse($request), fn () => $this->resolveLegacyApiResponse($request), ); } // ... }

unless 메서드는 when의 반대로, 피처가 비활성화된 경우에 첫 번째 클로저를 실행합니다.

return Feature::unless(NewApi::class, fn () => $this->resolveLegacyApiResponse($request), fn () => $this->resolveNewApiResponse($request), );

`HasFeatures` 트레이트

HasFeatures 트레이트를 User 모델(또는 피처와 관련된 다른 모델)에 추가하면, 모델에서 직접 피처를 유창하게(fluently) 확인할 수 있습니다.

<?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Laravel\Pennant\Concerns\HasFeatures; class User extends Authenticatable { use HasFeatures; // ... }

트레이트를 추가한 뒤에는 features 메서드를 통해 피처를 확인할 수 있습니다.

if ($user->features()->active('new-api')) { // ... }

features 메서드는 다양한 편의 메서드에 대한 접근을 제공합니다.

// 값 조회... $value = $user->features()->value('purchase-button'); $values = $user->features()->values(['new-api', 'purchase-button']); // 상태 확인... $user->features()->active('new-api'); $user->features()->allAreActive(['new-api', 'server-api']); $user->features()->someAreActive(['new-api', 'server-api']); $user->features()->inactive('new-api'); $user->features()->allAreInactive(['new-api', 'server-api']); $user->features()->someAreInactive(['new-api', 'server-api']); // 조건부 실행... $user->features()->when('new-api', fn () => /* ... */, fn () => /* ... */, ); $user->features()->unless('new-api', fn () => /* ... */, fn () => /* ... */, );

Blade 디렉티브

Blade 템플릿에서 피처를 확인할 때는 @feature 디렉티브를 사용하면 간결하게 작성할 수 있습니다.

@feature('site-redesign') <!-- 'site-redesign' 활성화 상태 --> @else <!-- 'site-redesign' 비활성화 상태 --> @endfeature

미들웨어

Pennant는 라우트에 적용할 수 있는 미들웨어도 제공합니다. 라우트에 이 미들웨어를 지정하면, 현재 인증된 사용자가 해당 피처에 접근할 수 있는지 라우트가 실행되기 전에 검사합니다. 지정된 피처 중 하나라도 비활성화 상태라면 400 Bad Request 응답이 반환됩니다. 여러 피처를 using 메서드에 전달할 수 있습니다.

use Illuminate\Support\Facades\Route; use Laravel\Pennant\Middleware\EnsureFeaturesAreActive; Route::get('/api/servers', function () { // ... })->middleware(EnsureFeaturesAreActive::using('new-api', 'servers-api'));

응답 커스터마이징

피처가 비활성화되었을 때 미들웨어가 반환하는 응답을 커스터마이징하려면 EnsureFeaturesAreActive 미들웨어의 whenInactive 메서드를 사용하세요. 이 메서드는 보통 서비스 프로바이더의 boot 메서드에서 호출합니다.

use Illuminate\Http\Request; use Illuminate\Http\Response; use Laravel\Pennant\Middleware\EnsureFeaturesAreActive; /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { EnsureFeaturesAreActive::whenInactive( function (Request $request, array $features) { return new Response(status: 403); } ); // ... }

인메모리 캐시

피처를 확인할 때 Pennant는 결과를 인메모리에 캐시합니다. database 드라이버를 사용하더라도, 같은 요청 안에서 동일한 피처 플래그를 반복 확인해도 추가 데이터베이스 쿼리가 발생하지 않습니다. 또한, 요청이 처리되는 동안 피처 값이 일관되게 유지됩니다.

인메모리 캐시를 수동으로 비워야 할 경우 flushCache 메서드를 사용하세요.

Feature::flushCache();

스코프

스코프 지정하기

앞서 설명했듯이, 피처는 기본적으로 현재 인증된 사용자를 기준으로 확인합니다. 하지만 상황에 따라 다른 스코프를 기준으로 확인해야 할 수도 있습니다. 이때는 Feature 파사드의 for 메서드로 스코프를 직접 지정합니다.

return Feature::for($user)->active('new-api') ? $this->resolveNewApiResponse($request) : $this->resolveLegacyApiResponse($request);

스코프는 반드시 "사용자"일 필요가 없습니다. 예를 들어, 새 결제 경험을 개별 사용자가 아닌 팀 단위로 배포하는 경우를 생각해 보겠습니다. 오래된 팀일수록 더 천천히 배포하고 싶다면 다음과 같이 정의할 수 있습니다.

use App\Models\Team; use Carbon\Carbon; use Illuminate\Support\Lottery; use Laravel\Pennant\Feature; Feature::define('billing-v2', function (Team $team) { if ($team->created_at->isAfter(new Carbon('1st Jan, 2023'))) { return true; } if ($team->created_at->isAfter(new Carbon('1st Jan, 2019'))) { return Lottery::odds(1 / 100); } return Lottery::odds(1 / 1000); });

이 클로저는 User가 아닌 Team 모델을 받습니다. 사용자의 팀을 기준으로 피처를 확인하려면 for 메서드에 팀을 전달하세요.

if (Feature::for($user->team)->active('billing-v2')) { return redirect()->to('/billing/v2'); } // ...

기본 스코프

Pennant가 피처를 확인할 때 사용하는 기본 스코프를 변경할 수도 있습니다. 예를 들어, 모든 피처를 사용자 대신 팀 단위로 확인한다면, 매번 Feature::for($user->team)을 호출하는 대신 팀을 기본 스코프로 설정할 수 있습니다. 이 설정은 서비스 프로바이더에서 합니다.

<?php namespace App\Providers; use Illuminate\Support\Facades\Auth; use Illuminate\Support\ServiceProvider; use Laravel\Pennant\Feature; class AppServiceProvider extends ServiceProvider { /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team); // ... } }

이제 for 메서드로 스코프를 명시하지 않으면, 현재 인증된 사용자의 팀이 기본 스코프로 사용됩니다.

Feature::active('billing-v2'); // 아래와 동일하게 동작합니다... Feature::for($user->team)->active('billing-v2');

Nullable 스코프

피처를 확인할 때 전달한 스코프가 null인데 피처 정의가 nullable 타입이나 유니언 타입에 null을 포함하지 않으면, Pennant는 자동으로 false를 반환합니다.

Artisan 명령어, 큐 Job, 비인증 라우트처럼 인증된 사용자가 없는 컨텍스트에서는 기본 스코프가 null이 될 수 있습니다. 이런 상황을 처리하려면 피처 정의에서 스코프 타입을 nullable로 선언하고 null인 경우를 명시적으로 처리해야 합니다.

use App\Models\User;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;
Feature::define('new-api', fn (User $user) => match (true) {//
Feature::define('new-api', fn (User|null $user) => match (true) {//
$user === null => true,//
$user->isInternalTeamMember() => true,
$user->isHighTrafficCustomer() => false,
default => Lottery::odds(1 / 100),
});

스코프 식별

Pennant의 기본 제공 arraydatabase 드라이버는 PHP의 모든 기본 데이터 타입과 Eloquent 모델에 대한 스코프 식별자를 올바르게 저장할 수 있습니다. 하지만 서드파티 Pennant 드라이버를 사용하는 경우, 해당 드라이버가 Eloquent 모델이나 커스텀 타입의 식별자를 제대로 처리하지 못할 수 있습니다.

이런 상황에 대비해 Pennant는 스코프로 사용되는 객체에 FeatureScopeable 계약(contract)을 구현함으로써 스코프 값을 드라이버에 맞게 포맷할 수 있게 해줍니다.

예를 들어, 한 애플리케이션에서 기본 database 드라이버와 서드파티 "Flag Rocket" 드라이버를 함께 사용하는 경우를 생각해 봅시다. "Flag Rocket" 드라이버는 Eloquent 모델 대신 FlagRocketUser 인스턴스를 필요로 합니다. FeatureScopeable 계약의 toFeatureIdentifier 메서드를 구현하면 드라이버별로 다른 스코프 값을 제공할 수 있습니다.

<?php namespace App\Models; use FlagRocket\FlagRocketUser; use Illuminate\Database\Eloquent\Model; use Laravel\Pennant\Contracts\FeatureScopeable; class User extends Model implements FeatureScopeable { /** * 주어진 드라이버에 맞는 피처 스코프 식별자로 변환합니다. */ public function toFeatureIdentifier(string $driver): mixed { return match($driver) { 'database' => $this, 'flag-rocket' => FlagRocketUser::fromId($this->flag_rocket_id), }; } }

스코프 직렬화

기본적으로 Pennant는 Eloquent 모델과 연관된 피처를 저장할 때 클래스의 완전한 클래스명(FQCN)을 사용합니다. 이미 Eloquent morph map을 사용하고 있다면, Pennant에도 morph map을 적용해 저장된 피처를 애플리케이션 구조에서 분리할 수 있습니다.

서비스 프로바이더에서 Eloquent morph map을 정의한 후 Feature 파사드의 useMorphMap 메서드를 호출하면 됩니다.

use Illuminate\Database\Eloquent\Relations\Relation; use Laravel\Pennant\Feature; Relation::enforceMorphMap([ 'post' => 'App\Models\Post', 'video' => 'App\Models\Video', ]); Feature::useMorphMap();

다양한 피처 값

지금까지는 피처가 "활성(active)" 또는 "비활성(inactive)"이라는 이진 상태만을 가지는 경우를 살펴봤습니다. 하지만 Pennant는 더 다양한 값을 저장하는 것도 지원합니다.

예를 들어, "지금 구매" 버튼의 색상을 세 가지로 테스트한다고 가정해 봅시다. truefalse 대신 문자열을 반환하면 됩니다.

use Illuminate\Support\Arr; use Laravel\Pennant\Feature; Feature::define('purchase-button', fn (User $user) => Arr::random([ 'blue-sapphire', 'seafoam-green', 'tart-orange', ]));

value 메서드로 purchase-button 피처의 현재 값을 가져올 수 있습니다.

$color = Feature::value('purchase-button');

Blade 디렉티브에서도 피처의 현재 값에 따라 조건부 렌더링을 쉽게 처리할 수 있습니다.

@feature('purchase-button', 'blue-sapphire') <!-- 'blue-sapphire' 활성 상태 --> @elsefeature('purchase-button', 'seafoam-green') <!-- 'seafoam-green' 활성 상태 --> @elsefeature('purchase-button', 'tart-orange') <!-- 'tart-orange' 활성 상태 --> @endfeature

NOTE

다양한 값을 사용할 때, false가 아닌 값이면 해당 피처는 "활성" 상태로 간주됩니다.

조건부 when 메서드를 호출할 때, 피처의 값이 첫 번째 클로저에 전달됩니다.

Feature::when('purchase-button', fn ($color) => /* ... */, fn ()

이 문서는 Laravel 공식 문서(MIT)를 한국 개발자를 위해 번역·재구성한 것입니다.

번역일: 2026년 6월 25일