Tech Stack : Java, Spring, WebSocket, Stomp, Redis
@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 을 체크할 수 있도록 인터셉터로 설정
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와 채팅방을 제거
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와 채팅방을 제거함
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이라면 채팅 중 아이템 사용
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를 선택
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에게도 아이템이 적용될 수 있게 구현
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 추가
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으로 형태를 변형하여 전송