Laravel과 Symfony
컨테이너 안의 클라이언트 하나, 두 번 발송할 수 없는 작업, 웹훅 컨트롤러, 그리고 네트워크에 닿지 않는 테스트.
설정하기
이 패키지에는 자체 프레임워크 통합이 없습니다. 서비스 프로바이더도, 번들도, 메일 트랜스포트도 없습니다. 컨테이너에 클라이언트 하나를 등록하고, 여느 PHP 프로그램에서와 똑같이 작업, 컨트롤러, 서비스에서 API를 호출합니다. 클라이언트는 프로세스가 살아 있는 동안 계속 두어도 안전하므로, 공유 서비스로 한 번만 등록하세요.
<?php return [ 'openemail' => [ 'key' => env('OPENEMAIL_API_KEY'), 'webhook_secret' => env('OPENEMAIL_WEBHOOK_SECRET'), ],];<?php namespace App\Providers; use Illuminate\Support\ServiceProvider;use OpenEmail\OpenEmail; final class AppServiceProvider extends ServiceProvider{ public function register(): void { $this->app->singleton(OpenEmail::class, fn(): OpenEmail => new OpenEmail( apiKey: (string) config('services.openemail.key'), timeout: 15, )); }}Laravel에서는 키를 .env에 두고 config/services.php를 통해 읽어야 php artisan config:cache 후에도 유지됩니다. 싱글턴은 무언가가 처음 요청할 때 만들어지므로, php artisan migrate 같은 명령은 키 없이도 잘 실행됩니다. 그 뒤로는 OpenEmail을 명시한 모든 생성자나 handle 메서드가 같은 클라이언트를 받습니다.
services: OpenEmail\OpenEmail: arguments: $apiKey: '%env(OPENEMAIL_API_KEY)%' $timeout: 15Symfony에서는 클라이언트를 클래스 이름으로 서비스에 선언하면, 오토와이어링이 OpenEmail을 요구하는 모든 생성자에 그것을 넘겨줍니다. 서비스는 기본적으로 공유되므로 컨테이너 전체가 클라이언트 하나를 씁니다.
예제는 Laravel 12 이상과 Symfony 6.4 이상을 사용합니다. 클라이언트 자체는 어떤 프레임워크에도 의존하지 않으므로, 이전 버전에서도 각 버전의 서비스 등록 방식으로 작동합니다.
작업에서 발송하기
요청이 아니라 큐에 넣은 작업에서 발송하면, 느리거나 실패하는 발송이 페이지를 붙잡아 두는 일이 없습니다. idempotencyKey:는 발송 대상인 레코드에서 만드세요. 큐가 재시도했거나 API가 응답한 뒤 워커가 죽어서 작업이 다시 실행되면 같은 키로 발송하므로, API는 두 번째 메시지를 보내지 않고 이미 보낸 메시지를 재생합니다.
<?php namespace App\Jobs; use App\Models\Invoice;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Queue\Queueable;use Illuminate\Support\Facades\Storage;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail; final class SendInvoiceEmail implements ShouldQueue{ use Queueable; public int $tries = 5; public int $backoff = 30; public function __construct(public Invoice $invoice) {} public function handle(OpenEmail $openemail): void { try { $sent = $openemail->emails->send([ 'from' => 'Acme Billing <[email protected]>', 'to' => $this->invoice->customer_email, 'subject' => 'Invoice ' . $this->invoice->number, 'html' => '<p>Your invoice ' . $this->invoice->number . ' is attached.</p>', 'attachments' => [[ 'filename' => $this->invoice->number . '.pdf', 'content' => OpenEmail::toBase64((string) Storage::get($this->invoice->pdf_path)), 'contentType' => 'application/pdf', ]], ], idempotencyKey: 'invoice:' . $this->invoice->id . ':email'); } catch (ValidationException $error) { $this->fail($error); return; } $this->invoice->update(['email_id' => $sent['id'], 'email_status' => $sent['status']]); }}NetworkException은 그대로 빠져나가게 두므로 큐가 $backoff초 뒤에 작업을 다시 시도하며, 첫 실행이 실제로 API에 닿았다면 같은 키 덕분에 다음 실행은 재생이 됩니다. ValidationException은 작업을 즉시 실패시킵니다. API가 작성된 그대로는 거부한 메시지는 다시 보내도 성공할 수 없기 때문입니다. NetworkException이 작업에 도달할 즈음이면 클라이언트는 이미 같은 키로 직접 발송을 다시 시도했으며, 기본값은 두 번입니다.
키는 invoice:42:email처럼 레코드와 목적에 대해 안정적으로 유지하세요. 타임스탬프나 uniqid()로 만든 키는 실행할 때마다 새로워지므로, 재시도된 작업이 두 번 발송하게 됩니다. 두 실행 사이에 바뀐 청구서처럼 다른 본문으로 키를 재사용하면, 발송되지 않고 422 idempotency_key_reuse로 거부됩니다.
Symfony Messenger 핸들러도 같은 방식으로 동작합니다. Messenger는 실패한 메시지를 스스로 재시도하며, UnrecoverableMessageHandlingException을 던지면 재시도하지 말라고 알리는 것입니다.
<?php namespace App\MessageHandler; use App\Message\SendWelcomeEmail;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail;use Symfony\Component\Messenger\Attribute\AsMessageHandler;use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; #[AsMessageHandler]final class SendWelcomeEmailHandler{ public function __construct(private readonly OpenEmail $openemail) {} public function __invoke(SendWelcomeEmail $message): void { try { $this->openemail->emails->send([ 'from' => 'Acme <[email protected]>', 'to' => $message->email, 'subject' => 'Welcome to Acme', 'text' => 'Glad you are here.', ], idempotencyKey: 'welcome:' . $message->userId); } catch (ValidationException $error) { throw new UnrecoverableMessageHandlingException($error->getMessage(), 0, $error); } }}Laravel에는 자체 ValidationException이 있습니다. 한 파일에서 둘 다 필요하면, use OpenEmail\Exception\ValidationException as OpenEmailValidationException;처럼 하나를 다른 이름으로 가져오세요.
웹훅 받기
전달에 따라 무언가를 하기 전에 반드시 검증하세요. 서명은 본문의 원시 바이트에 대한 것이므로, 검증기에는 파싱하거나 다시 인코딩한 사본이 아니라 $request->getContent()를, 서명 헤더를 읽어 낼 $request->headers와 함께 전달하세요.
<?php namespace App\Http\Controllers; use App\Jobs\HandleOpenEmailEvent;use Illuminate\Http\Request;use Illuminate\Http\Response;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; final class OpenEmailWebhookController{ public function __invoke(Request $request): Response { try { $event = OpenEmail::verifyWebhookSignature( $request->getContent(), $request->headers, (string) config('services.openemail.webhook_secret'), ); } catch (WebhookSignatureException) { return response('bad signature', 400); } HandleOpenEmailEvent::dispatch($event); return response()->noContent(); }}<?php use App\Http\Controllers\OpenEmailWebhookController;use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;use Illuminate\Support\Facades\Route; Route::post('/webhooks/openemail', OpenEmailWebhookController::class) ->withoutMiddleware([ValidateCsrfToken::class]);전달에는 CSRF 토큰이 없으므로, routes/web.php의 라우트는 그 미들웨어를 빼야 합니다. 그렇지 않으면 컨트롤러가 실행되기 전에 Laravel이 419로 POST를 거부합니다. routes/api.php의 라우트에는 애초에 CSRF 검사가 없습니다.
<?php namespace App\Controller; use App\Message\OpenEmailEvent;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Symfony\Component\DependencyInjection\Attribute\Autowire;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Attribute\AsController;use Symfony\Component\Messenger\MessageBusInterface;use Symfony\Component\Routing\Attribute\Route; #[AsController]final class OpenEmailWebhookController{ public function __construct( private readonly MessageBusInterface $bus, #[Autowire(env: 'OPENEMAIL_WEBHOOK_SECRET')] private readonly string $webhookSecret, ) {} #[Route('/webhooks/openemail', methods: ['POST'])] public function __invoke(Request $request): Response { try { $event = OpenEmail::verifyWebhookSignature($request->getContent(), $request->headers, $this->webhookSecret); } catch (WebhookSignatureException) { return new Response('bad signature', Response::HTTP_BAD_REQUEST); } $this->bus->dispatch(new OpenEmailEvent($event)); return new Response(null, Response::HTTP_NO_CONTENT); }}이벤트는 큐에 넘기고 바로 응답하세요: 5초 안에 응답을 받지 못한 전달은 실패로 간주되어 나중에 다시 전송됩니다. 이벤트의 id는 재시도와 재생 때마다 같으므로, 처리한 id를 저장해 두고 이미 본 것은 건너뛰세요.
400은 위조되었거나 오래된 전달에만 응답하세요. 시크릿이 없는 것은 다른 종류의 실패입니다: OpenEmail::verifyWebhookSignature()는 그 경우 InvalidArgumentException을 던지고 위의 컨트롤러는 이를 잡지 않으므로, 설정이 잘못된 앱은 500으로 응답하고 문제를 고친 뒤 전달이 다시 시도됩니다. 모든 이벤트가 위조로 거부되는 일은 없습니다.
장기 실행 워커
PHP-FPM에서는 요청마다 컨테이너가 만들어지고, 클라이언트도 마찬가지입니다. Laravel Octane, RoadRunner, 워커 모드의 FrankenPHP, 큐 워커에서는 싱글턴이 워커와 수명을 같이 하므로, 클라이언트 하나와 그 열린 연결이 워커가 처리하는 모든 요청을 처리합니다. 클라이언트는 한 요청에만 속하는 상태를 갖지 않습니다: 호출별 apiKey:는 그 호출에 한해서만 자격 증명을 대체합니다.
pcntl_fork처럼 fork하는 워커에서는 자식 프로세스가 자신만의 연결을 가집니다. 자식 프로세스는 물려받은 연결을 더 이상 쓰지 않고, 첫 요청에서 새 연결을 엽니다. 다만 자식 프로세스가 종료되면 물려받은 연결이 부모 쪽에서도 닫히므로, fork하기 전에 $client->close()를 호출하세요. 그러면 부모는 어떤 자식과도 공유하지 않는 새 연결을 엽니다.
테스트
테스트 동안 가짜 HTTP 클라이언트를 가진 클라이언트를 컨테이너에 넣으면 아무것도 컴퓨터 밖으로 나가지 않습니다. OpenEmail\Http\HttpClient를 구현하는 것이면 익명 클래스를 포함해 무엇이든 되며, 나갔을 내용을 정확히 볼 수 있습니다.
<?php namespace Tests\Feature; use App\Jobs\SendInvoiceEmail;use App\Models\Invoice;use Illuminate\Foundation\Testing\RefreshDatabase;use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse;use OpenEmail\OpenEmail;use Tests\TestCase; final class SendInvoiceEmailTest extends TestCase{ use RefreshDatabase; public function testARetriedJobSendsUnderOneKey(): void { $api = new class implements HttpClient { public array $requests = []; public function send(HttpRequest $request): HttpResponse { $this->requests[] = $request; return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}'); } }; $this->app->instance(OpenEmail::class, new OpenEmail( apiKey: 'oe_test_fake', httpClient: $api, maxRetries: 0, disableUpdateNotice: true, )); $invoice = Invoice::factory()->create(); SendInvoiceEmail::dispatchSync($invoice); SendInvoiceEmail::dispatchSync($invoice); $keys = array_map(fn(HttpRequest $request): ?string => $request->header('Idempotency-Key'), $api->requests); $this->assertSame(array_fill(0, 2, 'invoice:' . $invoice->id . ':email'), $keys); $this->assertSame('/emails', parse_url($api->requests[0]->url, PHP_URL_PATH)); }}기록된 각 요청은 method, url, headers, body, timeout을 가진 OpenEmail\Http\HttpRequest이며, 그 body는 전송되었을 JSON이므로 json_decode($request->body, true)로 메시지 자체를 볼 수 있습니다. 코드가 거부를 어떻게 처리하는지 테스트하려면 API의 오류 봉투와 함께 오류 상태를 반환하세요. Symfony에서는 config/services_test.yaml에서 테스트 환경용으로 서비스를 다시 정의하고, $httpClient 인자로 가짜 HTTP 클라이언트를 넘기세요.
<?php namespace App\Tests; use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse; final class FakeOpenEmailHttpClient implements HttpClient{ public array $requests = []; public function send(HttpRequest $request): HttpResponse { $this->requests[] = $request; return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}'); }}services: App\Tests\FakeOpenEmailHttpClient: ~ OpenEmail\OpenEmail: arguments: $apiKey: 'oe_test_fake' $httpClient: '@App\Tests\FakeOpenEmailHttpClient' $maxRetries: 0 $disableUpdateNotice: true