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

# Mercure 브로드캐스트 드라이버의 내부 구조

> Laravel framework에 추가된 Mercure 브로드캐스트 드라이버의 동작 방식을 소스 코드를 기반으로 해설합니다. Reverb나 Pusher와는 다른 HTTP/SSE 기반 아키텍처, 인가 Cookie, E2E 암호화 채널까지 다룹니다.

<Warning>
  이 페이지에서 다루는 `MercureBroadcaster`는 [laravel/framework PR #61474](https://github.com/laravel/framework/pull/61474)(2026년 9월 10일 병합)에서 추가된 기능입니다. 집필 시점에는 태그된 릴리스에 아직 포함되어 있지 않으며 [공식 문서](https://laravel.com/docs/broadcasting)에도 기재되어 있지 않습니다. 소스 코드와 docblock을 바탕으로 한 선행 해설이므로, 실제로 사용할 때는 `laravel/framework`의 변경 이력을 확인해 주세요.
</Warning>

## 개요

Laravel의 브로드캐스팅은 오랫동안 [Reverb](/ko/broadcasting)·Pusher·Ably라는 "WebSocket 네이티브" 드라이버를 제공해 왔지만, `config/broadcasting.php`의 `driver`에 새롭게 `mercure`가 추가되었습니다.

[Mercure](https://mercure.rocks)는 Server-Sent Events(SSE)를 기반으로 한 실시간 통신 프로토콜입니다. WebSocket과 같은 양방향 커넥션이 아니라 HTTP/1.1·HTTP/2 상의 롱 폴링에 가까운 방식으로 동작하기 때문에 다음과 같은 특징이 있습니다.

* 일반적인 HTTP 인프라(리버스 프록시, CDN, 로드 밸런서)를 그대로 통과할 수 있습니다
* 브라우저의 `EventSource` API만으로 클라이언트를 구현할 수 있어, 전용 클라이언트 라이브러리 없이도 동작합니다
* [FrankenPHP](/ko/blog/laravel-cloud)에는 내장 Mercure 허브가 탑재되어 있어 추가 인프라 없이 실행할 수 있습니다

```mermaid theme={null}
flowchart LR
    A["서버<br>broadcast(event)"] --> B["MercureBroadcaster"]
    B --> C["Mercure 허브<br>(Hub / FrankenPhpHub)"]
    C -->|"SSE (EventSource)"| D["브라우저<br>Laravel Echo"]
    D --> E["UI의<br>실시간 업데이트"]
```

## `driver` 추가와 지원되는 값

`config/broadcasting.php` 첫머리 주석에 있는 지원 드라이버 목록에 `mercure`가 추가되었습니다.

```php theme={null}
// Supported: "reverb", "pusher", "ably", "mercure", "redis", "log", "null"
```

커넥션 설정 샘플도 준비되어 있습니다.

```php theme={null}
'mercure' => [
    'driver' => 'mercure',
    'url' => env('MERCURE_URL'),
    'public_url' => env('MERCURE_PUBLIC_URL'),
    'secret' => env('MERCURE_JWT_SECRET'),
    'encryption_key' => env('MERCURE_ENCRYPTION_KEY'),
    'claims' => [
        'iss' => env('MERCURE_JWT_ISSUER'),
        'client_id' => env('APP_NAME'),
    ],
    'cookie_name' => env('MERCURE_COOKIE_NAME'),
    'subscribe_expiration' => (int) env('MERCURE_SUBSCRIBE_EXPIRATION', 5),
],
```

`url`을 생략하면 FrankenPHP의 내장 Mercure 허브(`mercure_publish()` 함수)로 자동 폴백합니다. 이 판정은 `CreatesMercureDrivers::mercure()`에서 이루어집니다.

```php theme={null}
public function mercure(array $config)
{
    if (empty($config['url'])) {
        return $this->frankenPhpMercure($config);
    }

    // ...
}
```

외부에 Mercure 허브를 운영하는 경우에는 `url`에 관리 API(publish용) URL을, `public_url`에 브라우저에서 보이는 URL을 지정합니다. 두 값이 다른 이유는, Docker Compose 등 내부 네트워크를 경유하여 publish하면서 공개 URL만 브라우저에 노출하는 구성을 상정하고 있기 때문입니다.

## 토큰 발행 방식: publish용과 subscribe용을 분리

Mercure는 JWT(JSON Web Token)로 접근 제어를 수행하는 프로토콜입니다. `CreatesMercureDrivers` 트레이트는 publish(서버 → 허브)와 subscribe(브라우저 → 허브)에 대해 **별도의 토큰 팩토리**를 구성합니다.

* `secret` / `publish_secret` / `subscribe_secret`을 개별적으로 지정할 수 있으며, 생략 시에는 `secret`으로 폴백합니다
* `algorithm` / `publish_algorithm` / `subscribe_algorithm`도 마찬가지로 개별 지정이 가능합니다(기본값 `HS256`)
* HS256은 32바이트, HS384는 48바이트, HS512는 64바이트 이상의 시크릿 길이가 필수이며, 이를 만족하지 못하면 `InvalidArgumentException`이 발생합니다

```php theme={null}
protected function mercureSecret(array $config, string $side)
{
    $secret = ($config[$side.'_secret'] ?? null) ?: ($config['secret'] ?? null);

    // ...

    $minimumLength = ['HS256' => 32, 'HS384' => 48, 'HS512' => 64][$algorithm] ?? 0;

    if (strlen($secret) < $minimumLength) {
        throw new InvalidArgumentException(/* ... */);
    }

    return $secret;
}
```

publish 토큰은 `Grant::ACTION_PUBLISH`로 모든 토픽(`*`)에 대한 발행 권한을 가지며, `CachingTokenProvider`에 의해 메모리 캐시됩니다. 브로드캐스트할 때마다 JWT를 다시 생성하지 않도록 하기 위한 최적화입니다.

## 채널 인가와 Cookie 기반의 구독 모델

WebSocket 드라이버와의 가장 큰 차이는, 구독 측의 인가가 **단일 Cookie**로 이루어진다는 점입니다. `MercureBroadcaster::auth()`는 `channel_names` 배열(1회 요청당 최대 100건)을 받아 채널마다 인가를 판정한 뒤, 하나의 인가 Cookie로 묶어서 발행합니다.

```php theme={null}
public function auth($request)
{
    $channelNames = (array) $request->input('channel_names', []);

    if ($channelNames === [] ||
        count($channelNames) > 100 ||
        $channelNames !== array_filter($channelNames, 'is_string')) {
        throw new AccessDeniedHttpException;
    }

    // 채널마다 인가를 판정하고 Grant를 누적합니다...

    return (new JsonResponse([/* ... */]))
        ->cookie($this->makeAuthorizationCookie($request, $grants, $user));
}
```

핵심은 "한 채널의 인가가 실패해도 응답 전체를 실패시키지 않는" 설계입니다. 거부된 채널은 `denied: true`로 응답에 포함될 뿐, 다른 허가된 채널의 구독은 계속 유지됩니다. Mercure는 하나의 EventSource로 여러 토픽을 다중화하기 때문에, 중간에 채널을 추가·제거해도 기존 커넥션을 끊지 않도록 하기 위함입니다.

Presence 채널은 일반적인 `private`/`private-encrypted` 채널과 다른 `Grant`를 발행하며, 구독 URL 패턴(Mercure의 [Subscription API](https://mercure.rocks/docs/hub/concepts/active-subscriptions))에 대한 `subscribe` 권한으로 편입됩니다.

## 엔드투엔드 암호화 채널

`private-encrypted-`로 시작하는 채널 이름은, Mercure 허브 자체에도 페이로드를 보여주지 않는 "엔드투엔드 암호화(E2EE)" 채널로 취급됩니다. 이는 기존 Pusher/Reverb 드라이버에는 없는 기능입니다.

`encryption_key`를 base64 인코딩된 32바이트 키로 설정하면 `ChannelEncrypter`가 활성화되며, `broadcast()` 실행 시점에 이벤트 이름·페이로드·소켓 ID를 함께 JWE(JSON Web Encryption)로 암호화합니다. 허브가 중계하는 것은 암호화된 바이트열뿐입니다.

```php theme={null}
'encryption_key' => env('MERCURE_ENCRYPTION_KEY'),
```

```shell theme={null}
php -r "echo base64_encode(random_bytes(32));"
```

구독 측은 `auth()` 응답에 포함된 `jwk`(JSON Web Key) 필드를 사용하여 브라우저 측에서 복호화합니다. 키 교환은 Mercure 사양이 권장하는 "인가 응답에 실어 아웃오브밴드로 전달하는" 방식으로, 허브 자체는 키를 전혀 알지 못합니다.

<Info>
  Presence 채널은 암호화 대상이 아닙니다. 멤버 목록이 Mercure의 Subscription API를 경유하여 허브에 보이는 사양이므로 E2EE와는 양립할 수 없습니다.
</Info>

## Whisper(클라이언트 간 직접 메시지)

`client_events`가 활성화된 경우(기본값 `true`), 각 가드 채널에는 전용 "whisper 토픽"이 할당되며, 구독자는 그 토픽에 대해서만 `publish` 권한을 가집니다.

```php theme={null}
if ($this->clientEvents && $whisperTopics !== []) {
    $grants[] = new Grant([Grant::ACTION_SUBSCRIBE, Grant::ACTION_PUBLISH], $whisperTopics);
}
```

채널 본체의 토픽은 서버 전용으로 유지되기 때문에, 클라이언트가 위장된 서버 이벤트를 발행할 수는 없습니다. Pusher의 "클라이언트 이벤트" 기능에 가까운 설계이지만, 토픽을 분리함으로써 권한을 보다 명확하게 나누고 있습니다.

## 토픽 명명 규칙

Mercure는 여러 애플리케이션이 허브를 공유할 수 있도록 설계되어 있으며, 토픽 충돌을 피하기 위해 `topic_prefix`라는 네임스페이스를 도입하고 있습니다. 기본값은 다음과 같습니다.

```php theme={null}
protected string $topicPrefix = 'https://laravel.alt/echo/',
```

`.alt`는 DNS 상에서 예약된 미해결 도메인([RFC 9476](https://www.rfc-editor.org/rfc/rfc9476.html))이므로 실제 도메인과 충돌할 걱정이 없습니다. 채널 이름은 [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986.html)에 따라 URL 인코딩되어 단일 경로 세그먼트에 담깁니다.

```php theme={null}
protected function channelTopic($channelName)
{
    return $this->topicPrefix.'channel/'.rawurlencode($channelName);
}
```

## Cookie 도메인과 오류 메시지

Mercure 허브가 애플리케이션과 다른 서브도메인에서 동작하는 구성(예: `mercure.example.com`과 `app.example.com`)도 흔하기 때문에, 인가 Cookie의 도메인 해석에 실패하면 원인을 특정하기 쉬운 예외 메시지가 발생합니다.

```php theme={null}
throw new BroadcastException(sprintf(
    'Mercure error: %s. Adjust the Mercure "public_url" configuration value so the hub [%s] shares a registrable domain with the application host [%s].',
    rtrim($e->getMessage(), '.'), $this->hub->getPublicUrl(), $request->getHost()
), 0, $e);
```

`__Secure-`나 `__Host-` 접두사가 붙은 Cookie 이름을 사용하는 경우에는, `public_url`이 HTTPS인지도 기동 시에 검사됩니다.

```php theme={null}
if (str_starts_with($hub->getCookieName(), '__') &&
    parse_url($hub->getPublicUrl(), PHP_URL_SCHEME) === 'http') {
    throw new InvalidArgumentException(/* ... */);
}
```

## Reverb·Pusher·Ably와의 사용 구분

| 드라이버          | 트랜스포트      | 인프라                     | E2E 암호화                  |
| ------------- | ---------- | ----------------------- | ------------------------ |
| Reverb        | WebSocket  | 자체 서버 필수                | 없음                       |
| Pusher / Ably | WebSocket  | SaaS                    | 없음                       |
| Mercure       | SSE (HTTP) | 자체 허브, 또는 FrankenPHP 내장 | 있음(`private-encrypted-`) |

Mercure는 WebSocket의 상시 양방향 커넥션이 필요 없는 유스케이스(알림, 진행 상황 표시, 채팅처럼 서버 → 클라이언트의 단방향 전송이 중심인 용도)에 적합합니다. FrankenPHP를 사용하고 있다면 추가 인프라 없이 동작하는 것도 장점입니다.

## 관련 페이지

* [브로드캐스트 기초](/ko/broadcasting)
* [Laravel Cloud](/ko/blog/laravel-cloud)(FrankenPHP 기반의 실행 환경)


## Related topics

- [브로드캐스팅](/ko/broadcasting.md)
- [Redis](/ko/redis.md)
- [Laravel 11 이후의 새 앱 구조 FAQ](/ko/advanced/app-structure-faq.md)
- [Laravel AI SDK](/ko/ai-sdk.md)
- [Laravel Reverb](/ko/reverb.md)
