[Spring/WebSocket] WebSocket + STOMP으로 귓속말 가능한 채팅방 만들기

·2025년 6월 18일

Spring

목록 보기
20/26
post-thumbnail

이전 포스팅 [Spring/WebSocket] 순수 WebSocket으로 채팅방 만들기에서 이어집니다

1. [귓속말 + STOMP]

1. 프로젝트 하위에 stompwebsocket패키지 생성 및 파일 작성

1-1. 주의사항

purewebsocket.config.WebSocketConfig의 @EnableWebSocket, @Configu은 주석처리
→@EnableWebSocketMessageBroker과 중복되면 에러발생(STOMP)

1-2. websocketConfig.java

package com.example.backendproject.stompwebsocket.config;

import com.example.backendproject.stompwebsocket.handler.CustomHandshakeHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-chat")
                .setHandshakeHandler(new CustomHandshakeHandler()) //귓속말 가능하게 해줌
                .setAllowedOriginPatterns("*");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //Prefix <- 메세지의 목적지를 구분하기 위한 접두고

        /** 구독용 Profix **/

        // /topic : 일반 채팅 받을 접두어
        // /queue : 귓속말 받을 접두어

        //구독용 경로 서버 -> 클라이언트(메시지를 분배한다)
        registry.enableSimpleBroker("/topic", "/queue");
        ```
        //전송용 경로 클라이언트 -> 서버 (메시지가 들어온다)
        registry.setApplicationDestinationPrefixes("/app");
        
        // /user 특정 사용자에게 메시지를 보낼 접두어
        /** 서버가 특정 사용자에게 메시지를 보낼 떄, 클라이언트가 구독할 경로 접두어 **/
        registry.setUserDestinationPrefix("/user"); //서버 -> 특정사용자
    }
    
    
}

1-3.ChatController.java

package com.example.backendproject.stompwebsocket.controller;

import com.example.backendproject.stompwebsocket.dto.ChatMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;

//STOMP는 이게 끝.
@Controller
@RequiredArgsConstructor
public class ChatController {
    
    private final SimpMessagingTemplate template;
    
    //동적으로 방 생성 가능
    @MessageMapping("/chat.sendMessage")
    public void sendmessage(ChatMessage message){
        if(message.getTo() != null && !message.getTo().isEmpty()){
            //귓속말
            //내 아이디로 귓속말 경로를 활성화 함
            template.convertAndSendToUser(message.getTo(), "/queue/private",message);
        }else {
            //일반 메시지
            //message에서 roomId를 추출해서 해당 roomId를 구독하고 있는 클라이언트에게 메시지를 전달
            template.convertAndSend("/topic/"+message.getRoomId(),message);
        }
    }
}

1-4. ChatMessage.java

package com.example.backendproject.stompwebsocket.dto;


import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
    private String message;
    private String from;

    private String to;  //귓속말을 받을 사람
    private String roomId;  //방 id
}

1-5. CustomHandshakeHandler.java

package com.example.backendproject.stompwebsocket.handler;

import org.springframework.http.server.ServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;

import java.security.Principal;
import java.util.Map;

//연결된 요청 url에서 사용자를 식별
public class CustomHandshakeHandler extends DefaultHandshakeHandler {
    @Override
    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {

        String nickname = getNickname(request.getURI().getQuery());
        return new StompPricipal(nickname);
    }

    //요청이 들어오면 닉네임을 추출해서 닉네임이 없으면 닉네임 없음 출력, 있으면 사용자 추출하는 핸들러
    private String getNickname(String query){
        if(query == null || !query.contains("nickname=")){
            System.out.println("겟닉네임작동");
            return "닉네임 없음";
        }
        else{
            return query.split("nickname=")[1];
        }
    }
}

1-6. StompPricipal

package com.example.backendproject.stompwebsocket.handler;

import java.security.Principal;

public class StompPricipal implements Principal {

    private final String name;

    public StompPricipal(String name) {
        this.name = name;
    }

    @Override
    public String getName() {
        return name;
    }
}

1-7. stompcaht2.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>WebSocket STOMP Chat</title>
    <style>
        body { font-family: 'Segoe UI', sans-serif; background: #f7f8fa; }
        .container {
            width: 400px; margin: 60px auto; background: #fff; padding: 32px 30px;
            border-radius: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.07);
        }
        h2 { text-align: center; color: #2c3e50; margin-bottom: 20px;}
        #chatArea {
            width: 100%; height: 250px; border: 1px solid #aaa;
            margin-bottom: 18px; overflow-y: auto; padding: 10px 7px; border-radius: 8px;
            background: #fafdff; font-size: 15px;
        }
        .row { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
        input[type="text"] {
            box-sizing: border-box; border: 1px solid #ccc; border-radius: 6px;
            font-size: 15px; padding: 9px; outline: none; background: #f9fafd;
            transition: border 0.2s;
        }
        input[type="text"]:focus { border-color: #4078c0; background: #fff; }
        #user, #room { width: 110px; }
        #msg { flex: 1; min-width: 0; }
        button {
            background: #4078c0; color: white; font-weight: bold;
            border: none; border-radius: 6px; padding: 10px 20px;
            font-size: 15px; cursor: pointer; transition: background 0.2s;
        }
        button:hover { background: #285690; }
        .btn-disconnect {
            background: #eee; color: #285690; font-weight: bold;
        }
        .btn-disconnect:hover { background: #e0e8f5; }
        .sysmsg { color: #666; font-style: italic; margin: 7px 0 3px 0;}
        .msgrow { margin-bottom: 3px;}
        .from { font-weight: bold; color: #4078c0;}
        .hidden { display: none; }
        #roomList { margin-bottom: 20px; }
        .room-item {
            padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; margin-bottom: 5px;
            cursor: pointer;
        }
        .room-item:hover { background: #e8f0ff; }
    </style>
</head>
<body>
<div class="container">
    <h2>Spring WebSocket + STOMP Chat</h2>

    <!-- 방 목록 -->
    <div id="roomList">
        <strong>방 목록:</strong>
        <div id="rooms"></div>
    </div>

    <!-- 입장 영역 -->
    <div class="row" id="enterRow">
        <input type="text" id="user" placeholder="닉네임">
        <input type="text" id="room" placeholder="방 번호">
        <button onclick="connect()">Connect</button>
    </div>

    <!-- 귓속말 대상 -->
    <div class="row hidden" id="whisperRow">
        <input type="text" id="whisperTo" placeholder="귓속말 대상 (닉네임)">
    </div>

    <!-- 채팅 영역 (입장 후에만 표시) -->
    <div id="chatWrapper" class="hidden">
        <div id="chatArea"></div>
        <div class="row">
            <input type="text" id="msg" placeholder="메시지">
            <button onclick="sendMessage()">Send</button>
            <button class="btn-disconnect" onclick="disconnect()">Disconnect</button>
        </div>
    </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
<script>
    let stompClient = null;
    let nickname = "";
    let roomId = "";

    window.onload = function () {
        loadRoomList();
    };

    function loadRoomList() {

        fetch("/api/rooms")
            .then(response => response.json())
            .then(data => {
                const roomsDiv = document.getElementById("rooms");
                const roomListDiv = document.getElementById("roomList");
                roomsDiv.innerHTML = "";
                if (data.length === 0) {
                    roomListDiv.classList.add("hidden");
                } else {
                    roomListDiv.classList.remove("hidden");
                    data.forEach(room => {
                        const div = document.createElement("div");
                        div.className = "room-item";
                        div.textContent = `방 번호: ${room.roomId}`;
                        div.onclick = () => {
                            const nick = prompt("닉네임을 입력하세요:");
                            if (!nick) return;
                            document.getElementById("user").value = nick;
                            document.getElementById("room").value = room.roomId;
                            connect();
                        };
                        roomsDiv.appendChild(div);
                    });
                }
            });
    }

    function connect() {
        nickname = document.getElementById("user").value;
        roomId = document.getElementById("room").value;

        if (!nickname || !roomId) {
            alert("닉네임과 방 번호를 입력하세요!");
            return;
        }

        //방생성기능
        //방생성기능
       fetch(`/api/rooms/${roomId}`, { method: "POST" })

      //  const socket = new WebSocket('/ws-chat');
        const socket = new WebSocket('/ws-chat?nickname=' + nickname);
        stompClient = Stomp.over(socket);

        stompClient.connect({}, function (frame) {
            showSysMsg(`[${nickname}]님이 방 [${roomId}]에 입장했습니다.`);
            document.getElementById("chatWrapper").classList.remove("hidden");
            document.getElementById("enterRow").classList.add("hidden");
            document.getElementById("roomList").classList.add("hidden");

           document.getElementById("whisperRow").classList.remove("hidden"); // ✅ 여기!  닉네임 작성


            stompClient.subscribe(`/topic/${roomId}`, function (chat) {
                const message = JSON.parse(chat.body);
                showMessage(message.from, message.message);
            });


            //귓속말
            stompClient.subscribe('/user/queue/private', function (message) {
                const msg = JSON.parse(message.body);
                alert("💬 귓속말: " + msg.from + " - " + msg.message);
            });

        });


    }

    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        showSysMsg('Disconnected');
        document.getElementById("chatWrapper").classList.add("hidden");
        document.getElementById("enterRow").classList.remove("hidden");
        document.getElementById("roomList").classList.remove("hidden");
        document.getElementById("whisperRow").classList.add("hidden"); // ✅ 추가
        loadRoomList();
    }

    function sendMessage() {
        const msg = document.getElementById("msg").value;

        const toUser = document.getElementById("whisperTo").value.trim();

        if (!nickname || !msg || !roomId) {
            alert("모든 정보를 입력해주세요!");
            return;
        }

        const payload = {
            from: nickname,
            message: msg,
            roomId: roomId
        };

        if (toUser) {
            payload.to = toUser; // 귓속말 대상이 있으면 to 추가
        }

        stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(payload));
        document.getElementById("msg").value = "";
    }

    function showMessage(from, message) {
        const chatArea = document.getElementById("chatArea");
        chatArea.innerHTML += `<div class="msgrow"><span class="from">${from}:</span> ${message}</div>`;
        chatArea.scrollTop = chatArea.scrollHeight;
    }
    function showSysMsg(msg) {
        const chatArea = document.getElementById("chatArea");
        chatArea.innerHTML += `<div class="sysmsg">${msg}</div>`;
        chatArea.scrollTop = chatArea.scrollHeight;
    }

    document.getElementById('msg').addEventListener('keydown', function(e) {
        if (e.key === 'Enter') sendMessage();
    });
</script>
</body>
</html>

2. htmlController에서 stompchat2.html로 변경

@Controller
public class HtmlController {
    @GetMapping("/")
    public String index(){
        return "redirect:/stompchat2.html";
    }
}

3. 결과 확인

profile
배우고 기록하며 성장하는 백엔드 개발자입니다!

0개의 댓글