카테고리 없음

직접 만들어 보면서 이해하는 웹소켓 (4) - STOMP 실시간 채팅방 구현(백엔드 영역)

팡펑퐁 2024. 6. 3. 01:14
728x90

직접 만들어 보면서 이해하는 웹소켓 마지막 단계이다.

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

 

728x90