CodeStates 메인 프로젝트 – 웹소켓/브레이킹 코드를 이용한 실시간 채팅 구현

github: https://github.com/codestates-seb/seb42_main_024/tree/feat/chatWithMember/server/src/main/java/com/main/server/chat

* 저는 Spring Boot 2.7.9 버전과 Java 11 버전을 사용하고 있습니다.

함수를 생성하기 위해 코드를 작성할 때 파악한 정보를 바탕으로 작성했기 때문에 잘못된 정보가 포함되어 있을 수 있습니다. 뇌에 기록된 정보는 확인을 피하는 방식으로 작성되었으며, 잘못된 정보가 있으면 피드백 부탁드립니다.

1. 구성

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

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

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

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

이 코드는 websocket 기능을 사용하는 데 필요한 설정을 구성합니다.

websocket 기능을 처음 찾았을 때 @EnableWebsocket과 @EnableWebsocketMessageBroker라는 두 가지 방법을 찾았습니다. 사실 두 가지가 독립적으로 사용되는지 몰라서 두 가지 모두 적용해서 개발했습니다. 코드가 사용되지 않는다는 사실을 깨닫고 두 함수가 분리되어 있음을 확인한 후 코드를 정리했습니다.


영어로 질문을 하고 번역기를 돌린 결과입니다.

나중에 Bing AI에게 물어보니 위와 같은 대답을 하더군요.

우리는 Stomp 프로토콜을 통해 통신 기능을 구현했기 때문에 MessageBroker 기능만 작동했습니다.

메시지브로커 좀 더 발전된 기능이라고 하길래 선택하길 잘한 것 같아요^^

그런 다음 @EnableWebsocketMessageBroker 주석을 살펴봐야 합니까?


WebSocketMessageBrokerConfigurer를 상속하고 메서드를 재정의하여 구성하는 예도 있습니다.

여기에서 우리는 HTTP 프로토콜을 사용하여 registerStompEndpoints 메서드로 지정된 끝점에 연결할 때 Stomp 프로토콜을 사용하여 통신을 활성화하기 위해 websocket이 연결된다는 것을 이해합니다.

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

Spring Official에서 제공하는 WebSocket 가이드에서 전면 JavaScript 코드를 보면 새로운 SockJS(Spring WebSocket은 SockJS 기능을 지원함)와 적당한 endpoint(외부 서버에 연결하기 위해서는 해당 도메인 주소도 입력해야 함)를 WebSocket으로 저장할 수 있습니다. 변수를 만들고 사용하십시오.

우리의 경우 위의 코드에서 볼 수 있는데 endpoint가 “/ws”로 표시되어 있으므로 “/gs-guide-websocket”을 “/ws” 또는 “http://localhost:8080/ws”로 변경한다. ” 이렇게 하면 websocket 연결이 활성화됩니다.

일단 연결되면 STOMP라는 프로토콜을 사용하여 통신이 이루어진다고 합니다.

재정의된 두 번째 메서드인 configureMessageBroker 를 살펴보겠습니다.

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

위의 코드를 같이 보면 실용성이 없을 것 같아서 관련 부분만 가져왔습니다.

이 코드에서 이상한 점을 발견하셨나요?

@EnableWebsocketMessageBroker 애노테이션의 설명에는 enableStompBrokerRelay 메소드에 주소를 넣어 사용하라고 되어 있는데 제가 작성한 코드(다른 블로그에 보고된)는 enableSimpleBroker 메소드를 호출합니다.

두 가지가 어느 정도 같은 기능을 하고 있다는 것을 느낄 수 있었지만 정확히 어떤 차이가 있는지 짚어내기 어려웠다.

이 경우 bingAI에게 도움을 요청해야 합니다!


영어 2로 질문 후 번역기를 돌린 결과입니다.

기본 사용에는 SimpleBroker를 사용하고 사용자 지정 기능 및 설정에는 StompBrokerRelay를 사용할 수 있는 것 같습니다.

웹 소켓과 실시간 채팅 기능에 대해서는 입문 수준에서 배우게 될 것이고, 우리가 작업하고 있는 웹 응용 프로그램의 범위가 크지 않기 때문에 SimpleBroker를 그대로 사용하고 기회가 되었으면 합니다. 나중에 StompBrokerRelay에 대해 알아보십시오.

둘의 차이점을 요약하면 공통적으로 어떤 기능을 가지고 있습니까? 만약에,

내가 아는 한 역할은 프런트 엔드 메시지 출력을 위해 “구독”이라는 함수를 작성하는 데 필요한 STOMP 주소를 제공하는 것입니다.

        stompClient.subscribe('/topic/greetings', function (greeting) {
            showGreeting(JSON.parse(greeting.body).content);
        });

나는 또한이 코드를 약간 가져 왔습니다.

위에서 작성한 프런트코드 아래에 주소 “/toipc/greetings”와 익명 함수가 stompClient.subscribe라는 함수에 매개변수로 전달되는 것을 볼 수 있습니다.

프런트 코드의 기능을 관찰하고 평가한 결과,

“/toipc/greetings”라는 주소를 구독하고 그 주소에서 STOMP 메시지를 받아 내용 값을 가져와서 화면에 표시하는 기능처럼 보였습니다.

코드에서 SimpleBroker에 대해 “/sub”라는 경로를 지정했습니다.

“/sub” 경로에 추가된 값에 따라 메시지는 해당 주소의 구독자에게만 배포됩니다.

예) “/sub/room/1” 주소를 구독하면 “sub/room/2″의 대화는 출력되지 않습니다.

이 부분은 설명하는 것보다 이해하려고 노력하는 것이 더 빠를 것 같습니다.

(아니면 제가 설명을 잘 못하는건지. 도와주세요ㅜㅜ)

그리고 주소를 쓰는 config.setApplicationDestinationPrefixes라는 또 다른 메서드가 있습니다.

STOMP 프로토콜을 통해 앞에서 메소드 주소로 본문 값을 보내면 @MessageMapping 주석이 달린 해당 주소에 해당하는 메소드가 백엔드 서버의 컨트롤러에서 실행된다.

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

해당 코드는 위에 링크된 공식 스프링 가이드에서 사용되는 전면 코드의 일부이기도 합니다.

이를 해석하려면 json 형식의 name: 변수를 연결된 웹소켓에 “/app/hello” 주소로 직렬화하고 STOMP 프로토콜로 보내는 것이 중요해 보입니다.

코드에서 우리는 총 4개의 변수를 얻습니다: memberId, memberName, messagem, “/pub” 주소의 chatroomId(DTO가 표시되지 않을 수 있음),

function sendName() {
  stompClient.send("/pub/chat/join", {}, JSON.stringify({'memberId' : $("#memberId").val() , 'memberName': $("#memberName").val() , 'message': $("#message").val() , 'chatroomId': $("#chatroomId").val()}));
}

같은 마음으로 쓰겠습니다.

컨트롤러 코드는 아직 보지 못해서 감이 잘 안잡히는 것 같아요. (설명하기 어렵다.)

전반적인 코드 리뷰 이후에 다시 흐름을 설명하겠습니다.

2. 컨트롤러

방을 생성하는 컨트롤러도 있지만 이 글에서는 websocket을 사용하여 채팅 흐름을 처리하고 STOMP 프로토콜을 사용하여 통신하는 적절한 컨트롤러만 언급하겠습니다.

전체 코드가 궁금하시다면 Github 링크를 참고하시면 됩니다.

(Github 코드는 현재 개발 중이므로 내용이 변경될 수 있습니다.)

@Slf4j
@RestController
@RequiredArgsConstructor
public class ChatController {

    private final ChatService chatService;

    @MessageMapping("/chat/join")
    public void enterUser(@Payload ChatRequestDto dto,
                          SimpMessageHeaderAccessor headerAccessor) {

        chatService.enterUser(dto);
        headerAccessor.getSessionAttributes().put("MemberName", dto.getMemberName());
        headerAccessor.getSessionAttributes().put("roomId", dto.getChatroomId());

        log.info("session: {}", headerAccessor.getSessionAttributes());
    }

    @MessageMapping("/chat/message")
    public void sendMessage(@Payload ChatRequestDto dto) {
        log.info("message: {}", dto.getMessage());
        log.info("chatroomId: {}", dto.getChatroomId());

        chatService.sendMessage(dto);
    }

    @EventListener
    public void webSocketDisconnectListener(SessionDisconnectEvent event) {
        log.info("Disconnect event: {}", event);

        chatService.leaveUser(event);
    }
}

여기서 주의해야 할 키워드는 3가지입니다.

1. @메시지매핑

2. SimplMessageHeaderAccessor

3. 세션 연결 해제 이벤트

먼저 @MessageMapping을 살펴보자.

내가 아는 한 HTTP 메서드에는 9가지 종류(GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, CONNECT, TRACE)가 있고 여기에는 MESSAGE 메서드가 없었습니다.

그런 다음 주석이 할당되고 호출되면

전면에서 보낸 STOMP 프로토콜 주소 중에는 config.setApplicationDestinationPrefixes(“/pub”); 위의 구성 코드에서 언급했습니다. .NET에서 “/pub”가 설정된 후 경로에 매핑하여 호출됩니다.

예를 들어 STOMP 로그를 /pub/chat/join으로 보내면 @MessageMapping(“/chat/join”) 메서드가 호출됩니다.

서비스 코드가 호출된 후 SimpMessageHeaderAccessor에서 SessionAttribute가 키 값으로 설정된 것을 확인할 수 있습니다.

SimpMessageHeaderAccessor에 대한 정보는 STOMP와 같은 간단한 프로토콜의 헤더와 작업하기 위해 필요한 클래스로 이해됩니다. 이 부분에 대해 더 배울 것이 있습니다.

마지막으로 SessionDisconnectEvent 아직 깊이 이해하지는 못하지만 websocket이 연결 해제될 때 호출되는 이벤트 리스너로 알고 있습니다.

또한 위에서 언급한 설정된 헤더 값은 이벤트를 통해 가져올 수 있습니다.

이를 통해 연결이 끊긴 사용자를 식별하고 이벤트를 처리할 수 있었습니다.

3. 서비스

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class ChatService {

    private final ChatroomService chatroomService;
    private final SimpMessagingTemplate template;
    private final ChatRepository chatRepository;


    public void enterUser(ChatRequestDto dto) {
        Chatroom chatroom = chatroomService.findVerifiedRoomId(dto.getChatroomId());

        if (chatroom.getMembers().size() < 4) {
            Integer memberNumber = chatroom
                    .enterMember(dto.getMemberName())
                    .getMemberNumber(dto.getMemberName());

            dto.setMessage("< " + dto.getMemberName() + " > 님이 입장하셨습니다.");

            template.convertAndSend("/sub/chat/room/" + dto.getChatroomId(),
                    dto.toResponseDto(memberNumber).isEnterType());
        } else {
            template.convertAndSend("/sub/chat/room/" + dto.getChatroomId(),
                    dto.toResponseDto(null).isErrorType("방의 정원이 초과했습니다."));
        }
    }

    public void sendMessage(ChatRequestDto dto) {
        Chatroom chatroom = chatroomService.findVerifiedRoomId(dto.getChatroomId());

        Chat chat = Chat.builder()
                .memberId(dto.getMemberId())
                .chatroom(chatroom)
                .content(dto.getMessage())
                .build();

        template.convertAndSend("/sub/chat/room/" + dto.getChatroomId(),
                dto.toResponseDto(chatroom.getMemberNumber(dto.getMemberName())));

        chatRepository.save(chat);
    }

    public void leaveUser(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

        String memberName = (String)headerAccessor.getSessionAttributes().get("MemberName");
        Long chatroomId = (Long)headerAccessor.getSessionAttributes().get("roomId");

        log.info("{}", memberName);
        log.info("{}", chatroomId.toString());

        Chatroom chatroom = chatroomService.findVerifiedRoomId(chatroomId);
        Integer memberNumber = chatroom.getMemberNumber(memberName);
        chatroom.leaveMember(memberName);

        log.info("headAccessor: {}", headerAccessor);

        String message = "< " + memberName + " > 님이 퇴장하셨습니다.";

        ChatResponseDto dto = ChatResponseDto.builder()
                .memberName(memberName)
                .chatroomId(chatroomId)
                .message(message)
                .memberNumber(memberNumber)
                .build()
                .isLeaveType();

        template.convertAndSend("/sub/chat/room/" + chatroomId, dto);
    }
}

코드 내 리포지토리나 로직에 대한 부분과 신중히 고려해야 할 부분은 건너뛰기

1.SimpMessagingTemplate

2. StompHeaderAccessor

보시면 될 것 같습니다.

이 코드에서 SimpMessagingTemplate은 DI를 통해 참조되며 convertAndSend라는 메서드를 호출합니다.

메서드는 주소를 첫 번째 매개변수로, 개체(DTO)를 두 번째 매개변수로 받아 해당 주소로 개체를 직렬화하여 보냅니다.

이 외에도 기능이 다른 메소드가 있으며 자세한 사용법은 공식 문서를 참조할 수 있습니다.

StompHeaderAccessor는 SessionDisconnectEvent의 값으로 Join에 설정된 헤더 값에 액세스하는 데 사용되는 것으로 보입니다.

아직 이 부분에 대한 자세한 내용은 파악하지 못했고, 더 알아봐야 할 것 같습니다.

언급할 중요한 사항을 찾으면 나중에 추가 기사를 작성하겠습니다.

4. 흐름 요약

전체 프로세스를 간략하게 요약하면 다음과 같습니다.

1. registerStompEndpoints 메서드에 설정된 addEndpoint(“/ws”)의 끝점 경로를 사용하여 HTTP 프로토콜을 통한 통신 및 websocket 통신을 구성합니다.

2. enableSimpleBroker(“/sub”)로 설정된 “/sub” 경로 뒤의 특정 주소를 구독합니다.

3. setApplicationDestinationPrefixes(“/pub”) 및 해당 본문 값으로 설정된 “/pub” 경로 뒤에 특정 주소를 전달하여 컨트롤러 메서드를 호출합니다.

4. 해당 메소드가 작성되는 로직을 동작시킨 후, SimpMessagingTemplate 변환 및 전송 dto는 메소드에 의해 특정 주소로 전송됩니다.

5. 특정 주소를 구독하는 클라이언트만 해당 dto를 가져오고 작성된 논리에 의해 화면에 표시됩니다.

어느 정도 이해합니다.