0. ๋ชฉ์ฐจ
1. ๊ฐ์ - ์ด์ ๊ณผ ๋ฌ๋ผ์ง ์ |
2. ์ ๊ฐ๋ |
3. ์ฝ๋ ๋ถ์ |
4. ํ๋ก์ ํธ ์งํํ๋ฉด์ ๊ฒช์๋ ์ค๋ฅ๋ค |
https://www.daddyprogrammer.org/post/5290/spring-websocket-chatting-server-enter-qut-event-view-user-count/ ๋ฅผ ๋ณด๊ณ ์งํ ํ์ต๋๋ค.
1. ๊ฐ์
โ ์ ์ฅ ํด์ฅ ์ ์ธ์ ์ ์ฒดํฌํด์ ์ธ์ ์๋ฅผ ์ฑํ ๋ฐฉ์ ํ์
โ ์ ๋ฒ ํฌ์คํ ๊น์ง๋ ์ฑํ ๋ฐฉ ์ ์ฅ๊ณผ ๊ตฌ๋ ์ ํด๋ผ์ด์ธํธ์์ ์ธ์งํ๊ณ ๋ฐฑ์๋๋ก ๋ณด๋ด์ฃผ๊ณ , ๊ทธ์ ๋ํ ์ฒ๋ฆฌ๋ฅผ ํ์. ์ด๋ฒ์๋ Sever Side์์ ํด๋น ์ด๋ฒคํธ๋ฅผ ์ง์ ์ธ์ง ํ๊ณ , ์ผ์ ์ฒ๋ฆฌํ๋๋ก ๋ฐ๊ฟจ์.
2. ์ ๊ฐ๋
์๋ ๋ฐฐ์ธ ๋ Controller -> service -> Repository๋ก ์ผ์ด ์ ํด์ ธ ์์์ง๋ง, ์ฌ๊ธฐ์๋ Repository์ ๋ฐ๋ก ์ ๊ทผํด์ ์ผ ์ฒ๋ฆฌ.
๊ทธ๋ฆฌ๊ณ StompHandler๊ฐ ํ๋ ์ผ์ด ์์ฒญ ๋์๋ค. ์ฐธ๊ณ ๋ฐ๋!!
3. ์ฝ๋ ๋ถ์
โด Config Directory
โ STOMP Handler
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.stereotype.Component;
import org.websocket.demo.model.ChatMessage;
import org.websocket.demo.repo.ChatRoomRepository;
import org.websocket.demo.service.ChatService;
import org.websocket.demo.service.JwtTokenProvider;
import java.security.Principal;
import java.util.Optional;
// WebSocket ์ฐ๊ฒฐ ์ ์์ฒญ header์ jwtToekn ์ ํจ์ฑ์ ๊ฒ์ฆํ๋ ์ฝ๋
// ์ ํจํ์ง ์์ Jwt ํ ํฐ์ด ์ธํ
๋ ๊ฒฝ์ฐ webSocket ์ฐ๊ฒฐ์ ํ์ง ์๊ณ ์์ธ ์ฒ๋ฆฌํ๋ค.
@Slf4j
@RequiredArgsConstructor // JwtTokenProvider์ ๋ํ ์์ฑ์ ์ฃผ์
์ด ์ผ์ด๋๋ค.
@Component
public class StompHandler implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
private final ChatRoomRepository chatRoomRepository;
private final ChatService chatService;
// webSocket์ ํตํด ๋ค์ด์จ ๋ฉ์ธ์ง์ ์ ์ก ์์ฒญ์ด ์ฒ๋ฆฌ ๋๊ธฐ ์ ์ ์คํ๋๋ค.
public Message<?> preSend(Message<?> message, MessageChannel channel) {
// STOMP๋ HTTP์๋ ๋ค๋ฅธ ์์ ๋ง์ ๊ท๊ฒฉ์ธ STOMP FRAME ์ ๊ฐ์ง๊ณ ์๋ค.
// StompHeaderAccessor ๋ ํด๋น STOMP FRAME ์์ ๋ฉ์ธ์ง๋ฅผ ์ถ์ถํ๊ฑฐ๋, ๋ฉ์ธ์ง๋ฅผ STOMP FRAME ์ผ๋ก ๋ง๋๋๋ฐ ์ฌ์ฉ
// StompHeaderAccessor ๊ฐ์ฒด๋ ์กด์ฌํ๋ ๋ฉ์ธ์ง๋ฅผ wrap ์ด๋ ๋งค์๋๋ก ๊ฐ์ธ์ ๋ฐ๋ก ์ฌ์ฉํ ์ ์๋ค.
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
// 1) webSocket ์ฐ๊ฒฐ ์์ฒญ์ ํค๋์ jwt Token ๊ฒ์ฆ
if(StompCommand.CONNECT == accessor.getCommand()){
String jwtToken = accessor.getFirstNativeHeader("token"); // ์์ฒญ์ Header ์ token ๊ฐ์ ๊ฐ์ ธ์ด
log.info("CONNECT {}", jwtToken);
jwtTokenProvider.validateToken(jwtToken); //์ ํจ์ฑ ๊ฒ์ฆ
}
//2) ํน์ ์ฑํ
๋ฐฉ์ ๋ค์ด๊ฐ๊ฒ ๋ค๋ ์์ฒญ์
// + sessionId - roomId ๋งตํ, ์ธ์์ ๋๋ฆฌ๊ธฐ, ์
์ฅ ๋ฉ์ธ์ง ๋ฐ์ก
// + ํด์ฅ ์์ฒญ ์ sessionId - roomId ๋งตํ ์ญ์ ๋ฐ ์ธ์์ ์ญ์
else if (StompCommand.SUBSCRIBE == accessor.getCommand()) {
System.out.println("๋ฉ์ธ์ง Header์ ๋ ๊ฒ๋ค: "+message.getHeaders().entrySet());
// 2-1) ํค๋์์ ๊ตฌ๋
Destination ์ ๋ณด๋ฅผ ์ป๊ณ , ๊ฑฐ๊ธฐ์ roomId๋ฅผ ์ถ์ถํ๋ค.
// Optional: ๊ฐ์ด Null์ด์ฌ๋ NPE ์๋ฌ๋ฅผ ๋ฑ์ง ์๊ณ , ์ฝ๋ ์คํ์ ๊ณ์ ์ด์ด๊ฐ ์ ์๋๋ก ํ๋ ํด๋์ค
// ofNullable: ๊ฐ์ด Null์ธ ๊ฒฝ์ฐ์๋ Optional ๊ฐ์ฒด๊ฐ ์์ฑ๋๋๋ก ํ๋ค.
// ์ฌ๊ธฐ์๋ simpDestination๋ Key์ ๊ฐ์ด ์๋ค๋ฉด ๊ฐ์ InvalidHeader๋ก ๋์ฒดํ๋ค.
String roomId = chatService.getRoomId(Optional.ofNullable((String) message.getHeaders().get("simpDestination")).orElse("InvalidRoomId"));
// ์ฑํ
๋ฐฉ์ ๋ค์ด์จ ํด๋ผ์ด์ธํธ sessionId๋ฅผ roomId์ ๋งตํํด ๋๋๋ค. (๋์ค์ ํน์ ์ธ์
์ด ์ด๋ค ์ฑํ
๋ฐฉ์ ๋ค์ด๊ฐ ์๋์ง ํ์ธํ๊ธฐ ์ํจ.)
String sessionId = (String) message.getHeaders().get("simpSessionId");
chatRoomRepository.setUserEnterInfo(sessionId, roomId);
// 2-2) ์ฑํ
๋ฐฉ์ ๋๊ฐ ๋ค์ด์์์ผ๋ก, ์ฑํ
๋ฐฉ ์ธ์ ์๋ฅผ ํ๋ ๋๋ฆฐ๋ค.
chatRoomRepository.plusUserCount(roomId);
// 2-3) ํด๋ผ์ด์ธํธ ์
์ฅ ๋ฉ์ธ์ง๋ฅผ ์ฑํ
๋ฐฉ์ ๋ฐ์กํ๋ค.
// ์ฌ์ฉ์ ์ด๋ฆ ์ป๊ธฐ
// Principal ๊ฐ์ฒด๋ ์๋ฐ์ ํ์ฅฐ ์ํ๋ฆฌํฐ ๊ธฐ์ ๋ก ๋ก๊ทธ์ธ ๋ ์ํ๋ผ๋ฉด ๊ณ์ ์ ๋ณด๋ฅผ ๋ด๊ณ ์๊ณ , ์๋๋ผ๋ฉด ์๋ฌด๊ฒ๋ ๋ด๊ณ ์์ง ์๋ค.
// ๋ฉ์ธ์ง๋ฅผ Principal ๊ฐ์ฒด๋ก ํ๋ณํ -> ์ด ๋ฉ์ธ์ง์ ์ ์ ์ด๋ฆ๊ณผ principal์ ์ ์ฅ๋ ์ฌ์ฉ์์ ์ด๋ฆ์ด ๊ฐ์์ง ํ์ธ
// ๋ง์ฝ ๊ฐ์ง ์์ผ๋ฉด map ํจ์์์ null์ด ๋ฐฐ์ถ๋ ๊ฒ์ด๊ณ , ๊ทธ๋ฌ๋ฉด orElse๋ฌธ์ UnKnownUser๊ฐ ์ถ๋ ฅ๋ ๊ฒ์ด๋ค.
// ์
์ฅ๊ณผ ํด์ฅ ๋ฉ์ธ์ง์์ ์ด๋ฆ์ด ์ ํจํ์ง์ ๋ํ ์ ํจ์ฑ ์ฒดํฌ๋ฅผ ํ๋ ๊ฒ์ ์๋๋ค.
// ๋ฉ์ธ์ง์์ ๋ณด๋ธ ์ด๊ฐ ์์ด ๋ค์ด์ค๋ ๊ฒฝ์ฐ, ์ด๋ค ํน์ ์ค๋ฅ ๋๋ฌธ์ ์ฌ์ฉ์์ ๋งค์นญ์ด ์๋๋ ๊ฒฝ์ฐ๋, ์์ผ ํต์ ์ด ์๋ฌ ์์ด ์งํ๋๋๋ก ํ๊ธฐ ์ํจ์ด๋ค.
String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
// chatService์ sendMessage๋ฅผ ์ด์ฉํด ๋ฉ์ธ์ง ๋ณด๋ด๊ธฐ
chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.ENTER).roomId(roomId).sender(name).build());
log.info("SUBSCRIBE {}, {}", name, roomId);
}
// 3) ํน์ ๋ฐฉ์์ ๋๊ฐ๊ฒ ๋ค๋ ์์ฒญ ์
else if (StompCommand.DISCONNECT == accessor.getCommand()) {
// ์ฐ๊ฒฐ์ ๋๊ธธ ์ํ๋ ํด๋ผ์ด์ธํธ์ sessionId๋ฅผ ๊ฐ์ง๊ณ ๋งคํ๋์ด์๋ roomId๋ฅผ ์ฐพ๋๋ค.
String sessionId = (String) message.getHeaders().get("simpSessionId");
String roomId = chatRoomRepository.getUserEnterRoomId(sessionId);
// ์ฑํ
๋ฐฉ์ ์ธ์ ์๋ฅผ -1ํ๋ค.
chatRoomRepository.minusUserCount(roomId);
// ํด๋ผ์ด์ธํธ์ ํด์ฅ ๋ฉ์ธ์ง๋ฅผ ์ฑํ
๋ฐฉ์ ๋ฐ์กํ๋ค.
// ์์์ ๋ค๋ฃฌ ๋ก์ง -> ์ด๋ฆ์ด ์ฌ์ฉ์ ์ ๋ณด์ ๋ค์ด๋ง๋ ์ด๋ฆ์ธ์ง ํ์ธ
String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
// ํด์ฅ ๋ฉ์ธ์ง ์ ์ก
chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.QUIT).roomId(roomId).sender(name).build());
// ํด์ฅํ ํด๋ผ์ด์ธํธ์ roomId ๋งตํ ์ ๋ณด๋ฅผ ์ญ์ ํ๋ค.
chatRoomRepository.removeUserEnterInfo(sessionId);
log.info("DISCONNECTED {}, {}", sessionId, roomId);
}
return message;
}
}
โ EmbeddedRedisConfig
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import redis.embedded.RedisServer;
// ๋ก์ปฌ ํ๊ฒฝ์์๋ง ์คํํฉ๋๋ค.
@Profile("local")
@Configuration
public class EmbeddedRedisConfig {
@Value("${spring.data.redis.port}")
private int redisPort;
private RedisServer redisServer;
@PostConstruct
public void redisServer() {
redisServer = RedisServer.builder()
.port(redisPort)
.setting("maxmemory 128M")
.build();
}
@PreDestroy
public void stopRedis() {
if(redisServer != null){
redisServer.stop();
}
}
}
โ RedisConfig
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.websocket.demo.pubsub.RedisSubscriber;
@RequiredArgsConstructor
@Configuration
public class RedisConfig {
// 1) ๋จ์ผ TOPIC ์ฌ์ฉ์ ์ํ Bean ์ค์
// ChannelTopic์ Redis์ ์กด์ฌํ๋ Channel(Topic) ๊ณผ ๋งตํ ๋๋ ๊ฐ์ฒด
@Bean
public ChannelTopic channelTopic() {
return new ChannelTopic("chatroom");
}
// 2) redis ๋ด์ฅ pub/sub ๊ธฐ๋ฅ์ ์ฌ์ฉํ๊ธฐ ์ํด Message Listener ์ค์ ์ ์ถ๊ฐ
// ++ redis ๋ก ๋ฐํ(publish)๋ ๋ฉ์ธ์ง๋ฅผ ์ฒ๋ฆฌํ๊ธฐ ์ํ ๋ฆฌ์ค๋ ์ค์
@Bean
public RedisMessageListenerContainer redisMessageListener(RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter, ChannelTopic channelTopic) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(listenerAdapter, channelTopic);
return container;
}
// 3) ์ค์ ๋ฉ์ธ์ง๋ฅผ ์ฒ๋ฆฌํ๋ subScriber ์ค์ ์ถ๊ฐ
@Bean
public MessageListenerAdapter listenerAdapter(RedisSubscriber subscriber){
return new MessageListenerAdapter(subscriber, "sendMessage");
}
// 4) ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์ดํ๋ฆฌ์ผ์ด์
์์ ์ฌ์ฉํ RedisTemplate ์ค์ , ์ง๋ ฌํ ์ด์ผ ํ ๊ฑด์ง, RestTemplate ์ฐ๊ฒฐ์๋ ์ด๋ค ํฉํ ๋ฆฌ๋ฅผ ์ฐ๋์ง
// RedisTemplate: Redis ๋ฐ์ดํฐ ๋ฒ ์ด์ค์ ์ํธ์์ฉํ๊ธฐ์ํ ์ค์ ์ ๋ด์ ํด๋์ค
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory){
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
return redisTemplate;
}
}
โ WebSecurityConfig
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
http.csrf(AbstractHttpConfigurer::disable) // ๊ธฐ๋ณธ์ ์ผ๋ก ON์ธ csrf ์ทจ์ฝ์ ๋ณด์์ ํด์ ํ๋ค.
// .header((s) -> s.frameOptions((a) -> a.sameOrigin()))
.headers((header) -> header.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) // SockJS๋ ๊ธฐ๋ณธ์ ์ผ๋ก HTML ifram ์์๋ฅผ ํตํ ์ ์ก์ ํ์ฉํ์ง ์๋๋ก ์ค์ ๋๋๋ฐ, ํด๋น ์ค์ ์ ํด์ ํ๋ค.
.formLogin(Object::toString) // ๊ถํ ์์ด ํ์ด์ง ์ ๊ทผ ์ ๋ก๊ทธ์ธ ํ์ด์ง๋ก ์ด๋ ์ํด
.authorizeHttpRequests((request) -> request.requestMatchers("/chat/*").hasRole("USER") // chat์ผ๋ก ์์ํ๋ ๋ฆฌ์์ค์ ๋ํ ์ ๊ทผ ๊ถํ ์ค์
.anyRequest().permitAll()); // ๋๋จธ์ง ๋ฆฌ์์ค์ ๋ํ ์ ๊ทผ ์ค์
return http.build();
}
/*
* ํ
์คํธ๋ฅผ ์ํด In-Memory์ ๊ณ์ ์ ์์๋ก ์์ฑํ๋ค.
* ์๋น์ค์์ ์ฌ์ฉ์์๋ DB ๋ฐ์ดํฐ๋ฅผ ์ด์ฉํ๋๋ก ์์ ์ด ํ์
* */
@Bean
public InMemoryUserDetailsManager userDetailService() {
UserDetails user = User.builder()
.username("user1")
.password(passwordEncoder().encode("1234"))
.roles("USER")
.build();
UserDetails user2 = User.builder()
.username("user2")
.password(passwordEncoder().encode("1234"))
.roles("USER")
.build();
UserDetails guest = User.builder()
.username("guest")
.password(passwordEncoder().encode("1234"))
.roles("GUEST")
.build();
return new InMemoryUserDetailsManager(user,user2,guest);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
โWebSockConfig
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
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.websocket.demo.config.handler.StompHandler;
// ์ด์ STOMP๋ฅผ ์ด์ฉํ์ฌ WebSocket์ ๊ตฌํํ ๊ฒ์ด๊ธฐ ๋๋ฌธ์ ๋ฐ๋ก WebSockHandler๊ฐ ํ์ ์๋ค.
// ์๋ํ๋ฉด STOMP๊ฐ ์น ์์ผ์์ ๋ฉ์ธ์ง๋ฅผ ๋ค๋ฃจ๋ ๋ฐฉ๋ฒ์ ๋ํ ๊ท์ฝ์ด๊ธฐ ๋๋ฌธ์ด๋ค.
@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker // ์น์์ผ์์ STOMP๋ฅผ ํ์ฑํ ํ๊ธฐ ์ํ ์ด๋
ธํ
์ด์
public class WebSockConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
// ๋ฉ์ธ์ง ์ก ์์ ์ ๋ํ ์ค์ ์ ๋ฑ๋กํ ๋งค์๋์ด๋ค.
// ๋ฉ์ธ์ง ์์ ์ sub/room/{๋ฐฉ ๋ฒํธ}๋ก ํด๋น ๋ฐฉ ๋ฒํธ๋ก ์จ ๋ฉ์ธ์ง๋ง ์์ ํ๋๋ก ์ค์
// ๋ฉ์ธ์ง ๋ฐ์ ์ ๊ฒฝ์ฐ์๋ pub/room ์ผ๋ก ๋ณด๋ด๊ณ ๋ฐฉ ๋ฒํธ๋ responseBody ์ ๋ฉํ๋ฐ์ดํฐ๋ก ์ ์ฅํ๋ค.
@Override
public void configureMessageBroker (MessageBrokerRegistry config){
//๋ฉ์ธ์ง๋ฅผ ๋ฐํํ๋ ์์ฒญ์ ์ ๋์ฌ๋ /pub๊ฐ ๋๋๋ก ์ค์
config.setApplicationDestinationPrefixes("/pub");
//๋ฉ์ธ์ง๋ฅผ ๊ตฌ๋
ํ๋ ์์ฒญ์ ์ ๋์ฌ๋ /sub๊ฐ ๋๋๋ก ์ค์
config.enableSimpleBroker("/sub");
}
// Stomp ์์ผ์ ์ธ ์ฃผ์๋ฅผ ํน์ ํ๋ค.
@Override
public void registerStompEndpoints(StompEndpointRegistry registry){
// stomp websocket์ผ๋ก ์ฐ๊ฒฐํ๋ ์ฃผ์์ endpoint๋ /ws-stomp๋ก ์ค์
// ๋ฐ๋ผ์ ์ ์ฒด ์ฃผ์๋ ws://localhost:8080/ws-stomp
registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*")
.withSockJS();
}
// ์ธํฐ์
ํฐ ์ค์ -> ์ฐ๋ฆฌ๊ฐ ๋ง๋ stompHandler๋ฅผ Websocket ์ฐ๊ฒฐ ์ ์ฒ๋ฆฌ๊ธฐ๋ก ๋ฑ๋ก
// WebSocket ์ต์ด ์ฐ๊ฒฐ ์ ์ ํจ์ฑ ๊ฒ์ฌ
@Override
public void configureClientInboundChannel (ChannelRegistration registration){
registration.interceptors(stompHandler);
}
}
โต controller Directory
โ ChatController
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import org.websocket.demo.model.ChatMessage;
import org.websocket.demo.repo.ChatRoomRepository;
import org.websocket.demo.service.ChatService;
import org.websocket.demo.service.JwtTokenProvider;
// WebSocket์ผ๋ก ๋ค์ด์ค๋ ๋ฉ์ธ์ง๋ฅผ ์ฒ๋ฆฌํ๋ ์ปจํธ๋กค๋ฌ
// ๋ฐํ๊ณผ ๊ตฌ๋
์ config์์ ์ค์ ํ๋ prefix๋ก ๊ตฌ๋ถ
// -> /pub/chat/message == pub ๋ค์ ์ฃผ์๋ก ๋ฉ์ธ์ง๋ฅผ ๋ฐํํ๋ ์์ฒญ
// -> /sub/chat/message == sub ๋ค์ ์ฃผ์๋ก๋ถํฐ ๋ฐํ๋๋ ๋ฉ์ธ์ง๋ฅผ ๋ฐ์์ค๊ฒ ๋ค๋ ์์ฒญ
// ํด๋ผ์ด์ธํธ๊ฐ ์ฑํ
๋ฐฉ ์
์ฅ ์ ์ฑํ
๋ฐฉ(TOPIC)์์ ๋ํ๊ฐ ๊ฐ๋ฅํ๋๋ก ๋ฆฌ์ค๋๋ฅผ ์ฐ๋ํ๋ enterChatRoom ๋ฉ์๋๋ฅผ ์ธํ
// ์ฑํ
๋ฐฉ์ ๋ฐํ๋ ๋ฉ์ธ์ง๋ ์๋ก ๋ค๋ฅธ ์๋ฒ์ ๊ณต์ ํ๊ธฐ ์ํด redis์ Topic์ผ๋ก ๋ค์ ๋ฐํ
@Slf4j
@RequiredArgsConstructor
@Controller
public class ChatController {
private final JwtTokenProvider jwtTokenProvider;
private final ChatRoomRepository chatRoomRepository;
private final ChatService chatService;
// @MessageMapping == ๋ฉ์ธ์ง๊ฐ WebSocket์ผ๋ก ๋ฐํ๋๋ ๊ฒฝ์ฐ ๋ฐ์ ๋งค์๋๊ฐ ์คํ๋๋ค.
@MessageMapping("/chat/message")
public void message (ChatMessage message, @Header("token") String token) {
// WebSocket ์ฐ๊ฒฐ ํ์ ๋ฉ์ธ์ง ์ ์ก ์ ๋ง๋ค ์ ํจํ Token์ ๊ฐ์ก๋์ง ๊ฒ์ฌํ๋ ์ฝ๋
// ๋ง์ฝ ์ ํจํ์ง ์์ ํ ํฐ์ ๊ฐ์ก๋ค๋ฉด, ์ดํ ์ฝ๋๊ฐ ์คํ ์๋ ํฐ์ด๊ณ , websocket์ ํตํด ๋ณด๋ธ ๋ฉ์ธ์ง๋ ๋ฌด์๋๋ค.
String nickname = jwtTokenProvider.getUserNameFromJwt(token);
// ๋ก๊ทธ์ธ ํ์ ์ ๋ณด๋ก ๋ํ๋ช
์ค์
message.setSender(nickname);
// ์ฑํ
๋ฐฉ ์ธ์ ์ ์ธํ
message.setUserCount(chatRoomRepository.getUserCount(message.getRoomId()));
// WebSocket์ ๋ฐํ๋ ๋ฉ์ธ์ง๋ฅผ redis๋ก ๋ฐํ
chatService.sendChatMessage(message);
}
}
โ ChatRoomController
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.websocket.demo.model.ChatRoom;
import org.websocket.demo.model.LoginInfo;
import org.websocket.demo.repo.ChatRoomRepository;
import org.websocket.demo.service.JwtTokenProvider;
import java.util.List;
// ์น ์์ผ ๋ด์ฉ ์๋๊ณ , ์ฑํ
ํ๋ฉด View ๊ตฌ์ฑ์ ์ํด ํ์ํ Controller
@RequiredArgsConstructor
@Controller
@RequestMapping ("/chat")
public class ChatRoomController {
private final ChatRoomRepository chatRoomRepository;
private final JwtTokenProvider jwtTokenProvider;
// 1) ์ฑํ
๋ฆฌ์คํธ ํ๋ฉด ๋ฐํ
@GetMapping("/room")
public String rooms (Model model) {
return "/chat/room";
}
// 2) ์ฑํ
๋ฐฉ ์์ฑ -> ํ๋์ Topic์ ์์ฑ (RestAPI )
@PostMapping("/room")
@ResponseBody
public ChatRoom createRoom(@RequestParam String name){
return chatRoomRepository.createChatRoom(name);
}
// 3) ๋ชจ๋ ์ฑํ
๋ฐฉ ๋ชฉ๋ก ๋ฐํ (RestAPI)
@GetMapping("/rooms")
@ResponseBody
public List<ChatRoom> room() {
// ํ์ฌ ์ ์ฅ๊ณ ์ ์กด์ฌํ๋ ๋ฐฉ์ ๋ชจ๋ ์ฐพ๊ณ
List<ChatRoom> chatRooms = chatRoomRepository.findAllRoom();
// ๋ฐฉ์ ํ๋ํ๋์ฉ ์ํํ๋ฉด์, ํด๋น ๋ฐฉ์ ์ธ์ ์๋ฅผ ์ต์ ํ
chatRooms.stream().forEach(room -> room.setUserCount(chatRoomRepository.getUserCount(room.getRoomId())));
return chatRooms;
}
// 4) ์ฑํ
๋ฐฉ์ ์
์ฅ -> ํด๋น ํ ํฝ์ ๊ตฌ๋
ํ๋ค๋ ๋ป
@GetMapping("/room/enter/{roomId}")
public String roomDetail (Model model, @PathVariable String roomId) {
model.addAttribute("roomId", roomId);
return "/chat/roomdetail";
}
// 5) ํน์ ์ฑํ
๋ฐฉ ์กฐํ
@GetMapping("/room/{roomId}")
@ResponseBody
public ChatRoom roomInfo(@PathVariable String roomId) {
return chatRoomRepository.findRoomById(roomId);
}
// 6) ๋ก๊ทธ์ธํ ํ์์ id ๋ฐ Jwt ํ ํฐ ์ ๋ณด๋ฅผ ์กฐํํ ์ ์๋๋ก ํ๋ RESTFUL API
@GetMapping("/user")
@ResponseBody
public LoginInfo getUserInfo() {
/* ์ธ์ฆ๋ ์ฌ์ฉ์ ์ ๋ณด์ธ Principal์ Authentication์์ ๊ด๋ฆฌํ๊ณ ,
* Authentication์ SecurityContext์์ ๊ด๋ฆฌํ๊ณ ,
* SecurityContext๋ SecurityContextHolder๊ฐ ๊ด๋ฆฌํ๋ค.
*/
// ์ฌ๊ธฐ์๋ ์ธ์ฆ๋ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๊บผ๋ด์, ๋ณผ ์ ์๊ฒ ํ๋ ๋ก์ง์ด๋ค.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String name = auth.getName();
return LoginInfo.builder().name(name).token(jwtTokenProvider.generateToken(name)).build();
}
}
โถ Model Directory
โ ChatMessage
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
// ์ฑํ
๋ฉ์ธ์ง๋ฅผ ์ฃผ๊ณ ๋ฐ๊ธฐ ์ํ DTO
@Getter
@Setter
public class ChatMessage {
public ChatMessage() {}
@Builder
public ChatMessage (MessageType type, String roomId, String sender, String message, long userCount) {
this.type = type;
this.roomId = roomId;
this.sender = sender;
this.message = message;
this.userCount = userCount;
}
// ๋ฉ์์ง ํ์
: ์
์ฅ, ํด์ฅ, ์ฑํ
-> ์๋ฒ์์ ํด๋ผ์ด์ธํธ๊ฐ ์ด๋ค ํ๋ํ ๋๋ง๋ค ๋ฉ์ธ์ง๊ฐ ๋ฐํ๋จ.
// ๊ทธ ํ๋์ด ๋ฌด์์ธ์ง ๊ธฐ์ ํ ๊ฒ
public enum MessageType {
ENTER, QUIT ,TALK
}
private MessageType type; // ๋ฉ์ธ์ง ํ์
private String roomId; // ๋ฐฉ ๋ฒํธ
private String sender; // ๋ฉ์ธ์ง ๋ณด๋ธ ์ฌ๋
private String message; // ๋ฉ์ธ์ง
private long userCount; // ์ฑํ
๋ฐฉ์ ์ธ์ ์, ์ฑํ
๋ฐฉ์ด ๋ฉ์ธ์ง๋ฅผ ์์ ํ ๋๋ง๋ค ์ธ์์ ๊ฐฑ์ ๋๋๋ก ํ ๊ฒ์.
}
๊ทธ ์ธ์ ๋ชจ๋ธ ๋จ์ ๊ทธ๋ ๊ฒ ์ค์ํ ์ ๋ฐ์ดํธ๊ฐ ์์ด์ ์๋ตํ๊ฒ ์ต๋๋ค. ์์ธํ ๋ด์ฉ์ ์ ๊นํ๋ธ๋ฅผ ์ฐธ๊ณ ํด์ฃผ์ธ์.
https://github.com/dalcheonroadhead/WebSocketBEPractice/tree/firstStep
โท pub / sub
โ RedisSubscriber
import com.fasterxml.jackson.databind.ObjectMapper;
import org.websocket.demo.model.ChatMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
public class RedisSubscriber {
private final ObjectMapper objectMapper;
private final SimpMessageSendingOperations messagingTemplate;
// Redis์์ ๋ฉ์ธ์ง๊ฐ ๋ฐํ(publish)๋๋ฉด ๋๊ธฐํ๊ณ ์๋ Redis Subscriber๊ฐ ํด๋น ๋ฉ์ธ์ง๋ฅผ ๋ฐ์์ ์ฒ๋ฆฌํ๋ค.
public void sendMessage (String publishMessage) {
try {
// ๋ฐํ๋ ๋ฉ์ธ์ง๋ฅผ chatMessage DTO์ ๋ง๊ฒ ๊ฐ์ฒด ๋งคํ
ChatMessage chatMessage = objectMapper.readValue(publishMessage, ChatMessage.class);
// ์ฑํ
๋ฐฉ์ ๊ตฌ๋
ํ ํด๋ผ์ด์ธํธ์๊ฒ ๋ฉ์ธ์ง ๋ฐ์ก
messagingTemplate.convertAndSend("/sub/chat/room/" + chatMessage.getRoomId(), chatMessage);
}catch (Exception e){
log.error("Exception {}", e);
}
}
}
โธ repo Directory
โ ChatRoomRepository
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.websocket.demo.model.ChatRoom;
import java.util.*;
// ์ฑํ
๋ฐฉ์ ์์ฑํ๊ณ ํน์ ์ฑํ
๋ฐฉ ์ ๋ณด๋ฅผ ์กฐํํ๋ Repository
// ์์ฑ๋ ์ฑํ
๋ฐฉ์ ์ด๊ธฐํ ๋์ง ์๋๋ก ์์ฑ ์ Redis Hash์ ์ ์ฅํ๋๋ก ์ฒ๋ฆฌ
// ๋ฐฉ ์ ๋ณด๋ฅผ ์กฐํํ ๋๋ Redis Hash์ ์ ์ฅ๋ ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ค๋๋ก ๋ฉ์๋ ๋ด์ฉ์ ์์
// ์ฑํ
๋ฐฉ ์
์ฅ ์์๋ ์ฑํ
๋ฐฉ ID๋ก Redis Topic์ ์กฐํํ์ฌ pub/sub ๋ฉ์ธ์ง ๋ฆฌ์ค๋์ ์ฐ๋
@RequiredArgsConstructor
@Service
public class ChatRoomRepository {
// Redis Cache๋ฅผ ๋ค์ฌ๋ค ๋ณด๊ธฐ ์ํ CacheKey
private static final String CHAT_ROOMS = "CHAT_ROOM"; // ์ฑํ
๋ฃธ์ ์ ์ฅํ Cache ์ด์ด๋ณด๋ KEY
private static final String USER_COUNT = "USER_COUNT"; // ์ฑํ
๋ฃธ๋ง๋ค ์
์ฅํ ํด๋ผ์ด์ธํธ ์๋ฅผ ์ ์ฅํ Cache ์ด์ด๋ณด๋ KEY
private static final String ENTER_INFO = "ENTER_INFO"; // ์ฑํ
๋ฃธ์ ์
์ฅํ ํด๋ผ์ด์ธํธ์ SessionID, ํ์ฌ ์
์ฅํ ์ฑํ
๋ฃธ์ roomId๋ฅผ ๋งตํํ ์ ๋ณด๋ฅผ ์ ์ฅํ Cache ์ด์ด๋ณด๋ KEY
// ์์กด ๊ฐ์ฒด๋ฅผ ์ฃผ์
ํ๋ ์ด๋
ธํ
์ด์
, ์ด๋ฆ -> ํ์
-> @Qualifier ์์ผ๋ก ์ฐพ๋๋ค.
// + @Qualifier๋ ์ฃผ์
๋ฐ์ ๋น ๊ฐ์ฒด๋ฅผ ํน์ ํ๋ ์ด๋
ธํ
์ด์
์ด๋ค.
// @Resource(name ="")๊ณผ ๊ฐ์ ์๋ฏธ์ด๋ค. ๊ทผ๋ฐ @Resource(name="")ํ๋ฉด ์ ์ฐ์ ์์๋ ์๊ด์์ด ์ด๋ฆ์ผ๋ก ๋ฐ๋ก ์ฐพ๋๋ค.
@Resource(name = "redisTemplate")
private HashOperations<String, String, ChatRoom> hashOpsChatRoom;
@Resource(name = "redisTemplate")
private HashOperations<String, String, String> hashOpsEnterInfo;
@Resource(name = "redisTemplate")
private ValueOperations<String, String> valueOps;
// ๋ชจ๋ ์ฑํ
๋ฐฉ ์กฐํ
public List<ChatRoom> findAllRoom() {
return hashOpsChatRoom.values(CHAT_ROOMS);
}
// ํน์ ์ฑํ
๋ฐฉ ์กฐํ
public ChatRoom findRoomById(String id) {
return hashOpsChatRoom.get(CHAT_ROOMS, id);
}
// ์ฑํ
๋ฐฉ ์์ฑ: ์์ฑ ํ ์๋ฒ๊ฐ ์ฑํ
๋ฐฉ ๊ณต์ ๋ฅผ ์ํด redis HK๊ฐ chat_room์ธ hashmap์ ์ ์ฅํ๋ค.
public ChatRoom createChatRoom(String name){
ChatRoom chatRoom = ChatRoom.create(name);
hashOpsChatRoom.put(CHAT_ROOMS, chatRoom.getRoomId(), chatRoom);
return chatRoom;
}
// ์ ์ ๊ฐ ์
์ฅํ ์ฑํ
๋ฐฉID์ ์ ์ ์ธ์
ID ๋งตํ ์ ๋ณด๋ฅผ ์ ์ฅ
public void setUserEnterInfo(String sessionId, String roomId){
hashOpsEnterInfo.put(ENTER_INFO,sessionId,roomId);
}
// ํน์ ์ ์ ์ธ์
์ด ์
์ฅํด ์๋ ์ฑํ
๋ฐฉ ID ์กฐํ
public String getUserEnterRoomId(String sessionId){
return hashOpsEnterInfo.get(ENTER_INFO, sessionId);
}
// ์ ์ ์ธ์
์ ๋ณด์ ๋งตํ๋ ์ฑํ
๋ฐฉ ID ์ญ์
public void removeUserEnterInfo(String sessionId){
hashOpsEnterInfo.delete(ENTER_INFO,sessionId);
}
//------------------------------------------------------------------------------------------------------
// valueOperations์ ๋ํด์ ์์ธํ ๋ด์ฉ์ด ๋์ค์ง ์์์ ์ผ๋จ ์ด๋ฐ ์์ผ๋ก ์ฌ์ฉ์ ์ธ์ ์๋ฅผ ์ผ๋ค๋ ๊ฒ์ ์ธ์งํ ๋ค์,
// Redis ์์
์ ๋ค์ผ๋ฉฐ ์ตํ์.
// ํน์ ์ฑํ
๋ฐฉ์ ์ ์ ์๋ฅผ ์กฐํ
public long getUserCount(String roomId) {
return Long.valueOf(Optional.ofNullable(valueOps.get(USER_COUNT+"_"+roomId)).orElse("0"));
}
// ์ฑํ
๋ฐฉ์ ์
์ฅํ ์ ์ ์ +1
public long plusUserCount(String roomId){
return Optional.ofNullable(valueOps.increment(USER_COUNT+"_"+roomId)).orElse(0L);
}
// ์ฑํ
๋ฐฉ์ ์
์ฅํ ์ ์ ์ -1
public long minusUserCount(String roomId){
return Optional.ofNullable(valueOps.decrement(USER_COUNT+"_"+roomId)).filter(count -> count >0).orElse(0L);
}
}
โน Service Directory
โ Chat Service
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.stereotype.Service;
import org.websocket.demo.model.ChatMessage;
import org.websocket.demo.repo.ChatRoomRepository;
@RequiredArgsConstructor
@Service
public class ChatService {
private final ChannelTopic channelTopic;
private final RedisTemplate redisTemplate;
private final ChatRoomRepository chatRoomRepository;
// destination ์ ๋ณด์์ roomId ์ถ์ถ
// lastIndexOf() ๋ฉ์๋๋ ์ง์ ๋ ๋ฌธ์ ๋๋ ๋ฌธ์์ด์ ํ์ ๋ฌธ์์ด์ด ๋ง์ง๋ง์ผ๋ก ๋ํ๋๋ ์์น๋ฅผ ๋ณํ
public String getRoomId(String destination) {
int lastIndex = destination.lastIndexOf('/');
// lastIndexOf๋ ๋ง์ฝ ์ฐ๋ฆฌ๊ฐ ์ฐพ๋ ๋ฌธ์๊ฐ ๋ฌธ์์ด ๋ด์ ์์ผ๋ฉด -1์ ๋ฑ๋๋ค.
if(lastIndex != -1) {
// ๋ง์ฝ /์ด ์๋ค๋ฉด ๊ทธ๊ฒ ์ดํ๋ถํฐ ์๋ผ์ ์จ๋ค.
// ๊ทธ๋ฌ๋๊น destination์ด chat/room/{๋ฐฉ๋ฒํธ} ์์ผ๋ก ์ฌ๊ธฐ์ {๋ฐฉ ๋ฒํธ}๋ง ๋ผ์ ์ค๋ ๊ฒ์ด๋ค.
return destination.substring(lastIndex +1);
}else {
return "";
}
}
// ์ฑํ
๋ฐฉ์ ๋ฉ์ธ์ง ๋ฐ์ก
public void sendChatMessage(ChatMessage chatMessage) {
chatMessage.setUserCount(chatRoomRepository.getUserCount(chatMessage.getRoomId()));
if(ChatMessage.MessageType.ENTER.equals(chatMessage.getType())){
chatMessage.setMessage(chatMessage.getSender() + "๋์ด ๋ฐฉ์ ์
์ฅํ์ต๋๋ค.");
chatMessage.setSender("[์๋ฆผ]");
} else if (ChatMessage.MessageType.QUIT.equals(chatMessage.getType())){
chatMessage.setMessage(chatMessage.getSender() + "๋์ด ๋ฐฉ์์ ๋๊ฐ์ต๋๋ค.");
chatMessage.setSender("[์๋ฆผ]");
}
redisTemplate.convertAndSend(channelTopic.getTopic(), chatMessage);
}
}
โ JwtTokenProvider๋ ์ ๋ฒ๊ณผ ๋ฌ๋ผ์ง ๊ฒ์ด ์์ด์ ํจ์ค