Laravel Pennant

번역일: 2026년 6월 27일

Laravel Pennant

소개

Laravel Pennant는 군더더기 없이 깔끔하게 설계된 기능 플래그(feature flag) 패키지입니다. 기능 플래그를 활용하면 새로운 기능을 전체 사용자에게 한꺼번에 공개하지 않고, 특정 사용자 그룹에 먼저 점진적으로 롤아웃하거나, A/B 테스트를 진행하거나, 개발 중인 기능을 안전하게 숨겨두는 등 다양한 시나리오를 유연하게 처리할 수 있습니다.

Laravel Pennant

소개

Laravel Pennant는 군더더기 없이 가볍고 단순한 피처 플래그(Feature Flag) 패키지입니다. 피처 플래그를 활용하면 새로운 기능을 점진적으로 배포하거나, 새로운 UI 디자인을 A/B 테스트하거나, 트렁크 기반 개발 전략을 보완하는 등 다양한 방식으로 안전하게 기능을 관리할 수 있습니다.

NOTE

피처 플래그란 코드 변경 없이 특정 기능을 켜고 끌 수 있는 스위치입니다. 예를 들어, 신규 결제 화면을 전체 사용자 중 일부에게만 먼저 보여주고 싶을 때 유용하게 활용할 수 있습니다.

설치

먼저 Composer를 사용해 Pennant를 프로젝트에 설치합니다.

composer require laravel/pennant

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

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

이후 데이터베이스 마이그레이션을 실행합니다.

php artisan migrate

Laravel Pennant

설치

Composer 패키지 매니저를 사용해 프로젝트에 Pennant를 설치합니다:

composer require laravel/pennant

다음으로, vendor:publish Artisan 명령어를 실행해 Pennant의 설정 파일과 마이그레이션 파일을 퍼블리시합니다:

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

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

php artisan migrate

설정

Pennant의 에셋을 퍼블리시하면 설정 파일이 config/pennant.php 경로에 생성됩니다. 이 파일에서는 피처 플래그의 해석된 값을 저장할 기본 스토리지 드라이버를 지정할 수 있습니다.

Pennant는 두 가지 드라이버를 기본 지원합니다.

  • array 드라이버 — 해석된 값을 메모리 내 배열에 저장합니다. 요청이 끝나면 데이터가 사라지므로 테스트나 임시 확인 용도에 적합합니다.
  • database 드라이버 — 해석된 값을 관계형 데이터베이스에 영구적으로 저장합니다. Pennant의 기본 드라이버입니다.

NOTE

운영 환경에서는 database 드라이버를 사용하는 것을 권장합니다. 동일한 사용자에게 피처 플래그 결과가 일관되게 유지되어야 하기 때문입니다.

피처 정의하기

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

피처는 보통 서비스 프로바이더의 boot 메서드 안에서 정의합니다. 클로저는 "스코프(scope)"를 인자로 받으며, 대부분의 경우 스코프는 현재 인증된 사용자입니다. 아래 예시는 새 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는 클로저 방식 외에도 클래스 기반으로 피처를 정의하는 방법을 제공합니다. 클래스 기반 피처는 서비스 프로바이더에 별도로 등록할 필요가 없습니다. pennant:feature Artisan 명령어를 실행하면 피처 클래스가 생성되며, 기본적으로 app/Features 디렉터리에 위치합니다.

php artisan pennant:feature NewApi

피처 클래스에는 resolve 메서드 하나만 정의하면 됩니다. 이 메서드는 주어진 스코프에 대한 피처의 초기 값을 결정하며, 스코프는 주로 현재 인증된 사용자입니다.

<?php namespace App\Features; use App\Models\User; 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), // 그 외 사용자는 1% 확률로 활성화 }; } }

클래스 기반 피처의 인스턴스를 직접 가져오고 싶다면 Feature 파사드의 instance 메서드를 사용할 수 있습니다.

use Illuminate\Support\Facades\Feature; $instance = Feature::instance(NewApi::class);

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);

기능 활성화 여부를 다양한 조건으로 확인할 수 있는 편의 메서드도 제공됩니다.

// 지정한 모든 기능이 활성화되어 있는지 확인... 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 모델(또는 기능 플래그와 연동할 다른 모델)에 추가하면, 모델 인스턴스에서 직접 기능 여부를 확인하는 유창한(fluent) API를 사용할 수 있습니다.

<?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@featureany 디렉티브를 사용합니다. 별도의 Feature 파사드 호출 없이 간결하게 조건 분기를 작성할 수 있습니다.

@feature('site-redesign') <!-- 'site-redesign' 기능이 활성화된 경우 --> @else <!-- 'site-redesign' 기능이 비활성화된 경우 --> @endfeature @featureany(['site-redesign', 'beta']) <!-- 'site-redesign' 또는 'beta' 중 하나라도 활성화된 경우 --> @endfeatureany

미들웨어

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); } ); // ... }

기능 확인 인터셉트

저장된 기능 값을 조회하기 전에 인메모리에서 먼저 선행 검사를 수행해야 할 때가 있습니다. 예를 들어, 새 API를 기능 플래그로 관리하다가 버그를 발견했다고 가정해 봅시다. 이때 저장된 기능 값을 유지한 채로 내부 팀원을 제외한 모든 사용자에게 즉시 비활성화하고, 버그 수정 후 다시 기존 접근 가능 사용자에게 복원하고 싶을 수 있습니다.

이런 경우 클래스 기반 기능before 메서드를 활용합니다. before 메서드는 저장소에서 값을 읽기 전에 항상 인메모리로 먼저 실행됩니다. null이 아닌 값을 반환하면 해당 요청 동안 저장된 값 대신 이 값이 사용됩니다.

<?php namespace App\Features; use App\Models\User; use Illuminate\Support\Facades\Config; use Illuminate\Support\Lottery; class NewApi { /** * 저장된 값을 조회하기 전에 인메모리에서 먼저 실행됩니다. */ public function before(User $user): mixed { if (Config::get('features.new-api.disabled')) { return $user->isInternalTeamMember(); } } /** * 기능의 초기 값을 결정합니다. */ public function resolve(User $user): mixed { return match (true) { $user->isInternalTeamMember() => true, $user->isHighTrafficCustomer() => false, default => Lottery::odds(1 / 100), }; } }

before 메서드는 기능 플래그로 관리하던 기능을 특정 날짜부터 전체 사용자에게 자동으로 활성화하는 점진적 출시(rollout) 스케줄링에도 활용할 수 있습니다.

<?php namespace App\Features; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Config; class NewApi { /** * 저장된 값을 조회하기 전에 인메모리에서 먼저 실행됩니다. */ public function before(User $user): mixed { if (Config::get('features.new-api.disabled')) { return $user->isInternalTeamMember(); } if (Carbon::parse(Config::get('features.new-api.rollout-date'))->isPast()) { return true; } } // ... }

인메모리 캐시

Pennant는 기능을 확인할 때 결과를 인메모리에 캐시합니다. database 드라이버를 사용하는 경우, 동일한 요청 내에서 같은 기능 플래그를 여러 번 확인해도 추가 데이터베이스 쿼리가 발생하지 않습니다. 또한 요청 전체에 걸쳐 기능 결과가 일관되게 유지됩니다.

인메모리 캐시를 수동으로 초기화해야 할 때는 Feature 파사드의 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 모델을 인자로 받습니다. 특정 사용자의 팀에 대해 이 피처가 활성화되어 있는지 확인하려면, Feature 파사드의 for 메서드에 팀을 전달하면 됩니다.

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

기본 스코프

Pennant가 피처를 확인할 때 사용하는 기본 스코프도 커스터마이즈할 수 있습니다. 예를 들어, 모든 피처를 현재 인증된 사용자가 아닌 그 사용자의 팀을 기준으로 확인하고 싶다면, 매번 Feature::for($user->team)을 호출하는 대신 팀을 기본 스코프로 지정할 수 있습니다. 이 설정은 보통 서비스 프로바이더의 boot 메서드에서 처리합니다.

<?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');

Null 스코프

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

따라서 스코프가 null일 가능성이 있고, 그때도 피처 리졸버가 실행되길 원한다면 피처 정의에서 null을 명시적으로 처리해야 합니다. 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는 Pennant 스코프로 사용되는 객체에 FeatureScopeable 계약을 구현하여 스토리지용 스코프 값을 직접 포맷할 수 있도록 지원합니다.

예를 들어, 하나의 애플리케이션에서 내장 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();

풍부한 피처 값 (Rich Feature Values)

지금까지는 피처가 "활성" 또는 "비활성"이라는 이진 상태만 갖는 경우를 주로 살펴봤습니다. 하지만 Pennant는 문자열이나 다른 값처럼 더 풍부한 형태의 값도 저장할 수 있습니다.

예를 들어, "지금 구매" 버튼의 색상을 세 가지 후보로 A/B/C 테스트한다고 가정해 보겠습니다. 이 경우 피처 정의에서 true / false 대신 문자열을 반환할 수 있습니다.

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

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

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

Pennant가 제공하는 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 () => /* 피처가 비활성일 때 처리 */, );

마찬가지로, 조건부 실행 unless 메서드를 호출하면 피처의 풍부한 값이 두 번째(선택적) 클로저의 인자로 전달됩니다.

Feature::unless('purchase-button', fn () => /* 피처가 비활성일 때 처리 */, fn ($color) => /* 색상 값을 활용한 처리 */, );

여러 기능 한 번에 조회하기

values 메서드를 사용하면 특정 스코프에 대해 여러 기능의 값을 한 번에 조회할 수 있습니다.

Feature::values(['billing-v2', 'purchase-button']); // [ // 'billing-v2' => false, // 'purchase-button' => 'blue-sapphire', // ]

정의된 모든 기능의 값을 한꺼번에 가져오려면 all 메서드를 사용하세요.

Feature::all(); // [ // 'billing-v2' => false, // 'purchase-button' => 'blue-sapphire', // 'site-redesign' => true, // ]

단, 클래스 기반 기능은 동적으로 등록되기 때문에, Pennant가 해당 클래스를 인식하려면 먼저 명시적으로 한 번 이상 확인(check)이 이루어져야 합니다. 즉, 현재 요청에서 아직 확인되지 않은 클래스 기반 기능은 all 메서드의 결과에 포함되지 않을 수 있습니다.

NOTE

클로저 기반 기능은 define 시점에 이름이 등록되므로 all 결과에 항상 포함됩니다. 반면 클래스 기반 기능은 실제로 호출되기 전까지 Pennant가 존재를 알 수 없습니다.

클래스 기반 기능을 all 메서드 결과에 항상 포함시키려면, Pennant의 기능 자동 탐지(feature discovery) 기능을 활용하세요. 서비스 프로바이더의 boot 메서드에서 discover 메서드를 호출하면 됩니다.

<?php namespace App\Providers; use Illuminate\Support\ServiceProvider; use Laravel\Pennant\Feature; class AppServiceProvider extends ServiceProvider { /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Feature::discover(); // ... } }

discover 메서드는 애플리케이션의 app/Features 디렉터리에 있는 모든 기능 클래스를 자동으로 등록합니다. 이후 all 메서드를 호출하면, 현재 요청에서 확인 여부와 관계없이 해당 클래스들이 결과에 포함됩니다.

Feature::all(); // [ // 'App\Features\NewApi' => true, // 'billing-v2' => false, // 'purchase-button' => 'blue-sapphire', // 'site-redesign' => true, // ]

Laravel Pennant

Eager Loading (즉시 로딩)

Pennant는 단일 요청 내에서 확인한 피처(feature) 값을 메모리에 캐싱합니다. 하지만 반복문 안에서 피처를 확인하는 경우, 여전히 성능 문제가 발생할 수 있습니다. Pennant는 이 문제를 해결하기 위해 피처 값을 미리 일괄 로딩하는 즉시 로딩(Eager Loading) 기능을 제공합니다.

예를 들어, 반복문 안에서 피처 활성화 여부를 확인하는 코드를 살펴보겠습니다:

use Laravel\Pennant\Feature; foreach ($users as $user) { if (Feature::for($user)->active('notifications-beta')) { $user->notify(new RegistrationSuccess); } }

데이터베이스 드라이버를 사용하고 있다면, 이 코드는 반복문을 돌 때마다 매번 데이터베이스 쿼리를 실행합니다. 사용자가 수백 명이라면 수백 번의 쿼리가 발생할 수 있습니다.

이런 N+1 성능 문제는 load 메서드로 해결할 수 있습니다. 반복문을 시작하기 전에 사용자 컬렉션 전체에 대한 피처 값을 한 번에 로딩해두면 됩니다:

Feature::for($users)->load(['notifications-beta']); foreach ($users as $user) { if (Feature::for($user)->active('notifications-beta')) { $user->notify(new RegistrationSuccess); } }

이미 로딩된 피처는 건너뛰고, 아직 로딩되지 않은 피처만 추가로 로딩하려면 loadMissing 메서드를 사용하세요:

Feature::for($users)->loadMissing([ 'new-api', 'purchase-button', 'notifications-beta', ]);

정의된 모든 피처를 한 번에 로딩하려면 loadAll 메서드를 사용할 수 있습니다:

Feature::for($users)->loadAll();

값 업데이트

피처의 값이 처음으로 결정(resolve)될 때, 내부 드라이버는 그 결과를 스토리지에 저장합니다. 이렇게 하면 사용자가 여러 요청에 걸쳐 일관된 경험을 받을 수 있습니다. 그러나 때로는 저장된 피처 값을 직접 수동으로 변경해야 할 때도 있습니다.

activatedeactivate 메서드를 사용하면 피처를 켜거나 끌 수 있습니다:

use Laravel\Pennant\Feature; // 기본 스코프에 대해 피처를 활성화합니다... Feature::activate('new-api'); // 특정 스코프에 대해 피처를 비활성화합니다... Feature::for($user->team)->deactivate('billing-v2');

activate 메서드의 두 번째 인자로 값을 전달하면, 피처에 구체적인 값(rich value)을 직접 설정할 수도 있습니다:

Feature::activate('purchase-button', 'seafoam-green');

Pennant가 특정 피처의 저장된 값을 삭제하도록 하려면 forget 메서드를 사용하세요. 이후 해당 피처를 다시 확인할 때 Pennant는 피처 정의에서 값을 새로 결정합니다:

Feature::forget('purchase-button');

일괄 업데이트

저장된 피처 값을 한 번에 일괄 업데이트하려면 activateForEveryonedeactivateForEveryone 메서드를 사용하세요.

예를 들어, new-api 피처의 안정성이 충분히 검증되었고, 결제 화면의 purchase-button 색상도 최종 결정했다면, 아래와 같이 모든 사용자에게 적용할 수 있습니다:

use Laravel\Pennant\Feature; Feature::activateForEveryone('new-api'); Feature::activateForEveryone('purchase-button', 'seafoam-green');

반대로, 모든 사용자에 대해 피처를 비활성화할 수도 있습니다:

Feature::deactivateForEveryone('new-api');

NOTE

이 메서드들은 Pennant의 스토리지 드라이버에 저장된 결정값만 업데이트합니다. 애플리케이션의 피처 정의 자체도 함께 수정해야 합니다.

피처 퍼지(삭제)

특정 피처를 스토리지에서 완전히 제거해야 할 때가 있습니다. 보통 애플리케이션에서 해당 피처를 제거하거나, 피처 정의를 수정한 내용을 모든 사용자에게 새로 적용하고 싶을 때 필요합니다.

purge 메서드를 사용하면 피처에 저장된 값을 모두 삭제할 수 있습니다:

// 단일 피처를 퍼지합니다... Feature::purge('new-api'); // 여러 피처를 한 번에 퍼지합니다... Feature::purge(['new-api', 'purchase-button']);

인자 없이 purge 메서드를 호출하면 스토리지의 모든 피처를 삭제합니다:

Feature::purge();

배포 파이프라인에서 피처를 퍼지하는 작업이 유용할 수 있으므로, Pennant는 pennant:purge Artisan 명령어를 제공합니다:

php artisan pennant:purge new-apiphp artisan pennant:purge new-api purchase-button

특정 피처만 남기고 나머지를 모두 퍼지하고 싶다면 --except 옵션을 사용하세요. 예를 들어, new-apipurchase-button은 유지하고 나머지 피처를 모두 삭제하려면 다음과 같이 실행합니다:

php artisan pennant:purge --except=new-api --except=purchase-button

편의를 위해 pennant:purge 명령어는 --except-registered 플래그도 지원합니다. 이 플래그를 사용하면 서비스 프로바이더에 명시적으로 등록된 피처를 제외한 나머지 피처가 모두 퍼지됩니다:

php artisan pennant:purge --except-registered

Laravel Pennant 테스트

테스트

피처 플래그와 상호작용하는 코드를 테스트할 때, 가장 간단한 방법은 테스트 내에서 피처를 다시 정의하는 것입니다. 예를 들어, 서비스 프로바이더에 다음과 같은 피처가 정의되어 있다고 가정해 보겠습니다:

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

테스트에서 피처의 반환값을 제어하려면, 테스트 시작 시점에 피처를 재정의하면 됩니다. 아래 테스트는 서비스 프로바이더에 Arr::random() 로직이 그대로 존재하더라도 항상 통과합니다:

Pest

use Laravel\Pennant\Feature; test('피처 값을 직접 제어할 수 있다', function () { Feature::define('purchase-button', 'seafoam-green'); expect(Feature::value('purchase-button'))->toBe('seafoam-green'); });

PHPUnit

use Laravel\Pennant\Feature; public function test_it_can_control_feature_values() { Feature::define('purchase-button', 'seafoam-green'); $this->assertSame('seafoam-green', Feature::value('purchase-button')); }

클래스 기반 피처에도 동일한 방식을 사용할 수 있습니다:

Pest

use Laravel\Pennant\Feature; test('클래스 기반 피처 값을 직접 제어할 수 있다', function () { Feature::define(NewApi::class, true); expect(Feature::value(NewApi::class))->toBeTrue(); });

PHPUnit

use App\Features\NewApi; use Laravel\Pennant\Feature; public function test_it_can_control_feature_values() { Feature::define(NewApi::class, true); $this->assertTrue(Feature::value(NewApi::class)); }

피처가 Lottery 인스턴스를 반환하는 경우, 테스트에 유용한 전용 헬퍼를 활용할 수 있습니다.

스토어 설정

테스트 중 Pennant가 사용할 스토어는 애플리케이션의 phpunit.xml 파일에 PENNANT_STORE 환경 변수를 정의하여 지정할 수 있습니다:

<?xml version="1.0" encoding="UTF-8"?> <phpunit colors="true"> <!-- ... --> <php> <env name="PENNANT_STORE" value="array"/> <!-- ... --> </php> </phpunit>

NOTE

테스트 환경에서는 array 스토어를 사용하는 것을 권장합니다. 인메모리 방식으로 동작하기 때문에 테스트 간 피처 상태가 격리되며, 데이터베이스나 외부 저장소에 의존하지 않아 테스트 속도가 빠릅니다.

커스텀 Pennant 드라이버 추가하기

드라이버 구현하기

기본 제공되는 스토리지 드라이버가 애플리케이션 요구사항에 맞지 않는다면, 직접 드라이버를 작성할 수 있습니다. 커스텀 드라이버는 반드시 Laravel\Pennant\Contracts\Driver 인터페이스를 구현해야 합니다.

<?php namespace App\Extensions; use Laravel\Pennant\Contracts\Driver; class RedisFeatureDriver implements Driver { public function define(string $feature, callable $resolver): void {} public function defined(): array {} public function getAll(array $features): array {} public function get(string $feature, mixed $scope): mixed {} public function set(string $feature, mixed $scope, mixed $value): void {} public function setForAllScopes(string $feature, mixed $value): void {} public function delete(string $feature, mixed $scope): void {} public function purge(array|null $features): void {} }

각 메서드를 Redis 연결을 통해 구현하면 됩니다. 구현 방법의 구체적인 예시는 Pennant 소스 코드Laravel\Pennant\Drivers\DatabaseDriver를 참고하세요.

NOTE

Laravel은 확장 클래스를 위한 별도의 디렉토리를 기본으로 제공하지 않습니다. 원하는 위치에 자유롭게 배치하면 됩니다. 위 예시에서는 RedisFeatureDriver를 보관하기 위해 Extensions 디렉토리를 생성했습니다.

드라이버 등록하기

드라이버 구현이 완료되면 Laravel에 등록해야 합니다. Feature 파사드의 extend 메서드를 사용하면 Pennant에 드라이버를 추가할 수 있습니다. extend 메서드는 애플리케이션의 서비스 프로바이더 중 하나의 boot 메서드에서 호출하는 것이 좋습니다.

<?php namespace App\Providers; use App\Extensions\RedisFeatureDriver; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; use Laravel\Pennant\Feature; class AppServiceProvider extends ServiceProvider { /** * 애플리케이션 서비스를 등록합니다. */ public function register(): void { // ... } /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Feature::extend('redis', function (Application $app) { return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []); }); } }

드라이버를 등록한 뒤에는 config/pennant.php 설정 파일에서 redis 드라이버를 지정해 사용할 수 있습니다.

'stores' => [ 'redis' => [ 'driver' => 'redis', 'connection' => null, ], // ... ],

외부에서 피처 정의하기

드라이버가 서드파티 피처 플래그 플랫폼을 래핑하는 형태라면, Pennant의 Feature::define 메서드 대신 해당 플랫폼에서 직접 피처를 정의하게 될 것입니다. 이 경우 커스텀 드라이버는 Laravel\Pennant\Contracts\Driver 외에 Laravel\Pennant\Contracts\DefinesFeaturesExternally 인터페이스도 함께 구현해야 합니다.

<?php namespace App\Extensions; use Laravel\Pennant\Contracts\Driver; use Laravel\Pennant\Contracts\DefinesFeaturesExternally; class FeatureFlagServiceDriver implements Driver, DefinesFeaturesExternally { /** * 주어진 스코프에 대해 정의된 피처 목록을 반환합니다. */ public function definedFeaturesForScope(mixed $scope): array {} /* ... */ }

definedFeaturesForScope 메서드는 전달된 스코프에 대해 외부 플랫폼에서 정의된 피처 이름 목록을 배열로 반환해야 합니다.

이벤트

Pennant는 애플리케이션 전반에서 기능 플래그를 추적할 때 유용한 다양한 이벤트를 디스패치합니다.

Laravel\Pennant\Events\FeatureRetrieved

기능이 확인될 때마다 디스패치되는 이벤트입니다. 특정 기능 플래그의 사용 현황을 측정하고 지표를 수집하는 데 활용할 수 있습니다.

Laravel\Pennant\Events\FeatureResolved

특정 스코프에서 기능의 값이 처음으로 결정(resolve)될 때 디스패치되는 이벤트입니다.

Laravel\Pennant\Events\UnknownFeatureResolved

특정 스코프에서 알 수 없는 기능이 처음으로 결정될 때 디스패치되는 이벤트입니다. 기능 플래그를 제거하려고 했지만 코드 여기저기에 참조가 남아 있는 경우, 이 이벤트를 리스닝하면 누락된 참조를 손쉽게 찾아낼 수 있습니다.

<?php namespace App\Providers; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Laravel\Pennant\Events\UnknownFeatureResolved; class AppServiceProvider extends ServiceProvider { /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Event::listen(function (UnknownFeatureResolved $event) { Log::error("알 수 없는 기능을 결정하려 했습니다: [{$event->feature}]"); }); } }

Laravel\Pennant\Events\DynamicallyRegisteringFeatureClass

요청 처리 중 클래스 기반 기능처음으로 동적 확인될 때 디스패치되는 이벤트입니다.

Laravel\Pennant\Events\UnexpectedNullScopeEncountered

null을 지원하지 않는 기능 정의에 null 스코프가 전달될 때 디스패치되는 이벤트입니다.

이 상황은 기본적으로 안전하게 처리되어 해당 기능은 false를 반환합니다. 그러나 이 기본 동작 대신 다른 처리 방식을 적용하고 싶다면, AppServiceProviderboot 메서드에서 이 이벤트에 대한 리스너를 등록하면 됩니다.

use Illuminate\Support\Facades\Log; use Laravel\Pennant\Events\UnexpectedNullScopeEncountered; /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Event::listen(UnexpectedNullScopeEncountered::class, fn () => abort(500)); }

Laravel\Pennant\Events\FeatureUpdated

특정 스코프에 대해 기능이 업데이트될 때 디스패치되는 이벤트입니다. 일반적으로 activate 또는 deactivate를 호출할 때 발생합니다.

Laravel\Pennant\Events\FeatureUpdatedForAllScopes

모든 스코프에 대해 기능이 업데이트될 때 디스패치되는 이벤트입니다. 일반적으로 activateForEveryone 또는 deactivateForEveryone을 호출할 때 발생합니다.

Laravel\Pennant\Events\FeatureDeleted

특정 스코프에 대해 기능이 삭제될 때 디스패치되는 이벤트입니다. 일반적으로 forget을 호출할 때 발생합니다.

Laravel\Pennant\Events\FeaturesPurged

특정 기능들을 퍼지(purge)할 때 디스패치되는 이벤트입니다.

Laravel\Pennant\Events\AllFeaturesPurged

모든 기능을 퍼지(purge)할 때 디스패치되는 이벤트입니다.

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

번역일: 2026년 6월 27일