本記事は Spring Boot 3.x(Java 17以上) を対象としています。

HTTP APIのREST開発には慣れてきたけど、チャットや通知のようなリアルタイム機能をどう実装すればいいか迷ったことはないですか?そんなときに使うのが WebSocket です。

Spring Bootでは STOMPSockJS を組み合わせることで、シンプルな設定でリアルタイム通信機能を実装できます。この記事では、ブロードキャスト型チャット機能をゼロから作る手順をステップバイステップで解説します。

WebSocket・STOMP・SockJSの関係を整理する

まず3つの技術の役割をざっくり整理しましょう。

  • WebSocket は双方向通信を可能にするトランスポート層のプロトコルです。HTTPのように「リクエストしてレスポンスを待つ」のではなく、サーバーとクライアントがいつでもメッセージを送り合えます。
  • STOMP (Simple Text Oriented Messaging Protocol)はWebSocketの上に乗るメッセージングプロトコルで、publish/subscribeモデルを提供します。「このトピックを購読する」「このトピックにメッセージを送る」という形で通信を整理できます。
  • SockJS はWebSocketをサポートしていないブラウザやネットワーク環境向けのフォールバックライブラリです。WebSocketが使えない場合に自動でHTTPベースの代替手段に切り替えてくれます。

Spring Bootは spring-boot-starter-websocket 一つでこのスタック全体をサポートしています。

依存関係の追加

Mavenの場合は pom.xml に以下を追加します。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

Gradleの場合はこちらです。

implementation 'org.springframework.boot:spring-boot-starter-websocket'

WebSocket設定クラスの実装

設定の核心となるクラスです。@EnableWebSocketMessageBroker を有効にして、エンドポイントとブローカーを設定します。

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // クライアントが接続するエンドポイント。withSockJS() でフォールバックを有効化
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // /app で始まるメッセージはアプリケーションの @MessageMapping に転送
        registry.setApplicationDestinationPrefixes("/app");
        // /topic で始まるメッセージはインメモリブローカーが全購読者に配信
        registry.enableSimpleBroker("/topic");
    }
}

/app はクライアントからサーバーへの送信先プレフィックス、/topic はブロードキャスト配信先のプレフィックスです。この2つの役割の違いを押さえておくと、後の流れがスムーズになります。

メッセージ受信ハンドラの実装

クライアントからのメッセージを受け取るコントローラを作ります。DTOも合わせて定義しておきましょう。

public class ChatMessage {
    private String sender;
    private String content;

    public String getSender() { return sender; }
    public void setSender(String sender) { this.sender = sender; }
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
}

@Controller
public class ChatController {

    @MessageMapping("/chat")       // /app/chat 宛てのメッセージを受信
    @SendTo("/topic/messages")     // /topic/messages の購読者全員に返信
    public ChatMessage handleMessage(ChatMessage message) {
        return message;
    }
}

@MessageMapping はHTTPの @RequestMapping と同じ感覚で使えます。/app プレフィックスはSpringが自動で解釈するので、ここでは /chat と書くだけでOKです。

サーバーサイドからのブロードキャスト(SimpMessagingTemplate)

特定ユーザーへの1対1メッセージング(@SendToUser / convertAndSendToUser)

ブロードキャストではなく、特定ユーザーだけにメッセージを送りたい場面では @SendToUser または SimpMessagingTemplate#convertAndSendToUser を使います。Spring はユーザー名ごとにユニークなキュー(/user/queue/...)を内部的に管理し、該当セッションだけに配信します。

@Controller
public class PrivateChatController {

    @MessageMapping("/private")
    @SendToUser("/queue/messages")  // 送信元ユーザー本人にのみ返信
    public ChatMessage handlePrivate(ChatMessage message, Principal principal) {
        // principal.getName() で認証済みユーザー名が取れる
        message.setSender(principal.getName());
        return message;
    }
}

サーバー起点で任意のユーザー宛てに送るには次のように書きます。

messagingTemplate.convertAndSendToUser(
    "alice",                 // 宛先ユーザー名
    "/queue/notifications",  // ユーザー別キュー
    new ChatMessage("system", "新しい通知があります")
);

クライアント側は /user/queue/notifications を購読すれば、自分宛てのメッセージだけを受信できます。/user プレフィックスは Spring が自動付与するため、サーバー側コードでは省略している点に注意してください。

認証ユーザーの取得とメッセージレベル認可(Principal / ChannelInterceptor)

HTTPハンドシェイク時点の認証は前述の Spring Security 設定で済みますが、「このトピックは管理者しか購読できない」といった メッセージ単位の認可 には ChannelInterceptor を使います。

@Configuration
public class WebSocketAuthConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor =
                    MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

                if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
                    String destination = accessor.getDestination();
                    Principal user = accessor.getUser();
                    if (destination != null && destination.startsWith("/topic/admin")
                            && !hasAdminRole(user)) {
                        throw new AccessDeniedException("管理者権限が必要です");
                    }
                }
                return message;
            }
        });
    }
}

StompHeaderAccessor から STOMP コマンド(CONNECT / SUBSCRIBE / SEND)と宛先を取り出して認可判定する、というのが定型パターンです。JWTでユーザーを取得する場合の前段については Spring BootにJWT認証を実装する方法 を参照してください。

複数インスタンス構成でのスケーリング(外部ブローカー)

enableSimpleBroker はインメモリ実装のため、Spring Boot アプリを複数Podで動かすと インスタンス間でメッセージが共有されません。本番でロードバランサ配下に置く場合は、RabbitMQ や ActiveMQ をリレーとして利用します。

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.setApplicationDestinationPrefixes("/app");
    registry.enableStompBrokerRelay("/topic", "/queue")
        .setRelayHost("rabbitmq.internal")
        .setRelayPort(61613)
        .setClientLogin("guest")
        .setClientPasscode("guest");
}

RabbitMQ 側は rabbitmq_stomp プラグインを有効化しておく必要があります。Kubernetes 上で運用する場合のローリングアップデート時の接続切断対策については Spring Bootのグレースフルシャットダウンとゼロダウンタイムデプロイを実現する方法 も合わせて参照してください。

また、クライアント @stomp/stompjs には reconnectDelay オプションがあり、デフォルトで5秒間隔の自動再接続が有効です。本番ではこの値を環境に合わせて調整しておくと、デプロイ時のユーザー影響を最小化できます。

コントローラの外から能動的にメッセージをプッシュしたいときは SimpMessagingTemplate を使います。定期通知やイベント発生時のプッシュなどに便利です。

@Service
public class NotificationService {

    private final SimpMessagingTemplate messagingTemplate;

    public NotificationService(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    public void sendNotification(String message) {
        messagingTemplate.convertAndSend("/topic/notifications", message);
    }
}

convertAndSend の第一引数がトピックのパス、第二引数がペイロードです。@Scheduled のスケジュールタスクや別のServiceクラスからも呼び出せます。

JavaScriptクライアントの実装

フロントエンド側は SockJS@stomp/stompjs(v5以降) を使います。以下のHTMLをそのままブラウザで開いて動作確認できます。

<!DOCTYPE html>
<html>
<head>
    <title>WebSocket Chat</title>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client/dist/sockjs.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs/bundles/stomp.umd.min.js"></script>
</head>
<body>
    <input id="message" placeholder="メッセージを入力">
    <button onclick="sendMessage()">送信</button>
    <div id="output"></div>

    <script>
        const client = new StompJs.Client({
            webSocketFactory: () => new SockJS('/ws')
        });

        client.onConnect = () => {
            client.subscribe('/topic/messages', (msg) => {
                const body = JSON.parse(msg.body);
                document.getElementById('output').innerHTML +=
                    `<p>${body.sender}: ${body.content}</p>`;
            });
        };

        client.activate();

        function sendMessage() {
            const content = document.getElementById('message').value;
            client.publish({
                destination: '/app/chat',
                body: JSON.stringify({ sender: 'user1', content: content })
            });
        }
    </script>
</body>
</html>

旧来の Stomp.over(socket) パターンは stompjs(v2.x)のAPIです。2015年以降メンテナンスが止まっており非推奨となっているため、現在は @stomp/stompjs が後継として推奨されています。

Spring SecurityとWebSocketの統合

Spring Securityが導入済みのプロジェクトでは、WebSocketエンドポイントが 403 でブロックされることがあります。以下の設定で解消できます。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/ws/**").permitAll()  // WebSocketエンドポイントを許可
                .anyRequest().authenticated()
            )
            .csrf(csrf -> csrf
                .ignoringRequestMatchers("/ws/**")      // WebSocketはCSRF保護の対象外に
            );
        return http.build();
    }
}

WebSocketはHTTPのCSRFトークンの仕組みと相性が悪いため、WebSocketエンドポイントに限って無効化するのが一般的な対処法です。認証済みユーザーのみ接続させたい場合は permitAll()authenticated() に変更すればOKです。

なお、この設定はHTTPハンドシェイクレベルの保護が対象です。「どのトピックを誰が購読・送信できるか」というWebSocketメッセージレベルのアクセス制御はスコープ外となります。JWTと組み合わせた認証設定の詳細については Spring BootにJWT認証を実装する方法 を参照してください。

動作確認の手順

アプリを起動したら、HTMLファイルをブラウザの複数タブで開いてみましょう。片方のタブで送信したメッセージがもう一方にも届くことで、ブロードキャストの動作を確認できます。

Chrome DevToolsの ネットワークタブ でWSフィルタを選ぶと、送受信されているSTOMPフレームを確認できます。接続時に CONNECT フレームが流れているのが見えるはずです。

うまく動かないときは以下を確認してみてください。

  • 403エラー はSpring SecurityがWebSocketエンドポイントをブロックしているケースがほとんどです。permitAll() の設定を見直しましょう。
  • 接続は成功するがメッセージが届かない 場合は、/app/topic プレフィックスの設定ミスが多いです。送信先と購読先のパスを再確認してください。
  • CORSエラー が出る場合は registerStompEndpoints.setAllowedOrigins("*") を追加できますが、これは開発・動作確認用途に限ってください。本番では許可オリジンを明示するか setAllowedOriginPatterns を検討してください。詳細は Spring BootのCORS設定ガイド を参照。

まとめ

Spring Boot + STOMP + SockJSでリアルタイム通信を実装する流れを見てきました。設定クラスでエンドポイントとブローカーを定義し、@MessageMapping でメッセージを受け取り、SimpMessagingTemplate でプッシュ通知を送る、この3層の構造が基本です。

WebSocketのリクエスト処理にインターセプターを使いたいシーンでは、FilterとInterceptorの違いと使い方 も参考にしてみてください。

よくある質問(FAQ)

Q. WebSocketとServer-Sent Events (SSE) はどう使い分ければよいですか?

双方向通信が必要なチャットや共同編集には WebSocket、サーバーからの単方向プッシュ(通知配信・進捗表示・株価ティッカーなど)には SSE が適しています。SSE はHTTPの上に乗るため、プロキシやファイアウォールとの相性が良くインフラ要件もシンプルです。SSE の実装については別記事で扱っています。

Q. 本番環境ではWSS(WebSocket Secure)を使う必要がありますか?

はい。本番では wss:// で接続するのが必須です。HTTPSと同様にTLS終端を行うため、リバースプロキシ(Nginx / ALB / Ingress)側で証明書を扱い、Spring Boot 側は通常のHTTPで受ける構成が一般的です。Nginxを使う場合は proxy_http_version 1.1;Upgrade / Connection ヘッダの中継設定を忘れずに入れてください。

Q. WebSocketセッションのアイドルタイムアウトを伸ばすには?

ServletServerContainerFactoryBean を Bean として定義し、setMaxSessionIdleTimeout を設定します。デフォルトは無制限ですが、リバースプロキシ側でタイムアウトされるケースがあるため、アプリ層とプロキシ層の両方を合わせて調整するのが定石です。

@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
    ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
    container.setMaxSessionIdleTimeout(60 * 60 * 1000L); // 1時間
    return container;
}

Q. テストはどう書けばよいですか?

@SpringBootTest(webEnvironment = RANDOM_PORT) で起動した上で、WebSocketStompClient を使って実際にSTOMP接続するインテグレーションテストが定番です。CompletableFuture でメッセージ受信を待ち受け、タイムアウト付きで get() するのが一般的な書き方になります。