- Simple Text-Oriented Messaging Protocol
- 메시지 브로커를 활용하여 쉽게 메시지를 주고 받을 수 있는 프로토콜
- pub/sub기반과 메시지 브로커 (발신자의 메시지를 받아와서 수신자에게 메시지를 전달하는 것)
- 웹소켓 위에 얹어 함께 사용할 수 있는 하위(서브) 프로토콜
- STOMP를 통해 클라이언트와 서버가 어떤 형식으로 메시지를 주고받을지 타입은 어떻게 명시할 것인지 본문과 설정데이터들을 어떻게 구분할 것인지를 정의해줄 수 있다
- STOMP는 HTTP와 비슷하게 command, header, body로 이루어져 있다

COMMAND
header1:value1
header2:value2Body^@
클라이언트가 5번 채팅방을 구독하는 경우
SUBSCRIBE
destination: /topic/chat/room/5
id: sub-1^@
클라이언트가 5번 채팅방에 메시지를 보내는 경우
SEND
destination: /pub/chat/room/5
content-type: application/json{"type": "MESSAGE", "writer": "clientB"} ^@
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {
// STOMP에서는 메시지 브로커를 사용한다
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 메시지를 구독 요청 URL => 즉 메시지를 받을 떄
registry.enableSimpleBroker("/app","/sub");
// 내장 브로커 사용, /queue, /topic, /pub 경로로 송신됐을 떄 심플 브로커가 메시지를 받고 구독자에게 전달
// /topic은 메시지가 일대다 (브로드캐스팅) 일때 /queue 는 1:1 일때 사용한다는 컨벤션이 있다
// 메시지를 발행 요청 URL => 즉 메시지를 보낼 때
registry.setApplicationDestinationPrefixes("/queue", "/topic","/pub");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 웹소켓 연결주소, 이전처럼 Handler 하나하나 추가할 필요 없다
registry.addEndpoint("/gs-guide-websocket").withSockJS();
}
}
@Controller
public class GreetingController {
// 간단한 메시징 프로토콜(예: STOMP)에 사용하기 위한 메서드가 포함된 MessageSendingOperations의 구현체
private final SimpMessageSendingOperations simpMessageSendingOperations;
// SimpMessageSendingOperations 구현체
SimpMessagingTemplate simpMessagingTemplate;
// 기존 request 매핑과 비슷하다, http 매핑이 들어오면 여기로 온다
// config에서 지정한 prefix인 /pub이 합쳐져 /pub/{chattingRoomId}/messages로 메시지 발행 요청
@MessageMapping("/{chattingRoomId}/messages")
// 데이터 처리가 완료되면 이쪽으로 보내겠다는 뜻
@SendTo("/sub/{chattingRoomId") // ConvertAndSend 대신 사용해도 된다
public String greeting(@DestinationVariable("chattingRoomId") String chattingRoomId, @Payload String message) {
// @DesticationVariable을 써줘야 인식을 한다
// simpMessageSendingOperations.convertAndSend("/topic/wiki", data);
return "ok";
}
}
@Slf4j
@RequiredArgsConstructor
@Controller
public class ChatController {
private final SimpMessageSendingOperations template;
private final ChatRoomRepository repository;
@MessageMapping("/chat/enterUser")
public void enterUser(@Payload ChatMessageDto chat, SimpMessageHeaderAccessor headerAccessor) {
repository.plusUserCnt(chat.getRoomId());
log.debug("enter User {}", chat);
// 채팅방에 유저 추가 및 UserUUID 반환
String userUUID = repository.addUser(chat.getRoomId(), new ChatUserDto(chat.getSender()));
// 반환 결과를 socket session 에 userUUID 로 저장
headerAccessor.getSessionAttributes().put("userUUID", userUUID);
headerAccessor.getSessionAttributes().put("roomId", chat.getRoomId());
chat.setMessage(chat.getSender() +"님 입장!!");
template.convertAndSend("/sub/chat/room/" + chat.getRoomId(), chat);
}
// 해당 유저
@MessageMapping("/chat/sendMessage")
public void sendMessage(@Payload ChatMessageDto chat) {
log.info("CHAT {}", chat);
chat.setMessage(chat.getMessage());
template.convertAndSend("/sub/chat/room/" + chat.getRoomId(), chat);
}
// 유저 퇴장 시에는 EventListener 을 통해서 유저 퇴장을 확인
@EventListener
public void webSocketDisconnectListener(SessionDisconnectEvent event) {
log.info("DisConnEvent {}", event);
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
// stomp 세션에 있던 uuid 와 roomId 를 확인해서 채팅방 유저 리스트와 room 에서 해당 유저를 삭제
String userUUID = (String) headerAccessor.getSessionAttributes().get("userUUID");
String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
log.info("headAccessor {}", headerAccessor);
// 채팅방 유저 -1
repository.minusUserCnt(roomId);
// 채팅방 유저 리스트에서 UUID 유저 닉네임 조회 및 리스트에서 유저 삭제
String username = repository.getUserName(roomId, new ChatUserDto(userUUID));
repository.delUser(roomId, new ChatUserDto(userUUID));
if (username != null) {
log.info("User Disconnected : " + username);
// builder 어노테이션 활용
ChatMessageDto chat = ChatMessageDto.builder()
.type(ChatMessageDto.MessageType.LEAVE)
.sender(username)
.message(username + " 님 퇴장!!")
.build();
template.convertAndSend("/sub/chat/room/" + roomId, chat);
}
}
// 채팅에 참여한 유저 리스트 반환
@GetMapping("/chat/userlist")
@ResponseBody
public ArrayList<String> userList(String roomId) {
return repository.getUserList(roomId);
}
/*// 채팅에 참여한 유저 닉네임 중복 확인
@GetMapping("/chat/duplicateName")
@ResponseBody
public String isDuplicateName(@RequestParam("roomId") String roomId, @RequestParam("username") String username) {
// 유저 이름 확인
String userName = repository.isDuplicateName(roomId, username);
log.info("동작확인 {}", userName);
return userName;
}*/
}
package hello.rootTest.chat;
import hello.rootTest.chat.config.ChatRoomRepository;
import hello.rootTest.chat.dto.ChatRoomDto;
import hello.rootTest.root.security.dto.PrincipalDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;
@Controller
@Slf4j
@RequiredArgsConstructor
public class ChatRoomController {
private final ChatRoomRepository chatRoomRepository;
// 채팅 리스트 화면
// / 로 요청이 들어오면 전체 채팅룸 리스트를 담아서 return
// 스프링 시큐리티의 로그인 유저 정보는 Security 세션의 PrincipalDetails 안에 담긴다
// 정확히는 PrincipalDetails 안에 ChatUser 객체가 담기고, 이것을 가져오면 된다.
@GetMapping("/index")
public String goChatRoom(Model model, @AuthenticationPrincipal PrincipalDetails principalDetails){
List<ChatRoomDto> chatRooms = chatRoomRepository.findAllRoom();
model.addAttribute("list", chatRooms);
// principalDetails 가 null 이 아니라면 로그인 된 상태!!
if (principalDetails != null) {
// 세션에서 로그인 유저 정보를 가져옴
model.addAttribute("user", principalDetails.getAccountDto());
log.info("user [{}] ",principalDetails);
}
// model.addAttribute("user", "hey");
log.info("SHOW ALL ChatList {}", chatRoomRepository.findAllRoom());
return "roomlist";
}
// 채팅방 생성
// 채팅방 생성 후 다시 / 로 return
@PostMapping("/chat/createroom")
public String createRoom(@RequestParam("roomName") String name, @RequestParam("roomPwd")String roomPwd, @RequestParam("secretChk")String secretChk,
@RequestParam(value = "maxUserCnt", defaultValue = "100")String maxUserCnt, RedirectAttributes rttr) {
// log.info("chk {}", secretChk);
// 매개변수 : 방 이름, 패스워드, 방 잠금 여부, 방 인원수
ChatRoomDto room = chatRoomRepository.createChatRoom(name, roomPwd, Boolean.parseBoolean(secretChk), Integer.parseInt(maxUserCnt));
log.info("CREATE Chat Room [{}]", room);
rttr.addFlashAttribute("roomName", room);
return "redirect:/";
}
// 채팅방 입장 화면
// 파라미터로 넘어오는 roomId 를 확인후 해당 roomId 를 기준으로
// 채팅방을 찾아서 클라이언트를 chatroom 으로 보낸다.
@GetMapping("/chat/room")
public String roomDetail(Model model, @RequestParam("roomId") String roomId, @AuthenticationPrincipal PrincipalDetails principalDetails){
log.info("roomId {}", roomId);
// principalDetails 가 null 이 아니라면 로그인 된 상태!!
if (principalDetails != null) {
// 세션에서 로그인 유저 정보를 가져옴
model.addAttribute("user", principalDetails.getAccountDto());
}
model.addAttribute("room", chatRoomRepository.findRoomById(roomId));
log.info("rome detail {}", chatRoomRepository.findRoomById(roomId));
return "chatroom";
}
// 채팅방 비밀번호 확인
@PostMapping("/chat/confirmPwd/{roomId}")
@ResponseBody
public boolean confirmPwd(@PathVariable String roomId, @RequestParam String roomPwd){
// 넘어온 roomId 와 roomPwd 를 이용해서 비밀번호 찾기
// 찾아서 입력받은 roomPwd 와 room pwd 와 비교해서 맞으면 true, 아니면 false
return chatRoomRepository.confirmPwd(roomId, roomPwd);
}
/* // 채팅방 삭제
@GetMapping("/chat/delRoom/{roomId}")
public String delChatRoom(@PathVariable String roomId){
// roomId 기준으로 chatRoomMap 에서 삭제, 해당 채팅룸 안에 있는 사진 삭제
chatRoomRepository.delChatRoom(roomId);
return "redirect:/";
}*/
@GetMapping("/chat/chkUserCnt/{roomId}")
@ResponseBody
public boolean chUserCnt(@PathVariable String roomId){
return chatRoomRepository.checkRoomUserCount(roomId);
}
}
'use strict';
var chatPage = document.querySelector('#chat-page');
var messageForm = document.querySelector('#messageForm');
var messageInput = document.querySelector('#message');
var messageArea = document.querySelector('#messageArea');
var connectingElement = document.querySelector('.connecting');
var stompClient = null;
// username은 HTML에서 Thymeleaf를 통해 설정됩니다.
var colors = [
'#2196F3', '#32c787', '#00BCD4', '#ff5652',
'#ffc107', '#ff85af', '#FF9800', '#39bbb0'
];
// roomId 파라미터 가져오기
const url = new URL(location.href).searchParams;
const roomId = url.get('roomId');
function connect() {
// 연결하고자하는 Socket 의 endPoint
var socket = new SockJS('/ws-stomp');
stompClient = Stomp.over(socket);
stompClient.connect({}, onConnected, onError);
}
function onConnected() {
// sub 할 url => /sub/chat/room/roomId 로 구독한다
stompClient.subscribe('/sub/chat/room/' + roomId, onMessageReceived);
// 서버에 username 을 가진 유저가 들어왔다는 것을 알림
// /pub/chat/enterUser 로 메시지를 보냄
stompClient.send("/pub/chat/enterUser",
{},
JSON.stringify({
"roomId": roomId,
sender: username,
type: 'ENTER'
})
)
console.log("Connected and sent ENTER message"); // 디버깅을 위한 로그
connectingElement.classList.add('hidden');
}
function onError(error) {
connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
connectingElement.style.color = 'red';
}
function sendMessage(event) {
var messageContent = messageInput.value.trim();
var now = new Date();
var hours = String(now.getHours()).padStart(2, '0'); // 두 자리 수로 포맷팅
var minutes = String(now.getMinutes()).padStart(2, '0'); // 두 자리 수로 포맷팅
var time = hours + ':' + minutes; // "HH:mm" 형식으로 시간 포맷팅
if (messageContent && stompClient) {
var chatMessage = {
"roomId": roomId,
sender: username,
message: messageInput.value,
time: time,
type: 'TALK'
};
stompClient.send("/pub/chat/sendMessage", {}, JSON.stringify(chatMessage));
messageInput.value = '';
}
event.preventDefault();
}
function onMessageReceived(payload) {
var chat = JSON.parse(payload.body);
console.log("Received message:", chat); // 디버깅을 위한 로그
var messageElement = document.createElement('li');
if (chat.type === 'ENTER') {
messageElement.classList.add('event-message');
chat.content = chat.sender + "님이 입장하셨습니다.";
getUserList();
} else if (chat.type === 'LEAVE') {
messageElement.classList.add('event-message');
chat.content = chat.sender + '님이 퇴장하셨습니다.';
getUserList();
} else {
messageElement.classList.add('chat-message');
if (chat.sender === username) {
messageElement.classList.add('my-message');
} else {
messageElement.classList.add('other-message');
var avatarElement = document.createElement('i');
var avatarText = document.createTextNode(chat.sender[0]);
avatarElement.appendChild(avatarText);
avatarElement.style['background-color'] = getAvatarColor(chat.sender);
messageElement.appendChild(avatarElement);
var usernameElement = document.createElement('span');
var usernameText = document.createTextNode(chat.sender);
usernameElement.appendChild(usernameText);
messageElement.appendChild(usernameElement);
}
}
var contentElement = document.createElement('p');
if (chat.s3DataUrl != null) {
var imgElement = document.createElement('img');
imgElement.setAttribute("src", chat.s3DataUrl);
imgElement.setAttribute("width", "300");
imgElement.setAttribute("height", "300");
var downBtnElement = document.createElement('button');
downBtnElement.setAttribute("class", "btn fa fa-download");
downBtnElement.setAttribute("id", "downBtn");
downBtnElement.setAttribute("name", chat.fileName);
downBtnElement.setAttribute("onclick", `downloadFile('${chat.fileName}', '${chat.fileDir}')`);
contentElement.appendChild(imgElement);
contentElement.appendChild(downBtnElement);
} else {
var messageText = document.createTextNode(chat.message);
contentElement.appendChild(messageText);
}
// 시간 정보 추가
var timeElement = document.createElement('span');
var timeText = document.createTextNode(chat.time); // 서버에서 받은 time 정보 사용
timeElement.classList.add('message-time');
contentElement.appendChild(timeElement); // 시간 요소를 메시지 내용의 오른쪽에 추가
messageElement.appendChild(contentElement);
messageArea.appendChild(messageElement);
messageArea.scrollTop = messageArea.scrollHeight;
}
function getAvatarColor(messageSender) {
var hash = 0;
for (var i = 0; i < messageSender.length; i++) {
hash = 31 * hash + messageSender.charCodeAt(i);
}
var index = Math.abs(hash % colors.length);
return colors[index];
}
// 유저 리스트 받기
// ajax 로 유저 리스를 받으며 클라이언트가 입장/퇴장 했다는 문구가 나왔을 때마다 실행된다.
function getUserList() {
const $list = $("#list");
$.ajax({
type: "GET",
url: "/chat/userlist",
data: {
"roomId": roomId
},
success: function (data) {
var users = "";
for (let i = 0; i < data.length; i++) {
//console.log("data[i] : "+data[i]);
users += "<li class='dropdown-item'>" + data[i] + "</li>"
}
$list.html(users);
}
})
}
// 페이지 로드 시 자동으로 연결
window.addEventListener('load', connect);
messageForm.addEventListener('submit', sendMessage, true);
# compose 파일 버전
version: '3'
services:
# 서비스 명
zookeeper:
# 사용할 이미지
image: wurstmeister/zookeeper
# 컨테이너명 설정
container_name: zookeeper
# 접근 포트 설정 (컨테이너 외부:컨테이너 내부)
ports:
- "2181:2181"
# 서비스 명
kafka:
# 사용할 이미지
image: wurstmeister/kafka
# 컨테이너명 설정
container_name: kafka
# 접근 포트 설정 (컨테이너 외부:컨테이너 내부)
ports:
- "9092:9092"
# 환경 변수 설정
environment:
KAFKA_ADVERTISED_HOST_NAME: localhost
KAFKA_CREATE_TOPICS: "Topic:1:1" # 토픽이름:파티션개수:레플리카
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
# 볼륨 설정
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# 의존 관계 설정
depends_on:
- zookeeper
# zookeeper 도커 접속
docker exec -it zookeeper bash
# Zookeper 서버 실행
bash bin/zkServer.sh start /opt/zookeeper-3.4.13/conf/zoo.cfg -demon
# kafka 도커 접속
docker exec -it kafka bash
# kafka 서버 실행
kafka-server-start.sh -daemon
# 토픽 생성
kafka-topics.sh --create --zookeeper zookeeper:2181 --topic test-topic --partitions 1 --replication-factor 1
# 토픽 생성 확인
kafka-topics.sh --list --zookeeper zookeeper
# 토픽 삭제 명령어
kafka-topics.sh --delete --zookeeper zookeeper:2181 --topic [토픽 이름]