> ## 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의 bind·singleton·scoped 내부 구현을 분석하여, 장수명 워커에서 상태를 이월하지 않는 패키지 설계와 라이프사이클 경계 테스트를 해설합니다.

패키지를 일반 HTTP 요청뿐 아니라 Octane이나 큐 워커에서도 사용할 수 있게 하려면, 서비스가 **어느 기간 동안, 무엇을 보유해도 되는지**를 정해야 합니다. 요청별 데이터를 singleton에 저장하면 다른 처리에서도 오래된 데이터를 참조할 가능성이 있습니다.

이 페이지에서는 Laravel Framework `v13.36.0`의 컨테이너와 큐 워커, 그리고 Octane의 구현을 확인하고, [서비스 컨테이너](/ko/service-container)의 기본 등록 방법을 패키지의 장기 유지보수와 연결합니다. `scoped()`를 선택하는 것만으로 객체 간의 참조나 정적 상태가 모두 리셋되는 것은 아닙니다.

## 등록 방법은 서비스의 보유 기간으로 선택한다

| 등록 | 일반 `make()`를 통한 조회 | 적합한 설계 |
| - | - | - |
| `bind()` | 해석할 때마다 생성한다 | 작업 단위로 생성해도 되는 서비스 |
| `singleton()` | 같은 컨테이너에서는 생성된 인스턴스를 재사용한다 | 요청·잡 고유의 상태를 보유하지 않는 서비스 |
| `scoped()` | 같은 라이프사이클에서는 재사용하고, 경계에서 저장된 인스턴스를 제거한다 | 하나의 요청·하나의 잡 안에서 공유하는 상태 |

이것은 일반적인 해석을 비교한 표입니다. 파라미터를 명시한 해석이나 contextual binding 등 특수한 경로는 별도로 확인하세요.

`bind()`라도 조회한 서비스를 다른 singleton의 프로퍼티에 저장하면 그 참조는 남습니다. 등록 방법은 컨테이너의 생성·재사용 규칙이며, 사용자가 보유한 객체까지 강제로 다시 만드는 규칙이 아닙니다.

## scoped는 공유 등록과 제거 대상 기록을 조합한다

확인한 `Container::scoped()`는 다음 두 가지를 수행합니다.

1. 등록 키를 `scopedInstances`에 기록합니다.
2. `singleton()`을 호출하여 공유 바인딩으로 등록합니다.

인스턴스 자체는 singleton과 같은 `instances`에 저장됩니다. `forgetScopedInstances()`는 기록된 키에 대해 저장된 인스턴스를 `unset()`합니다. 바인딩의 생성 처리는 남아 있으므로 다음 일반 해석에서 새 객체를 만들 수 있습니다.

```mermaid theme={null}
flowchart TD
    A["register()에서 scoped 등록"] --> B["생성 방법과 제거 대상 키를 기록"]
    B --> C["처리 A에서 처음 make()"]
    C --> D["인스턴스 A를 생성·저장"]
    D --> E["처리 A 안에서는 같은 인스턴스를 조회"]
    E --> F["라이프사이클 경계에서<br>forgetScopedInstances()"]
    F --> G["컨테이너에 저장된 인스턴스를 제거"]
    G --> H["처리 B의 make()에서 인스턴스 B를 생성"]
```

이 처리는 오래된 객체의 프로퍼티를 초기화하거나 다른 객체가 가진 참조를 바꾸지 않습니다. 참조가 남아 있는 오래된 객체는 계속 사용될 수 있습니다.

### 경계를 만드는 것은 실행 환경

컨테이너 자체가 HTTP 요청이나 잡의 시작을 감시하는 것은 아닙니다.

* **큐 워커**: `QueueServiceProvider`가 스코프를 리셋하는 콜백을 Worker에 전달합니다. `Worker::daemon()`은 다음 잡을 가져오기 전 루프에서 이를 호출하며, 콜백 안에서 `forgetScopedInstances()`와 Facade의 해석된 인스턴스 정리 등을 수행합니다.
* **Octane**: 공식 기본 설정에서는 `OperationTerminated`에 `FlushTemporaryContainerInstances`를 등록합니다. 이 리스너가 `forgetScopedInstances()`를 호출합니다.
* **자체 상주 커맨드나 루프**: 단순히 `make()`를 반복해도 경계가 되지 않습니다. 실행 기반 쪽에서 라이프사이클을 설계해야 합니다.

<Warning>
  일반적인 애플리케이션 처리나 패키지의 `boot()`에서 임의로 `forgetScopedInstances()`를 호출하지 마세요. 컨테이너 전체의 scoped 서비스에 영향을 줍니다. 또한 동기 실행을 포함한 모든 잡 경로가 상주 Worker와 같은 리셋 경로를 거치는 것은 아닙니다.
</Warning>

## 처리 안에서 공유하는 상태를 scoped로 만든다

배송 패키지가 하나의 요청·잡 안에서 배송지 식별자를 공유하고 싶은 예를 생각해 봅시다. HTTP Request를 직접 보유하지 않고 필요한 값만 저장합니다.

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

namespace Acme\Courier;

final class DeliveryContext
{
    private ?string $destinationId = null;

    public function setDestinationId(string $destinationId): void
    {
        $this->destinationId = $destinationId;
    }

    public function destinationId(): ?string
    {
        return $this->destinationId;
    }
}
```

패키지의 서비스 프로바이더에서는 생성 시 사용자의 상태를 읽지 않고 빈 context를 등록합니다.

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

namespace Acme\Courier;

use Illuminate\Support\ServiceProvider;

class CourierServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->scoped(DeliveryContext::class);
    }
}
```

사용하는 애플리케이션의 컨트롤러나 잡의 `handle()`에서 입력 검증과 권한 확인을 마친 식별자를 설정합니다. 같은 라이프사이클에서 이 타입을 해석하는 다른 서비스는 같은 context를 참조할 수 있습니다.

```php theme={null}
$context = app(\Acme\Courier\DeliveryContext::class);
$context->setDestinationId($authorizedDestinationId);
```

이 context는 인가 기능이 아닙니다. 식별자를 설정할 수 있다는 것과 해당 배송지에 대한 접근이 허용되었다는 것은 별개입니다. 또한 `scoped()`는 데이터베이스나 외부 캐시로의 영속화도 아닙니다. 다음 잡에 전달할 값은 잡의 데이터로 명시적으로 전달합니다.

<Info>
  Octane에서는 서비스 프로바이더의 `register()`와 `boot()`가 워커의 초기 기동 시에 실행됩니다. 현재 사용자나 배송지를 `boot()`에서 context에 저장하는 설계로 만들지 마세요. 처리별 값은 처리가 시작된 후에 설정합니다.
</Info>

## singleton에서 scoped로의 참조는 자동으로 갱신되지 않는다

다음 등록은 context만 scoped로 만들어도 의도대로 동작하지 않습니다.

```php theme={null}
$this->app->singleton(DeliveryService::class, function ($app) {
    return new DeliveryService($app->make(DeliveryContext::class));
});
```

`DeliveryService`가 생성자에서 context를 프로퍼티에 저장한다면, 처음 생성한 context를 계속 보유합니다. 컨테이너에서 context의 저장을 제거해도 이미 생성된 `DeliveryService`의 프로퍼티는 바뀌지 않습니다.

대책은 서비스의 책임에 맞게 선택합니다.

1. **처리별로 필요한 값을 메서드 인수로 전달한다**. 상태를 공유할 필요가 없다면 이것이 가장 단순합니다.
2. **보유하는 쪽도 scoped로 만든다**. `DeliveryService`와 context를 모두 처리 단위로 다시 만듭니다.
3. **사용 시점에 해석하는 resolver를 전달한다**. singleton을 유지해야 하는 경우에는 현재 컨테이너에서 필요한 서비스를 그때마다 가져옵니다.

세 번째 예에서는 오래된 context를 클로저에 캡처하지 않고 호출 시점에 해석합니다.

```php theme={null}
use Illuminate\Container\Container;

$this->app->singleton(DeliveryService::class, function () {
    return new DeliveryService(
        fn () => Container::getInstance()->make(DeliveryContext::class),
    );
});
```

이 예의 `DeliveryService`는 생성자에서 `Closure`를 받아 **작업할 때마다 호출하는 API**로 변경해야 합니다. 생성자 안에서 resolver를 한 번만 호출하고 그 결과를 저장하면 해결되지 않습니다. 이는 공식 Octane 문서의 container resolver를 패키지 고유의 context에 응용한 설계 예입니다.

### scoped로 사라지지 않는 상태도 확인한다

`scoped()`는 다음 상태를 자동으로 제거하지 않습니다.

* 패키지 고유의 정적 프로퍼티에 추가한 값
* 다른 singleton이나 전역 변수에 저장한 context에 대한 참조
* singleton 내부에 보유한 "현재 사용자", "현재 배송지"
* 자체 Facade·캐시 계층이 보유한 참조

표준 실행 기반에 의한 리셋과 패키지 고유 상태의 리셋은 구별합니다. 처리별 상태를 보유하는 곳을 줄이는 편이 모든 실행 환경에서 자체 정리 처리를 유지하는 것보다 단순합니다.

## 같은 컨테이너에서 경계를 넘는 테스트 작성하기

HTTP 테스트가 매번 새 애플리케이션을 만드는 것만으로는 상주 프로세스에서 같은 컨테이너를 계속 사용하는 문제를 놓칠 수 있습니다. 먼저 컨테이너 단독으로 같은 처리 안에서의 공유와 경계 이후의 재생성을 검증합니다.

다음 테스트는 PHPUnit 형식입니다. 첫 번째 테스트는 공식 Container 테스트와 마찬가지로 `forgetScopedInstances()`를 사용하여, Worker 전체를 기동하지 않고 경계의 작용을 확인합니다.

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

namespace Acme\Courier\Tests;

use Acme\Courier\DeliveryContext;
use Illuminate\Container\Container;
use PHPUnit\Framework\TestCase;

class ServiceLifetimeTest extends TestCase
{
    public function test_context_is_shared_then_recreated(): void
    {
        $container = new Container;
        $container->scoped(DeliveryContext::class);

        $first = $container->make(DeliveryContext::class);
        $first->setDestinationId('destination-a');

        $this->assertSame($first, $container->make(DeliveryContext::class));
        $this->assertSame('destination-a', $first->destinationId());

        $container->forgetScopedInstances();

        $second = $container->make(DeliveryContext::class);
        $this->assertNotSame($first, $second);
        $this->assertNull($second->destinationId());
    }

    public function test_singleton_keeps_a_captured_scoped_dependency(): void
    {
        $container = new Container;
        $container->scoped(DeliveryContext::class);
        $container->singleton('courier.holder', function ($app) {
            return (object) ['context' => $app->make(DeliveryContext::class)];
        });

        $holder = $container->make('courier.holder');
        $oldContext = $holder->context;

        $container->forgetScopedInstances();

        $this->assertSame($holder, $container->make('courier.holder'));
        $this->assertSame($oldContext, $holder->context);
        $this->assertNotSame($oldContext, $container->make(DeliveryContext::class));
    }
}
```

두 번째는 참조가 남는 사양을 기록하는 테스트입니다. 실제 패키지에는 채택한 대책으로 새 context나 값이 사용되는지 검증하는 테스트도 추가합니다.

| 업데이트 시 확인 사항 | 기대하는 결과 |
| - | - |
| 같은 처리 안에서 context를 여러 번 해석 | 같은 인스턴스·설정한 값을 공유한다 |
| 같은 컨테이너에서 경계를 넘어 해석 | 새 인스턴스이며, 이전 값을 가지지 않는다 |
| singleton이 context를 사용 | 오래된 context를 저장하지 않고 현재 값을 사용한다 |
| 사용하는 앱에서 Octane의 연속 요청을 실행 | 두 번째 요청이 첫 번째의 배송지 등을 이어받지 않는다 |
| 상주 큐 워커에서 연속 잡을 실행 | 잡별 설정값을 분리한다 |
| Laravel·Octane을 업데이트 | 리셋 호출 경로와 사용하는 앱의 설정을 다시 확인한다 |

컨테이너 단독 테스트는 Octane의 sandbox나 큐 워커의 실제 실행 경로를 검증하는 것이 아닙니다. 지원을 표명하는 실행 환경에서 연속 처리를 수행하는 통합 테스트도 필요합니다. 등록 방법이나 서비스 구현을 변경한 배포에서는 상주 프로세스를 다시 로드하여 새 코드를 적용합니다.

## 관련 페이지

<Columns cols={2}>
  <Card title="패키지의 서비스 해석 후 훅" icon="puzzle-piece" href="/ko/advanced/package-service-resolution">
    afterResolving과 공유 인스턴스 조회, 재해석 시 설정의 차이를 확인합니다.
  </Card>

  <Card title="Laravel Octane" icon="bolt" href="/ko/octane">
    Request나 컨테이너의 보유, 상주 프로세스 운영상의 주의점을 확인합니다.
  </Card>
</Columns>

## 참고한 1차 자료

* [Laravel 공식 문서: Binding scoped singletons](https://github.com/laravel/docs/blob/13.x/container.md#binding-scoped-singletons)
* [Laravel 공식 문서: Dependency injection and Octane](https://github.com/laravel/docs/blob/13.x/octane.md#dependency-injection-and-octane)
* [Container: scoped()·singleton()·forgetScopedInstances()·resolve()](https://github.com/laravel/framework/blob/v13.36.0/src/Illuminate/Container/Container.php)
* [ContainerTest: scoped의 공유와 재생성](https://github.com/laravel/framework/blob/v13.36.0/tests/Container/ContainerTest.php)
* [QueueServiceProvider: Worker에 전달하는 resetScope](https://github.com/laravel/framework/blob/v13.36.0/src/Illuminate/Queue/QueueServiceProvider.php)
* [Worker: daemon()에서의 스코프 리셋](https://github.com/laravel/framework/blob/v13.36.0/src/Illuminate/Queue/Worker.php)
* [Octane: FlushTemporaryContainerInstances](https://github.com/laravel/octane/blob/ecc9254e6d79cd607f191015f634f9be944e2267/src/Listeners/FlushTemporaryContainerInstances.php)
* [Octane: OperationTerminated의 기본 리스너](https://github.com/laravel/octane/blob/ecc9254e6d79cd607f191015f634f9be944e2267/config/octane.php)


## Related topics

- [Laravel 패키지 개발](/ko/advanced/package-development.md)
- [고급 주제](/ko/advanced/index.md)
- [요청 라이프사이클](/ko/lifecycle.md)
- [세션 라이프사이클 이벤트](/ko/packages/laravel-copilot-sdk/session-lifecycle-event.md)
- [서비스 컨테이너](/ko/service-container.md)


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