메인 콘텐츠로 건너뛰기

Laravel Pennant란

Laravel Pennant는 단순하고 가벼운 기능 플래그(Feature Flag) 패키지입니다. 기능 플래그를 사용하면 새로운 기능을 단계적으로 롤아웃하거나, A/B 테스트를 실시하거나, 트렁크 기반 개발을 보완할 수 있습니다.

기능 플래그란

기능 플래그를 사용하면 코드의 배포와 릴리스를 분리할 수 있습니다. 코드는 프로덕션 환경에 배포해두면서, 기능의 ON/OFF를 설정으로 제어할 수 있습니다.

설치

1

패키지 설치

Composer를 사용해 Pennant를 설치합니다.
composer require laravel/pennant
2

설정 파일과 마이그레이션 공개

vendor:publish Artisan 명령으로 파일을 공개합니다.
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
이를 통해 config/pennant.phpdatabase/migrations에 마이그레이션 파일이 생성됩니다.
3

마이그레이션 실행

Pennant가 기능 플래그 값을 저장하는 features 테이블을 생성합니다.
php artisan migrate

설정

config/pennant.php에서 사용할 스토리지 드라이버를 설정할 수 있습니다. Pennant는 두 종류의 드라이버를 지원합니다.
드라이버설명
database관계형 DB에 값을 영구 저장 (기본값)
array인메모리에 저장 (테스트나 일시적 사용용)
// config/pennant.php
'default' => env('PENNANT_STORE', 'database'),

기능 정의

클로저 기반 정의

기능은 Feature 파사드의 define 메서드로 정의합니다. 보통 서비스 프로바이더의 boot 메서드에서 정의합니다. 클로저에는 “스코프”(보통 인증된 사용자)가 전달됩니다.
<?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),
        });
    }
}
이 기능의 로직은 다음과 같습니다.
  • 사내 팀 멤버는 반드시 ON
  • 트래픽이 많은 고객은 OFF
  • 그 외에는 1% 확률로 ON
기능이 처음 체크될 때 클로저의 결과가 스토리지 드라이버에 저장됩니다. 다음번부터는 저장된 값이 사용됩니다.
정의가 Lottery만 반환하는 경우 클로저를 생략할 수 있습니다.
Feature::define('site-redesign', Lottery::odds(1, 1000));

클래스 기반 정의

Pennant는 클래스 기반의 기능 정의도 지원합니다. 클래스 기반인 경우 서비스 프로바이더에 대한 등록은 불필요합니다.
php artisan pennant:feature NewApi
생성된 클래스는 app/Features 디렉터리에 배치됩니다. 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),
        };
    }
}

저장명 커스터마이즈

기본적으로 완전 수식 클래스명이 저장됩니다. Name 애트리뷰트로 이름을 커스터마이즈할 수 있습니다.
use Laravel\Pennant\Attributes\Name;

#[Name('new-api')]
class NewApi
{
    // ...
}

기능 체크 가로채기 (before 메서드)

클래스 기반 기능에는 before 메서드를 정의할 수 있습니다. 이 메서드는 스토리지에서 값을 가져오기 전에 인메모리에서 실행되며, null 이외의 값을 반환하면 그 값이 사용됩니다.
class NewApi
{
    public function before(User $user): mixed
    {
        if (Config::get('features.new-api.disabled')) {
            return $user->isInternalTeamMember();
        }
    }

    public function resolve(User $user): mixed
    {
        // ...
    }
}
before 메서드는 버그 발생 시 기능을 긴급 비활성화하거나, 특정 일시에 롤아웃을 스케줄링할 때 유용합니다.

기능 확인

Feature::active() / Feature::inactive()

active 메서드로 기능이 활성화되어 있는지 확인할 수 있습니다. 기본적으로 현재 인증된 사용자에 대해 체크가 수행됩니다.
use Laravel\Pennant\Feature;

if (Feature::active('new-api')) {
    // 새로운 API를 사용하는 처리
}
클래스 기반 기능인 경우 클래스명을 전달합니다.
use App\Features\NewApi;

if (Feature::active(NewApi::class)) {
    // ...
}
그 밖에 편리한 메서드도 준비되어 있습니다.
// 모든 기능이 활성화되어 있는지
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']);

조건부 실행 (when / unless)

when 메서드를 사용하면 기능이 활성화되어 있는 경우에만 클로저를 실행할 수 있습니다.
return Feature::when(NewApi::class,
    fn () => $this->resolveNewApiResponse($request),
    fn () => $this->resolveLegacyApiResponse($request),
);
unlesswhen의 반대로, 기능이 비활성화되어 있는 경우에 첫 번째 클로저를 실행합니다.
return Feature::unless(NewApi::class,
    fn () => $this->resolveLegacyApiResponse($request),
    fn () => $this->resolveNewApiResponse($request),
);

HasFeatures 트레이트

HasFeatures 트레이트를 User 모델에 추가하면, 모델에서 직접 기능을 체크할 수 있습니다.
use Laravel\Pennant\Concerns\HasFeatures;

class User extends Authenticatable
{
    use HasFeatures;
}
if ($user->features()->active('new-api')) {
    // ...
}

// 값 가져오기
$value = $user->features()->value('purchase-button');

// 조건부 실행
$user->features()->when('new-api',
    fn () => /* ... */,
    fn () => /* ... */,
);

Blade 디렉티브

Blade 템플릿에서는 @feature 디렉티브를 사용할 수 있습니다.
@feature('site-redesign')
    {{-- 'site-redesign'이 활성화된 경우 --}}
@else
    {{-- 'site-redesign'이 비활성화된 경우 --}}
@endfeature

@featureany(['site-redesign', 'beta'])
    {{-- 둘 중 하나가 활성화된 경우 --}}
@endfeatureany

미들웨어

EnsureFeaturesAreActive 미들웨어를 사용하면 라우트 접근에 기능이 필요함을 지정할 수 있습니다. 기능이 비활성화되어 있는 경우 400 Bad Request가 반환됩니다.
use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::get('/api/servers', function () {
    // ...
})->middleware(EnsureFeaturesAreActive::using('new-api', 'servers-api'));
응답을 커스터마이즈하려면 whenInactive 메서드를 사용합니다.
EnsureFeaturesAreActive::whenInactive(
    function (Request $request, array $features) {
        return new Response(status: 403);
    }
);

인메모리 캐시

Pennant는 1리퀘스트 내에서 기능의 결과를 인메모리에 캐시합니다. 같은 기능 플래그를 여러 번 체크해도 추가 DB 쿼리는 발생하지 않습니다. 캐시를 수동으로 클리어하려면 flushCache 메서드를 사용합니다.
Feature::flushCache();

스코프

스코프 지정

기본적으로 인증된 사용자가 스코프가 되지만, for 메서드로 임의의 스코프를 지정할 수 있습니다.
// 특정 사용자에 대해 체크
Feature::for($user)->active('new-api');

// 팀에 대해 체크
Feature::for($user->team)->active('billing-v2');
팀별로 기능을 관리하는 예입니다.
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);
});

기본 스코프 커스터마이즈

Feature::resolveScopeUsing으로 기본 스코프를 커스터마이즈할 수 있습니다.
Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);
설정 후에는 for를 생략하면 기본 스코프가 사용됩니다.
Feature::active('billing-v2');
// 위는 아래와 동등
Feature::for($user->team)->active('billing-v2');

Nullable Scope

스코프가 null인 경우(미인증 라우트, Artisan 명령 등), 기능 정의가 null에 대응하지 않으면 자동으로 false가 반환됩니다. null을 다루는 경우 nullable 타입으로 정의해 주세요.
Feature::define('new-api', fn (User|null $user) => match (true) {
    $user === null => true,
    $user->isInternalTeamMember() => true,
    $user->isHighTrafficCustomer() => false,
    default => Lottery::odds(1 / 100),
});

리치 기능 값

기능은 boolean 이외의 값도 반환할 수 있습니다. 예를 들어 A/B 테스트에서 버튼의 색상을 제어하는 경우입니다.
Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));
값을 가져오려면 value 메서드를 사용합니다.
$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
리치 값을 사용하는 경우, false 이외의 모든 값이 활성으로 간주됩니다.
when 메서드에 리치 값이 전달되는 경우, 첫 번째 클로저에 값이 전달됩니다.
Feature::when('purchase-button',
    fn ($color) => /* $color에 값이 들어감 */,
    fn () => /* 비활성 시 */,
);

여러 기능 가져오기

values 메서드로 여러 기능의 값을 한 번에 가져올 수 있습니다.
Feature::values(['billing-v2', 'purchase-button']);

// [
//     'billing-v2' => false,
//     'purchase-button' => 'blue-sapphire',
// ]
all 메서드로 정의된 모든 기능의 값을 가져올 수 있습니다.
Feature::all();
클래스 기반 기능을 all의 결과에 포함시키려면 서비스 프로바이더에서 discover를 호출합니다.
Feature::discover();
이를 통해 app/Features 디렉터리의 모든 기능 클래스가 등록됩니다.

Eager Loading

루프 안에서 기능 체크를 수행하는 경우 성능 문제가 발생할 수 있습니다. load 메서드를 사용해 미리 값을 가져와 두면 해결할 수 있습니다.
// NG: 루프마다 DB 쿼리가 발생
foreach ($users as $user) {
    if (Feature::for($user)->active('notifications-beta')) {
        $user->notify(new RegistrationSuccess);
    }
}

// OK: 미리 일괄 가져오기
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',
]);

값 업데이트

수동 업데이트

activate / deactivate 메서드로 기능의 ON/OFF를 전환할 수 있습니다.
// 기본 스코프에서 활성화
Feature::activate('new-api');

// 특정 스코프에서 비활성화
Feature::for($user->team)->deactivate('billing-v2');

// 리치 값 설정
Feature::activate('purchase-button', 'seafoam-green');
저장된 값을 잊게 하려면 forget 메서드를 사용합니다. 다음 체크 시에 정의로부터 재평가됩니다.
Feature::forget('purchase-button');

일괄 업데이트

activateForEveryone / deactivateForEveryone으로 스토리지 내 모든 스코프에 일괄 적용할 수 있습니다.
Feature::activateForEveryone('new-api');
Feature::activateForEveryone('purchase-button', 'seafoam-green');
Feature::deactivateForEveryone('new-api');

기능 퍼지

기능을 애플리케이션에서 삭제한 경우나 정의를 변경한 경우, 스토리지에서 값을 삭제(퍼지)할 수 있습니다.
// 단일 기능 퍼지
Feature::purge('new-api');

// 여러 기능 퍼지
Feature::purge(['new-api', 'purchase-button']);

// 모든 기능 퍼지
Feature::purge();
Artisan 명령으로도 퍼지할 수 있습니다. 배포 파이프라인에 통합하면 편리합니다.
php artisan pennant:purge new-api

# 여러 개 지정
php artisan pennant:purge new-api purchase-button

# 지정한 기능 이외를 모두 퍼지
php artisan pennant:purge --except=new-api --except=purchase-button

# 서비스 프로바이더에 등록된 기능 이외를 퍼지
php artisan pennant:purge --except-registered

테스트

기능 재정의

테스트에서는 Feature::define으로 기능을 재정의함으로써 반환 값을 제어할 수 있습니다.
tab=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');
});
tab=PHPUnit
use Laravel\Pennant\Feature;

public function test_it_can_control_feature_values(): void
{
    Feature::define('purchase-button', 'seafoam-green');

    $this->assertSame('seafoam-green', Feature::value('purchase-button'));
}
클래스 기반 기능도 마찬가지로 다룰 수 있습니다.
tab=Pest
test('it can control feature values', function () {
    Feature::define(NewApi::class, true);

    expect(Feature::value(NewApi::class))->toBeTrue();
});
tab=PHPUnit
use App\Features\NewApi;

public function test_it_can_control_feature_values(): void
{
    Feature::define(NewApi::class, true);

    $this->assertTrue(Feature::value(NewApi::class));
}

테스트용 스토어 설정

테스트 중에 사용할 스토어를 phpunit.xml의 환경 변수로 지정할 수 있습니다.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
    <php>
        <env name="PENNANT_STORE" value="array"/>
    </php>
</phpunit>

커스텀 드라이버

기존 드라이버가 요건에 맞지 않는 경우, 커스텀 드라이버를 작성할 수 있습니다. 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 {}
}
서비스 프로바이더의 boot 메서드에서 extend를 호출해 등록합니다.
Feature::extend('redis', function (Application $app) {
    return new RedisFeatureDriver($app->make('redis'), $app->make('events'), []);
});
등록 후에는 config/pennant.php에서 드라이버를 지정할 수 있습니다.
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => null,
    ],
],

정리

하고 싶은 일방법
기능 설치composer require laravel/pennant
기능 정의Feature::define('name', fn ($user) => ...)
기능 확인Feature::active('name')
Blade에서 확인@feature('name') ... @endfeature
값 업데이트Feature::activate('name') / deactivate
전체 스코프에 적용Feature::activateForEveryone('name')
테스트에서 제어Feature::define('name', true)로 재정의
스토리지에서 퍼지Feature::purge('name')

다음 단계

디버그와 에러 핸들링

애플리케이션의 예외 처리와 리포트의 구조를 배웁니다.

Laravel Pulse

애플리케이션의 성능 모니터링 대시보드를 도입합니다.
마지막 수정일 2026년 7월 13일