도메인 이벤트에서 웹훅으로

발행: (2025년 12월 28일 오전 08:11 GMT+9)
7 min read
원문: Dev.to

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

  1. 이벤트 타입이 설정된 $webhookTopics에 포함되어 있는지 확인합니다.
  2. 이벤트를 정규화하거나(또는 커스텀 페이로드를) 생성합니다.
  3. 페이로드를 RemoteEvent에 래핑합니다.
  4. 토픽과 일치하는 각 구독에 대해 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-IdRemoteEvent ID
  • Webhook-EventRemoteEvent 이름

서명 알고리즘이나 헤더 이름을 커스터마이즈하려면 다음 서비스를 데코레이트합니다:

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에서 게시되었습니다

질문이 있나요? LinkedIn 또는 Twitter에서 찾아주세요.

Back to Blog

관련 글

더 보기 »

이벤트 기반 아키텍처 설명: 심층 탐구

Event-Driven Architecture Explained: A Deep Dive 오늘날 급변하는 소프트웨어 환경에서, 확장 가능하고 복원력 있으며 반응성이 뛰어난 애플리케이션을 구축하는 것은 p...