[Springboot] WebSocket+STOMP 이용 채팅 구현
개발 환경
Springboot를 이용했다.
DB로는 기본적으로는 MySQL을 사용했다. 채팅방 정보는 MySQL에 저장하고, 채팅 메시지 정보는 MongoDB에 저장하였다.
이 외에 로그인에 사용할 토큰은 Redis에 저장하였다.
WebSocket + STOMP
WebSocket
WebSocket은 웹/앱과 서버 간의 지속적인 연결을 제공하는 프로토콜로, 서버와 클라이언트 간에 양방향 통신을 가능하도록 한다. HTTP와는 달리 WebSocket 연결은 한 번 열리면 계속 유지되기 때문에 실시간으로 진행되는 통신(ex.채팅)에서 사용된다.
이전까지 개발하던 RestAPI와 다른 점은 한 번 API 콜을 보낸 후 끝나는 것이 아니라, 한 번 연결되면 계속 통신이 가능하다는 점.
STOMP
STOMP에 대한 설명은 아래 기술 블로그를 참고하면 좋을 것 같다.
https://velog.io/@qkrqudcks7/STOMP%EB%9E%80
STOMP란?
websocket 위에서 동작하는 문자 기반 메세징 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 매커니즘이다.TCP와 웹소켓과 같은 신뢰할 수 있는 양방향 스트리
velog.io
이것을 Springboot에서 채팅을 구현하는 데에 사용하는 이유는 우선 Springboot는 WebSocket과 STOMP를 기본적으로 지원하기 때문에 이용하기가 쉽고, 둘을 함께 이용했을 때 메시리 처리 로직이 깔끔해지며 유지보수성이 좋아진다. WebSocket 만으로는 단순히 연결 유지 및 메시지 전송만 제공하기 때문에 추가적인 로직 구현이 필요하지만, STOMP를 이용하면 토픽(Topic) 기반의 Pub/Sub 패턴을 지원하기 때문하여 하나의 메시지를 다수의 구독자에게 자동으로 전달할 수 있어 채팅 시스템과 알림 시스템 증에 적합하다.
지금 채팅방에 입장하고 있는지, 메시지를 보낸 건지, 메시지를 받은 건지, 채팅방에서 나가는 것인지 등등을 보다 쉽게 관리할 수 있다.
이를 이용해 구현할 채팅 기능의 로직을 간단히 설명하면 다음과 같다.
1. 웹소켓 연결(connect)
2. 채팅방 구독(subscribe)
3. 구독한 채팅방에 채팅을 전송
4. 구독한 채팅방으로 채팅 수신
더 자세한 설명은 아래의 구현 단계에서 설명하도록 하겠다.
본격적인 개발에 앞서!
MongoDB 설치
만약 MongoDB가 설치되어있지 않다면 설치해준다. 만약 채팅 내용을 다른 DB에 저장할 것이라면 이 과정은 생략해도 된다.
내가 MongoDB에 채팅 내용을 저장하기로 한 이유는 채팅 서비스는 실시간으로 많은 채팅 메시지가 저장되어야 하므로 빠른 쓰기 성능이 중요한데, MongoDB는 NoSQL 기반이어서 트랜잭션 관리가 필요 없는 경우 빠른 INSERT 작업이 가능하다. 또 채팅방별 메시지를 빠르게 조회할 수 있어야 하는데, MongoDB는 컬렉션 내 Document 단위의 인덱스 지원으로 특정 채팅방의 채팅 내역을 빠르게 검색할 수 있다. (조회 성능이 좋다.)
채팅 내용은 수정이 불가능하도록 구현할 것이고, 조회 성능이 가장 중요하기에 MongoDB를 선택하게 되었다.
맥북 M2 기준으로 다음의 명령어를 이용하여 설치 완료했다.
brew update
brew install mongodb-community@6.0
그리고 Springboot를 실행할 때 항상 MongoDB도 함께 실행해준다.
brew services start mongodb-community@6.0 // 몽고 DB 시작
brew services stop mongodb-community@6.0 // 몽고 DB 중지
build.gradle
gradle 파일에 다음을 추가한다.
// mongoDB
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
// websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.security:spring-security-messaging'
application.yml
내가 사용한 로컬용 yml 파일은 다음과 같다. 웹소켓과 관련없는 부분들은 삭제한 버전이다.
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/cockChat?createDatabaseIfNotExist=true&characterEncoding=UTF-8&characterSetResults=UTF-8
username: root
password:
jpa:
hibernate:
ddl-auto: update # create -> update
generate-ddl: true
show-sql: true
data:
mongodb:
uri: mongodb://localhost:27017
database: cockChatMongo
mvc:
hidden-method:
filter:
enabled: true
websocket:
time-to-live: 1800000
logging:
level:
org.springframework.web.socket: DEBUG
org.springframework.messaging: DEBUG
management:
endpoints:
web:
exposure:
include: "*"
server:
forward-headers-strategy: framework
servlet:
session:
timeout: 30m
port: 8080
WebSocketConfig.java
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue"); // 클라이언트가 구독할 수 있는 prefix
registry.setApplicationDestinationPrefixes("/app"); // 클라이언트가 서버로 보낼 때 사용하는 prefix
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-chat")
.setAllowedOriginPatterns("*")
.setAllowedOrigins("http://localhost:63342","https://도메인", "https://도메인.vercel.app")
.addInterceptors(new HttpSessionHandshakeInterceptor() {
@Override
public boolean beforeHandshake(
ServerHttpRequest request, ServerHttpResponse response,
org.springframework.web.socket.WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
System.out.println("🔍 WebSocket Request Headers: " + request.getHeaders());
System.out.println("🔍 Upgrade Header: " + request.getHeaders().getFirst("Upgrade"));
System.out.println("🔍 Connection Header: " + request.getHeaders().getFirst("Connection"));
return true;
}
@Override
public void afterHandshake(
ServerHttpRequest request, ServerHttpResponse response,
org.springframework.web.socket.WebSocketHandler wsHandler, Exception exception) {
System.out.println("✅ WebSocket Handshake 완료!");
}
});
//.withSockJS()
}
}
메시지 브로커 관련 설정들은 다음과 같다.
.enableSimpleBroker("/topic", "/queue")
메시지 브로커를 활성화하여 특정 prefix를 가지는 메시지를 클라이언트에게 전달한다. 뒤에 이어서 자세히 나오겠지만 나는 /topic/public/{roomId} 로 구독하여 채팅방의 채팅들을 클라이언트에게 전달될 수 있도록 구현하였다.
/topic vs /queue
- /topic : Broadcast (여러 클라이언트에게 전달, 즉 그룹 채팅)
-/queue : Point-to-Point (특정 클라이언트에게 1:1 전달, 즉 개인 메시지)
일반적으로 위의 방식을 많이 따른다고 한다!
.setApplicationDestinationPrefixes("/app")
클라이언트가 서버로 보낼 메시지의 prefix를 설정한다. 이것 역시 뒤에 이어서 다시 나오겠지만 나는 /app/chat/send를 통해 사용자가 채팅을 보내면 앞서 설정해놓은 브로드캐스팅 prefix인 /topic/public/{roomId}로 사용자가 보낸 메시지가 전달되도록 구현하였다.
사용자가 메시지를 보낼 때 이용하게 되는 경로라고 이해해도 좋다!
이어서 STOMP 관련 설정들을 다음과 같다.
.addEndPoint("/ws-chat")
웹소켓이 연결된 경로의 엔드포인트를 지정한다.
위와 같이 설정해놓은 경우, 예를 들어 다음과 같이 웹소켓 연결을 시도해볼 수 있다.
터미널 창에서 다음 명령어를 이용한다. 결과로 200인 아닌 101 이 반환되면 정상적으로 웹소켓이 연결된 것이다.
wscat -c ws://localhost:8080/ws-chat # 로컬에서 테스트하는 경우
wscat -c wss://도메인/ws-chat # https 배포 도메인에서 연결하는 경우
.setAllowOrigins()
허용할 경로를 열어둔다. 보통 작성하는 CorsConfig와는 별도로 여기서도 허용해주어야 한다. 아직 프론트와의 연결 작업이 완료되지 않아서 다양한 경로를 허용해두었지만, 이후 서버에서 사용할 경로만 남겨준다.
.addInterceptors()
웹소켓 연결 관련 로그를 확인하기 위해 추가해놓은 부분으로, 구현하는 데에 필수적인 부분은 아니다. 생략해도 된다.
.withSockJS()
이 부분을 나는 주석처리해두었지만, 주석 해제해도 된다. 주석을 해제할 경우 만약 브라우저의 버전 등에 의한 이유로 웹소켓 연결을 실패했을 때 웹소켓을 대신하여 Polling(폴링) 방식을 이용해 통신하도록 해준다.
웹소켓 vs 폴링
웹소켓 : 양방향 통신이 가능하여 서버와 클라이언트 간 실시간 통신이 가능함. 성공 코드로 101 반환.
폴링 : 클라이언트가 주기적으로 서버에 요청을 보내 새로운 데이터가 있는지를 확인함. 성공 코드로 200 반환.
나의 경우 배포 환경에서 웹소켓 연결이 계속 실패하고 대신 폴링이 이용된다는 점을 발견하였다. 처음에는 이를 알지 못하고 웹소켓 연결이 성공한 줄 알았다. 하지만 응답 코드로 웹소켓이 아닌 폴링에서의 200이 온다는 점을 알게 되었고, 우선 웹소켓 연결이 안 되는 원인을 해결하기 위해 withSockJS() 를 주석처리하여 강제로 웹소켓 연결만을 시도하도록 하였다.
(현재에는 문제를 해결하였지만, 아직까지 웹소켓 연결이 실패하는 경우는 없었기에 sockJS를 여전히 비활성화해두었다.)
따라서 이 부분 역시 허용해주거나 비활성화해주거나 선택하면 될 것 같다.
ChatMessageRequestDto.java
@Getter
public class ChatMessageRequestDto {
private Long roomId;
private MessageType type;
private Long senderId;
private String content;
}
클라이언트가 채팅을 서버로 보낼 때 전달해야 하는 내용이다.
MongoChatRepository.java
public interface MongoChatRepository extends MongoRepository<Chat, String> {
}
채팅 내용을 MongoDB에 저장하기 위해 작성해준다.
ChatController.java
@Controller
@Slf4j
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
/* 채팅 보내기 */
@MessageMapping("/chat/send")
public void sendMessage(@Payload ChatMessageRequestDto requestDto){
log.info("Received message request: {}", requestDto);
chatService.sendMessage(requestDto.getRoomId(), requestDto);
}
}
위의 코드를 통해 클라이언트는 /app/chat/send 경로로 채팅을 보낼 수 있게 된다.
여기서 @MessageMapping에 의해 설정된 경로는 "/chat/send" 인데 어째서 /app/chat/send를 이용해야 하는지 궁금할 수도 있다. 이는 우리가 WebSocketConfig에서 설정한 내용 때문이다.
registry.setApplicationDestinationPrefixes("/app")
@MessageMapping("/chat/send")는 클라이언트가 /app/chat/send 로 보낸 메시지를 처리하게 된다.
ChatService.java
@Service
@Transactional
@RequiredArgsConstructor
public class ChatService {
@Autowired
private MongoChatRepository mongoChatRepository;
private final ChatRoomRepository chatRoomRepository;
private final ParticipantRepository participantRepository;
private final SimpMessagingTemplate messagingTemplate;
/* 메시지 보내기 */
public void sendMessage(Long roomId, ChatMessageRequestDto requestDto) {
/* 채팅 mongoDB 저장 */
Chat chat = mongoChatRepository.save(Chat.builder()
.chatroomId(roomId)
.participantId(requestDto.getSenderId())
.message(requestDto.getContent())
.chatType(requestDto.getType())
.build()
);
/* 메시지 송신 */
messagingTemplate.convertAndSend("/topic/public/" + roomId, chat);
}
}
사용자의 메시지를 MongoDB에 저장하고 앞서 설정해둔 /topic/public/roomId 경로로 전달한다. 그러면 /topic/public/roomId를 구독(subscribe) 하고 있는 사용자들은 메시지를 전달받을 수 있다. (=브로드캐스트)
이제 실제 메시지가 잘 전송되는지를 테스트해볼 것이다. 테스트하는 방법은 다양한데, 나의 경우 간단한 HTML 코드를 작성하여 실제 웹소켓 연결-구독-메시지 전달(+DB저장)-메시지 수신 이 과정들이 잘 이루어지는지를 확인하였다.
HTML 코드는 https://velog.io/@koseungbin/WebSocket 블로그를 참고하였다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>WebSocket 테스트</title>
<script src="https://cdn.jsdelivr.net/npm/stompjs/lib/stomp.min.js"></script>
</head>
<body>
<h3>연결 상태: <span id="status">연결 안 됨</span></h3>
<input type="text" id="messageInput" placeholder="메시지를 입력하세요" />
<button id="sendButton">전송</button>
<div id="messages"></div>
<script>
let stompClient = null;
function connect() {
const socket = new WebSocket("ws://localhost:8080/ws-chat"); // WebSocket 전용
stompClient = Stomp.over(socket);
stompClient.debug = null; // 콘솔 로그 제거 (선택)
stompClient.connect({}, function (frame) {
console.log("✅ 웹소켓 연결됨:", frame);
document.getElementById("status").innerText = "연결됨";
// 35번 방에 대한 구독
stompClient.subscribe("/topic/public/35", function (message) {
console.log("📩 받은 메시지:", JSON.parse(message.body));
displayMessage(JSON.parse(message.body).content);
});
}, function (error) {
console.error("❌ 연결 실패:", error);
document.getElementById("status").innerText = "연결 실패";
});
}
function sendMessage() {
const messageContent = document.getElementById("messageInput").value;
if (stompClient && stompClient.connected) {
const message = {
roomId: 35, // 항상 35번 방
type: "CHAT", // 메시지 타입
senderId: 20, // 보낸 사람 ID
content: messageContent // 메시지 내용
};
stompClient.send("/app/chat/send", {}, JSON.stringify(message));
document.getElementById("messageInput").value = ''; // 입력창 비우기
}
}
function displayMessage(message) {
const messagesDiv = document.getElementById('messages');
const messageElement = document.createElement('div');
messageElement.textContent = message;
messagesDiv.appendChild(messageElement);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
document.getElementById('sendButton').addEventListener('click', sendMessage);
connect(); // 페이지 로드 시 자동 연결
</script>
</body>
</html>
위의 코드는 로컬에서 잘 동작하는지만 확인하기 위해 작성한 것이어서 사용자 정보를 인증하는 부분이라던가 하는 내용은 생략되었다. 해당 내용은 뒤에 게시할 포스트에서 설명하도록 하겠다.
인텔리제이의 resources > static 폴더에 html 파일 생성하여 작성해주고 열어주면 된다. 이후 개발자 도구를 열어 로그를 확인할 수 있다.
피드백은 언제나 환영합니다! :)
전체 코드를 볼 수 있는 레포는 아래에 있습니다!
https://github.com/2024-fall-ewha-capston-design/BACK
GitHub - 2024-fall-ewha-capston-design/BACK: 캡스톤 디자인 백 레포입니다.
캡스톤 디자인 백 레포입니다. Contribute to 2024-fall-ewha-capston-design/BACK development by creating an account on GitHub.
github.com