프로젝트

CodeStates 메인프로젝트 - 웹소켓을 이용한 실시간 채팅 구현 / 코드 파해치기

GoF9490 2023. 3. 19. 21:19

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

 

* 스프링 부트 2.7.9 버전과 Java 11 버전을 사용하고 있습니다.

 

제가 해당 기능을 만들기위해 코드를 짜며 파악한 정보를 바탕으로 작성되는 글이기 때문에 틀린 정보가 포함되있을 수도 있습니다. 뇌피셜로 작성된 정보는 확언을 피해서 작성하고있으며, 틀린 정보가 있다면 피드백을 부탁드립니다.

 

1. Config

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

}

웹소켓 기능을 이용하기 위해 필요한 설정을 구성하는 코드입니다.

제가 맨 처음 웹소켓 기능을 배우기위해 얼필 찾아봤을때 @EnableWebsocket 과 @EnableWebsocketMessageBroker 두가지 방식을 발견했었고, 사실 두가지가 독립적으로 쓰이는지를 몰라서 둘다 적용하고 개발했었는데, 개발 도중 @EnableWebsocket 이 붙은 컨픽과 그에 따른 기능이 쓰이지 않고있다는것을 깨닫고 두개의 기능이 별개라는 것을 파악 후 코드를 정리했습니다.

 

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

이후에 bing AI에게 물어보니 위와 같은 답을 해주었습니다. 

저희는 stomp 프로토콜을 통해 통신기능을 구현하고 있었고, 때문에 MessageBroker 기능만 작동을 하던 것이였습니다.

MessageBroker 가 좀더 고급기능이라는 답을 들으니까 선택을 잘 한 느낌이 들더군요.^^

 

그럼 이제 @EnableWebsocketMessageBroker 라는 어노테이션에 대해 한번 뜯어봐야겠지요?

 

친절하게 WebSocketMessageBrokerConfigurer 를 상속받고 두개의 메서드를 오버라이드해서 구성하라고 예시까지 들어있습니다.

 

여기서 registerStompEndpoints 메서드로 설정되는 엔드포인트로 http 프로토콜로 접속하면 이후 웹소켓이 연동되어 stomp 프로토콜로 소통이 가능해진다고 파악하고 있습니다.

 

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

스프링 공식으로 제공하는 웹소켓 가이드에서 프론트쪽 자바스크립트 코드를 보면 new SockJS (스프링 웹소켓은 SockJS 기능을 지원한다고 합니다.) 에 알맞는 엔드포인트 (외부 서버와 연결을 위해서는 알맞는 도메인 주소도 같이 입력해야 합니다.) 를 이용해 웹소켓을 생성해 변수로 저장하고 사용하는 것을 볼 수 있습니다.

저희같은 겅우 위에 코드에서도 볼 수 있지만, "/ws" 라는 엔드포인트로 지정이 되어있기에 "/gs-guide-websocket" 부분을 "/ws" 또는 "http://localhost:8080/ws" 로 바꿔주면 웹소켓 연결이 가능하게 됩니다.

연결 이후에는 STOMP 라는 프로토콜로 통신이 이루어진다고 합니다.

 

 

두번째 오버라이드 메서드인 configureMessageBroker 에 대해서도 한번 알아보겠습니다.

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

위의 코드를 같이보기 불편하실까봐 해당부분만 가져왔습니다.

 

이 코드에서 한가지 특이한 점을 눈치채셨을까요?

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

 

둘이 같은 기능을 한다는 것은 어느정도 감으로 느낄 수 있었지만, 정확히 무슨 차이인지는 찾기 어려웠습니다.

이럴때야말로 bingAI 에게 도움을 청해야 할때 !

 

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

보아하니 간단하게 사용할 경우에 SimpleBroker를, 기능이나 설정을 커스텀해서 사용할 겅우 StompBrokerRelay를 사용하면 될 것 같습니다.

저희는 웹소켓과 실시간 채팅 기능에 대해 입문수준으로 배우는 입장이고, 작업중인 웹어플의 규모도 크지 않기에 일단 SimpleBroker를 그대로 사용하는 것으로 하고, 추후에 StompBrokerRelay를 배워볼 기회가 있었으면 하네요.

 

두개의 차이는 이정도로 정리하고, 그렇다면 그것들은 공통적으로 무슨기능을 하느냐? 하면, 

제가 파악하기로는 프론트에서 메세지 출력을 위해 subscribe 라는 기능을 쓰기위해 필요한 STOMP 주소를 지정해 주는 역할이라고 파악하고 있습니다.

 

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

역시 해당 코드만 살짝 가져와봤습니다.

 

위의 작성된 프론트 코드 중 stompClient.subscribe 라는 함수에 "/toipc/greetings" 라는 주소와 어떠한 익명함수를 매개변수로 넘겨주는것을 볼 수 있습니다.

프론트 코드가 동작하는것을 관찰하고 판단한 결과,

"/toipc/greetings" 라는 주소를 구독(subscribe) 하고, 이후 해당 주소로 오는 STOMP 메세지를 받아서 content 값을 가져와 화면에 출력해주는 역할을 하는 함수로 보였습니다.

 

저희 코드에서는 SimpleBroker 로 "/sub" 라는 패스를 지정해주었습니다. 

"/sub" 패스 뒤에 붙는 값에 따라서 해당 주소의 구독자들에게만 메세지가 뿌려지는 형식입니다.

ex ) "/sub/room/1" 이라는 주소를 구독하고 있다면 "sub/room/2" 에서 이루어지는 대화는 출력이 되지 않습니다.

 

해당 부분은 설명하기보다는 직접 해보고 이해하는편이 빠를것 같습니다.

(아니면 제가 설명을 잘 못하는 것일지도 모릅니다. 살려줘요ㅜㅜ)

 

그리고 또하나 주소를 쓰고있는 config.setApplicationDestinationPrefixes 라는 메서드.

해당 메서드의 주소로 프론트에서 STOMP 프로토콜로 바디값을 넣어 보내면, 백엔드 서버의 컨트롤러에서 @MessageMapping 어노테이션이 붙은 알맞은 주소에 해당하는 메서드가 실행되는 방식이였습니다.

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

해당코드 역시 위에 링크로 드린 스프링 공식 가이드에서 쓰인 프론트 코드중 일부입니다.

해석하자면, 연결된 웹소켓에 "/app/hello" 주소로 name : 변수 의 Json 형태를 직렬화 해서 STOMP 프로토콜로 보내는 역할을 하는것 같습니다.

 

저희코드에서는 "/pub" 라는 주소로 (DTO는 안보이시겠지만) memberId, memberName, messagem, chatroomId 총 4개의 변수를 받고잇기에,

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

같은 느낌으로 작성이 되겠습니다.

 

아직 Controller 코드를 안보여드려서 감 잡기가 힘드실것 같습니다. (설명하기 어렵네요ㅜㅜ)

뒤에 전체적인 코드 리뷰 후 흐름을 다시한번 설명드리겠습니다.

 

2. Controller

방을 만드는 컨트롤러도 따로 있지만, 해당 글에서는 전체적으로 웹소켓을 이용한 채팅, 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. @MessageMapping

2. SimpMessageHeaderAccessor

3. SessionDisconnectEvent

 

일단 @MessageMapping 를 보겠습니다.

제가 알기로는 http 메서드의 종류는 총 9개로 (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, CONNECT, TRACE) 여기에 MESSAGE 라는 메서드는 없었습니다.

그럼 해당 어노테이션은 무엇에 매핑되서 호출되느냐 하면,

프론트에서 보내는 STOMP 프로토콜의 주소 중, 위의 컨픽 코드에서 언급된 config.setApplicationDestinationPrefixes("/pub"); 에서 설정된 "/pub" 이후의 패스에 매핑되서 호출됩니다.

 

예로 "/pub/chat/join" 으로 STOMP 프로토콜을 보내면 @MessageMapping("/chat/join") 메서드가 호출되는 형식입니다.

 

이후 service 코드가 호출된 이후 SimpMessageHeaderAccessor 에 키 벨류 형태로 SessionAttribute 가 set되는것을 보실 수 있습니다.

SimpMessageHeaderAccessor 에 대한 정보는 STOMP 같은 간단한 프로토콜의 헤더에 대한 작업을 하기위해 필요한 클래스 정도로 파악하고 있습니다. 이 부분에 대해서는 아직 학습이 필요해보입니다.

 

마지막으로 SessionDisconnectEvent 이녀석도 아직 깊이 파악하고있지는 못합니다만, 웹소켓의 연결이 끊길때 호출되는 이벤트리스너 정도로 파악하고 있습니다.

또한 여기서 위에 언급된 set된 헤더값을 이벤트를 통해서 조회할 수 있으며,

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

 

3. Service

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

해당 코드에서 repository나 로직에 대한 부분은 넘기고, 주의깊게 봐야 될 부분은

1. SimpMessagingTemplate

2. StompHeaderAccessor

 

정도로 볼 수 있을것 같습니다.

 

해당 코드에서 SimpMessagingTemplate은 DI를 통해서 참조되어  convertAndSend 라는 메서드를 호출하고 있습니다.

해당 메서드는 첫번째 매개변수로 주소를 받고 두번째 매개변수로 오브젝트(DTO)를 받아, 해당 주소로 오브젝트를 직렬화 시켜서 보내게 됩니다.

그 외에 다양한 기능을 가진 메서드가 있으며, 자세한 사용방법은 공식문서를 참조하시면 될 것 같습니다.

 

StompHeaderAccessor 는 SessionDisconnectEvent 로 얻은 값으로 join 에서 set된 header 값에 접근하기 위해 사용되는 녀석인 것 같습니다.

이 부분에 대한것은 아직 자세하게는 파악하지 못했으며, 좀더 알아봐야 할 것 같습니다.

좀 중요하게 언급해야할 부분을 찾아낸다면 이후 추가적으로 글을 작성해보도록 하겠습니다.

 

4. 흐름 요약

전체적인 흐름을 간단하게 요약하자면,

 

1. registerStompEndpoints 메서드에서 설정한 addEndpoint("/ws") 의 엔드포인트 패스로 http 프로토콜을 통해 통신, 웹소켓 통신을 구성한다.

 

2. enableSimpleBroker("/sub") 로 설정한 "/sub" 패스 뒤의 특정 주소를 구독(subscribe)한다.

 

3. setApplicationDestinationPrefixes("/pub") 로 설정한 "/pub" 패스 뒤의 특정 주소와 알맞는 body값을 전달해 controller의 메서드를 호출한다.

 

4. 해당 메서드가 작성된 로직을 작업 후 SimpMessagingTemplate 의 convertAndSend 메서드를 통해 특정 주소로 dto를 담아 보낸다.

 

5. 해당 특정 주소를 구독하고 있는 클라이언트들만 해당 dto를 받고, 작성된 로직을 통해 화면에 노출된다.

 

정도로 파악하고 있습니다.

 

5. 겪은 오류

 

프론트 서버로 테스트를 하는 중 2번의 cors 에러를 만났습니다.

첫번째는 허용하는 오리진이 와일드카드(*) 인것이 문제인 것 같아서 특정 오리진을 입력했고,

곧바로 아래의 에러를 만나게 되었습니다.

 

두개의 에러를 해석해보면, 웹소켓을 연결하기위한 엔드포인트 "/ws" 에 접근하기 위해서는 크리덴셜이 필요하고,

그 크리덴셜은 와일드카드(*) 오리진을 허용하면 문제가 생기는 듯 했습니다.

 

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        config.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:3001", "http://localhost:8080"));
        config.setAllowedMethods(Arrays.asList("*"));
        config.setAllowedHeaders(Arrays.asList("*"));
        config.setAllowCredentials(true);

        config.addExposedHeader("Authorization");
        config.addExposedHeader("Refresh");
        config.addExposedHeader("Set-Cookie");

        UrlBasedCorsConfigurationSource source =
                new UrlBasedCorsConfigurationSource();

        source.registerCorsConfiguration("/**", config);
        return source;
    }

일단 프론트는 로컬환경에서 테스트 할것이기에 localhost의 리액트의 기본포트 3000번과, 프론트팀원의 요청으로 3001번까지 열어주었습니다.

이후 setAllowCredentials 값을 true로 주어서 cors 문제를 해결하였습니다.