Laravel Pennant

번역일: 2026년 7월 2일

Laravel Pennant

소개

Laravel Pennant는 군더더기 없이 가볍게 사용할 수 있는 피처 플래그(feature flag) 패키지입니다. 피처 플래그를 활용하면 새로운 기능을 전체 사용자에게 한꺼번에 공개하지 않고, 특정 사용자 그룹에만 단계적으로 롤아웃하거나, A/B 테스트를 진행하거나, 작업 중인 기능을 안전하게 숨겨두는 등 다양한 배포 전략을 유연하게 구사할 수 있습니다.

Laravel Pennant

소개

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

NOTE

피처 플래그란, 코드 배포 없이 특정 기능을 켜거나 끌 수 있도록 하는 메커니즘입니다. 예를 들어, 신규 결제 UI를 일부 사용자에게만 먼저 노출하거나, 특정 사용자 그룹을 대상으로 새 기능을 점진적으로 출시할 때 유용합니다.

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

Laravel Pennant

설정

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

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

  • array 드라이버: 확인된 피처 플래그 값을 인메모리 배열에 저장합니다. 요청이 끝나면 데이터가 사라지므로 주로 테스트 환경에서 유용합니다.
  • database 드라이버: 확인된 값을 관계형 데이터베이스에 영구적으로 저장합니다. Pennant의 기본 드라이버입니다.

기능 정의하기

기능(Feature)을 정의하려면 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/100 확률로 무작위 배정됩니다.

특정 사용자에 대해 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

기능 클래스는 서비스 컨테이너를 통해 resolve됩니다. 따라서 필요한 의존성을 생성자에 타입힌트로 주입할 수 있습니다.

저장되는 기능 이름 커스터마이징

기본적으로 Pennant는 기능 클래스의 전체 클래스명(FQCN)을 스토리지에 저장합니다. 클래스명이 바뀌더라도 저장된 기능 이름을 유지하고 싶거나, 내부 구조와 분리하고 싶다면 Name 어트리뷰트를 클래스에 추가하세요. 이 어트리뷰트에 지정한 값이 클래스명 대신 저장됩니다.

<?php namespace App\Features; use Laravel\Pennant\Attributes\Name; #[Name('new-api')] class NewApi { // ... }

Laravel Pennant — 기능 플래그 확인

기능 활성화 여부 확인

기능이 활성화되어 있는지 확인하려면 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 모델(또는 기능 플래그를 적용할 다른 모델)에 추가하면, 모델 인스턴스에서 직접 기능을 확인하는 편리한 방법을 제공합니다.

<?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 템플릿에서 기능 플래그를 간편하게 사용할 수 있도록 Pennant는 @feature@featureany 디렉티브를 제공합니다.

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

응답 커스터마이징

기능이 비활성화되었을 때 미들웨어가 반환하는 응답을 변경하려면 EnsureFeaturesAreActivewhenInactive 메서드를 사용합니다. 일반적으로 서비스 프로바이더의 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 메서드를 활용해 특정 날짜 이후 기능을 전체 사용자에게 자동으로 활성화하는 롤아웃 일정을 설정할 수도 있습니다.

<?php namespace App\Features; use App\Models\User; 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 드라이버를 사용하더라도, 같은 요청 내에서 동일한 기능 플래그를 여러 번 확인해도 추가 쿼리가 발생하지 않습니다. 또한 요청 전체에서 기능의 결과가 일관되게 유지됩니다.

인메모리 캐시를 직접 비워야 할 경우 flushCache 메서드를 사용합니다.

Feature::flushCache();

스코프 (Scope)

스코프 지정하기

앞서 설명했듯이, 피처는 일반적으로 현재 인증된 사용자를 기준으로 확인합니다. 하지만 항상 그럴 필요는 없습니다. Feature 파사드의 for 메서드를 사용하면 특정 피처를 어떤 스코프에 대해 확인할지 직접 지정할 수 있습니다:

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

피처 스코프는 "사용자"에만 국한되지 않습니다. 예를 들어, 새로운 결제 경험을 개인 사용자가 아닌 팀 단위로 롤아웃하는 상황을 생각해 보세요. 오래된 팀일수록 더 천천히 롤아웃하고 싶을 수도 있습니다. 이런 경우 피처 리졸브 클로저는 다음과 같이 작성할 수 있습니다:

use App\Models\Team; use Illuminate\Support\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'); } // ...

기본 스코프 설정

피처를 확인할 때마다 매번 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');

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는 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 모프 맵을 사용하고 있다면, Pennant에서도 모프 맵을 활용해 저장된 피처 데이터를 애플리케이션의 클래스 구조에서 분리할 수 있습니다.

서비스 프로바이더에서 Eloquent 모프 맵을 정의한 뒤, Feature 파사드의 useMorphMap 메서드를 호출하면 됩니다:

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

NOTE

모프 맵을 활성화하면 데이터베이스에 저장되는 스코프 식별자가 클래스명 대신 모프 맵의 별칭으로 바뀝니다. 기존 데이터가 있다면 마이그레이션 또는 데이터 변환 작업이 필요할 수 있으니 주의하세요.

다양한 값을 가지는 피처

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

예를 들어, "지금 구매" 버튼의 색상을 세 가지 중 하나로 A/B 테스트한다고 가정해 보겠습니다. 이 경우 피처 정의에서 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) => /* 피처가 활성화된 경우, $color에 값이 전달됩니다 */, fn () => /* 피처가 비활성화된 경우 */, );

마찬가지로 조건부 실행 unless 메서드를 사용할 때는, 피처의 값이 두 번째 클로저(선택 사항)의 인수로 전달됩니다:

Feature::unless('purchase-button', fn () => /* 피처가 비활성화된 경우 */, fn ($color) => /* 피처가 활성화된 경우, $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가 그 존재를 알지 못합니다. 따라서 all 메서드를 호출했을 때, 아직 한 번도 확인되지 않은 클래스 기반 기능은 결과에 포함되지 않을 수 있습니다.

클래스 기반 기능을 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는 단일 요청 내에서 확인된 피처 값을 인메모리 캐시에 보관합니다. 하지만 루프 안에서 피처를 반복적으로 확인하는 경우, 성능 문제가 발생할 수 있습니다. Pennant의 Eager Loading 기능을 사용하면 이를 효율적으로 해결할 수 있습니다.

예를 들어, 사용자 컬렉션을 순회하며 피처 활성화 여부를 확인하는 코드를 살펴보겠습니다:

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

데이터베이스 드라이버를 사용하는 경우, 이 코드는 루프의 각 사용자마다 별도의 쿼리를 실행합니다. 사용자 수가 많다면 수백 건의 쿼리가 발생할 수 있습니다.

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

Laravel Pennant

값 업데이트

피처의 값이 처음 결정(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

특정 피처만 남기고 나머지를 모두 퍼지할 수도 있습니다. 예를 들어 new-apipurchase-button의 저장 값은 유지하면서 나머지 피처를 모두 제거하려면 --except 옵션을 사용하세요:

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('it can control feature values', 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('it can control feature values', 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\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 메서드는 전달받은 스코프에 대해 정의된 피처 이름의 배열을 반환해야 합니다.

Laravel Pennant

이벤트

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 스코프가 전달될 때 디스패치됩니다.

이 상황은 기본적으로 graceful하게 처리되며, 해당 기능은 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년 7월 2일