Spring Boot で WebSocket のチャットを作るなら、spring-boot-starter-websocket と STOMP(WebSocket の上でやりとりの形を決めるテキストの規約)を使うのが近道です。 サーバーは @EnableWebSocketMessageBroker の設定クラスと @MessageMapping のコントローラ、ブラウザは @stomp/stompjs の Client で書けます。

役割 サーバー(Spring) ブラウザ(@stomp/stompjs)
接続口 registry.addEndpoint("/ws") brokerURL: 'ws://…/ws'
送る — client.publish({ destination: '/app/chat', … })
受け取って処理 @MessageMapping("/chat") —
全員に配る @SendTo("/topic/messages") か SimpMessagingTemplate client.subscribe('/topic/messages', …)
1人に送る convertAndSendToUser(名前, "/queue/notice", …) client.subscribe('/user/queue/notice', …)

確認環境:Spring Boot 4.1.1・Spring Framework 7.0.9・Spring Security 7.1.1。設定の既定値(メッセージの大きさの上限・ハートビート・外部ブローカーの接続先)、ハンドシェイクで 403 になる条件、CSRF のエラー文は各 GitHub タグのソースで確かめました。ブラウザ側は @stomp/stompjs 7.4.0 を Node.js 22.22.2(ws 8.22.0)と Chromium で動かし、STOMP の通信をそのまま記録する小さなサーバーに接続して、送られるフレーム・403 のときの見え方・ERROR フレームの受け取り方を確かめました(確認日 2026年10月13日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring BootのWebSocket|STOMPでチャットを作る完成コード」

しくみ:宛先の3種類

STOMP では、メッセージに「宛先(destination)」を付けて送ります。Spring は宛先の頭の文字で、行き先を分けます。

宛先の頭 行き先 例
/app(setApplicationDestinationPrefixes) 自分のコントローラの @MessageMapping /app/chat → @MessageMapping("/chat")
/topic・/queue(enableSimpleBroker) ブローカー(購読している全員に配る係) /topic/messages
/user(setUserDestinationPrefix) ログインしているその人だけへの宛先に変換 /user/queue/notice

流れは「ブラウザが /app/chat に送る → コントローラが受け取って加工 → /topic/messages に送る → ブローカーが購読者全員に配る」です。

完成コード(サーバー)

依存

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-websocket")
    implementation("org.springframework.boot:spring-boot-starter-security")   // 認証する場合
    implementation("org.springframework.security:spring-security-messaging")  // 認証する場合
}

設定クラス

package com.example.chat;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                // 別のオリジン(開発中の Vite など)から接続するとき。既定は同じオリジンだけ
                .setAllowedOriginPatterns("http://localhost:5173", "https://*.example.com");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        var scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(1);
        scheduler.setThreadNamePrefix("ws-heartbeat-");
        scheduler.initialize();

        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic", "/queue")
                .setTaskScheduler(scheduler);          // これでハートビートが 10000,10000 になる
        registry.setUserDestinationPrefix("/user");   // 既定も "/user"
    }

    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
        registration.setMessageSizeLimit(64 * 1024)        // 受け取る1メッセージの上限(既定 64KB)
                    .setSendTimeLimit(10 * 1000)           // 1人への送信にかけてよい時間(既定 10秒)
                    .setSendBufferSizeLimit(512 * 1024);   // 1人への送信待ちのバッファ(既定 512KB)
    }
}

Spring Framework 7.0.9 のソースで確かめた既定値です。

項目 既定値 決めている場所
受け取る STOMP メッセージの上限 64KB(64 * 1024) StompSubProtocolHandler
1つの接続への送信にかけてよい時間 10秒 SubProtocolWebSocketHandler
1つの接続への送信待ちのバッファ 512KB 同上
接続してから最初のメッセージを待つ時間 60秒 同上
シンプルブローカーのハートビート TaskScheduler を設定したときだけ 10000,10000(無ければ送らない) SimpleBrokerMessageHandler
ユーザー宛ての頭 /user MessageBrokerRegistry
受け付ける STOMP の版 1.2・1.1・1.0 StompSubProtocolHandler

送信の時間・バッファの上限を超えると、遅いクライアントの接続は閉じられます。1人の遅いブラウザのせいで、ほかの人への配信が止まるのを防ぐしくみです。

メッセージの型とコントローラ

package com.example.chat;

import java.time.Instant;

public record ChatIn(String text) {}

public record ChatOut(String from, String text, Instant at) {}
package com.example.chat;

import java.security.Principal;
import java.time.Instant;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.stereotype.Controller;

@Controller
public class ChatController {

    // ブラウザが /app/chat に送る → 戻り値が /topic/messages の購読者全員に届く
    @MessageMapping("/chat")
    @SendTo("/topic/messages")
    public ChatOut chat(ChatIn in, Principal principal) {
        if (in.text() == null || in.text().isBlank() || in.text().length() > 500) {
            throw new IllegalArgumentException("メッセージは1〜500文字です");
        }
        // 送り主の名前は、ブラウザが送ってきた値ではなく、認証した Principal から取る
        String from = (principal != null) ? principal.getName() : "guest";
        return new ChatOut(from, in.text(), Instant.now());
    }

    // 例外は、送った本人の /user/queue/errors にだけ返す
    @MessageExceptionHandler
    @SendToUser("/queue/errors")
    public String handle(IllegalArgumentException ex) {
        return ex.getMessage();
    }
}

@MessageMapping のメソッドは、@Payload(本文。注釈は省略できる)、Principal、@Header、@DestinationVariable(宛先の {roomId} の部分)などを引数に取れます。

@MessageMapping("/rooms/{roomId}/chat")
@SendTo("/topic/rooms/{roomId}")
public ChatOut roomChat(@DestinationVariable String roomId, ChatIn in, Principal principal) { … }

コントローラの外から送る:SimpMessagingTemplate

REST API の処理や、バッチ・イベントの中から送るときは SimpMessagingTemplate を使います。

package com.example.chat;

import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

@Service
public class NoticeService {

    private final SimpMessagingTemplate template;

    public NoticeService(SimpMessagingTemplate template) {
        this.template = template;
    }

    // 全員へ
    public void broadcast(String text) {
        template.convertAndSend("/topic/messages", new ChatOut("system", text, java.time.Instant.now()));
    }

    // 1人へ:ブラウザは /user/queue/notice を購読する
    public void notifyUser(String username, String text) {
        template.convertAndSendToUser(username, "/queue/notice", text);
    }
}

convertAndSendToUser の第1引数は、ログインしている人の名前(Principal.getName())です。 認証していない接続には Principal が無いので、ユーザー宛てのメッセージは届きません。

接続・切断を知りたいときは、SessionConnectedEvent・SessionDisconnectEvent を @EventListener で受け取ります。

@Component
public class PresenceListener {
    @EventListener
    public void onDisconnect(SessionDisconnectEvent e) {
        var user = e.getUser();
        System.out.println("disconnect: " + (user != null ? user.getName() : e.getSessionId()));
    }
}

完成コード(ブラウザ側)

@stomp/stompjs(7.4.0)を使います。

npm install @stomp/stompjs
<!-- src/main/resources/static/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>チャット</title></head>
<body>
  <ul id="log"></ul>
  <form id="f"><input id="text" autocomplete="off"><button>送信</button></form>
  <p id="status">接続中…</p>
  <script type="module" src="/app.js"></script>
</body>
</html>
// src/main/resources/static/app.js(バンドラーを使わない場合は CDN の ESM 版を import する)
import { Client } from 'https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.4.0/+esm';

const scheme = location.protocol === 'https:' ? 'wss' : 'ws';

const client = new Client({
  brokerURL: `${scheme}://${location.host}/ws`,
  reconnectDelay: 5000,          // 切れたら5秒後につなぎ直す(既定 5000)
  heartbeatIncoming: 10000,      // 既定 10000
  heartbeatOutgoing: 10000,      // 既定 10000
  // connectHeaders: { 'X-CSRF-TOKEN': token },  // Spring Security の CSRF を使うとき(下で説明)
  onConnect: () => {
    document.getElementById('status').textContent = '接続しました';
    client.subscribe('/topic/messages', (msg) => {
      const m = JSON.parse(msg.body);
      const li = document.createElement('li');
      li.textContent = `${m.from}: ${m.text}`;        // innerHTML は使わない(XSS 対策)
      document.getElementById('log').append(li);
    });
    client.subscribe('/user/queue/errors', (msg) => alert(msg.body));
  },
  onStompError: (frame) => {
    console.error('STOMP ERROR:', frame.headers['message'], frame.body);
  },
  onWebSocketClose: (evt) => {
    document.getElementById('status').textContent = `切断(code=${evt.code})。再接続します…`;
  },
});

client.activate();

document.getElementById('f').addEventListener('submit', (e) => {
  e.preventDefault();
  const input = document.getElementById('text');
  if (!client.connected) return;
  client.publish({ destination: '/app/chat', body: JSON.stringify({ text: input.value }) });
  input.value = '';
});

@stomp/stompjs 7.4.0 のソースで確かめた既定値は、reconnectDelay が 5000ms、heartbeatIncoming・heartbeatOutgoing が 10000ms、connectionTimeout が 0(時間切れなし)、splitLargeFrames が false です。

実際に流れるフレーム

上のクライアントを Node.js で動かし、STOMP の通信をそのまま記録するサーバーにつないだときの送受信です(\n は改行、本文は省略)。

>>> CONNECT\naccept-version:1.2,1.1,1.0\nheart-beat:10000,10000\n\n
<<< CONNECTED\nuser-name:taro\nheart-beat:10000,10000\nversion:1.2\ncontent-length:0\n\n
>>> SUBSCRIBE\nid:sub-0\ndestination:/topic/messages\n\n
>>> SEND\ndestination:/app/chat\ncontent-length:26\n\n
<<< MESSAGE\nmessage-id:m-1\nsubscription:sub-0\ncontent-type:application/json\ndestination:/topic/messages\ncontent-length:40\n\n
>>> DISCONNECT\nreceipt:close-1\n\n
<<< RECEIPT\nreceipt-id:close-1\ncontent-length:0\n\n

サーバーが受け取った生の SEND フレームは次のとおりで、最後に \0(NULL 文字)が付いていました。

SEND\ndestination:/app/chat\ncontent-length:26\n\n{"text":"こんにちは"}\u0000

content-length は文字数ではなくバイト数です。 {"text":"こんにちは"} は11文字+ひらがな5文字ですが、UTF-8 ではひらがなが1文字3バイトなので 26 になりました。メッセージの上限(既定 64KB)もバイト数で数えます。ハンドシェイクでは、ws のサブプロトコルとして v12.stomp が選ばれました。

認証:Spring Security と組み合わせる

1. ハンドシェイク(HTTP)を守る

WebSocket の接続は、最初に HTTP のリクエスト(ハンドシェイク)で始まります。ふつうの画面と同じ SecurityFilterChain で /ws を守ると、ログイン済みのセッション Cookie を持つ接続だけが通ります。その人の Principal が、STOMP のメッセージにも引き継がれます。

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/login", "/css/**").permitAll()
            .requestMatchers("/ws/**").authenticated()
            .anyRequest().authenticated())
        .formLogin(Customizer.withDefaults());
    return http.build();
}

ログインの作り方は、関連記事「Spring Security入門|SecurityFilterChainとDB認証」で扱っています。

2. STOMP のメッセージを守る:@EnableWebSocketSecurity

package com.example.chat;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity;
import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager;

@Configuration
@EnableWebSocketSecurity
public class WebSocketSecurityConfig {

    @Bean
    AuthorizationManager<Message<?>> messageAuthorizationManager(
            MessageMatcherDelegatingAuthorizationManager.Builder messages) {
        return messages
                .nullDestMatcher().authenticated()                        // CONNECT・DISCONNECT など
                .simpTypeMatchers(SimpMessageType.DISCONNECT, SimpMessageType.HEARTBEAT).permitAll()
                .simpDestMatchers("/app/**").authenticated()              // 送信
                .simpSubscribeDestMatchers("/topic/**", "/user/queue/**").authenticated() // 購読
                .simpDestMatchers("/topic/**", "/queue/**").denyAll()     // ブローカーへの直接送信は禁止
                .anyMessage().denyAll()
                .build();
    }
}

Spring Security 7.1.1 のソースでは、@EnableWebSocketSecurity を付けて AuthorizationManager<Message<?>> の Bean を定義しないと、すべてのメッセージに認証を求める設定(anyMessage().authenticated())になります。上のように、ブラウザから /topic/** へ直接送ることを禁止しておくと、コントローラを通らない書き込み(なりすましの送信)を防げます。

3. CSRF:CONNECT に X-CSRF-TOKEN を付ける

@EnableWebSocketSecurity は、STOMP の CONNECT に CSRF トークンを求めます(Spring Security 7.1.1 の XorCsrfChannelInterceptor)。ハンドシェイクのときに HTTP セッションの CSRF トークンを WebSocket のセッションへ写し、CONNECT のヘッダーの値と比べます。足りないと、ソースでは次の例外になります。

状況 例外とメッセージ
ハンドシェイクのセッションに CSRF トークンが無い MissingCsrfTokenException:Could not verify the provided CSRF token because no token was found to compare.
CONNECT のヘッダーの値が違う・無い InvalidCsrfTokenException:Invalid CSRF Token '…' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.

画面のテンプレートやエンドポイントからトークンを渡し、connectHeaders に入れます。

@RestController
public class CsrfController {
    @GetMapping("/csrf")
    public CsrfToken csrf(CsrfToken token) {     // {"token": "...", "headerName": "X-CSRF-TOKEN", ...}
        return token;
    }
}
const csrf = await fetch('/csrf').then((r) => r.json());
const client = new Client({
  brokerURL: `${scheme}://${location.host}/ws`,
  connectHeaders: { [csrf.headerName]: csrf.token },
  // …
});

CSRF のしくみと 403 の切り分けは、関連記事「Spring Securityで403になる原因|POSTだけ失敗する時」で扱っています。

トークン(JWT)で認証する場合

SPA から JWT で認証する場合、ブラウザの WebSocket はハンドシェイクに Authorization ヘッダーを付けられません。そのため、STOMP の CONNECT のヘッダーにトークンを入れ、サーバー側の ChannelInterceptor で検証して setUser します。

@Configuration
@EnableWebSocketMessageBroker
public class JwtStompConfig implements WebSocketMessageBrokerConfigurer {

    private final JwtDecoder jwtDecoder;
    private final JwtAuthenticationConverter converter = new JwtAuthenticationConverter();

    public JwtStompConfig(JwtDecoder jwtDecoder) { this.jwtDecoder = jwtDecoder; }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor acc = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                if (acc != null && StompCommand.CONNECT.equals(acc.getCommand())) {
                    String auth = acc.getFirstNativeHeader("Authorization");
                    if (auth == null || !auth.startsWith("Bearer ")) {
                        throw new AccessDeniedException("missing token");
                    }
                    Jwt jwt = jwtDecoder.decode(auth.substring(7));
                    acc.setUser(converter.convert(jwt));   // 以降のメッセージの Principal になる
                }
                return message;
            }
        });
    }
    // registerStompEndpoints・configureMessageBroker は上と同じ
}

この形では、セッション Cookie を使わないので CSRF の確認は要りません。@EnableWebSocketSecurity と組み合わせる場合は、CSRF の ChannelInterceptor を csrfChannelInterceptor という名前の Bean で差し替えられます(7.1.1 のソースで、この名前の Bean があれば既定のかわりに使う)。JWT の検証は、関連記事「Spring Security JWT認証の実装|Boot 4対応の設定コード全文」で扱っています。

スケールアウト:外部ブローカー(STOMP ブローカーリレー)

enableSimpleBroker のシンプルブローカーは、そのサーバーのメモリの中で購読者を覚えています。サーバーを2台にすると、A に接続した人のメッセージは、B に接続した人に届きません。複数台にするなら、RabbitMQ・ActiveMQ などの STOMP 対応ブローカーに中継させます。

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.setApplicationDestinationPrefixes("/app");
    registry.enableStompBrokerRelay("/topic", "/queue")
            .setRelayHost("rabbitmq.internal")
            .setRelayPort(61613)
            .setClientLogin("chat-app")
            .setClientPasscode(System.getenv("STOMP_PASSWORD"))
            .setSystemLogin("chat-app")
            .setSystemPasscode(System.getenv("STOMP_PASSWORD"));
}
設定 既定値(v7.0.9 のソース) 意味
relayHost 127.0.0.1 ブローカーのホスト
relayPort 61613 STOMP のポート
clientLogin / clientPasscode guest / guest 利用者ごとの接続に使う ID
systemLogin / systemPasscode guest / guest サーバー自身の共有接続に使う ID
systemHeartbeatSendInterval / ReceiveInterval 10000ms / 10000ms 共有接続のハートビート

既定の guest は、RabbitMQ では localhost 以外から使えない設定が一般的です。 専用のユーザーを作ります。ブローカーリレーは TCP の接続に Reactor Netty を使うので、io.projectreactor.netty:reactor-netty を依存に足します。RabbitMQ 側では STOMP プラグイン(rabbitmq_stomp)を有効にします。宛先の書き方(/topic/…・/queue/…)はブローカーごとに意味が違うので、ブローカーのドキュメントで確かめます。

方法 複数台 向いている場面
シンプルブローカー × 1台で動かす・開発中
STOMP ブローカーリレー(RabbitMQ など) ○ 本番で台数を増やす
シンプルブローカー+Redis の Pub/Sub で配り直す ○(自作) すでに Redis がある

ロードバランサーや nginx を前に置くときは、Upgrade・Connection ヘッダーを転送し、アイドルのタイムアウトをハートビートの間隔(10秒)より長くします。nginx の設定は、関連記事「Node.js WebSocket実装|ws・再接続・認証・nginxまで」で扱っています。

よくあるエラー

ハンドシェイクで 403

ブラウザのコンソールに出る文と、onclose の値です(Chromium で確認)。

WebSocket connection to 'ws://localhost:18081/ws' failed: Error during WebSocket handshake: Unexpected response code: 403
onerror type=error
onclose code=1006 wasClean=false reason=""

ブラウザの JavaScript には「403 だった」ことは渡されず、code=1006(異常な切断)としか分かりません。 理由はサーバーのログで見ます。@stomp/stompjs を Node.js で使った場合は Unexpected server response: 403 と出ました。

原因 サーバー側で起きていること 直し方
オリジンが許可されていない OriginHandshakeInterceptor が 403 を返す。DEBUG ログに Handshake request rejected, Origin header value … not allowed setAllowedOriginPatterns(…) に画面のオリジンを足す
Spring Security で /ws が未認証 ログインしていない、Cookie が送られていない ログインする・permitAll の範囲を見直す
ほかのオリジンの画面から Cookie が届かない SameSite の制限で Cookie が付かない 同じオリジンに置く・トークン認証にする

Spring Framework 7.0.9 のソースでは、オリジンの拒否は DEBUG レベルでしかログに出ません。 403 の理由が分からないときは、logging.level.org.springframework.web.socket=DEBUG にします。setAllowedOriginPatterns("*") にするとどのサイトの画面からでも接続できてしまうので、本番では具体的なオリジンを書きます。CORS 全般は、関連記事「Spring Boot CORS設定|3つの書き方とSecurity併用のコード全文」で扱っています。

ハンドシェイクのそのほかの失敗(v7.0.9 のソース)

HTTP の状態 DEBUG ログ・理由 よくある原因
400 Handshake failed due to invalid Upgrade header: … プロキシが Upgrade ヘッダーを転送していない
400 Handshake failed due to invalid Connection header… 同上(Connection: upgrade)
400 Missing "Sec-WebSocket-Key" header WebSocket ではない普通の HTTP で開いた
405 Handshake failed due to unexpected HTTP method: … GET 以外で呼んだ
426 Handshake failed due to unsupported WebSocket version: … WebSocket の版が合わないクライアント
404 — addEndpoint のパスと URL が違う・コンテキストパスの付け忘れ

接続後のエラー

症状 原因 直し方
onStompError に STOMP 'content-length' header value … exceeds configured buffer size limit … メッセージが上限(既定 64KB)を超えた setMessageSizeLimit を上げる・画像は HTTP で送る
送ったのに誰にも届かない 宛先の頭が違う(/app を付けずに /chat に送った等) 宛先の表を見直す
@SendTo を付けたのに届かない 購読している宛先と @SendTo の宛先が違う ブラウザの subscribe と合わせる
convertAndSendToUser が届かない 接続に Principal が無い(未認証) ハンドシェイクか CONNECT で認証する
CONNECT のあとすぐ切れる CSRF トークンが無い・違う connectHeaders に X-CSRF-TOKEN
数十秒〜数分で切れる プロキシのアイドルタイムアウト・ハートビートが無い シンプルブローカーに TaskScheduler、プロキシのタイムアウトを延ばす
2台目のサーバーの人に届かない シンプルブローカーはサーバーごと ブローカーリレーにする

@stomp/stompjs を記録用のサーバーにつないで、上限を超えたメッセージに ERROR フレームを返させたときの受け取り方です。

<<< ERROR\ncontent-length:0\nmessage:STOMP 'content-length' header value 211  exceeds configured buffer size limit 100\ncontent-length:0\n\n
onStompError message=STOMP 'content-length' header value 211  exceeds configured buffer size limit 100
onWebSocketClose code=1005

エラーの理由は message ヘッダーに入ります(frame.headers['message'])。Spring の StompSubProtocolHandler も、例外のメッセージを ERROR フレームの message ヘッダーに入れて送ります(v7.0.9 のソース)。

よくある質問

Q. SockJS は使いますか。 A. Spring Framework 7.0.9 にも SockJS のサポート(withSockJS())は残っています。WebSocket が通らない環境(WebSocket に対応していないプロキシなど)向けの代わりの通信です。いまの主なブラウザは WebSocket に対応しているので、この記事では素の WebSocket と @stomp/stompjs を使っています。

Q. STOMP を使わず、素の WebSocket で書けますか。 A. WebSocketHandler(TextWebSocketHandler)と @EnableWebSocket で書けます。宛先・購読・ユーザー宛てなどを自分で作ることになるので、チャットのように「誰に配るか」を扱うなら STOMP のほうが短く書けます。

Q. WebSocket と Server-Sent Events(SSE)はどちらを使いますか。 A. サーバーからの通知だけなら SSE(SseEmitter)のほうが単純です。ブラウザからも頻繁に送るチャット・共同編集なら WebSocket です。

まとめ

  • 依存は spring-boot-starter-websocket、設定は @EnableWebSocketMessageBroker
  • 宛先は /app → コントローラ、/topic・/queue → ブローカー、/user → その人だけ
  • ブラウザは @stomp/stompjs の Client。content-length はバイト数で、上限は既定 64KB
  • シンプルブローカーのハートビートは TaskScheduler を設定したときだけ
  • 認証はハンドシェイクのセッション、または CONNECT のヘッダー+ChannelInterceptor。@EnableWebSocketSecurity は CONNECT に CSRF トークンを求める
  • 送り主の名前は Principal から取る。ブラウザの値を信じない
  • 複数台なら enableStompBrokerRelay(RabbitMQ など)
  • 403 はブラウザでは code=1006 としか見えない。サーバーを DEBUG ログにして Origin を確かめる

参考・出典

確認日はいずれも 2026年10月13日です。

  • Spring Framework Reference「STOMP」:https://docs.spring.io/spring-framework/reference/web/websocket/stomp.html
  • Spring Framework v7.0.9 ソース(spring-websocket・spring-messaging):https://github.com/spring-projects/spring-framework/tree/v7.0.9
  • Spring Security Reference「WebSocket Security」:https://docs.spring.io/spring-security/reference/servlet/integrations/websocket.html
  • Spring Security 7.1.1 ソース(spring-security-messaging・EnableWebSocketSecurity):https://github.com/spring-projects/spring-security/tree/7.1.1
  • @stomp/stompjs(npm・7.4.0):https://www.npmjs.com/package/@stomp/stompjs
  • STOMP Protocol Specification, Version 1.2:https://stomp.github.io/stomp-specification-1.2.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring BootのWebSocket|STOMPでチャットを作る完成コード」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?