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
반응형
'개발' 카테고리의 다른 글
웹 소켓을 이용한 채팅 내용 저장 (React + SpringBoot + SockJS + STOMP + MySQL) (0) | 2023.04.26 |
---|---|
폼 데이터와 함께 파일 업로드 (다중 파일 선택창 & 다중 파일 선택) (0) | 2023.04.12 |
웹 애플리케이션에서 도커 컨테이너 실행, 삭제, 조회 (0) | 2023.04.10 |
카카오 로그인 (0) | 2023.04.06 |
네이버 로그인 (0) | 2023.04.06 |
댓글