이제 본격적으로 웹소켓을 구현해보자
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-chat") //클라이언트가 웹소켓 연결을 위해 마지막에 붙혀야하는 경로
.setAllowedOriginPatterns("*") //CORS 설정(테스트용으로 전체 허용이나 배포 시 변경 필요)
.withSockJS(); //호환성을 높이기 위해 JS 사용(없을경우 http/1.1 이하에서 사용 불가)
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub"); //서버가 구독자에게 메세지 보내는 경로
registry.setApplicationDestinationPrefixes("/pub"); //클라이언트가 서버에 메시지 보내는 경로
}
}
웹소켓컨피그 내에서는 endPoint와 메시지 경로를 설정할 수 있다
만약 api/room1/ws-chat과 같은 url을 통해 요청이 들어오면
클라이언트가 웹소켓에 연결을 요청한다는 의미가 된다
이후 연결에 성공했다면
사용자는 /pub/**과 같은 url로 원하는 메시지 요청을 보내게 되고
서버는 /sub/**과 같은 url을 통해 구독자에게 해당 메시지를 전달한다
@Getter
@Document(collection = "chatroom")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatRoom extends BaseEntityWithCreatedAt {
@Id
private String id;
private Long sellerId; // 판매자 닉네임
private Long customerId; // 구매자 닉네임
private Long tradeBoardId; //판매글 Id
private String lastMessageId;
}
필자는 채팅에 관련된건 MongoDB에서 다룰 예정이기 때문에
@Entity가 아닌 @Document 어노테이션을 사용했다
원래 판매자와 구매자의 아이디 값만 넣어놓을까 고민하다
구매를 원하는 상품을 보여주는 것이 좋다고 생각해
게시글의 아이디도 함께 받았다
@Getter
@Document(collection = "chatMessage")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
@Id
private String id;
private String chatRoomId;
private Long senderId;
private String message;
@CreatedDate
private LocalDateTime createdAt;
}
이후 채팅 Document도 생성해준다
@RestController
@RequestMapping("/chat-room")
@RequiredArgsConstructor
public class ChatRoomController {
private final ChatRoomService chatRoomService;
/**
* 채팅방 생성
* @param tradeBoardId 거래하려는 게시판 id
* @return 거래게시판 url, title, 채팅에 참여한 유저정보
*/
@PostMapping("/{tradeBoardId}")
public ResponseEntity<CommonResponse<ChatRoomCreateResponseDto>> createChatRoom(
@PathVariable Long tradeBoardId
) {
return CommonResponse.of(SuccessCode.CREATED, chatRoomService.saveChatRoom(tradeBoardId));
}
/**
* 채팅방 조회
* @param page 조회할 페이지
* @return 로그인한 사용자의 채팅방
*/
@GetMapping
public ResponseEntity<CommonResponse<Page<ChatRoomAllGetResponseDto>>> getAllCharRoom(
@RequestParam(defaultValue = "1") int page
) {
return CommonResponse.of(SuccessCode.FOUND, chatRoomService.findAllChatRoom(page));
}
/**
* 채팅방 단건 조회
* @param chatRoomId 조회하려는 채팅방 아이디
* @return 채팅 내역 및 거래 물품
*/
@GetMapping("/{chatRoomId}")
public ResponseEntity<CommonResponse<ChatRoomGetResponseDto>> getByChatRoomId(
@PathVariable String chatRoomId
) {
return CommonResponse.of(SuccessCode.FOUND, chatRoomService.findByChatRoomId(chatRoomId));
}
/**
* 채팅방 삭제(소프트 딜리트 구현)
* @param chatRoomId 삭제하려는 채팅방 아이디
* @return 삭제 확인 메시지
*/
@DeleteMapping("/{chatRoomId}")
public ResponseEntity<CommonResponse<Void>> deleteChatRoom(
@PathVariable String chatRoomId
) {
chatRoomService.deleteChatRoom(chatRoomId);
return CommonResponse.of(SuccessCode.DELETED);
}
}
@Service
@RequiredArgsConstructor
public class ChatRoomServiceImpl implements ChatRoomService{
private final ChatRoomRepository chatRoomRepository;
private final TradeBoardRepository tradeBoardRepository;
private final UserRepository userRepository;
private final ChatMessageRepository chatMessageRepository;
//채팅방 생성
@Override
public ChatRoomCreateResponseDto saveChatRoom(Long tradeBoardId) {
//토큰 값으로 수정 예정
User customer = userRepository.findById(2L)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
TradeBoard tradeBoard = tradeBoardRepository.findById(tradeBoardId)
.orElseThrow(() -> new CustomException(ErrorCode.TRADE_BOARD_NOT_FOUND));
User seller = userRepository.findById(tradeBoard.getUser().getId())
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
//해당 물품에 관한 채팅방이 존재할 경우 예외처리
if (chatRoomRepository.findByTradeBoardIdAndSellerId(tradeBoardId, seller.getId()) != null) {
throw new CustomException(ErrorCode.CHAT_ROOM_ALREADY_EXIST);
}
ChatRoom chatRoom = ChatRoom.builder()
.sellerId(seller.getId())
.customerId(customer.getId())
.tradeBoardId(tradeBoardId)
.tradeBoardTitle(tradeBoard.getTitle())
.tradeBoardUrl("/trade-boards/" + tradeBoard.getId())
.build();
ChatRoom savedCharRoom = chatRoomRepository.save(chatRoom);
return new ChatRoomCreateResponseDto(savedCharRoom, tradeBoard);
}
//로그인 한 사용자 채팅방 전체 조회
@Override
public Page<ChatRoomAllGetResponseDto> findAllChatRoom(int page) {
//수정 예정
User customer = userRepository.findById(2L)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
int adjustPage = page > 0 ? page - 1 : 0;
PageRequest pageable = PageRequest.of(adjustPage, 10);
Page<ChatRoom> chatRooms = chatRoomRepository.findAllByCustomerIdAndIsDeletedFalse(customer.getId(), pageable);
return chatRooms.map(ChatRoomAllGetResponseDto::new);
}
//채팅방 단건 조회
@Override
public ChatRoomGetResponseDto findByChatRoomId(String chatRoomId) {
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId)
.orElseThrow(() -> new CustomException(ErrorCode.CHAT_ROOM_NOT_FOUND));
List<ChatMessage> messages = chatMessageRepository.findAllByChatRoomId(chatRoomId);
return new ChatRoomGetResponseDto(chatRoom, messages);
}
//채팅방 삭제(소프트 딜리트)
@Override
public void deleteChatRoom(String chatRoomId) {
//채팅방 유저 검증 로직
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId)
.orElseThrow(() -> new CustomException(ErrorCode.CHAT_ROOM_NOT_FOUND));
chatRoom.deactivateChatRoom();
chatRoomRepository.save(chatRoom);
}
}
public interface ChatRoomRepository extends MongoRepository<ChatRoom, String> {
ChatRoom findByTradeBoardIdAndSellerId(Long tradeBoardId, Long sellerId);
Page<ChatRoom> findAllByCustomerIdAndIsDeletedFalse(Long customerId, PageRequest pageable);
}
채팅방 생성, 조회, 삭제의 경우
Restful하게 설계를 했다
아직 user에 대한 코드를 받지 못해 데이터 값은 임의로 넣어두었다
채팅방은 카톡처럼 사람 대 사람이 같으면 같은 채팅방
vs 당근처럼 물건이 다르다면 다른 채팅방
이 두가지를 놓고 고민을 좀 했는데
중고거래라는 특성상 물건별로 별도의 채팅이 존재하는게 좋을거같아
물품으로만 예외처리를 진행해줬다
@Controller
@RequiredArgsConstructor
public class ChatMessageController {
private final ChatMessageService chatMessageService;
private final SimpMessagingTemplate messagingTemplate;
//메시지 보내기
@MessageMapping("/message")
public void sendMessage(
@RequestBody ChatMessageSendRequestDto requestDto
) {
ChatMessage chatMessage = chatMessageService.createChatMessage(requestDto);
messagingTemplate.convertAndSend("/sub/room/" + requestDto.getChatRoomId(), chatMessage);
}
}
@Service
@RequiredArgsConstructor
public class ChatMessageServiceImpl implements ChatMessageService {
private final ChatMessageRepository chatMessageRepository;
private final ChatRoomRepository chatRoomRepository;
@Override
public ChatMessage createChatMessage(ChatMessageSendRequestDto requestDto) {
ChatRoom chatRoom = chatRoomRepository.findById(requestDto.getChatRoomId())
.orElseThrow(() -> new CustomException(ErrorCode.CHAT_ROOM_NOT_FOUND));
ChatMessage chatMessage = ChatMessage.builder()
.chatRoomId(requestDto.getChatRoomId())
.senderId(requestDto.getSenderId())
.message(requestDto.getMessage())
.build();
chatMessageRepository.save(chatMessage);
chatRoom.updateLastMessage(requestDto.getMessage());
chatRoomRepository.save(chatRoom);
return chatMessage;
}
}
채팅 메신저는 간단하게 해당 채팅방의 url과
채팅을 보내는 메세지만 담길 수 있도록 했다
테스트를 위한 html은 지피티의 도움을 받아 작성했다
http://localhost:8080/chat.html
해당 url을 통해 접속해보면

채팅이 동작하는걸 확인할 수 있다

마지막으로 콘솔을 살펴보면
웹소켓과 STOMP까지 잘 연결 된 것을 확인할 수 있다
웹소켓과 STOMP를 사용해 채팅 기능을 구현해보았다. 이틀동안 이것만 붙잡고 있었는데 기대한대로 코드가 돌아가는 것 같아 기뻤다. 동작하는걸 확인했으니 HTML도 실제 내가 원하는 채팅처럼 개선할 예정이고 아직 예외처리나 완성도가 조금 떨어지는거같아 추후에 리팩토링 예정 중에 있다. RabbitMQ나 Kafka같은 메시지 브로커들을 활용할 수 있게끔 만들어 보는것도 재밌을것 같다.