[Spring Boot] WebSocket을 활용한 실시간 쪽지 기능 구현(1)

한동근·2023년 11월 26일
7

SpringBoot

목록 보기
3/12
post-thumbnail

현재 진행중인 캡스톤 프로젝트에서 웹소켓을 활용해서 실시간 쪽지 기능을 구현하고자 하였다. 실시간 채팅과 쪽지 둘 중 고민하였으나 프로젝트의 컨셉에 실시간 쪽지가 맞다고 생각하였다.
이제 웹소켓을 사용해서 쪽지 기능을 구현하면서 고민했던 것들을 기록해보겠다.

웹소켓이란 무엇인가?

웹소켓(WebSocket)은 인터넷에서 실시간으로 양방향 통신을 가능하게 하는 기술이다. 일반적인 HTTP 프로토콜은 클라이언트에서 서버로 요청을 보내고, 서버가 그 요청에 응답하는 '요청-응답' 방식이다. 하지만 웹소켓은 서버와 클라이언트 간에 지속적인 연결이 유지되어 양방향으로 데이터를 주고받을 수 있다. 지속적인 연결이 포인트다.

웹소켓을 사용하면 서버 역시 클라이언트에게 실시간 정보를 푸시할 수 있으며, 채팅 애플리케이션, 멀티플레이어 게임, 실시간 통계 및 알림 등 다양한 실시간 서비스를 구현하는 데 유용하다.

웹소켓은 HTTP와 같은 표준 프로토콜을 사용하여 초기 연결을 설정하고, 연결이 된 후에는 웹소켓 프로토콜로 데이터를 전송하게 된다. 웹소켓은 웹 브라우저와 웹 서버 사이에서 높은 효율성과 낮은 지연 시간으로 실시간 통신을 가능하게 한다.

한 가지 예시를 들면 이해가 더 잘 될 것이다.
HTTP의 경우를 생각해보자. 클라이언트는 서버에게 요청을 보내고 응답을 받으면 연결이 끊어진다. 이 경우 데이터의 처리가 빈번한 경우에는 많은 비용이 들게 된다.
하지만 웹소켓을 사용할 시 한 번 연결을 하면 지속적인 연결이 유지되어 실시간 데이터 처리에 유용하다.

더 명확한 예시를 들어보자.
A가 서버로 메시지를 전송한다 -> 서버는 B에게 메시지를 전송한다 -> B는 메시지를 확인한다.

이 예시에서 HTTP 통신은 클라이언트가 요청해야 서버가 응답한다. 그래서 서버가 B에게 메시지가 도착했다고 알리고싶어도 클라이언트의 요청이 없기 때문에 일방적으로 응답을 할 수 없다. 그리고 B의 입장에서도 자신에게 메시지가 도착했는지 아닌지 알 수 없어 서버에게 메시지의 데이터를 요청할 수 없다. 그래서 웹소켓을 사용하게 되었다.

흐름

흐름에 대해서 말해보겠다.
1. 유저 A가 로그인을 하면 동시에 웹소켓에 연결을 하며 쪽지가 몇 개 있는지 확인한다. (프론트는 받은 웹소켓 메시지로 쪽지의 개수를 표시)
2. 유저 B가 A에게 쪽지를 보내면서 웹소켓 메시지로 "쪽지가 도착했습니다. 새로운 쪽지 ~개" 라는 메시지도 같이 보내준다. (프론트는 쪽지의 개수 업데이트) 이 때 실시간으로 받은 쪽지 개수가 바뀐다.
간단해보이지만 실제로 구현하는데 어려움이 많았어서 많은 생각과 고민을 했었다. 이제 구현을 시작해보자.

구현

구현을 시작해보자. 먼저 의존성 추가가 필요하다.

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

WebSocketConfig 설정

package com.example.SignServer.Config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

// WebSocket 기능을 활성화
@EnableWebSocket
// 이 클래스를 스프링 설정 클래스로 선언
@Configuration
// WebSocketConfigurer 인터페이스를 구현하여 웹소켓 설정을 커스터마이징
public class WebSocketConfig implements WebSocketConfigurer {

    // 웹소켓 메시지를 처리하는 핸들러
    WebSocketMessageHandler webSocketMessageHandler;

    // 생성자를 통해 WebSocketMessageHandler 인스턴스를 주입받음
    public WebSocketConfig(WebSocketMessageHandler webSocketMessageHandler) {
        this.webSocketMessageHandler = webSocketMessageHandler;
    }

    // 웹소켓 핸들러를 등록하는 메소드를 오버라이드
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketMessageHandler, "/test").setAllowedOrigins("*");
    }
}
  • WebSocketMessageHandler : 웹소켓 메시지를 처리하는 핸들러를 선언해주고 의존성 주입을 해준다.
  • registerWebSocketHandlers : 웹소켓 핸들러를 등록하기 위한 코드.
  • "/test"라는 엔드포인트에 대해 webSocketMessageHandler를 핸들러로 등록하고, setAllowedorigins("*")로 모든 CORS요청을 허용해준다.
  • 웹소켓을 연결할 때 ws://loaclhost:8080/ws/test 로 연결을 해야한다.

다음 구현 과정에서 문제점

이 이후가 문제였다. 실시간 채팅의 경우 자료가 많아 구현하는데 어렵지는 않았다. 하지만 실시간 쪽지 기능의 경우 자료가 없었고 그나마 있었던 자료 하나도 옛날 자료라 도움이 되지는 못했다. 그래서 실시간 채팅 자료를 참고해 거기서 내가 필요한 기능들을 바꿔서 구현하게 되었다.
처음에는 WebSocketMessageBroker를 활용해서 구현하려고 했는데 결론적으로 메시지브로커에 대한 학습이 부족해 다른 방법을 사용했다.

WebSocketMessageHandler

package com.example.SignServer.Config;

import com.example.SignServer.Entity.UserEntity;
import com.example.SignServer.Repository.MessageRepository;
import com.example.SignServer.Repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.*;

@Component
//웹소켓 메시지를 처리하는 클래스
public class WebSocketMessageHandler extends TextWebSocketHandler {
    // 사용자 id와 웹소켓 세션을 매핑하는 hashmap
    HashMap<String, WebSocketSession> sessionMap = new HashMap<>();
    private final MessageRepository messageRepository;
    private final UserRepository userRepository;

    public WebSocketMessageHandler(
            @Autowired MessageRepository messageRepository,
            UserRepository userRepository) {
        this.messageRepository = messageRepository;
        this.userRepository = userRepository;
    }

    // 웹소켓 연결이 설정된 후에 실행되는 메소드
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 웹소켓 세션으로부터 사용자 id를 찾아냄
        String userid = searchUserName(session);
        // 세션맵에 사용자 id와 웹소켓 세션을 추가
        sessionMap.put(userid,session);

        // 웹소켓 세션의 URI를 파싱하여 uid를 얻어냄
        UriComponents uriComponents =
                UriComponentsBuilder.fromUriString(Objects.requireNonNull(session.getUri()).toString()).build();
        String uid = uriComponents.getQueryParams().getFirst("uid");
        // uid를 통해 사용자를 찾음
        UserEntity user = userRepository.findByUid(uid);
        // 해당 사용자가 존재하는 경우
        if(user != null) {
            // 사용자의 ID를 얻어냄
            Long userId = user.getId();
            // 해당 사용자가 읽지 않은 메시지의 개수를 얻어냄
            Long unreadMessagesCount = messageRepository.countByReceiverIdAndReadStatus(userId, false);
            // 웹소켓 메시지로 읽지 않은 메시지의 개수를 전송
            session.sendMessage(new TextMessage("읽지 않은 쪽지의 개수: " + unreadMessagesCount));
        } else {
            // 해당 사용자가 존재하지 않는 경우, 오류 메시지를 전송
            session.sendMessage(new TextMessage("존재하지 않는 사용자입니다."));
        }
    }

    // 웹소켓 연결이 종료된 후에 실행되는 메소드
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 웹소켓 세션으로부터 사용자 이름을 찾아냄
        String userid = searchUserName(session);
        // 세션맵에서 해당 사용자를 제거
        sessionMap.remove(userid);
    }

    // 특정 사용자에게 웹소켓 메시지를 전송하는 메소드
    public void sendNotification(String username, String message) throws Exception {
        // 세션맵에서 사용자의 웹소켓 세션을 얻어냄
        WebSocketSession session = sessionMap.get(username);
        // 해당 세션이 존재하고 열려있는 경우, 메시지를 전송
        if (session != null && session.isOpen()) {
            session.sendMessage(new TextMessage(message));
        }
    }

    // 웹소켓 세션의 URI를 파싱하여 사용자 id를 찾아내는 메소드
    public String searchUserName(WebSocketSession session) {
        UriComponents uriComponents = UriComponentsBuilder.fromUriString(session.getUri().toString()).build();
        return uriComponents.getQueryParams().getFirst("uid");
    }
}

uid = 유저의 아이디이다. 처음에는 유저의 닉네임으로 매핑을 시켜주려 했다가 유저의 아이디로 변경해서. searchUserName은 searchUid라고 보면 된다.

  • HashMap을 사용해서 id와 웹소켓 세션을 매핑시켜줬다. id로 매핑을 시킨 이유는 클라이언트에서 로그인이 성공하면, 아이디값을 저장하고 그 값을 웹소켓 연결때 사용하기 위해서다.
  • afterConnectionEstablished : 웹소켓 연결이 설정되고 바로 실행되는 메소드이다.
    먼저 웹 소켓 세션에서 사용자 id를 찾는다. 웹소켓에 연결할 때 "uid" 파라미터를 붙여서 보내야 searchUserName 메소드가 작동한다. 그 후 id와 세션을 SessionMap에 넣어준다.
    서버는 파라미터에서 파싱한 uid를 통해 사용자를 찾고 읽지 않은 쪽지의 개수를 웹소켓 메시지로 전송한다.
  • afterConnectionClosed : 웹소켓 연결이 종료된 후 실행되는 메소드
    웹소켓 세션에서 사용자 id를 찾아내서 제거한다.
  • sendNotification : 쪽지를 보내고 상대방에게 쪽지가 도착했다는 웹소켓 메시지를 전송할 때 쓰인다.
  • searchUserName : 웹소켓 세션의 URI를 파싱해 사용자 id를 찾는 메소드이다. ws://localhost:8080/test?uid={uid} 로 연결을 요청해야 파라미터 값을 파싱할 수 있다.

이제 웹소켓 연결을 위한 모든 설정이 끝났다. 다음 게시글에서는 쪽지 기능을 구현해보고, 실제로 테스트하는 것까지 해보겠다.

사진 출처 : https://ko.wikipedia.org/wiki/%EC%9B%B9%EC%86%8C%EC%BC%93

잘못된 부분이 있으면 댓글 부탁드립니다!!

Spring Boot

  • version : 2.7.16
  • java : 11
  • Gradle - Groovy
  • Java
profile
와플대조교의 개발 블로그

5개의 댓글

comment-user-thumbnail
2023년 11월 27일

정말 유익했습니다

답글 달기
comment-user-thumbnail
2023년 11월 27일

흥미롭군요,,

1개의 답글
comment-user-thumbnail
2023년 11월 27일

잘보고 갑니다

답글 달기
comment-user-thumbnail
2023년 11월 29일

좋은 자료 잘 보고 갑니다^^

답글 달기