0.๋ชฉ์ฐจ
๊ณต๋ถํ ๋ด์ฉ๋ค์ ์ถ์ฒ: ddadyProgrammer๋์ ๋ธ๋ก๊ทธ
1. ๊ฐ์ (์๋กญ๊ฒ ์ถ๊ฐํ ๊ฒ๋ค) |
2. ์ ๊ฐ๋ |
3. ์ฝ๋ ๋ถ์ |
4. ํ๋ก์ ํธ ์งํํ๋ฉฐ ๋ง๋ฌ๋ ์ค๋ฅ ๋ชจ์ |
1. ๊ฐ์
์ด๋ฒ ์ฅ์์๋ ์๋กญ๊ฒ Spring Security๋ฅผ ์ ์ฉํ๋ค. ์๋ ํ๋ก์ ํธ๋ ์ ๊ทผํ ๋ชจ๋ ์ฌ์ฉ์๊ฐ ๋ํ๋ช ๋ง ์ ๋ ฅํ๋ฉด ๋ชจ๋ ์ฌ์ฉํ ์ ์์๋ค. ํ์ง๋ง ์ด๋ฒ ์ฅ์์๋ ํ์๊ณผ ์ผ๋ฐ ๋ฐฉ๋ฌธ์๋ฅผ ๋๋๊ณ , ํ์๋ง์ด ์ฑํ ํ๋ก๊ทธ๋จ์ ์ธ ์ ์๋๋ก ์ ์ฝ์ ๊ฑธ์๋ค. ์ด๋ ๊ฒ ์ ํจ์ฑ ๊ฒ์ฆ์ ํตํด ์ ๋ขฐ๋๊ฐ ์๋ ํ์๋ง ์น ์๋น์ค๋ฅผ ์ธ ์ ์๋๋ก ๋์์ฃผ๋ Spring Library ์ค ํ๋๊ฐ Spring Security์ด๋ค. ์ด๋ฒ ์ฅ์์๋ Spring Security๋ฅผ ์ด์ฉํด ์น ์๋น์ค์ ์ ์ฝ๊ณผ ๋ณด์์ ๊ฑธ๊ณ , JWT๋ฅผ ์ด์ฉํด ์ฌ์ฉ์์ ๋ํ ์ ํจ์ฑ ๊ฒ์ฆ์ ํ ๊ฒ์ด๋ค. JWT๋ JSON WEB TOKEN์ ์ฝ์์ด๋ค. ํด๋ผ์ด์ธํธ๋ ํน์ ํ์ด์ง ์ด๋์ด๋ ์๋น์ค ์ด์ฉ ์ ๋ฐ๊ธ ๋ฐ์ JWT๋ฅผ Header์ ๋๋ดํ์ฌ ์ฐ๋ฆฌ์๊ฒ ๋ณด๋ธ๋ค. ์ฐ๋ฆฌ๋ ํด๋น JWT๊ฐ ์ ํต๊ธฐํ์ด ์ง๋ฌ๋์ง, ์ ํจํ์ง ๋ฑ์ ์ฒดํฌํ๋ค. ๊ทธ ํ ์ ํจ์ฑ์ด ์ธ์ ๋๋ ์ฌ์ฉ์๋ง ์ฐ๋ฆฌ๋ค์ ์๋น์ค๋ฅผ ์ฌ์ฉํ๋๋ก ํ๋ค.
2. ์ ๊ฐ๋
์ด๋ฒ ํ๋ก์ ํธ์ ์ ๊ฐ๋๋ฅผ ๊ฐ๋ตํ ๊ทธ๋ ค๋ดค๋ค.
๊ฐ ์์ญ๋ณ๋ก ๋ ํ๋ํด์ ๋ณด์ฌ์ฃผ๊ฒ ๋ค.
๋ฐ๋ ๋ถ๋ถ ์ค ๊ฐ์ฅ ์ฃผ๋ชฉํ ๋งํ ๋ถ๋ถ์ Redis Publisher๊ฐ ์ฌ๋ผ์ง๊ณ , Subscriber๊ฐ ๊ฐ์ํ ๋ ๊ฒ์ด๋ค. ์ด์ ๊น์ง์ Chapter์์๋ ์ฌ์ฉ์๊ฐ ํน์ ์ฑํ ๋ฐฉ์ ๋ค์ด์ฌ ๋๋ง๋ค ํด๋น ์ฑํ ๋ฐฉ ์ด๋ฆ์ Topic์ ์๋ก ๋ง๋ค์๋ค. ๊ทธ๋ฆฌ๊ณ Topic์ ๋ง๋ค๋๋ง๋ค, ํด๋น Topic์ผ๋ก ๋ฐํ๋ ๋ฉ์ธ์ง๋ฅผ ๊ด๋ฆฌํ๋ SubScriber๋ฅผ ํญ์ ์๋ก ๋ฌ์์ค์ผ ํ๋ค. ๋ค์ ๊ทธ๋ฆผ๊ณผ ๊ฐ์ด ๋ง์ด๋ค.
ํ์ง๋ง ์ด๋ฒ ์ฅ๋ถํฐ๋ Topic๊ฐ์ฒด๋ฅผ ํ๋ก๊ทธ๋จ ์คํ ์ ๋ฑ ํ๋ฒ๋ง ๋ง๋ ๋ค. ๊ทธ๋์ subScriber๋ RedisConfig์์ ๋ฑ ํ๋ฒ๋ง ์ฐ์ธ๋ค. ํ๋๋ฟ์ธ TOPIC์ ํ๋ฒ๋ง ์ฅ์ฐฉ๋๊ธฐ ๋๋ฌธ์ด๋ค. ์๋ Publisher๊ฐ ํ๋ ๋ฉ์ธ์ง ๋ฐํ ๊ตฌํ์ RedisTemplate๋ฅผ ์ด์ฉํ Redis DB์ ์ง์ CRUD๋ฅผ ํ๋ ๊ฒ์ผ๋ก ๋์ฒดํ๋ค.
3. ์ฝ๋๋ถ์
โ StompHandler
WebSocket ์ฐ๊ฒฐํ๊ธฐ ์ ์, ์ฐ๊ฒฐ์ ์์ฒญํ ์ฌ์ฉ์๊ฐ ์ ํจํ TOKEN์ ๊ฐ์ง ์ฌ์ฉ์์ธ์ง ํ์ธํ๋ ์ธํฐ์ ํฐ Handler์ด๋ค. ์๊ฒฉ ๊ฒ์ฆ์ JwtTokenProvider๋ผ๋ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ํด๋์ค์ ๊ฐ์ฒด๋ฅผ ์ด์ฉํ๋ค.
// WebSocket ์ฐ๊ฒฐ ์ ์์ฒญ header์ jwtToekn ์ ํจ์ฑ์ ๊ฒ์ฆํ๋ ์ฝ๋
// ์ ํจํ์ง ์์ Jwt ํ ํฐ์ด ์ธํ
๋ ๊ฒฝ์ฐ webSocket ์ฐ๊ฒฐ์ ํ์ง ์๊ณ ์์ธ ์ฒ๋ฆฌํ๋ค.
@Slf4j
@RequiredArgsConstructor // JwtTokenProvider์ ๋ํ ์์ฑ์ ์ฃผ์
์ด ์ผ์ด๋๋ค.
@Component
public class StompHandler implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
// webSocket์ ํตํด ๋ค์ด์จ ์์ฒญ์ด ์ฒ๋ฆฌ ๋๊ธฐ ์ ์ ์คํ๋๋ค.
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
// webSocket ์ฐ๊ฒฐ ์ ํค๋์ jwt Token ๊ฒ์ฆ
if(StompCommand.CONNECT == accessor.getCommand()){
jwtTokenProvider.validateToken(accessor.getFirstNativeHeader("token"));
}
return message;
}
}
โ EmbeddedRedisConfig๋ ์ ๋ฒ๊ณผ ๊ฐ์์ ์๋ต
โ RedisConfig
Topic ์์ฑ์ ๋จ์ผํ, ๊ทธ์๋ฐ๋ผ Subscriber ๋ฑ๋ก๋ ๋ฑ ํ๋ฒ๋ง ํ๊ฒ๋๋ค. ๋ฐ๋ผ์ ์๋๋ Service ๋จ์์ ๊ตฌํํ๋ Sub๋ก์ง์ RedisCOnfig์์ ๋ค๋ฃฌ๋ค. pub ๋ก์ง์ ์ฌ๋ผ์ง๊ณ , ๋์ Chat Controller์์ RedisTemplate๋ฅผ ์ด์ฉํ ์ง์ Redis DB์ Post ์์ ์ ํ๋ค.
package org.websocket.demo.config;
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
์ฌ๊ธฐ์ chat/ ~~ ๋ก ์์ํ๋ ํ์ด์ง์๋ ๋ฌด์กฐ๊ฑด ๋ก๊ทธ์ธ์ผ๋ก ํ ํฐ์ ๋ฐ์ ์ฌ์ฉ์๋ง ๋ค์ด๊ฐ๋๋ก ์ ํํ๋ ๋ก์ง์ ์งฐ๋ค. ๋ก์ง์ Spirng Security๋ฅผ ์ด์ฉํ๋ค. ๋ํด์, ํ์ฌ๋ Mysql ๊ฐ์ RDS๋ฅผ ์ฐ์ง ์์์ผ๋ก, ์ฌ์ฉ์ ์ค์ ํ๋ ๋ก์ง๋ ์ถ๊ฐํ๋ค. ํด๋น ๋ก์ง์ ๋ง์ฝ์ DB๋ฅผ ๋ฌ ๊ฒฝ์ฐ ์ ์ ํ์ ์๋ ๋ก์ง์ด๋ค. ๋ํ ๋น๋ฐ๋ฒํธ ์ํธํ๋ฅผ ์ํ BcryptPasswordEncoder๋ฅผ ์ถ๊ฐํ๋ค.
package org.websocket.demo.config;
/*
* WebSecurityConfigurerAdapter ๋ deprecated ๋์์. ๋ฐ๋ผ์ ๊ทธ ๋์์ผ๋ก
* Component-based Security Configuration์ ์ฌ์ฉํ๋ ๊ฒ์ด ๊ถ์ฅ๋จ. configure() -> filterChain
*
* */
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
์ฌ๊ธฐ์ ๋ณ๋ค๋ฅผ ๊ฒ ์๊ณ , configureCilentInboundChannel์ด๋ ๋งค์๋์์ ์๊น ์ฐ๋ฆฌ๊ฐ ๋ง๋ค์๋ Interceptor Handler์ธ StompHandler๋ฅผ ์ฅ์ฐฉ ์ํค๋ ๋ก์ง์ ๋ง์ง๋ง์ ์งฐ๋ค. ์ด์ WebSocket์ ์ฐ๊ฒฐํ๊ธฐ ์ ์ Token์ด ์๋ ์ฌ์ฉ์์ธ์ง ํ์ธํ๊ฒ ๋๋ค.
package org.websocket.demo.config;
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);
}
}
โ Chat Controller
์ถ๊ฐ๋ ๋ก์ง์ jwtTokenProvider๋ฅผ ์ด์ฉํด ํ ํฐ์ ๋ง๋ ๋๋ค์์ด ์๋์ง ํ์ธํ๋ค. ํด๋น ๊ณผ์ ์์ ์ค๋ฅ๊ฐ ๋๋ฉด, ๊ทธ ๋ฐ์ด ์คํ๋์ง ์์์ ์ค๋ฅ๊ฐ ๋๋ค. ํ ํฐ์ ๋งค์นญ๋๋ ๋๋ค์์ด ์๋ค๋ฉด ๋ฐ์ด ์คํ๋๋ค. ๋ฉ์ธ์ง๋ฅผ Topic์ผ๋ก ๋ฐํํ๋ ๋ก์ง์ ์์์ ์ค๋ช ํ๋ฏ์ด RedisTemplate.convertAndSend๋ก ๋์ฒด๋์๋ค.
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.JwtTokenProvider;
// WebSocket์ผ๋ก ๋ค์ด์ค๋ ๋ฉ์ธ์ง๋ฅผ ์ฒ๋ฆฌํ๋ ์ปจํธ๋กค๋ฌ
// ๋ฐํ๊ณผ ๊ตฌ๋
์ config์์ ์ค์ ํ๋ prefix๋ก ๊ตฌ๋ถ
// -> /pub/chat/message == pub ๋ค์ ์ฃผ์๋ก ๋ฉ์ธ์ง๋ฅผ ๋ฐํํ๋ ์์ฒญ
// -> /sub/chat/message == sub ๋ค์ ์ฃผ์๋ก๋ถํฐ ๋ฐํ๋๋ ๋ฉ์ธ์ง๋ฅผ ๋ฐ์์ค๊ฒ ๋ค๋ ์์ฒญ
// ํด๋ผ์ด์ธํธ๊ฐ ์ฑํ
๋ฐฉ ์
์ฅ ์ ์ฑํ
๋ฐฉ(TOPIC)์์ ๋ํ๊ฐ ๊ฐ๋ฅํ๋๋ก ๋ฆฌ์ค๋๋ฅผ ์ฐ๋ํ๋ enterChatRoom ๋ฉ์๋๋ฅผ ์ธํ
// ์ฑํ
๋ฐฉ์ ๋ฐํ๋ ๋ฉ์ธ์ง๋ ์๋ก ๋ค๋ฅธ ์๋ฒ์ ๊ณต์ ํ๊ธฐ ์ํด redis์ Topic์ผ๋ก ๋ค์ ๋ฐํ
@Slf4j
@RequiredArgsConstructor
@Controller
public class ChatController {
private final RedisTemplate<String, Object> redisTemplate;
private final ChannelTopic channelTopic;
private final JwtTokenProvider jwtTokenProvider;
// @MessageMapping == ๋ฉ์ธ์ง๊ฐ WebSocket์ผ๋ก ๋ฐํ๋๋ ๊ฒฝ์ฐ ๋ฐ์ ๋งค์๋๊ฐ ์คํ๋๋ค.
@MessageMapping("/chat/message")
public void message (ChatMessage message, @Header("token") String token) {
//WebSocket ์ฐ๊ฒฐ ํ์ ๋ฉ์ธ์ง ์ ์ก ์ ๋ง๋ค ์ ํจํ Token์ ๊ฐ์ก๋์ง ๊ฒ์ฌํ๋ ์ฝ๋
// ๋ง์ฝ ์ ํจํ์ง ์์ ํ ํฐ์ ๊ฐ์ก๋ค๋ฉด, if๋ฌธ ์ดํ ์ฝ๋๊ฐ ์คํ ์๋ ํฐ์ด๊ณ , websocket์ ํตํด ๋ณด๋ธ ๋ฉ์ธ์ง๋ ๋ฌด์๋๋ค.
String nickname = jwtTokenProvider.getUserNameFromJwt(token);
// ์ฑํ
๋ฐฉ ์
์ฅ ์์๋ ๋ํ๋ช
๊ณผ ๋ฉ์ธ์ง๋ฅผ ์๋์ผ๋ก ์ธํ
ํ๋ค.
if(ChatMessage.MessageType.ENTER.equals(message.getType())){
message.setSender("[์๋ฆผ]");
message.setMessage(nickname + "๋์ด ์
์ฅํ์
จ์ต๋๋ค.");
}
// ๋ก๊ทธ์ธ ํ์ ์ ๋ณด๋ก ๋ํ๋ช
์ค์
message.setSender(nickname);
//WebSocket์ ๋ฐํ๋ ๋ฉ์ธ์ง๋ฅผ redis๋ก ๋ฐํ(publish)
redisTemplate.convertAndSend(channelTopic.getTopic(), message);
}
}
โ ChatRoomController
์ค์ํ ๊ฒ ์ถ๊ฐ๋์ง ์์๋ค. ๋ค๋ง REST API๋ฅผ ํตํด ํ์ฌ ํ ํฐ์ ๋ฐ๊ณ ์ ์ํ User์ ์ ๋ณด๋ฅผ JSON ํํ๋ก ๋ฐ์๋ณผ ์ ์๋๋ก ํ์๋ค.
package org.websocket.demo.controller;
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() {
return chatRoomRepository.findAllRoom();
}
// 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() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String name = auth.getName();
return LoginInfo.builder().name(name).token(jwtTokenProvider.generateToken(name)).build();
}
}
โ ChatMessage์ ChatRoom์ ๋ฐ๋ ๋ด์ฉ์ด ์์ด์ ๋์ด๊ฐ๋ค.
โ LoginInfo
ํ์ฌ ๋ธ๋ผ์ฐ์ ์ ์ ์ํ ์ ์ ๋ฅผ ๋ํ๋ด๋ Info ๋ฌธ์ด๋ค. Builder๋ฅผ ์ธ ์ ์๊ฒ ์ด๋ ธํ ์ด์ ์ฒ๋ฆฌ ํ๊ณ ํน๋ณํ ๊ฒ์ ์๋ค.
package org.websocket.demo.model;
import lombok.Builder;
import lombok.Getter;
// id ๋ฐ jwt ํ ํฐ์ ์ ๋ฌํ DTO
@Getter
public class LoginInfo {
private String name;
private String token;
@Builder
public LoginInfo (String name, String token){
this.name = name;
this.token = token;
}
}
โRedisSubscriber
Topic์ผ๋ก ๋ฐํ๋ ๋ฉ์ธ์ง๋ฅผ ์ด๋ป๊ฒ ์ฒ๋ฆฌํ ๊ฒ์ธ์ง์ ๋ํ ๋ก์ง์ ๋ด์ ํด๋์ค์ด๋ค.
@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);
}
}
}
โChatRoomRepository
์ด๋ฆ์ด Repository์ธ๋ฐ ์ฌ์ค์ Service๋ก์ง์ด๋ค. ์ ๋ฒ ํฌ์คํ ๊น์ง ์ฌ๊ธฐ์ Topic์ ๋ง๋ค๊ณ ๊ด๋ฆฌํ์๋๋ฐ, ์ด์ ๊ทธ๋ด ํ์๊ฐ ์์ด์ก๋ค. Topic์ ์ด์ RedisConfig์์ ๋ฑ ํ๋ ๋ง๋ค์ด์ ๊ด๋ฆฌํ๊ธฐ ๋๋ฌธ์ด๋ค. ์ฌ๊ธฐ์๋ RedisTemplate.opsForHash()๋ผ๋ ๋ช ๋ น์ด๋ก DB ๋ด์ ์ ์ฅ์ ํ๋๋ฅผ ์ป๊ณ , createRoom ๋งค์๋์ผ ์, ์ ์ฅ์์ ์๋ก์ด ์ฑํ ๋ฐฉ ID๋ฅผ ๋ง๋ค์ด์ ์ ์ฅ, find~~ ๋งค์๋ ์ผ ์ ์ ์ฅ์ ๋ด์ ๋ฐ์ดํฐ๋ฅผ ์กฐํํ๋ ์ญํ ์ ํ๊ณ ์๋ค.
package org.websocket.demo.repo;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Repository;
import org.websocket.demo.model.ChatRoom;
import java.util.*;
// ์ฑํ
๋ฐฉ์ ์์ฑํ๊ณ ํน์ ์ฑํ
๋ฐฉ ์ ๋ณด๋ฅผ ์กฐํํ๋ Repository
// ์์ฑ๋ ์ฑํ
๋ฐฉ์ ์ด๊ธฐํ ๋์ง ์๋๋ก ์์ฑ ์ Redis Hash์ ์ ์ฅํ๋๋ก ์ฒ๋ฆฌ
// ๋ฐฉ ์ ๋ณด๋ฅผ ์กฐํํ ๋๋ Redis Hash์ ์ ์ฅ๋ ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ค๋๋ก ๋ฉ์๋ ๋ด์ฉ์ ์์
// ์ฑํ
๋ฐฉ ์
์ฅ ์์๋ ์ฑํ
๋ฐฉ ID๋ก Redis Topic์ ์กฐํํ์ฌ pub/sub ๋ฉ์ธ์ง ๋ฆฌ์ค๋์ ์ฐ๋
@RequiredArgsConstructor
@Repository
public class ChatRoomRepository {
// ์ฑํ
๋ฐฉ์ ๋ฐํ๋๋ ๋ฉ์ธ์ง๋ฅผ ์ฒ๋ฆฌํ Listener
private final RedisMessageListenerContainer redisMessageListener;
// Redis ์์ ์ฑํ
๋ฐฉ ์ ์ฅ์ ์ด๋ฆ์ CHAT_ROOMS์ผ๋ก ํ๊ฒ ๋ค๋ ์๋ฏธ
private static final String CHAT_ROOMS = "CHAT_ROOM";
// Redis์ ChatRoom ์ ์ฅ์์ CRUD๋ฅผ ์งํํ๊ธฐ ์ํจ.
private final RedisTemplate<String, Object> redisTemplate;
// chatRoom์ด๋ ์ด๋ฆ์ HashMap์ <K: ๋ฐฉ ๋ฒํธ, V: ์ฑํ
๋ฐฉ ๊ฐ์ฒด> ํํ๋ก ์ ์ฅ
private HashOperations<String, String, ChatRoom> opsHashChatRoom;
@PostConstruct
private void init() {
opsHashChatRoom = redisTemplate.opsForHash();
}
public List<ChatRoom> findAllRoom() {
return opsHashChatRoom.values(CHAT_ROOMS);
}
public ChatRoom findRoomById (String id) {
return opsHashChatRoom.get(CHAT_ROOMS, id);
}
// ์ฑํ
๋ฐฉ ์์ฑ: ์๋ฒ ๊ฐ ์ฑํ
๋ฐฉ ๊ณต์ ๋ฅผ ์ํด redis hash์ ์ ์ฅํ๋ค.
public ChatRoom createChatRoom(String name) {
// ์ฑํ
๋ฐฉ์ ๋ง๋ค๊ณ
ChatRoom chatRoom = ChatRoom.create(name);
//Redis Hash์ ์ ์ฅ
opsHashChatRoom.put(CHAT_ROOMS, chatRoom.getRoomId(), chatRoom);
// ๊ทธ ํ ๋ง๋ ChatRoom์ ๋ฐํ
return chatRoom;
}
}
โ JwtTokenProvider
ํด๋น ํด๋์ค๊ฐ ์ด๋ฒ ํฌ์คํ ์ ํต์ฌ์ด๋ค.
์ด๋ฆ์ ์ํธํํ์ฌ ํ ํฐ ์์ฑ ๋ฐ ์ํธํ, ์ํธํ๋ ํ ํฐ์ ๋ณตํธํํ์ฌ ๋ค์ ์ด๋ฆ ์ป๊ธฐ, ํ ํฐ ์ ํจ์ฑ ๊ฒ์ฆ ๋ก์ง ๋ฑ์ด ์กด์ฌํ๋ค.
package org.websocket.demo.service;
import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
@Slf4j
@Component
public class JwtTokenProvider {
@Value("${spring.jwt.secret}")
private String secretKey;
// ํ ํฐ์ ์ ํจ๊ธฐ๊ฐ
private long tokenValidMilisecond = 1000L*60*60;
//1) ์ด๋ฆ์ผ๋ก JWT TOKEN์ ์์ฑํ๋ค.
public String generateToken(String name) {
Date now = new Date();
return Jwts.builder()
.setId(name)
.setIssuedAt(now) // ๋ฐํ ์ผ์
.setExpiration(new Date(now.getTime() + tokenValidMilisecond)) // ํ ํฐ์ ์๋ช
.signWith(SignatureAlgorithm.HS256, secretKey) // ์ํธํ๊ฐ -> secret๊ฐ์ ๋ฐ๊ฟ์ ์ธํ
.compact();
}
//2) Jwt Token์ ๋ณตํธํ ํ์ฌ ์ด๋ฆ์ ์ป๋๋ค.
public String getUserNameFromJwt(String jwt) {
return getClaims(jwt).getBody().getId();
}
//3) Jwt Token ์ ํจ์ฑ ์ฒดํฌ
public boolean validateToken(String jwt) {
return this.getClaims(jwt) != null;
}
//4) ์ ํจ์ฑ ๊ฒ์ฆ์ ํ๋ ์ค์ง์ ์ธ ๋ก์ง -> ์ธ๋ถ ์ ๊ทผ ๋ชปํ๊ฒ private๋ก ๋ง์
private Jws<Claims> getClaims(String jwt) {
try {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt);
}catch (SignatureException ex){
log.error("Invalid JWT signature");
throw ex;
}catch (MalformedJwtException ex) {
log.error("Invalid JWT token");
throw ex;
}catch (ExpiredJwtException ex) {
log.error("Expired JWT token");
throw ex;
}catch (UnsupportedJwtException ex) {
log.error("Unsupported JWT token");
throw ex;
} catch (IllegalArgumentException ex) {
log.error("JWT claims string is empty. ");
throw ex;
}
}
}
SecretKey๋ ์ํธํ๋ Token์ ๋ณตํธํํ ๋ ์ฌ์ฉํ Key๊ฐ์ด๋ค.
return Jwts.builder()
.setId(name)
.setIssuedAt(now) // ๋ฐํ ์ผ์
.setExpiration(new Date(now.getTime() + tokenValidMilisecond)) // ํ ํฐ์ ์๋ช
.signWith(SignatureAlgorithm.HS256, secretKey) // ์ํธํ๊ฐ -> secret๊ฐ์ ๋ฐ๊ฟ์ ์ธํ
.compact();
๋ก์ง์ ๋ณด๋ฉด, HS256์ผ๋ก ์ํธํ ์์, ๊ทธ๊ฑธ ํ ์ ์๋ ํค๋ก secretKey๋ฅผ ์ค์ ํ๊ณ ์์์ ๋ณผ ์ ์๋ค.
Claim์ด๋ ๋ฌด์์ธ๊ฐ์?
Claim์ด๋ Token์ ๋ด๊ธด ์ค์ ๋ด์ฉ์ด๋ค ์ฆ, Token์ ๋ด๊ธด ์ํฐํฐ์ ๋ฉํ ๋ฐ์ดํฐ์ ๋ํ ๋ช ์ธ์ ์ด๋ค. ๋ฐ๋ผ์ Token์ ๋ฐ๋ ๊ฐ๋ ์ผ๋ก Token์ ๋ณตํธํํ ์์, Claim์ด ๋์จ๋ค.
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt);
ํด๋น ๋ถ๋ถ์ jwt๊ฐ ์ธ์๋ก ๋ฐ์ ํ ํฐ์ผ ๋, ์ด๋ฅผ ๊ฒ์ฆํ๋ ๋ก์ง์ธ๋ฐ, JWS์ JWT์ ๋ํด์ ๋์ค์ ๋ค์ ์์ธํ ๊ณต๋ถํด๋ด์ผ๊ฒ ๋ค. ์ผ๋จ ํด๋น ๋ถ๋ถ์ ํ๋ฆ์ ์ดํดํ์ผ๋ ๋์ด๊ฐ๊ฒ ๋ค.
4. ํ๋ก์ ํธ๋ฅผ ์งํํ๋ฉฐ ๊ฒช์๋ ์ค๋ฅ๋ค
๋ฐ์ธ๋ฉ ์ค๋ฅ: https://dalcheonroadhead.tistory.com/376