본문 바로가기
공부/Spring

[Spring][WebSocket] 채팅 입장, 퇴장 메시지 구현하기(Web Socket with STOMP) (2)

by 웅대 2023. 5. 19.
728x90
반응형

https://growth-coder.tistory.com/157

 

[Spring][WebSocket] 스프링 STOMP와 웹 소켓 개념 및 사용법 (Web Socket with STOMP) (1)

클라이언트와 서버가 통신할 때 HTTP 통신을 주로 사용한다. HTTP 통신은 다음과 같은 특징이 있다. 비연결성 (connectionless) : 연결을 맺고 요청을 하고 응답을 받으면 연결을 끊어버린다. 무상태성 (

growth-coder.tistory.com

이전 포스팅에서 예제를 통해 간단한 채팅 서버를 구현해보았다.

 

이번 포스팅에서는 간단한 입퇴장 메시지를 구현하고 채팅 메시지를 입력하는 사용자를 식별해보려고 한다.

 

이전 포스팅에서 사용한 코드를 그대로 사용하였고 약간 수정을 하였다.

 

복습 삼아서 잠깐 이전 포스팅의 코드 동작 과정에 대해 정리를 해보려고 한다.

 

<WebSocketConfig>

@Configuration
@EnableWebSocketMessageBroker //웹 소켓 메시지를 다룰 수 있게 허용
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic"); //발행자가 "/topic"의 경로로 메시지를 주면 구독자들에게 전달
        config.setApplicationDestinationPrefixes("/app"); // 발행자가 "/app"의 경로로 메시지를 주면 가공을 해서 구독자들에게 전달
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/gs-guide-websocket").withSockJS(); // 커넥션을 맺는 경로 설정. 만약 WebSocket을 사용할 수 없는 브라우저라면 다른 방식을 사용하도록 설정
    }
 }

connection을 맺을 때의 경로는 "/gs-guide-websocket"이다.

 

사용자가 메시지를 보낼 때 경로가 "/topic"으로 시작하면 그 메시지를 그대로 구독자들에게 전달한다.

 

경로가 "/app"으로 시작하면 중간에 가공을 거쳐 메시지를 전달한다.

 

메시지를 바로 전달하기 보다는 가공해서 전달을 할 예정이므로 이번 포스팅에서는 "/topic"으로의 요청은 넣지 않을 예정이다.

 

"/app/hello"으로 시작하는 요청이 들어오면 컨트롤러에서 해당 요청을 받아서 가공 후 "/topic/greetings"를 구독 중인 사용자들에게 보낸다.

 

@Controller
public class GreetingController {
    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Greeting greeting(HelloMessage message, StompHeaderAccessor session) throws Exception {
        return new Greeting(HtmlUtils.htmlEscape(message.getName() + "님 환영합니다"));
    }
}

 

  1. 사용자는 "/gs-guide-websocket" 경로로 connection을 맺는다.
  2. "/app/hello" 경로로 메시지를 보낸다.
  3. 컨트롤러가 요청을 받아서 greeting 메소드가 실행된다.
  4. 메시지를 가공해서 "/topic/greetings"를 구독 중인 사용자들에게 전달한다.

이제 사용자를 식별하고 입퇴장 메시지를 구현해보자.

 

우선 사용자의 식별을 위해서 connection을 맺을 때 헤더에 사용자의 정보를 넣어준다.

 

메시지를 보낼 때마다 식별을 하는 것이 아니라 처음 connection을 맺을 때 넣어준 사용자의 정보를 사용한다.

 

STOMP 프로토콜에서는 메시지의 COMMAND가 존재한다. 이를 사용하여 입퇴장 메시지를 보낸다.

 

이전 포스팅에서 구현한 내용에 추가로 ChannelInterceptor를 적용할 예정이다.

 

다음과 같은 ChannelInterceptor를 생성한다. 

@Component
public class ChannelInboundInterceptor implements ChannelInterceptor {
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor header = StompHeaderAccessor.wrap(message);
        System.out.println("command : "+header.getCommand());
        System.out.println("destination : "+header.getDestination());
        System.out.println("name header : "+header.getFirstNativeHeader("name"));
        return message;
    }
}

인터셉터를 등록해준다. configureClientInboundChannel에 해당

@Configuration
@EnableWebSocketMessageBroker //웹 소켓 메시지를 다룰 수 있게 허용
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    private final ChannelInboundInterceptor channelInboundInterceptor;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic"); //발행자가 "/topic"의 경로로 메시지를 주면 구독자들에게 전달
        config.setApplicationDestinationPrefixes("/app"); // 발행자가 "/app"의 경로로 메시지를 주면 가공을 해서 구독자들에게 전달
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/gs-guide-websocket").withSockJS(); // 커넥션을 맺는 경로 설정. 만약 WebSocket을 사용할 수 없는 브라우저라면 다른 방식을 사용하도록 설정
    }

    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(channelInboundInterceptor);
    }

}

 

지금은 ChannelInterceptor를 메시지 정보를 확인하는 용도로 구현하였다.

 

우리는 connection을 맺을 때 header에 유저 정보를 넣어줄 것이고 메시지를 보낼 때에는 header를 넣어주지 않을 것이다.

 

html, js 파일 또한 이전 포스팅에서 사용했던 것을 사용한다.

 

자바스크립트의 connect 함수는 connection을 맺을 때 사용하는 함수로 이전 포스팅과 다르게 header를 넣어준다.

function connect() {
    var socket = new SockJS('/gs-guide-websocket');
    stompClient = Stomp.over(socket);
    var headers = {
            name : "chulsoo"
        };
    stompClient.connect(headers, function (frame) {
        setConnected(true);
        console.log('Connected: ' + frame);
        stompClient.subscribe('/topic/greetings', function (greeting) {
            showGreeting(JSON.parse(greeting.body).content);
        });
    });
}

사용자 식별을 위한 name header를 넣어주었다.

 

다음은 채팅을 보내는 함수인 sendName이다. 여기서는 header를 비워준다. (이전 포스팅 코드가 이름을 보내는 것이어서 함수 이름이 sendName인데 여기서는 채팅을 보내는 함수라고 생각하면 된다.)

function sendName() {
    stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
}

한번 실행하고 connect를 누르고 이름을 한번 보내보고 disconnect를 눌러보고 콘솔 창의 정보를 확인해보자.

 

첫 번째로 connect를 눌렀을 때이다. 연결을 맺는 과정이다.

 

command : CONNECT
destination : null
name header : chulsoo

 

destination은 없고 우리가 header에 담은 name 값이 잘 출력되는 모습을 확인할 수 있다.

 

그 다음은 구독을 했을 때이다. connect 함수를 자세히 보면 connection을 맺고 구독을 하는 코드를 확인할 수 있다.

 

command : SUBSCRIBE
destination : /topic/greetings
name header : null

 

"/topic/greetings"을 구독을 했고 header는 따로 추가를 하지 않아서 null인 모습을 볼 수 있다.

 

그 다음은 이름을 보냈을 때이다.

 

command : SEND
destination : /app/hello
name header : null

 

"/app/hello" 경로로 이름을 바로 보내는 것을 확인할 수 있다. 역시 header에 name은 존재하지 않는다.

 

그 다음은 연결을 끊을 때이다.

 

command : DISCONNECT
destination : null
name header : null

 

이제 이 정보를 활용해서 구현을 해보자.

 

먼저 매 요청마다 헤더에 name을 넣는게 아니라 처음 요청에만 헤더에 name을 넣어서 사용자를 식별하기로 했다.

 

이를 위해서 command가 CONNECT라면 StompHeaderAccess의 attributes에 이름을 세팅하면 된다.

 

그리고 이 메시지를 가공하는 컨트롤러에서는 StompHeaderAccess의 attributes를 꺼내서 사용자를 식별하면 된다.

 

ChannelInterceptor의 코드를 다음과 같이 변경한다.

@Component
public class ChannelInboundInterceptor implements ChannelInterceptor {
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor header = StompHeaderAccessor.wrap(message);
        if (StompCommand.CONNECT.equals(header.getCommand())) {
            //connect라면 name값을 꺼내서 sessionAttributes에 넣기.
            Map<String, Object> attributes = header.getSessionAttributes();
            attributes.put("name", header.getFirstNativeHeader("name"));
            header.setSessionAttributes(attributes);
        }
        return message;
    }
}

이제 처음 연결을 맺을 때 name 값을 sessionAttributes에 넣는다.

반응형

그리고 이제 컨트롤러를 수정할 차례이다.

 

@Controller
public class GreetingController {
    @MessageMapping("/enter")
    @SendTo("/topic/greetings")
    public Greeting enter(HelloMessage message, StompHeaderAccessor session) throws Exception {
        return new Greeting(HtmlUtils.htmlEscape(session.getSessionAttributes().get("name") + "님께서 입장하셨습니다!"));
    }
    @MessageMapping("/exit")
    @SendTo("/topic/greetings")
    public Greeting exit(HelloMessage message, StompHeaderAccessor session) throws Exception {
        return new Greeting(HtmlUtils.htmlEscape(session.getSessionAttributes().get("name") + "님께서 퇴장하셨습니다!"));
    }
    @MessageMapping("/chat")
    @SendTo("/topic/greetings")
    public Greeting chat(HelloMessage message, StompHeaderAccessor session) throws Exception {
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

        Date now = new Date();

        String currentTime = format.format(now);

        System.out.println(currentTime);
        return new Greeting(HtmlUtils.htmlEscape(session.getSessionAttributes().get("name") + " : "+message.getName()+"["+currentTime+"]"));
    }


}

입장, 퇴장, 채팅에 관한 메소드들이다.

 

채팅의 경우 이름과 보낸 시각이 같이 출력된다.

 

그리고 js 파일에서 connect를 맺을 때 "/app/enter"로 메시지를 보내고

 

connect를 끊을 때 "/app/exit"로 메시지를 보내고

 

채팅을 보낼 때 "/app/chat"로 메시지를 보내도록 구현하면 된다.

 

(혹시 보시는 분들께서 헷갈릴까봐 첨언하자면 HelloMessage는 name 필드 하나만 가지고 있는데 이게 곧 채팅 내용이다. 이전 포스팅의 코드를 그대로 사용해서 그렇다. sessionAttributes에 저장되어 있는 name이 이름이고 HelloMessage의 name은 채팅 내용이다.)

 

 

<app.js>

var stompClient = null;

function setConnected(connected) {
    $("#connect").prop("disabled", connected);
    $("#disconnect").prop("disabled", !connected);
    if (connected) {
        $("#conversation").show();
    }
    else {
        $("#conversation").hide();
    }
    $("#greetings").html("");
}

function connect() {
    var socket = new SockJS('/gs-guide-websocket');
    stompClient = Stomp.over(socket);
    var headers = {
            name : "chulsoo"
        };
    stompClient.connect(headers, function (frame) {
        setConnected(true);
        console.log('Connected: ' + frame);
        stompClient.subscribe('/topic/greetings', function (greeting) {
            showGreeting(JSON.parse(greeting.body).content);
        });
        stompClient.send("/app/enter", {}, JSON.stringify({'name': $("#name").val()}));
    });
}

function disconnect() {
    if (stompClient !== null) {
        exit();
        stompClient.disconnect();
    }
    setConnected(false);
    console.log("Disconnected");
}

function sendName() {
    stompClient.send("/app/chat", {}, JSON.stringify({'chat': $("#chat").val()}));
}
function enter(){
    stompClient.send("/app/enter", {}, JSON.stringify({}));
}
function exit(){
    stompClient.send("/app/exit", {}, JSON.stringify({}));
}

function showGreeting(message) {
    $("#greetings").append("<tr><td>" + message + "</td></tr>");
}

$(function () {
    $("form").on('submit', function (e) {
        e.preventDefault();
    });
    $( "#connect" ).click(function() { connect(); });
    $( "#disconnect" ).click(function() { disconnect(); });
    $( "#send" ).click(function() { sendName(); });
});

그냥 connect 함수에 "/app/enter"로 메시지 보내고 disconnect 함수에 "/app/exit"로 메시지 보내는 코드가 추가됐을 뿐이다.

 

원하는대로 채팅을 구현했다. name이 같아서 혼자 대화하는 것처럼 보이는데 창 두 개를 띄워서 테스트 해 본 것이다.

 

이전 포스팅의 코드를 그대로 사용하다보니 조금 헷갈릴 여지가 많은 것 같다.

 

이제 다음 포스팅에서는 채팅방을 만들어서 해당 채팅방에 존재하는 사람들끼리 채팅하도록 구현해 볼 예정이다.

 

참고

https://ppaksang.tistory.com/18

https://velog.io/@raddaslul/Stomp%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%EC%B1%84%ED%8C%85-%EB%B0%8F-item-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

728x90
반응형

댓글