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

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

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

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 一つでこのスタック全体をサポートしています。

STOMP は「フレーム」と呼ばれるテキストの単位でやり取りするプロトコルで、Spring Boot のアプリで登場する主なフレームは次の 4 つです。

  • CONNECT はクライアントが接続直後に送るフレームで、サーバーは CONNECTED を返します。
  • SUBSCRIBE は /topic/messages のような宛先の購読を宣言します。
  • SEND はクライアントからサーバーへメッセージを送るフレームで、/app/chat 宛てなら後述の @MessageMapping に届きます。
  • MESSAGE はサーバーが購読者へ配信するフレームです。

フレームは次のように、コマンド行・ヘッダ行・空行・本文という HTTP によく似た構造をしています。

SEND
destination:/app/chat
content-type:application/json

{"sender":"user1","content":"hello"}

この構造を知っておくと、後半の動作確認で DevTools に流れるフレームが読めるようになります。

なお、通知配信や進捗表示のようにサーバーからの単方向プッシュだけで足りる場合は、WebSocketではなく Server-Sent Events (SSE) が適しています。SSEはHTTPの上に乗るためプロキシやファイアウォールとの相性が良く、インフラ要件もシンプルです。SseEmitter を使った実装は Spring BootでServer-Sent Events (SSE)を実装する方法 で解説しています。双方向通信が必要なチャットや共同編集なら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)

コントローラの外から能動的にメッセージをプッシュしたいときは 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クラスからも呼び出せます。次の節で登場する messagingTemplate も、ここと同じようにコンストラクタで注入した 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;
    }
}

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

ChatMessage notice = new ChatMessage();
notice.setSender("system");
notice.setContent("新しい通知があります");

messagingTemplate.convertAndSendToUser(
    "alice",                 // 宛先ユーザー名
    "/queue/notifications",  // ユーザー別キュー
    notice
);

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

WebSocketセッションごとに状態を持たせる websocket スコープのBean

「このユーザーが接続している間だけ有効な状態を持ちたい」というときは、Spring の websocket スコープを使います。@EnableWebSocketMessageBroker を付けると、singleton・request・session に加えて websocket という Bean スコープが登録され、WebSocket セッション(STOMP の CONNECT から DISCONNECT まで)単位で Bean のインスタンスが作られます。セッションが切れると Bean も破棄されるので、接続ごとのカウンタや直近の送信履歴を置く場所としてちょうどいいですよね。

@Component
@Scope(scopeName = "websocket", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionCounter {
    private int count;

    public int increment() {
        return ++count;
    }
}

@Controller
public class CounterController {

    private final SessionCounter counter; // プロキシ経由で接続ごとの実体に解決される

    public CounterController(SessionCounter counter) {
        this.counter = counter;
    }

    @MessageMapping("/count")
    @SendToUser("/queue/count")
    public int count() {
        return counter.increment();
    }
}

ポイントは proxyMode = ScopedProxyMode.TARGET_CLASS です。コントローラは singleton なので、プロキシを挟まないと最初の接続の実体が全セッションで共有されてしまいます。また、このスコープの Bean を参照できるのは @MessageMapping などクライアントからのメッセージ処理中だけで、@Scheduled のようにセッション文脈のないスレッドから触ると例外になります。singleton や request といった他のスコープとの違いは Spring BootのBeanスコープを理解する で整理しています。

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

HTTPハンドシェイク時点の認証は後述の「Spring SecurityとWebSocketの統合」で設定しますが、「このトピックは管理者しか購読できない」といった メッセージ単位の認可 には 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;
            }
        });
    }

    private boolean hasAdminRole(Principal user) {
        return user instanceof Authentication auth
            && auth.getAuthorities().stream()
                .anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
    }
}

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秒間隔の自動再接続が有効です。本番ではこの値を環境に合わせて調整しておくと、デプロイ時のユーザー影響を最小化できます。

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設定ガイド を参照。

STOMPを使わずに素のWebSocketでTextMessage・BinaryMessageを扱う方法

publish/subscribe が不要で「接続してきた相手と 1 対 1 で生データをやり取りしたいだけ」なら、STOMP を挟まずに Spring の低レベル WebSocket API を使う選択肢もあります。@EnableWebSocket と WebSocketConfigurer でハンドラを登録し、テキストは TextWebSocketHandler、画像や音声のようなバイナリは BinaryWebSocketHandler を継承して受け取ります。

@Configuration
@EnableWebSocket
public class RawWebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new EchoHandler(), "/raw");
    }
}

public class EchoHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
        // 受け取ったテキストをそのまま送り返す
        session.sendMessage(new TextMessage("echo: " + message.getPayload()));
    }
}

バイナリを受けるときは BinaryWebSocketHandler を継承して handleBinaryMessage をオーバーライドし、BinaryMessage#getPayload で ByteBuffer を取り出します。送信側も session.sendMessage(new BinaryMessage(bytes)) と書くだけです。1 フレームの上限はデフォルトで 8KB なので、大きめのファイルを送るなら次の節で紹介する ServletServerContainerFactoryBean の setMaxBinaryMessageBufferSize で広げておきましょう。

ただし生 WebSocket では「誰がどのトピックを購読しているか」をすべて自前で管理することになります。チャットや通知のように配信先が複数ある機能なら、ここまで見てきた STOMP の方が結果的にシンプルです。

本番運用で押さえておきたいポイント

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

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

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

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

まとめ

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

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