Stomp를 이용하여 채팅 및 item 사용하기

김영민·2022년 4월 13일
2


Tech Stack : Java, Spring, WebSocket, Stomp, Redis

1. Stomp로 채팅 구현하기

(1) WebSocketConfig

@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {        
        registry.enableSimpleBroker("/sub");
        registry.setApplicationDestinationPrefixes("/pub");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }
}

@EnableWebsocketMessageBroker : webSocket을 사용한다고 설정하기

configureMessageBroker() : subscribe와 publish할 때 detination prefix .enableSimpleBroker, .setApplicationDestinationPrefixes 를 설정

registerStompEndpoints() : 처음 webSocket에 접속할 때 HandShake와 통신을 담당할 엔드포인트를 .addEndpoint("/ws-stomp") 지정
.setAllowedOriginPatterns : 출처는 *(모두 접근 가능)로 지정
.withSockJS : WebSocket을 지원하지 않는 브라우저의 경우 SockJS를 통해 다른 방식으로 채팅이 이뤄질 수 있게 구현

configureClientInboundChannel() : StompHandler 가 Websocket 앞단에서 token 을 체크할 수 있도록 인터셉터로 설정

(2) StompHandler

StompHandler에서 ChannelInterceptor 인터페이스를 구현하여 user 인증과정을 거침

public class StompHandler implements ChannelInterceptor {

	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
		String sessionId = (String) message.getHeaders().get("simpSessionId");

		if (StompCommand.CONNECT == accessor.getCommand()) {
			String token = accessor.getFirstNativeHeader("Authorization").substring(7);
			if(jwtDecoder.decodeUserId(token) == null) {
				throw new LoginUserNotFoundException("로그인을 해주시기 바랍니다.");
          }
      }

		else if (StompCommand.SUBSCRIBE == accessor.getCommand()) {
			String token = accessor.getFirstNativeHeader("Authorization").substring(7);
			Long userId = Long.parseLong(jwtDecoder.decodeUserId(token));
			User user = userRepository.findById(userId)
				.orElseThrow(() -> new UserNotFoundException("해당 유저가 존재하지 않습니다."));
			ChatRoomUser chatRoomOldUser = chatRoomUserRepository.findByUser_Id(userId);

			String roomId = chatMessageService.getRoomId(
				Optional.ofNullable((String) message.getHeaders().get("simpDestination"))
				.orElse("InvalidRoomId"));
			ChatRoom chatRoom = chatRoomRepository.findById(Long.valueOf(roomId))
				.orElseThrow(() -> new ChatRoomNotFoundException("해당 토론방이 존재하지 않습니다."));
			if (chatRoomOldUser == null) {
				ChatRoomUser chatRoomUser = new ChatRoomUser(chatRoom, user);
				chatRoomUserRepository.save(chatRoomUser);
			} else throw new DuplicateChatRoomUserException("이미 다른 토론방에 있습니다.");

			redisRepository.setSessionRoomId(sessionId, roomId);
      }

		else if (StompCommand.DISCONNECT == accessor.getCommand()) {
			String rawToken = Optional.ofNullable(accessor.getFirstNativeHeader("Authorization"))
            	.orElse("unknownUser");
			if(!rawToken.equals("unknownUser")) {
				String token = rawToken.substring(7);
				Long userId = Long.parseLong(jwtDecoder.decodeUserId(token));
				chatRoomUserRepository.deleteByUser_Id(userId);
				String roomId = redisRepository.getSessionRoomId(sessionId);
				chatMessageService.accessChatMessage(
                	ChatMessageRequestDto.builder().type(ChatMessage.MessageType.EXIT)
                    .roomId(roomId).userId(userId).build());

				redisRepository.removeSessionRoomId(sessionId);
          }
      }
      return message;
	}
  
	private String getRoomId(String destination) {
		int lastIndex = destination.lastIndexOf('/');
        if (lastIndex != -1) {
            return destination.substring(lastIndex + 1);
        } else {
            return null;
        }
    }

    private void saveChatRoomUser(User user, ChatRoomUser chatRoomOldUser, ChatRoom chatRoom) {
        if (chatRoomOldUser == null) {
            ChatRoomUser chatRoomUser = new ChatRoomUser(chatRoom, user);
            chatRoomUserRepository.save(chatRoomUser);
        } else throw new DuplicateChatRoomUserException("이미 다른 토론방에 있습니다.");
    }
}

StompCommand.CONNECT : command로 connect를 시도할 때 로그인 여부 확인

StompCommand.SUBSCRIBE : 구독 요청이 들어왔을 때 user가 일치하는지 확인하고 일치한다면 채팅방 목록에 추가함.
setSessionRoomId() : Redis를 이용하여 user와 채팅방을 매핑시킴

StompCommand.DISCONNECT : disconnect 신호가 들어오면 user를 확인 후 채팅방 목록에서 제외시킴
removeSessionRoomId() : 매핑되었던 user와 채팅방을 제거

(3) RedisRepository

public class RedisRepository {

    public static final String ENTER_INFO = "ENTER_INFO";

    @Resource(name = "redisTemplate")
    private HashOperations<String, String, String> stringHashOpsEnterInfo;

    public void setSessionRoomId(String sessionId, String roomId) {
        stringHashOpsEnterInfo.put(ENTER_INFO, sessionId, roomId);
    }

    public String getSessionRoomId(String sessionId) {
        return stringHashOpsEnterInfo.get(ENTER_INFO, sessionId);
    }

    public void removeSessionRoomId(String sessionId) {
        stringHashOpsEnterInfo.delete(ENTER_INFO, sessionId);
    }
}

HashOperations<> : 해쉬에서 동작하는 특정 매핑 작업
setSessionRoomId() : user와 채팅방을 매핑시켜줌
getSessionRoomId() : user와 매핑시킨 채팅방 Id를 조회
removeSessionRoomId() : 매핑시켰던 user와 채팅방을 제거함

(4) ChatMessageController

public class ChatMessageController {

    private final ChatMessageService chatMessageService;
    private final JwtDecoder jwtDecoder;
    
    @MessageMapping("/chat/enter")
    @ApiOperation(value = "채팅방 입장")
    public void enterMessage(@RequestBody ChatMessageRequestDto chatMessageRequestDto, 
    						 @Header("Authorization") String rawToken) {
        String token = rawToken.substring(7);
        Long userId = Long.parseLong(jwtDecoder.decodeUserId(token));
        chatMessageRequestDto.setUserId(userId);
        chatMessageService.accessChatMessage(chatMessageRequestDto);
    }

    @MessageMapping("/chat/message")
    @ApiOperation(value = "채팅 메세지 수신")
    public void message(@RequestBody ChatMessageRequestDto chatMessageRequestDto, 
    					@Header("Authorization") String rawToken) 
                        throws IOException, NoSuchAlgorithmException {
        String token = rawToken.substring(7);
        Long userId = Long.parseLong(jwtDecoder.decodeUserId(token));
        chatMessageRequestDto.setUserId(userId);

        if (chatMessageRequestDto.getType().equals(ChatMessage.MessageType.TALK)) {
            ChatMessage chatMessage = chatMessageService.save(chatMessageRequestDto);
            chatMessageService.sendChatMessage(chatMessage, chatMessageRequestDto);
        } else if (chatMessageRequestDto.getType().equals(ChatMessage.MessageType.ITEM)) {
            chatMessageService.itemChatMessage(chatMessageRequestDto);
        }
    }
}

MessageMapping : publish 할 때 최종 destination 설정
enterMessage() : user가 채팅방에 입장했을 때 보내는 입장 메세지

message() : user가 채팅방에서 채팅할 때 보내는 채팅 메세지
MessageType.TALK : 메세지 타입이 TALK라면 일반 채팅 메세지
MessageType.ITEM : 메세지 타입이 ITEM이라면 채팅 중 아이템 사용

(5) ChatMessageService

public class ChatMessageService {

    private final ChannelTopic channelTopic;
    private final RedisTemplate redisTemplate;
    private final ChatItemService chatItemService;
    private final UserRepository userRepository;
    private final ChatMessageRepository chatMessageRepository;
    private final ChatRoomRepository chatRoomRepository;

	// 메세지 저장
    @Transactional
    public ChatMessage save(ChatMessageRequestDto chatMessageRequestDto) throws IOException, NoSuchAlgorithmException {

        configDate(chatMessageRequestDto);
        User user = userRepository.findById(chatMessageRequestDto.getUserId())
                .orElseThrow(() -> new UsernameNotFoundException("해당 유저가 존재하지 않습니다."));
        chatItemService.chatItem(chatMessageRequestDto, user);

        ChatRoom chatRoom = chatRoomRepository.findById(Long.valueOf(chatMessageRequestDto.getRoomId()))
                .orElseThrow(() -> new ChatRoomNotFoundException("채팅방이 존재하지 않습니다."));

        ChatMessage chatMessage = new ChatMessage(chatMessageRequestDto, user, chatRoom);
        return chatMessageRepository.save(chatMessage);
    }

    // 채팅방 입출입 시 메시지 발송
    @Transactional
    public void accessChatMessage(ChatMessageRequestDto chatMessageRequestDto) {
        User user = userRepository.findById(chatMessageRequestDto.getUserId())
                .orElseThrow(() -> new LoginUserNotFoundException("로그인 후 이용해 주시기 바랍니다."));
        ChatRoom chatRoom = chatRoomRepository.findById(Long.valueOf(chatMessageRequestDto.getRoomId()))
                .orElseThrow(() -> new ChatRoomNotFoundException("채팅방이 존재하지 않습니다."));

        if (ChatMessage.MessageType.ENTER.equals(chatMessageRequestDto.getType())) {
            chatMessageRequestDto.setMessage(user.getNickname() + "님이 방에 입장했습니다.");
            chatRoom.setUserCnt(chatRoom.getChatRoomUser().size());
            chatRoomRepository.save(chatRoom);
            chatItemService.enterItem(chatMessageRequestDto);
            ChatMessageEnterResponseDto chatMessageEnterResponseDto = new ChatMessageEnterResponseDto(chatMessageRequestDto, user);
            redisTemplate.convertAndSend(channelTopic.getTopic(), chatMessageEnterResponseDto);
        } else if (ChatMessage.MessageType.EXIT.equals(chatMessageRequestDto.getType())) {
            chatMessageRequestDto.setMessage(user.getNickname() + "님이 방에서 나갔습니다.");

            if (chatRoom.getChatRoomUser() != null) {
                chatRoom.setUserCnt(chatRoom.getChatRoomUser().size());
            } else {
                chatRoom.setUserCnt(0);
            }

            chatRoomRepository.save(chatRoom);
            ChatMessageExitResponseDto chatMessageExitResponseDto = new ChatMessageExitResponseDto(chatMessageRequestDto, user);
            redisTemplate.convertAndSend(channelTopic.getTopic(), chatMessageExitResponseDto);
        }
    }

    // 채팅방에서 메세지 발송
    @Transactional
    public void sendChatMessage(ChatMessage chatMessage, ChatMessageRequestDto chatMessageRequestDto) {
        Boolean bigFont = chatMessageRequestDto.getBigFont();
        ChatMessageResponseDto chatMessageResponseDto = new ChatMessageResponseDto(chatMessage, bigFont);
        redisTemplate.convertAndSend(channelTopic.getTopic(), chatMessageResponseDto);
    }

	// 메세지 생성 시간 저장
    private void configDate(ChatMessageRequestDto chatMessageRequestDto) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        Calendar cal = Calendar.getInstance();
        Date date = cal.getTime();
        sdf.setTimeZone(TimeZone.getTimeZone("Asia/Seoul"));
        String dateResult = sdf.format(date);
        chatMessageRequestDto.setCreatedAt(dateResult);
    }
}

save() : 채팅 메세지 저장하기

accessChatMessage : 채팅방 입출입 시 메세지 보내기
MessageType.ENTER : 채팅방 입장할 때 type을 ENTER로 설정 후 입장 메세지 전송
MessageType.EXIT : 채팅방 퇴장할 때 type을 EXIT로 설정 후 퇴장 메세지 전송

sendChatMessage : 채팅방에서 채팅하기

redisTemplate.convertAndSend() : Message Broker로 Redis를 설정, 설정된 topic으로 등록한 Dto 전송

⭐ MessageBroker로 Redis를 설정한 이유
: 외부 Broker를 사용한 이유는 Spring 내부 Broker인 SimpleBroker는 이용자 수가 많아졌을 시 기존 비지니스 로직과 채팅에 대한 부담을 하나의 서버가 맡기 때문에 외부 Broker를 사용
: 외부 Broker로 Redis를 선택한 이유는 구현한 채팅방 지속시간이 길어야 1시간이기 때문에 지속성이 중요하지 않아서 짧은 채팅에 적합하고 메모리에서 Cache를 가져다 쓴다는 점에서 속도가 빠른 Redis를 선택

(6) ChatItemService

public class ChatItemService {

    private final ChannelTopic channelTopic;
    private final RedisTemplate redisTemplate;
    private final UserRepository userRepository;
    private final ChatMessageItemRepository chatMessageItemRepository;


    // 채팅방에서 아이템 사용
    @Transactional
    public void itemChatMessage(ChatMessageRequestDto chatMessageRequestDto) {
        User user = userRepository.findById(chatMessageRequestDto.getUserId())
                .orElseThrow(IllegalAccessError::new);
        String nickname = user.getNickname();
        String item = chatMessageRequestDto.getItem();

        switch (item) {
            case "onlyMe":
                chatMessageRequestDto.setOnlyMe(nickname);
                chatMessageRequestDto.setMessage(nickname + "님이 나만 말하기를 사용하셨습니다.");
                break;
            case "myName":
                chatMessageRequestDto.setMyName(nickname);
                chatMessageRequestDto.setMessage(nickname + "님이 내이름으로를 사용하셨습니다.");
                break;
            case "papago":
                chatMessageRequestDto.setPapago(nickname);
                chatMessageRequestDto.setMessage(nickname + "님이 파파고를 사용하셨습니다.");
                break;
            case "reverse":
                chatMessageRequestDto.setReverse(nickname);
                chatMessageRequestDto.setMessage(nickname + "님이 로꾸꺼를 사용하셨습니다.");
                break;
        }
        ChatMessageItem chatMessageItem = new ChatMessageItem(chatMessageRequestDto);
        chatMessageItemRepository.save(chatMessageItem);
        ChatMessageItemResponseDto chatMessageItemResponseDto = new ChatMessageItemResponseDto(chatMessageItem);
        redisTemplate.convertAndSend(channelTopic.getTopic(), chatMessageItemResponseDto);
    }

    // 아이템 사용시간 지난 후 아이템 삭제
    @Transactional
    public void itemDeleteMessage(String roomId, String item) {
        ChatMessageItemResponseDto chatMessageItemResponseDto = new ChatMessageItemResponseDto();
        chatMessageItemResponseDto.setRoomId(roomId);
        chatMessageItemResponseDto.setItem(null);
        chatMessageItemResponseDto.setType(ChatMessage.MessageType.ITEMTIMEOUT);

        switch (item) {
            case "onlyMe":
                chatMessageItemResponseDto.setMessage("나만 말하기 사용시간이 완료되었습니다.");
                break;
            case "myName":
                chatMessageItemResponseDto.setMessage("내 이름으로 사용시간이 완료되었습니다.");
                break;
            case "papago":
                chatMessageItemResponseDto.setMessage("파파고 사용시간이 완료되었습니다.");
                break;
            case "reverse":
                chatMessageItemResponseDto.setMessage("로꾸꺼 사용시간이 완료되었습니다.");
                break;
        }
        chatMessageItemResponseDto.setOnlyMe(null);
        chatMessageItemResponseDto.setMyName(null);
        chatMessageItemResponseDto.setPapago(null);
        chatMessageItemResponseDto.setReverse(null);
        redisTemplate.convertAndSend(channelTopic.getTopic(), chatMessageItemResponseDto);
    }

	// 사용한 아이템을 채팅에 적용
    public void chatItem(ChatMessageRequestDto chatMessageRequestDto, User user) throws IOException, NoSuchAlgorithmException {
        String nickname = user.getNickname();
        if (chatMessageRequestDto.getPapago() != null) {
            if (!nickname.equals(chatMessageRequestDto.getPapago())) {
                String message = ItemService.papago(chatMessageRequestDto.getMessage());
                chatMessageRequestDto.setMessage(message);
            }
        } else if (chatMessageRequestDto.getReverse() != null) {
            if (!nickname.equals(chatMessageRequestDto.getReverse())) {
                String message = ItemService.reverseWord(chatMessageRequestDto.getMessage());
                chatMessageRequestDto.setMessage(message);
            }
        }
    }

	// 입장할 때 아이템 적용
    public void enterItem(ChatMessageRequestDto chatMessageRequestDto) {
        ChatMessageItem chatMessageItem = chatMessageItemRepository.findByRoomId(chatMessageRequestDto.getRoomId());
        if (chatMessageItem != null) {
            User itemUser = userRepository.findById(chatMessageItem.getUserId())
                    .orElseThrow(() -> new LoginUserNotFoundException("로그인 후 이용해 주시기 바랍니다."));
            String item = chatMessageItem.getItem();
            switch (item) {
                case "onlyMe":
                    chatMessageRequestDto.setOnlyMe(itemUser.getNickname());
                    break;
                case "myName":
                    chatMessageRequestDto.setMyName(itemUser.getNickname());
                    break;
                case "papago":
                    chatMessageRequestDto.setPapago(itemUser.getNickname());
                    break;
                case "reverse":
                    chatMessageRequestDto.setReverse(itemUser.getNickname());
                    break;
            }
        }
    }
}

itemChatMessage() : 아이템을 사용했을 시 아이템을 사용했다는 메세지 전송
itemDeleteMessage() : 아이템 사용시간이 끝났을 때 시간 종료됐음을 알려주는 메세지 전송

chatItem() : 사용한 아이템을 채팅 메세지에 적용시키기
getPapago() : 파파고 아이템을 쓰면 파파고 api를 이용하여 채팅 메세지가 랜덤언어로 번역되게 구현
getReverse() : 리버스 아이템을 쓰면 채팅 메세지가 순서가 뒤집어져서 나옴

enterItem() : 입장한 채팅방이 아이템을 사용중이면 방금 입장한 user에게도 아이템이 적용될 수 있게 구현

(7) RedisConfig

public class RedisConfig {

    @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;
    }

    @Bean
    public ChannelTopic channelTopic() {
        return new ChannelTopic("chatroom");
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListener(RedisConnectionFactory connectionFactory,
                                                              MessageListenerAdapter listenerAdapter,
                                                              ChannelTopic channelTopic) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listenerAdapter, channelTopic);
        return container;
    }

    @Bean
    public MessageListenerAdapter listenerAdapter(RedisSubscriber subscriber) {
        return new MessageListenerAdapter(subscriber, "sendMessage");
    }
}

RedisTemplate : 특정 Entity 뿐만 아니라 여러가지 원하는 타입을 넣기 위해 설정
connectionFactory : Redis에 connection 해줌
setKeySerializer , setValueSerializer : redis-cli 상에서 key값이 깨지지 않고 제대로 보이게 함

channelTopic() : 단일 topic 사용을 위한 Bean 설정

redisMessageListener : Redis에서 publish한 메세지 처리를 위한 listener 설정

listenerAdapter : 실제 메세지를 처리하는 subscriber 추가

(8) RedisSubscriber

public class RedisSubscriber {

    private final ObjectMapper objectMapper;
    private final SimpMessageSendingOperations messagingTemplate;

    public void sendMessage(String publishMessage) {
        try {
            if (publishMessage.startsWith("ENTER", 9)) {
                ChatMessageEnterResponseDto chatMessageEnterResponseDto = objectMapper.readValue(publishMessage, ChatMessageEnterResponseDto.class);
                messagingTemplate.convertAndSend("/sub/chat/" + chatMessageEnterResponseDto.getRoomId(), chatMessageEnterResponseDto);
            }

            else if (publishMessage.startsWith("EXIT", 9)) {
                ChatMessageExitResponseDto chatMessageExitResponseDto = objectMapper.readValue(publishMessage, ChatMessageExitResponseDto.class);
                messagingTemplate.convertAndSend("/sub/chat/" + chatMessageExitResponseDto.getRoomId(), chatMessageExitResponseDto);
            }
            
            else if(publishMessage.startsWith("TALK", 9)){
                ChatMessageResponseDto chatMessageResponseDto = objectMapper.readValue(publishMessage, ChatMessageResponseDto.class);
                messagingTemplate.convertAndSend("/sub/chat/" + chatMessageResponseDto.getRoomId(), chatMessageResponseDto);
            }

            else if(publishMessage.startsWith("ITEM", 9)){
                ChatMessageItemResponseDto chatMessageItemResponseDto = objectMapper.readValue(publishMessage, ChatMessageItemResponseDto.class);
                messagingTemplate.convertAndSend("/sub/chat/" + chatMessageItemResponseDto.getRoomId(), chatMessageItemResponseDto);
            }

            else if(publishMessage.startsWith("ITEMTIMEOUT", 9)){
                ChatMessageItemResponseDto chatMessageItemResponseDto = objectMapper.readValue(publishMessage, ChatMessageItemResponseDto.class);
                messagingTemplate.convertAndSend("/sub/chat/" + chatMessageItemResponseDto.getRoomId(), chatMessageItemResponseDto);
            }

        } catch (Exception e) {
            log.error("Exception = {}", e);
        }
    }
}

sendMessage : 각 메세지의 MessageType에 맞게 subscribe하고 있는 destination으로 메세지를 전송
messageTemplate : 서버에서 전송 시 UserDestinationMessageHandler를 통해서 설정한 destination으로 형태를 변형하여 전송

profile
Macro Developer

0개의 댓글