본문 바로가기
개발

웹 소켓을 이용한 채팅 내용 저장 (React + SpringBoot + SockJS + STOMP + MySQL)

by ^..^v 2023. 4. 26.
728x90
반응형

웹 소켓을 이용해서 채팅 기능을 구현합니다. 

채팅 내용은 MySQL DB에 저장되며, 채팅에 참여하면 이전 대화 내용의 일부를 받아 출력해 줍니다. 

 

프론트엔드

의존 모듈 설치

의존 모듈을 설치합니다. SockJS는 WebSocket과 유사한 객체를 제공하는 브라우저 JavaScript 라이브러리로, sockjs-client는 JavaScript 클라이언트 라이브러리입니다. stompjs 라이브러리는 WebSocket 클라이언트를 통해 STOMP 브로커에 연결하고 STOMP를 제공합니다. 

c:\board-react> npm install sockjs-client
c:\board-react> npm install @stomp/stompjs websocket

 

Chatting2.js

SockJS와 Stomp를 이용해서 웹 소켓 서버로 연결하고 메시지를 주고 받는 기능을 구현합니다. 

import React, { useCallback, useRef, useState, useEffect } from 'react';
import './Chatting2.css';
import { Stomp } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

// SockJS와 Stomp를 이용해서 웹 소켓 서버로 연결하고 메시지를 주고 받는 기능을 구현합니다.
const Chatting2 = () => {
    // 상태 변수 정의
    // -----------------------------------------------------------------------------------------
    // isJoin       채팅 참가 여부
    //              초기값은 false이며 연결 후 JOIN 메시지를 수신했을 때 true로 설정합니다.
    //              채팅 참가 후 닉네임을 변경할 수 없도록 하기 위해 사용합니다.
    // chatHistory  [ { type, sender, message }, { ... }, ... ] 형식의 채팅 내용을 저장하는 배열
    // sender       사용자 이름
    // message      사용자가 작성한 채팅 내용
    const [isJoin, setIsJoin] = useState(false);
    const [chatHistory, setChatHistory] = useState([]);
    const [sender, setSender] = useState('');
    const [message, setMessage] = useState('');
    
    // ref 변수 정의
    // -----------------------------------------------------------------------------------------
    // refDialogDiv     채팅 내용 출력 영역을 자동 스크롤하기 위해서 사용합니다.
    // refSenderInput   사용자 이름 입력 창에 포커스를 부여하기 위해서 사용합니다.
    // refMessageInput  채팅 내용 입력 창에 포커스를 부여하기 위해서 사용합니다.
    // stompClient      스톰프 클라이언트의 상태를 유지시키지 위해서 사용합니다.
    const refDialogDiv = useRef();
    const refSenderInput = useRef();
    const refMessageInput = useRef();
    const stompClient = useRef(null);
    

    // 채팅 참여
    // -----------------------------------------------------------------------------------------
    // 닉네임을 입력하고 참가 버튼을 클릭했을 때 호출합니다.
    // 웹 소켓 객체와 스톰프 클라이언트 객체를 생성하고, 서버(connect)와 연결을 시도합니다.
    // 서버와 연결 시 연결에 성공한 경우(onConnected)와 실패한 경우(onError)에 호출할 콜백 함수를
    // 등록합니다. (연결이 끊겼을 때 호출할 콜백 함수를 정의할 수도 있습니다.)
    const joinChatting = useCallback((e) => {
        e.preventDefault();

        if (!sender) {
            alert('닉네임을 입력하세요.');
            refSenderInput.current.focus();
            return;
        }

        // index.html에서 <script> 태그를 이용해서 라이브러리를 가져온 경우 사용
        // const { SockJS, Stomp } = window;

        // https://stomp-js.github.io/stomp-websocket/codo/extra/docs-src/sockjs.md.html
        stompClient.current = Stomp.over(() => new SockJS('http://localhost:8080/ws'));  
        // https://stomp-js.github.io/stomp-websocket/codo/extra/docs-src/Usage.md.html#toc_5  
        stompClient.current.connect({}, onConnected, onError);
    }, [sender]);

    // 연결에 성공한 경우
    // -----------------------------------------------------------------------------------------
    // 메시지 구독을 신청(subscribe)하고, 사용자 등록 메시지를 전송(send)합니다.
    // 메시지 구독을 신청할 때 메시지를 수신했을 때 호출할 콜백 함수(onMessageReceived)를 등록합니다. 
    const onConnected = useCallback(() => {
        // https://stomp-js.github.io/stomp-websocket/codo/extra/docs-src/Usage.md.html#toc_9
        stompClient.current.subscribe('/topic/chatting', onMessageReceived);
        // https://stomp-js.github.io/stomp-websocket/codo/extra/docs-src/Usage.md.html#toc_8
        stompClient.current.send('/app/chat.addUser', {}, JSON.stringify({ sender, type: 'JOIN' }));
    }, [sender]);

    // 연결에 실패한 경우
    // -----------------------------------------------------------------------------------------
    // 서버 연결에 실패한 경우, 로그를 남깁니다.
    const onError = useCallback(error => {
        console.log('연결실패', error);
    }, []);

    // 채팅 메시지를 전달하는 경우
    // -----------------------------------------------------------------------------------------
    // 메시지 입력창에 메시지를 입력하고 전송 버튼을 클릭했을 때 호출합니다.
    // 채팅 메시지를 전송(send)하고, 메시지 입력창에 내용을 지우고 포커스를 부여합니다.
    const sendMessage = useCallback(e => {
        e.preventDefault();

        if (stompClient) {
            // https://stomp-js.github.io/stomp-websocket/codo/extra/docs-src/Usage.md.html#toc_8
            stompClient.current.send('/app/chat.sendMessage', {}, JSON.stringify({ sender, message, type: 'CHAT' }));
        }
        
        setMessage('');
        refMessageInput.current.focus();
    }, [message]);
    
    // 메시지를 수신한 경우
    // -----------------------------------------------------------------------------------------
    // 메시지 구독(subscribe) 신청한 메시지가 수신되었을 때 호출됩니다. 
    // 매개변수로 전달된 값(payload.body)을 이용해서 상태변수에 값을 설정합니다. 
    // JOIN 메시지인 경우, 채팅 참가 상태를 변경하고, 
    // 함께 전달된 이전 채팅 이력(history)을 chatHistory 상태변수에 설정합니다. 
    // 그외 메시지인 경우, 메시지를 chatHistory 상태변수에 설정합니다.
    const onMessageReceived = useCallback(payload => {
        const message = JSON.parse(payload.body);

        if (message.type === 'JOIN' && message.sender === sender) {
            setIsJoin(true);
            message.history.map(msg => setChatHistory(chatHistory => [...chatHistory, msg]))
        } else {
            setChatHistory(chatHistory => [...chatHistory, message]);
        }
    }, [sender]);

    // 채팅 내용 출력 영역을 자동 스크롤
    // -----------------------------------------------------------------------------------------
    // 채팅 내용이 변경된 경우, 
    // 출력 영역 보다 채팅 내용이 많은 경우 최신 내용이 보일 수 있도록 스크롤 다운합니다. 
    useEffect(() => {
        refDialogDiv.current.scroll({
            top: refDialogDiv.current.scrollHeight, 
            behavior: 'smooth'});
    }, [chatHistory])

    return (
        <>
            <div id='chat-wrap'>
                <div id='chat'>
                    <div id='dialog' ref={refDialogDiv}>
                        <div className='dialog-board'>
                        {   /* 채팅 내용을 출력 */
                            chatHistory.map((item, idx) => (
                                <div key={idx} className={item.sender === sender ? 'me' : 'other'}>
                                    <span><b>{item.sender}</b></span>
                                    <span className="date">{item.createdDt}</span><br />
                                    <span>{item.message}</span>
                                </div>
                            ))
                        }
                        </div>
                    </div>
                    <div id='divSender'>
                        <label>닉네임</label>
                        <input id='senderInput' type='text' placeholder='닉네임을 입력하세요.' maxLength={7} 
                            ref={refSenderInput} value={sender} disabled={isJoin} 
                            onChange={e => setSender(e.target.value)} 
                            onKeyDown={e => { if (e.keyCode === 13) { joinChatting(e); }}}/>
                        <input type='button' value='참가' id='btnJoin' disabled={isJoin} onClick={joinChatting} />
                    </div>
                    <div id='divMessage'>
                        <label>메시지</label>
                        <textarea id='messageInput' value={message} ref={refMessageInput}
                            onChange={e => setMessage(e.target.value)}
                            onKeyDown={e => { if (e.keyCode === 13) { sendMessage(e); }}}></textarea>
                        <input type='button' value='전송' id='btnSend' onClick={sendMessage} />
                    </div>
                </div>
            </div>
        </>
    );
};

export default Chatting2;

 

Chatting2.css

* {
  font-size: 1rem;
  font-family: 'Noto Sans KR', 'Segoe UI', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
  border-collapse: collapse;
  box-sizing: border-box;
  color: black;
}

#chat-wrap {
  width: 500px;
  background-color: #ededed;
  margin: 50px auto;
  padding: 20px 10px;
  border-radius: 10px;
  box-shadow: 41px 41px 82px #c9c9c9, -41px -41px 82px #ffffff;
}

#chat {
  width: 100%;
  margin: 0 auto;
}

#chat #dialog {
  width: 100%;
  height: 400px;
  overflow-y: auto;
  position: relative;
  border-radius: 10px 10px 0 0;
  background-color: #fefefe;
}

#chat #dialog .dialog-board {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

#chat div {
  width: 60%;
  display: block;
  padding: 10px;
  border-radius: 10px;
  box-sizing: border-box;
}

#chat div.me {
  background-color: #EAFAF1;
  margin: 0px 0px 10px 40%;
}

#chat div.other {
  background-color: #FEF9E7;
  margin: 10px 0px 10px 0;
}

#chat #divSender {
  margin-top: 20px;
  padding-bottom: 0;
  width: 100%;
}

#divSender label {
  height: 40px;
  line-height: 40px;
}

label {
  display: inline-block;
  width: 60px;
  vertical-align: bottom;
  text-align: center;
  padding-right: 10px;
  font-weight: bold;
}

#divSender input[type='text'] {
  border: none;
  border-bottom: 1px solid #ccc;
  width: calc(100% - 60px - 60px - 3px);
  height: 40px;
  padding-left: 15px;
  margin-right: 3px;
}

#chat #divMessage {
  width: 100%;
}

#divMessage label {
  height: 70px;
  line-height: 70px;
}

#btnJoin {
  display: inline-block;
  width: 60px;
  height: 40px;
  vertical-align: bottom;
  border: 1px solid #dcdcdc;
  background-color: cornflowerblue;
  border-radius: 5px;
}

#messageInput {
  width: calc(100% - 60px - 60px - 3px);
  height: 70px;
  margin-right: 3px;
  display: inline-block;
  resize: none;
  border: 1px solid #dcdcdc;
  background-color: #fff;
  box-sizing: border-box;
  vertical-align: bottom;
  border-radius: 5px;
}

#btnSend {
  display: inline-block;
  width: 60px;
  height: 70px;
  vertical-align: bottom;
  border: 1px solid #dcdcdc;
  background-color: cornflowerblue;
  border-radius: 5px;
}

.date {
  color: #666;
  padding-left: 10px;
  font-size: 0.9rem;
}

 

App.js

import { Route } from 'react-router-dom';
import './App.css';
import Chatting2 from './Chatting2';

function App() {
  
  return (
    <>
      <div className="boxA">
        <div className="box1"><Chatting2 /></div>
        <div className="box2"><Chatting2 /></div>        
      </div>
    </>
  );
}

export default App;

 

App.css

.boxA {
	width: 1030px;
	margin: 0 auto;
}
.boxA::after {
	content: "";
	clear: both;
	display: block;
}
.box1 {
	float: left;
}
.box2 {
	float: right;
}

 

 

데이터베이스

채팅 내용을 저장할 t_chat 테이블을 생성합니다.

CREATE TABLE `t_chat` (
  `seq_no` int(11) NOT NULL AUTO_INCREMENT COMMENT '일련번호',
  `type` varchar(10) NOT NULL COMMENT '메시지 종류 (JOIN, CHAT, LEAVE)',
  `message` varchar(5000) NOT NULL COMMENT '메시지 내용',
  `sender` varchar(100) DEFAULT NULL COMMENT '메시지 송신자 이름',
  `created_dt` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '메시지 생성 시간',
  PRIMARY KEY (`seq_no`)
) ENGINE=InnoDB AUTO_INCREMENT=57 DEFAULT CHARSET=utf8 COMMENT='채팅 이력을 저장하는 테이블';

 

 

백엔드

build.gradle

WebSocket 애플리케이션 개발을 위해서 spring-boot-starter-websocket 의존 모듈을 추가합니다. 

				... (생략) ...
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.0'
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  runtimeOnly 'com.mysql:mysql-connector-j'
  annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  
  implementation group: 'org.bgee.log4jdbc-log4j2', name: 'log4jdbc-log4j2-jdbc4.1', version: '1.16'
  implementation group: 'io.springfox', name: 'springfox-boot-starter', version: '3.0.0'
	
  // -----------------------------------------------------------------------------------------
  // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-websocket
  implementation group: 'org.springframework.boot', name: 'spring-boot-starter-websocket', version: '3.0.5'
}
				... (생략) ...

 

ChatDto.java

채팅 데이터를 저장할 DTO 객체를 정의합니다. 

package board.chat;

import java.util.List;

import lombok.Data;

@Data
public class ChatDto {
	private MessageType type;
	private String message;
	private String sender;
	private String createdDt;
	private List<ChatDto> history;
	
	public enum MessageType {
		JOIN, CHAT, LEAVE
	}
}

 

sql-sample.xml

채팅 내용을 저장하고 조회하는 쿼리를 정의합니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="board.chat.ChatMapper">
	<select id="selectMessages" parameterType="int" resultType="board.chat.ChatDto">
		select * from (
			select * from t_chat order by created_dt desc limit ${num} 
		) a order by a.created_dt
	</select>
	
	<insert id="insertMessage" parameterType="board.chat.ChatDto">
		insert into t_chat (type, message, sender, created_dt)
		values (#{type}, #{message}, #{sender}, #{createdDt}) 
	</insert>
</mapper>

 

ChatMapper.java

package board.chat;

import java.util.List;

import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface ChatMapper {
	List<ChatDto> selectMessages(int num) throws Exception;
	void insertMessage(ChatDto chatDto) throws Exception;
}

 

ChatService.java

package board.chat;

import java.util.List;

public interface ChatService {
	List<ChatDto> selectMessages() throws Exception;
	void insertMessage(ChatDto chatDto) throws Exception;
}

 

ChatServiceImpl.java

package board.chat;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class ChatServiceImpl implements ChatService {
	@Autowired
	ChatMapper mapper;
	
    // 한번에 가져올 이전 대화의 개수
    private final int CONST_MAX_MESSAGE_COUNT = 10;
	
	@Override
	public List<ChatDto> selectMessages() throws Exception {
		return mapper.selectMessages(CONST_MAX_MESSAGE_COUNT);
	}

	@Override
	public void insertMessage(ChatDto chatDto) throws Exception {
        // 현재 시간을 설정해서 대화 내용을 저장
		LocalDateTime now = LocalDateTime.now();
		chatDto.setCreatedDt(now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
		mapper.insertMessage(chatDto);
	}
}

 

ChatController.java

package board.chat;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;

/*
 * 메시지 처리 방법을 정의
 * 한 클라이언트로 부터 수신한 메시지를 같은 토픽을 구독하고 있는 클라이언트에게 브로드캐스팅
 * @MessageMapping 어노테이션으로 메시지를 처리할 메서드를 지정하고, @SendTo 어노테이션으로 발행할 토픽을 지정
 */
@Controller
public class ChatController {
	@Autowired
	ChatService service;

	// 사용자 등록 처리
	// 세션에 사용자 이름을 저장(#1)하고 입장 메시지(#2)와 이전 대화 내용(#3)을 추가해서 토픽을 발행(#4)
	// 세션에 저장한 사용자 이름은 WebSocketEventListener에서 연결이 끊어졌을 때 사용자를 식별하기 위한 용도로 사용
	@MessageMapping("/chat.addUser")
	@SendTo("/topic/chatting") /* #4 */
	public ChatDto addUser(@Payload ChatDto chatDto, SimpMessageHeaderAccessor headerAccessor) throws Exception {

		headerAccessor.getSessionAttributes().put("username", chatDto.getSender()); /* #1 */

		chatDto.setMessage(chatDto.getSender() + "님이 입장하셨습니다."); /* #2 */

		List<ChatDto> list = service.selectMessages(); /* #3 */
		chatDto.setHistory(list);

		return chatDto;
	}

	// 채팅 메시지 전달 처리
	// 채팅 메시지를 DB에 저장(#5)하고 토픽을 발행(#6)
	@MessageMapping("/chat.sendMessage")
	@SendTo("/topic/chatting") /* #6 */
	public ChatDto sendMessage(@Payload ChatDto chatDto) throws Exception {
		service.insertMessage(chatDto); /* #5 */
		return chatDto;
	}
}

 

WebSocketConfiguration.java

WebSocket 클라이언트에서 간단한 메시징 프로토콜(예: STOMP)로 메시지 처리를 구성하는 방법을 정의합니다.

package board.chat;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

/*
 * WebSocket 클라이언트에서 간단한 메시징 프로토콜(예: STOMP)로 메시지 처리를 구성하는 방법을 정의
 * 웹 소켓 서버를 활성화(#1)하고, 
 * WebSocketMessageBrokerConfigurer 인터페이스에서 웹 소켓 연결을 구성하는 메서드(#2, #3)를 구현
 */
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {

	// #2
	// 특정 URL에 매핑하는 STOMP 엔드포인트를 등록하고 (선택 사항) SockJS 폴백 옵션을 활성화 및 구성
	// addEndpoint              주어진 매핑 경로에서 WebSocket 끝점을 통해 STOMP를 등록
	// setAllowedOriginPatterns 브라우저에서 교차 출처 요청이 허용되는 출처를 패턴으로 설정
	// withSockJS               SockJS 폴백 옵션을 활성화
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
	}

	// #3
	// 메시지 브로커 옵션을 구성
	// #3-1 /app로 시작하는 메시지를 메시지 처리 메서드로 라우팅
	// #3-2 /topic으로 시작하는 메시지를 메시지 브로커로 라우팅
	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/app"); // #3-1
		registry.enableSimpleBroker("/topic"); // #3-2
	}
}

 

WebSocketEventListener.java

웹 소켓 연결과 연결 해제 이벤트가 발생했을 때 동작을 정의합니다.

package board.chat;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

import lombok.extern.slf4j.Slf4j;

// 웹 소켓 연결(#1)과 연결 해제(#2) 이벤트가 발생했을 때 동작을 정의
// 연결 시 동작은 ChatController에서 addUser 메서드를 통해서 구현하고 있어 별도로 추가 동작을 정의하지 않으며, 
// 연결 해제 시에는 세션에서 사용자 정보가 있는 경우 퇴장 메시지(토픽)를 발행
@Slf4j
@Component
public class WebSocketEventListener {

	@Autowired
	private SimpMessageSendingOperations messagingTemplate;

	// #1
	@EventListener
	public void handleWebSocketConnectListener(SessionConnectedEvent event) {
		log.info("Received a new web socket connection");
	}

	// #2
	@EventListener
	public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
		StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

		String username = (String) headerAccessor.getSessionAttributes().get("username");
		if (username != null) {
			log.info("User Disconnected : " + username);

			ChatDto chatMessage = new ChatDto();
			chatMessage.setType(ChatDto.MessageType.LEAVE);
			chatMessage.setSender(username);
			chatMessage.setMessage(username + "님이 퇴장하셨습니다.");

			messagingTemplate.convertAndSend("/topic/chatting", chatMessage);
		}
	}
}

 

 

실행 결과

채팅에 참가하면 최근 대화 내용 열 개가 시간 순으로 보여집니다.

 

새로운 사용자가 채팅에 참가하면 입장 메시지가 출력됩니다.

 

브라우저를 닫는 등으로 연결이 종료되면 퇴장 메시지가 출력됩니다.

728x90
반응형

댓글