본문 바로가기

백엔드 개발/WebSocket

SpringBoot3 WebSocket ChattingServer만들기 5단계 - 채팅방 인원 수 체크, Server Side Event 처리

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. 전개도 

전체 그림

 

Config 단 자세히
service, Repository 단 자세히

원래 배울 때 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. 프로젝트 진행하면서 겪었던 오류들