간단한 메신저를 구현해 보았다.
기존의 api는 http 요청을 보내면 서버는 그 요청에대한 답만 해주면 되지만,
메신저는 내가 다른사람에게 메신저를 보냇을 때 서버가 나에게만 응답을 보내는 것이 아닌, 내가 메신저 보낸 사람들에게 모두 응답을 보내주어야 한다.
기존의 http 프로토콜로는 이를 구현할 수 없기 때문에, 웹 소켓이라는 새로운 프로토콜을 적용하였다.
또한, 웹 소켓은 http처럼 정해진 데이터 양식이 없기 때문에, Stomp라는 프로토콜을 사용하여 데이터 양식을 정의하였다.
1. 관련 라이브러리 추가
//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework:spring-messaging:5.3.6'
2. 웹소켓 관련 설정 추가
@Configuration
@Slf4j
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/subscribe");
registry.setApplicationDestinationPrefixes("/publish");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/socket")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureClientInboundChannel(ChannelRegistration registry) {
registry.interceptors(stompHandler);
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class StompHandler implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
String token = accessor.getFirstNativeHeader("Authorization");
if (!accessor.getCommand().equals(StompCommand.DISCONNECT)) {
if (token != null) {
try {
jwtTokenProvider.getAuthentication(token);
return message;
} catch (Exception e) {
throw new RuntimeException("인증이 잘못되었습니다.");
}
} else {
throw new RuntimeException("Jwt 토큰값이 비었습니다.");
}
}
return message;
}
}
- Endpoints 관련 설정을 통해 /socket이라는 url로 웹소켓 연결을 받으며, 모든 origin 양식을 허용하였으며, IE에서는 웹소켓을 지원하지 않기때문에 이를 가능하게 해주는 sockjs라이브러리를 사용한다는 의미로 withSockjs를 추가해주었다.
- Stomp는 subsribe와 publish라는 개념으로 사용하는데, publish로 들어오는 메세지를 받고, subsribe로 해당하는 유저들에게 메세지를 보내준다.
- configureClientInboundChannel에서 핸들러를 추가하여 JWT 인증을 따로 진행해주었다.
3. ChatService 작성
@Service
@Slf4j
@RequiredArgsConstructor
public class ChatService {
private final UserRepository userRepository;
private final ChatRepository chatRepository;
private final ChatRoomRepository chatRoomRepository;
private final ChatRoomUserRepository chatRoomUserRepository;
private final SimpMessagingTemplate simpMessageTemplate;
@Transactional
public ChatUserListResponseDto getChatUserList(User user) {
List<User> userList = userRepository.findAll();
List<ChatUserDto> chatUserDtoList = new ArrayList<>();
userList.forEach((member) -> {
if(!member.getEmail().equals(user.getEmail())) {
ChatRoomUser chatRoomUser = chatRoomUserRepository.findByUserAndTargetUser(user, member).orElse(null);
Long chatRoomId = null;
ChatRoom chatRoom = new ChatRoom();
String lastMessage = null;
if(chatRoomUser != null) {
chatRoomId = chatRoomUser.getChatRoom().getId();
chatRoom = chatRoomRepository.findById(chatRoomId).orElse(null);
if(chatRoom != null) {
lastMessage = chatRoom.getLastMessage();
}
}
chatUserDtoList.add(
ChatUserDto.builder()
.email(member.getEmail())
.nickname(member.getNickname())
.profileUrl(member.getProfileImgUrl())
.chatRoomId(chatRoomId)
.lastMessage(lastMessage)
.modifiedDate(chatRoom.getModifiedDate())
.build()
);
}
});
return ChatUserListResponseDto.builder().chatRoomList(chatUserDtoList).build();
}
@Transactional
public ChatUserListResponseDto createChatRoom(User user, String target) throws Exception {
User targetUser = userRepository.findByEmail(target).orElseThrow(() -> new UsernameNotFoundException("해당 유저를 찾을 수 없습니다."));
ChatRoomUser userChatRoom = chatRoomUserRepository.findByUserAndTargetUser(user, targetUser).orElse(null);
ChatRoomUser targetChatRoom = chatRoomUserRepository.findByUserAndTargetUser(targetUser, user).orElse(null);
if(userChatRoom == null && targetChatRoom == null) {
ChatRoom chatRoom = chatRoomRepository.save(ChatRoom.builder().user(user).build());
chatRoomUserRepository.save(ChatRoomUser.builder().chatRoom(chatRoom).user(user).targetUser(targetUser).build());
chatRoomUserRepository.save(ChatRoomUser.builder().chatRoom(chatRoom).user(targetUser).targetUser(user).build());
}
return this.getChatUserList(user);
}
@Transactional
public ChatResponseDto getChatList(Long chatRoomId) {
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId).orElseThrow(() -> new EntityNotFoundException());
List<Chat> chatList = chatRepository.findAllByChatRoom(chatRoom);
List<ChatDto> chatDtoList = new ArrayList<>();
chatList.forEach((chat) -> {
chatDtoList.add(ChatDto.toDto(chat));
});
return ChatResponseDto.builder().chatList(chatDtoList).build();
}
@Transactional
public ChatResponseDto saveChat(User user, ChatRequestDto requestDto) {
ChatRoom chatRoom = chatRoomRepository.findById(requestDto.getChatRoomId()).orElseThrow(() -> new EntityNotFoundException());
chatRepository.save(Chat.builder()
.chatRoom(chatRoom)
.user(user)
.content(requestDto.getMessage()).build());
chatRoom.setLastMessage(requestDto.getMessage());
return this.getChatList(requestDto.getChatRoomId());
}
@Transactional
public void sendMessage(ChatRequestDto requestDto) {
log.info(requestDto.toString());
User sendUser = userRepository.findByEmail(requestDto.getSenderEmail()).orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다."));
simpMessageTemplate.convertAndSend("/subscribe/rooms/" + requestDto.getChatRoomId(),
ChatMessage.builder()
.email(sendUser.getEmail())
.content(requestDto.getMessage())
.sendDate(LocalDateTime.now())
.nickname(sendUser.getNickname())
.profileUrl(sendUser.getProfileImgUrl())
.build());
}
}
- getChatUserList는 채팅 유저의 목록을 반환해주는 메서드이다.
- createChatRoom은 채팅방을 개설해주는 메서드이다. 나는 여러명이 함꼐하는 그룹채팅은 구현하지 않았기에 위와같이 작성했지만 매우 비효율적인 것같아 추후에 수정 예정이다.
- getChatList는 해당 채팅방의 채팅 내역들을 반환해주는 메서드이다.
- saveChat은 해당 채팅방에 입력한 채팅을 저장해주는 메서드이다.
- sendMessage는 publish로 받은 메세지를 같은 채팅방을 구독하고 있는 유저들에게 전달해주는 메서드이다.
4. ChatController 작성
@RestController
@Slf4j
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
@GetMapping("/chats/create")
public ResponseEntity<? extends Object> createChatRoom(@CurrentUser User user, @RequestParam(value = "target") String target) throws Exception {
try {
return ResponseEntity.ok().body(chatService.createChatRoom(user, target));
} catch (Exception e) {
return ResponseEntity.badRequest().body("요청에 오류가 발생하였습니다.");
}
}
@GetMapping("/chats/user-list")
public ResponseEntity getChatUserList(@CurrentUser User user) {
try {
return ResponseEntity.ok().body(chatService.getChatUserList(user));
} catch (Exception e) {
return ResponseEntity.badRequest().body("요청에 오류가 발생하였습니다.");
}
}
@MessageMapping("/messages")
public void chat(ChatRequestDto requestDto) {
chatService.sendMessage(requestDto);
}
@GetMapping("/chats/chat-list")
public ResponseEntity getChatList(@RequestParam(value = "chatRoomId", required = false) Long chatRoomId) {
try {
return ResponseEntity.ok().body(chatService.getChatList(chatRoomId));
} catch (Exception e) {
return ResponseEntity.badRequest().body("요청에 오류가 발생하였습니다.");
}
}
@PostMapping("/chats/chat")
public ResponseEntity saveChat(@CurrentUser User user, @RequestBody ChatRequestDto requestDto) {
try {
return ResponseEntity.ok().body(chatService.saveChat(user, requestDto));
} catch (Exception e) {
return ResponseEntity.badRequest().body("요청에 오류가 발생하였습니다.");
}
}
}
- 다른거는 앞의 내용과 비슷하지만, 웹소켓의 메세지를 받기위해 @MessageMapping 어노테이션을 사용하였다. 이를 사용하면 publish뒤의 url에 매칭되게 해준다.
'Web > 인스타 클론 코딩' 카테고리의 다른 글
[인스타그램 클론코딩] 13. 게시글 검색 기능 구현(Back-End) (0) | 2023.02.16 |
---|---|
[인스타그램 클론코딩] 12. 메신저 기능 구현(Front-End) (0) | 2023.02.16 |
[인스타그램 클론코딩] 11. 프로필 수정 기능 구현(Front-End) (0) | 2023.02.07 |
[인스타그램 클론코딩] 11. 프로필 수정 기능 구현(Back-End) (0) | 2023.02.07 |
[인스타그램 클론코딩] 10. 게시물 댓글 기능 구현(Front-End) (0) | 2023.02.07 |