PPAK

[Spring/WebSocket] WebSocket 도입과 STOMP subscribe, send 인가 구현 본문

프로젝트

[Spring/WebSocket] WebSocket 도입과 STOMP subscribe, send 인가 구현

PPakSang 2022. 8. 31. 15:54

프로젝트를 기획하면서 가장 무모(?) 하게 도전한 챌린지 중에 하나가 WebSocket 사용이였던 것 같습니다.

 

사실 WebSocket 이라는 것도 찾아보고 안 것이지 기획 단계에서는 그저 "비동기적으로 서버 측에서 브라우저로 메세지를 날리는 것" 정도로 이야기하고 넘어갔던 것이 기억납니다.

 

그 때 당시에는 브라우저와 파이프라인을 만드는 써드파티 라이브러리가 있지 않을까? 혹은 아무 방도가 없어라도 서버로 비동기적으로 호출을 날려놓고 이벤트가 발생하면 응답해주는 방식으로 구현하면 되지 않을까? 라며 어떻게든 해답이 있을거라 판단하였습니다.

 

MVP 가장 마지막 개발 기능으로 추가해 놓은 "서버에서 역으로 메세징 하기" 는 자료조사 끝에 WebSocket 이라는 통신 프로토콜을 이용해 구현이 가능하다는 것을 확인했습니다. (HTTP Push, Server Push 등의 키워드를 제공해준 은인이 있어 생각보다 빠르게 찾았다.)

 

또한 처음에 제가 생각한 클라이언트로 비동기 호출을 보내고 이벤트가 발생하는 시점에 응답을 되돌려 보내는 것은 이미 폴링(polling) 이라는 하나의 통신 방식으로 존재한다는 것을 확인할 수 있었습니다.

 

 

하지만 Polling, Long Polling, Streaming 방식은 제각각의 단점이 존재하는데 간단하게만 살펴보면

 

Polligng: 주기적으로 서버 호출을 통해 이벤트가 발생한지 확인하는 방식으로, 간단하게만 생각해도 불필요한 요청이 많을 것을 예상할 수 있습니다.

 

Long Polling: 최초 한번의 요청으로 이벤트가 발생할 때 비로소 응답을 받는 방식으로 효율적으로 보이지만 실시간으로 무수히 많은 요청이 온다면(채팅) Polling 과 별반 다를 바가 없어지며 HTTP 방식을 통한 요청은 응답헤더와 같이 꽤나 무거운 요청이기 때문에 성능상 안좋을 수 있습니다.

 

Streaming : 딱 한번의 요청으로 파이프라인을 형성하는 방식으로 볼 수 있으며 WebSocket 이 나오기 전 가장 발전한 형태의 Server Push 통신방법이라고 볼 수 있다. 하지만 단방향성이고(서버 -> 클라이언트), 역시나 HTTP 를 통한 통신이기 때문에 네트워크 오버헤드가 커질 위험이 있습니다.

 

WebSocket

WebSocket 이라는 것은 하나의 통신 프로토콜입니다. (따라서 http(s) 와 구별되는 ws(s) 라는 독자적인 프로토콜을 사용한다.)

한 가지 흥미로운 점은 요청은 ws:// 를 사용하지만 연결 자체는 내부적으로 http(s) 를 통해서 한다는 것입니다.

 

그 순서를 살펴보면

0. http tcp/ip handshake

1. http 를 통한 ws 프로토콜로의 Upgrade 신청 (websocket handshake request)

2. server 는 connection 생성 후 클라이언트에게 제공 (websocket handshake response)

3. 연결 완료

 

HandShake Request

GET /chat HTTP/1.1
Host: server address
Upgrade: WebSocket
Connection: Upgrade
Sec-WebSocket-Key: base64 encoded Number
Origin: http://localhost:8080
Sec-WebSocket-Version: 13
 Header Name   div   Description 
GET Require 요청 명령어는 GET을 사용해야 하며, HTTP 버전은 1.1 이상이어야 한다.
Host Require 웹소켓 서버의 주소
Upgrade Require WebSocket이라는 단어를 사용해야 한다. 대소문자는 구분 X
Connection Require Upgrade라는 단어를 사용해야 한다. 대소문자는 구분 X
Sec-WebSocket-Key Require 길이가 16Byte인 임의로 선태괸 숫자를 base64 인코딩한 값 이다.
Origin Require 클라이언트로 웹 브라우저를 사용하는 경우 필수항목으로, 클라이언트의 주소
Sec-WebSocket-Version Require 13을 사용한다.
Sec-WebSocket-Protocol Option 클라이언트가 사용하고 싶은 하위 프로토콜 이름을 명시한다.
Sec-WebSocket-Extensions Option 클라이언트가 사용하고 싶은 추가 옵션을 기술한다.

 

HandShake Response

HTTP/1.1 101 Switching Protocols
Upgrade: WebSocket
Connection: Upgrade
Sec-WebSocket-Accept: calculated value
 Header Name   div   Description 
HTTP Require HTTP 버전은 1.1이며, 클라이언트로부터의 요청이 이상 없는 경우 101을 상태코드로 사용한다.
Upgrade Require WebSocket이라는 단어를 사용해야 한다. 대소문자는 구분 X
Connection Require Upgrade라는 단어를 사용해야 한다. 대소문자는 구분 X
Sec-WebSocket-Accept Require 클라이언트로부터 받은 Sec-WebSocket-Key를 사용하여 계산된 값이다.
Sec-WebSocket-Protocol Option 서버에서 서비스하는 하위 프로토콜을 명시한다. 클라이언트가 요청하지 않는 하위 프로토콜을 명시하면 HandShake는 실패한다.
Sec-WebSocket-Extensions Option 서버가 사용하는 추가 옵션을 기술한다. 클라이언트가 요청하지 않는 추가 옵션을 명시하면 HandShake는 실패한다.

 

Spring 에서는 간단한 의존성 추가와 설정 정보를 가지고 클라이언트-서버 WebSocket 연결을 구현할 수 있습니다.

 

WebSocket 은 기본적으로 텍스트 기반 바이너리 데이터를 교환하고(간단하게 메시지), 해당 메시지를 어떻게 내부적으로 처리(핸들링) 할지에 따라서 연결 지점이 달라집니다.

 

가령

ws://test.com/chat 에서 주고받는 텍스트는 첫 메세지가 User 로 시작해야한다던지

ws://test.com/secret 에서 주고받는 텍스트는 첫 메세지가 Key 로 시작해야한다던지

사전에 협의한 방식에 따라서 그 연결 지점(end point) 가 달라진다고 볼 수 있습니다.

 

제 서버의 요구사항은 같은 그룹의 사람 간 메세지 조회 및 전송 기능 이였기 때문에 기본적으로 connection 단계와 송신 단계에서 사용자의 권한을 확인하는 방식이 필요했습니다.

 

 

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

WebSocket 요청 핸들링

@Component
@RequiredArgsConstructor
public class AlertWebSocketHandler extends TextWebSocketHandler {

    Logger logger = LoggerFactory.getLogger(this.getClass());
    private static Map<Long, List<WebSocketSession>> sessions = new HashMap<>();
    private final JwtProvider jwtProvider;

    private final AccountRepository accountRepository;


    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String authorization = JwtExtractor.extractJwt(session);
        if (authorization.equals(Strings.EMPTY)) {
            throw new NotFoundException("인증 정보를 찾을 수 없습니다.");
        }

        String email = jwtProvider.getEmailFromToken(authorization);
        Account account = accountRepository
                .findAccountByEmail(email).orElseThrow(() -> new NotFoundException("계정을 찾을 수 없습니다."));

        List<WebSocketSession> list = sessions.getOrDefault(account.getLine().getId(), new ArrayList<>());
        for (WebSocketSession s : list) {
            if (!s.equals(session)) s.sendMessage(message);
        }
    }

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        String authorization = JwtExtractor.extractJwt(session);
        if (authorization.equals(Strings.EMPTY)) {
            throw new NotFoundException("인증 정보를 찾을 수 없습니다.");
        }

        String email = jwtProvider.getEmailFromToken(authorization);
        Account account = accountRepository
                .findAccountByEmail(email).orElseThrow(() -> new NotFoundException("계정을 찾을 수 없습니다."));

        List<WebSocketSession> list = sessions.getOrDefault(account.getLine().getId(), new ArrayList<>());
        list.add(session);
        sessions.put(account.getLine().getId(), list);
    }
    
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        super.afterConnectionClosed(session, status);
    }
}

위는 WebSocket 작동 확인을 위해서 간단하게 짠 코드입니다.(실제로 빈약한 부분이 많기 때문에 잘 수정해서 쓰셔야 합니다.)

 

Spring 은 기본적으로 한번 연결된 WebSocket Connection 의 Session 을 유지하기 때문에 요청이 왔을 때 사용자 식별이 가능하며 런타임 상황에서 필요한 정보들을 세션에 추가할 수 있는 장점이 있습니다.

 

하지만 그룹을 형성하는(채팅방으로 따지면 한 채팅방 안에 들어있는 유저들) 등의 Session 집합을 형성하는 기능은 별도로 제공하고 있지 않기 때문에 저 같은 경우에는 최초 Connection 요청에 포함된 Authorizaiton 헤더에서 토큰을 받아 유저 정보를 참고하여 유저의 아파트 라인 이름을 key 로 session list 를 유지하는 방식으로 구현했습니다.

 

또한 매 메세지 요청마다 세션 인증 정보 검증 방식을 통해서 비정상적인 경로로 날아오는 메세징 요청에 대해서 차단하는 방식을 채택했습니다.

 

@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    private final AlertWebSocketHandler alertWebSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
    
        registry.addHandler(alertWebSocketHandler, "/ws").setAllowedOrigins("*");
    }
}

 

위와 같이 ws://localhost:8080/ws 와 같은 WebSocket 연결 엔드포인트와 그 곳에서 사용될 핸들러를 지정합니다.

 

여기 까지만 설정하고 postman 과 같은 에이전트를 통해 WebSocket Request 를 전송해보면 정상적으로 연결되는 것을 확인할 수 있습니다.

 

STOMP (Simple Text Oriented Messaging Protocol)

WebSocket 만을 사용하기만해도 기본적인 실시간 메세징 서비스는 구축할 수 있을 것입니다.

 

하지만 WebSocket 은 텍스트 데이터를 교환한다는 것 외에 어떠한 형식도 존재하지 않기 때문에 클라이언트와 데이터를 교환하는데에 한계가 있음을 알 수 있습니다. (정해진 형식이 아예 없다는 것은 이식성과 확장성이 굉장히 낮다는 것)

 

따라서 개발자들은 이러한 WebSocket 을 통한 메세징 방식에 하나의 통신 규약을 더 얹었고, 그것이 바로 STOMP 입니다.

 

쉽게 말해 STOMP 는 메세지를 일정한 규칙에 맞춰서 작성하자 인데 이는 굉장히 직관적일 수 있습니다. 그 이유는 HTTP 통신 방식의 데이터 구조와 굉장히 유사하기 때문입니다.

 

또한 WebSocket 그 자체로는 위 코드에서 보았듯이 세션 그룹을 관리하는 기능이 존재하지 않으며, 서로 다른 플랫폼 간의 데이터 교환이 상당히 불편하다는 것을 알 수 있습니다.

 

따라서 개발자들은 이러한 그룹을 만드는 구독(subscription) 과 특정 그룹에게 메세징을 보내는 발행(publishing) 매커니즘을 수행하는 브로커라는 것을 도입했습니다.

 

다시 정리하면, WebSocket 을 통한 단순 텍스트 교환 방식과 달리 STOMP 프로토콜은 메세징의 형식이 존재하고, 구독과 발행의 개념을 통해 메세징을 처리합니다.

 

clientInboundChannel: 클라이언트로 받은 데이터를 처리하는 채널

brokerChannel: 애플리케이션에서 브로커에게 전송할 데이터를 처리하는 채널

clientOutboundChannel: 클라이언트로 전송될 데이터를 처리하는 채널

 

위 그림은 spring doc 에서 제시하는 외부 브로커(external broker) 를 사용하지 않았을 때의 그림입니다.

Subscription prefix 를 /topic, Publishing prefix 를 /app 으로 사용하고 있고, 외부 브로커를 사용하지 않는다는 것은 Spring Application 에서 준비된 SimpleBroker 를 사용한다는 의미의 그림입니다.

 

한 가지 특이한 점은 하나의 Publishing prefix 에 대해 대응되는 여러가지 핸들러 메소드를 정의함으로써 기존의 세션관리와 메세지 핸들링을 같이 수행하는 핸들러의 역할을 분리하여 재활용성을 높였다는 것을 확인할 수 있습니다.

 

channel 이라는 것은 STOMP 데이터를 파싱하고 메세지 내용에 따라서 적절한 로직을 수행합니다.(filter chain 같은 역할?)

 

다시 돌아와서 STOMP 에서 제시하는 메세징의 형식을 살펴보면 아래와 같습니다.

COMMAND
header1:value1
header2:value2

Body^@

 

 

 

동작의 종류를 나타내는 Command, Headers, Body 와 같은 HTTP Request/Response 와 아주 유사한 구조를 가지고 있습니다.

 

다음은 실제 COMMAND 의 대표적인 종류와 그 예시를 살펴보겠습니다.

 

SUBSCRIBE

"/chat/room/5 의 메세지를 받겠습니다."

SUBSCRIBE
destination: /sub/chat/room/5
id: sub-1

 

/sub(Subscription prefix) 이후의 destination 경로는 그룹 식별 경로 쯤으로 우선 이해하고 넘어가면 될 것 같습니다.

 

SEND

"/chat 경로에 매핑되는 메세지 핸들러에게 아래와 같은 데이터를 전송합니다."

SEND
destination: /pub/chat
content-type: application/json

{"chatRoomId": 5, "type": "MESSAGE", "writer": "clientB"}

핸들러는 위와 같은 데이터를 전송받고, 서비스 요구사항에 맞게 로직을 수행한 후 브로커에게 메세지를 전달합니다.

 

MESSAGE

"/chat/room/5 를 구독하는 클라이언트에게 메세지를 전송합니다."

MESSAGE
destination: /sub/chat/room/5
message-id: d4c0d7f6-1
subscription: sub-1

{"chatRoomId": 5, "type": "MESSAGE", "writer": "clientB"}

 

아래는 Stomp 공식 문서에서 제공하는 스펙입니다.

 

내부 브로커 단점 (Simple In-Memory Broker)

내부 브로커를 사용한다는 것은 서버 프로세스 자원(메모리) 을 사용하겠다는 의미입니다.

여기서 클라이언트 세션을 서버 메모리에서 다룰때와 마찬가지로 발생하는 가장 큰 문제점 중 하나가 프로세스 간 유저 세션 동기화입니다.

서버가 한 대 일때는 상관없지만 여러 대가 될 경우에 구독자를 관리하기가 쉽지 않아 보입니다.

 

https://brunch.co.kr/@springboot/695

위 그림과 같이 하나의 구독은 브로커로와 하나의 메세지큐를 통해 메세지를 주고받고 하는데 서버가 늘어났을 때 내부 브로커는 외부(다른 서버) 에 존재하는 구독자의 존재를 알 수가 없습니다.

 

외부 브로커의 경우에는 추후에 서버에 적용을 하며 자세하게 포스팅을  하겠습니다.

 

STOMP 적용 코드

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

    private final StompInterceptor stompInterceptor;

    @Bean
    public StompErrorHandler stompErrorHandler() {
        return new StompErrorHandler();
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/an-ws")
                .setAllowedOriginPatterns("*");
        registry.setErrorHandler(stompErrorHandler());
    }

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

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompInterceptor);
    }
}

WebSocket 을 통해 STOMP 메세징이 이루어지기 때문에 개략적인 설정들은 비슷합니다. (ws 프로토콜을 이용하지만 STOMP 문맥에서는 이를 그냥 STOMP 프로토콜이라고 부르는 것 같다.)

 

registerStompEndpoints() 를 통해서

1. STOMP 프로토콜을 연결할 엔드포인트를 지정합니다.

 

2. 필자의 경우에는 channel 에서 발생하는 예외를 핸들링할 클래스를 별도로 생성했습니다.

 

configureMessageBroker() 를 통해서

1. SimpleBroker(internel broker) 에게 전달되는 destination pattern(/sub) 을 정합니다

-> channel 에서는 해당 prefix 로 시작하는 메세지를 그대로 브로커에게 전달합니다.

 

2. annotated method(@MessageMapping) 로 전달되는 destination patteern(/pub) 를 정합니다.

-> 위에서 설정한 것이 broker 에게 직접적으로 전달하는 메세지라고 한다면 해당 설정은 애플리케이션에 의해서 처리되는 메세지를 의미합니다. 

 

개인적인 생각

보통의 경우에는 클라이언트가 직접적으로 브로커를 향해(1번의 경우) 요청을 보낼일은 잘 없을 것 같습니다... 왜냐하면 저 같은 간단한 경우에는 못해도  [클라이언트 요청 -> validation, logging -> 브로커 -> 응답] 와 같은 순서로 진행해야 하는 상황에서 서버 로직을 타지 않는다는 것은 힘들지 않을까 하는 생각 때문입니다.

 

STOMP 레이어 설계

저의 서버 같은 경우에는 JWT 를 이용해 유저 인증 정보를 교환하고 있고, 아파트 라인 단위로 구독이 이루어져야 하는 상황이였습니다. 그 때의 핵심 요구사항은 아래와 같습니다.

 

1. 인증 정보 기반으로 구독 요청(SUBSCRIBE)이 승인되어야 한다.

2. 인증 정보 기반으로 메세지 전송(SEND)이 승인되어야 한다.

 

한 가지 문제점은 STOMP 요청의 Validation 을 어디서 진행해야 하는지 고민이였고 이와 관련된 자료 또한 많지 않았습니다.

 

여러가지 자료들 중 저는 해당 글 에서 제시하는 ChannelInterceptor 를 통해 Validation 로직을 구성하려고 합니다.

 

ChannelInterceptor 는 메세징 요청/응답(InBound, OutBound) 을 가로채 로직을 수행하는 일종의 필터 역할을 수행한다고 볼 수 있습니다.

 

이 Interceptor 를 이용해 아래와 같은 방식으로 설계했습니다.

CONNECT

1.1 CONNECT 요청 헤더에 Authorization 을 추가하고 토큰값을 저장한다.

1.2 Authorization 을 통해 유저를 검증하고, 라인정보를 세션에 저장한다. (실패 시 connection 을 거절하고 경고 메세지를 발송한다.)

 

SUBSCRIBE

2.1 SUBSCRIBE 요청 destination 값에 포함된 라인정보와 세션에 저장된 라인정보가 일치하는지 확인한다.

2.2 확인이 된다면 정상적으로 브로커에게 메세지를 전달하한다. (실패 시 subscribe 를 거절하고 경고 메세지를 발송한다.)

 

Credential 을 헤더에 저장하는 setUser 방법도 존재하였지만 세션이 유지되는 동안 line 값은 변하지 않기 때문에 session attribute 에 저장을 하였습니다.

 

@Component
@RequiredArgsConstructor
public class StompInterceptor implements ChannelInterceptor {

    private final String SUB_LINE_PREFIX = "/sub/line/";

    private final JwtProvider jwtProvider;
    
    private final LineRepository lineRepository;
    private final AccountRepository accountRepository;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);

        if (StompCommand.CONNECT.equals(headerAccessor.getCommand())) {
            Line line = getLineByAuthorizationHeader(headerAccessor);

            Map<String, Object> sessionAttributes = headerAccessor.getSessionAttributes();
            sessionAttributes.put("line", line.getName());
            headerAccessor.setSessionAttributes(sessionAttributes);

        } else if (StompCommand.SUBSCRIBE.equals(headerAccessor.getCommand())) {
            validateSubscriptionHeader(headerAccessor);
        }

        return message;
    }

    private void validateSubscriptionHeader(StompHeaderAccessor headerAccessor) {
        String lineName = (String) headerAccessor.getSessionAttributes().get("line");
        String destination = headerAccessor.getDestination();

        if (destination != null && destination.startsWith(SUB_LINE_PREFIX)) {
            if (!destination.replace(SUB_LINE_PREFIX, "").equals(lineName)) {
                throw new IllegalStateException("인증 정보가 잘못되었습니다.");
            }
        }
    }

    private Line getLineByAuthorizationHeader(StompHeaderAccessor headerAccessor) {
        List<String> authorization = headerAccessor.getNativeHeader("Authorization");
        if (authorization == null || authorization.size() != 1) {
            throw new NotFoundException("인증 정보를 찾을 수 없습니다.");
        }

        String token = authorization.get(0);
        Long accountId = jwtProvider.getAccountIdFromToken(token);

        Account account = accountRepository.findAccountById(accountId);
        Line line = lineRepository.findById(account.getLine().getId());
        return line;
    }


}

 

destination 이 /pub 로 시작하는 요청

해당 요청은 application 에서 자체적으로 핸들링이 가능한 요청입니다

 

SEND (/alert)

3.1 SEND 요청 body에 Authorization 을 추가하고 토큰값을 저장한다.

3.2 해당 토큰값을 바탕으로 같은 라인의(구독자) 클라이언트에게 메세지를 전송합니다.

 

@MessageMapping annotation 에 명시된 경로에 매핑됩니다 (아래는 /pub/alert)

@RequiredArgsConstructor
@RestController
public class AlertController {
    private final SimpMessagingTemplate template;
    private final JwtProvider jwtProvider;

    private final AccountRepository accountRepository;
    private final LineRepository lineRepository;
    private final HouseRepository houseRepository;

    @MessageMapping(value = "/alert")
    public void alert(MessageDTO<?> messageDTO) {
        String email = jwtProvider.getEmailFromToken(messageDTO.getToken());

        Account account = accountRepository.findAccountByEmail(email)
                .orElseThrow(() -> new NotFoundException("계정을 찾을 수 없습니다."));
        Line line = lineRepository.findById(account.getLine().getId());
        House house = houseRepository.findById(account.getHouse().getId());

        template.convertAndSend("/sub/line/" + line.getName()
                , house.getName() + "호 에서 긴급요청이 왔습니다. 메세지: " + messageDTO.getText());
    }
}

현재는 핸들러 작동 여부를 파악하기 위해서 핸들러 내부에서 Validation 을 수행하였지만 추후 OutBound Channel 의 preSend 에서 로직을 수행하는 코드로 리팩토링 해볼 예정입니다.

 

최종적으로 해당 메세지를 받은 (현재는 destination header 와 body text 밖에 없음) 브로커가 특정 구독자들에게 메세지를 전송하는 것으로 구현을 마쳤습니다.

 

현재 기능 상 추가해야하는 부분은 OutBoundChannel 에서 메세지를 보내는 요청자가 라인에 속해 있는지 검증하는 로직을 추가해야합니다.

 


로직 일부 수정

브로커를 중심으로 InBound/OutBound 로 나뉘는 점을 적극 활용하기 위해서 InBoundChannelInterceptor 에서 SEND command 에 대한 로직을 추가하였습니다.

 

또한 하나의 웹소켓은 최초 연결 이후 항상 그 파이프라인을 유지하고 있기 때문에 매 요청시마다 토큰을 통한 검증이 필요없다고 판단을 하였습니다. 따라서 구독 과정에서 세션에 사용자 정보를 저장하는 것으로 변경하였습니다.

 

@Component
@RequiredArgsConstructor
public class StompInboundInterceptor implements ChannelInterceptor {

    private final String SUB_LINE_PREFIX = "/sub/line/";

    private final JwtProvider jwtProvider;
    
    private final LineRepository lineRepository;
    private final HouseRepository houseRepository;
    private final AccountRepository accountRepository;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
        Map<String, Object> sessionAttributes = headerAccessor.getSessionAttributes();

        if (StompCommand.CONNECT.equals(headerAccessor.getCommand())) {
            Map<String, Object> info = getHouseInfoByAuthorizationHeader(headerAccessor);

            sessionAttributes.put("line", ((Line)info.get("line")).getName());
            sessionAttributes.put("house", ((House)info.get("house")).getName());
            headerAccessor.setSessionAttributes(sessionAttributes);
        }

        if (StompCommand.SUBSCRIBE.equals(headerAccessor.getCommand())) {
            validateSubscriptionHeader(headerAccessor);
        }

        if (StompCommand.SEND.equals(headerAccessor.getCommand())) {
            String destination = headerAccessor.getDestination();

            if (destination == null || !destination.startsWith("/pub")) {
                throw new IllegalStateException("잘못된 접근 입니다.");
            }

            headerAccessor.setNativeHeader("line", sessionAttributes.get("line").toString());
            headerAccessor.setNativeHeader("house", sessionAttributes.get("house").toString());

            message = MessageBuilder.createMessage(message.getPayload(), headerAccessor.toMessageHeaders());
        }

        return message;
    }

    private void validateSubscriptionHeader(StompHeaderAccessor headerAccessor) {
        String lineName = (String) headerAccessor.getSessionAttributes().get("line");
        String destination = headerAccessor.getDestination();

        if (destination != null && destination.startsWith(SUB_LINE_PREFIX)) {
            if (!destination.replace(SUB_LINE_PREFIX, "").equals(lineName)) {
                throw new IllegalStateException("인증 정보가 잘못되었습니다.");
            }
        }
    }

    private Map<String, Object> getHouseInfoByAuthorizationHeader(StompHeaderAccessor headerAccessor) {
        List<String> authorization = headerAccessor.getNativeHeader("Authorization");
        if (authorization == null || authorization.size() != 1) {
            throw new NotFoundException("인증 정보를 찾을 수 없습니다.");
        }

        String token = authorization.get(0);
        Long accountId = jwtProvider.getAccountIdFromToken(token);

        Account account = accountRepository.findAccountById(accountId);
        Line line = lineRepository.findById(account.getLine().getId());
        House house = houseRepository.findById(account.getHouse().getId());

        Map<String, Object> result = new HashMap<>();
        result.put("line", line);
        result.put("house", house);

        return result;
    }


}

최초 Connection 요청에서 얻은 토큰을 통해 사용자 검증을 수행하고 아파트 세대 정보를 세션에 저장합니다.

또한 Subscribe 요청은 destination 의 세대 정보와 실제 세션에 저장된 유저 세대 정보 일치 여부를 확인합니다.

마지막 Send 요청은 /pub 경로를 통해서만 요청을 받을 수 있도록 합니다.

 

핸들러

핸들러의 경우에도 일부 수정을 하였습니다. SimpMessageHeaderAccessor 의 쓰레드에서 생성된 클래스 인스턴스(Bean 아님) 를 주입받아 세션에 저장된 값을 이용하였습니다.

@RequiredArgsConstructor
@RestController
public class AlertController {
    private final SimpMessagingTemplate template;

    @MessageMapping(value = "/alert")
    public void alert(MessageDTO<?> messageDTO, SimpMessageHeaderAccessor accessor) {
        String line = accessor.getNativeHeader("line").get(0);
        String house = accessor.getNativeHeader("house").get(0);

        template.convertAndSend("/sub/line/" + line,
                house + "호 에서 긴급요청이 왔습니다. 메세지: " + messageDTO.getText());
    }
}

 

 

이렇게 관심사의 분리 (Interceptor 에서 요청 검증, 세션값 저장 / Handler 에서 브로커에게 보낼 요청을 형성, 요청) 를 어느정도 수행했습니다.

 

여전히 부족한 부분이 많고, 자료가 다소 부족해 디버깅을 하며 제가 느꼈던 부분을 작성한 부분도 있어 설명이 빈약할 수 있습니다.

 

잘못된 정보가 있다면 댓글로 알려주시면 감사하겠습니다!!

Comments