[Spring WebSocket] Spring WebSocket STOMP 적용
1. STOMP
STOMP(Simple Text Oriented Messaging Protocol)는 메시징 전송을 효율적으로 하기 위한 프로토콜로, PUB/SUB 기반으로 동작한다.
WebSocket만 사용해서 구현하면 해당 메시지가 어떤 요청인지, 어떤 포맷인지, 메시지 통신 과정을 어떻게 처리해야 하는지 정해져 있지 않아 일일이 구현해야 한다.
STOMP를 서브 프로토콜로 사용하면 클라이언트와 서버가 서로 통신하는 데 있어 메시지의 형식, 유형, 내용 등을 정의해준다. 따라서 메시지를 송신, 수신에 대한 처리를 명확하게 정의할 수 있으며, WebSocketHandler를 직접 구현할 필요없이, @MessageMapping
과 같은 어노테이션을 사용해서 메시지 발행 시 엔드포인트를 별도로 분리해서 관리할 수 있다.
스프링은 spring-websocket 모듈을 통해 STOMP를 제공하고 있다.
1.1. STOMP 형식
COMMAND
header1:value1
header2:value2
Body^@
클라이언트는 메시지를 전송하기 위해 COMMAND로 SEND 또는 SUBSCRIBE 명령을 사용하며, header와 value로 메시지의 수신 대상과 메시지에 대한 정보를 설명할 수 있다.
destination는 의도적으로 정보를 불분명하게 정의해 STOMP 구현체에서 구문에 따라 직접 의미를 부여할 수 있다. 따라서 destination 정보는 STOMP 서버 구현체마다 달라질 수 이씩 때문에 각 구현체의 스펙을 살펴봐야 한다.
그러나 일반적으로 아래 형식을 따른다.
"topic/.." -> publish-subscribe (1:N)
"queue/" -> point-to-point (1:1)
ex) 클라이언트A가 5번 채팅방 구독
SUBSCRIBE
destination: /topic/chat/room/5
id: sub-1
^@
ex) 클라이언트B가 채팅 메시지 전송
SEND
destination: /pub/chat
content-type: application/json
{"chatRoomId": 5, "type": "MESSAGE", "writer": "clientB"} ^@
STOMP 서버는 모든 구독자에게 메시지를 브로드캐시팅(BroadCasting)하기 위해 MESSAGE COMMAND를 사용할 수 있다.
서버는 내용을 기반으로 메시지를 전송할 브로커에 전달한다.
MESSAGE
destination: /topic/chat/room/5
message-id: d4c0d7f6-1
subscription: sub-1
{"chatRoomId": 5, "type": "MESSAGE", "writer": "clientB"} ^@
서버는 불분명한 메시지를 전송할 수 없기 때문에 서버의 모든 메시지는 특정 클라이언트 구독에 응답해야 하고, 서버 메시지의 “subscription-id” 헤더는 클라이언트 구독 “id” 헤더와 일치해야 한다.
2. Spring WebSocket STOMP
2.0. 작업 환경
Java 11
Spring Boot 2.7.12
Gradle 7.6.1
2.1. 의존성 주입
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}
2.2. WebSocket STOMP 설정
| WebSocketConfig.java
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;
@EnableWebSocketMessageBroker // (1)
@Configuration
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) { // (2)
registry.addEndpoint("/chattings") // (3)
.setAllowedOrigins("*");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) { // (4)
registry.enableSimpleBroker("/subscription"); // (5)
registry.setApplicationDestinationPrefixes("/publication"); // (6)
}
}
(1) 메시지 브로커가 지원하는 WebSocket 메시지 처리를 활성화
(2) HandShake와 통신을 담당할 EndPoint 지정
(3) WebSocket 연결 시 요청을 보낼 EndPoint 지정
(4) 메모리 기반의 Simple Message Broker 활성화
(5) 메시지 브로커가 Subscriber들에게 메시지를 전달할 URL 지정(메시지 구독 요청)
(6) 클라이언트가 서버로 메시지 보낼 URL 접두사 지정(메시지 발행 요청)
2.3. 채팅 컨트롤러 작성
| ChattingController.java
import com.jeongyuneo.springwebsocket.dto.ChattingRequest;
import com.jeongyuneo.springwebsocket.service.ChattingService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
@Slf4j
@RequiredArgsConstructor
@Controller
public class ChattingController {
private final SimpMessagingTemplate simpMessagingTemplate; // (1)
@MessageMapping("/chattings/{chattingRoomId}/messages") // (2)
public void chat(@DestinationVariable Long chattingRoomId, ChattingRequest chattingRequest) { // (3)
simpMessagingTemplate.convertAndSend("/subscription/chattings/" + chattingRoomId, chattingRequest.getContent());
log.info("Message [{}] send by member: {} to chatting room: {}", chattingRequest.getContent(), chattingRequest.getSenderId(), chattingRoomId);
}
}
(1) @EnableWebSocketMessageBroker
를 통해 등록되는 Bean으로, Broker로 메시지 전달
(2) 클라이언트가 SEND할 수 있는 경로
WebSocketConfig
에서 등록한 applicationDestinationPrfixes와 @MessageMapping
의 경로가 합쳐진다.
(3) 클라이언트에서 /publication/chattings/{chattingRoomId}/messages
로 메시지를 보내면 해당 채팅방을 구독 중인 사용자들에게 메시지를 전달
@DestinationVariable
는 구독 및 발행 URL의 경로변수를 지정한다.
| ChattingRequest.java
import lombok.*;
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChattingRequest {
private Long senderId;
private String content;
}
3. apic으로 STOMP 통신 테스트하기
3.1. 테스트 데이터 삽입
# database 생성
create database cupid;
# database 전환
use cupid;
# table 생성
create table chatting (chatting_id bigint not null auto_increment, created_date datetime(6), deleted bit not null, deleted_date datetime(6), last_modified_date datetime(6), content varchar(255), chatting_room_id bigint, member_id bigint, primary key (chatting_id)) engine=InnoDB;
create table chatting_room (chatting_room_id bigint not null auto_increment, created_date datetime(6), deleted bit not null, deleted_date datetime(6), last_modified_date datetime(6), name varchar(255), primary key (chatting_room_id)) engine=InnoDB;
create table chatting_room_member (chatting_room_member_id bigint not null auto_increment, created_date datetime(6), deleted bit not null, deleted_date datetime(6), last_modified_date datetime(6), chatting_room_id bigint, member_id bigint, primary key (chatting_room_member_id)) engine=InnoDB;
create table member (member_id bigint not null auto_increment, created_date datetime(6), deleted bit not null, deleted_date datetime(6), last_modified_date datetime(6), name varchar(255), primary key (member_id)) engine=InnoDB;
alter table chatting add constraint FKi6wx9n3gd5wd32f9vsmb94gdd foreign key (chatting_room_id) references chatting_room (chatting_room_id);
alter table chatting add constraint FKlv6j5tubiorgcy3hcq1xulhq0 foreign key (member_id) references member (member_id);
alter table chatting_room_member add constraint FKjhhox3yk8o1u5p9q7ixudvhgl foreign key (chatting_room_id) references chatting_room (chatting_room_id);
alter table chatting_room_member add constraint FKatbj5qw57fkw5xheqf9hhgdn foreign key (member_id) references member (member_id);
# member 삽입
INSERT INTO `cupid`.`member` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '김가나');
INSERT INTO `cupid`.`member` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '이다라');
INSERT INTO `cupid`.`member` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '박마바');
INSERT INTO `cupid`.`member` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '신사아');
INSERT INTO `cupid`.`member` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '정자차');
INSERT INTO `cupid`.`member` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '윤카타');
INSERT INTO `cupid`.`member` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '연파하');
# chatting_room 삽입
INSERT INTO `cupid`.`chatting_room` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '저녁식사모임');
INSERT INTO `cupid`.`chatting_room` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '스프링초고수단');
INSERT INTO `cupid`.`chatting_room` (`created_date`, `last_modified_date`, `deleted`, `name`) VALUES (now(), now(), 0, '개발자들');
# chatting_room_member 삽입
INSERT INTO `cupid`.`chatting_room_member` (`created_date`, `last_modified_date`, `deleted`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, 1, 1);
INSERT INTO `cupid`.`chatting_room_member` (`created_date`, `last_modified_date`, `deleted`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, 1, 2);
INSERT INTO `cupid`.`chatting_room_member` (`created_date`, `last_modified_date`, `deleted`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, 1, 3);
INSERT INTO `cupid`.`chatting_room_member` (`created_date`, `last_modified_date`, `deleted`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, 2, 1);
INSERT INTO `cupid`.`chatting_room_member` (`created_date`, `last_modified_date`, `deleted`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, 2, 2);
INSERT INTO `cupid`.`chatting_room_member` (`created_date`, `last_modified_date`, `deleted`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, 2, 3);
INSERT INTO `cupid`.`chatting_room_member` (`created_date`, `last_modified_date`, `deleted`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, 3, 1);
INSERT INTO `cupid`.`chatting_room_member` (`created_date`, `last_modified_date`, `deleted`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, 3, 3);
INSERT INTO `cupid`.`chatting_room_member` (`created_date`, `last_modified_date`, `deleted`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, 4, 1);
# chatting 삽입
INSERT INTO `cupid`.`chatting` (`created_date`, `last_modified_date`, `deleted`, `content`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, '안녕하세요', 1, 1);
INSERT INTO `cupid`.`chatting` (`created_date`, `last_modified_date`, `deleted`, `content`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, '방가방가', 2, 1);
INSERT INTO `cupid`.`chatting` (`created_date`, `last_modified_date`, `deleted`, `content`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, '뭐하고 있어요', 1, 1);
INSERT INTO `cupid`.`chatting` (`created_date`, `last_modified_date`, `deleted`, `content`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, '안녕하세요~', 3, 1);
INSERT INTO `cupid`.`chatting` (`created_date`, `last_modified_date`, `deleted`, `content`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, '개발중입니당', 2, 1);
INSERT INTO `cupid`.`chatting` (`created_date`, `last_modified_date`, `deleted`, `content`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, '반가워유', 4, 1);
INSERT INTO `cupid`.`chatting` (`created_date`, `last_modified_date`, `deleted`, `content`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, '개발 고?', 1, 2);
INSERT INTO `cupid`.`chatting` (`created_date`, `last_modified_date`, `deleted`, `content`, `member_id`, `chatting_room_id`) VALUES (now(), now(), 0, '고고', 2, 2);
# data 조회
select * from member;
select * from chatting;
select * from chatting_room;
select * from chatting_room_member;
3.2. apic 설치
크롬 확장 프로그램으로 설치해서 테스트가 가능했지만, 더이상 지원하지 않아 아래 사이트에서 프로그램을 설치해야 한다.
apic — The complete API solution
apic — The complete API solution
The complete API solution APIC provides an end to end solution for APIs, staring from design to documentation to testing. With a simplistic UI for Designing APIs and a feature rich platform for testing them, APIC provides a common platform for your designe
apic.app
3.3. 테스트
1. apic 실행 후 Tester에서 New tab 우측에 ws 버튼을 누른다.
2. 커넥션 정보를 입력한다.
(1) WebSocket Endpoint 입력
(2) WebSocker Endpoint 입력 후 Connect 버튼을 눌러 연결이 완료되면 “Stomp connected.” 메시지가 출력됨
(3) Connection type에서 Stomp 선택 후 구독 URL 입력
(4) destination 입력
(5) 채팅 전송 정보 입력
3. 테스트 데이터를 전송한다.
(1) 전송 버튼을 누르면 /publication/chattings/1/messages로 채팅 정보 전송
(2) 전송된 채팅은 같은 채팅방을 구독 중인 다른 사용자에게 노출됨
4. 채팅 내역 저장 로직 추가
4.1. 도메인
4.2. 디렉토리 구조
├── main
│ ├── java
│ │ └── com
│ │ └── jeongyuneo
│ │ └── springwebsocket
│ │ ├── SpringWebsocketApplication.java
│ │ ├── config
│ │ │ ├── JpaAuditingConfig.java
│ │ │ └── WebSocketConfig.java
│ │ ├── controller
│ │ │ ├── ChattingController.java
│ │ │ └── ChattingRoomController.java
│ │ ├── dto
│ │ │ ├── ChattingRequest.java
│ │ │ ├── ChattingResponse.java
│ │ │ └── ChattingRoomResponse.java
│ │ ├── entity
│ │ │ ├── BaseEntity.java
│ │ │ ├── Chatting.java
│ │ │ ├── ChattingRoom.java
│ │ │ ├── Member.java
│ │ │ └── MemberChattingRoom.java
│ │ ├── repository
│ │ │ ├── ChattingRoomRepository.java
│ │ │ ├── MemberChattingRoomRepository.java
│ │ │ └── MemberRepository.java
│ │ └── service
│ │ └── ChattingService.java
│ └── resources
│ └── application.properties
└── test
└── java
└── com
└── jeongyuneo
└── springwebsocket
└── SpringWebsocketApplicationTests.java
4.3. 소스 코드
spring-labs/spring-websocket at main · jeongyuneo/spring-labs
GitHub - jeongyuneo/spring-labs: Spring 학습 내용 저장소
Spring 학습 내용 저장소. Contribute to jeongyuneo/spring-labs development by creating an account on GitHub.
github.com
5. To Do
5.1. SockJS 적용
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chattings")
.setAllowedOrigins("http://www.jikting.com", "https://www.jikting.com") // 허용할 Origin 설정
.withSockJS();
}
- SockJS는 보안 이슈로 인해 Origin 설정에 와일드 카드를 허용하지 않음
- 소켓통신 테스트 툴로 apic을 사용했는데, 해당 툴에서 요청을 보낼 때 Origin을 알 수 없어 SockJS없이 테스트를 진행함
5.2. 에러 핸들링
- 메시지 처리 중 에러 발생 시 이를 처리하는 로직 추가해야 함
5.3. 채팅 저장 NoSQL DB로 변경
- 현재 MySQL로 채팅내역을 저장하고 있어 채팅 트래픽이 많아지면 부하가 커질 것 같음
- Redis 등으로 채팅내역 저장소를 변경할 필요가 있어보임
- 최근 채팅내역(일주일)은 NoSQL DB를 사용해 저장하고, 일주일 후에는 RDB에 저장해 보관하다가 한 달 후 삭제하도록 해야함
References