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

# 패키지의 HTTP 클라이언트와 테스트

> Laravel 13의 공유 HTTP Factory를 사용해 요청별 상태 분리, 서비스 등록, Http::fake()로 검증할 수 있는 API 클라이언트를 설계합니다.

패키지에서 외부 API를 사용하고 싶을 때는 **Laravel이 관리하는 HTTP 클라이언트 설정을 이어받는 것**과 **호출마다의 설정을 다음 호출로 넘기지 않는 것**을 양립시킵니다. 독자적으로 Factory를 생성하거나 전송 전의 요청을 오랫동안 보관하면, 사용하는 쪽의 fake가 적용되지 않는 원인이 됩니다.

이 페이지는 Laravel Framework `v13.39.0`의 구현을 확인한 내용입니다. 기본 조작은 [HTTP 클라이언트](https://kawax.biz/ko/http-client.md)를, 검증 환경 준비는 [패키지 테스트](https://kawax.biz/ko/advanced/package-testing.md)를 참조하세요.

## Factory는 공유하고 PendingRequest는 매번 만든다

Laravel의 HTTP 클라이언트에는 서로 다른 책임을 가진 객체가 있습니다.

| 객체 | 역할 | 패키지에서의 취급 |
| - | - | - |
| `Illuminate\Http\Client\Factory` | fake, 공통 옵션, 공통 미들웨어 등을 관리한다 | Laravel의 컨테이너에서 받는다 |
| `Illuminate\Http\Client\PendingRequest` | 전송 대상, 헤더, 타임아웃 등 전송할 요청의 설정을 가진다 | API 메서드를 호출할 때마다 생성한다 |
| `Illuminate\Http\Client\Response` | 상태나 본문을 검사한다 | 통신 결과로 다루고, 필요하면 예외나 반환값으로 변환한다 |

`FoundationServiceProvider`는 Factory를 singleton으로 등록하고, `Http` 파사드도 같은 클래스 이름을 사용해 컨테이너에서 해석합니다. 따라서 일반적인 Laravel 애플리케이션에서는 **컨테이너에서 받은 Factory를 통한 통신도 `Http::fake()`로 검증할 수 있습니다**. 파사드를 직접 사용하는 설계도 가능하지만, 이 페이지에서는 의존 관계를 명시하기 위해 Factory를 주입합니다.

Factory에 대한 `baseUrl()` 등 일반적인 요청 설정 메서드는 `__call()`을 통해 새로운 PendingRequest로 전달됩니다. `createPendingRequest()`는 생성 시점의 fake용 콜백과 stray request 제한을 넘기고, `newPendingRequest()`는 공통 미들웨어와 공통 옵션을 적용합니다.

```mermaid theme={null}
flowchart LR
    A["Laravel의 컨테이너"] --> B["공유 Factory"]
    C["Http::fake()로 응답을 설정"] --> B
    B --> D["API 호출마다<br>새로운 PendingRequest"]
    D --> E["호출 고유의 설정"]
    E --> F["전송과 응답 검사"]
```

> [!WARNING]
>
> 패키지 안에서 `new Factory()`나 다른 HTTP 라이브러리의 클라이언트를 생성하면, 공유 Factory의 fake나 `Http::preventStrayRequests()`는 이어지지 않습니다. 이들은 프로세스 전체의 통신을 차단하는 기능이 아닙니다. Laravel용 클라이언트에서는 공유 Factory를 사용하고, 독립된 클라이언트를 제공하는 경우에는 그 클라이언트 전용 테스트 방법을 준비하세요.

## API 조작을 작은 클라이언트로 정리하기

가상의 카탈로그 API에서 상품을 가져오는 `Acme\Catalog` 패키지를 예로 듭니다. `src/CatalogClient.php`는 Factory와 베이스 URL만 보관하고, 요청은 `product()` 실행 중에 만듭니다.

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

namespace Acme\Catalog;

use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\PendingRequest;
use UnexpectedValueException;

final class CatalogClient
{
    public function __construct(
        private readonly Factory $http,
        private readonly string $baseUrl,
    ) {
    }

    public function product(int $id, ?string $language = null): array
    {
        $request = $this->request();

        if ($language !== null) {
            $request->withHeaders(['Accept-Language' => $language]);
        }

        $data = $request->get('/products/'.$id)
            ->throw()
            ->json(flags: JSON_THROW_ON_ERROR);

        if (! is_array($data)) {
            throw new UnexpectedValueException('Catalog response must be an array.');
        }

        return $data;
    }

    private function request(): PendingRequest
    {
        return $this->http->baseUrl($this->baseUrl)
            ->acceptJson()
            ->connectTimeout(3)
            ->timeout(10);
    }
}
```

이 설계에서는 같은 클라이언트로 `product(42, 'ja')` 다음에 `product(42)`를 호출해도, 이전 호출에서 추가한 `Accept-Language`는 남지 않습니다. 사용하는 쪽 애플리케이션이 Factory에 설정한 공통 설정은 계속 각 요청에 적용됩니다.

* 패키지 고유의 설정은 새로 만든 PendingRequest에 적용합니다. 패키지 초기화에서 `Http::globalOptions()`를 설정하면 관계없는 통신에도 영향을 주므로 피합니다.
* 생성자에서는 전송하지 않습니다. 서비스 해석, Artisan 실행, 설정 파일 공개만으로 외부 API에 접속하지 않는 설계로 합니다.
* `throw()`는 4xx·5xx를 `RequestException`으로 만듭니다. 접속 실패나 타임아웃의 `ConnectionException`과 구별하고, 실패를 빈 배열 등의 성공 값으로 바꾸지 않습니다.
* `json(flags: JSON_THROW_ON_ERROR)`는 잘못된 JSON을 `JsonException`으로 만듭니다. 예제에서는 배열인지 여부만 검사하므로, 실제 API에서는 필수 필드나 데이터 타입도 검증합니다.

> [!TIP]
>
> 베이스 URL이나 헤더의 공통화만 필요하다면 공식 문서의 HTTP 매크로도 선택지입니다. API 조작, 반환값 검사, 예외 계약까지 패키지로 제공하는 경우에는 이런 전용 클래스로 나누면 유지보수하기 쉬워집니다. 매크로의 구조는 [Macroable 트레이트](https://kawax.biz/ko/advanced/macroable.md)를 참조하세요.

## 서비스 프로바이더에서 등록하기

패키지의 `config/catalog.php`에 초기 설정을 준비합니다. 여기에 HTTP 클라이언트나 클로저를 저장하지 말고, 설정 캐시에 저장할 수 있는 값만 둡니다.

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

return [
    'base_url' => 'https://catalog.example.test/api',
];
```

`src/CatalogServiceProvider.php`는 설정을 병합하고, 클라이언트를 해석할 때 공유 Factory를 넘깁니다. Factory 자체의 바인딩은 덮어쓰지 않습니다.

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

namespace Acme\Catalog;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\ServiceProvider;

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

        $this->app->bind(CatalogClient::class, function (Application $app): CatalogClient {
            return new CatalogClient(
                $app->make(Factory::class),
                $app['config']->get('acme-catalog.base_url'),
            );
        });
    }

    public function boot(): void
    {
        $this->publishes([
            __DIR__.'/../config/catalog.php' => config_path('acme-catalog.php'),
        ], 'acme-catalog-config');
    }
}
```

프로바이더는 [패키지 자동 검출](https://kawax.biz/ko/advanced/package-discovery.md) 등으로 로드합니다. 사용자는 설정을 공개하고, `config/acme-catalog.php`의 베이스 URL을 실제 API로 변경합니다. 예제의 `catalog.example.test`는 설명과 fake용 가상 호스트입니다.

```shell theme={null}
php artisan vendor:publish --tag=acme-catalog-config
```

사용하는 쪽에서는 생성자 주입이나 컨테이너로 클라이언트를 해석합니다. 이 호출에서 처음으로 상품 조회 요청이 전송됩니다.

```php theme={null}
use Acme\Catalog\CatalogClient;

$product = app(CatalogClient::class)->product(42, 'ja');
```

등록에는 `bind()`를 사용하지만, 클라이언트를 보관하는 쪽이 singleton이라면 그 참조까지 매번 새로 만들어지는 것은 아닙니다. 호출 고유의 언어, 인증 정보, 요청 객체를 프로퍼티에 저장하지 않는 것이 중요합니다. 자세한 내용은 [패키지 서비스의 라이프사이클 설계](https://kawax.biz/ko/advanced/package-service-lifetimes.md)를 참조하세요.

설정 덮어쓰기와 배포 시 캐시 갱신은 [패키지 설정의 병합과 캐시](https://kawax.biz/ko/advanced/package-config-merging.md)에서 다룹니다. 베이스 URL은 신뢰할 수 있는 애플리케이션 설정으로 관리하고, 요청 입력으로 접속 대상을 자유롭게 지정하게 하지 마세요.

## 실제 클라이언트를 fake로 검증하기

클라이언트 자체를 모킹하면 공유 Factory를 사용하고 있는지, URL이나 헤더를 올바르게 조립하고 있는지는 확인할 수 없습니다. 패키지 테스트에서는 **실제 `CatalogClient`를 컨테이너에서 해석하고, HTTP 응답만 fake로 만듭니다**.

다음은 [Orchestra Testbench의 테스트 환경](https://kawax.biz/ko/advanced/package-testing.md)에서 실행하는 예입니다. 각 테스트의 `setUp()`에서 `preventStrayRequests()`를 활성화합니다. 통신하는 테스트에서는 예상 URL에만 응답을 설정하고 `'*'` 폴백을 두지 않음으로써 URL의 오류도 검출할 수 있습니다.

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

namespace Acme\Catalog\Tests;

use Acme\Catalog\CatalogClient;
use Acme\Catalog\CatalogServiceProvider;
use Illuminate\Http\Client\Request;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\StrayRequestException;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;

class CatalogClientTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [CatalogServiceProvider::class];
    }

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

        Http::preventStrayRequests();
    }

    public function test_resolving_the_client_does_not_send_a_request(): void
    {
        Http::fake();

        $this->assertInstanceOf(CatalogClient::class, $this->app->make(CatalogClient::class));

        Http::assertNothingSent();
    }

    public function test_product_request_uses_the_shared_factory(): void
    {
        $client = $this->app->make(CatalogClient::class);

        Http::fake([
            'https://catalog.example.test/api/products/42' => Http::response([
                'id' => 42,
                'name' => 'Notebook',
            ]),
        ]);

        $this->assertSame(['id' => 42, 'name' => 'Notebook'], $client->product(42, 'ja'));

        Http::assertSent(static fn (Request $request): bool =>
            $request->method() === 'GET'
            && $request->url() === 'https://catalog.example.test/api/products/42'
            && $request->hasHeader('Accept', 'application/json')
            && $request->hasHeader('Accept-Language', 'ja')
        );
        Http::assertSentCount(1);
    }

    public function test_language_is_not_carried_over_to_the_next_request(): void
    {
        Http::fake([
            'https://catalog.example.test/api/products/42' => Http::response(['id' => 42]),
        ]);

        $client = $this->app->make(CatalogClient::class);
        $client->product(42, 'ja');
        $client->product(42);

        Http::assertSentInOrder([
            static fn (Request $request): bool => $request->hasHeader('Accept-Language', 'ja'),
            static fn (Request $request): bool => ! $request->hasHeader('Accept-Language'),
        ]);
    }

    public function test_server_errors_are_not_returned_as_product_data(): void
    {
        Http::fake([
            'https://catalog.example.test/api/products/42' => Http::response(['error' => 'unavailable'], 503),
        ]);

        $this->expectException(RequestException::class);

        $this->app->make(CatalogClient::class)->product(42);
    }

    public function test_a_request_without_a_matching_fake_is_rejected(): void
    {
        Http::fake([]);

        $this->expectException(StrayRequestException::class);

        $this->app->make(CatalogClient::class)->product(42);
    }
}
```

두 번째 테스트에서는 **클라이언트를 해석한 후에 fake를 설정하고 있습니다**. Factory를 보관하고 실제 호출 시에 PendingRequest를 만들기 때문에 이 순서로도 fake가 반영됩니다. 생성자에서 PendingRequest를 생성·보관하는 설계로 변경하면, 생성 후에 추가한 fake가 반영되지 않는 것을 검출할 수 있습니다.

`assertSent()`는 일치하는 요청이 1건 이상 있는지를 검증합니다. 의도하지 않은 추가 전송을 놓치지 않도록 `assertSentCount()`도 함께 사용합니다. `assertSentInOrder()`는 건수와 순서를 검증하므로, 같은 클라이언트를 연속으로 사용했을 때의 상태 분리 확인에도 활용할 수 있습니다.

가드 설정은 `parent::setUp()` 이후에 합니다. 이 테스트만으로 프로바이더 부팅 중의 통신까지 검증할 수 있는 것은 아니므로, 등록과 부팅에서는 통신하지 않는 설계를 유지합니다.

> [!NOTE]
>
> fake로 확인할 수 있는 것은 클라이언트가 조립한 요청과 준비한 응답에 대한 처리입니다. 실제 서비스의 인증, TLS 접속, 실제 타임아웃 시간, 외부 API의 사양 변경까지 보장하는 것은 아닙니다. 외부 접속을 수반하는 검증은 명시적으로 준비한 검증 환경에서 별도로 실시합니다.

## 장기 유지보수를 위해 추가할 검증

| 변경이나 이상 케이스 | 확인할 것 |
| - | - |
| 베이스 URL 덮어쓰기 | 애플리케이션 설정이 반영되어 의도한 경로로 전송된다 |
| 접속 실패 | `Http::failedConnection()`으로 `ConnectionException`이 사용하는 쪽에 전달된다 |
| 잘못된 JSON·예상 밖의 타입 | 잘못된 JSON은 `JsonException`, 배열이 아닌 값은 예제의 `UnexpectedValueException`이 된다 |
| 재시도 추가 | 시도 횟수, 대상 상태, 전송 내용을 검증하고, 부작용이 있는 API에서는 중복 실행을 고려한다 |
| 인증이나 언어 추가 | 같은 클라이언트를 여러 번 사용해도 이전 호출의 값이 넘어가지 않는다 |
| Laravel 업데이트 | 지원하는 버전에서 Factory의 해석 경로, fake 적용, 예외 계약을 계속 확인한다 |

공유 Factory와 각 PendingRequest의 책임을 나눠 두면, 사용하는 쪽이 일반적인 Laravel 테스트 방법을 그대로 사용할 수 있습니다. 독자적인 fake 구조를 먼저 만들기보다, 우선 공유 Factory를 거치는 구현과 이를 확인하는 테스트를 준비하세요.

## 참조한 1차 정보

* [Laravel 공식 문서: HTTP 클라이언트](https://github.com/laravel/docs/blob/13.x/http-client.md)
* [Laravel 공식 문서: 패키지 개발](https://github.com/laravel/docs/blob/13.x/packages.md)
* [Framework v13.39.0: Factory의 fake·createPendingRequest·\_\_call](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Factory.php)
* [Framework v13.39.0: PendingRequest의 설정과 stub 처리](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/PendingRequest.php)
* [Framework v13.39.0: Response의 throw와 JSON 디코드](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Http/Client/Response.php)
* [Framework v13.39.0: Http 파사드의 해석 대상과 fake](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Http.php)
* [Framework v13.39.0: FoundationServiceProvider의 singleton 등록](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Laravel 공식 테스트: HttpClientTest](https://github.com/laravel/framework/blob/v13.39.0/tests/Http/HttpClientTest.php)


## Related topics

- [HTTP 클라이언트](https://kawax.biz/ko/http-client.md)
- [고급 주제](https://kawax.biz/ko/advanced/index.md)
- [엔진 API 연동 앱 개발 가이드 - VOICEVOX for Laravel](https://kawax.biz/ko/packages/laravel-voicevox/app-guide.md)
- [테스트](https://kawax.biz/ko/packages/laravel-bluesky/testing.md)
- [클라이언트 모드: 토크 (텍스트 음성 합성) - VOICEVOX for Laravel](https://kawax.biz/ko/packages/laravel-voicevox/client-talk.md)


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