[Java] Websocket을 이용해서 유저(사이트 운영자)가 다른 유저와 채팅하는 방법


Development note/Java  2020. 5. 6. 23:18

안녕하세요. 명월입니다.


이 글은 Java에서 Websocket을 이용해서 유저(사이트 운영자)가 다른 유저와 채팅하는 방법에 대한 글입니다.


이전 글에서 사이트 운영자와 유저간의 1:1 채팅을 하는 방법이 있는가하는 질문이 있어서 구현해 보았습니다.

링크 - [Java] WebSocket의 Session 사용 방법(Broadcast)과 웹 채팅 소스 예제

사실 Socket의 개념을 알고 있으면 매우 간단하게 해결되는 사항이긴 합니다만, WebSocket의 특성상 웹 개발만 전문으로 개발하시는 분들도 사용하기 때문에 아무래도 소켓 개념에 대해서 생소할 수도 있겠구나라고 생각도 됩니다.

참고 - [Java강좌 - 23] 소켓 통신 (Socket)


먼저 사양의 조건은 운영자가 다른 유저와 Websocket으로 채팅하는 방법입니다.

어차피 운영자나 유저는 Server의 입장에서 보면 둘 다 소켓 client입니다. 즉, Server측에서 client간의 어떤게 유저이고 어떤게 운영자인지 구분할 필요가 있습니다.

그런데 WebSocket은 Java에서 Servlet으로 구분이 되니, 접속하는 url에 따른 client를 구분하도록 합시다.


첫번째 사양으로는 일반 유저는 index.jsp로 접속을 하고 운영자는 admin.jsp로 접속합니다.

일반 유저는 서버와 1:1 채팅이 됩니다만 운영자 유저는 서버와 1:n 채팅이 되어야 합니다.

서버와 운영자 client는 하나의 소켓으로 묶여 있으니 key를 통해서 구분을 합니다.

두번째 사양으로는 일반 유저가 접속을 하면 unique 키를 부여하고 서버에서는 운영자 유저와 채팅의 구분을 위해 이 unique 키를 통한 데이터를 주고 받는 것으로 설정하겠습니다.


먼저 일반 유저와 서버간의 통신입니다.

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<title>Web Socket Example</title>
</head>
<body>
  <!-- 채팅 영역 -->
  <form>
    <!-- 텍스트 박스에 채팅의 내용을 작성한다. -->
    <input id="textMessage" type="text" onkeydown="return enter()">
    <!-- 서버로 메시지를 전송하는 버튼 -->
    <input onclick="sendMessage()" value="Send" type="button">
  </form>
  <br />
  <!-- 서버와 메시지를 주고 받는 콘솔 텍스트 영역 -->
  <textarea id="messageTextArea" rows="10" cols="50" disabled="disabled"></textarea>
  <script type="text/javascript">
    // 서버의 broadsocket의 서블릿으로 웹 소켓을 한다.
    var webSocket = new WebSocket("ws://localhost:8080/ChatExample/broadsocket");
    // 콘솔 텍스트 영역
    var messageTextArea = document.getElementById("messageTextArea");
    // 접속이 완료되면
    webSocket.onopen = function(message) {
      // 콘솔에 메시지를 남긴다.
      messageTextArea.value += "Server connect...\n";
    };
    // 접속이 끝기는 경우는 브라우저를 닫는 경우이기 떄문에 이 이벤트는 의미가 없음.
    webSocket.onclose = function(message) { };
    // 에러가 발생하면
    webSocket.onerror = function(message) {
      // 콘솔에 메시지를 남긴다.
      messageTextArea.value += "error...\n";
    };
    // 서버로부터 메시지가 도착하면 콘솔 화면에 메시지를 남긴다.
    webSocket.onmessage = function(message) {
      messageTextArea.value += "(operator) => " + message.data + "\n";
    };
    // 서버로 메시지를 발송하는 함수
    // Send 버튼을 누르거나 텍스트 박스에서 엔터를 치면 실행
    function sendMessage() {
      // 텍스트 박스의 객체를 가져옴
      let message = document.getElementById("textMessage");
      // 콘솔에 메세지를 남긴다.
      messageTextArea.value += "(me) => " + message.value + "\n";
      // 소켓으로 보낸다.
      webSocket.send(message.value);
      // 텍스트 박스 추기화
      message.value = "";
    }
    // 텍스트 박스에서 엔터를 누르면
    function enter() {
      // keyCode 13은 엔터이다.
      if(event.keyCode === 13) {
        // 서버로 메시지 전송
        sendMessage();
        // form에 의해 자동 submit을 막는다.
        return false;
      }
      return true;
    }
  </script>
</body>
</html>
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
// 일반 유저에서 서버간의 웹 소켓 url
@ServerEndpoint("/broadsocket")
public class BroadSocket {
  // searchUser 함수의 filter 표현식을 위한 인터페이스
  private interface SearchExpression {
    // 람다식을 위한 함수
    boolean expression(User user);
  }
  // 서버와 유저간의 접속을 key로 구분하기 위한 인라인 클래스
  private class User {
    Session session;
    String key;
  }
  // 유저와 서버간의 접속 리스트
  private static List<User> sessionUsers = Collections.synchronizedList(new ArrayList<>());
  // Session으로 접속 리스트에서 User 클래스를 탐색
  private static User getUser(Session session) {
    return searchUser(x -> x.session == session);
  }
  // Key로 접속 리스트에서 User 클래스를 탐색
  private static User getUser(String key) {
    return searchUser(x -> x.key.equals(key));
  }
  // 접속 리스트 탐색 함수
  private static User searchUser(SearchExpression func) {
    Optional<User> op = sessionUsers.stream().filter(x -> func.expression(x)).findFirst();
    // 결과가 있으면
    if (op.isPresent()) {
      // 결과를 리턴
      return op.get();
    }
    // 없으면 null 처리
    return null;
  }
  // browser에서 웹 소켓으로 접속하면 호출되는 함수
  @OnOpen
  public void handleOpen(Session userSession) {
    // 인라인 클래스 User를 생성
    User user = new User();
    // Unique키를 발급 ('-'는 제거한다.)
    user.key = UUID.randomUUID().toString().replace("-", "");
    // WebSocket의 세션
    user.session = userSession;
    // 유저 리스트에 등록한다.
    sessionUsers.add(user);
    // 운영자 Client에 유저가 접속한 것을 알린다.
    Admin.visit(user.key);
  }
  // browser에서 웹 소켓을 통해 메시지가 오면 호출되는 함수
  @OnMessage
  public void handleMessage(String message, Session userSession) throws IOException {
    // Session으로 접속 리스트에서 User 클래스를 탐색
    User user = getUser(userSession);
    // 접속 리스트에 User가 있으면(당연히 있다. 없으면 버그..)
    if (user != null) {
      // 운영자 Client에 유저 key와 메시지를 보낸다.
      Admin.sendMessage(user.key, message);
    }
  }
  // 운영자 client가 유저에게 메시지를 보내는 함수
  public static void sendMessage(String key, String message) {
    // key로 접속 리스트에서 User 클래스를 탐색
    User user = getUser(key);
    // 접속 리스트에 User가 있으면(당연히 있다. 없으면 버그..)
    if (user != null) {
      try {
        // 유저 Session으로 socket을 취득한 후 메시지를 전송한다.
        user.session.getBasicRemote().sendText(message);
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }
  // WebSocket이 종료가 되면, 종료 버튼이 없기 때문에 유저 브라우저가 닫히면 발생한다.
  @OnClose
  public void handleClose(Session userSession) {
    // Session으로 접속 리스트에서 User 클래스를 탐색
    User user = getUser(userSession);
    // 접속 리스트에 User가 있으면(당연히 있다. 없으면 버그..)
    if (user != null) {
      // 운영자 Client에 유저 key로 접속 종료를 알린다.
      Admin.bye(user.key);
      // 위 유저 접속 리스트에서 유저를 삭제한다.
      sessionUsers.remove(user);
    }
  }
  // 유저간의 접속 리스트의 키를 취득하려고 할때.
  public static String[] getUserKeys() {
    // 반환할 String 배열을 선언한다.
    String[] ret = new String[sessionUsers.size()];
    // 유저 리스트를 반복문에 돌린다.
    for (int i = 0; i < ret.length; i++) {
      // 유저의 키만 반환 변수에 넣는다.
      ret[i] = sessionUsers.get(i).key;
    }
    // 값 반환
    return ret;
  }
}

여기까지가 index.jsp로 /broadsocket로 연결되는 유저 - 서버 간의 웹 소켓입니다.


이번에는 운영자와 서버간의 통신입니다.

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<title>Web Socket Example</title>
<style>
  /* 여러 채팅창 간의 간격과 배열 위치*/
  .float-left{
    float:left;
    margin: 5px;
  }
</style>
</head>
<body>
  <!-- 유저가 접속할 때마다 이 템플릿으로 채팅창을 생성한다. -->
  <div class="template" style="display:none">
    <form>
      <!-- 메시지 텍스트 박스 -->
      <input type="text" class="message" onkeydown="if(event.keyCode === 13) return false;">
      <!-- 전송 버튼 -->
      <input value="Send" type="button" class="sendBtn">
    </form>
    <br />
    <!-- 서버와 메시지를 주고 받는 콘솔 텍스트 영역 -->
    <textarea rows="10" cols="50" class="console" disabled="disabled"></textarea>
  </div>
  <!-- 소스를 간단하게 하기 위하 Jquery를 사용했습니다. -->
  <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
  <script type="text/javascript">
    // 서버의 admin의 서블릿으로 웹 소켓을 한다.
    var webSocket = new WebSocket("ws://localhost:8080/ChatExample/admin");
    // 운영자에서의 open, close, error는 의미가 없어서 형태만 선언
    webSocket.onopen = function(message) { };
    webSocket.onclose = function(message) { };
    webSocket.onerror = function(message) { };
    // 서버로 부터 메시지가 오면
    webSocket.onmessage = function(message) {
      // 메시지의 구조는 JSON 형태로 만들었다.
      let node = JSON.parse(message.data);
      // 메시지의 status는 유저의 접속 형태이다.
      // visit은 유저가 접속했을 때 알리는 메시지다.
      if(node.status === "visit") {
        // 위 템플릿 div를 취득한다.
        let form = $(".template").html();
        // div를 감싸고 속성 data-key에 unique키를 넣는다.
        form = $("<div class='float-left'></div>").attr("data-key",node.key).append(form); 
        // body에 추가한다.
        $("body").append(form);
      // message는 유저가 메시지를 보낼 때 알려주는 메시지이다.
      } else if(node.status === "message") {
        // key로 해당 div영역을 찾는다.
        let $div = $("[data-key='"+node.key+"']");
        // console영역을 찾는다.
        let log = $div.find(".console").val();
        // 아래에 메시지를 추가한다.
        $div.find(".console").val(log + "(user) => " +node.message + "\n");
      // bye는 유저가 접속을 끊었을 때 알려주는 메시지이다.
      } else if(node.status === "bye") {
        // 해당 키로 div를 찾아서 dom을 제거한다.
        $("[data-key='"+node.key+"']").remove();
      }
    };
    // 전송 버튼을 클릭하면 발생하는 이벤트
    $(document).on("click", ".sendBtn", function(){
      // div 태그를 찾는다.
      let $div = $(this).closest(".float-left");
      // 메시지 텍스트 박스를 찾아서 값을 취득한다.
      let message = $div.find(".message").val();
      // 유저 key를 취득한다.
      let key = $div.data("key");
      // console영역을 찾는다.
      let log = $div.find(".console").val();
      // 아래에 메시지를 추가한다.
      $div.find(".console").val(log + "(me) => " + message + "\n");
      // 텍스트 박스의 값을 초기화 한다.
      $div.find(".message").val("");
      // 웹소켓으로 메시지를 보낸다.
      webSocket.send(key+"#####"+message);
    });
    // 텍스트 박스에서 엔터키를 누르면
    $(document).on("keydown", ".message", function(){
      // keyCode 13은 엔터이다.
      if(event.keyCode === 13) {
        // 버튼을 클릭하는 트리거를 발생한다.
        $(this).closest(".float-left").find(".sendBtn").trigger("click");
        // form에 의해 자동 submit을 막는다.
        return false;
      }
      return true;
    });
  </script>
</body>
</html>
import java.io.IOException;
import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
// 운영자 유저에서 서버간의 웹 소켓 url
@ServerEndpoint("/admin")
public class Admin {
  // 운영자 유저는 하나라고 가정하고 만약 둘 이상의 세션에서 접속을 하면 마지막 세션만 작동한다.
  private static Session admin = null;
  // 운영자 유저가 접속을 하면 발생하는 이벤트 함수
  @OnOpen
  public void handleOpen(Session userSession) {
    // 기존에 운영자 유저의 소켓이 접속중이라면
    if (admin != null) {
      try {
        // 접속을 끊는다.
        admin.close();
      } catch (IOException e) {

      }
    }
    // 운영자 유저의 세션을 바꾼다.
    admin = userSession;
    // 기존에 접속해 있는 유저의 정보를 운영자 client로 보낸다.
    for(String key : BroadSocket.getUserKeys()) {
      // 전송.. 전송
      visit(key);
    }
  }
  // 운영자 유저가 메시지를 보내면 발생하는 이벤트
  @OnMessage
  public void handleMessage(String message, Session userSession) throws IOException {
    // key와 메시지 구분키를 #####를 넣었다. (json으로 해도 되는데 Gson 연결까지 하면 귀찮아져서...)
    String[] split = message.split("#####", 2);
    // 앞은 key 데이터
    String key = split[0];
    // 뒤 정보는 메시지
    String msg = split[1];
    // 일반 유저의 key로 탐색후 메시지 전송
    BroadSocket.sendMessage(key, msg);
  }
  // 접속이 끊기면 위 운영자 세션을 null 처리한다.
  @OnClose
  public void handleClose(Session userSession) {
    admin = null;
  }
  // 운영자 유저로 메시지를 보내는 함수
  private static void send(String message) {
    if (admin != null) {
      try {
        admin.getBasicRemote().sendText(message);
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }
  // 일반 유저가 접속했을 때, 운영자 유저에게 알리는 함수
  public static void visit(String key) {
    // json 구조로 status는 visit이고 key는 유저 키 정보이다.(javascript와 맞추는 프로토콜)
    send("{\"status\":\"visit\", \"key\":\"" + key + "\"}");
  }
  // 일반 유저가 메시지를 보낼 때, 운영자 유저에게 알리는 함수
  public static void sendMessage(String key, String message) {
    // json 구조로 status는 message이고 key는 유저 키 정보이다.(javascript와 맞추는 프로토콜) message는 보내는 메시지이다.
    send("{\"status\":\"message\", \"key\":\"" + key + "\", \"message\":\"" + message + "\"}");
  }
  // 일반 유저가 접속을 끊을 때, 운영자 유저에게 알리는 함수
  public static void bye(String key) {
    // json 구조로 status는 bye이고 key는 유저 키 정보이다.(javascript와 맞추는 프로토콜)
    send("{\"status\":\"bye\", \"key\":\"" + key + "\"}");
  }
}

이제 소스 작성은 끝났으니 실행해 보겠습니다.

먼저 일반 유저의 3개의 창으로 접속하겠습니다.

그리고 이번에는 운영자 유저를 접속하겠습니다.

접속하니 세개의 채팅 창이 열린 것을 확인할 수 있습니다.


먼저 일반 유저로 각각 다르게 메시지를 보내봅니다.

운영자 유저의 각기 창으로 각기 메시지가 열린 것을 확인할 수 있습니다.


이번에는 운영자 유저가 답해야 할 차례입니다.

각각의 브라우져 창으로 메시지가 전송되는 것을 확인 할 수 있습니다.


이번에는 제가 유저2의 브라우저를 닫겠습니다.

운영자 유저의 채팅 창에서 유저 2의 채팅창이 없어졌습니다.


얼추 사양이 맞는 것 같습니다. 디자인이나 javascript는 최대한 이해하기 쉽게 Jquery와 바닐라를 섞어서 작성했습니다.

실무에서 사용하시려면 보안을 위해서 프로토콜도 복잡하게 하고 스크립트를 잘 알아서 사양에 맞게 작성하시면 될 것 같네요.


여기까지 Java에서 Websocket을 이용해서 유저(사이트 운영자)가 다른 유저와 채팅하는 방법에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.