[SpringBoot] WebSocket 만들기 (+ React 채팅구현)

P__.mp4·2022년 8월 5일
4

Spring

목록 보기
4/6
post-thumbnail

WebSocket이란?

일종의 웹 버전의 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>

WebSocket 클래스 만들기

1. bean 등록 / webSocket 활성화

@Service	//bean 등록
@ServerEndpoint("/socket/chatt") //해당 URL로 Socket연결 (Singleton pattern)
public class WebSocketChat {
    ...
}

2. 클라이언트의 session 정보 저장

@Service
@ServerEndpoint("/socket/chatt")
public class WebSocketChat {
    private static Set<Session> clients = 
    	Collections.synchronizedSet(new HashSet<Session>());
}

해당 클래스는 클라이언트가 접속될 때마다 생성되어 클라이언트와 직접 통신하는 클래스이다. 클라이언트가 접속할 때마다 session 관련 정보를 정적으로 저장하여, 1:N 통신이 가능하도록 한다.

3. 접속 / 수신 / 해제 Handler

클라이언트의 대한 이벤트 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) {
    }
}

4. 이벤트 Handler 구현


@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);
    }
}
  1. @OnOpen
    • @ServerEndpoint 에서 명시한 URL로 요청이 들어올 경우 해당 메서드가 실행되어 클라이언트의 정보를 매개변수로 전달받습니다. 상수인 clients에 해당 session이 존재하지 않으면 clients에 접속된 클라이언트를 추가합니다.
  1. @OnMessage
    • 클라이언트와 서버Socket이 연결된 상태에서, 메세지가 전달되면 해당 메서드가 실행되어 상수인 clients에 있는 모든 session에게 메세지를 전달합니다.
  2. @OnClose
    - 클라이언트가 URL을 바꾸거나 브라우저를 종료하면 해당 메서드가 실행되어 클라이언트의 세션정보를 clients에서 제거합니다.

5. WebSocketConfiguration

일반적으로 클래스들은 Spring의 의해 bean으로 등록되고 해당 인스턴스는 Singleton으로 관리되지만, @ServerEndPoint Annotation이 달린 클래스들은 WebSocket이 생성될 때마다 인스턴스가 생성되고 JWA구현에 의해 관리되기 때문에 내부에 Autowired가 설정된 멤버들이 정상적으로 초기화가 되지않는다.

이때 이를 연결해 주고 초기화해 주는 클래스가 필요합니다.

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

간단한 채팅 구현(JavaScript WebSocket 연결)

React 를 사용해 구현하였습니다.

VIEW

  1. 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;

  1. 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);
    }
}
profile
개발은 자신감

0개의 댓글