๋ณธ ๊ธ€์—์„œ ์–ธ๊ธ‰ํ•˜๋Š” ๊ด€๋ จ ์ฝ”๋“œ๋Š” github.com/kdevkr/spring-boot-demo/websocket-demo์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ผ๋ฐ˜์ ์ธ ์Šคํ”„๋ง ๋ถ€ํŠธ ์Šคํƒ€ํ„ฐ์™€๋Š” ๋‹ค๋ฅด๊ฒŒ ์Šคํƒ€ํ„ฐ ์›น์†Œ์ผ“ ๋ชจ๋“ˆ์—๋Š” ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ์— ๋Œ€ํ•œ ์ž๋™ ๊ตฌ์„ฑ์„ ์ˆ˜ํ–‰ํ•˜์ง€๋Š” ์•Š๋Š”๋‹ค. ์›น์†Œ์ผ“ ๊ด€๋ จํ•œ ์ž๋™ ๊ตฌ์„ฑ(WebSocketServletAutoConfiguration)์€ ํ”„๋กœ์ ํŠธ์—์„œ ์‚ฌ์šฉ์ค‘์ธ ์„œ๋ธ”๋ฆฟ ์ปจํ…Œ์ด๋„ˆ์— ๋”ฐ๋ผ ์›น์†Œ์ผ“์— ๋Œ€ํ•ด ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก ํ™•์žฅํ•˜๋ฉฐ WebSocketMessagingAutoConfiguration ์—์„œ๋Š” Stomp ๋ฐฉ์‹์˜ ์›น์†Œ์ผ“์„ ์œ„ํ•œ ๋ฉ”์‹œ์ง€ ๋ธŒ๋กœ์ปค๋ฅผ ๊ตฌ์„ฑํ•  ๋•Œ ์‚ฌ์šฉ๋˜๋Š” ๋ฉ”์‹œ์ง€ ์ปจ๋ฒ„ํ„ฐ๋ฅผ ์„ค์ •ํ•œ๋‹ค.

@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new TextWebSocketHandler(){}, "/ws")
                .setAllowedOriginPatterns("*")
                .addInterceptors(new HttpSessionHandshakeInterceptor())
                .withSockJS();
    }
}

WebSocket API์˜ WebSocketConfigurer ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ตฌํ˜„ํ•˜์—ฌ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ์— ๋Œ€ํ•ด์„œ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜, ์ผ๋ฐ˜์ ์ธ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ ๋ฐฉ์‹์—๋Š” ์—ฌ๋Ÿฌ๊ฐ€์ง€ ๋‹จ์ ์ด ์žˆ๋Š”๋ฐ ์„ธ์…˜๊ณผ ์‹œํ๋ฆฌํ‹ฐ์™€ ๊ฐ™์€ ๋ถ€๊ฐ€์ ์ธ ๊ธฐ๋Šฅ๊ณผ์˜ ์—ฐ๊ณ„๋ฅผ ์ง์ ‘์ ์œผ๋กœ ๊ตฌํ˜„ํ•ด์•ผํ•œ๋‹ค๋Š” ๊ฒƒ์ด๋‹ค.

SockJS Fallback

SockJS๋Š” ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ์— ๋Œ€ํ•œ ๋ฌธ์ œ๋ฅผ ๋ณด์™„ํ•˜๊ธฐ ์œ„ํ•ด์„œ ๋„์ž…ํ•˜๋Š” ๊ธฐ์ˆ ์ด๋ฉฐ SockJS ํด๋ผ์ด์–ธํŠธ๋Š” ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ ์ฃผ์†Œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์„œ๋ฒ„์—๊ฒŒ /info ์—”๋“œํฌ์ธํŠธ๋ฅผ ์š”์ฒญํ•˜์—ฌ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ ๋ฐฉ์‹์— ๋Œ€ํ•ด ์งˆ์˜๋ฅผ ํ•˜๊ณ  ์‘๋‹ต๋ฐ›์€ ๊ฒฐ๊ณผ๋ฅผ ํ† ๋Œ€๋กœ ์—ฐ๊ฒฐ์„ ์‹œ๋„ํ•œ๋‹ค. ์Šคํ”„๋ง ์›น์†Œ์ผ“ ๋ชจ๋“ˆ์˜ DefaultSockJsService ์—์„œ๋Š” ์•„๋ž˜์™€ ๊ฐ™์€ ์—ฐ๊ฒฐ์„ ์ง€์›ํ•œ๋‹ค.

DefaultSockJsService.java
private static Set<TransportHandler> getDefaultTransportHandlers(@Nullable Collection<TransportHandler> overrides) { Set<TransportHandler> result = new LinkedHashSet<>(8); result.add(new XhrPollingTransportHandler()); result.add(new XhrReceivingTransportHandler()); result.add(new XhrStreamingTransportHandler()); result.add(new EventSourceTransportHandler()); result.add(new HtmlFileTransportHandler()); try { result.add(new WebSocketTransportHandler(new DefaultHandshakeHandler())); } // ... if (overrides != null) { result.addAll(overrides); } return result; }
# http://localhost:8080/ws/info?t=1696757550304
{
  "entropy": 1279751018,
  "origins": [
    "*:*"
  ],
  "cookie_needed": true,
  "websocket": true
}

# {websocket-protocol}://{host}:{port}/{websocket-endpoint}/{server-id}/{session-id}/{transport}
ws://localhost:8080/ws/712/yyfmvviz/websocket

๊ธฐ๋ณธ์ ์œผ๋กœ SockJsServiceRegistration์˜ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ ์„ค์ •์ด ํ™œ์„ฑํ™”๋˜์–ด์žˆ๊ณ  ์ผ๋ถ€ ๋กœ๋“œ๋ฐธ๋Ÿฐ์„œ์—์„œ ์›น์†Œ์ผ“์„ ์ง€์›ํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด ๋น„ํ™œ์„ฑํ™”ํ•  ์ˆ˜ ์žˆ๋„๋ก ์ง€์›ํ•œ๋‹ค.

Stomp over WebSocket

Webjars

๋ฆฌ์•กํŠธ๋‚˜ ๋ทฐ์™€ ๊ฐ™์€ ํ”„๋ก ํŠธ์—”๋“œ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์„ ๊ตฌ์„ฑํ•œ๋‹ค๋ฉด ์ž์ฒด์ ์œผ๋กœ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํŒจํ‚ค์ง€๋ฅผ ์„ค์น˜ํ•˜๊ณ  ๊ด€๋ฆฌํ•˜๊ฒ ์ง€๋งŒ ๋ฐฑ์—”๋“œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์ œ๊ณตํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด Webjars๋ฅผ ์ด์šฉํ•  ์ˆ˜๋„ ์žˆ๋‹ค.

build.gradle
dependencies { implementation 'org.webjars:webjars-locator-core:0.53' implementation 'org.webjars:sockjs-client:1.5.1' implementation 'org.webjars:stomp-websocket:2.3.4' }

์Šคํ”„๋ง ์„ธ์…˜๊ณผ์˜ ํ†ตํ•ฉ

Stomp ๋ฐฉ์‹์˜ ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ์„ ๊ตฌ์„ฑํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” WebSocketMessageBrokerConfigurer๋ฅผ ์ง์ ‘ ๊ตฌํ˜„ํ•˜๊ธฐ๋ณด๋‹ค ์Šคํ”„๋ง ์„ธ์…˜ ๋ชจ๋“ˆ์— ํฌํ•จ๋˜์–ด์žˆ๋Š” AbstractSessionWebSocketMessageBrokerConfigurer๋ฅผ ํ™•์žฅํ•˜๋Š” ๊ฒƒ์ด ๋” ํŽธ๋ฆฌํ•˜๋‹ค. ์Šคํ”„๋ง ์„ธ์…˜๊ณผ ์—ฐ๊ณ„๋˜๋Š” ๋ฏธ๋ฆฌ ๊ตฌํ˜„๋œ ํด๋ž˜์Šค๋“ค์„ ๋นˆ์œผ๋กœ ๋“ฑ๋กํ•˜์—ฌ ์›น์†Œ์ผ“ ์„ธ์…˜(WebSocketSession)์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์ฃผ์ž…ํ•˜๊ณ  ์‰ฝ๊ฒŒ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ๋„๋ก ์ง€์›ํ•œ๋‹ค.

build.gradle
dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.session:spring-session-core' }
@EnableWebSocketMessageBroker
@Configuration
public class StompConfig extends AbstractSessionWebSocketMessageBrokerConfigurer<MapSession> {
    @Override
    protected void configureStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws/stomp")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // NOTE: /topic: Broadcast, /queue: Unicast
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/app");
        registry.setPreservePublishOrder(true);
    }
}
StompController.java
@AllArgsConstructor @Slf4j @RestController public class StompController { private final SimpMessagingTemplate template; @SendTo("/topic/hello") @MessageMapping("/hello") public Map<String, String> hello(GenericMessage<String> message, @Header(name = "simpSessionId") String wsSessionId, @Header(name = "simpSessionAttributes") Map<String, Object> sessionAttributes, Principal principal) { String username = SessionRepositoryMessageInterceptor.getSessionId(sessionAttributes); if (principal instanceof Authentication) { username = principal.getName(); } Map<String, String> payload = new HashMap<>(); payload.put("message", "Hello, %s".formatted(username)); payload.put("from", "StompController"); // NOTE: similar @SendToUser template.convertAndSendToUser(wsSessionId, "/queue/hello", payload, message.getHeaders()); return payload; } }

์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์™€์˜ ํ†ตํ•ฉ

Stomp ๋ฐฉ์‹์˜ ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ์˜ ๊ฒฝ์šฐ ์Šคํ”„๋ง ์„ธ์…˜๊ณผ์˜ ํ†ตํ•ฉ์ฒ˜๋Ÿผ ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์™€์˜ ํ†ตํ•ฉ๋„ ์ง€์›ํ•œ๋‹ค. ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ๊ฐ€ ๊ธฐ๋ณธ HTTP ๋ณด์•ˆ์„ ์„ค์ •ํ•œ๋‹ค๋ฉด @EnableWebSocketSecurity๊ฐ€ ์„ ์–ธ๋œ ๊ตฌ์„ฑ ํด๋ž˜์Šค๋ฅผ ํ†ตํ•ด์„œ Stomp ๋ฉ”์‹œ์ง€์— ๋Œ€ํ•ด ๋ณด์•ˆ ๊ทœ์น™์„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. ์ผ๋ฐ˜์ ์ธ ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ์„ ๊ตฌ์„ฑํ•˜๋Š” ๊ฒฝ์šฐ์— ๋ณด์•ˆ์ ์ธ ๋กœ์ง์„ ์ง์ ‘ ๊ตฌํ˜„ํ•ด์•ผํ•˜์ง€๋งŒ ๋” ๊ฐ„๋‹จํ•˜๊ฒŒ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

build.gradle
dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.security:spring-security-messaging' testImplementation 'org.springframework.security:spring-security-test' }
WebSocketSecurity.java
@Configuration @EnableWebSocketSecurity public class WebSocketSecurity { @Bean public AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) { // NOTE: Failed to send message to ExecutorSubscribableChannel[clientInboundChannel]: Access Denied messages .nullDestMatcher().authenticated() .simpDestMatchers("/app/**").hasRole("USER") .simpSubscribeDestMatchers("/user/queue/error").authenticated() .simpSubscribeDestMatchers("/user/**", "/topic/hello").hasRole("USER") .anyMessage().denyAll(); return messages.build(); } }

WebSocketSession Management

์Šค์ผ€์ค„๋ง ๊ธฐ๋Šฅ์„ ํ†ตํ•ด ํŠน์ • ์ƒํ™ฉ์—์„œ ์›น์†Œ์ผ“์— ์—ฐ๊ฒฐ๋œ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฉ”์‹œ์ง€๋ฅผ ์ „๋‹ฌํ•  ํ•„์š”์„ฑ์ด ์žˆ๋‹ค. ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ์— ๋Œ€ํ•œ ์„ธ์…˜ ๊ด€๋ฆฌ๋ฅผ ํ•ด์ฃผ์ง€๋งŒ ์„ธ์…˜์ด ์—ฐ๊ฒฐ์ค‘์ธ WebSocketSession ๋ชฉ๋ก์€ ๊ด€๋ฆฌํ•ด์ฃผ์ง€ ์•Š๋Š”๋‹ค. ์•ž์„œ, WebSocketRegistryListener๋ฅผ ํ†ตํ•ด ์—ฐ๊ฒฐ๊ณผ ํ•ด์ง€ ๊ทธ๋ฆฌ๊ณ  ๋ฉ”์‹œ์ง€ ์ˆ˜์‹  ๊ตฌ๋…์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•  ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์›น ์†Œ์ผ“ ์„ธ์…˜์„ ์ €์žฅํ•˜๋Š” ํด๋ž˜์Šค๋ฅผ ๊ตฌํ˜„ํ•ด๋ณด๋„๋ก ํ•˜์ž.

์ผ๋ถ€ ์˜ˆ์ œ์—์„œ๋Š” WebSocketHandlerDecorator ํด๋ž˜์Šค๋ฅผ ํ™•์žฅํ•˜์—ฌ ์›น ์†Œ์ผ“ ์„ธ์…˜ ๊ด€๋ฆฌ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์„ ์ฐพ์•„๋ณผ ์ˆ˜ ์žˆ์œผ๋‚˜ ์ด๋ฏธ ๊ตฌํ˜„๋˜์–ด์žˆ๊ณ  ๊ตณ์ด ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ต์ฒดํ•  ํ•„์š”๊ฐ€ ์—†์ด ์œ„์™€ ๊ฐ™์ด ์ด๋ฒคํŠธ๋งŒ์„ ๋ฐ›์•„์„œ ์ฒ˜๋ฆฌํ•˜๋Š”๊ฒŒ ๋” ๊ฐ„๋‹จํ•˜๋‹ค. ๋˜ํ•œ, ๊ตณ์ด ํ•ธ๋“ค๋Ÿฌ ์œ„์น˜๊ฐ€ ์•„๋‹ˆ๋”๋ผ๋„ SimpMessagingTemplate๋ฅผ ํ†ตํ•ด ๋ฉ”์‹œ์ง€๋ฅผ ์†ก์‹ ํ•  ์ˆ˜ ์žˆ๋‹ค. SessionSubscribeEvent ์™€ SessionUnsubscribeEvent๋Š” ์„ธ์…˜ ์ž์ฒด๋ฅผ ์ „๋‹ฌํ•ด์ฃผ์ง€๋Š” ์•Š์ง€๋งŒ ์›น์†Œ์ผ“ ์„ธ์…˜ ์•„์ด๋””๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ํŠน์ • ํŒจํ„ด์˜ ๊ตฌ๋… ์ฃผ์†Œ๋ฅผ ๊ฐ์ง€ํ•˜์—ฌ ์„ธ์…˜ ์•„์ด๋”” ๋ชฉ๋ก์„ ๊ด€๋ฆฌํ•˜๊ณ  ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์–ด๋– ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋‹ค.

์Šคํ”„๋ง ์„ธ์…˜ ๋ชจ๋“ˆ๊ณผ ์—ฐ๊ณ„๋œ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ์„ ๊ตฌ์„ฑํ•˜๋Š” ๊ฒฝ์šฐ์— SimpMessaingTemplate๋ฅผ ํ†ตํ•ด ์›น์†Œ์ผ“ ์„ธ์…˜ ์•„์ด๋””๊ฐ€ ์•„๋‹Œ ์‚ฌ์šฉ์ž ์ด๋ฆ„์œผ๋กœ๋„ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๋‹ค.

์›น ์†Œ์ผ“ ๊ด€๋ จ ๋ฌธ์„œ