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

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

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"); //서버 -> 특정사용자
}
}
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);
}
}
}
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
}
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];
}
}
}
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;
}
}
<!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>
@Controller
public class HtmlController {
@GetMapping("/")
public String index(){
return "redirect:/stompchat2.html";
}
}

