도메인 이벤트에서 웹훅으로
Source: Dev.to
도메인 이벤트
도메인 이벤트는 다음 인터페이스를 구현합니다:
<?php
interface DomainEvent
{
public function aggregateRootId(): string;
public function displayReference(): string;
public function occurredAt(): \DateTimeImmutable;
public static function eventType(): DomainEventType;
}
예시 이벤트
<?php
#[TriggerWebhook]
class OrderConfirmed implements DomainEvent
{
public function __construct(
private string $orderId,
private string $orderNumber,
private \DateTimeImmutable $confirmedAt,
) {}
public function aggregateRootId(): string
{
return $this->orderId;
}
public function displayReference(): string
{
return $this->orderNumber;
}
public function occurredAt(): \DateTimeImmutable
{
return $this->confirmedAt;
}
public static function eventType(): DomainEventType
{
return DomainEventType::OrderConfirmed;
}
}
#[TriggerWebhook] 속성은 이 이벤트를 웹훅 전송 대상으로 표시합니다.
이벤트‑타입 열거형
DomainEventType 열거형은 이벤트 타입을 구체적인 클래스에 매핑합니다.
(우리는 또한 이벤트를 이벤트 스토어에 저장합니다; eventClass() 메서드는 저장된 이벤트를 역직렬화할 때 사용됩니다 – 해당 부분은 여기서 생략했습니다.)
<?php
enum DomainEventType: string
{
case OrderConfirmed = 'order.confirmed';
// … more event types
public function eventClass(): string
{
return match ($this) {
self::OrderConfirmed => OrderConfirmed::class,
// … more mappings
};
}
}
엔티티
엔티티는 무슨 일이 일어났는지 기록합니다. 리포지토리는 이를 저장하고 이벤트를 디스패치합니다 (Symfony Messenger를 통해).
<?php
class Order
{
/** @var DomainEvent[] */
private array $events = [];
public function confirm(\DateTimeImmutable $confirmedAt): void
{
$this->status = OrderStatus::Confirmed;
$this->events[] = new OrderConfirmed(
orderId: $this->id,
orderNumber: $this->orderNumber,
confirmedAt: $confirmedAt,
);
}
public function releaseEvents(): array
{
$events = $this->events;
$this->events = [];
return $events;
}
}
<?php
class OrderRepository
{
public function __construct(
private EntityManagerInterface $em,
private MessageBusInterface $eventBus,
) {}
public function save(Order $order): void
{
$events = $order->releaseEvents();
$this->em->persist($order);
$this->em->flush();
foreach ($events as $event) {
$this->eventBus->dispatch($event);
}
}
}
Webhooks
메시지 핸들러는 모든 도메인 이벤트를 처리하지만, $webhookTopics 배열에 포함된 이벤트에 대해서만 웹훅을 전송합니다.
이 배열은 Symfony의 resource tags( PR #59704 에서 추가됨) 를 사용해 #[TriggerWebhook] 어트리뷰트가 붙은 모든 클래스를 수집함으로써 채워집니다.
<?php
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Uid\Factory\UuidFactory;
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;
use Symfony\Component\Webhook\Subscriber;
#[AsMessageHandler]
class WebhookSender
{
/**
* @param DomainEventType[] $webhookTopics
*/
public function __construct(
private MessageBusInterface $bus,
private WebhookSubscriptions $subscriptions,
private NormalizerInterface $normalizer,
private UuidFactory $uuidFactory,
#[Autowire(param: 'webhook.topics')]
private array $webhookTopics,
) {}
public function __invoke(DomainEvent $event): void
{
if (!\in_array($event::eventType(), $this->webhookTopics, true)) {
return;
}
$payload = $this->createPayload($event);
$remoteEvent = new RemoteEvent(
name: $event::eventType()->value,
id: $this->uuidFactory->create()->toString(),
payload: $payload,
);
foreach ($this->subscriptions->findByTopic($event::eventType()) as $subscription) {
$this->bus->dispatch(
new SendWebhookMessage(
new Subscriber($subscription->url, $subscription->secret),
$remoteEvent,
),
);
}
}
private function createPayload(DomainEvent $event): array
{
// Option 1: Send the event directly
// return $this->normalizer->normalize($event);
// Option 2: Send a dataless notification with a resource URL (used here)
return [
'resourceId' => $event->aggregateRootId(),
'displayReference' => $event->displayReference(),
'occurredAt' => $event->occurredAt()->getTimestamp(),
'topic' => $event::eventType()->value,
// … additional fields as needed
];
}
}
Handler flow
- 이벤트 타입이 설정된
$webhookTopics에 포함되어 있는지 확인합니다. - 이벤트를 정규화하거나(또는 커스텀 페이로드를) 생성합니다.
- 페이로드를
RemoteEvent에 래핑합니다. - 토픽과 일치하는 각 구독에 대해
SendWebhookMessage를 디스패치합니다.
Example payload generation (optional)
// In practice, we use ApiPlatform\Metadata\IriConverterInterface to generate resource URLs,
// you can use a similar strategy.
[
'url' => "https://example.com/api/orders/{$event->aggregateRootId()}",
];
구독 인터페이스 및 모델
<?php
interface WebhookSubscriptions
{
/**
* @return iterable<WebhookSubscription>
*/
public function findByTopic(DomainEventType $topic): iterable;
}
<?php
class WebhookSubscription
{
public function __construct(
public string $url,
public string $secret,
) {}
}
Symfony\Component\Webhook\Subscriber 클래스는 Symfony에서 웹훅 소비자를 나타냅니다. 이 클래스는 웹훅 URL과 시크릿을 보관합니다. Symfony의 웹훅 전송기가 HTTP POST 요청을 보낼 때 자동으로 다음 헤더를 추가합니다:
- Webhook-Signature – 시크릿을 사용한 HMAC‑SHA256 서명 (
sha256=...형식) - Webhook-Id –
RemoteEventID - Webhook-Event –
RemoteEvent이름
서명 알고리즘이나 헤더 이름을 커스터마이즈하려면 다음 서비스를 데코레이트합니다:
TIP: SendWebhookMessage 를 비동기 전송기로 라우팅하여 논블로킹 전송을 구현하세요. 일시적인 실패를 처리하기 위해 재시도 전략(지연 + 배수)을 구성합니다. 이벤트 순서가 중요하다면 FIFO 큐를 사용하십시오.
API Platform (OpenAPI)으로 웹훅 문서화
API Platform을 사용하고 있다면, OpenAPI에 웹훅을 문서화할 수 있습니다. 아래는 앞서 소개한 dataless‑notification 패턴을 활용한 예시입니다.
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Attributes\Webhook;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\Parameter;
use ApiPlatform\OpenApi\Model\PathItem;
use ApiPlatform\OpenApi\Model\Response;
#[ApiResource(
operations: [
new Post(
openapi: new Webhook(
name: 'Webhook',
pathItem: new PathItem(
post: new Operation(
operationId: 'resource_webhook',
tags: ['Webhooks'],
responses: [
'2XX' => new Response(
description: 'Return 2xx to acknowledge receipt'
),
'default' => new Response(
description: 'Non-2xx triggers retry: 5m, 25m, 2h5m'
),
],
summary: 'Webhook notification for order events',
description: 'Sent when subscribed order events occur. Event type in Webhook-Event header.',
parameters: [
new Parameter(
name: 'Webhook-Signature',
in: 'header',
description: 'HMAC‑SHA256 signature of the payload',
required: true,
),
new Parameter(
name: 'Webhook-Id',
in: 'header',
description: 'Unique identifier of the RemoteEvent',
required: true,
),
new Parameter(
name: 'Webhook-Event',
in: 'header',
description: 'Name of the event (topic)',
required: true,
),
],
),
),
),
),
],
)]
class Order
{
// …
}
추가 OpenAPI 예시
<?php
#[OA\Get(
path: '/webhooks',
operationId: 'listWebhooks',
tags: ['Webhooks'],
summary: 'List all webhooks',
description: 'Retrieve a paginated list of webhook subscriptions.',
parameters: [
new Parameter(
name: 'page',
in: 'query',
description: 'Page number for pagination',
required: false,
schema: ['type' => 'integer', 'default' => 1]
),
new Parameter(
name: 'perPage',
in: 'query',
description: 'Number of items per page',
required: false,
schema: ['type' => 'integer', 'default' => 20]
),
],
responses: [
new OA\Response(
response: 200,
description: 'Successful response',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(
property: 'data',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/WebhookPayload')
),
new OA\Property(
property: 'meta',
type: 'object',
properties: [
new OA\Property(property: 'total', type: 'integer'),
new OA\Property(property: 'page', type: 'integer'),
new OA\Propert
y(property: 'perPage', type: 'integer')
]
)
]
)
)
]
)]
class WebhookPayload
{
public function __construct(
public string $resourceId,
public string $displayReference,
public \DateTimeImmutable $occurredAt,
public string $url,
public string $topic,
) {}
}
원본은 my blog에서 게시되었습니다