WebSocket 채팅 (4)

보트·2023년 9월 22일
0

채팅

목록 보기
5/7

로컬 설정

프로젝트 환경

  • embeded redis
    • build.gradle
      // redis
      implementation 'org.springframework.boot:spring-boot-starter-data-redis'
      // embedded-redis
      compileOnly group: 'it.ozimov', name: 'embedded-redis', version: '0.7.2'
  • redis 설정
    • application.properties
      spring.profiles.active=local
      spring.data.redis.port=6379
      spring.data.redis.host=localhost
  • 다른 환경에서 테스트가 필요할 경우
    • application-alpha.yml
      • alpha 서버용 환경 설정 파일
        spring:
          profiles:
            active: alpha
          redis:
            host: redis가 설치된 서버 호스트
            port: redis가 설치된 서버 포트

기능

Redis (Embedded Redis)

  • Redis에는 공통 주제(Topic)에 대하여 구독자(Subscriber)에게 메세지를 발행(Publish) 하는 기능이 있음
    • topic - 채팅방
    • pub/ sub - 메세지 보내기, 메세지 받기
  • EmbeddedRedisConfig.java
    • @PostConstruct, @PreDestroy - 채팅 서버가 실행될 때 Embedded Redis 서버도 동시에 실행될 수 있도록

    • @Profile(”local”) - local 환경에서만 실행되도록

      @Profile("local")
      @Configuration
      public class EmbeddedRedisConfig {
      
          @Value("${spring.redis.port}")
          private int redisPort;
      
          private RedisServer redisServer;
      
          @PostConstruct
          public void redisServer() {
              redisServer = new RedisServer(redisPort);
              redisServer.start();
          }
      
          @PreDestroy
          public void stopRedis() {
              if(redisServer != null) {
                  redisServer.stop();
              }
          }
      }
  • RedisConfig.java
    • MessageListener 추가 - Redis의 pub/sub 이용

    • RedisTemplate 설정 - 어플리케이션에서 redis 사용

      @Configuration
      public class RedisConfig {
      
          /*
              redis pub/sub 메세지를 처리하는 listener 설정
           */
          @Bean
          public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
              RedisMessageListenerContainer container = new RedisMessageListenerContainer();
              container.setConnectionFactory(connectionFactory);
              return container;
          }
          
          /*
              어플리케이션에서 사용할 redisTemplate 설정
           */
          @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;
          }
      }
  • RedisPublisher.java
    • 채팅방에 입장 후 메세지 작성

      → 메세지를 Redis Topic에 발행
      @RequiredArgsConstructor
      @Service
      public class RedisPublisher {
      
          private final RedisTemplate<String, Object> redisTemplate;
      
          /*
              채팅방에 입장 후 메세지 작성
              -> 해당 메세지 Redis Topic에 발행
              -> 대기하고 있던 redis 구독 서비스가 메세지 처리
           */
          public void publish(ChannelTopic topic, ChatMessage message) {
              redisTemplate.convertAndSend(topic.getTopic(), message);
          }
      }
  • RedisSubscriber.java
    • Redis에 메세지 발행이 될 때까지 대기

    • 발행되면 해당 메세지 읽어 처리
      - Redis 발행 메세지 → ChatMessage 변환 → messagingTemplate으로 채팅방의 모든 클라이언트에게 메세지 전달

      @Slf4j(topic = "RedisSubscriber")
      @RequiredArgsConstructor
      @Service
      public class RedisSubscriber implements MessageListener {
      
          private final ObjectMapper objectMapper;
          private final RedisTemplate redisTemplate;
          private final SimpMessageSendingOperations messagingTemplate;
      
          /*
              Redis에서 메세지가 발행(publish)
              -> 대기하고있던 onMessage() 가 해당 메세지 처리
           */
          @Override
          public void onMessage(Message message, byte[] pattern) {
              try {
                  // redis 에서 발행된 데이터 받아 deserialize
                  String publishMessage = (String) redisTemplate.getStringSerializer().deserialize(message.getBody());
                  // ChatMessage 객체로 매핑
                  ChatMessage roomMessage = objectMapper.readValue(publishMessage, ChatMessage.class);
                  // Websocket 구독자에게 ChatMessage 발송
                  messagingTemplate.convertAndSend("/sub/chat/room/" + roomMessage.getRoomId(), roomMessage);
              } catch (Exception e) {
                  log.error(e.getMessage());
              }
      
          }
      }

Redis

  • Embedded Redis가 아닌 Redis 사용
  • RedisConfig.java
    @Configuration
    public class RedisConfig {
    
        @Value("${redis.host}")
        private String redisHost;
    
        @Value("${redis.port}")
        private int redisPort;
    
        // Redis 저장소와 연결
        @Bean
        public RedisConnectionFactory connectionFactory() {
            return new LettuceConnectionFactory(redisHost, redisPort);
        }
    
        /*
            redis pub/sub 메세지를 처리하는 listener 설정
         */
        @Bean
        public RedisMessageListenerContainer redisMessageListenerContainer() {
            RedisMessageListenerContainer container = new RedisMessageListenerContainer();
            container.setConnectionFactory(connectionFactory());
            return container;
        }
    
        /*
            어플리케이션에서 사용할 redisTemplate 설정
         */
        @Bean
        public RedisTemplate<String, Object> redisTemplate() {
            RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
            redisTemplate.setConnectionFactory(connectionFactory());
            // RedisTemplate을 사용할 때 Spring-Redis 간 데이터 직렬화/역직렬화 시 사용하는 방식이 jdk 직렬화 방식
            // 동작에는 문제가 없지만 redis-cli를 통해 데이터를 확인할 때 알아볼 수 없는 형태로 출력되기 때문
            redisTemplate.setKeySerializer(new StringRedisSerializer());
            redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
            return redisTemplate;
        }
    }

Chat

  • ChatController.java
    • enterChatRoom() - 클라이언트 입장 시 채팅방(topic)에서 대화 가능하도록 리스너 연동
      - 채팅방에 발행된 메세지 → 서로 다른 서버에 공유하기 위해 redis의 topic으로 발행

      @RequiredArgsConstructor
      @Controller
      @Slf4j(topic = "ChatController")
      public class ChatController {
      
          private final RedisPublisher redisPublisher;
          private final ChatRoomRepository chatRoomRepository;
      
          /*
              Websocket "/pub/chat/message"로 들어오는 메세지 처리
           */
          @MessageMapping("/chat/message")
          public void message(ChatMessage message) {
              // 입장 메세지일 경우
              if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
                  chatRoomRepository.enterChatRoom(message.getRoomId());
                  message.setMessage(message.getSender() + "님이 입장하셨습니다.");
              }
      
              // Websocket 에 발행된 메세지 redis 로 발행 (publish)
              redisPublisher.publish(chatRoomRepository.getTopic(message.getRoomId()), message);
          }
      }
  • ChatRoomRepository.java
    • 채팅방 정보가 초기화되지 않도록 생성시 Redis Hash에 저장

      @RequiredArgsConstructor
      @Repository
      public class ChatRoomRepository {
      
          // 채팅방 (topic)에 발행되는 메세지 처리할 listener
          private final RedisMessageListenerContainer redisMessageListener;
          // 구독 처리 서비스
          private final RedisSubscriber redisSubscriber;
          // Redis
          private static final String CHAT_ROOMS = "CHAT_ROOM";
          private final RedisTemplate<String, Object> redisTemplate;
          private HashOperations<String, String, ChatRoom> opsHashChatRoom;
          // 채팅방의 대화 메세지를 발행하기 위한 redis topic 정보
          // 서버별로 채팅방에 매치되는 topic 정보를 Map에 넣어 roomId로 찾을 수 있도록
          private Map<String, ChannelTopic> topics;
      
          @PostConstruct
          private void init() {
              opsHashChatRoom = redisTemplate.opsForHash();
              topics = new HashMap<>();
          }
      
          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);
              opsHashChatRoom.put(CHAT_ROOMS, chatRoom.getRoomId(), chatRoom);
              return chatRoom;
          }
      
          /*
              채팅방 입장 : redis 에 topic 을 만들고 pub/sub 통신을 하기 위해 listener 설정
           */
          public void enterChatRoom(String roomId) {
              ChannelTopic topic = topics.get(roomId);
              if(topic == null) {
                  topic = new ChannelTopic(roomId);
                  redisMessageListener.addMessageListener(redisSubscriber, topic);
                  topics.put(roomId, topic);
              }
          }
      
          public ChannelTopic getTopic(String roomId) {
              return topics.get(roomId);
          }
      }
  • ChatRoom.java
    • Serializable
      - Redis에 저장되는 객체들은 Serialize 가능해야함
      - serialVersionUID

      @Getter
      public class ChatRoom implements Serializable {
      
          @Serial
          private static final long serialVersionUID = 6494678977089006639L;
      
          private String roomId;
          private String name;
      
          public static ChatRoom create(String name) {
              ChatRoom chatRoom = new ChatRoom();
              chatRoom.roomId = UUID.randomUUID().toString();
              chatRoom.name = name;
              return chatRoom;
          }
      }

테스트

다중 서버 채팅 테스트

  • 두 개의 서버를 실행시켜 테스트 성공
  • cmd로 실행
    • 프로젝트 폴더로 이동
    • 실행 파일 만들기
      • **gradlew.bat build**
    • 실행 파일이 생성된 위치로 이동
      • **cd build/libs**
    • 포트 번호 새로 설정 후 실행
      • **java -jar -Dserver.port=8090 websocket-prac-0.0.1-SNAPSHOT.jar**
    • 실행 종료 후 빌드 된 파일 삭제
      • **gradlew.bat clean**

  • 포트 지정 실행
    • java -jar
      • java -jar -Dserver.port={포트번호} {파일명.jar}
    • Gradle
      • —server 앞에 공백 필수
      • ./gradlew bootrun —args ‘ —server.port={포트번호}’
    • Maven
      • mvn spring-boot:run -Dspring-boot.run.jvmArgument=’-Dserver.port={포트번호}’
profile
일주일에 한 번

0개의 댓글