스프링에 WebSocket 구현하기

김채원·2025년 6월 6일
post-thumbnail

이제 본격적으로 웹소켓을 구현해보자

Config

@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을 통해 구독자에게 해당 메시지를 전달한다

Document

ChatRoom

@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 어노테이션을 사용했다

원래 판매자와 구매자의 아이디 값만 넣어놓을까 고민하다
구매를 원하는 상품을 보여주는 것이 좋다고 생각해
게시글의 아이디도 함께 받았다

ChatMessage

@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도 생성해준다

ChatRoom

controller

@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

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

repository

public interface ChatRoomRepository extends MongoRepository<ChatRoom, String> {

	ChatRoom findByTradeBoardIdAndSellerId(Long tradeBoardId, Long sellerId);

	Page<ChatRoom> findAllByCustomerIdAndIsDeletedFalse(Long customerId, PageRequest pageable);
}

채팅방 생성, 조회, 삭제의 경우
Restful하게 설계를 했다

아직 user에 대한 코드를 받지 못해 데이터 값은 임의로 넣어두었다

채팅방은 카톡처럼 사람 대 사람이 같으면 같은 채팅방
vs 당근처럼 물건이 다르다면 다른 채팅방

이 두가지를 놓고 고민을 좀 했는데
중고거래라는 특성상 물건별로 별도의 채팅이 존재하는게 좋을거같아
물품으로만 예외처리를 진행해줬다

ChatMessage

controller

@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같은 메시지 브로커들을 활용할 수 있게끔 만들어 보는것도 재밌을것 같다.

출처

우아한테크 유튜브 영상

1:1 채팅 관련 블로그

profile
김채원 판교간다

0개의 댓글