์คํ๋ง ๋ถํธ ์น์์ผ
๋ณธ ๊ธ์์ ์ธ๊ธํ๋ ๊ด๋ จ ์ฝ๋๋ 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.javaprivate 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.gradledependencies { 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.gradledependencies { 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.gradledependencies { 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๋ฅผ ํตํด ์น์์ผ ์ธ์ ์์ด๋๊ฐ ์๋ ์ฌ์ฉ์ ์ด๋ฆ์ผ๋ก๋ ์ ๋ฌํ ์ ์๋ค.