> ## 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의 잡 저장과 복원을 살펴보고, 클래스명과 프로퍼티의 호환성, 모델 재조회, 이전 페이로드 테스트, 워커 업데이트 순서를 설명합니다.

패키지를 업데이트해도 업데이트 전에 디스패치한 잡을 처리할 수 있도록 합니다. 큐에 저장되는 것은 실행할 코드가 아니라 잡 클래스의 이름과 상태입니다. 새 코드의 단위 테스트뿐 아니라 **이전 코드가 저장한 상태를 새 코드로 복원하는 테스트**가 필요합니다.

> [!NOTE]
>
> 이 페이지는 Laravel 13을 대상으로 Framework `v13.42.0`의 구현을 확인했습니다. [큐의 기본](https://kawax.biz/ko/queues.md)과 [패키지 테스트](https://kawax.biz/ko/advanced/package-testing.md)를 전제로 합니다. 아래 호환성 설계는 패키지 측의 방침이며, Laravel이 자체 페이로드 형식을 자동으로 마이그레이션하는 기능이 아닙니다.

## 저장과 실행 사이에 코드가 바뀐다

객체 잡에서는 `Queue::createObjectPayload()`가 `serialize(clone $job)`를 실행하고 결과를 페이로드의 `data.command`에 넣습니다. 암호화하는 경우에도 그 앞 단계에서 같은 직렬화가 이루어집니다.

워커 측의 `CallQueuedHandler::getCommand()`는 저장된 데이터를 `unserialize()`하고, 복원한 잡을 Dispatcher에 넘깁니다. 일반적인 `handle()`은 그 시점에 동작 중인 워커의 코드와 서비스 컨테이너로 실행됩니다.

```mermaid theme={null}
sequenceDiagram
    participant P as 디스패치 측<br>패키지 이전 버전
    participant Q as 큐
    participant W as 워커<br>패키지 새 버전

    P->>Q: 클래스명과 프로퍼티 저장
    Note over P,W: 배포로 코드 업데이트
    Q->>W: 저장된 페이로드 가져오기
    W->>W: 현재 클래스로 복원
    W->>W: 현재 handle() 실행
```

장기 실행 워커를 재시작하지 않으면 업데이트 후에도 이전 코드가 계속 동작합니다. 롤링 배포에서는 "이전 디스패치 측과 새 워커"뿐 아니라 "새 디스패치 측과 이전 워커" 조합도 고려합니다.

| 변경하는 것 | 저장된 잡에 미치는 영향 |
| - | - |
| 잡의 완전한 클래스명 | 이전 이름으로 클래스를 로드할 수 없으면 정상적인 잡으로 복원할 수 없다 |
| 프로퍼티 이름·가시성 | 저장 시의 키와 복원 시의 키가 일치하지 않을 수 있다 |
| 프로퍼티 타입 | 이전 값을 대입할 수 없어 복원 시 실패할 수 있다 |
| 프로퍼티의 선언 시 기본값 | 저장이 생략된 값의 의미가 바뀔 수 있다 |
| `handle()`이나 의존 서비스 | 복원할 수 있어도 이전 데이터에 대한 처리 결과가 바뀔 수 있다 |

클래스의 이름 변경이나 삭제는 사용자가 그 클래스를 직접 호출하는지만으로 판단하지 않습니다. 대기 중·지연 중·재시도 대기 중인 잡과 실패한 잡의 보존 기간도 호환성의 대상입니다.

## SerializesModels가 저장하는 것

`Illuminate\Foundation\Queue\Queueable`은 내부에서 `SerializesModels`를 사용합니다. 이는 모델 치환뿐 아니라, 잡 자신의 프로퍼티를 나열하는 `__serialize()`와 값을 되돌리는 `__unserialize()`도 제공합니다.

### 선언 시 기본값과 같은 값은 저장되지 않는다

`__serialize()`는 static·미초기화 등의 프로퍼티를 제외하고, **프로퍼티의 선언 시 기본값과 현재 값이 엄격하게 같은 경우에도 저장을 생략**합니다.

```php theme={null}
public string $format = 'csv';
```

이 프로퍼티가 `'csv'` 그대로라면 저장 배열에 `format`이 들어가지 않습니다. 이후 릴리스에서 선언 시 기본값을 `'json'`으로 바꾸면 이전 잡에도 `'json'`이 적용될 수 있습니다. 단순한 겉모습의 기본값 변경이 아니라 이전 데이터를 읽는 방식의 변경입니다.

### 복원 시 생성자는 다시 호출되지 않는다

`__unserialize()`는 현재 클래스에 존재하고 저장 배열에도 키가 있는 프로퍼티에 값을 대입합니다. 생성자를 통한 입력 보완이나 검증은 실행하지 않습니다.

```php theme={null}
public function __construct(
    public string $format = 'csv',
) {}
```

이 예의 `'csv'`는 **생성자 인수의 기본값**입니다. 프로퍼티 자체의 선언 시 기본값이 아니므로, `format`이 없는 이전 페이로드를 복원할 때 보완되지 않습니다. 추가한 타입 지정 프로퍼티가 미초기화 상태로 남으면 나중에 접근할 때 실패합니다.

또한 public의 키는 이름 그대로, protected는 가시성을 나타내는 접두사가 붙은 키, private은 클래스명도 포함한 키가 됩니다. 프로퍼티를 public에서 private으로 바꾸는 변경도 이전 페이로드로 검증합니다.

> [!WARNING]
>
> `SerializesModels`가 나열하는 것은 잡의 프로퍼티입니다. 임의의 DTO 내부나 배열 깊숙이 있는 모델까지 재귀적으로 같은 변환이 이루어진다고 생각하지 않습니다. 모델 이외의 객체를 넘기는 경우 그 클래스와 저장 형식도 호환성의 대상이 됩니다.

## 식별자와 실행 서비스를 분리한다

실행에 필요한 식별자나 선택지는 잡에 저장하고, API 클라이언트나 Exporter 같은 실행 서비스는 `handle()`에 주입합니다. 생성자에 서비스를 넘겨 보관하면 그 내부 상태까지 저장 대상이 될 수 있습니다.

다음은 이전 버전에서는 `reportId`만 가지고 있던 같은 이름의 잡에 형식 선택과 페이로드 버전을 추가하는 예입니다. `ReportExporter`는 패키지가 제공하고 컨테이너에 bind하는 계약으로, `export(string $reportId, string $format): void`를 가진다고 가정합니다.

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

namespace Acme\Reports\Jobs;

use Acme\Reports\Contracts\ReportExporter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use UnexpectedValueException;

class GenerateReport implements ShouldQueue
{
    use Queueable;

    public string $format = 'csv';

    public int $payloadVersion = 1;

    public function __construct(
        public string $reportId,
        string $format = 'csv',
    ) {
        $this->format = $format;
        $this->payloadVersion = 2;
    }

    public function handle(ReportExporter $exporter): void
    {
        if (! in_array($this->payloadVersion, [1, 2], true)) {
            throw new UnexpectedValueException('Unsupported report payload version.');
        }

        $exporter->export($this->reportId, $this->format);
    }
}
```

이 설계에서는 누락된 값의 의미와 새로 디스패치하는 값을 분리합니다.

* 이전 페이로드에 `format`이 없으면 선언 시 기본값인 `'csv'`를 사용한다.
* 이전 페이로드에 `payloadVersion`이 없으면 선언 시 기본값인 `1`로 취급한다.
* 새로 생성할 때는 생성자가 `payloadVersion`을 `2`로 설정한다. 선언 시 기본값과 다르므로 이 값은 저장된다.
* 새 잡의 `format`이 `'json'`이면 그 값도 저장된다. `'csv'`면 생략되므로 선언 시 기본값 `'csv'`를 앞으로도 유지한다.

나중에 새 잡의 기본 형식만 바꾸려면 생성자 인수의 기본값과 대입을 변경하고, 이전 데이터용 프로퍼티 기본값과는 분리해서 검토할 수 있습니다. 페이로드 버전은 패키지의 릴리스 번호가 아니라 **저장 형식과 해석의 세대**로 관리합니다.

> [!TIP]
>
> 버전 번호를 추가하는 것만으로는 호환성을 얻을 수 없습니다. 이 예의 새 버전은 이전 형식도 처리할 수 있지만, 이전 버전의 워커가 새 `format`이나 `payloadVersion`을 이해한다는 보장은 없습니다. 뒤에서 설명할 디스패치 측과 워커의 업데이트 순서도 필요합니다.

## 모델을 넘기는 경우는 스냅숏과 구별한다

모델을 직접 프로퍼티에 저장하면 `SerializesAndRestoresModelIdentifiers`는 클래스·ID·연결명·로드된 관계명 등을 `ModelIdentifier`로 치환합니다. 단일 모델을 복원할 때는 DB에서 `firstOrFail()`로 다시 조회하고 대상 관계를 로드합니다.

이는 디스패치 시점의 속성이나 관련 데이터를 고정하는 구조가 아닙니다.

* 실행 전에 레코드가 업데이트되면 복원 후에는 업데이트된 내용을 참조한다.
* 미리 적용한 관계의 필터 조건은 저장되지 않으므로, 필요한 조건은 실행 시에 다시 붙인다.
* 불필요한 관계는 `withoutRelations()`나 `#[WithoutRelations]`로 저장 대상에서 제외한다.
* 모델이 삭제된 경우 잡의 `handle()` 이전의 복원 단계에서 실패할 수 있다.

삭제된 모델의 잡을 폐기해도 된다면 공식 `#[DeleteWhenMissingModels]`를 검토할 수 있습니다. 다만 패키지가 임의로 폐기를 기본 동작으로 삼지 말고, 사용자에게 실패 방침을 공개합니다. ID만 넘기고 `handle()` 안에서 검색하는 설계라면 그 검색의 실패나 건너뛰기는 직접 제어합니다.

디스패치 시점의 내용이 필요하다면 필요한 값을 명시적으로 저장하거나, 불변의 리포트 요청 등을 DB에 기록하고 그 ID를 넘깁니다. 단순히 모델을 넘기는 것과 업무 데이터의 스냅숏을 저장하는 것을 구별합니다.

### 커밋 후 디스패치는 별개의 문제

트랜잭션 중에 생성한 레코드를 처리하는 경우 커밋 전에 워커가 읽기 시작하지 않도록 합니다.

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($reportId) {
    // 이 트랜잭션 안에서 대상 레코드를 생성·업데이트한다.

    GenerateReport::dispatch($reportId)->afterCommit();
});
```

`afterCommit()`은 열려 있는 부모 트랜잭션이 커밋될 때까지 디스패치를 기다리고, 롤백 시에는 그 디스패치를 폐기합니다. 클래스명·프로퍼티의 호환성이나 외부 처리의 중복 실행을 해결하는 기능이 아닙니다.

## fake와 이전 페이로드 테스트를 분리한다

일반적인 `Queue::fake()`는 디스패치를 기록하기 위한 것입니다. `assertPushed()`가 통과했다고 해서 저장·복원이나 새 버전에 의한 이전 페이로드 처리를 확인한 것은 아닙니다.

현재 클래스에서의 왕복은 다음과 같이 검증할 수 있습니다.

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;
use Illuminate\Support\Facades\Queue;

Queue::fake()->serializeAndRestore();

GenerateReport::dispatch('report-001', 'json');

Queue::assertPushed(GenerateReport::class, function (GenerateReport $job) {
    return $job->reportId === 'report-001'
        && $job->format === 'json'
        && $job->payloadVersion === 2;
});
```

`serializeAndRestore()`는 fake 안에서 `serialize()`와 `unserialize()`를 실행합니다. 다만 실제 백엔드로의 디스패치·워커에 의한 실행·이전 버전 코드에서의 생성은 검증하지 않습니다.

### 이전 버전에서 생성한 fixture를 남긴다

이전 버전의 `GenerateReport`가 `reportId`만 가진 상태에서, 실제 데이터를 포함하지 않는 고정된 잡을 생성합니다. 아래는 이전 버전의 테스트 환경에서 한 번 실행하는 준비용 코드입니다. `tests/fixtures/`는 미리 준비합니다.

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;

file_put_contents(
    __DIR__.'/fixtures/generate-report-v1.serialized',
    serialize(new GenerateReport('report-legacy')),
);
```

fixture를 버전 관리하고 새 버전의 테스트에서 읽어 들입니다. 테스트할 때마다 새 버전으로 다시 생성하면 하위 호환성 검증이 아니게 되므로 고정한 채로 둡니다.

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

namespace Acme\Reports\Tests;

use Acme\Reports\Jobs\GenerateReport;

class JobCompatibilityTest extends TestCase
{
    public function test_new_payload_keeps_explicit_values(): void
    {
        $job = unserialize(serialize(new GenerateReport('report-001', 'json')));

        $this->assertInstanceOf(GenerateReport::class, $job);
        $this->assertSame('report-001', $job->reportId);
        $this->assertSame('json', $job->format);
        $this->assertSame(2, $job->payloadVersion);
    }

    public function test_default_format_is_omitted_but_new_version_is_saved(): void
    {
        $values = (new GenerateReport('report-001'))->__serialize();

        $this->assertArrayNotHasKey('format', $values);
        $this->assertSame(2, $values['payloadVersion']);
    }

    public function test_old_payload_uses_legacy_defaults(): void
    {
        $job = unserialize(file_get_contents(
            __DIR__.'/fixtures/generate-report-v1.serialized',
        ));

        $this->assertInstanceOf(GenerateReport::class, $job);
        $this->assertSame('report-legacy', $job->reportId);
        $this->assertSame('csv', $job->format);
        $this->assertSame(1, $job->payloadVersion);
    }
}
```

이 예의 `TestCase`는 [패키지 테스트 환경](https://kawax.biz/ko/advanced/package-testing.md)에서 준비하는 기본 클래스입니다. `unserialize()`에는 직접 관리하는 fixture만 넘기고 외부 입력을 넘기지 마세요.

또한 복원한 이전 잡의 `handle()`을 테스트용 Exporter로 실행해 출력과 저장 결과까지 검증합니다. 모델을 넘기는 경우는 업데이트·삭제·연결·관계 재조회를, 실제로 사용하는 큐 연결에서는 커밋 후 디스패치와 재시도도 별도로 검증합니다.

## 새 형식을 디스패치하기 전에 읽는 쪽을 업데이트한다

**1. 이전 형식을 읽을 수 있는 릴리스를 준비한다**

이전 클래스명과 이전 값의 해석을 유지하면서 새 형식도 읽을 수 있는 코드를 먼저 배포합니다. fixture로 복원뿐 아니라 처리 결과도 확인합니다.

**2. 이전 워커가 남지 않았는지 확인한다**

사용하는 앱의 배포 절차에 워커 교체를 포함합니다. 일반적인 `queue:work`에서는 `php artisan queue:restart`로 현재 잡 처리 후의 종료를 요청하고, 프로세스 매니저가 새 버전을 기동하는지 확인합니다. 재시작 시그널에는 캐시가 사용됩니다.

**3. 새 형식의 디스패치를 활성화한다**

읽는 쪽이 대응한 후에 기능 플래그 등으로 새 형식의 디스패치로 전환합니다. 같은 코드의 배포만으로 새 형식의 생성도 시작되는 경우, 디스패치를 일시 정지하거나 새·이전 큐와 대응하는 워커를 분리하는 등 이전 워커에 넘기지 않는 절차가 필요합니다.

**4. 재시도와 롤백 기간도 유지보수한다**

실패한 잡의 재시도나 체인 안에서 나중에 디스패치되는 잡도 확인합니다. 새 형식을 저장한 후 이전 코드로 되돌린다면 이전 코드에서도 읽을 수 있는지 검증합니다. 큐가 한 번 비었다는 것만으로 이전 클래스나 이전 형식의 대응을 삭제하지 않습니다.

`queue:restart`는 저장된 페이로드를 다시 쓰는 커맨드가 아닙니다. 패키지의 업데이트 안내에는 지원 형식, 필요한 워커 교체, 이전 형식을 폐지하는 조건을 명시합니다. [버전 호환성 관리](https://kawax.biz/ko/advanced/package-versioning.md)와 [Changelog 운용](https://kawax.biz/ko/advanced/package-changelog.md)에도 같은 변경을 기록합니다.

## 참조한 1차 정보

* [Laravel 공식 문서: 잡의 구조와 의존성 주입](https://github.com/laravel/docs/blob/13.x/queues.md#class-structure)
* [Laravel 공식 문서: 커밋 후 디스패치](https://github.com/laravel/docs/blob/13.x/queues.md#jobs-and-database-transactions)
* [Laravel 공식 문서: 워커와 배포](https://github.com/laravel/docs/blob/13.x/queues.md#queue-workers-and-deployment)
* [Laravel 공식 문서: 삭제된 모델의 처리](https://github.com/laravel/docs/blob/13.x/queues.md#ignoring-missing-models)
* [Queue: 객체 페이로드 생성](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/Queue.php)
* [Queueable: 잡용 트레이트의 구성](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Foundation/Queue/Queueable.php)
* [SerializesModels: 프로퍼티의 저장과 복원](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/SerializesModels.php)
* [SerializesAndRestoresModelIdentifiers: 모델의 치환과 재조회](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php)
* [CallQueuedHandler: 복원과 실행](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/CallQueuedHandler.php)
* [QueueFake: fake 안에서의 저장·복원](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Support/Testing/Fakes/QueueFake.php)
* [Laravel 공식 테스트: 모델의 직렬화](https://github.com/laravel/framework/blob/v13.42.0/tests/Integration/Queue/ModelSerializationTest.php)


## Related topics

- [Orchestra Testbench로 Laravel 패키지를 테스트하기](https://kawax.biz/ko/advanced/package-testing.md)
- [고급 주제](https://kawax.biz/ko/advanced/index.md)
- [패키지의 Morph map과 저장 데이터의 호환성](https://kawax.biz/ko/advanced/package-morph-maps.md)
- [패키지의 공개 에셋과 업데이트](https://kawax.biz/ko/advanced/package-assets.md)
- [패키지 마이그레이션의 배포와 업데이트](https://kawax.biz/ko/advanced/package-migrations.md)


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