> ## 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의 MailManager를 분석해 패키지에서의 트랜스포트 등록, 설정 전달, Mailer의 보관과 재해석, fake로는 확인할 수 없는 전송 경로를 해설합니다.

패키지에서 독자적인 메일 전송 방법을 제공하려면 **Symfony의 Transport를 생성하는 처리**를 Laravel에 등록합니다. Mailable이나 Laravel의 Mailer를 새로 만들 필요는 없습니다.

이 페이지는 Laravel Framework `v13.39.0`의 구현을 확인한 내용입니다. 기본 전송 방법은 [메일 전송](https://kawax.biz/ko/mail.md)을, 프로바이더 준비는 [패키지 개발](https://kawax.biz/ko/advanced/package-development.md)을 참조하세요.

## Mailer 이름과 Transport 이름 구분하기

| 요소 | 이 페이지의 예 | 역할 |
| - | - | - |
| Transport 이름 | `acme-memory-mail` | `extend()`로 등록하는 생성 방법 |
| Mailer 이름 | `receipts` / `alerts` | `mail.mailers`의 설정 이름과 생성된 Mailer의 식별자 |
| Symfony Transport | `MemoryTransport` | 메일 배송 처리. 이 예에서는 메모리 내 기록만 수행 |
| Laravel Mailer | `Illuminate\Mail\Mailer` | 본문 생성, 전송 이벤트, Transport로의 전달 등 |

`Mail::mailer('receipts')`는 `mail.mailers.receipts`를 읽고 `transport`에 대응하는 생성 처리를 호출합니다. `MailManager::build()`가 그 Transport를 Laravel의 Mailer에 전달하며, 일반적인 이름 기반 해석에서는 전역 발신자 등도 설정합니다.

```mermaid theme={null}
flowchart TD
    A["Mail::mailer('receipts')"] --> B{"receipts는 해석되었는가?"}
    B -->|예| C["보관된 Mailer를 반환"]
    B -->|아니요| D["mail.mailers.receipts를 읽어들임"]
    D --> E["acme-memory-mail의 생성 처리<br>인수는 설정 배열뿐"]
    E --> F["Symfony Transport를 반환"]
    F --> G["Laravel의 Mailer를 생성하고<br>receipts라는 이름으로 보관"]
    G --> H["전송 시 Transport::send()를 호출"]
    C --> H
```

커스텀 생성 처리는 내장 Transport보다 먼저 선택됩니다. `smtp`나 `ses`를 덮어쓰지 말고 패키지 고유의 Transport 이름을 사용하세요.

## 외부 전송 없이 등록 경로 만들기

먼저 배송처 API가 아니라 설정과 해석 경로를 확인합니다. `Acme\Postbox` 패키지의 `src/Transport/MemoryTransport.php`에 표준 `ArrayTransport`를 상속한 클래스를 만듭니다.

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

namespace Acme\Postbox\Transport;

use Illuminate\Mail\Transport\ArrayTransport;

class MemoryTransport extends ArrayTransport
{
    public function __construct(
        private readonly string $label,
    ) {
        parent::__construct();
    }

    public function __toString(): string
    {
        return 'acme-memory-mail:'.$this->label;
    }
}
```

`ArrayTransport::send()`는 Symfony의 `SentMessage`를 내부 Collection에 저장합니다. `messages()`로 확인할 수 있고, `flush()`로 해당 인스턴스의 기록을 지울 수 있습니다. 라벨은 설정이 생성 처리까지 전달되었는지 확인하기 위한 것입니다.

> [!WARNING]
>
> 이 예는 메일을 외부로 배송하지 않습니다. 등록과 테스트를 익히기 위한 예시입니다. 인메모리 기록은 프로세스 간에 공유되거나 영속화되지 않으며, 수신자에게 도달했는지 확인하는 수단도 아닙니다. 메모리에 기록하는 것만이 목적이라면 내장 `array` Transport로 충분합니다.

### 지연 로드되는 Manager에 등록하기

공식 문서에서는 `boot()`에서 `Mail::extend()`를 호출합니다. 패키지에서는 메일을 사용하지 않는 애플리케이션에서 Manager가 불필요하게 생성되지 않도록 [서비스 해석 후 훅](https://kawax.biz/ko/advanced/package-service-resolution.md)을 조합할 수 있습니다.

`src/PostboxServiceProvider.php`를 만듭니다.

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

namespace Acme\Postbox;

use Acme\Postbox\Transport\MemoryTransport;
use Illuminate\Mail\MailManager;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Mailer\Transport\TransportInterface;

class PostboxServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->callAfterResolving('mail.manager', static function (
            MailManager $manager,
        ): void {
            $manager->extend('acme-memory-mail', static function (
                array $config,
            ): TransportInterface {
                return new MemoryTransport($config['label'] ?? 'mail');
            });
        });
    }
}
```

프로바이더는 [패키지 자동 검출](https://kawax.biz/ko/advanced/package-discovery.md) 등으로 로드합니다.

* Laravel의 `MailServiceProvider`는 지연 프로바이더이며 `mail.manager`를 singleton으로 등록합니다. `Mail` 파사드도 이 키를 해석합니다.
* `callAfterResolving()`은 아직 해석되지 않았다면 이후의 해석을 기다리고, 이미 해석된 Manager에는 즉시 확장을 적용합니다. **생성된 Mailer까지 교체하는 처리는 아닙니다.**
* 확장을 등록하는 자신의 프로바이더까지 단순히 `DeferrableProvider`로 만드는 것은 피하세요. 훅이 등록되기 전에 독자적인 Transport가 요청되지 않도록 합니다.
* 이 예는 자신의 `boot()`에서 훅을 등록합니다. 다른 프로바이더가 그보다 먼저 독자적인 Mailer를 사용하는 부팅 순서는 보장하지 않습니다. 등록 순서를 설계하고, `register()` 안에서는 메일을 전송하지 마세요.

### extend의 인수를 다른 Manager와 혼동하지 않기

`MailManager::createSymfonyTransport()`는 커스텀 생성 처리를 **설정 배열 하나만**으로 호출합니다. `Cache::extend()`처럼 애플리케이션과 설정의 두 인수를 받는 방식이 아닙니다. 반환값은 `TransportInterface`를 구현한 Transport이며, Laravel의 Mailer나 전송 결과가 아닙니다.

또한 `MailManager::extend()`는 클로저를 보관할 뿐이며 Manager로의 재바인딩은 하지 않습니다. 예에서는 static 클로저와 명시적인 인수를 사용해 `$this`의 해석에 의존하지 않도록 했습니다.

외부 API용 클라이언트가 필요하다면 이 생성 처리에서 설정을 적용해 Transport에 전달합니다. 등록 시점에는 전송하지 않고, 같은 Transport 이름을 쓰는 Mailer가 여러 개 있을 때 설정이나 수신처가 섞이지 않도록 설계하세요.

### 사용하는 애플리케이션에 Mailer 추가하기

`config/mail.php`의 기존 `mailers` 배열에 다음 두 항목을 추가합니다. 패키지 쪽에서 사용자의 기본값이나 `mailers` 전체를 덮어쓰지 않습니다.

```php theme={null}
'receipts' => [
    'transport' => 'acme-memory-mail',
    'label' => 'receipts-v1',
],

'alerts' => [
    'transport' => 'acme-memory-mail',
    'label' => 'alerts',
],
```

기본값으로 사용하고 싶을 때 `MAIL_MAILER`에 지정하는 것은 Transport 이름이 아니라 **`receipts`라는 Mailer 이름**입니다. 개별로 지정하면 기존 기본값을 바꾸지 않고 사용할 수 있습니다.

```php theme={null}
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Mail;

Mail::mailer('receipts')->raw('Receipt body', static function (Message $message): void {
    $message->from('noreply@example.com')
        ->to('reader@example.com')
        ->subject('Receipt');
});

$transport = Mail::mailer('receipts')->getSymfonyTransport();

$recorded = $transport->messages()->sole();
```

이 `raw()`는 뷰를 준비하지 않고 동기 전송 경로를 확인하기 위한 예입니다. Mailable을 사용하는 일반적인 전송에서도 Mailer가 본문 등을 생성한 뒤 같은 Transport에 전달합니다.

## 실제 배송 Transport를 만들 때의 경계

외부 서비스로 배송하는 구현에서는 공식 문서에 따라 `Symfony\Component\Mailer\Transport\AbstractTransport`를 상속하고 `doSend(SentMessage $message): void`와 `__toString(): string`을 구현할 수 있습니다. 위의 등록 방법은 그대로 사용할 수 있습니다.

| 확인할 사항 | 패키지가 담당하는 범위 |
| - | - |
| 메시지와 수신처 | 본문뿐 아니라 HTML, 첨부 파일, CC·BCC, Envelope의 수신처를 배송처 API에 올바르게 전달 |
| 오류 | API 실패를 성공으로 삼키지 않고 호출 측에 전달 |
| 설정 분리 | Mailer별 접속처·인증 정보를 Transport에 적용 |
| 문자열 표현 | `__toString()`에 인증 정보나 메일 본문을 포함하지 않음 |
| 테스트 | 등록·설정·재해석 테스트에 더해 API로의 변환과 오류 처리를 검증 |

Laravel의 `Mailer::sendSymfonyMessage()`는 Transport의 `send()`를 호출합니다. `MessageSending` 리스너가 전송을 중단했거나 Transport가 `null`을 반환한 경우, Laravel의 `send()`도 전송 결과를 반환하지 않습니다. **예외가 없었다는 사실만으로 배송 성공을 판단하지 마세요.** `SentMessage`가 반환되더라도 수신자의 메일함에 도달했다는 보장은 아닙니다.

## 설정 변경과 Mailer 재해석 구분하기

`MailManager`는 Mailer와 그 Transport를 Mailer 이름별로 보관합니다. 설정 변경이나 `extend()` 재등록만으로는 이미 생성된 객체가 갱신되지 않습니다.

```php theme={null}
use Illuminate\Support\Facades\Mail;

$before = Mail::mailer('receipts');

config(['mail.mailers.receipts.label' => 'receipts-v2']);

$same = Mail::mailer('receipts');

Mail::purge('receipts');

$after = Mail::mailer('receipts');
```

`$same`은 `$before`와 같은 Mailer입니다. `purge('receipts')` 이후에 가져온 `$after`에서는 새 설정으로 Transport가 생성됩니다.

| 조작 | 대상 |
| - | - |
| `Mail::purge('receipts')` | Manager가 보관하는 지정 Mailer의 참조를 제거 |
| `Mail::forgetMailers()` | Manager가 보관하는 모든 Mailer의 참조를 제거. 확장 등록은 유지됨 |
| `$transport->flush()` | 이 예의 MemoryTransport가 보관하는 메시지 기록을 삭제 |

확인한 `purge()` 구현은 Manager 내부 배열에서 참조를 제거할 뿐입니다. 이전의 `$before`나 그 Mailer를 이미 주입받은 서비스는 교체되지 않습니다. 외부 연결을 명시적으로 종료하는 처리도 아닙니다.

### failover 등의 부모 Mailer도 별도로 보관된다

`failover`와 `roundrobin`은 자식 Mailer 이름의 설정을 읽어 **Transport를 직접 생성해** 부모 Transport에 포함합니다. 자식의 `Mail::mailer()` 인스턴스를 공유하는 구조가 아닙니다.

따라서 자식의 설정을 변경하고 `purge('receipts')`를 호출해도, 이미 해석된 부모 Mailer의 내부 Transport는 갱신되지 않습니다. 갱신 대상인 부모 Mailer도 재해석해야 합니다. `forgetMailers()`로도 이미 사용하는 쪽이 보관하고 있는 부모 Mailer의 참조까지 교체할 수는 없습니다.

운영 환경에서는 설정 캐시 갱신과 장기 실행 워커의 재시작을 일반적인 배포 절차에 포함하세요. 재해석을 모든 프로세스의 일괄 갱신으로 여기지 말고 [서비스의 라이프사이클](https://kawax.biz/ko/advanced/package-service-lifetimes.md)도 확인하세요.

## Mail::fake뿐 아니라 실제 생성 경로 테스트하기

`Mail::fake()`는 Mailable의 전송·큐 등록을 기록하기 위한 것입니다. `MailFake::mailer()`는 실제 Mailer를 해석하지 않고 Mailer 이름을 기록한 뒤 fake 자신을 반환합니다. `raw()`도 전송 처리를 하지 않습니다. 독자적인 Transport가 등록되어 있지 않아도 Mailable 전송 어설션이 성공할 수 있습니다.

[패키지 테스트 환경](https://kawax.biz/ko/advanced/package-testing.md)에서 다음과 같이 **fake를 사용하지 않고** 실제 Manager와 Transport를 거치게 합니다. 이 예의 Transport는 외부로 전송하지 않으므로 실제 배송 위험 없이 등록 경로를 확인할 수 있습니다. 보조 프로바이더를 먼저 로드해 Manager가 이미 해석된 경우도 검증합니다.

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

namespace Acme\Postbox\Tests;

use Acme\Postbox\PostboxServiceProvider;
use Acme\Postbox\Transport\MemoryTransport;
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\ServiceProvider;
use Orchestra\Testbench\TestCase;
use Symfony\Component\Mime\Email;

class MailTransportTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [EarlyMailManagerProvider::class, PostboxServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('mail.default', 'receipts');
        $app['config']->set('mail.from', [
            'address' => 'noreply@example.com',
            'name' => 'Postbox',
        ]);
        $app['config']->set('mail.mailers.receipts', [
            'transport' => 'acme-memory-mail',
            'label' => 'receipts-v1',
        ]);
        $app['config']->set('mail.mailers.alerts', [
            'transport' => 'acme-memory-mail',
            'label' => 'alerts',
        ]);
    }

    public function test_registers_on_an_already_resolved_manager(): void
    {
        $this->assertSame(
            $this->app->make('postbox.test.early-manager'),
            $this->app->make('mail.manager'),
        );

        $transport = Mail::mailer('receipts')->getSymfonyTransport();

        $this->assertInstanceOf(MemoryTransport::class, $transport);
        $this->assertSame('acme-memory-mail:receipts-v1', (string) $transport);
    }

    public function test_message_reaches_the_transport(): void
    {
        $mailer = Mail::mailer('receipts');

        $sent = $mailer->raw('Receipt body', static function (Message $message): void {
            $message->to('reader@example.com')->subject('Receipt');
        });

        $transport = $mailer->getSymfonyTransport();

        $this->assertNotNull($sent);
        $this->assertCount(1, $transport->messages());

        $recorded = $transport->messages()->sole();
        $email = $recorded->getOriginalMessage();

        $this->assertInstanceOf(Email::class, $email);
        $this->assertSame('Receipt', $email->getSubject());
        $this->assertSame('Receipt body', $email->getTextBody());
        $this->assertSame('noreply@example.com', $email->getFrom()[0]->getAddress());
        $this->assertSame(
            'reader@example.com',
            $recorded->getEnvelope()->getRecipients()[0]->getAddress(),
        );
    }

    public function test_mailer_names_have_separate_transports(): void
    {
        $receipts = Mail::mailer('receipts');
        $alerts = Mail::mailer('alerts');

        $this->assertNotSame($receipts->getSymfonyTransport(), $alerts->getSymfonyTransport());
        $this->assertSame('acme-memory-mail:alerts', (string) $alerts->getSymfonyTransport());

        $receipts->raw('Receipt body', static function (Message $message): void {
            $message->to('reader@example.com')->subject('Receipt');
        });

        $this->assertCount(1, $receipts->getSymfonyTransport()->messages());
        $this->assertCount(0, $alerts->getSymfonyTransport()->messages());
    }

    public function test_config_change_requires_purging_the_mailer(): void
    {
        $before = Mail::mailer('receipts');

        config(['mail.mailers.receipts.label' => 'receipts-v2']);

        $this->assertSame($before, Mail::mailer('receipts'));
        $this->assertSame('acme-memory-mail:receipts-v1', (string) $before->getSymfonyTransport());

        Mail::purge('receipts');

        $after = Mail::mailer('receipts');

        $this->assertNotSame($before, $after);
        $this->assertSame('acme-memory-mail:receipts-v2', (string) $after->getSymfonyTransport());
        $this->assertSame('acme-memory-mail:receipts-v1', (string) $before->getSymfonyTransport());
    }
}

class EarlyMailManagerProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->app->instance('postbox.test.early-manager', $this->app->make('mail.manager'));
    }
}
```

이 테스트는 등록·설정·동기 전송·보관된 객체의 경계를 확인합니다. 실제 배송용 Transport에서는 별도로 API 클라이언트의 목이나 fake로 페이로드와 실패 응답을 검증하세요. 큐 전송을 제공하는 경우에는 [큐](https://kawax.biz/ko/queues.md) 테스트도 분리하고, 워커 쪽에서도 프로바이더가 로드되는지 확인하세요.

## 참조한 1차 자료

* [Laravel 공식 문서: 메일의 커스텀 Transport와 테스트(확인 시점)](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/mail.md#custom-transports)
* [Laravel 공식 문서: 패키지 개발](https://github.com/laravel/docs/blob/13.x/packages.md)
* [MailManager: 생성 처리, Mailer 보관, purge와 복합 Transport](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/MailManager.php)
* [MailServiceProvider: MailManager 바인딩](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/MailServiceProvider.php)
* [ServiceProvider: callAfterResolving](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/ServiceProvider.php)
* [Mailer: Transport로의 전송과 이벤트](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Mailer.php)
* [ArrayTransport: 메시지 기록](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Mail/Transport/ArrayTransport.php)
* [Mail 파사드와 MailFake](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Facades/Mail.php)
* [MailFake: Mailer 선택과 Mailable 기록](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/Testing/Fakes/MailFake.php)
* [Laravel 공식 테스트: Mailer와 ArrayTransport를 이용한 전송 검증](https://github.com/laravel/framework/blob/v13.39.0/tests/Mail/MailMailerTest.php)


## Related topics

- [메일 전송](https://kawax.biz/ko/mail.md)
- [고급 주제](https://kawax.biz/ko/advanced/index.md)
- [패키지의 Storage 드라이버 등록과 재해석](https://kawax.biz/ko/advanced/package-filesystem-drivers.md)
- [패키지의 이벤트 리스너 등록과 캐시](https://kawax.biz/ko/advanced/package-events.md)
- [패키지의 Artisan 명령어 등록과 의존성 해결](https://kawax.biz/ko/advanced/package-commands.md)


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