반응형

스프링부트, stomp를 이용해서 실시간 알림을 받을 수 있도록 해보겠습니다.

구현할 내용은 다음과 같습니다.

1. 사용자 1번이 접속해서 웹소켓이 열린다.

2. 사용자 2번이 사용자 1번에게 메세지를 전송하면, 사용자 1번은 알림을 받는다.

3. 소켓을 연결할 때, 로그인을 통해 발급받은 jwt 토큰을 이용해서 사용자 인증을 수행한다.

텍스트 기반 메시징 프로코톨인 stomp의 pub/sub 모델을 이용할 것입니다.

 

 

implementation 'org.springframework.boot:spring-boot-starter-websocket'
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'

먼저 위와 같은 dependency를 추가해줍니다. (프론트에서 사용하기 위한 것도 포함)

 

 

다음과 같은 config 파일을 작성해줍니다.

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler; // jwt 토큰 인증 핸들러

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub");
        registry.setApplicationDestinationPrefixes("/pub");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler); // 핸들러 등록
    }
}

/ws-stomp로 소켓을 연결하고, /sub/... 을 구독하고 있으면, 메세지를 전송할 수 있습니다.

토큰을 인증하기 위한 stompHandler를 추가해줍니다. 연결이 되기 전에 해당 핸들러의 메소드를 실행할 것입니다.

 

@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        if(accessor.getCommand() == StompCommand.CONNECT) {
            if(!jwtTokenProvider.validateToken(accessor.getFirstNativeHeader("token")))
                throw new AccessDeniedException("");
        }
        return message;
    }
}

preSend를 오버라이딩하여, CONNECT하는 상황이라면, 토큰을 검증해줍니다.

토큰이 유효하지 않다면, 예외를 발생시켜줄 것입니다.

토큰 검증하는 코드는 예제가 많으므로 생략하겠습니다.

 

@Controller
@RequiredArgsConstructor
public class AlarmController {

    private final SimpMessageSendingOperations messagingTemplate;

    // stomp 테스트 화면
    @GetMapping("/alarm/stomp")
    public String stompAlarm() {
        return "/stomp";
    }

    @MessageMapping("/{userId}")
    public void message(@DestinationVariable("userId") Long userId) {
        messagingTemplate.convertAndSend("/sub/" + userId, "alarm socket connection completed.");
    }
}

@DestinationVariable은 @PathVariable이랑 비슷하게 생각하시면 됩니다.

/ws-stomp로 소켓을 연결하면, 클라이언트에서는 /sub/{userId}를 구독할 것입니다.

클라이언트에서 /ws-stomp로 요청하면 소켓이 연결되는 것이라 생각하면 됩니다.

 

 

public MessageDto createMessage(MessageCreateRequestDto requestDto) {
    ...
    alarmService.alarmByMessage(messageDto);
    return messageDto;
}

메세지를 작성하면, alarmService를 이용해서 메시지에 의한 알람을 발송해주겠습니다.

 

@Service
@RequiredArgsConstructor
public class AlarmService {

    private final SimpMessageSendingOperations messagingTemplate;

    public void alarmByMessage(MessageDto messageDto) {
        messagingTemplate.convertAndSend("/sub/" + messageDto.getReceiverId(), messageDto);
    }

}

AlarmService는 위와 같습니다.

소켓을 연결하며 클라이언트가 구독했던 곳으로 메세지를 전송해주면 됩니다.

 

이제 프론트 테스트를 위한 소스코드를 보겠습니다.

https://spring.io/guides/gs/messaging-stomp-websocket/

 

Using WebSocket to build an interactive web application

this guide is designed to get you productive as quickly as possible and using the latest Spring project releases and techniques as recommended by the Spring team

spring.io

해당 소스코드는 위의 가이드를 참고하였습니다.

 

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/event-stream; charset=utf-8"/>
    <title>Hello WebSocket</title>
    <link href="/webjars/bootstrap/css/bootstrap.min.css" rel="stylesheet">
    <link href="/main.css" rel="stylesheet">
    <script src="/webjars/jquery/jquery.min.js"></script>
    <script src="/webjars/sockjs-client/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/stomp.min.js"></script>
    <script src="/app.js"></script>
</head>
<body>
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being
    enabled. Please enable
    Javascript and reload this page!</h2></noscript>
<div id="main-content" class="container">
    <div class="row">
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="connect">WebSocket connection:</label>
                    <button id="connect" class="btn btn-default" type="submit">Connect</button>
                    <button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect
                    </button>
                </div>
            </form>
        </div>
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="name">What is your name?</label>
                    <input type="text" id="name" class="form-control" placeholder="Your name here...">
                </div>
                <button id="send" class="btn btn-default" type="submit">Send</button>
            </form>
        </div>
    </div>
    <div class="row">
        <div class="col-md-12">
            <table id="conversation" class="table table-striped">
                <thead>
                <tr>
                    <th>Greetings</th>
                </tr>
                </thead>
                <tbody id="greetings">
                </tbody>
            </table>
        </div>
    </div>
</div>
</body>
</html>

<script>
    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() {
        let socket = new SockJS('http://localhost:8080/ws-stomp');
        stompClient = Stomp.over(socket);
        stompClient.connect({"token" : "발급받은 토큰"}, function (frame) {
            setConnected(true);
            console.log('Connected: ' + frame);
            stompClient.subscribe('/sub/1', function (msg) {
                console.log('구독 중', msg);
            });
        });
    }

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

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

    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(); });
    });

</script>

dependency에 추가했던 webjar를 이용해서 jquery, sockjs 등을 불러옵니다.

모든 코드를 다 볼 필요는 없고, 아래 코드만 참고하면 됩니다.

 

function connect() {
    let socket = new SockJS('http://localhost:8080/ws-stomp');
    stompClient = Stomp.over(socket);
    stompClient.connect({"token" : "발급받은 토큰"}, function (frame) {
        setConnected(true);
        console.log('Connected: ' + frame);
        stompClient.subscribe('/sub/1', function (msg) {
            console.log('구독 중', msg);
        });
    });
}

stompClient.connect의 첫 번째 인자로 토큰을 넣어주시면 됩니다.

stompClinet.subscribe로 '/sub/{userId}'를 구독한 상황입니다.

임의의 상황으로,

1번 사용자가 로그인하고, 2번 사용자가 1번 사용자에게 메시지를 전송하는 상황이라고 가정해보겠습니다.

토큰에는 1번 사용자의 토큰을 넣고, 1번 사용자는 자신의 id에 맞춰서 '/sub/1'를 구독하고 있는 것입니다.

해당 페이지의 소스코드로 접속하면 아래와 같습니다.

stomp main

connect 버튼을 누르면 stompClient.connect()가 수행되면서 연결이 일어날 것입니다.

콘솔에서 다음과 같은 로그를 확인할 수 있습니다.

stomp console

잘 연결이 되었습니다. 1번 사용자는 /sub/1을 구독하고 있습니다.

이제 2번 사용자가 1번 사용자에게 메세지를 전송해보겠습니다.

stomp send

콘솔을 다시 확인해보면, '/sub/1'을 구독하고 있는 클라이언트가 메세지를 수신한 것을 확인할 수 있습니다.

 

 

 

stomp err

만약 유효하지 않은 토큰으로 연결을 시도했다면, 위와 같이 예외가 발생하며 연결이 되지 않을 것입니다.

이제 테스트 코드를 작성해보겠습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
class AlarmServiceTest {

    static final String WEBSOCKET_TOPIC = "/sub/";

    BlockingQueue<String> blockingQueue;
    WebSocketStompClient stompClient;

    @Autowired RegionRepository regionRepository;
    @Autowired TownRepository townRepository;
    @Autowired SignService signService;
    @Autowired MessageService messageService;
    @LocalServerPort Integer port;

    @BeforeEach
    public void beforeEach() {
        ...
        // 발신자와 수신자 회원가입
        signService.registerUser(new UserRegisterRequestDto("sender", "1234", "sender"));
        signService.registerUser(new UserRegisterRequestDto("receiver", "1234", "receiver"));
        blockingQueue = new LinkedBlockingDeque<>();
        stompClient = new WebSocketStompClient(new SockJsClient(
                Arrays.asList(new WebSocketTransport(new StandardWebSocketClient()))));
    }

    @Test
    public void connectionFailedByInvalidateTokenTest() { // 유효하지않은 토큰 연결 테스트

        // given
        StompHeaders headers = new StompHeaders(); // 헤더에 토큰 값 삽입
        headers.add("token", "invalidate token");

        // when, then
        // 잘못된 토큰으로 연결하면 예외 발생
        Assertions.assertThatThrownBy(() -> { 
            stompClient
                    .connect(getWsPath(), new WebSocketHttpHeaders() ,headers, new StompSessionHandlerAdapter() {})
                    .get(10, SECONDS);
        }).isInstanceOf(ExecutionException.class);
    }

    @Test
    public void alarmByMessageTest() throws Exception { // 메시지 수신 시 알람 테스트

        // given
        UserLoginResponseDto sender = signService.loginUser(new UserLoginRequestDto("sender", "1234"));
        UserLoginResponseDto receiver = signService.loginUser(new UserLoginRequestDto("receiver", "1234"));
        StompHeaders headers = new StompHeaders(); // 헤더에 토큰 삽입
        headers.add("token", sender.getToken());
        StompSession session = stompClient
                .connect(getWsPath(), new WebSocketHttpHeaders() ,headers, new StompSessionHandlerAdapter() {})
                .get(10, SECONDS); // 연결
        session.subscribe(WEBSOCKET_TOPIC + receiver.getId(), new DefaultStompFrameHandler()); // "/sub/{userId}" 구독

        // when
        MessageCreateRequestDto requestDto = new MessageCreateRequestDto(sender.getId(), receiver.getId(), "MESSAGE TEST");
        MessageDto messageDto = messageService.createMessage(requestDto); // 메세지 전송

        // then
        ObjectMapper mapper = new ObjectMapper();
        String jsonResult = blockingQueue.poll(10, SECONDS); // 소켓 수신 내역 꺼내옴
        Map<String, String> result = mapper.readValue(jsonResult, Map.class); // json 파싱
        assertThat(result.get("message")).isEqualTo(messageDto.getMessage());
    }

    class DefaultStompFrameHandler implements StompFrameHandler {
        @Override
        public Type getPayloadType(StompHeaders stompHeaders) {
            return byte[].class;
        }

        @Override
        public void handleFrame(StompHeaders stompHeaders, Object o) {
            blockingQueue.offer(new String((byte[]) o));
        }
    }

    private String getWsPath() {
        return String.format("ws://localhost:%d/ws-stomp", port);
    }
}

 

대략적인 테스트 코드는 위와 같습니다. 필요한 내용은 주석으로 적어두었습니다.

 

오류가 있으면 지적부탁드립니다.

 

참고자료 : 스프링 가이드, 대디프로그래머 stomp 게시글

반응형

+ Recent posts