> ## 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 13의 Schedule과 해석 후 훅을 분석해 패키지의 정기 태스크를 명시적으로 활성화하는 설계, 이중 등록, 락 이름, 설정 변경과 테스트를 해설합니다.

패키지에 정기적인 동기화나 Heartbeat를 추가하려면 **처리의 제공**과 **스케줄 등록**을 분리합니다. 설치만으로 사용 애플리케이션의 정기 처리가 늘어나지 않게 하고, 사용자가 활성화 여부·실행 빈도·여러 서버에서의 동작을 선택할 수 있도록 설계합니다.

이 페이지에서는 Laravel Framework `v13.39.0`의 공식 소스를 확인하고 [태스크 스케줄링](https://kawax.biz/ko/scheduling.md)의 기본 조작을 패키지 개발에 적용합니다. 프로바이더 로드는 [패키지 자동 감지의 내부 구조](https://kawax.biz/ko/advanced/package-discovery.md)를, 설정 공개는 [패키지 설정 병합과 캐시](https://kawax.biz/ko/advanced/package-config-merging.md)를 참조하세요.

## Schedule이 해석된 후에 등록하기

일반적인 Laravel 애플리케이션에서는 `FoundationServiceProvider`가 `Schedule::class`를 singleton으로 등록합니다. 그 생성 처리는 Console Kernel의 `resolveConsoleSchedule()`을 호출해 애플리케이션의 타임존과 스케줄러용 캐시 스토어를 적용합니다.

패키지는 이 공유 Schedule을 확장해야 하며, 자체적인 `new Schedule()`을 만들고 끝내지 않도록 합니다. 다음 프로바이더는 `Acme\Courier\`가 Composer의 PSR-4에서 `src/`에 대응하고, 패키지에 `config/courier.php`가 있는 구성입니다.

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

namespace Acme\Courier;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;

class CourierServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__.'/../config/courier.php', 'courier');

        $this->app->singleton(CourierSchedule::class);
    }

    public function boot(): void
    {
        if (! $this->app->runningInConsole()) {
            return;
        }

        $this->callAfterResolving(Schedule::class, function (Schedule $schedule): void {
            $this->app->make(CourierSchedule::class)->register($schedule);
        });
    }
}
```

`callAfterResolving()`은 아직 해석되지 않았다면 이후의 해석을 기다리고, 이미 해석되었다면 인스턴스를 가져와 그 자리에서도 콜백을 호출합니다. 따라서 다른 프로바이더가 먼저 Schedule을 사용한 경우에도 등록할 수 있습니다. 자세한 내용은 [패키지의 서비스 해석 후 훅](https://kawax.biz/ko/advanced/package-service-resolution.md)을 참조하세요.

`runningInConsole()`은 특정 Artisan 명령어를 판별하는 것이 아닙니다. 이 예에서는 HTTP를 통해 Schedule을 사용하는 용도를 대상에서 제외합니다. 또한 훅을 등록하는 프로바이더 자체는 지연 프로바이더로 만들지 않습니다. 필요한 시점까지 `boot()`가 실행되지 않으면 훅도 등록되지 않기 때문입니다.

```mermaid theme={null}
flowchart TD
    A["패키지 프로바이더의 boot()"] --> B["Schedule 해석 후 훅을 등록"]
    B --> C{"Schedule이 해석되었는가?"}
    C -->|No| D["최초 해석까지 대기"]
    C -->|Yes| E["기존 singleton을 가져옴"]
    D --> F["활성화 설정과 등록 여부 판정"]
    E --> F
    F -->|활성화되어 있고 미등록| G["이벤트 정의를 추가"]
    F -->|비활성화 또는 등록됨| H["추가하지 않음"]
    G --> I["이후 schedule:run에서<br>실행 조건을 평가"]
```

> [!NOTE]
>
> 이벤트 등록과 태스크 실행은 별개입니다. 이 프로바이더를 로드해도 그 자리에서 Heartbeat가 실행되는 것은 아닙니다. 등록만으로는 cron이나 `schedule:work`도 시작되지 않습니다.

## 사용자가 명시적으로 활성화하기

패키지 측의 `config/courier.php`는 초기 상태를 비활성화로 둡니다.

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

return [
    'schedule' => [
        'enabled' => false,
        'cron' => '*/5 * * * *',
        'timezone' => 'UTC',
        'overlap_minutes' => 10,
        'on_one_server' => false,
    ],
];
```

사용 애플리케이션에서는 설정을 공개하거나 작성하고 `schedule.enabled`를 `true`로 변경합니다. 이 예에서는 값을 등록 시점에 읽습니다. 타임존은 UTC를 기본값으로 하며, 다른 타임존을 선택하는 경우에는 서머타임으로 인한 실행 중복·누락도 고려합니다.

설정은 [얕게 병합되므로](https://kawax.biz/ko/advanced/package-config-merging.md) 등록기에서도 각 키의 기본값을 지정합니다. 부분적인 덮어쓰기로 `schedule` 배열의 다른 키가 사라지더라도 기본 설정으로 등록할 수 있습니다.

## 같은 Schedule에 대한 이중 등록 방지하기

다음 `src/CourierSchedule.php`는 로그 1건을 기록하는 최소한의 Heartbeat를 등록합니다. `WeakMap`을 사용해 **이 등록기가 같은 Schedule 객체에 추가하는 것은 한 번뿐**이 되도록 합니다.

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

namespace Acme\Courier;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Config\Repository;
use Psr\Log\LoggerInterface;
use WeakMap;

final class CourierSchedule
{
    public const EVENT_NAME = 'acme-courier:heartbeat';

    private WeakMap $registered;

    public function __construct(private Repository $config)
    {
        $this->registered = new WeakMap();
    }

    public function register(Schedule $schedule): void
    {
        if (! $this->config->get('courier.schedule.enabled', false)
            || isset($this->registered[$schedule])) {
            return;
        }

        $event = $schedule->call(static function (LoggerInterface $logger): void {
            $logger->info('Courier heartbeat');
        })
            ->name(self::EVENT_NAME)
            ->cron($this->config->get('courier.schedule.cron', '*/5 * * * *'))
            ->timezone($this->config->get('courier.schedule.timezone', 'UTC'))
            ->withoutOverlapping($this->config->get('courier.schedule.overlap_minutes', 10));

        if ($this->config->get('courier.schedule.on_one_server', false)) {
            $event->onOneServer();
        }

        $this->registered[$schedule] = true;
    }
}
```

이 등록기를 singleton으로 했기 때문에 프로바이더의 훅에서 다시 가져와도 등록 여부 판정을 공유합니다. 한편 새로운 Schedule에는 등록할 수 있습니다. 프로세스 전체의 정적인 '등록함' 플래그로 새 컨테이너나 테스트의 Schedule까지 제외하지 않도록 합니다.

`Schedule::call()`·`exec()`·`job()`은 이벤트를 배열에 추가합니다. 같은 이름의 이벤트를 찾아 교체하거나 중복을 제거하는 API가 아닙니다.

> [!WARNING]
>
> `withoutOverlapping()`은 등록의 중복을 바로잡지 않습니다. 같은 태스크가 2건 등록되어 있고 먼저 실행된 것이 종료되어 락이 해제되면, 뒤의 이벤트도 실행될 수 있습니다. `onOneServer()`도 이중 등록을 제거하는 기능이 아닙니다.
>
> 위의 WeakMap은 이 등록기의 중복만 방지합니다. 사용자가 `routes/console.php`에 같은 태스크를 추가하는 경우에는 패키지의 자동 등록을 비활성화하고 등록 담당을 한 곳으로 정합니다.

## 이벤트 이름은 락의 호환성과도 관련된다

`name()`은 `description()`의 별칭이지만, 락 이름에 미치는 영향은 이벤트 종류에 따라 다릅니다. 다음은 기본 mutex 이름입니다.

| 이벤트 | mutex 이름의 바탕이 되는 값 | `name()`의 역할 |
| - | - | - |
| `command()`·`exec()`의 Event | cron 식과 정규화된 명령어 문자열 | 표시용 설명. 기본 mutex 이름에는 포함되지 않음 |
| `call()`·`job()`의 CallbackEvent | 이벤트의 description | 표시 이름과 락 식별. cron 식은 포함되지 않음 |

CallbackEvent에서는 `withoutOverlapping()`이나 `onOneServer()`보다 **먼저 비어 있지 않은 이름**을 붙입니다. 이름이 설정되지 않았다면 이들 메서드는 예외를 던집니다.

`job()`은 보통 잡 클래스 이름 등을 초기 이름으로 사용합니다. 다만 같은 잡을 서로 다른 인수·대상으로 등록하는 경우에는 대상별 이름을 명시합니다. 인수만 바꾼다고 해서 초기 이름이 자동으로 다른 이름이 되지는 않습니다.

```php theme={null}
use Acme\Courier\Jobs\SyncCatalogJob;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new SyncCatalogJob('tenant-a'))
    ->name('acme-courier:catalog:tenant-a')
    ->everyFiveMinutes()
    ->onOneServer();

Schedule::job(new SyncCatalogJob('tenant-b'))
    ->name('acme-courier:catalog:tenant-b')
    ->everyFiveMinutes()
    ->onOneServer();
```

`SyncCatalogJob`은 패키지가 제공하는 잡 클래스의 예입니다. 잡 구현 방법은 [큐와 잡](https://kawax.biz/ko/queues.md)을 참조하세요.

이름이나 명령어, cron 식을 변경하면 종류에 따라 락의 식별자도 바뀝니다. 구버전과 신버전의 프로세스가 섞여 있는 배포에서는 같은 처리라도 서로 다른 락을 획득할 가능성이 있습니다. 단순한 표시 문구 변경과 같이 취급하지 말고, 태스크 이름을 안정적인 공개 사양으로 관리합니다.

### onOneServer와 withoutOverlapping을 구분해서 생각하기

* `onOneServer()`는 해당 실행 시각에 실행을 담당할 서버를 선택합니다. 확인한 `CacheSchedulingMutex`는 이벤트의 mutex 이름에 시·분을 덧붙여 1시간의 유효 기간으로 유지합니다. 태스크 종료에 맞춰 해제되는 중복 방지 락과는 별개입니다.
* `withoutOverlapping()`은 이전 실행이 계속되는 동안의 중복을 방지합니다. 지정하는 기한의 단위는 분이며 기본값은 1,440분입니다. 기한이 너무 짧으면 처리가 끝나기 전에 락이 만료됩니다.
* `job()`이 큐에 투입하는 경우, 스케줄 이벤트의 완료는 잡 처리의 완료가 아닙니다. 워커에서의 중복 제어는 잡 측의 유니크 잡이나 `WithoutOverlapping` 미들웨어 등으로 별도로 설계합니다.

`onOneServer()`를 활성화하는 사용자에게는 공식 문서가 지정하는 `database`·`memcached`·`dynamodb`·`redis` 공유 캐시 구성을 필수 조건으로 안내합니다. 락은 업무 처리의 exactly-once를 보장하지 않으므로, 다시 실행되어도 문제가 생기지 않는 처리도 필요합니다.

## 무거운 의존성은 실행 시점에 해석하기

위 Heartbeat의 `LoggerInterface`는 이벤트 실행 시 `CallbackEvent`가 컨테이너의 `call()`을 사용해 해석합니다. 훅 등록 시점에 로그를 출력하거나 클라이언트를 생성하지 않는 구성입니다.

| 정의 방법 | 등록 시 주의할 점 |
| - | - |
| `call()`에 클로저를 전달 | 본체는 나중에 호출된다. 단, 바깥에서 생성해 capture한 객체는 이미 생성되어 있다 |
| `call(new Task)` | `new Task`는 등록 시점에 실행된다 |
| `command(CommandClass::class)` | Schedule이 컨테이너로 클래스를 생성하고 이름과 설명을 가져온다 |
| `job(JobClass::class)` | 잡 생성은 이벤트의 콜백 안, 즉 실행 시점까지 미뤄진다 |

생성자에 외부 연결이나 업무 처리를 두지 않는 설계는 [패키지의 Artisan 명령어 등록과 의존성 해결](https://kawax.biz/ko/advanced/package-commands.md)에서도 중요합니다.

또한 `call()`과 `job()`은 CallbackEvent이므로 `runInBackground()`를 사용할 수 없습니다. 시간이 걸리는 처리는 큐에 위임하거나, 필요에 따라 `command()`의 백그라운드 실행을 선택합니다.

## 애플리케이션 전체의 설정을 가로채지 않기

`Schedule::useCache()`는 Schedule이 가진 mutex 객체의 스토어를 변경합니다. 패키지의 이벤트만을 대상으로 하는 설정이 아니며, 같은 mutex를 공유하는 이벤트에도 영향을 줍니다. 패키지의 `boot()`에서 무조건 호출하지 말고, 사용 애플리케이션이 선택하는 설정으로 다룹니다.

마찬가지로 `Schedule::alwaysOnOneServer()`·스케줄러 일시 정지·유지보수 모드 중의 실행 방침을 패키지가 임의로 변경하지 않도록 합니다.

## 설정 변경 후에는 Schedule을 새로 생성하기

예시의 `enabled`·cron 식·타임존은 등록 시점의 설정입니다. 이미 생성된 Schedule에 대해 `config()` 값만 바꿔서는 이벤트가 추가·삭제되거나 설정이 다시 읽히지 않습니다. WeakMap 판정도 재설정을 위한 것이 아닙니다.

설정 캐시를 사용하는 배포에서는 캐시를 다시 빌드하고, 새 설정을 읽어 들인 프로세스에서 `schedule:list`를 확인합니다. 장시간 동작하는 스케줄러나 1분 미만의 태스크를 실행 중인 `schedule:run`에 대해서도 이전 코드·이전 설정이 남지 않는 운영 방식을 확인합니다. 1분 미만 태스크의 배포 시 중단에 대해서는 [태스크 스케줄링](https://kawax.biz/ko/scheduling.md)을 참조하세요.

## 등록을 실행과 분리해서 테스트하기

다음은 패키지의 Composer 오토로드와 PHPUnit을 사용하는 `tests/CourierScheduleTest.php`의 예입니다. mutex를 테스트 더블로 바꾸고, 외부 캐시나 실제 cron을 사용하지 않고 등록 결과를 확인합니다.

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

namespace Acme\Courier\Tests;

use Acme\Courier\CourierSchedule;
use Illuminate\Config\Repository;
use Illuminate\Console\Scheduling\EventMutex;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Console\Scheduling\SchedulingMutex;
use Illuminate\Container\Container;
use PHPUnit\Framework\TestCase;

class CourierScheduleTest extends TestCase
{
    private Container $previousContainer;
    private Repository $config;
    private CourierSchedule $registrar;

    protected function setUp(): void
    {
        parent::setUp();

        $this->previousContainer = Container::getInstance();
        $container = new Container();
        Container::setInstance($container);

        $container->instance(EventMutex::class, $this->createMock(EventMutex::class));
        $container->instance(SchedulingMutex::class, $this->createMock(SchedulingMutex::class));

        $this->config = new Repository();
        $this->registrar = new CourierSchedule($this->config);
    }

    protected function tearDown(): void
    {
        Container::setInstance($this->previousContainer);

        parent::tearDown();
    }

    public function test_disabled_by_default(): void
    {
        $schedule = new Schedule();
        $this->registrar->register($schedule);

        $this->assertCount(0, $schedule->events());
    }

    public function test_partial_config_uses_defaults_without_duplicate_registration(): void
    {
        $this->config->set('courier.schedule.enabled', true);
        $schedule = new Schedule();

        $this->registrar->register($schedule);
        $this->registrar->register($schedule);

        $this->assertCount(1, $schedule->events());
        $event = $schedule->events()[0];
        $this->assertSame(CourierSchedule::EVENT_NAME, $event->description);
        $this->assertSame('*/5 * * * *', $event->expression);
        $this->assertSame('UTC', $event->timezone);
        $this->assertTrue($event->withoutOverlapping);
        $this->assertSame(10, $event->expiresAt);
        $this->assertFalse($event->onOneServer);
    }

    public function test_explicit_schedule_settings_are_applied(): void
    {
        $this->config->set('courier.schedule', [
            'enabled' => true,
            'cron' => '0 * * * *',
            'timezone' => 'Asia/Tokyo',
            'overlap_minutes' => 30,
            'on_one_server' => true,
        ]);

        $schedule = new Schedule();
        $this->registrar->register($schedule);
        $event = $schedule->events()[0];

        $this->assertSame('0 * * * *', $event->expression);
        $this->assertSame('Asia/Tokyo', $event->timezone);
        $this->assertSame(30, $event->expiresAt);
        $this->assertTrue($event->onOneServer);
    }

    public function test_setting_changes_require_a_new_schedule(): void
    {
        $this->config->set('courier.schedule.enabled', true);
        $first = new Schedule();
        $this->registrar->register($first);

        $this->config->set('courier.schedule.cron', '0 * * * *');
        $this->registrar->register($first);

        $second = new Schedule();
        $this->registrar->register($second);

        $this->assertCount(1, $first->events());
        $this->assertCount(1, $second->events());
        $this->assertSame('*/5 * * * *', $first->events()[0]->expression);
        $this->assertSame('0 * * * *', $second->events()[0]->expression);
    }
}
```

이 테스트는 등록기의 단위 테스트입니다. 프로바이더의 자동 감지·설정 병합·Console Kernel과의 통합은 [Orchestra Testbench](https://kawax.biz/ko/advanced/package-testing.md)나 검증용 애플리케이션에서 별도로 확인합니다.

| 확인할 케이스 | 완료 조건 |
| - | - |
| 기본 설정으로 도입 | 패키지의 정기 태스크가 등록되지 않는다 |
| 활성화 후 `schedule:list` | 이벤트가 1건만 있고 빈도와 타임존이 올바르다 |
| Schedule 해석 전·해석 후에 프로바이더를 부팅 | 어느 쪽이든 등록이 반영된다 |
| 등록만 수행 | 업무 서비스의 생성·처리가 시작되지 않는다 |
| 태스크 본체를 개별 실행 | 필요한 의존성을 해석하고, 기대한 처리·예외를 확인할 수 있다 |
| 여러 서버와 장시간 처리 | 공유 캐시의 전제와 두 종류의 락을 따로 검증한다 |
| 설정 캐시가 있는 상태에서 업데이트 | 새 프로세스에 이전 태스크나 이중 등록이 남지 않는다 |

등록 테스트를 통과시키기 위해 `schedule:run`으로 사용 애플리케이션의 모든 태스크를 실행하지 않도록 합니다. 본체 테스트와 통제된 환경에서의 스케줄러 통합 테스트를 분리하면 관계없는 정기 처리의 부작용을 피할 수 있습니다.

## 관련 페이지

> [card] [태스크 스케줄링](https://kawax.biz/ko/scheduling.md)
>
> 실행 빈도, cron 도입, 큐로의 위임, 배포 시 운영 방법을 확인합니다.

> [card] [패키지의 서비스 해석 후 훅](https://kawax.biz/ko/advanced/package-service-resolution.md)
>
> 해석 전·해석된 서비스에 대한 적용과 재등록 시의 주의점을 확인합니다.

## 참고한 1차 자료

* [Laravel 공식 문서: 태스크 스케줄링](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/scheduling.md)
* [Laravel 공식 문서: 패키지 개발](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/packages.md)
* [FoundationServiceProvider: Schedule의 singleton 등록](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Console Kernel: Schedule 생성과 기본 설정](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Console/Kernel.php)
* [ServiceProvider: callAfterResolving()](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/ServiceProvider.php)
* [Schedule: 이벤트 추가, 잡의 지연 생성, mutex 공유](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Schedule.php)
* [Event: 명령어 이벤트의 mutex 이름](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Event.php)
* [CallbackEvent: 이름의 필수 조건과 실행 시 의존성 해석](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CallbackEvent.php)
* [CacheEventMutex: 중복 방지 락과 기한 변환](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheEventMutex.php)
* [CacheSchedulingMutex: 실행 시각별 락](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php)
* [공식 테스트: 이벤트 등록과 타임존](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/ConsoleEventSchedulerTest.php)
* [공식 테스트: 잡 클래스 이름의 지연 생성](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/Scheduling/ScheduleTest.php)


## Related topics

- [패키지의 Storage 드라이버 등록과 재해석](https://kawax.biz/ko/advanced/package-filesystem-drivers.md)
- [패키지의 Artisan 명령어 등록과 의존성 해결](https://kawax.biz/ko/advanced/package-commands.md)
- [패키지의 이벤트 리스너 등록과 캐시](https://kawax.biz/ko/advanced/package-events.md)
- [패키지의 라우트 등록과 캐시](https://kawax.biz/ko/advanced/package-routes.md)
- [패키지의 버전 호환성 관리](https://kawax.biz/ko/advanced/package-versioning.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.