RSocket과 Reconnection 대응기
재연결 전략의 핵심 개념인 ReliableRequest와 Operation
작성일 2026.01.22
페이지가 생성된 시간 2026.02.03 00:47:07

1

들어가며

최근 네트워크가 불안정하여 작업내용이 유실된다는 VOC가 많이 들어왔다. 관련해서 코드 분석과 디버깅에 들어갔다.

RSocket 채널들이 재연결 될 때, 채널버퍼(이전글 참고) 내부 요청들이 유실되는 문제가 있었다..! 문제를 해결하는 과정에서 배우고 고민한 내용을 토대로 작성한 글이다.


네트워크 연결이 끊겼을 때 시스템이 어떻게 복구하는지를 설명한다. 실시간 협업 환경에서 네트워크 단절은 피할 수 없다. 중요한 것은 끊겼을 때 사용자의 작업을 잃지 않고, 서버 상태와 정합성을 유지하는 것이다.

이 문서에서는 재연결 전략의 핵심 개념인 ReliableRequestOperation을 자세히 다룹니다.

왜 재연결 전략이 중요한가

실시간 협업의 도전

협업 편집에서는 여러 사용자가 동시에 같은 디자인을 편집한다. 이 환경에서 네트워크 단절이 발생하면 다음 문제가 생긴다.

Alice가 편집 중인데 연결이 끊기면?
- 방금 보낸 편집이 서버에 도착했나?
- 다른 사용자들의 편집을 놓쳤나?
- 내 화면과 서버 상태가 다른가?

발생할 수 있는 시나리오

실제로 다음 상황들이 자주 발생한다.

상황원인빈도
브라우저 Background탭 전환, 화면 잠금매우 자주
네트워크 전환WiFi ↔ LTE, 네트워크 변경자주
일시적 네트워크 불안정신호 약함, 패킷 손실자주
Keep-alive Timeout장시간 유휴 상태가끔
서버 재배포서버 업데이트가끔

재연결 전략의 목표

재연결 전략은 다음을 목표로 한다

  1. 사용자 작업 보존: 끊긴 동안의 편집이 사라지지 않음
  2. 상태 정합성: 재연결 후 서버와 클라이언트 상태가 일치
  3. 사용자 경험: 끊김을 최소한으로 느끼게 함

재연결 트리거: 언제 재연결을 시도하나

연결 끊김 감지

RSocket은 KEEPALIVE 프레임으로 연결 상태를 모니터링한다다. 일정 시간 동안 응답이 없으면 연결이 끊긴 것으로 판단한다.

이때 RSocketClient라는 모듈의 상태가 disconnected로 바뀌고, Observer들에게 알린다.

브라우저 이벤트 감지

연결 끊김을 기다리지 않고, 브라우저 이벤트를 통해 미리 감지할 수도 있다.

// 네트워크 상태 변화
window.addEventListener('online', onChangeBrowserActive);
window.addEventListener('offline', onChangeBrowserActive);
// 탭 가시성 변화
window.addEventListener('visibilitychange', onVisibilityChange);
이벤트의미처리
online네트워크 연결됨재연결 시도
offline네트워크 끊김대기
visibilitychange (visible)탭이 다시 보임세션 확인 후 재연결
visibilitychange (hidden)탭이 숨겨짐숨겨진 시간 기록

세션 유효성 확인

재연결 전에 세션이 유효한지 먼저 확인합니다. 세션이 만료되었다면 재연결해도 소용없기 때문이다.

async () => {
  // 1. 세션 확인
  const { isValid } = await sessionChecker.checkSession();
  if (isValid) {
    // 2. 세션 유효하면 재연결
    await onSessionValid();
  } else {
    // 3. 세션 만료면 로그인 요청
    onSessionInvalid();
  }
};

재연결 전체 흐름

재연결 과정은 다음 순서로 이루어진다.

(버퍼 플러시, Openration 응답 대기는 아래에서 설명)

ReliableRequest: 재전송할 요청 선별

문제: 모든 요청을 다시 보내면?

RSocket requestN 개수가 모자라면 채널버퍼에 요청이 쌓이게 된다. 그 상태에서 연결이 끊겼다면 버퍼에는 미처 전송되지 못한 요청들이 쌓였을 것이다. 재연결 후 이 요청들을 어떻게 처리해야 할까?

모든 요청을 다시 보내면 비효율이 생긴다. 굳이 보낼 필요가 없는 요청들도 전송되기 때문이다. 가령 마우스 포인터 위치정보 같은 것들은 유실되어도 되는 요청들이다.

연결 끊기기 전 버퍼:
[요청1] [요청2] [요청3] [요청4] [요청5]

→ 모두 다시 보내면 비효율

해결: ReliableRequest로 선별

모든 요청을 다시 보내지 않고, 재전송이 필요한 요청만 선별한다

// 채널 초기화 시
isReliableRequest: (request) => {
  // Command 요청만 재전송 대상
  return request.requestType === 'COMMAND';
}

Command(디자인을 바꾸는 작업) 요청ReliableRequest로 설정했다. 편집 명령은 반드시 서버에 도달해야 하기 때문이다.

재연결 시 버퍼 처리 (의사코드로)

재연결 시:
1. 기존 버퍼 보관
2. 버퍼 비우기
3. 채널 재연결 + 핸드셰이크
4. 성공하면:
   - 기존 버퍼에서 ReliableRequest만 필터링
   - 버퍼에 다시 넣기
   - 플러시 (서버로 전송)
5. 실패하면:
   - ReliableRequest만 버퍼에 보존
   - 다음 재연결 시도에서 다시 시도

Operation: 요청-응답 쌍 추적

문제: 요청을 보냈다고 끝이 아니다

요청을 서버로 보냈다고 해서 처리가 완료된 것이 아니다. 응답을 받아야 진짜 완료라고 봐야 한다.

시나리오:
1. 재연결 후 버퍼에 있던 Command 3개를 전송
2. Command 1의 응답이 아직 안 옴
3. 이 상태에서 syncDesign을 하면?
   → Command 1의 변경이 반영되지 않은 상태를 받아올 수 있음!

해결: Operation으로 응답 대기

Operation은 "요청-응답 쌍을 추적해야 하는 요청".

operationConfig: {
  // 이 요청이 Operation인지 판단
  isOperation: (request) => request.requestType === 'COMMAND',
  // 요청에서 식별자 추출
  getIdentifier: (operation) => operation.requestId,
  // 응답 메시지에서 매칭되는 식별자 추출
  messageToMatchingIdentifier: (message) => {
    if (message.type === 'RESPONSE' || message.type === 'FANOUT_COMMAND') {
      return message.payload.requestId;
    }
    return null;
  },
}

플러시 완료 판단

버퍼를 플러시할 때, Operation들의 응답이 모두 도착해야 "플러시 완료"

플러시 완료 대기와 UI

플러시 상태 추적

Channel은 플러시 시작/완료를 알린다.

// 플러시 시작
onProcessQueueStart: (processId: string) => void;
// 플러시 완료 (Operation 응답까지 도착)
onProcessQueueResolved: (processId: string) => void;

RSocketClient는 현재 플러시 중인 채널 수를 추적.

// 플러시 중인 채널들
private processQueueFlushingChannels: Map<string, RSocketChannel> = new Map();
// Observer들에게 알림
onChangeProcessQueueFlushingChannels?: (channels: RSocketChannel[]) => void;

재연결 시 플러시 완료 대기

    // 1. 재연결
    await rsocketClient.reconnect(true);
    // 2. 플러시 완료 대기 (최대 3초)
    await firstValueFrom(
      race([
        flushingChannelsCount$.pipe(
          tap((count) => {
            if (count > 0) {
              blockEditing();
            }
          }),
          filter((count) => count === 0) // 0이 되면 완료
        ),
        timer(3000), // 최대 3초 대기
      ])
    );
    // 3. 서버 상태 동기화
    await syncDesign();

Jitter: 동시 재연결 방지

문제: Thundering Herd

서버가 재시작되면 모든 클라이언트가 동시에 재연결을 시도한다. 이것을 Thundering Herd 문제라고 한다.

해결: Exponential Backoff + Jitter

재연결 시 랜덤 지연(Jitter) 을 추가해 동시 재연결을 분산.

// jitter: 0~1000ms 랜덤 대기
if (enableJitter) {
  await jitter(1000);
}

이렇게 하면

  • Alice: 200ms 후 재연결
  • Bob: 800ms 후 재연결
  • Charlie: 500ms 후 재연결

재연결이 이븐하게 분산되어 서버 부하가 줄어든다.

다이어그램으로 보는 전체 흐름

요약

이 문서에서 다룬 재연결 전략의 핵심

재연결 트리거

  • KEEPALIVE 타임아웃
  • 브라우저 이벤트 (online/offline, visibilitychange)
  • 세션 유효성 확인 후 재연결

ReliableRequest

  • 모든 요청을 재전송하지 않음
  • Command 같은 중요 요청만 선별해 재전송
  • isReliableRequest 함수로 판별

Operation

  • 요청-응답 쌍을 추적
  • 응답이 모두 도착해야 플러시 완료
  • 타임아웃으로 무한 대기 방지

최종 정합성

  • 버퍼 플러시 완료 대기
  • syncDesign으로 서버 상태 동기화
  • Sequence 비교로 변경 여부 확인

안정성

  • Jitter로 동시 재연결 분산
  • 최대 6회 재시도
  • 실패 시 사용자에게 알림

참고 자료

RSocket Protocol Specification

Reactive Streams

©2024 dlwl98
github
PostsAbout