본문 바로가기
IT이야기/PROJECT

[React + SpringBoot] 1대1 랜덤채팅 프로젝트 - Redis를 이용한 매칭 로직

by JI_NOH 2024. 3. 11.

일대일 랜덤채팅 프로젝트

1. 환경설정 및 구성

2. 화면기능 및 설계

3. Spring + 웹소켓 + STOMP 설정

4. Spring + Redis 사용

5. Spring + Redis 매칭 로직 (현재)


 

 

목적

- 로스트 아크 게임 유저들을 위한 깐부 찾기 랜덤채팅 사이트

구성

- 프론트 : React, Shadcn, Javascript, Socket, STOMP
- 백 : SpringBoot, Java, MySQL, Redis, WebSocket, STOMP


 

 

 

지난 포스팅은 Spring Boot + Redis간의 사용법에 대해서 간단히 알아보았다.

다시 말하지만 Sorted-Set을 이용했다. 먼저 매칭한 순서대로 자료를 찾아야 하기 때문이다.

 

Redis를 연동해서 쓰는 것이 처음이다 보니 이렇게 하는게 맞나? 싶을 정도로 좀 지저분한 매칭로직이라

나름의 구구절절한 설명을 함께하기 위해 따로 포스팅을 했다.

 

 

 

목차

     

     

    1. Redis Sorted-Set

    1-1. Sorted-Set이란

    매칭 로직은 이전에도 간단히 설명했지만 설명하기에 앞서 Sorted-Set 데이터형이라는점과 그에 따라 Key-Value의 값이 중요하다.

    A Redis sorted set is a collection of unique strings (members) 
    ordered by an associated score. 
    When more than one string has the same score, 
    the strings are ordered lexicographically. 
    
    출처 : https://redis.io/docs/data-types/sorted-sets/
     

    Redis공홈에 적힌 설명으로만 봐도

    SortedSet은 동일 key에 유니크한 문자열(members)의 콜렉션으로 가중치에 의해 정렬된다.

    가중치가 같아면 문자열 기준으로 정렬된다.

     

    List도 있고 Set도 있고 뭐 많지만 굳이 Sorted-Set으로 한 이유는

    첫째로, 매칭에는 '우선순위'가 중요하기 때문이고

    둘째로, 아이템의 추가, 삭제, 조회에 O(1)의 시간 복잡도가 들기 때문이다.

     

     

    1-2. Sorted-Set의 구조

    다시 본론으로 돌아와서 나의 Sorted-Set의 Key-Value값에 대해 말해보겠다.

    위 사진처럼 여러 옵션이 존재하고 이 중에서 N개를 선택했을 때 동일한 옵션을 선택한 사람을 매칭시키는 것이 첫째 핵심이다. 결국 조회를 해야하는 주체가 '옵션'이 된다.

    KEY

    Key는 "선택 옵션 개수:선택한 옵션배열" 즉 "3:1,3,5"의 형태를 가진다.

    각 옵션은 고유 tag값을 가지고 있다고 보면 된다. 굳이 선택 옵션 개수가 들어가는 이유는 듣다보면 알 수 있다. 물론 없어도 지장은 없는데 Sorted-Set의 검색 방식때문에 추가하게 되었다.

    VALUE(Members)

    Value는 "등록ID:RoomKey"즉 "abcdef:1111-2222-3333"의 형태를 가진다.

    이런 값을 가지게 된 것도 매칭 로직을 듣다보면 알겠지만

    간단히 얘기해서 매칭이 되고 어느 방에 들어갈 것인지, 그리고 매칭된 사람에게 너 매칭됐다고 알려줘야 하기 때문에 등록ID와 RoomKey가 다 필요한 상황이기 때문에 두가지로 가지게 되었다.

     

    참고로 Key,Value는 우리가 Spring에서 RedisTemplate<String, String>으로 선언했기 때문에 String으로 아무렇게나 구성해도 상관없다. 나는 Key에도 Value에도 두 종류의 값이 들어가서 콜론으로 구분했을 뿐이다.

     

     

     

     

    2. 매칭 로직

    2-1. Service 계층 로직

    #1. 매칭 시작 시 DB(Redis)에서 우선순위 높은 순대로 동일 매칭 옵션을 확인한다.

    #2. 동일 매칭 키 옵션이 있다면 해당 DB에 등록되어있는 사람의 roomKey기반으로 방 입장 그리고 등록되어 있는 사람을 대기열에서 삭제

    이 때 roomKey는 DB에 등록된 사람의 value에서 가져온다.

    매칭이 된 사람은 roomKey기반의 chatRoom 엔티티에 정보를 업데이트 해준다.

    #3. 동일한 매칭 키 옵션이 없다면 전체 옵션개수 중에서 일부만 매칭되도 허용인지 확인 후 n개라도 매칭되는지 확인을 한다.

    ex) 옵션은 3개를 선택하였으나 2개만 일치해도 괜찮다고 설정한 경우

    #4. 위 작업을 거쳐도 일치하는 리스트가 없다면 본인을 DB에 등록한다.

        // 매칭을 수행하는 메소드
        @Transactional
        public String preferMatching(MatchReq req) {
            int positionIdx = req.getPrefer().indexOf("-");
    
            // 매칭 시, prefer 중 포지션 부분은 서로 반대되는 것 끼리 매칭시켜줘야 함
            // position은 둘 중 하나임.
            String position = req.getPrefer().substring(positionIdx + 1).equals("101") ? "100" :
                                    req.getPrefer().substring(positionIdx + 1).equals("111")?"111":"101";
            String prefer = Integer.toString(req.getOptionCount())+":"+ req.getPrefer().substring(0, positionIdx) + "-" + position;
            int preferCount = req.getPrefer().split(",").length;
    
            #1.
            Set<String> user = redisTemplate.opsForZSet().range(prefer, 0, 0);
            
            #2.
            // 매칭 prefer과 매칭되는 사용자가 있다면 방 입장 및 redis 대기열에서 삭제
            if(user !=null && user.size()> 0){
                String matchUser = user.iterator().next();
                // ':'를 기준으로 분리하여 value 가져오기
                String[] parts = matchUser.split(":");
                String uuId = "";
                String roomKey = "";
                if (parts.length >= 2) {
                    uuId = parts[0];
                    roomKey = parts[1];
                }
                enterRoomInfo(req, uuId, roomKey);
                redisTemplate.opsForZSet().remove(prefer, uuId + ":" + roomKey);
    
                return roomKey;
            }
            #3.
            else if(user.size()== 0){
                // 옵션 전체 일치 아닐 시 옵션개수 기준으로 비교
                ScanOptions scanOptions = ScanOptions.scanOptions().match(req.getOptionCount()+":*").build();
                Cursor<byte[]> keys = redisTemplate.getConnectionFactory().getConnection().scan(scanOptions);
    
                if (keys.hasNext()) {
                    return checkOptions2(keys, req);
                }
            }
            return "";
        }
     

    #3의 경우는 key값이 3:11,12,15 -> 2:11,12,15 로 변환되었을 경우를 고려하여

    3개 옵션 전부 꼭 매칭이 되어야 하는 사람의 경우

    ScanOptions scanOptions = 
        ScanOptions.scanOptions().match(req.getOptionCount()+":*").build();
     

    위 조회를 이용하여 "3:" 으로 시작하는 key를 전부 조회하겠다. 는 뜻이며

    만약 req.getOptionCount()가 2라면 "2:"로 시작하는 key를 전부 조회하겠다. 가 된다.

     

    * 사실 초기 구조에는 옵션 수량에 대한 정보까지 key에 담을 생각이 없었으나 조회 속도가 빠른 장점인 Sorted-Set을 이용하며 필요한 정보만큼만 조회하기 위해서는 옵션 수량이 필요하다고 판단되어 나중에 추가되었다.

        #3. 이어서
        private String checkOptions2(Cursor<byte[]> keys, MatchReq req) {
    
            while (keys.hasNext()) {
                String key = new String(keys.next());
    
                // 순차적으로 해당 key를 가진 user의 value도 가져오기 위함
                Set<String> user = redisTemplate.opsForZSet().reverseRange(key, 0, 0);
                String matchUser = user.iterator().next();
                // ':'를 기준으로 분리하여 value 가져오기
                String[] parts = matchUser.split(":");
                String cmpUuId = "";
                String cmpRoomKey = "";
                if (parts.length >= 2) {
                    cmpUuId = parts[0];
                    cmpRoomKey = parts[1];
                }
    
                // 본인이라면 패쓰
                if (cmpUuId.equals(req.getUuId())) {
                    continue;
                }
                // 타인이라면 선호 비교
                else {
                    int cmpPositionIdx = key.indexOf("-");
                    String[] cmpPrefers = key.substring(key.indexOf(":") + 1, cmpPositionIdx).split(",");
                    int myPositionIdx = req.getPrefer().indexOf("-");
                    String[] myPrefers = req.getPrefer().substring(0, myPositionIdx).split(",");
    
                    List<String> tmpList = new ArrayList<>(Arrays.asList(cmpPrefers));
    
                    int matchCount = 0;
                    for (String p : myPrefers) {
                        matchCount = tmpList.contains(p) ? matchCount + 1 : matchCount;
                    }
    
                    if (matchCount >= req.getOptionCount()) {
                        enterRoomInfo(req, cmpUuId, cmpRoomKey);
                        redisTemplate.opsForZSet().remove(key, matchUser);
                        redisTemplate.opsForZSet().remove(Integer.toString(req.getOptionCount())+":"+ req.getPrefer(), req.getUuId()+ ":" + req.getRoomKey());
                        return cmpRoomKey;
    
                    }
                }
            }
            return "";
        }
     

    위는 key가 "2:11,12,15"인 것과 "2:11,13,15"인 것과 비교할 때 사용되는 로직이다.

    서로의 prefer는 11,12,15 / 11,13,15 이다. 11, 15 두개가 일치하면 매칭시켜주기위한 로직이다.

     

    매칭이 되면 앞선 것과 마찬가지로 해당 DB에 등록되어있는 사람의 roomKey기반으로 방 입장 그리고 등록되어 있는 사람을 대기열에서 삭제한다.

     

     

     

     

    3. 매칭 Redis 등록과 삭제 시점

    3-1. 해당 로직의 주의점

    매칭을 실시했을 때 매칭되는 사람이 없다면 Redis에 본인의 정보를 저장하게 된다.

    이 때 정상 매칭이 되는 순간에 대기열은 정상적으로 remove되겠으나

    페이지 새로고침, 인터넷 종료 등의 여러 사유로 정상적 매칭취소가 이루어지지 않으면 Redis에서 삭제되지 않는 문제가 발생한다.

     

    앞선 포스팅에서도 말했다싶이 매칭은 WebSocket + STOMP + Redis 의 합작으로 이루어져 있다.

    1. 매칭 시작
    2. 매칭이 안되면 WebSocket연결과 동시에 Redis에 등록
    3. 대기하고 있다가 매칭이 되면 STOMP subscribe를 통해 인지 후 WebSocket연결 끊기 & Redis 삭제
    4. 매칭 취소 시 마찬가지로 WebSocket연결 끊기 & Redis 삭제
     

    이렇게 사용하기 위한 구조라고 보면 되겠다.

     

    어쨌든 정상적 매칭취소가 이루어지지 않은 경우 WebSocket 연결 끊김 감지를 이용하여 작업을 처리해 줄 필요가 있었는데 SessionDisconnectEvent를 이용하기로 하였으며

    거기에 연결끊김 감지를 SessionDisconnectEvent로 받아도 '어떤 WebSocket연결'을 끊을 지 판단할 수 없기에 추가적으로 ChannelInterceptor preSend를 이용하기로 하였다.

     

     

     

    3-2. SessionDisconnectEvent, ChannelInterceptor

    다시 정리해보자.

    SessionDisconnectEvent : WebSocket 연결 끊김 감지
    ChannelInterceptor : 어떤 WebSocket연결을 끊을 것인지 판단하기 위함

     

    어떤 의미냐면 현재 나의 경우 WebSocket연결을 두 가지 엔드포인트를 이용하여 진행하고 있다.

        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            //Client에서 websocket 연결할 때 사용할 API 경로를 설정 - 채팅용
            registry.addEndpoint("/chat")
                    .setAllowedOriginPatterns("*")
                    .withSockJS();
            //Client에서 websocket 연결할 때 사용할 API 경로를 설정 - 매칭용
            registry.addEndpoint("/match")
                    .setAllowedOriginPatterns("*")
                    .withSockJS();
        }
     

    그리고 서버 하나에 여러 WebSocket연결이 들어오게 될 텐데

    "/match" 관련 엔드포인트로 들어오는 요청들 중에 본인의 고유 내용(Redis 에 등록되어 사용 될 key-value)를 같이 싣어 보내주면 그걸 이용할 수 있지 않을까? 에서 비롯된 방법이다.

     

     

    3-2-1. 리액트 websocket 연결

    아래는 리액트에서 socket연결 시 header에 정보를 담는 과정이다.

      const openSocket=async (preferParam, uuId, roomKey)=>{
    
        const socketURL = 'http://localhost:8080/match';
        const socket = new SockJS(socketURL);
        const stomp = Stomp.over(socket);
    
        const headers = {
          endpoint: 'match',
          key: `${preferParam}`,
          value: `${uuId}:${roomKey}`,
          Authorization: getCookie('accessToken')
        };
    
        await checkAccessToken(); // 액세스 토큰 확인 및 갱신
    
        stomp.connect(headers, ()=>{
            // connect 후 subscribe 등의 액션
    
        })
      }
     

    /match 엔드포인트로 소켓 연결을 할거고

    header에다가 해당 endpoint정보와 Redis에 등록해서 사용하는 key-value를 가공한 채로 넣겠다.

    그리고 stomp.connect(headers,()=>{ // ~~~~ }); 할 때 해당 헤더를 싣어 보내겠다.

    이런 내용이다.

     

     

    3-2-2. 스프링 websocket 연결 관련 ChannelInterceptor

    이제 스프링에서 해당 소켓 메세지를 전달 받게 될 것이다.

    바로 ChannelInterceptor 인터페이스가 제공하는 preSend() 라는 함수를 통해서.

    출처 :&nbsp; https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/messaging/support/ChannelInterceptor.html
     

    메세지가 채널로 전송되기 전에 필요하다면 가공해서 처리할 수 있게 하는 함수다.

    @Configuration
    @RequiredArgsConstructor
    public class StompHandler implements ChannelInterceptor {
    
        private final Map<String, String> sessionInfo = new ConcurrentHashMap<>();
        private final RedisService redisService;
    
        @Override
        public Message<?> preSend(Message<?> message, MessageChannel channel) {
            StompHeaderAccessor accessor =
                    StompHeaderAccessor.wrap(message);
    
            // CONNECT 경우에만 처리
            if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                String sessionId = accessor.getSessionId();
    
                if (sessionId != null && Objects.equals(accessor.getFirstNativeHeader("endpoint"), "match")) {
                    // 연결 요청에서 전달된 파라미터 읽기
                    String key = accessor.getFirstNativeHeader("key");
                    String value = accessor.getFirstNativeHeader("value");
    
                    sessionInfo.put(sessionId, key + "/" + value);
    
                }
            }
            return message;
        }
    }
     

    리액트에서 sockJS() 연결 시, header에 정보를 담았던 것이 기억날 것이다.

    A MessageHeaderAccessor to use when creating a Message from a decoded STOMP frame, 
    or when encoding a Message to a STOMP frame.
    
    출처 : https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/messaging/simp/stomp/StompHeaderAccessor.html
     

    STOMP 프레임을 디코딩하여 Message객체로 만들거나, Message를 STOMP프레임으로 인코딩 할 때 사용된다는 MessageHeaderAccessor 이용하여 STOMP헤더를 읽어올 것이다.

     

    그 중에서도 StompCoomand.Connect(연결 시) 에 해당 작업을 수행할 것이며

    고유 sessionId를 가지고 있을 때 Header에 담아 보냈던 key, value와 sessionId를 ConcurrentHashMap으로 받아서 저장해둔다.

    그리고 이렇게 저장한 sessionInfo는 onDisconnectEvent에서 사용할 것이다.

     

     

    3-2-3. 스프링 webSocket 연결끊김 감지 onDisconnectEvent

    onDisconnectEvent를 @EventLisntener와 함께 선언해둔 후 테스트를 해보면 알겠지만

    페이지 새로고침이나, 다른 페이지 이동 등을 할 때마다 해당 메소드를 타게 되는 것을 확인할 수 있다.

        @EventListener
        public void onDisconnectEvent(SessionDisconnectEvent event) {
            StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
            String sessionId = accessor.getSessionId();
    
            if(sessionId!=null){
                // 세션에서 key와 value 가져오기
                String info = sessionInfo.get(sessionId);
    
                if (info != null) {
                    String normalDisconnect = accessor.getDestination();
                    assert normalDisconnect != null;
                    if(!normalDisconnect.equals("/disconnect")){
                        String[] parts = info.split("/");
                        String key = parts[0];
                        int optionsCount = Integer.valueOf(key.split(":")[0]);
                        String prefer = key.split(":")[1];
                        String value = parts[1];
                        String uuId = value.split(":")[0];
                        String roomKey = value.split(":")[1];
    
                        MatchReq req = MatchReq.builder()
                                .uuId(uuId)
                                .prefer(prefer)
                                .optionCount(optionsCount)
                                .roomKey(roomKey)
                                .build();
                        redisService.cancelMatch(req);
                    }
                }
            }
    
     

    이 때 SessionDisconnect 객체의 Message를 받아오면 preSend의 Message와 똑같은 객체로 받아와 파싱할 수 있다.

    그러면 webSocket연결이 끊길 때 해당 socket정보의 sessionId를 확인을 하고

    해당sessionId의 Info에 저장 된 정보를 기반으로 redisService에 구현해 둔 cancelMatch를 실행시킬 수 있다.

     

     

     

    처음에 아무생각없이 매칭 시스템만 구현했다가 여러번 테스트를 반복하면서

    자꾸 Redis에 불필요한 데이터들이 남아서 어떻게 처리할지 해결하느라 다음 포스팅이 생각보다 미뤄졌다.

     

    아 참고로 channelInterceptor를 상속받는 stompHandler에 대한 호출은

    앞서 작성했었던 WebSocketConfig에서

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

    인터셉터 등록을 해주면 된다.