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
GitHub - dalcheonroadhead/WebSocketBEPractice: 💌웹소켓, 멀티 스레딩, 연습💌
💌웹소켓, 멀티 스레딩, 연습💌 . Contribute to dalcheonroadhead/WebSocketBEPractice development by creating an account on GitHub.
github.com
⑷ 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는 저번과 달라진 것이 없어서 패스
4. 프로젝트 진행하면서 겪었던 오류들
'백엔드 개발 > WebSocket' 카테고리의 다른 글
SpringBoot WebSocket Chatting Server만들기 4단계 - SpringBoot Security 장착 (0) | 2024.01.22 |
---|---|
SpringBoot WebSocket Chatting Server 만들기 3단계 - 여러 채팅 서버 간의 메세지 공유 (by Redis Pub/sub) (0) | 2024.01.20 |
Spring websocket chatting server 만들기 2단계 - 고도화 (0) | 2024.01.14 |
SpringBoot WebSocket chatting server 만들기 1단계 (0) | 2024.01.07 |