본문 바로가기
카테고리 없음

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

by 팡펑퐁 2024. 6. 3.
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