> ## 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의 ContextualAttribute를 분석해 패키지 설정을 주입하는 속성, 해석 우선순위, make와 call의 차이, 수명이 긴 서비스의 테스트와 유지보수를 해설합니다.

패키지 사용자가 서비스 프로바이더에 설정 키를 나열하지 않고도 필요한 값을 주입할 수 있게 하고 싶을 때가 있습니다. 컨텍스트 속성을 공개하면 인수에 붙인 메타데이터로부터 패키지 고유의 설정이나 서비스를 해석할 수 있습니다.

이 페이지에서는 Laravel Framework `v13.39.0`의 공식 소스를 확인하고 `courier` 설정을 읽는 독자적인 속성을 구현합니다. 내장된 `#[Config('courier.timeout')]`만으로 목적을 달성할 수 있다면 독자적인 속성을 늘릴 필요는 없습니다. 독자적인 키 규칙이나 해석 방침을 패키지의 API로 제공하는 경우에 검토합니다.

## 속성의 역할은 주입할 값을 결정하는 것

`Illuminate\Contracts\Container\ContextualAttribute`를 구현한 속성은 Laravel의 컨테이너가 인수를 해석할 때 읽습니다. 속성 객체 자체가 아니라 resolver가 반환한 값이 사용 클래스에 전달됩니다.

| 방법 | 설정하는 위치 | 적합한 용도 |
| - | - | - |
| `when()->needs()->give()` | 서비스 프로바이더 | 사용 클래스를 변경하지 않고 애플리케이션 측에서 의존성을 전환한다 |
| 내장 `Config`·`Storage`·`Tag` 속성 | 사용 클래스의 인수 | Laravel이 제공하는 해석 방법을 선언한다 |
| 패키지 고유의 컨텍스트 속성 | 사용 클래스의 인수와 속성 클래스 | 패키지 고유의 규칙으로 값을 해석한다 |

일반적인 바인딩과 내장 속성은 [서비스 컨테이너](https://kawax.biz/ko/service-container.md)를 참조하세요. 컨텍스트 속성은 `Bind`·`Singleton`·`Scoped`처럼 클래스나 인터페이스의 바인딩을 선언하는 속성과는 별개의 구조입니다.

## 패키지 설정을 읽는 속성 만들기

### 기본 설정 등록하기

먼저 패키지의 `config/courier.php`에 기본값을 준비합니다.

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

return [
    'timeout' => 30,
    'retries' => 3,
];
```

`src/CourierServiceProvider.php`의 `register()`에서 설정을 병합합니다. 프로바이더 자동 등록은 [패키지 자동 감지의 내부 구조](https://kawax.biz/ko/advanced/package-discovery.md)를, 공개와 설정 캐시 처리는 [패키지 설정 병합과 캐시](https://kawax.biz/ko/advanced/package-config-merging.md)를 참조하세요.

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

namespace Acme\Courier;

use Illuminate\Support\ServiceProvider;

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

### `ContextualAttribute` 구현하기

`src/Attributes/CourierConfig.php`에서 설정 키에 `courier.`를 붙이는 속성을 정의합니다.

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

namespace Acme\Courier\Attributes;

use Attribute;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Container\ContextualAttribute;
use ReflectionParameter;

#[Attribute(Attribute::TARGET_PARAMETER)]
final class CourierConfig implements ContextualAttribute
{
    public function __construct(
        public ?string $key = null,
        public mixed $default = null,
    ) {}

    public static function resolve(
        self $attribute,
        Container $container,
        ReflectionParameter $parameter,
    ): mixed {
        $key = $attribute->key ?? $parameter->getName();

        return $container->make('config')->get(
            'courier.'.$key, $attribute->default
        );
    }
}
```

확인한 구현에서는 resolver에 다음 3개의 인수가 전달됩니다.

1. 인수에 붙은 속성의 인스턴스.
2. 의존성을 해석하고 있는 컨테이너.
3. 해석 대상을 나타내는 `ReflectionParameter`.

이 예에서는 키를 생략하면 인수 이름을 사용합니다. `#[CourierConfig(default: 30)] int $timeout`은 `courier.timeout`을 참조합니다. 사용 측의 인수 이름을 바꿔도 같은 설정을 읽고 싶다면 `#[CourierConfig('timeout', default: 30)]`처럼 키를 명시합니다.

`ContextualAttribute`는 확인 시점에는 메서드를 선언하지 않는 마커 인터페이스입니다. 따라서 `implements`만으로는 resolver의 존재나 시그니처가 보장되지 않습니다. 위와 같이 `resolve()`를 준비하면 이 속성을 위해 일반적인 `bind()`를 등록할 필요가 없습니다.

> [!WARNING]
>
> 속성의 생성자는 메타데이터를 보관하는 용도로만 사용합니다. 컨테이너는 해석 시와 속성 후처리 시에 각각 `newInstance()`를 호출하므로 "한 번만 실행하는 초기화"를 위한 장소가 아닙니다. 외부 통신이나 변경 가능한 상태를 갖게 하지 마세요.

### 컨테이너에서 사용 클래스 생성하기

`src/CourierOptions.php`에 속성을 붙입니다.

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

namespace Acme\Courier;

use Acme\Courier\Attributes\CourierConfig;

final class CourierOptions
{
    public function __construct(
        #[CourierConfig('timeout', default: 30)] public int $timeout,
        #[CourierConfig('retries', default: 3)] public int $retries,
    ) {}
}
```

사용하는 애플리케이션에서 설정을 덮어쓰면 다음 해석 시에 그 값이 주입됩니다.

```php theme={null}
use Acme\Courier\CourierOptions;

config(['courier.timeout' => 45]);

$options = app()->make(CourierOptions::class);
```

이 예에서 `$options->timeout`은 `45`, `$options->retries`는 `3`입니다. 속성은 설정을 가져올 뿐이며, 양의 정수인지, 허용 최댓값 이내인지 같은 업무상의 검증은 하지 않습니다. 타입 선언도 설정값 검증 처리를 대신하지 않습니다.

## 생성자에서의 해석 순서 읽기

`Container::resolveDependencies()`는 각 인수에 대해 다음 순서로 값을 결정합니다.

1. `makeWith()` 등으로 전달된, 인수 이름과 일치하는 덮어쓰기 값을 사용한다.
2. `Util::getContextualAttributeFromDependency()`로 컨텍스트 속성을 찾아 해석한다.
3. 속성의 결과가 `null`이면 일반적인 프리미티브 값 또는 클래스 해석으로 진행한다.
4. 속성 후처리를 거쳐 결정된 값을 생성자에 전달한다.

1의 덮어쓰기 경로는 해당 인수의 속성 해석과 후처리를 건너뜁니다.

```mermaid theme={null}
flowchart TD
    A["생성자 인수"] --> B{"인수 이름으로 덮어쓰기됨?"}
    B -->|Yes| C["덮어쓰기 값을 사용"]
    B -->|No| D{"ContextualAttribute가 있음?"}
    D -->|Yes| E["속성의 resolver 호출"]
    E --> F{"결과가 null?"}
    F -->|No| G["속성의 값을 사용"]
    F -->|Yes| H["일반적인 타입·프리미티브 해석"]
    D -->|No| H
    G --> I["속성 후처리"]
    H --> I
    C --> J["생성자에 전달"]
    I --> J
```

`makeWith()`의 키는 설정 키가 아니라 사용 클래스의 인수 이름입니다.

```php theme={null}
use Acme\Courier\CourierOptions;

$options = app()->makeWith(CourierOptions::class, [
    'timeout' => 5,
]);
```

이 경우 `timeout`은 설정값과 관계없이 `5`입니다. 한편 속성이 `null` 이외의 값을 반환하면 일반적인 `when()->needs('$timeout')->give(...)`보다 속성의 결과가 우선합니다. 패키지에 속성을 추가하는 것이 기존 사용자의 컨텍스트 바인딩을 바꿀 가능성도 테스트합니다.

`Util`은 `ContextualAttribute`를 구현한 속성을 Reflection으로 가져와 그중 첫 번째 1건을 해석용으로 선택합니다. 서로 다른 컨텍스트 속성을 여러 개 나열해도 resolver가 순서대로 합성되는 것은 아닙니다.

### 등록된 handler는 `resolve()`보다 우선한다

`Container::resolveFromAttribute()`는 속성 클래스 이름을 키로 하여 `whenHasAttribute()`로 등록된 handler를 먼저 찾습니다. 없으면 속성의 `resolve()`를 사용하고, 둘 다 없으면 `BindingResolutionException`을 던집니다.

`whenHasAttribute()`는 사용 클래스별이 아니라 속성 종류별 설정입니다. 같은 속성 클래스에 다시 등록하면 이전 handler를 덮어씁니다. 패키지가 다른 패키지나 Laravel 내장 속성의 handler를 무조건 변경하면 사용하는 애플리케이션 전체에 영향을 줍니다. 독자적인 속성의 표준 동작은 `resolve()`에 두고, handler에 의한 변경은 명시적인 확장 지점으로 다룹니다.

## `make()`·`call()`·직접 호출은 같지 않다

`app()->call()`도 속성을 해석하지만, 일반적인 `BoundMethod` 경로는 속성을 찾으면 그 결과를 그대로 인수에 추가합니다. 생성자 해석과 달리 속성의 결과가 `null`이어도 기본값 해석으로 돌아가지 않습니다.

다른 컨텍스트 바인딩이 없는 상태에서 존재하지 않는 `courier.missing`을 읽는 예입니다.

```php theme={null}
use Acme\Courier\Attributes\CourierConfig;

final class NullableTimeoutOptions
{
    public function __construct(
        #[CourierConfig('missing')] public ?int $timeout = 99,
    ) {}
}

$readTimeout = function (
    #[CourierConfig('missing')] ?int $timeout = 99,
): ?int {
    return $timeout;
};
```

| 호출 | 결과 | 이유 |
| - | - | - |
| `app()->make(NullableTimeoutOptions::class)->timeout` | `99` | 속성이 `null`을 반환하고 프리미티브 기본값으로 진행한다 |
| `app()->call($readTimeout)` | `null` | 속성의 결과를 그대로 전달한다 |
| `$readTimeout()` | `99` | PHP의 일반적인 호출이며 속성을 해석하지 않는다 |

`new CourierOptions(timeout: 5, retries: 1)`도 일반적인 PHP에 의한 생성입니다. 속성을 붙이는 것만으로는 직접적인 `new`나 메서드 호출에 의존성 주입이 추가되지 않습니다.

> [!NOTE]
>
> 생성자와 callable 양쪽에서 같은 기본 동작이 필요하다면 인수의 기본값에만 의존하지 말고 `#[CourierConfig('timeout', default: 30)]`처럼 속성 측의 기본값을 정의합니다. 이 `default`는 설정 키가 존재하지 않을 때 사용되며, 존재하는 키의 값이 `null`인 경우에는 대체하지 않습니다. `null` 자체를 의미 있는 값으로 다루는 API에서는 두 해석 경로를 각각 검증하세요.

## 값의 재주입과 서비스 수명 구분하기

속성은 인수를 해석하는 시점의 설정을 읽습니다. 이미 생성된 `CourierOptions`의 프로퍼티를 감시하거나 갱신하는 기능이 아닙니다.

예를 들어 `CourierOptions`를 singleton으로 하면 처음 주입된 값이 그 인스턴스에 보관됩니다. 이후 설정을 변경하고 일반적인 `make()`를 호출해도 같은 인스턴스의 값은 바뀌지 않습니다.

요청별 정보를 읽는 속성을 만드는 경우에도 속성 자체가 singleton이나 scoped의 수명을 결정하는 것은 아닙니다. 주입된 값이나 객체를 보관하는 서비스의 수명을 [패키지 서비스의 라이프사이클 설계](https://kawax.biz/ko/advanced/package-service-lifetimes.md)에 따라 선택합니다. 설정 캐시의 재생성과, 수명이 긴 프로세스나 생성된 서비스의 갱신도 구분해서 생각합니다.

## 해석 경로 테스트하기

속성 단위 테스트는 공식 테스트와 마찬가지로 새 `Container`와 `Illuminate\Config\Repository`를 사용해 작성할 수 있습니다. 다음 PHPUnit 예는 위에서 정의한 `CourierConfig`·`CourierOptions`·`NullableTimeoutOptions`를 불러온 테스트 환경을 전제로 합니다.

```php theme={null}
use Acme\Courier\Attributes\CourierConfig;
use Acme\Courier\CourierOptions;
use Illuminate\Config\Repository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Container\Container as ContainerContract;
use PHPUnit\Framework\TestCase;

class CourierConfigTest extends TestCase
{
    private function container(array $settings = []): Container
    {
        $container = new Container;
        $container->instance('config', new Repository([
            'courier' => $settings,
        ]));

        return $container;
    }

    public function testConfigDefaultsAndNamedOverrides(): void
    {
        $container = $this->container();
        $this->assertSame(30, $container->make(CourierOptions::class)->timeout);

        $container->make('config')->set('courier.timeout', 45);
        $container->when(CourierOptions::class)->needs('$timeout')->give(77);
        $this->assertSame(45, $container->make(CourierOptions::class)->timeout);

        $options = $container->makeWith(CourierOptions::class, ['timeout' => 5]);
        $this->assertSame(5, $options->timeout);
        $this->assertSame(3, $options->retries);
    }

    public function testParameterNameIsAvailableToTheResolver(): void
    {
        $container = $this->container(['timeout' => 45]);

        $value = $container->call(function (
            #[CourierConfig(default: 30)] int $timeout,
        ): int {
            return $timeout;
        });

        $this->assertSame(45, $value);
    }

    public function testRegisteredHandlerOverridesResolve(): void
    {
        $container = $this->container(['timeout' => 45]);
        $container->whenHasAttribute(
            CourierConfig::class,
            static fn (CourierConfig $attribute, ContainerContract $app) => 7,
        );

        $this->assertSame(7, $container->make(CourierOptions::class)->timeout);
    }

    public function testNullDiffersBetweenMakeAndCall(): void
    {
        $container = $this->container();
        $readTimeout = function (
            #[CourierConfig('missing')] ?int $timeout = 99,
        ): ?int {
            return $timeout;
        };

        $this->assertSame(99, $container->make(NullableTimeoutOptions::class)->timeout);
        $this->assertNull($container->call($readTimeout));
        $this->assertSame(99, $readTimeout());
    }

    public function testSingletonKeepsItsResolvedValues(): void
    {
        $container = $this->container(['timeout' => 45]);
        $container->singleton(CourierOptions::class);
        $options = $container->make(CourierOptions::class);

        $container->make('config')->set('courier.timeout', 60);

        $this->assertSame($options, $container->make(CourierOptions::class));
        $this->assertSame(45, $options->timeout);
    }
}
```

handler가 항상 `7`을 반환하게 하는 예는 우선순위 테스트 전용입니다. 이 컨테이너 안에서는 `retries`를 포함한 같은 속성의 모든 해석에 영향을 줍니다.

이 단위 테스트만으로는 패키지의 프로바이더 등록이나 `mergeConfigFrom()`, 설정 캐시와의 통합까지는 확인할 수 없습니다. [패키지 테스트](https://kawax.biz/ko/advanced/package-testing.md)의 애플리케이션 환경에서도 공개 전·공개 후·설정 캐시가 있는 상태를 검증합니다.

### 릴리스 전 확인 항목

* 속성의 클래스 이름, 생성자 인수 이름, 설정 키, 반환 타입을 공개 API로 다룬다. 이름 있는 인수로 사용되는 `key`·`default`의 변경에도 주의한다.
* 키를 생략하면 인수 이름을 사용하는 사양에서는 사용 클래스의 인수 이름 변경이 참조하는 설정을 바꾸지 않는지 확인한다.
* 속성이 없는 기존 바인딩, 속성이 붙은 해석, 인수 이름을 통한 덮어쓰기를 각각 테스트한다.
* `null`, 설정 누락, 부적절한 타입을 `make()`와 `call()` 양쪽에서 테스트한다.
* singleton이나 수명이 긴 워커가 오래된 값을 보관하지 않는 설계인지 확인한다.
* Laravel 지원 버전을 갱신할 때는 resolver의 인수, 우선순위, 속성 후처리를 공식 소스와 테스트로 다시 확인한다.

## 관련 페이지

> [card] [패키지 설정 병합과 캐시](https://kawax.biz/ko/advanced/package-config-merging.md)
>
> 기본 설정, 공개된 설정, 캐시의 갱신 경계를 확인합니다.

> [card] [패키지 서비스의 라이프사이클 설계](https://kawax.biz/ko/advanced/package-service-lifetimes.md)
>
> 주입한 값을 보관하는 서비스의 수명을 선택합니다.

> [card] [패키지의 컨테이너 태그와 플러그인 집약](https://kawax.biz/ko/advanced/package-container-tags.md)
>
> 내장 Tag 속성과 등록된 구현의 집약을 확인합니다.

> [card] [PHP Reflection API](https://kawax.biz/ko/advanced/php-reflection.md)
>
> 속성과 인수의 메타데이터를 읽어 들이는 구조를 확인합니다.

## 참조한 1차 정보

* [Laravel 공식 문서: Defining custom attributes](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/container.md#defining-custom-attributes)
* [Laravel 공식 문서: 패키지 설정](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/packages.md#configuration)
* [Container: resolveDependencies()·resolveFromAttribute()·whenHasAttribute()·속성 후처리](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/Container.php)
* [BoundMethod: callable의 인수 해석](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/BoundMethod.php)
* [Util: Reflection을 이용한 ContextualAttribute 검출](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/Util.php)
* [ContextualAttribute: 마커 인터페이스](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Contracts/Container/ContextualAttribute.php)
* [Config: 내장 설정 속성](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/Attributes/Config.php)
* [Config Repository: get()과 설정값 조회](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Config/Repository.php)
* [Arr: get()의 기본값과 존재하는 null 처리](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Collections/Arr.php)
* [공식 테스트: 속성의 resolver·등록 handler·ReflectionParameter·call에서의 해석](https://github.com/laravel/framework/blob/v13.39.0/tests/Container/ContextualAttributeBindingTest.php)


## Related topics

- [패키지의 컨테이너 태그와 플러그인 집약](https://kawax.biz/ko/advanced/package-container-tags.md)
- [패키지의 Artisan 명령어 등록과 의존성 해결](https://kawax.biz/ko/advanced/package-commands.md)
- [패키지의 서비스 해석 후 훅](https://kawax.biz/ko/advanced/package-service-resolution.md)
- [패키지의 정적 해석 (PHPStan / Larastan)](https://kawax.biz/ko/advanced/package-static-analysis.md)
- [패키지의 Storage 드라이버 등록과 재해석](https://kawax.biz/ko/advanced/package-filesystem-drivers.md)


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