본문 바로가기
Java/java

웹소켓을 이용한 채팅 구현하기

by dyddyd0 2025. 4. 1.

spring boot를 이용해 채팅을 구현해 보았다.

 

사용기술

  • Spring Boot
  • Java
  • WebSocket
  • JSP
  • Thymeleaf

 

웹소켓을 이용한 채팅은 일반 HTTP API와 다르게 연결을 유지한 채 메시지를 주고받는다.

 

따라서 컨트롤러에는 화면을 렌더링 하는 API만 존재하며, 별도의 웹소켓 설정이 필요하다.

 

대신 웹소켓을 사용하기 위한 기본 설정이 필요하다.

ChatWebSocketHandelr , WebSocketConfig

 

 

여기까지 폴더구조는 다음과 같다.

📂 src
└── 📂 main
    └── 📂 java
        └── 📂 jpabasic.toyvaserver
            ├── 📂 config
            │   ├── 📄 ChatWebSocketHandler.java
            │   ├── 📄 WebSocketConfig.java
            ├── 📂 controller
            │   ├── 📄 ChatController.java
            ├── 📄 ToyvaServerApplication.java

 


Controller

제일 간단한 컨트롤러부터 보자.

package jpabasic.toyvaserver.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class ChatController {

    @GetMapping("chat")
    public String chatPage(){
        System.out.println("connect /chat");
        return "chat";
    }

}

화면단 요청밖에 필요하지 않아 이게 끝이다. println() 출력 부분도 필요 없음.

 

웹소켓은 요청을 띡 보내고 끝내는 일반 http api와 다르게, 한번 연결을 하면 종료하기 전까지 연결을 끊지 않는다.

 


Config

설정파일을 알아보자.

 

스프링에서 웹소켓을 도와주는 다음 TextWebSocketHandler 클래스를 사용할 건데,
아래 작성한 세 개의 메서드는 각각의 역할이 필요해서 구현해야 한다.

  • (1) afterConnectionEstablished() : 클라이언트가 웹소켓 연결을 요청할 때, 해당 세션을 리스트에 저장하여 관리하기 위함
  • (2) handleTextMessage() : 클라이언트가 메시지를 전송하면, 해당 메시지를 받아서 다른 모든 클라이언트에게 전달하기 위함
  • (3) afterConnectionClosed() : 클라이언트의 연결이 종료될 때, 세션 리스트에서 제거하여 메모리 누수를 방지하기 위함
package jpabasic.toyvaserver.config;

import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.util.concurrent.CopyOnWriteArrayList;

/*
 * WebSocket Handler 작성
 * 소켓 통신은 서버와 클라이언트가 1:n으로 관계를 맺는다. 따라서 한 서버에 여러 클라이언트 접속 가능
 * 서버에는 여러 클라이언트가 발송한 메세지를 받아 처리해줄 핸들러가 필요
 * TextWebSocketHandler를 상속받아 핸들러 작성
 * 클라이언트로 받은 메세지를 log로 출력하고 클라이언트로 환영 메세지를 보내줌
 * */
public class ChatWebSocketHandler extends TextWebSocketHandler {

    // CopyOnWriteArrayList : 동시성 제어 리스트 참고글 ->
    // https://curiousjinan.tistory.com/entry/java-copyonwritearraylist-concurrency#3.%20%EC%BD%94%EB%93%9C%20%EC%9E%91%EC%84%B1%3A%20ArrayList%EC%99%80%20CopyOnWriteArrayList%EB%A5%BC%20%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%20%ED%81%B4%EB%9E%98%EC%8A%A4%20(%EC%8A%A4%ED%94%84%EB%A7%81%20%EB%B9%88)-1
    // 현재 연결된 세션들을 담을거임
    private final CopyOnWriteArrayList<WebSocketSession> sessions = new CopyOnWriteArrayList<>();

    // 소켓연결 확인부 : 클라이언트가 웹소켓 연결 요청했을 때
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        sessions.add(session);
        System.out.println("새로운 클라이언트 연결: " + session.getId());
    }

    // 메세지 전송핸들부 : 클라이언트에게 메세지 전송받았을 때
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        System.out.println("수신한 메시지: " + payload);

        for (WebSocketSession s : sessions) {
            s.sendMessage(new TextMessage(payload));
        }
    }

    // 소켓 종료 확인부 : 클라이언트 연결 종료되면 실행
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        sessions.remove(session);
        System.out.println("클라이언트 연결 종료: " + session.getId());
    }
}

 

 

TextWebSocketHandler를 까보면 이렇다.


텅 비어있음.

 

이제 Config 파일을 작성해 보자

package jpabasic.toyvaserver.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@EnableWebSocket // WebSocket 기능을 활성화
public class WebSocketConfig implements WebSocketConfigurer {

    private final WebSocketHandler webSocketHandler;

    public WebSocketConfig() {
        webSocketHandler = new ChatWebSocketHandler();
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // WebSocket 엔드포인트를 "/ws/chat"으로 설정
        // (ws://localhost:8080/ws/chat 로 요청 들어오면, WebSocket 통신 시작)
        registry.addHandler(webSocketHandler, "/ws/chat")
                .setAllowedOrigins("*"); // CORS 허용 -> 모든 도메인(web, 모바일)에서 접속 허용

    }
}

별거 없다.

그냥 위에서 만든 웹소켓 핸들러를 registry의 핸들러로 추가해 주고 어느 경로에서 통신할 건지 지정해 주면 됨.

 


JSP

대충 jsp는 다음과 같다.

 

jsp 보면 다음 부분이 있는데,

<script>
  // WebSocket 서버에 연결
  const socket = new WebSocket("ws://" + window.location.host + "/ws/chat");

Config 파일에 적어준 엔드포인트와 WebSocket생성할 url의 엔드포인트 맞춰서 연결하자.

 

chat.jsp

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WebSocket Chat</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
  <style>
    body {
      font-family: Arial, sans-serif;
      background-color: #f8f9fa;
    }
    #chat {
      border: 1px solid #ccc;
      height: 300px;
      overflow-y: auto;
      margin-bottom: 10px;
      padding: 10px;
      background-color: white;
      border-radius: 5px;
    }
    #message {
      width: 80%;
      padding: 10px;
    }
    .btn-send {
      padding: 10px;
    }
  </style>
</head>
<body>

<!-- WebSocket 채팅 UI -->
<div class="container mt-4">
  <h2 class="mb-3">WebSocket 채팅</h2>

  <!-- 채팅 메시지 표시 영역 -->
  <div id="chat" class="mb-3"></div>

  <!-- 메시지 입력 및 전송 버튼 -->
  <div class="input-group">
    <input id="message" type="text" class="form-control" placeholder="메시지를 입력하세요">
    <button class="btn btn-primary btn-send" onclick="sendMessage()">전송</button>
  </div>
</div>

<script>
  // WebSocket 서버에 연결
  const socket = new WebSocket("ws://" + window.location.host + "/ws/chat");

  // 임시로 사용할 닉네임 (빈값 X, 12자 이하)
  let nickname;
  do {
    nickname = prompt("닉네임을 입력하세요 (최대 12자) :")?.trim();
  } while (!nickname || nickname.length > 12);

  // WebSocket이 열리면 실행되는 이벤트 핸들러
  socket.onopen = function() {
    console.log("WebSocket 연결됨");
  };

  // 서버로부터 메시지를 받으면 실행되는 이벤트 핸들러
  socket.onmessage = function(event) {
    const chat = document.getElementById("chat");
    const msg = document.createElement("p");

    msg.innerText = event.data; // 서버에서 받은 메시지
    chat.appendChild(msg); // 채팅 영역에 메시지 추가
    chat.scrollTop = chat.scrollHeight; // 스크롤을 최신 메시지로 자동 이동
  };

  // 메시지를 서버로 전송하는 함수
  function sendMessage() {
    const messageInput = document.getElementById("message");
    const message = messageInput.value; // 입력한 메시지 가져오기
    const sendingTime = new Date().toLocaleTimeString();

    if (message) {
      socket.send("["+sendingTime +"] " + nickname + " : " + message); // 메시지를 서버로 전송
      messageInput.value = ""; // 입력창 비우기
    }
  }

  // 엔터로 메세지 전송 허용
  document.getElementById("message").addEventListener("keydown", function(event) {
    if (event.key === "Enter") {
      event.preventDefault(); // 기본 Enter 동작 방지
      sendMessage(); // 메시지 전송
    }
  });

</script>

</body>
</html>

참고로 IDE환경에서는 잘 동작하는데,
jar 파일로 배포하는 springBoot에서는 배포된 폴더 구조 상 jsp는 비추한다고 공식으로 나와있다.
(jsp는 war배포에 적합하다 함.)

폴더구조를 resources/META-INF/resources/WEB-INF/jsp/chat.jsp 와 같이 변경해 주면 꼼수로 된다는데 나는 안 됐다.

boot를 war로 배포하는 방법도 있다지만, 본인은 화면단 리액트로 바꿀 거라 thymeleaf로 임시로 바꿈.

 

chat.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket Chat</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f8f9fa;
        }
        #chat {
            border: 1px solid #ccc;
            height: 300px;
            overflow-y: auto;
            margin-bottom: 10px;
            padding: 10px;
            background-color: white;
            border-radius: 5px;
        }
        #message {
            width: 80%;
            padding: 10px;
        }
        .btn-send {
            padding: 10px;
        }
    </style>
</head>
<body>

<!-- WebSocket 채팅 UI -->
<div class="container mt-4">
    <h2 class="mb-3">WebSocket 채팅</h2>

    <!-- 채팅 메시지 표시 영역 -->
    <div id="chat" class="mb-3"></div>

    <!-- 메시지 입력 및 전송 버튼 -->
    <div class="input-group">
        <input id="message" type="text" class="form-control" placeholder="메시지를 입력하세요">
        <button class="btn btn-primary btn-send" onclick="sendMessage()">전송</button>
    </div>
</div>

<script>
    // WebSocket 서버에 연결
    const socket = new WebSocket("ws://" + window.location.host + "/ws/chat");

    // 임시로 사용할 닉네임 (빈값 X, 12자 이하)
    let nickname;
    do {
        nickname = prompt("닉네임을 입력하세요 (최대 12자) :")?.trim();
    } while (!nickname || nickname.length > 12);

    // WebSocket이 열리면 실행되는 이벤트 핸들러
    socket.onopen = function() {
        console.log("WebSocket 연결됨");
    };

    // 서버로부터 메시지를 받으면 실행되는 이벤트 핸들러
    socket.onmessage = function(event) {
        const chat = document.getElementById("chat");
        const msg = document.createElement("p");

        msg.innerText = event.data; // 서버에서 받은 메시지
        chat.appendChild(msg); // 채팅 영역에 메시지 추가
        chat.scrollTop = chat.scrollHeight; // 스크롤을 최신 메시지로 자동 이동
    };

    // 메시지를 서버로 전송하는 함수
    function sendMessage() {
        const messageInput = document.getElementById("message");
        const message = messageInput.value; // 입력한 메시지 가져오기
        const sendingTime = new Date().toLocaleTimeString();

        if (message) {
            socket.send("["+sendingTime +"] " + nickname + " : " + message); // 메시지를 서버로 전송
            messageInput.value = ""; // 입력창 비우기
        }
    }

    // 엔터로 메세지 전송 허용
    document.getElementById("message").addEventListener("keydown", function(event) {
        if (event.key === "Enter") {
            event.preventDefault(); // 기본 Enter 동작 방지
            sendMessage(); // 메시지 전송
        }
    });

</script>

</body>
</html>

 

 

소켓연결 동작과정 부연설명
->

더보기


1. 소켓은 생성과 동시에 자동으로 연결을 요청한다.

  • const socket = new WebSocket("ws://" + location.host + "/ws/chat");를 호출하면, WebSocket 객체가 생성되며 즉시 서버에 연결 요청을 보낸다.
  • 연결이 성공하면 onopen 이벤트가 호출된다.

 

 

2. WebSocketConfig에서 endpoint를 같이 지정해주지 않으면 서버는 요청이 오는 주소를 찾지못해 클라이언트의 요청을 받지 못한다.

  • WebSocket의 엔드포인트는 클라이언트와 서버 간의 통신을 설정하는 경로임.
  • WebSocketConfig에서 적절한(동일하게) 엔드포인트를 지정하지 않으면, 클라이언트가 WebSocket 연결을 시도할 때 서버가 해당 요청을 인식하지 못하고 연결을 수립할 수 없다.

 

 3. 엔드포인트가 일치하면 클라이언트요청 서버가 받아서 웹소켓 자동으로 열린다.

  • 클라이언트가 지정한 URL(ws://localhost:8080/ws/chat)과 서버의 WebSocketConfig에서 설정한 엔드포인트가 일치하면, 클라이언트의 연결 요청이 성공적으로 수락된다.
  • 이때 서버는 새로운 WebSocket 세션을 생성하고, 클라이언트와의 통신을 시작함.

웹소켓 연결 수명주기와 이벤트 핸들러, CORS-보안 :

더보기

 

  • WebSocket 연결의 수명 주기:
    • WebSocket 연결은 한 번 열리면, 명시적으로 닫히기 전까지 계속 유지됨.
      이 때문에 서버와 클라이언트는 지속적으로 메시지를 주고받을 수 있다.
  • 이벤트 핸들러의 중요성:
    • WebSocket의 다양한 상태 변화에 대응하기 위해 onopen, onmessage, onclose, onerror와 같은 이벤트 핸들러를 설정하는 것이 중요함.
      이를 통해서 연결 상태를 관리하고 사용자에게 피드백을 제공.
  • CORS와 보안:
    • WebSocket 요청이 보안상의 이유로 CORS 정책을 따르므로, 필요한 경우 CORS 설정을 고려해야 함.
      이를 통해 다른 도메인에서의 WebSocket 요청을 허용할 수 있다.

 

반응형