minlog
article thumbnail

 

프로젝트 시작 전, 웹소켓과 프론트를 연결하는 기술에 대해 미리 알아보고 습득했는데, 강사님께서 도움이 될만한 강의를 추천해주셨다 .  노마더코드 줌 구현으로 채팅 기능이 포함이 되어 있다고 하여 프론트 맏으신 분과 함께 내용을 빠르게 들어 보았다.  일단 작업 환경은 조금 달랐는데 프론트와 백앤드 모두 node.js에서 이루어지고 있었다.  강의 속에서 여러 기술들을 소개하고 다루는 방법에 대해 알려주었다. 각 기술들의 장점과 단점에 대해 정리하고 더 맞는 방법을 찾아보다가 프론트에서는 SockJs를 사용하기로 하였다. 

 

1. 채팅 사용기술 정리 - 프론트

1-1. WS

강의에서는 WS와 Socket.io 를 다루고 있었다. 먼저 WS는 websocket 이 기본 브라우저에서 제공하는 기능으로 따로 설치가 없어 사용이 가볍다는 장점이 있다. 하지만 기본적인 기능만 제공을하여 각 기능들에 대한 구현을 개발자가 직접해주어야한다는 단점이 있다. 그리고 서버로 전달할 시 객체를 string으로 변환하여 전송해주어야한다. 가장 중요한 기능 Websocket이 오류가 있을 경우 채팅 기능이 작동하지 않는다는 문제점도 있다.

 

1-1 Socket.io

Socket.io는 주로 JavaScript를 기반으로 사용되는 라이브러리이다.  Websocket을 사용하지 않는 실시간 양방향 통신 방법이다.

장점으로는 다양한 기능들을 제공해준다. WS 에서는 개발자가 직접 코드를 만들어야하는 기능들을 제공해주어 손쉽게 사용할 수 있다.  채팅 사용시에도 사용자의 연결이 문제로 인해 끈길경우 자동 재연결해준다는 장점이 있다. 단점으로는 ws보다 좀더 무겁고 주로 node.js 환경에서 사용된다.  websocket을 사용하지 않기 때문에 클라이언트와  서버 모두 socket.io를 설치해주어야한다. 

이 둘을 비교했을때는 기능적으로 Socket.io가 더 좋다는 생각이 들었는데 해당 프레임워크를 사용하기엔,  레퍼런스가 적은 것 같았다.  만약 사용하려면 , 현재 프로젝트 Spring가 연결하고 사용하는 방법을 공부해야한다.  그래서 더 찾아본 것이 SockJS 이다. Spring 에서 일반적으로 사용한다고 한다.

 

🚩 WS 와 Socket.io 테스트 작업 :  https://github.com/min-log/zoom

 

GitHub - min-log/zoom: Zoom Clone using Node.js, WebRTC and WebSocket

Zoom Clone using Node.js, WebRTC and WebSocket. Contribute to min-log/zoom development by creating an account on GitHub.

github.com

 

1-3. SockJs ⭐

SockJs 는 Servlet 스택에서 server , client 용도로 SockJs를 모두 지원한다. 웹소켓은 HTML5 이후에 나왔기 때문에 HTML5 이전의 기술로 구현된 서비스에서는 웹소켓 기술을 사용할 수 없다는 단점이 있는데 SockJs가 웹소켓을 지원하지 않는 브라우저에서  정상적으로 동작되도록 지원해준다. 먼저 WebSocket 연결을 시도하고 실패할 경우 SSE, Long-Polling과 같은 HTTP 기반의 다른 기술로 전환하여 다시 연결을 시도한다.

 

사용방법

1) 설치 

- 서버 설치  - build.gradle

implementation group: 'org.springframework.boot', name: 'spring-boot-starter-websocket', version: '3.1.2'
implementation 'org.webjars:sockjs-client:1.1.2'

 

- 클라이언트 설치 - HTML 

<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>

 

1) 클라이언트 생성

let socket = new SockJS(`/stomp/chat`)
let stompClient = Stomp.over(socket)

 

2) 서버 연결

stompClient.connect({}, function () {
	...
});

 

- 구독

stompClient.subscribe(`/sub/chat-room/get/${chatRoomId}`, (message) => {
   ...
})

- 전송

stompClient.send(`/pub/chat-room/send`, {}, JSON.stringify({
	...
});

 

- 웹소켓과 커넥션 끈기

stompClient.disconnect()

 

- 웹소켓 사용시 콘솔 메시지 제거

stompClient.debug = null;

 


 

2. 채팅 사용기술 정리 - 백앤드

2-1. WebSocket

웹소켓 프로토콜

채팅 기능에 필요한 웹소켓은 실시간으로 양방향 통신이 가능한 프로토콜이다. 실시간으로 이루어지는 채팅, 게임 등에서 사용된다. HTTP와는 다른 TCP 프로토콜이지만 HTTP에서 동작 가능하며, 80,443포트를 사용하여 방화벽 규칙을 재 사용할 수 있도록 되어 있다. 하지만 메시지 내용의 규정을 정의하지 않는 low level 전송 프로토콜로 클라이언트와 서버가 미리 정의한 메시지 규약없이는 메시지가 라우팅 되거나 처리되지 못한다.

 

사용방법

1) 설치 

스프링부트에서 웹소켓 서버 환경을  구성하기 위해서는 아래 라이브러리를 설치 해주면된다.

 - build.gradle

implementation group: 'org.springframework.boot', name: 'spring-boot-starter-websocket', version: '3.1.2'

 

🚩  웹소켓 만 사용한 대화 창 작업 : https://github.com/min-log/webChatting

 

GitHub - min-log/webChatting: 소켓 웹 채팅 기능 구현

소켓 웹 채팅 기능 구현. Contribute to min-log/webChatting development by creating an account on GitHub.

github.com

 

우리는 STOMP  하위프로토콜도 함께 사용하기로 하였으므로 Config 설정에 대해서는 아래에서 설명하려고한다.

 

 

2-2. STOMP ( Simple Text Oriented Messaging Protocol )

STOMP는 웹소켓과 함께 사용할 수 있는 하위 프로토콜이다. 메시지 브로커로 메시지 전송을 효율적으로 하기 위해 사용된다.

publisher (발행), subscriber(구독) 방식을 사용하여 메시지의 발행자와 구독자가 존재하고 메시지를 보내는 사람과 받는 사람이 구분되어 있다. 메시지 브로커는 발행자가 보낸 메시지를 구독자에게 전달해주는 역할을 한다.  STOMP는 HTTP와 비슷하게 frame 기반 프로토콜 command, header, body로 이루어져 있다.

 

사용방법

1) 설치 

 - build.gradle

implementation 'org.webjars:stomp-websocket:2.3.3-1'

 

 

2) STOMP 설정 

- STOMP 기본 설정  : StompWebSocketConfig.java

WebSocketMessageBrokerConfigurer 를 상속 받아  메시지 처리를 구성합니다.

※ WebSocketMessageBrokerConfigurer : WebSocket 클라이언트에서 간단한 메시징 프로토콜(예: STOMP)을 사용하여 메시지 처리를 구성하기 위한 방법을 정의

package llustmarket.artmarket.config;

...

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

    private final HttpSession httpSession;

    /*어플리케이션 내부에서 사용할 path 지정*/
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/stomp/chat")
                .setAllowedOriginPatterns("http://localhost:8070")
                .addInterceptors(new WebSocketConnectHandler(httpSession))
                .withSockJS()
                .setClientLibraryUrl("https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js");
    }

     /*어플리케이션 내부에서 사용할 path 지정*/
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 메시지 받기 : sub 1:1 · topic 1:n
        registry.enableSimpleBroker("/sub","/topic")
                .setTaskScheduler(taskScheduler())
                .setHeartbeatValue(new long[] {3000L, 3000L});
        // 메시지 전송
        registry.setApplicationDestinationPrefixes("/pub");

    }

    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.initialize();
        return taskScheduler;
    }


    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
        registration.setMessageSizeLimit(20000000); //20MB 
    }



}

 

 

registerStompEndpoints  

  registerStompEndpoints  : 각각을 특정 URL에 매핑하고 (선택적으로) SockJS 폴백 옵션을 활성화 및 구성하는 STOMP 엔드포인트를 등록합니다.

 

  • addEndpoint( )  =  WebSocket 또는 SockJS Client가 웹소켓 핸드셰이크 커넥션을 생성할 경로이다.
  • setAllowedOriginPatterns( ) =  허용되는 Origin(도메인)  클라이언트를 적어주는 경로이다.
  • addInterceptors( ) =  'WebSocketConnectHandler.java'는 HandshakeInterceptor 객체를 상속 받은 클래스를 연결해주었다. (* 해당 내용은 아래에서 자세하게 다루려고한다.)
  • withSockJS( ) = 웹 소켓을 지원하지 않는 브라우저라면, sockJS를 사용하도록 설정한다.
  • setClientLibraryUrl( ) = sock-client.js 파일의 위치를 알려준다.  외부 cdn이라면 해당 경로를 적으면 된다.

 

setAllowedOriginPatterns 의 경우, 기존에는 모두 가능한 * 로 설정해놨었는데 프론트와 연결하여 테스트 해보니 CORS 이슈가 발생하여 수정하게 되었다.  특정 Origin 을 지정해야 한다. 

 

CORS 란 ? 
클라이언트와 서버의 Origin이 다를때 발행하는 이슈 이다.  
동일 출처 정책으로 말 그대로 같은 출처에서만 리소스를 공유할 수 있다는 규칙 

Origin ( Protocol +  Host + Path + Query String + Fragment )
Protocol : http://
Host : localhost:8080
Path : /chat-room
Query String  : ?key=value& key=value
Fragment : #chat

 

🌩️ CORS 문제 X-FRAME-OPTIONS 트러블슈팅 해결 상세 내용 https://jimin-log.tistory.com/175

 

[ Project · ArtMarket ] X-Frame-Options 트러블 슈팅

채팅 기능 구현을 완료하고 프론트 담당자 분이 프론트와 연결하는 작업을 진행하다가 ` X-Frame-Options ` 오류를 발견해서 이슈 해결 방법을 찾아보게 되었다. 01 . X-Frame-Options 오류 ` X-Frame-Options `

jimin-log.tistory.com

 

configureMessageBroker

  configureMessageBroker:   메시지 브로커 옵션을 구성합니다.

  • enableSimpleBroker("  ","  ") =  메세지를 받을 때 경로를 설정해주는 함수이다. sub 는 1:1,  topic 은 1:N  의 연결을 해준다. 
  • setApplicationDestinationPrefixes("/pub"); = 메시지를 보낼때 경로를 설정해주는 함수이다. 
  • setTaskScheduler(taskScheduler()) =  스케줄링을 사용할 수 있다.
  • setHeartbeatValue(new long[] {3000L, 3000L}); =  30000ms 마다 지속적으로 연결확인하고 스케줄링을 해준다.

 

configureWebSocketTransport

  configureWebSocketTransport :   WebSocket 클라이언트와 주고받는 메시지 처리와 관련된 옵션을 구성합니다..

작업중인 채팅 기능에서는 메시지 내용외에 이미지 및 파일들을 주고 받기 때문에 한번에 전달하는 사이즈가 클 수 있는데, 기본 데이터를 사이즈 이상 보낼 경우, 이슈가 생겨서 추가로 작업하게 되었다. 

  • registration.setMessageSizeLimit(  ) = 최대 사이즈를 지정해줄 수 있다. 프로젝트에서 20MB까지의 파일들을 받기로 했어서. 동일하게 지정해주었다.  ( in bytes )

 

 

- STOMP 커넥션 연결 확인  : WebSocketConnectHandler.java

채팅방으로 사용자가 접속 시 HttpSession 에 사용자 id와 룸 id 정보가 있는 DTO가 저장 된다.

커넥션 시 해당 정보를 가져와 사용자가 올바른 사용자인지 체크하고 중복을 방지해준다.

해당 기능을 추가한 것은 커넥션을 종료했을때,  해당 사용자의 마지막 접속 날짜와 시간을 기록한다.

그리고 채팅룸 안에 포함되는 다른 사용자가 메시지 전송시, 룸에 포함되는 상대방 사용자의 접속 유무를 체크하여 알람과 메일을 전송하기 위해 추가되었다. 

원래는 메시지를 전송하는 메시지 컨트롤러에서 HttpSession을 가져와 사용할 수 있는지 확인해봤는데 불가능하여, 위와 같이 코딩을 했다. 그리고 이렇게 Map으로 저장된 세션은 컨트롤러에서 SimpMessageHeaderAccessor를 통해 받아와 사용할 수 있다. 

 

package llustmarket.artmarket.config;
..

@Log4j2
@RequiredArgsConstructor
@Component
public class WebSocketConnectHandler implements HandshakeInterceptor {

    private final HttpSession httpSession;
    private final List<ChatSessionDTO> chatSessionList = new ArrayList<>();

    @Transactional
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        log.info("# 연결 확인");

        if (request instanceof ServletServerHttpRequest) {
            // HttpSession에서 사용자 정보 가져오기
            ChatSessionDTO userDTO = (ChatSessionDTO) httpSession.getAttribute("chatSession");
            // log.info("연결된 사용자 : {}",userDTO);
            // WebSocket 세션에 사용자 정보 저장
            if(userDTO != null){
                if(null ==  attributes.get("chatSessionList")){
                    log.info("첫진입");
                    // 첫 진입
                    chatSessionList.add(userDTO);
                    attributes.put("chatSessionList", chatSessionList);
                    attributes.put("chatSessionUser",userDTO);
                    return true;
                } else {
                    // log.info("접속한 사용자가 있음.");
                    // 복제 하여 사용 -- 동시 접속시 오류 줄일 수 잇다 .
                    List<ChatSessionDTO> copyOfChatSessionList;
                    List<ChatSessionDTO> chatSessions = (List<ChatSessionDTO>)attributes.get("chatSessionList");
                    synchronized (chatSessionList) {
                        copyOfChatSessionList = new ArrayList<>(chatSessions);
                    }
                    for(ChatSessionDTO user : copyOfChatSessionList){
                        if(user.getMemberId() != userDTO.getMemberId() && user.getChatRoomID() != userDTO.getChatRoomID()){
                            //동일한 회원, 동일한 방이 아닐 경우
                            copyOfChatSessionList.add(userDTO);
                            attributes.put("chatSessionList",copyOfChatSessionList);
                        }
                    }
                    attributes.put("chatSessionUser",userDTO);
                    return true;
                }
            }
            // 사용자가 null 값으로 오면
            return false;
        }
        return false;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
        //log.info("연결 후 실행");
    }



}

 

 

 

- STOMP 커넥션 연결 종료 확인  : WebSocketDisconnectHandler.java

package llustmarket.artmarket.config;

...

@Log4j2
@RequiredArgsConstructor
@Component
public class WebSocketDisconnectHandler {
    private final ChatService chatService;

    @EventListener
    public void handleDisconnectEvent(SessionDisconnectEvent event) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
        // WebSocket 연결 해제 시 세션 정보 가져오기
        Map<String, Object> sessionAttributes = accessor.getSessionAttributes();

        // 연결 종료된 사용자
        ChatSessionDTO chatSessionUser = (ChatSessionDTO)sessionAttributes.get("chatSessionUser");

        // 연결된 사용자 리스트 수정 및 연결 종료 된 사용자 세션 제거
        // DB 상태 변경 - 마지막 종료 시간
        List<ChatSessionDTO>  chatSessionList = (List<ChatSessionDTO>) sessionAttributes.get("chatSessionList");
        if (chatSessionList != null) {
            // WebSocket 연결을 해제시 처리되어야할 내용
            // 사용자 리스트에서 제거, 연결된 session 제거
            log.info("# 연결 종료");
            chatService.updateChatLastDate(chatSessionUser.getChatRoomID(), chatSessionUser.getMemberId());
            chatSessionList.remove(chatSessionUser);
            sessionAttributes.put("chatSessionList",chatSessionList);
            sessionAttributes.remove("chatSessionUser");
        } else {
            log.info("연결 종료된 사용자 정보 없음");
        }
    }
}
profile

minlog

@jimin-log

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