[Spring Boot] WebSocket으로 채팅 기능 구현하기

Wonjun Seo·2023년 8월 14일
0

Spring Boot 환경에서 WebSocket을 이용해 채팅을 구현해보기 위한 포스트이다.

STOMP

WebSocket 프로토콜은 두 가지 유형의 메세지를 정의하고 있지만 그 메세지의 내용까지는 정의하고 있지 않는다.

STOMP (Simple Text Oriented Messaging Protocol)은 메세징 전송을 효율적으로 하기 위해 탄생한 프로토콜이고, 기본적으로 pub / sub 구조로 되어있어 메세지를 전송하고 메세지를 받아 처리하는 부분이 확실히 정해져 있기 때문에 개발자 입장에서 명확하게 인지하고 개발할 수 있는 이점이 있다. 한 줄로 정의하자면, STOMP 프로토콜은 WebSocket 위에서 동작하는 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 매커니즘이다.

또한 STOMP를 이용하면 메세지의 헤더에 값을 줄 수 있어 헤더 값을 기반으로 통신 시 인증 처리를 구현하는 것도 가능하며 STOMP 스펙에 정의한 규칙만 잘 지키면 여러 언어 및 플랫폼 간 메세지를 상호 운영할 수 있다.

프로젝트 설정

build.gradle

다음과 같은 의존성을 추가해주어야 한다.

implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.1.2'
implementation 'org.webjars:stomp-websocket:2.3.3-1'

WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	
	//endpoint를 /stomp로 하고, allowedOrigins를 "*"로 하면 페이지에서
    //Get /info 404 Error가 발생한다. 그래서 아래와 같이 2개의 계층으로 분리하고
    //origins를 개발 도메인으로 변경하니 잘 동작하였다.
    //이유는 왜 그런지 아직 찾지 못함
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/stomp/chat")
                .setAllowedOrigins("http://localhost:8090")
                .withSockJS();
    }

    /*어플리케이션 내부에서 사용할 path를 지정할 수 있음*/
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/pub");
        registry.enableSimpleBroker("/sub");
    }
	
}
  • @EnableWebSocketMessageBroker
    -> Stomp를 사용하기 위해 선언하는 어노테이션

  • setApplicationDestinationPrefixes
    -> Client에서 SEND 요청을 처리

  • enableSimpleBroker
    -> 해당 경로로 SimpleBroker를 등록. SimpleBroker는 해당하는 경로를 SUBSCRIBE하는 Client에게 메세지를 전달하는 간단한 작업을 수행

  • enableStompBrokerRelay
    -> SimpleBroker의 기능과 외부 Message Broker(RabbitMQ, ActiveMQ, ...)에 메세지를 전달하는 기능을 가짐


코드 구현

StompChatController

@Slf4j
@RequiredArgsConstructor
@Controller
public class StompChatController {
	
	private final ChatService chatService;
	
	private final SimpMessagingTemplate template;
	
	@MessageMapping(value = "/chat/enter")
    public void enter(ChatMessageCreateDto message){
		log.info("enter()");
        message.setMessage(message.getLoginId() + "님이 채팅방에 참여하였습니다.");
        template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
    }

    @MessageMapping(value = "/chat/message")
    public void message(ChatMessageCreateDto message){
    	log.info("message(message = {})", message);
    	
    	chatService.create(message);
        template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
    }
    
    @MessageMapping("/sub/chat/room/{roomId}")
    @SendTo("/pub/chat/room/{roomId}")
    public List<ChatMessage> handleMessages(List<ChatMessage> messages) {	
        return messages;
    }

}

ChatRoomController

@Slf4j
@RequiredArgsConstructor
@Controller
@RequestMapping("/chat")
public class ChatRoomController {
	
	private final UserService userService;
	private final ChatService chatService;
	
	/**
	 * 오픈 채팅 목록을 불러옴
	 * @param model
	 * @return
	 */
	@GetMapping("/list")
	public String getList(Model model) {
		log.info("getList()");
		
		List<ChatRoomDto> list = chatService.findAllRooms();
		model.addAttribute("rooms", list);
		
		return "chat/list";
	}
	
	/**
	 * 새로운 오픈 채팅방을 개설함
	 * @param name
	 * @param principal
	 * @return
	 */
	@PostMapping("/room/create")
	public String create(@RequestParam("name") String name, Principal principal) {
		log.info("create(name = {})", name);
		
		String loginId = "";
		if(principal != null) {
			loginId = principal.getName();
		}
		
		chatService.create(name, loginId);
		
		return "redirect:/chat/list";
	}
	
	/**
	 * 특정 오픈 채팅방을 불러옴
	 * @param id
	 * @param principal
	 * @param model
	 * @return
	 */
	@GetMapping("/room/{id}")
	@PreAuthorize("isAuthenticated()")
	public String room(@PathVariable("id") long id, Principal principal, Model model) {
		log.info("room(id = {})", id);
		
		User user = userService.findUserByLoginId(principal.getName());
		model.addAttribute("user", user);
		
		ChatRoom room = chatService.getRoom(id);
		model.addAttribute("room", room);
		
		return "chat/room";
	}

}

ChatMessageCreateDto

@Data
public class ChatMessageCreateDto {
	
	private long roomId;
	private String loginId;
	private String message;

}

ChatRoomRepository

public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
	
	@Query("select new com.example.forest.dto.chat.ChatRoomDto(cr.id, cr.name, u as creator, cr.modifiedTime) "
			+ " from ChatRoom cr, User u "
			+ " where cr.creatorId = u.id "
			+ " order by cr.modifiedTime desc")
	List<ChatRoomDto> findAllRooms();
	
	@Query("select new com.example.forest.dto.chat.ChatRoomDto(cr.id, cr.name, u as creator, cr.modifiedTime) "
			+ " from ChatRoom cr, User u "
			+ " where cr.creatorId = u.id "
			+ " and lower(cr.name) LIKE lower('%' || :keyword || '%') "
			+ " order by cr.modifiedTime desc")
	List<ChatRoomDto> findAllRoomsByKeyword(@Param("keyword") String keyword);

}

View

room.html

<html xmlns:th="http://www.thymeleaf.org"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6"
    layout:decorate="~{layout/base_layout}">

<div layout:fragment="main" class="mt-5 p-4">
	
	<div class="container">
        <div id="chatting-room" class="border border-dark rounded-2 w-50" style="margin: 0 auto;">
            <div class="p-2">
	            <div>
	            	<h1 th:text="${room.name}"></h1>
	            </div>
	            <div id="messages" class=" overflow-auto" style="height: 700px;">
	            </div>
	            <div class="input-group mt-1">
	                <input type="text" id="msg" class="form-control" placeholder="메세지를 입력하세요.">
	                <div class="input-group-append">
	                    <button class="btn btn-outline-secondary" type="button" id="btnSend">전송</button>
	                </div>
	            </div>
	            
	            <div class="mt-1">
	            	<a href="/chat/list" class="btn btn-outline-danger">Exit</a>
	            </div>
	        </div>
        </div>
        
        <input type="hidden" id="roomId" th:value="${room.id}" />
        <input type="hidden" id="loginId" th:value="${#authentication.principal.username}"/>
        
        
    </div>
</div>

<th:block layout:fragment="myscripts">
	<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
    <script src="/js/chat/chat-message.js"></script>
</th:block>

</html>

chat-message.js

document.addEventListener("DOMContentLoaded", function () {
    const roomId = document.querySelector('input#roomId').value;;
    const loginId = document.querySelector('input#loginId').value;

    console.log(roomId + ", " + loginId);

    const sockJs = new SockJS("/stomp/chat");
    // SockJS를 내부에 들고 있는 stomp를 내어줌
    const stomp = Stomp.over(sockJs);

	// connection이 맺어지면 실행
    stomp.connect({}, function () {
        console.log("STOMP Connection");

        stomp.subscribe("/sub/chat/room/" + roomId, function (chat) {
            loadChatMessages();
        });

        stomp.send('/pub/chat/enter', {}, JSON.stringify({roomId: roomId, loginId: loginId}));
    });
    
    // 메세지 전송했을 때 처리하는 메서드
    const sendMessage = (e) => {
		const msg = document.querySelector('input#msg');

        console.log(loginId + ":" + msg.value);
        stomp.send('/pub/chat/message', {}, JSON.stringify({roomId: roomId, message: msg.value, loginId: loginId}));
        msg.value = '';
	};
    
    const sendBtn = document.querySelector('button#btnSend');
    sendBtn.addEventListener('click', sendMessage);
    
    // 날짜 데이터 formatting
    const formatDateTime = (dateTimeString) => {
	    const options = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' };
	    return new Date(dateTimeString).toLocaleDateString('ko-KR', options);
	}
    
    // 채팅 메세지를 불러오기 위한 메서드
    const loadChatMessages = async () => {
		const url = `/api/v1/chat/messages/${roomId}`;
		
		const response = await axios.get(url);
		console.log(response.data);
		
		let htmlStr = '';
		for(let chat of response.data) {
			const formattedTime = formatDateTime(chat.createdTime);
			
			if(chat.sender.loginId == loginId) {
				htmlStr += `
					<div class="d-flex align-items-center alert alert-warning border border-dark">
					  <div class="flex-shrink-0">
					    <img src="https://storage.googleapis.com/itwill-forest-bucket/bd64b031-42c8-4229-a734-b8d25f0fd9f0" alt="..." style="width: 50px; height: 50px;">
					  </div>
					  <div class="flex-grow-1 ms-3">
					    <h5>${chat.sender.nickname}</h5>
					    <p>${chat.content}</p>
					    <p>${formattedTime}</p>
					  </div>
					</div>
				`;
			} else {
				htmlStr += `
					<div class="d-flex align-items-center alert alert-primary border border-dark">
					  <div class="flex-shrink-0">
					    <img src="https://storage.googleapis.com/itwill-forest-bucket/bd64b031-42c8-4229-a734-b8d25f0fd9f0" alt="..." style="width: 50px; height: 50px;">
					  </div>
					  <div class="flex-grow-1 ms-3">
					    <h5>${chat.sender.nickname}</h5>
					    <p>${chat.content}</p>
					    <p>${formattedTime}</p>
					  </div>
					</div>
				`;
			}
		}
		
		// 채팅 내역을 불러올 때 가장 최근 메세지가 보이도록 함
		const messageArea = document.querySelector('div#messages');
		messageArea.innerHTML = htmlStr;
		messageArea.scrollTop = messageArea.scrollHeight;
	};

	// 채팅 메세지를 불러옴
    loadChatMessages();
});

테스트 결과

채팅 목록

채팅방


References

https://dev-gorany.tistory.com/235#stomp

2개의 댓글

comment-user-thumbnail
2023년 8월 14일

글 잘 봤습니다.

답글 달기
comment-user-thumbnail
2024년 7월 24일

//Get /info 404 Error가 발생한다. 그래서 아래와 같이 2개의 계층으로 분리하고
해당 내용에서 저도 동일증상 겪었네요
maven빌드, spring-websocket 4.3.25.RELEASE
registry.addEndpoint("/websocket").setAllowedOrigins("").addInterceptors(new HttpHandshakeInterceptor());
=> wscat -c ws://{IP:PORT}/websocket/websocket
registry.addEndpoint("/test").setAllowedOrigins("
").addInterceptors(new HttpHandshakeInterceptor());
=> wscat -c ws://{IP:PORT}/test/websocket

이런식으로 뒤에 websocket이 붙어야 정상동작 되더라고요
이전 버전에서는
=> wscat -c ws://{IP:PORT}/websocket 으로 정상동작 되었구요
혹시 해결하셨으면 답글 부탁드립니다.

  • 저도 글 참고해서 2계층으로 설정해서 사용중입니다
답글 달기