[재능교환소] Spring Boot로 Stomp 기반 1:1 채팅 구현 (with React)

10000JI·2024년 6월 18일
0

프로젝트

목록 보기
12/14
post-thumbnail

🌻 상황

[재능교환소] 프로젝트를 만들면서 필요한 필수 기능이 채팅 이다.

재능교환 게시물을 올리면 사용자끼리 소통이 이루어져야 매칭서비스가 가능하기 때문에 쪽지 혹은 채팅 기능은 필수였다.

쪽지와 채팅 둘 중 어느 기능을 구현할까 고민하다가 1:1 채팅을 구현하기로 하였다.

필자는 백엔드를 맡았기 때문에 소스코드는 백엔드 위주로 설명하려고 한다.

🌼 개념

WebSocket

ws 프로토콜을 기반으로 한 클라이언트와 서버 사이에 지속적인 완전 양방향 연결 스트림을 만들어 주는 기술이다.

웹소켓(Websocket) 은 HTTP 와 구분되는 통신 프로토콜이다.

HTTP 통신은 요청 (Request) 와 응답 (Response) 이 존재한다.

여기서 요청은 클라이언트가, 응답은 서버가 전송한다.

이런 구조로 서버는 능동적으로 클라이언트에 ‘먼저’ 데이터를 전송할 수 없다.

따라서 클라이언트는 서버의 데이터를 얻기 위해 항상 요청을 해야하고, 서버는 이에 수동적으로 응답해주는 구조로 구성되어있다.

이와 같은 통신 구조를 반이중통신(Half-Duplex Communication) 이라고 한다.

HTTP 통신과 다르게 웹소켓은 TCP/IP 의 소켓과 마찬가지로 전이중통신(Full-Duplex Communication)을 지원한다.

웹소켓으로 연결된 서버와 클라이언트는 각 주체가 요청과 응답없이 능동적으로 메세지를 보낼 수 있다.

따라서 웹소켓은 전이중통신과 실시간 네트워킹이 보장되어야 하는 환경에서 유용하게 사용될 수 있다.

Stomp

STOMP는 Simple Text Oriented Messaging Protocol의 약자이다.

Stomp는 메시지 전송을 효율적으로 하기 위한 프로토콜로, 기본적으로 pub와 sub 구조로 되어있다.

pub : 메시지를 전송하고

sub : 전송한 메세지를 받아서 처리하는 구조

STOMP 프로토콜은 클라이언트/서버 간 전송할 메시지의 유형, 형식, 내용들을 정의한 규칙이다.

TCP 또는 WebSocket과 같은 [양방향 네트워크 프로토콜 기반]으로 동작한다.

헤더에 값을 세팅할 수 있어서 헤더 값을 기반으로 통신 시 인증처리를 구현할 수 있다.

pub / sub 구조

pub/sub란 메시지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 메시징 방법이다.

우체통(Topic)이 있다면 집배원(Publisher)이 신문을 우체통에 배달하는 행위가 있고, 우체통에 신문이 배달되는 것을 기다렸다가 빼서 보는 구독자(Subscriber)의 행위가 있다. 이때 구독자는 다수가 될 수 있다.

즉, 채팅방을 생성하는 것은 우체통 Topic을 만드는 것이고 채팅방에 들어가는 것은 구독자로서 Subsciber가 되는 것이다. 채팅방에 글을 써서 보내는 행위는 우체통에 신문을 넣는 Publisher가 된다.

Message Brocker

이때 Message Brocker란 개념이 있다.

이것은 [Publisher]로 부터 전달받은 메시지를 [Subscriber]에게 메시지를 주고 받게 해주는 중간 역할 하는 것을 말한다.

클라이언트는 SEND, SUBSCRIBE 명령을 통해서 메시지의 내용과 수신 대상을 설명하는 "destination" 헤더와 함께 메시지에 대한 전송이나 구독을 할 수 있다.

이것이 브로커를 통해 연결된 다른 클라이언트로 메시지를 보내거나, 서버로 메시지로 보내 일부 작업을 요청할 수 있는 PUB/SUB 메커니즘을 가능하게 한다.

스프링이 지원하는 Stomp에서는 스프링 웹 소켓 애플리케이션이 클라이언트에게 Stomp 브로커의 역할을 한다. 이때 메시지는 @Controller 메시지 처리 방법이나, Subscriber를 추적해서 구독중인 사용자에게 메시지를 전파(Broadcast)하는 Simple In Momory 브로커에게 라우팅 된다.

이렇게 Spring 환경에서 추가적인 설정없이 Stomp 프로토콜을 사용하면 메시지 브로커는 자동으로 In Memory Brocker를 사용하게 된다.

🌷 Stomp를 사용한 1:1 채팅 구현

순서대로 소스코드를 살펴보자.

필자는 Maven을 사용하였기 때문에 pom.xml에 의존성을 추가해주었다.

pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-messaging</artifactId>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

Websocket을 사용하기 위해 Websocket dependency를 추가해주었다.

여기에 Stomp의 Message 기능을 위해 spring-messaging도 추가해주었다.

Stomp는 메세지를 json 형태로 사용하기 때문에 jackson-core(json)과 json을 dto로 자동 데이터 변환 해주는 jackson-databind가 필요하다.

WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {

        // socketJs 클라이언트가 WebSocket 핸드셰이크를 하기 위해 연결할 endpoint를 지정할 수 있다.
        registry.addEndpoint("/chat/inbox")
                .setAllowedOriginPatterns("*") // cors 허용을 위해 꼭 설정해주어야 한다.
                .withSockJS(); //웹소켓을 지원하지 않는 브라우저는 sockJS를 사용하도록 한다.
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 메모리 기반 메시지 브로커가 해당 api를 구독하고 있는 클라이언트에게 메시지를 전달한다.
        // to subscriber
        registry.enableSimpleBroker("/sub");

        // 클라이언트로부터 메시지를 받을 api의 prefix를 설정한다.
        // publish
        registry.setApplicationDestinationPrefixes("/pub");


    }
}

WebSocketMessageBrokerConfigurer 인터페이스를 구현한다.

두 번째 메서드에서 enableSimpleBroker가 위에서 언급한 메시지 브로커이다.

ws://localhost/chat/inbox를 호출하면 websocket 연결이 된다.

이후 /sub를 통해 채팅방에 구독 신청을 하고, 채팅 데이터를 전송할 때 마다 /pub 관련 메서드를 호출해 채팅방 구독하는 유저에게 메시지 브로커가 메시지를 전달할 것이다.

다음은 채팅을 위해 추가한 Entity를 살펴보자.

ChatRoom

@Data
@Entity
@Table(name = "ChatRoom")
@DynamicUpdate
@Builder
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@EntityListeners(value = {AuditingEntityListener.class})
@NoArgsConstructor
@AllArgsConstructor
public class ChatRoom {
    @EqualsAndHashCode.Include
    @Id
    @Column(name = "id")
    private String id;

    //단방향
    @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "lastChatMesgId")
    private ChatMessage lastChatMesg;

    @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinTable(name = "ChatRoom_Members",
            joinColumns = @JoinColumn(name = "chatRoomId"),
            inverseJoinColumns = @JoinColumn(name = "userId"))
    private Set<User> chatRoomMembers = new HashSet<>();

    @Column(name = "createdAt", updatable = false)
    @CreatedDate
    private LocalDateTime createdAt;

    public static ChatRoom create() {

        ChatRoom room = new ChatRoom();
        room.setId(UUID.randomUUID().toString());

        return room;
    }

    public void addMembers(User roomMaker, User guest) {
        this.chatRoomMembers.add(roomMaker);
        this.chatRoomMembers.add(guest);
    }
}

ChatMessage

@Data
@Entity
@Table(name = "ChatMessage")
@Builder
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@EntityListeners(value = {AuditingEntityListener.class})
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {

    @EqualsAndHashCode.Include
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @JoinColumn(name = "roomId", insertable = false, updatable = false)
    private String roomId; //단순히 ID 값만 필요  (ChatRoom)

    @JoinColumn(name = "authorId", insertable = false, updatable = false)
    private String authorId; //단순히 ID 값만 필요 (User)

    @Column(name = "message")
    private String message;

    @Column(name = "createdAt", updatable = false)
    @CreatedDate
    private LocalDateTime createdAt;
}

채팅 기능을 구현하려면 먼저 채팅이 이루어질 채팅방이 존재해야 한다.

따라서 채팅 메시지 통신 과정에 앞서, 채팅방을 생성하고 데이터베이스에 저장하는 단계가 선행되어야 한다.

필자는 먼저 채팅방 생성 API를 구현하여 데이터베이스에 저장하는 과정을 거쳤다.

그 다음에 실제 채팅 통신이 이루어질 때, 이전에 생성된 채팅방에 메시지를 저장하는 방식으로 구현하였다.

따라서 채팅 메시지 통신 과정을 설명하기에 앞서, 채팅방 개설 과정부터 먼저 살펴보자.

ChatRoomController

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/chatRoom/")
@Slf4j
public class ChatRoomController {

    private final ChatRoomService chatRoomService;

    @PostMapping("/personal") //개인 DM 채팅방 생성
    public ChatDto.CreateChatRoomResponse createPersonalChatRoom(@RequestBody ChatDto.CreateChatRoomRequest request) {
        return chatRoomService.createChatRoomForPersonal(request);
    }
}

CreateChatRoomRequest

/**
 * 채팅방 개설 요청 dto
 */
@Getter
public static class CreateChatRoomRequest {
    private String roomMakerId;
    private String guestId;
}

CreateChatRoomResponse

/**
 * 채팅방 개설 성공시 응답 dto
 */
@Getter
public static class CreateChatRoomResponse {
    private String roomMakerId;
    private String guestId;
    private String chatRoomId;

    /* Entity -> Dto */
    public CreateChatRoomResponse(String roomMakerId, String guestId, String chatRoomId) {
        this.roomMakerId = roomMakerId;
        this.guestId = guestId;
        this.chatRoomId = chatRoomId;
    }
}

ChatRoomServiceImpl

@Service
@RequiredArgsConstructor
public class ChatRoomServiceImpl implements ChatRoomService {

    private final UserRepository userRepository;
    private final ChatRoomRepository chatRoomRepository;
    private final SecurityUtil securityUtil;

    @Override // 개인 DM방 생성
    public ChatDto.CreateChatRoomResponse createChatRoomForPersonal(ChatDto.CreateChatRoomRequest chatRoomRequest) {
        String id = securityUtil.getCurrentMemberUsername(); //id=roomMakerId 같아야 함
        if (!id.equals(chatRoomRequest.getRoomMakerId())) {
            throw UserNotFoundException.EXCEPTION;
        }
        User roomMaker = userRepository.findById(id).orElseThrow(() -> UserNotFoundException.EXCEPTION);
        User guest = userRepository.findById(chatRoomRequest.getGuestId()).orElseThrow(() -> UserNotFoundException.EXCEPTION);

        ChatRoom newRoom  = ChatRoom.create();
        newRoom.addMembers(roomMaker, guest);

        chatRoomRepository.save(newRoom);

        return new ChatDto.CreateChatRoomResponse(roomMaker.getId(),guest.getId(), newRoom.getId());
    }
}

String id = securityUtil.getCurrentMemberUsername();는 SecurityContext에 저장된 User의 id를 가져오는 메서드이다.

ChatRoom의 id는 UUID로 랜점한 값으로 설정하여 채팅방을 만들었고, 로그인한 사용자와 대화할 사용자 id를 ChatRoom_Members 엔티티에 저장하였다.

DB에는 위와 같이 저장된다.

다음으로 실제로 채팅을 발행할 수 있게 만들어줄 수 있도록 엔드포인트가 호출되는 Controller를 살펴보자.

ChatMessageController

@Controller
@RequiredArgsConstructor
@Slf4j
public class ChatMessageController {
    private final ChatMessageService chatMessageService;
    private final ChatRoomService chatRoomService;
    private final SimpMessagingTemplate messagingTemplate;

    @MessageMapping("/message")
    public void sendMessage(ChatDto.ChatMessageDto message) {
        // 실시간으로 방에서 채팅하기
        ChatMessage newChat = chatMessageService.createChatMessage(message);
        log.info("received message: {}", message);

        // 방에 있는 모든 사용자에게 메시지 전송
        messagingTemplate.convertAndSend("/sub/channel/"+message.getRoomId(), newChat);
    }
}

ChatMessageDto

/**
 * 웹소켓 접속시 요청 Dto
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ChatMessageDto {
    private String roomId;
    private String authorId;
    private String message;

    /* Dto -> Entity */
    public ChatMessage toEntity() {
        ChatMessage chatMessage = ChatMessage.builder()
                .roomId(roomId)
                .authorId(authorId)
                .message(message)
                .createdAt(LocalDateTime.now())
                .build();
        return chatMessage;
    }
}

ChatMessageServiceImpl

@Service
@RequiredArgsConstructor
public class ChatMessageServiceImpl implements ChatMessageService{

    private final ChatRoomRepository chatRoomRepository;

    @Override
    public ChatMessage createChatMessage(ChatDto.ChatMessageDto chatMessageDto) {
        ChatMessage chatMessage = chatMessageDto.toEntity();
        ChatRoom chatRoom = chatRoomRepository.findById(chatMessage.getRoomId()).orElseThrow();
        chatRoom.setLastChatMesg(chatMessage);
        chatRoomRepository.save(chatRoom);

        return chatMessage;
    }
}

Stomp는 @MessageMapping을 이용하여 handler를 직접 구현하지 않고 controller를 따로 분리해서 관리 가능하다.

@MessageMapping

작성된 경로와 이전에 configuration에서 지정한 prefix인 /pub가 합쳐져 /pub/message로 메시지 발행 요청을 하는 것이다.

SimpleMessagingTemplate

simpleMessagingTemplate는 이전에 @EnableWebSocketMessageBroker를 통해 bean으로 등록된 것이다.
이를 통해 메시지 브로커로 데이터를 전달한다.

ChatMessageController 를 보면 messagingTemplate.convertAndSend("/sub/channel/"+message.getRoomId(), newChat); 라고 작성되어 있다.

이는 /sub/channel/{roomId}를 구독하고 있는 모든 사용자에게 메시지 전송된다는 뜻이다.

여기서 필자는 구독하고 있는 사용자들에게 메시지를 전송하기 전에 도착한 메세지를 DB에 저장하였다.

🌷 APIC로 Stomp 테스트-1

참고로 WebSocketConfig의 .withSockJS();는 주석 처리해주어야 테스트가 진행된다.

APIC로 테스트 하러 가기

현재 Postman으로는 Stomp 테스트가 불가하기 때문에 필자는 APIC로 Stomp를 테스트하였다.

들어가면 바로 테스트할 수 있는 창이 나오는데, 여기서 그대로 테스트를 진행하는게 아니라 상단에 ws 바를 클릭해준다.

Request URL에는 websocket을 연결하기 위해 ws://localhost/chat/inbox을 적어준다.

그리고 Connection type에 WebSocket 대신 Stomp를 클릭해주고, Stomp Subscription URL에는 앞서 말한 /sub/channel/{roomId}을 적어준다.

roomId는 채팅방을 생성해준 뒤 반환해주는 roomId 필드 값을 그대로 써주면 된다.

/sub/channel/6f3cfc9d-1504-4f9b-96cb-843cd96031e0 라고 필자는 적어주었다.

그리고 서버를 실행시켜준 뒤 Connect를 눌러준다.

Connect 대신 Disconnect로 변경되었고, Messages 부분에도 Somp connected 라고 뜬다.

새로운 창을 다시 띄어준뒤, 앞서 설정한 Request URL와 Subscription URL을 동일하게 작성해주고 Connect를 눌러준다.

그리고 Send의 Destination Queue에는 메세지 발행 요청을 위해 /pub/message라고 적어주었다.

옆에 json형태로 메세지를 보내줄 건데, 위에서 본 DTO 형식 그대로 적어주면 된다.

{
    "roomId" : "6f3cfc9d-1504-4f9b-96cb-843cd96031e0",
    "authorId" : "alswl3359",
    "message" : "다시 인사드립니다."
}

roomId는 채팅방 id, authorId는 보내는이, message는 작성하고 싶은 메세지를 적어주면 된다.

그리고 send를 눌러준다.

send를 눌러주면(왼쪽) 해당 채팅방을 구독하고 있는 사용자(오른쪽)에게 메세지가 도착하는 것을 확인할 수 있다.

🌈 (2024.06.23) 인증 로직 추가

위의 테스트 과정에서 메시지를 보낼 때 roomId, authorId, 및 message가 Json 형식으로 전송된다.

하지만 실제로 메시지를 이렇게 전송하면 authorId는 로그인한 사용자가 아닌 다른 사용자의 ID로 요청될 수 있기 때문에 보안상 취약하다.

따라서 세션(Session) 또는 JWT(Json Web Token)와 같은 인증 수단이 필요하다.

인증을 통해 메시지를 전송하는 사용자가 실제로 해당 메시지를 보낼 권한이 있는지 확인해야 한다.

이를 통해 메시지 전송 과정에서의 보안성을 높일 수 있다.

필자는 JWT를 사용하여 AccessToken을 발급하고, 이를 HTTP 통신 시 인증 방법으로 사용한다.

새로운 토큰을 발급받는 것보다 기존에 사용하던 액세스 토큰을 사용하는 것이 더 낫다고 판단하여, HTTP 통신때 처럼 요청 헤더에 액세스 토큰을 key:value 형식으로 액세스 토큰을 전달하였다.

그런 다음, 토큰에서 직접 유저 정보를 추출해서 웹 소켓 연결 세션에 저장하여 통신하였다.

그럼 변경된 코드를 살펴보자.

WebSocketMessageBrokerConfigurer

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

    private final StompHandler stompHandler;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {

        // socketJs 클라이언트가 WebSocket 핸드셰이크를 하기 위해 연결할 endpoint를 지정할 수 있다.
        registry.addEndpoint("/chat/inbox")
                .setAllowedOriginPatterns("*"); // cors 허용을 위해 꼭 설정해주어야 함. setCredential() 설정시에 AllowedOrigin 과 같이 사용될 경우 오류가 날 수 있으므로 OriginPatterns 설정으로 사용하였음
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 메모리 기반 메시지 브로커가 해당 api를 구독하고 있는 클라이언트에게 메시지를 전달함
        // to subscriber
        registry.enableSimpleBroker("/sub");

        // 클라이언트로부터 메시지를 받을 api의 prefix를 설정함
        // publish
        registry.setApplicationDestinationPrefixes("/pub");


    }

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

}

StompHandler

@RequiredArgsConstructor
@Component
@Slf4j
public class StompHandler implements ChannelInterceptor {

    private final JwtService jwtService;

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

        if (accessor.getCommand() == StompCommand.CONNECT) {
            String accessToken = accessor.getFirstNativeHeader("Authorization");
            if (!this.validateAccessToken(accessToken)) {
                throw AccessTokenRequiredException.EXCEPTION;
            }
            String id = getUserId(accessToken);
            accessor.addNativeHeader("senderUserId", id);
        }


        return message;
    }

    public String getUserId(String accessToken) {
        try {
            String token = accessToken.trim();
            if (token.startsWith("Bearer ")) {
                token = token.substring(7).trim();
            }
            String id = jwtService.extractUsername(token);
            if (id == null || id.isEmpty()) {
                throw UserTokenExpriedException.EXCEPTION;
            }
            return id;
        } catch (ExpiredJwtException e) {
            log.error("만료된 JWT 토큰: {}", e.getMessage());
            throw UserTokenExpriedException.EXCEPTION;
        } catch (MalformedJwtException e) {
            log.error("잘못된 형식의 JWT 토큰: {}", e.getMessage());
            throw UserTokenExpriedException.EXCEPTION;
        } catch (Exception e) {
            log.error("사용자 ID 추출 중 예기치 않은 오류 발생: {}", e.getMessage());
            throw UserTokenExpriedException.EXCEPTION;
        }
    }

    private boolean validateAccessToken(String accessToken) {
        if (accessToken == null || accessToken.trim().isEmpty()) {
            return false;
        }

        String token = accessToken.trim();
        if (token.startsWith("Bearer ")) {
            token = token.substring(7).trim();
        }

        try {
            Claims claims = jwtService.extractAllClaims(token);
            return true;
        } catch (ExpiredJwtException | MalformedJwtException e) {
            log.error("토큰 검증 실패: {}", e.getMessage());
            return false;
        }
    }
}

WebSocketMessageBrokerConfigurer에 ChannelInterceptor를 구현한 StompHandler가 추가되었다.

configureClientInboundChannel에서는 요청 전에 인터셉터를 설정할 수 있는데, 요청 전에 토큰 검증을 위해 StompHandler를 설정해주었다.

StompHandler에서는 소켓 연결 후 헤더에서 Authorization을 찾고, validateAccessToken으로 유효한 토큰인지 검증한다.

만약 유효하다면 AccessToken에서 userId를 추출하여 accessHeader.addNativeHeader를 사용해 기존 헤더에 userId 정보를 추가해 저장한다.

StompEventListener

@Component
@Slf4j
@RequiredArgsConstructor
public class StompEventListener {

    private final JwtService jwtService;
    private final StompHandler stompHandler;

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {

        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String sessionId = headerAccessor.getSessionId();

        log.info("[Connected] websocket session id : {}", sessionId);
    }

    @EventListener(SessionConnectEvent.class)
    public void onApplicationEvent(SessionConnectEvent event) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
        String accessToken = accessor.getFirstNativeHeader("Authorization");

        if (accessToken != null && accessToken.startsWith("Bearer ")) {
            // "Bearer " 접두사 제거 및 공백 제거
            accessToken = accessToken.substring(7).trim();

            try {
                if (!jwtService.isTokenExpired(accessToken)) {
                    String id = stompHandler.getUserId(accessToken);
                    accessor.getSessionAttributes().put("senderUserId", id);
                } else {
                    // 토큰이 만료된 경우 로그
                    log.warn("Expired token received");
                }
            } catch (Exception e) {
                // JWT 처리 중 발생한 예외 로그
                log.error("Error processing JWT token", e);
            }
        } else {
            // 유효한 Authorization 헤더가 없는 경우 로그
            log.warn("No valid Authorization header found");
        }
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {

        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String sessionId = headerAccessor.getSessionId();

        log.info("[Disconnected] websocket session id : {}", sessionId);
    }
}

더 정확하게 디버깅하기 위해 이벤트 리스너도 추가하였다.

ChatMessageController

@Controller
@RequiredArgsConstructor
@Slf4j
public class ChatMessageController {
    private final ChatMessageService chatMessageService;
    private final SimpMessagingTemplate messagingTemplate;

    @MessageMapping("/message")
    public void sendMessage(ChatDto.ChatMessageDto message, SimpMessageHeaderAccessor accessor) {
        String userId = (String) accessor.getSessionAttributes().get("senderUserId");

        // 실시간으로 방에서 채팅하기
        ChatMessage newChat = chatMessageService.createChatMessage(message, userId);
        log.info("received message: {}", message);

        // 방에 있는 모든 사용자에게 메시지 전송
        messagingTemplate.convertAndSend("/sub/channel/"+message.getRoomId(), newChat);
    }
}

ChatMessageDto

/**
 * 웹소켓 접속시 요청 Dto
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ChatMessageDto {
    private String roomId;
    //private String authorId;
    private String message;

    /* Dto -> Entity */
    public ChatMessage toEntity(String userId) {
        ChatMessage chatMessage = ChatMessage.builder()
                .roomId(roomId)
                .authorId(userId)
                .message(message)
                .createdAt(LocalDateTime.now())
                .build();
        return chatMessage;
    }
}

마지막으로 메세지를 전송하는 컨트롤러에선 연결 세션에서 userId의 정보를 받으면 json으로 요청하지 않아도 세션에서 꺼내와 사용이 가능하다.

🌈 인증 추가 후 APIC로 STOMP 테스트-2

Headers 부분에 Key는 Authorization, Value는 Bearer {AccessToken}을 작성해준다.

Send의 요청 메세지는 authorId를 제외한 roomId와 message를 넣어주었다.

여기서 주의할 점은 Headers에 AccessToken을 설정하려고 하면 위 사진처럼 체크박스가 선택된 채로 Key:Value가 하나 더 나오게 된다.

만약 체크박스 되어있다면 풀어주고 우측에 x 버튼을 눌러 삭제시켜 주자
(빈 Key와 빈 Value로 서버에 요청이 되버러 에러가 발생하니 유의하자)

1:1 채팅이 가능하도록 새 탭을 하나 더 만들어 다른 사용자의 AccessToken을 헤더에 설정해주도록 하자

요청을 보내면 토큰에서 추출된 사용자 ID와 함께 메세지가 전송되고, 상대방에게도 잘 도착한다.

메시지가 정상적으로 송수신되는 것을 확인할 수 있다.

🌱 느낀점

Spring Boot와 STOMP를 사용하여 1:1 채팅 기능을 구현하는 과정에서 실시간 데이터 전송, 메시징 브로커, 컴포넌트 간 분리, 사용자 인증 등 다양한 측면을 경험할 수 있었다.

이번 프로젝트에서는 Spring의 내장 브로커를 사용하였지만 실제 프로덕션 환경에선 외부 메시지 브로커를 사용하는 것이 좋을 것 같다.

이후 RabbitMQ와 같은 외부 메시지 브로커로 고도화하는 작업도 구현해보도록 하자. 🍀

출처

채팅서비스를 구현하며 배워보는 Websocket 원리 (feat. node.js)

Spring Boot WebSocket (2) - 웹소켓 이해하기 _ STOMP로 채팅 고도화 하기

[Stomp] Spring Boot with React 채팅 서버

STOMP와 JWT Token을 활용한 채팅 서버 구축

profile
Velog에 기록 중

0개의 댓글