일대일 랜덤채팅 프로젝트
1. 환경설정 및 구성
2. 화면기능 및 설계
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() 라는 함수를 통해서.
메세지가 채널로 전송되기 전에 필요하다면 가공해서 처리할 수 있게 하는 함수다.
@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);
}
인터셉터 등록을 해주면 된다.
'IT이야기 > PROJECT' 카테고리의 다른 글
[PostMan, SpringBoot] 포스트맨 multipart 테스트 에러 HttpMediaTypeNotSupportedException (0) | 2024.10.25 |
---|---|
[AWS] S3 + CloundFront를 이용한 React 배포 (3) | 2024.10.21 |
[React + SpringBoot] 1대1 랜덤채팅 프로젝트 - Redis를 이용한 매칭 / Redis설정 및 사용방법 (0) | 2024.03.10 |
[React + SpringBoot] 1대1 랜덤채팅 프로젝트 - Spring&웹소켓&STOMP 설정 및 구현 (0) | 2024.03.09 |
[React + SpringBoot] 1대1 랜덤채팅 프로젝트 - 화면 및 기능 설계 (실시간 통신, 매칭) (1) | 2024.03.08 |