본문 바로가기
개발

웹 소켓을 이용한 채팅 (React + SpringBoot)

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

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

하나의 채팅방에서 사용자 이름(닉네임)으로 구분해서 대화를 주고 받도록 되어 있으며, 채팅방 생성 및 지정, 채팅 내용 저장 기능은 반영되어 있지 않습니다. 

 

React

Chatting.js

// 브라우저의 기본 스타일을 제거해 주는 node 패키지
import React, { useCallback, useRef, useState, useEffect } from 'react';
import './Chatting.css';

const Chatting = () => {
    // # ---------------------------------------------------------
    // # 상태 변수 정의
    // # ---------------------------------------------------------
    // 채팅 참여 후 주고 받은 대화 내용을 저장하는 상태 변수 
    // { 닉네임, 메세지, 일시 } 형식의 객체를 배열로 저장
    const [chatHistory, setChatHistory] = useState([]);
    
    // 채팅 참여 여부 정보를 저장하는 상태 변수
    // 서버로 연결을 요청하면 true로 변경
    const [isLogin, setLogin] = useState(false);

    // 사용자 닉네임과 메시지를 저장하는 상태 변수
    const [nickname, setNickname] = useState("");
    const [msg, setMsg] = useState("");

    // 서버로부터 전달받은(수신한) 데이터를 저장하는 상태 변수
    const [socketData, setSocketData] = useState();


    // # ---------------------------------------------------------
    // # Ref 변수 정의
    // # ---------------------------------------------------------
    // 웹 소켓 객체를 저장
    const ws = useRef(null); 

    // 대화창을 가리키는 Ref 객체로 대화 내용이 많아질 경우 자동 스크롤에 사용
    const refDialogDiv = useRef();
    // 사용자 닉네임과 메시지 입력창을 가리키는 Ref 객체로 포커스 제어에 사용
    const refNicknameInput = useRef();
    const refMsgInput = useRef();


    // # ---------------------------------------------------------
    // # 현재 시간을 YYYY-MM-DD HH:MI:SS 형식으로 반환하는 함수
    // # ---------------------------------------------------------
    const now = () => {
        const date = new Date();
        const year = date.getFullYear();
        const month = ("0" + (1 + date.getMonth())).slice(-2);
        const day = ("0" + date.getDate()).slice(-2);
        const hours = ("0" + date.getHours()).slice(-2);
        const minutes = ("0" + date.getMinutes()).slice(-2);
        const seconds = ("0" + date.getSeconds()).slice(-2);
    
        return year+"/"+month+"/"+day+" "+hours+":"+minutes+":"+seconds;
    };

    
    // # ---------------------------------------------------------
    // # 서버로부터 전달받은(수신한) 데이터가 존재하는 경우
    // # 대화 내용을 저장하는 상태 변수를 업데이트
    // # ---------------------------------------------------------
    useEffect(() => {
        if (socketData !== undefined) {
            const newChatHistory = chatHistory.concat(socketData);
            setChatHistory(newChatHistory);       
        }
    }, [socketData]);


    // # ---------------------------------------------------------
    // # 대화 내용을 저장하는 상태 변수가 변경된 경우
    // # 최신 대화 내용이 노출될 수 있도록 대화창을 맨 아래로 스크롤
    // # ---------------------------------------------------------
    useEffect(() => {
        refDialogDiv.current.scroll({
            top: refDialogDiv.current.scrollHeight, 
            behavior: 'smooth'});
    }, [chatHistory])


    // # ---------------------------------------------------------
    // # 서버로 연결하고 응답을 받은 경우 수행할 콜백 함수를 등록
    // # 응답을 받은 경우 상태 변수를 업데이트
    // # ---------------------------------------------------------
    const webSocketLogin = useCallback(() => {
        ws.current = new WebSocket("ws://localhost:8080/chat");
        ws.current.onmessage = m => setSocketData(JSON.parse(m.data));
        setLogin(true);
    }, []);


    // # ---------------------------------------------------------
    // # 입력한 메시지를 서버로 전달
    // # 전송 버튼을 클릭하거나 메시지 입력창에서 엔터키를 누른 경우 호출
    // # ---------------------------------------------------------
    const send = useCallback(() => {
        // 서버로 연결되지 않은 경우 연결을 수행
        // 닉네임이 설정되지 않은 경우 경고 메시지를 출력하고 입력창에 포커스를 부여
        // 닉네임은 메시지를 본인 것과 그 외 것으로 구분하는 용도로 사용
        if (!isLogin) {
            if (nickname.trim() === '') {
                alert('닉네임을 입력하세요.');
                setNickname('');
                refNicknameInput.current.focus();
                return;
            }
            webSocketLogin();
        }
        
        // 메시지가 입력되지 않은 경우 경고 메시지를 출력하고 입력창에 포커스를 부여
        if (msg.trim() === '') {
            alert('메세지를 입력하세요.');
            setMsg('');
            refMsgInput.current.focus();
            return;
        } 

        // 송신 데이터를 { 닉네임, 메시지, 현재일시 } 형식으로 만들어서 서버로 전달
        // 소켓은 생성되었으나 연결이 생성되지 않은 상태인 경우 (readState = 0)
        // 연결이 생성되었을 때 메시지를 전달하도록 콜백 함수를 등록
        const data = JSON.stringify({ nickname, msg, date: now() });
        // CONNECTING
        if (ws.current.readyState === 0) {
            ws.current.onopen = () => ws.current.send(data);
        } 
        // OPEN
        else {
            ws.current.send(data);
        }

        // 메시지 전달이 끝나면 메시지 입력창을 클리어하고 입력창으로 포커스를 이동
        setMsg('');            
        refMsgInput.current.focus();
    });

    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.nickname === nickname ? 'me' : 'other'}>
                                    <span><b>{item.nickname}</b></span>
                                    <span className="date">{item.date}</span><br />
                                    <span>{item.msg}</span>
                                </div>
                            ))
                        }
                        </div>
                    </div>
                    <div id='divNickname'>
                        <label>닉네임</label>
                        <input type='text' placeholder='닉네임을 입력하세요.' maxLength={7} ref={refNicknameInput}
                            disabled={isLogin} value={nickname} onChange={e => setNickname(e.target.value) } />
                    </div>
                    <div id='divMsg'>
                        <label>메시지</label>
                        <textarea id='msgInput' value={msg} ref={refMsgInput}
                            onChange={e => setMsg(e.target.value)}
                            onKeyDown={e => { if (e.keyCode === 13) { e.preventDefault(); send(e); }}}></textarea>
                        <input type='button' value='전송' id='btnSend' onClick={send} />
                    </div>
                </div>
            </div>
        </>
    );
};

export default Chatting;

 

Chatting.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 #divNickname {
  margin-top: 20px;
  padding-bottom: 0;
  width: 100%;
}

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

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

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

#chat #divMsg {
  width: 100%;
}

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

#msgInput {
  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 BoardList from "./board/BoardList";
import BoardDetail from "./board/BoardDetail";
import BoardWrite from './board/BoardWrite';
import Chatting from './Chatting';

function App() {
  return (
    <>
      <div className="boxA">
        <div className="box1"><Chatting /></div>
        <div className="box2"><Chatting /></div>
      </div>
      
      {/* <Route path="/" component={BoardList} exact={true} />
      <Route path="/board" component={BoardList} exact={true} />
      <Route path="/board/write" component={BoardWrite} />
      <Route path="/board/detail/:boardIdx" component={BoardDetail} /> */}
    </>
  );
}

export default App;

 

App.css

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

 

 

SpringBoot

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'
}
				... (생략) ...

 

WebSocketChat.java

 

package board.utils;

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@ServerEndpoint("/chat")
public class WebSocketChat {
	
	private static Set<Session> clients = Collections.synchronizedSet(new HashSet<Session>());
	
	@OnOpen
	public void onOpen(Session session) throws Exception {
		
		if (clients.contains(session)) {
			log.info("이미 존재하는 세션 >>> {}", session);
		} else {
			clients.add(session);
			log.info("새로운 세션으로 연결 >>> {}", session);
			
		}
	}

	@OnMessage
	public void onMessage(String message, Session session) throws Exception {
		for (Session s : clients) {
			log.info("SEND MESSAGE [{}] FROM [{}] TO [{}]", message, session, s);
			s.getBasicRemote().sendText(message);
		}
	}

	@OnClose
	public void onClose(Session session) {
		clients.remove(session);
		log.info("세션 종료 >>> {}", session);
	}
}

 

WebSocketConfiguration.java

package board.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Component
public class WebSocketConfiguration {
    @Bean
    ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

 

실행 결과

728x90
반응형

댓글