개발바닥곰발바닥
728x90

Stomp WebSocket + JWT + Spring Security 채팅 구현

Spring에서 REST(또는 HTTP) API를 만들 때, WebSocket 프로토콜인 STOMP와 소켓을 연결할 때 JWT를 사용한 인증 방법에 대해 소개하겠다.

본 포스팅은 REST API 기준으로 작성하였기 때문에 프론트엔드단의 JavaScript 코드는 포함하지 않는다.

STOMP란?

STOMP는 Simple Text Oriented Messaging Protocol의 약자로, 메시지의 형식, 유형, 내용 등을 정의하여 메시징 전송을 효율적으로 도와주는 프로토콜이다.

STOMP의 형식

COMMAND
header:value

Body

COMMAND를 통해 SEND 또는 SUBSCRIBE, CONNECT 등의 명령을 지정하고, header를 정의할 수 있다. 그리고 메시지는 Body에 담아서 보내는 형식이다.

아래는 /topic/chat/1 을 구독하는 메시지 형태를 예시로 만들어 본 것이다.

SUBSCRIBE
Authorization:token
destination: /topic/chat/1
id:sub-1

^@

HTTP의 형식과 닮은 것을 알 수 있는데, COMMAND는 Method와 비슷한 역할이고 header와 Body는 HTTP에도 있는 형식이라 이해하기 쉽다.

STOMP는 Publisher(발행자)-Subscriber(구독자) 관계를 기반으로 동작한다.

발행자와 구독자를 지정하여 메시지 브로커가 특정 구독 채널에 메시지를 전송하는 방식이다.

build.gradle

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

구현에 앞서 build.gradle에 spring-websocket 모듈을 추가해준다.

WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    private final StompHandler stompHandler; // jwt 인증

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic/chat");
        config.setApplicationDestinationPrefixes("/app");
    }

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

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }
}
  • @EnableWebSocketMessageBroker : 메시지 브로커가 메시지를 처리할 수 있게 활성화시킨다.
  • configureMessageBroker : enableSimpleBroker를 통해 메시지 브로커가 /topic/chat으로 시작하는 주소를 구독한 Subscriber들에게 메시지를 전달하도록 한다. setApplicationDestinationPrefixes는 클라이언트가 서버로 메시지를 발송할 수 있는 경로의 prefix를 지정한다.
  • registerStompEndpoints : 소켓에 연결하기 위한 엔드 포인트를 지정해준다. 이 때 CORS를 피하기 위해 AllowedOriginPatterns를 *으로 지정해줬다.(실무에서는 정확한 도메인 지정 필요)
  • configureClientInboundChannel: jwt 토큰 검증을 위해 생성한 stompHandler를 인터셉터로 지정해준다.

StompHandler

@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {
    private final TokenProvider tokenProvider;

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

preSend 메소드에서 클라이언트가 CONNECT할 때 헤더로 보낸 Authorization에 담긴 jwt Token을 검증하도록 한다.

Entity 클래스 정의

@Entity
@DynamicInsert
@Getter
public class Message {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String message;
    private LocalDateTime sendTime;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member sender;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "receiver_id")
    private Member receiver;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "room_id")
    private ChatRoom chatRoom;

}
@Entity
@Table(name = "chat_room")
public class ChatRoom {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "seller_id")
    private Member seller;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Member customer;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;

    @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL)
    private List<Message> messageList = new ArrayList<>();
}

채팅을 구현하기 위한 메시지와 채팅방 Entity 클래스를 만들어 준다.

MessageController

@RestController
@RequiredArgsConstructor
@CrossOrigin
public class MessageController {
    private final SimpMessagingTemplate messagingTemplate;
    private final ChatRoomService chatRoomService;
    private final MessageService messageService;

    @MessageMapping("/chat/send")
    public void chat(MessageDto.Send message) {
        messageService.sendMessage(message);
        messagingTemplate.convertAndSend("/topic/chat/" + message.getReceiverId(), message);
    }

    @PostMapping("/chat/room")
    public ResponseEntity<BasicResponse> JoinChatRoom(@RequestBody ChatRoomDto.Request dto) {
        try {
            Long roomId = chatRoomService.joinChatRoom(dto);
            return ResponseEntity.status(HttpStatus.CREATED).body(new Result<>(roomId));
        } catch(IllegalStateException e) {
            return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage(), "400"));
        }
    }

    @GetMapping("/chat/room")
    public ResponseEntity<BasicResponse> getChatRoomList(@RequestParam Long memberId) {
        return ResponseEntity.ok(new Result<>(chatRoomService.getRoomList(memberId)));
    }

}

클라이언트에서 /app/chat/send 로 메시지를 발행하므로 메시지를 처리하기 위해 MessageController에서 @MessageMapping을 이용해 받아준다.

받은 메시지를 데이터베이스에 저장하기 위해 messageService의 sendMessage 메소드를 호출하고, messagingTemplate의 convertAndSend 메소드를 통해 /topic/chat/수신자ID 를 구독한 유저에게 해당 메시지를 보낸다.

밑의 JoinChatRoom 메소드는 방을 생성하거나 이미 채팅방이 있는 경우 방의 id를 반환해준다.

getChatRoomList에서는 해당 유저의 채팅방 목록을 반환한다.

이렇게 로직을 구현하면 클라이언트에서 소켓을 연결한 후 /topic/chat/자신의id를 구독하면 자신에게 오는 메시지를 받아 처리할 수 있다.

Spring Security

위의 jwt 검증 로직을 구현했음에도 401 에러가 발생하는 경우가 있는데, 해당 경우에는 Spring Security Configuration 클래스에서 WebSocketConfig의 registerStompEndpoints에서 생성한 엔드포인트 경로를 .permitAll 해주면 해결된다.

728x90
profile

개발바닥곰발바닥

@bestinu

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!