직접 만들어 보면서 이해하는 웹소켓 마지막 단계이다.
STOMP 실시간 채팅방 구현을 위한 백엔드 코드는 생각보다 매우 간단하다.
사실 자바 스크립트도 간단한데 프론트엔드 영역을 잘 모르는 상태로 무작정 구현을 하다 보니 상당히 애를 먹었던 것 같다.
가장 어려웠던 부분이 프론트 엔드 영역 코드와 벡엔드 영역 코드의 연결 부분이었는데 이를 쉽게 이해하려면 지난 글을 함께 봐야 한다.
전체 코드는 깃허브 레파지토리에서 확인할 수 있다.
https://github.com/wonyongg/test/tree/main/webSocketTest
test/webSocketTest at main · wonyongg/test
test and summarize what i learned today. Contribute to wonyongg/test development by creating an account on GitHub.
github.com
🛠️ build.gradle 설정
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.0.2'
implementation 'org.webjars:stomp-websocket:2.3.3'
implementation 'org.webjars:bootstrap:3.3.7'
implementation 'org.webjars:jquery:3.1.1-1'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
- 프로젝트 생성은 인텔리제이로 진행했다.
⚙️ WebSockerStompConfig
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/stomp")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/pub", "/sub");
registry.enableSimpleBroker("/sub");
}
}
@EnableWebSocketMessageBroker
- WebSocket 메시지 브로커를 활성화하는 애너테이션이다.
- WebSocket을 이용한 메시징 기능을 사용하겠다는 의미이다.
registerStompEndpoints()
- .addEndpoint("/stomp")
- STOMP 프로토콜을 사용하기 위한 엔드포인트를 등록한다.
- .setAllowedOriginPatterns("*")
- "/stomp" 경로로 엔드포인트를 추가하고 모든 도메인에서의 접근을 허용한다.
- .withSockJS();
- SockJS를 활성화하여 WebSocket이 지원되지 않는 브라우저에서도 대체 옵션을 제공한다.
configureMessageBroker()
- registry.setApplicationDestinationPrefixes("/pub", "/sub");
- 발행자가 메시지를 보낼 때 해당 메시지의 목적지를 설정한다.
- /pub를 프리픽스로 사용하면 클라이언트는 /pub 뒤에 추가적인 경로를 지정하여 메시지를 보낼 수 있다.
- 예를 들어, 클라이언트가 /pub/chat으로 메시지를 보내면 이 메시지는 /chat 주제로 전송된다.
- 뿐만 아니라, 클라이언트에서 구독 후 서버로부터 데이터를 가져오려고 하는 경우에도 사용한다.
- 기존에 /sub이라는 이름의 경로를 구독 중이라면 최초 구독 시 /sub을 통해 서버에서 데이터를 가져오기 위해서는 /sub 경로를 추가해야 한다.
- registry.enableSimpleBroker("/sub");
- 클라이언트 간에 메시지를 교환하고 전달하는 역할을 담당하는 간단한 메시지 브로커를 활성화한다.
- /sub를 구독(subscribe)하면 해당 주제(topic)로 전달되는 메시지를 수신할 수 있다.
- 예를 들어, 클라이언트가 /sub/chat을 구독하면 /chat 주제로 전달되는 메시지를 수신할 수 있다.
app.js
var socket = new SockJS('/stomp');
stompClient = Stomp.over(socket);
- appjs.의 function connect()에서 해당 엔드포인트를 설정하여 소켓 통신을 연결한다.
채팅방 구현을 위해서는 크게 메시지 기능과 알림 기능이 필요했다.
메시지 기능은 클라이언트 간 소켓 연결을 통해 채널을 구독하고 메시지를 발행하면 상대방이 메시지를 받는 방식으로 작동한다.
알림 기능이란 유저가 접속하거나 나갔을 때 다른 유저들에게 알려주는 역할을 한다.
알림 기능을 구현하기 위해서는 이벤트 리스너를 사용해야 했다.
🙉 이벤트 리스너
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketEventListener {
private final SimpMessageSendingOperations simpMessageSendingOperations;
private final AtomicInteger totalSubscribers = new AtomicInteger(0);
/**
* key : nickname
* value : sessionId
*/
@Getter
private final Map<String, String> sessionMap = new ConcurrentHashMap<>();
@EventListener
public void handleConnectEvent(SessionConnectEvent event) {
totalSubscribers.incrementAndGet();
notifyTotalSubscriberCountChanged();
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = headerAccessor.getSessionId();
String nickname = headerAccessor.getFirstNativeHeader("nickname");
log.info("[ {} ][ 세션 연결 ] - nickname: {}", sessionId, nickname);
if (!(sessionMap.containsValue(sessionId))) {
sessionMap.put(nickname, sessionId);
simpMessageSendingOperations.convertAndSend("/sub/user-list", sessionMap);
} else {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("message", "DUPLICATED");
errorResponse.put("sessionId", sessionId);
simpMessageSendingOperations.convertAndSend("/sub/user-list", errorResponse);
}
}
// 컨트롤러에서 사용, sessionMap이 이곳에 있기 때문에 controller에서 호출하여 이곳에서 유저의 닉네임을 변경한다.
public void changeNicknameEvent(Enroll enroll) {
sessionMap.remove(enroll.getExNickname());
sessionMap.put(enroll.getChangeNickname(), enroll.getSessionId());
log.info("[ {} ][ 닉네임 변경 ] - ex_nickname: {}, change_nickname: {}", enroll.getSessionId(), enroll.getExNickname(), enroll.getChangeNickname());
simpMessageSendingOperations.convertAndSend("/sub/user-list", sessionMap);
}
@EventListener
public void handleDisconnectEvent(SessionDisconnectEvent event) {
totalSubscribers.decrementAndGet();
notifyTotalSubscriberCountChanged();
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = headerAccessor.getSessionId();
log.info("[ {} ][ 세션 연결 종료 ]", sessionId);
for (Map.Entry<String, String> entry : sessionMap.entrySet()) {
if (entry.getValue().equals(sessionId)) {
sessionMap.remove(entry.getKey());
}
}
simpMessageSendingOperations.convertAndSend("/sub/user-list", sessionMap);
}
// 전체 구독자수 카운트 메서드
public int getTotalSubscriberCount() {
return totalSubscribers.get();
}
// 모든 구독자에게 알리는 현재 접속자 수를 알리는 메서드
private void notifyTotalSubscriberCountChanged() {
int count = getTotalSubscriberCount();
simpMessageSendingOperations.convertAndSend("/sub/user-count", count);
}
}
public void handleConnectEvent(SessionConnectEvent event) {}
- 누군가 채팅방에 접속할 경우, 이벤트 리스너를 통해 SessionConnectEvent에 연결 event 객체를 담아 처리한다.
totalSubscribers.incrementAndGet() -> notifyTotalSubscriberCountChanged()
- 현재 접속자 수를 한 명 늘리고, 모든 구독자에게 현재 접속자 수를 업데이트하는 메시지를 발행한다.
simpMessageSendingOperations.convertAndSend("/sub/user-list", sessionMap);
- sessionMap에 유저의 serssionId, nickname을 추가하고 메시지를 발행하여 유저리스트에 해당 유저의 닉네임이 보이게 한다.
public void handleDisconnectEvent(SessionDisconnectEvent event) {}
- 누군가 채팅방을 끄거나 연결을 종료할 경우, 이벤트 리스너를 통해 SessionDisconnectEvent에 연결 해제 event 객체를 담아 처리한다.
totalSubscribers.decrementAndGet() -> notifyTotalSubscriberCountChanged()
- 현재 접속자 수를 한명 내리고, 모든 구독자에게 현재 접속자 수를 업데이트하는 메시지를 발행한다.
simpMessageSendingOperations.convertAndSend("/sub/user-list", sessionMap);
- sessionMap에 유저의 serssionId, nickname을 삭제하고 메시지를 발행하여 유저리스트에 해당 유저의 닉네임을 지운다.
🏃🏻 컨트롤러
@Slf4j
@Controller
@RequiredArgsConstructor
public class MessageController {
private final SimpMessageSendingOperations simpMessageSendingOperations;
private final WebSocketEventListener webSocketEventListener;
/**
* key : sessionId
* value : nickname
*/
private final List<String> channelList = new ArrayList<>();
@SubscribeMapping("/user-count")
public int getInitialUserCount() {
return webSocketEventListener.getTotalSubscriberCount();
}
@SubscribeMapping("/user-list")
public Map<String, String> getInitialUserList() {
return webSocketEventListener.getSessionMap();
}
@MessageMapping("/enroll")
public void enroll(@RequestBody Enroll enroll) {
webSocketEventListener.changeNicknameEvent(enroll);
}
@MessageMapping("/channel-list")
public void channel(String channelName) {
if (!channelList.contains(channelName)) {
channelList.add(channelName);
}
simpMessageSendingOperations.convertAndSend("/sub/channel-list", channelList);
}
@SubscribeMapping("/channel-list")
public List<String> getInitialChannelList() {
return channelList;
}
@MessageMapping("/chat")
public void message(@RequestBody Message message) {
log.info("message : {}", message);
simpMessageSendingOperations.convertAndSend("/sub/channel/" + message.getChannelName(), message);
}
}
@SubscribeMapping
- 클라이언트가 특정 주제를 구독할 때 초기 메시지를 보내는 데 사용된다.
- 이 어노테이션이 붙은 메서드는 클라이언트가 주제를 구독할 때만 한 번 호출되며, 이를 통해 구독 초기화 시 필요한 데이터를 클라이언트에게 바로 보낼 수 있다.
@MessageMapping
- 클라이언트로부터 메시지를 받아 처리하는 메서드를 지정하는 데 사용된다.
- 이 어노테이션이 붙은 메서드는 클라이언트가 특정 경로로 메시지를 보낼 때 그 메시지를 처리한다.
SimpMessageSendingOperations
- 메시지를 WebSocket 세션 또는 특정 대상(subscribe하는 사용자들)에게 프로그래밍 방식으로 보내는 기능을 제공한다.
- Spring의 메시징 모듈 내에서 사용되며, 주로 STOMP 위에서 구축된 애플리케이션에서 사용된다.
📌 DTO
참고
뤼튼
https://growth-coder.tistory.com/157