> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Laravel Pennant 실전 유스케이스

> Laravel Pennant의 기본을 넘어선 실전 패턴을 설명합니다. 팀 단위 스코프, 긴급 킬 스위치, 다크 런치, 관리 명령 등 공식 문서만으로는 알기 어려운 노하우를 소개합니다.

기본 사용법은 [가이드 페이지](/ko/pennant)를 참고하세요. 이 페이지에서는 공식 문서에 실려 있지 않은 실전 패턴을 소개합니다.

***

## 멀티 테넌트 SaaS에서의 팀 스코프

개인 사용자가 아니라 팀(테넌트) 단위로 플래그를 관리하고 싶은 경우, 기본 스코프를 팀으로 변경합니다.

```php theme={null}
// AppServiceProvider
Feature::resolveScopeUsing(fn () => Auth::user()?->currentTeam);
```

이것만으로 `Feature::active('billing-v2')`가 자동으로 현재 팀을 대상으로 합니다. 팀 멤버가 누구든 같은 팀에 속하는 한 같은 결과가 반환되므로, UI의 일관성이 유지됩니다.

팀 가입일에 따른 단계적 롤아웃 예시입니다.

```php theme={null}
use App\Models\Team;
use Illuminate\Support\Carbon;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('new-billing', function (Team $team) {
    // 2024년 이후에 등록한 팀은 즉시 유효
    if ($team->created_at->isAfter(new Carbon('2024-01-01'))) {
        return true;
    }

    // 2022~2023년 등록은 10%씩 롤아웃
    if ($team->created_at->isAfter(new Carbon('2022-01-01'))) {
        return Lottery::odds(1 / 10);
    }

    // 오래된 팀은 신중하게 1%부터
    return Lottery::odds(1 / 100);
});
```

특정 팀만 활성화하고 싶은 경우(엔터프라이즈 고객에게 선행 제공 등):

```php theme={null}
// 배포 후 특정 팀만 수동으로 활성화
Feature::for($earlyAccessTeam)->activate('new-billing');
```

***

## 긴급 킬 스위치 (`before` 메서드 활용)

프로덕션에서 버그가 발각되었을 때, 코드 롤백 없이 즉시 기능을 무효화할 수 있습니다. 클래스 기반 피처에 `before` 메서드를 추가하면 스토리지의 값보다 먼저 체크가 실행됩니다.

```php theme={null}
<?php

namespace App\Features;

use App\Models\User;
use Illuminate\Support\Facades\Config;

class NewCheckout
{
    /**
     * 프로덕션 버그 발생 시 config로 즉시 무효화할 수 있는 긴급 스위치
     */
    public function before(User $user): mixed
    {
        // config/features.php의 값, 또는 env로 제어
        if (Config::boolean('features.new_checkout_disabled', false)) {
            return false; // 전원에게 즉시 무효화
        }

        // 관리자는 항상 유효 (디버그를 위해)
        if ($user->isAdmin()) {
            return true;
        }

        return null; // null을 반환하면 일반적인 resolve()로 진행
    }

    public function resolve(User $user): mixed
    {
        return $user->isPremium() || Lottery::odds(1 / 5);
    }
}
```

환경 변수 `FEATURES_NEW_CHECKOUT_DISABLED=true`를 설정하는 것만으로, DB를 건드리지 않고 기능을 멈출 수 있습니다. 배포 불필요한 킬 스위치입니다.

<Tip>
  `before`가 `null`을 반환하면 `resolve()`가 실행됩니다. `false`를 반환하면 즉시 비활성으로 처리됩니다. 긴급 시 이외에는 `null`을 반환하도록 하세요.
</Tip>

***

## 스케줄된 롤아웃

특정 일시에 자동으로 전 사용자에게 공개하고 싶은 케이스. `before` 메서드로 구현할 수 있습니다.

```php theme={null}
public function before(User $user): mixed
{
    $rolloutDate = Config::get('features.new_api.rollout_date');

    if ($rolloutDate && Carbon::parse($rolloutDate)->isPast()) {
        return true; // 롤아웃 날짜를 지나면 전원에게 공개
    }

    return null; // 아직인 경우는 일반적인 resolve()로
}

public function resolve(User $user): mixed
{
    // 롤아웃 전에는 사내 멤버만
    return $user->isInternalTeamMember();
}
```

```ini theme={null}
# .env
FEATURES_NEW_API_ROLLOUT_DATE=2025-04-01
```

이제 `2025-04-01`을 지나면 자동으로 전 사용자에게 공개됩니다. 배포 없이, DB 없이, Artisan 명령 없이 자동화할 수 있습니다.

***

## 다크 런치 (Shadow Mode)

새로운 로직을 사용자에게 보이지 않고 프로덕션 데이터로 동작시켜, 기존 로직과 결과를 비교하는 패턴입니다. 문제가 없다면 플래그를 켜는 것만으로 릴리스 완료입니다.

```php theme={null}
class RecommendationController
{
    public function index(Request $request)
    {
        $legacyResult = $this->getLegacyRecommendations($request->user());

        // shadow 모드에서는 새 알고리즘을 실행하여 비교하지만, 표시는 기존 로직
        if (Feature::for($request->user())->active('recommendation-v2-shadow')) {
            try {
                $newResult = $this->getNewRecommendations($request->user());

                // 차이를 로그에 기록 (사용자에게는 보여주지 않음)
                if ($legacyResult !== $newResult) {
                    Log::channel('shadow_mode')->info('recommendation diff', [
                        'user_id' => $request->user()->id,
                        'legacy'  => $legacyResult,
                        'new'     => $newResult,
                    ]);
                }
            } catch (\Throwable $e) {
                Log::error('shadow mode error', ['error' => $e->getMessage()]);
            }
        }

        // 응답은 항상 기존 로직의 결과
        return response()->json($legacyResult);
    }
}
```

로그를 확인하고 차이가 없어지면, 플래그를 `recommendation-v2`로 전환하기만 하면 됩니다.

***

## 이벤트를 사용한 A/B 테스트 결과 수집

공식 문서에는 `FeatureRetrieved` 이벤트에 대한 언급이 있지만, 실제 A/B 테스트 집계 패턴은 제시되어 있지 않습니다.

`FeatureResolved` 이벤트는 피처의 값이 **처음으로** 해결되었을 때만 발화합니다. 이것을 사용하여 사용자의 배리언트 할당을 기록합니다.

```php theme={null}
use Laravel\Pennant\Events\FeatureResolved;
use Illuminate\Support\Facades\Event;

// AppServiceProvider나 EventServiceProvider에 등록
Event::listen(function (FeatureResolved $event) {
    if ($event->feature !== 'purchase-button') {
        return;
    }

    // 사용자에게 배리언트 할당 시점에 기록
    analytics()->identify($event->scope?->id, [
        'ab_purchase_button' => $event->value,
    ]);
});
```

컨버전 발생 시에 별도로 기록하면, 배리언트별 컨버전율을 집계할 수 있습니다.

```php theme={null}
// 구매 완료 시
Event::dispatch(new PurchaseCompleted($user, $product));
```

```php theme={null}
// PurchaseCompleted 리스너
analytics()->track('purchase_completed', [
    'user_id'              => $user->id,
    'ab_purchase_button'   => Feature::for($user)->value('purchase-button'),
]);
```

<Info>
  `FeatureResolved`와 `FeatureRetrieved`의 차이: `FeatureResolved`는 초회 평가 시에만 발화, `FeatureRetrieved`는 매 체크마다 발화합니다. 할당 기록에는 `FeatureResolved`, 페이지 뷰 추적에는 `FeatureRetrieved`가 적합합니다.
</Info>

***

## 큐잉된 잡에서의 스코프

큐의 잡에서는 인증된 사용자가 존재하지 않으므로, 피처 체크가 예상과 다른 동작을 할 수 있습니다. 잡에 스코프를 명시적으로 가지게 하세요.

```php theme={null}
class SendWeeklyDigest implements ShouldQueue
{
    public function __construct(
        private readonly User $user
    ) {}

    public function handle(): void
    {
        // NG: 인증 사용자가 존재하지 않으므로 항상 false가 됨
        // if (Feature::active('new-digest-layout')) { ... }

        // OK: 사용자를 명시적으로 전달
        if (Feature::for($this->user)->active('new-digest-layout')) {
            $this->sendNewLayout($this->user);
        } else {
            $this->sendLegacyLayout($this->user);
        }
    }
}
```

잡의 디스패치 시에 플래그를 평가하여 잡에 전달하는 방법도 있습니다.

```php theme={null}
// 컨트롤러에서 평가하여 잡에 전달
$useNewLayout = Feature::for($user)->active('new-digest-layout');

SendWeeklyDigest::dispatch($user, $useNewLayout);
```

***

## Artisan 명령에 의한 관리 UI

플래그를 관리하는 간단한 Artisan 명령을 만들면, 배포 없이 프로덕션 플래그를 조작할 수 있습니다.

```php theme={null}
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Laravel\Pennant\Feature;

class ManageFeatureFlag extends Command
{
    protected $signature = 'feature {action : activate|deactivate|status} {feature : Feature name}';
    protected $description = '기능 플래그를 관리한다';

    public function handle(): void
    {
        $feature = $this->argument('feature');

        match ($this->argument('action')) {
            'activate'   => $this->activate($feature),
            'deactivate' => $this->deactivate($feature),
            'status'     => $this->status($feature),
            default      => $this->error('알 수 없는 액션입니다'),
        };
    }

    private function activate(string $feature): void
    {
        Feature::activateForEveryone($feature);
        $this->info("✓ {$feature}을(를) 모든 사용자에게 활성화했습니다");
    }

    private function deactivate(string $feature): void
    {
        Feature::deactivateForEveryone($feature);
        $this->info("✓ {$feature}을(를) 모든 사용자에게 비활성화했습니다");
    }

    private function status(string $feature): void
    {
        $count = \DB::table('features')
            ->where('name', $feature)
            ->where('value', json_encode(true))
            ->count();

        $this->line("{$feature}: {$count} 스코프에서 유효");
    }
}
```

```shell theme={null}
php artisan feature activate new-checkout
php artisan feature deactivate new-checkout
php artisan feature status new-checkout
```

***

## 피처 이름의 안전한 리팩토링 (`Name` 어트리뷰트)

클래스 기반 피처를 리네임할 때, DB에 저장된 플래그 이름이 바뀌면 모든 사용자의 플래그가 리셋되어 버립니다. `Name` 어트리뷰트로 저장 이름을 고정합시다.

```php theme={null}
use Laravel\Pennant\Attributes\Name;

// 이전 클래스명: CheckoutV2 → 새 클래스명: NewCheckoutExperience
// DB에는 항상 'checkout-v2'로 저장됨
#[Name('checkout-v2')]
class NewCheckoutExperience
{
    public function resolve(User $user): mixed
    {
        return $user->isPremium();
    }
}
```

이것으로 클래스 이름을 자유롭게 리팩토링해도, DB의 데이터는 그대로 유지됩니다.

***

## 정리

공식 문서의 기본을 다졌다면, 이하의 패턴으로 Pennant의 진가가 발휘됩니다.

| 패턴                    | 사용처                 |
| --------------------- | ------------------- |
| 팀 스코프                 | 멀티 테넌트 SaaS         |
| `before` 킬 스위치        | 프로덕션 버그의 긴급 대응      |
| 스케줄 롤아웃               | 일시 지정 릴리스 자동화       |
| 다크 런치                 | 안전한 새 알고리즘의 프로덕션 검증 |
| `FeatureResolved` 이벤트 | A/B 테스트 결과의 정확한 수집  |
| 잡의 명시적 스코프            | 큐 환경에서의 정확한 플래그 평가  |
| 관리 Artisan 명령         | 배포 없는 프로덕션 플래그 조작   |
| `Name` 어트리뷰트          | 안전한 클래스 리팩토링        |

<Card title="Laravel Pennant 가이드" icon="flag" href="/ko/pennant">
  설치부터 기본 사용법까지는 가이드 페이지를 참고하세요.
</Card>


## Related topics

- [InteractsWithData 트레이트](/ko/advanced/interacts-with-data.md)
- [파일 스토리지](/ko/filesystem.md)
- [PHP AST](/ko/advanced/php-ast.md)
- [PHP FFI](/ko/advanced/ffi.md)
- [Eloquent의 스코프](/ko/advanced/eloquent-scopes.md)
