일종의 웹 버전의 TCP
또는 Socket
이다. 서버와 클라이언트간의 연결정보를 유지하여, 양방향 통신 또는 데이터 통신을 유지하는 기술이다.
TCP
연결형 서비스를 지원하는 프로토콜
Socket
클라이언트와 서버 양쪽에서 서로에게 데이터를 전달하는 방식의 양방향 통신
일반적인 웹 어플리케이션은 HTTP 프로토콜를 통하여 클라이언트 Request(요청)
→ 서버 Response(응답)
과정을 걸치고, 연결이 종료된다. 이러한 방식을 Request/Response기반의 Stateless protocol
이라고 한다. 즉, 단방향 통신이다(클라이언트에서 서버로).
이럴 경우, 서버에서 데이터 정보가 변경되어도 클라이언트에서 새로운 Request가 있지 않는 이상 응답 결과를 못보내준다.
이러한 문제를 해결하기 위한 방법으로 WebSocket
을 몰랐을 때, 나는 1초에 한 번씩 Ajax
통신을 통해 응답결과를 받는 방법을 생각했다. 하지만 이 방법은 빠른 응답에 적합하지않고 무엇보다 서버에 부담이 될 거라 생각했다. 이걸 Long polling
이라고 하더라.
본인은 Spring Boot
+ React
조합으로 구현하려고 한다. 해당 개발환경 설정은 미리 포스팅 해뒀다.
https://velog.io/@postlist/Spring-Boot-React-개발환경-연동
pom.xml에 추가
<!– websocket –> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>
@Service //bean 등록
@ServerEndpoint("/socket/chatt") //해당 URL로 Socket연결 (Singleton pattern)
public class WebSocketChat {
...
}
@Service
@ServerEndpoint("/socket/chatt")
public class WebSocketChat {
private static Set<Session> clients =
Collections.synchronizedSet(new HashSet<Session>());
}
해당 클래스는 클라이언트가 접속될 때마다 생성되어 클라이언트와 직접 통신하는 클래스이다. 클라이언트가 접속할 때마다 session 관련 정보를 정적으로 저장하여, 1:N 통신이 가능하도록 한다.
클라이언트의 대한 이벤트 Handler를 아래 Annotation
과 함께 정의한다.
Annotation | 설명 |
---|---|
@OnOpen | 클라이언트가 접속할 때마다 실행 |
@OnMessage | 메세지 수신 시 |
@OnClose | 클라이언트가 접속을 종료할 시 |
@Service
@ServerEndpoint("/socket/chatt")
public class WebSocketChat {
private static Set<Session> clients = Collections.synchronizedSet(new HashSet<Session>());
@OnOpen
public void onOpen(Session session) throws Exception {
}
@OnMessage
public void onMessage(String message, Session session) {
}
@OnClose
public void onClose(Session session) {
}
}
@Service
@ServerEndpoint("/socket/chatt")
public class WebSocketChat {
private static Set<Session> clients = Collections.synchronizedSet(new HashSet<Session>());
private static Logger logger = LoggerFactory.getLogger(WebSocketChat.class);
@OnOpen
public void onOpen(Session session) {
logger.info("open session : {}, clients={}", session.toString(), clients);
if(!clients.contains(session)) {
clients.add(session);
logger.info("session open : {}", session);
}else{
logger.info("이미 연결된 session");
}
}
@OnMessage
public void onMessage(String message, Session session) throws IOException {
logger.info("receive message : {}", message);
for (Session s : clients) {
logger.info("send data : {}", message);
s.getBasicRemote().sendText(message);
}
}
@OnClose
public void onClose(Session session) {
logger.info("session close : {}", session);
clients.remove(session);
}
}
@OnOpen
@ServerEndpoint
에서 명시한 URL로 요청이 들어올 경우 해당 메서드가 실행되어 클라이언트의 정보를 매개변수로 전달받습니다. 상수인 clients에 해당 session이 존재하지 않으면 clients에 접속된 클라이언트를 추가합니다.@OnMessage
@OnClose
일반적으로 클래스들은 Spring의 의해 bean으로 등록되고 해당 인스턴스는 Singleton으로 관리되지만, @ServerEndPoint
Annotation이 달린 클래스들은 WebSocket이 생성될 때마다 인스턴스가 생성되고 JWA구현에 의해 관리되기 때문에 내부에 Autowired
가 설정된 멤버들이 정상적으로 초기화가 되지않는다.
이때 이를 연결해 주고 초기화해 주는 클래스가 필요합니다.
@Component
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
React
를 사용해 구현하였습니다.
VIEW
- chat.js
import React, { useCallback, useRef, useState, useEffect } from 'react'; import {createGlobalStyle} from 'styled-components'; import reset from 'styled-reset'; const Chat = () => { const [msg, setMsg] = useState(""); const [name, setName] = useState(""); const [chatt, setChatt] = useState([]); const [chkLog, setChkLog] = useState(false); const [socketData, setSocketData] = useState(); const ws = useRef(null); //webSocket을 담는 변수, //컴포넌트가 변경될 때 객체가 유지되어야하므로 'ref'로 저장 const msgBox = chatt.map((item, idx) => ( <div key={idx} className={item.name === name ? 'me' : 'other'}> <span><b>{item.name}</b></span> [ {item.date} ]<br/> <span>{item.msg}</span> </div> )); useEffect(() => { if(socketData !== undefined) { const tempData = chatt.concat(socketData); console.log(tempData); setChatt(tempData); } }, [socketData]); const GlobalStyle = createGlobalStyle` //css 초기화가 된 component ${reset} `; //webSocket //webSocket //webSocket //webSocket //webSocket //webSocket const onText = event => { console.log(event.target.value); setMsg(event.target.value); } const webSocketLogin = useCallback(() => { ws.current = new WebSocket("ws://localhost:8080/socket/chatt"); ws.current.onmessage = (message) => { const dataSet = JSON.parse(message.data); setSocketData(dataSet); } }); const send = useCallback(() => { if(!chkLog) { if(name === "") { alert("이름을 입력하세요."); document.getElementById("name").focus(); return; } webSocketLogin(); setChkLog(true); } if(msg !== ''){ const data = { name, msg, date: new Date().toLocaleString(), }; //전송 데이터(JSON) const temp = JSON.stringify(data); if(ws.current.readyState === 0) { //readyState는 웹 소켓 연결 상태를 나타냄 ws.current.onopen = () => { //webSocket이 맺어지고 난 후, 실행 console.log(ws.current.readyState); ws.current.send(temp); } }else { ws.current.send(temp); } }else { alert("메세지를 입력하세요."); document.getElementById("msg").focus(); return; } setMsg(""); }); //webSocket //webSocket //webSocket //webSocket //webSocket //webSocket return ( <> <GlobalStyle/> <div id="chat-wrap"> <div id='chatt'> <h1 id="title">WebSocket Chatting</h1> <br/> <div id='talk'> <div className='talk-shadow'></div> {msgBox} </div> <input disabled={chkLog} placeholder='이름을 입력하세요.' type='text' id='name' value={name} onChange={(event => setName(event.target.value))}/> <div id='sendZone'> <textarea id='msg' value={msg} onChange={onText} onKeyDown={(ev) => {if(ev.keyCode === 13){send();}}}></textarea> <input type='button' value='전송' id='btnSend' onClick={send}/> </div> </div> </div> </> ); }; export default Chat;
- chat.sass
& { width:500px; background-color: #ededed; margin: 50px auto; padding:20px 10px; border-radius: 20px; box-shadow: 41px 41px 82px #c9c9c9, -41px -41px 82px #ffffff; } /* input 기본 스타일 초기화 */ input { -webkit-appearance: none; -moz-appearance: none; appearance: none; } /* IE10 이상에서 input box 에 추가된 지우기 버튼 제거 */ input::-ms-clear { display: none; } /* input type number 에서 화살표 제거 */ input[type='number']::-webkit-inner-spin-button, input[type='number']::-webkit-outer-spin-button { -webkit-appearance: none; -moz-appearance: none; appearance: none; } #chatt{ width: 100%; margin: 0 auto; #title{ font-size: 30pt; text-align: center; margin-bottom: 20px; } #talk{ width: 100%; height: 400px; overflow-y: auto; border-radius: 18px; position: relative; .talk-shadow{ position: absolute; top: 0; left: 0; width: 100%; height: 100%; } div{ width: 60%; display: block; padding: 10px; border-radius:10px; box-sizing: border-box; &.me{ background-color : #ffc; margin : 0px 0px 20px 40%; } &.other{ margin : 20px 0px 2px 0; } } } #name{ display: block; border: 1px solid #dcdcdc; background-color: #EDEDED; padding:5px 2px; margin-top: 20px; } #sendZone{ > * {vertical-align: top;} margin-top: 10px; $sendZone-H : 70px; display: flex; #msg{ width: 90%; height: $sendZone-H; display: block; resize: none; border: 1px solid #dcdcdc; background-color: #fff; box-sizing: border-box; } #btnSend{ width: 10%; height: $sendZone-H; border: 1px solid #dcdcdc; } } }
Server
webSocket.java
import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import javax.websocket.OnClose; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.*; @Service @ServerEndpoint("/socket/chatt") public class WebSocketChat { private static Set<Session> clients = Collections.synchronizedSet(new HashSet<Session>()); private static Logger logger = LoggerFactory.getLogger(WebSocketChat.class); @OnOpen public void onOpen(Session session) { logger.info("open session : {}, clients={}", session.toString(), clients); Map<String, List<String>> res = session.getRequestParameterMap(); logger.info("res={}", res); if(!clients.contains(session)) { clients.add(session); logger.info("session open : {}", session); }else{ logger.info("이미 연결된 session"); } } @OnMessage public void onMessage(String message, Session session) throws IOException { logger.info("receive message : {}", message); for (Session s : clients) { logger.info("send data : {}", message); s.getBasicRemote().sendText(message); } } @OnClose public void onClose(Session session) { logger.info("session close : {}", session); clients.remove(session); } }