Skip to content

Long Polling으로 랜덤매칭

HeeJu Cho edited this page Oct 26, 2023 · 4 revisions

기능 요구사항

사용자가 채팅 버튼을 누르면, 랜덤으로 상대방이 매칭되어 채팅방이 생성되어야 합니다.

중요한 요구사항은 다음과 같습니다.

  • 실시간으로 접속되어있는 사용자끼리 1:1 매칭할 것
  • 같은 그룹의 사용자끼리 매칭할 것
  • 차단한 사용자와는 매칭하지 않을 것

구현방식 채택 과정

랜덤매칭 기능을 작업 흐름은 다음과 같습니다.

  • 사용자가 랜덤채팅 요청 시 대기열에 저장
    • Redis에 실시간으로 요청한 사용자를 저장합니다.
    • Map에 사용자의 DeferredResult를 저장합니다.
  • 대기열에서 랜덤으로 매칭 상대방 찾기
    • 매칭되면 채팅방 생성 후 채팅방 id 반환합니다.
    • 매칭 실패하면 422 코드 반환합니다.
  • 422 코드를 받으면 클라이언트에서는 사용자에게 매칭에 실패했음을 알리고, 재요청 할 것인지 아니면 나갈 것인지 묻기

1. SSE (Server Sent Events)

가장 먼저 고려한 방식은 SSE입니다.

SSE는 서버와 클라이언트가 한 번 연결을 맺고 나면, 일정시간 동안 서버에서 클라이언트로 데이터를 전송할 수 있습니다.

타임아웃 시 브라우저에서 자동으로 서버에 재연결 요청을 보내기 때문에 Long Polling이나 Polling보다 효율적입니다.

sse

저희 애플리케이션에서는 매칭 실패 시 "매칭 상대방을 찾을 수 없습니다!"라는 메세지를 띄우고 사용자에게 채팅을 재요청 할 것인지 아니면 다른 화면으로 나갈 것인지 묻습니다.

이를 위해 타임아웃 시 예외코드를 반환합니다.

실패응답을 클라이언트에서 별도로 처리하지 않으면 SSE에서는 자동으로 재연결 요청을 보내 사용자가 랜덤 매칭을 무기한으로 기다리게 됩니다.

emitter.onTimeout(() -> {
            emitter.completeWithError(new CustomException());
        });

타임아웃마다 클라이언트가 재요청을 한다면 Long Polling과 비교해 SSE의 장점이 없어지기 때문에,

Http를 사용하는 Long Polling이 더 적합하다고 판단해 SSE에서 Long Polling으로 적용기술을 변경했습니다.

2. Long Polling

Long Polling은 단방향 통신으로 클라이언트에서 요청을 해야 서버에서 응답을 보낼 수 있고,

자동으로 재요청이 가지 않기 때문에 클라이언트는 응답을 받은 뒤 다시 요청을 보내야 합니다.

Long Polling에서는 클라이언트의 요청에 즉시 응답을 보내지 않고 일정시간동안 기다렸다가 응답을 보냅니다.

응답을 보내는 시점은 두 가지로 나눠볼 수 있습니다

  • 서버의 데이터가 변경되었을 때
    • deferredresult.setResult() 호출
  • 정해진 타임아웃 시간이 지났을 때
    • new DeferredResult(10 * 1000L) 생성자로 타임아웃 시간 설정
longPolling

Long Polling에서의 동시성 문제

아래와 같이 Long Polling을 사용해 랜덤 매칭 기능을 구현했을 때, 동시성 문제가 발생했습니다.


    Chatcontroller.java

    // 타임아웃 시 422 반환하도록 설정
    DeferredResult<ResponseEntity<Map<String, Object>>> deferredResult =
                new DeferredResult<>(10 * 1000L, 
                        ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(Map.of("message", "매칭에 실패했습니다."));

    // 클라이언트 요청 시 que에 deferredResult 저장
    que.put(key, deferredResult);

    // 랜덤매칭 로직 실행
    var result = chatService.matching(groupId, memberId);

    // 매칭 성공 시, 본인과 매칭 상대방 성공 응답 전송
    if (result.geChatRoomId != 0L) {
            deferredResult.setResult(ResponseEntity.ok().body(Map.of("chatRoomId", result.getChatRoomId)));
            var partnerDeferredResult = que.get(partnerKey);
            partnerDeferredResult.setResult(ResponseEntity.ok().body(Map.of("chatRoomId", result.getChatRoomId)));
    }

    // 콜백 시, redis와 대기 큐에서 본인과 매칭 상대 제거 
    deferredResult.onCompletion(() -> {
            redisService.remove(key);
            redisService.remove(partnerKey);
            que.remove(deferredResult);
            que.remove(partnerDeferredResult);
    });

먼저 A가 랜덤 매칭 요청을 하고 잠시 후 이어서 B가 랜덤 매칭 요청을 합니다.

A의 요청으로 A와 B가 매칭되었을 때, A와 B의 deferredResultsetResult()를 호출해 클라이언트에 응답을 보냅니다.

그리고 A의 콜백 메서드 onCompletion()가 실행되면 대기열에서 A와 B를 제거합니다.

그런데 A의 콜백 메서드가 실행되어 두 사람이 대기열에서 빠지기 전에, B의 요청에 의해 B가 다른 사람과 매칭이 됩니다.

B는 두 사람과 중복으로 매칭이 되는 문제가 발생합니다.

문제 해결


    Chatcontroller.java

    // 요청 시 2초 기다리기
    try {
            Thread.sleep(2 * 1000L);
    } catch (InterruptedException e) {
            throw new RuntimeException(e);
    }

    ChatService.java

    // synchronized 사용
    @Transactional
    public synchronized MatchingResult matching() {
        ...
    }
    

참고자료