JUN0.DEV
JUN0.DEV

GRIT에서 Redis로 방 상태 동기화하기

Published on
  • avatarJunyoung Yang

GRIT에서 실시간 화상 스터디 기능을 만들면서 처음에는 영상과 음성이 잘 연결되는지가 가장 큰 문제라고 생각했다. LiveKit을 연동하고 방에 입장해서 서로 화면과 음성이 오가면 핵심 기능은 어느 정도 구현된 것처럼 보였다.

하지만 실제로 더 중요했던 것은 연결 자체보다 방 상태였다. 같은 방에 있는 사람들끼리 타이머, 발표자, 마이크 권한을 서로 다르게 보고 있으면 영상이 잘 나와도 서비스가 어색해진다.

특히 운영 가능한 구조를 생각하면서 서버 인스턴스가 여러 개 있는 상황을 전제로 두니 문제가 더 분명해졌다. 어떤 사용자는 인스턴스 1에 연결되고, 다른 사용자는 인스턴스 2에 연결될 수 있다. 이때 한 서버에서 바뀐 방 상태가 다른 서버에도 같은 기준으로 보여야 했다.

이 글은 GRIT에서 LiveKit은 미디어 전달을 맡기고, Redis로 방 상태를 동기화하는 구조를 정리한 과정이다.

처음 선택한 구조

화상 통신 구조를 고민할 때 보통 P2P와 SFU를 먼저 비교하게 된다. P2P는 구조를 이해하기 쉽지만, 참가자가 늘어나면 연결 수가 빠르게 늘고 중앙에서 다루기도 어렵다.

GRIT처럼 발표 순서나 마이크 권한처럼 방 규칙이 중요한 서비스는 상태를 더 안정적으로 다룰 수 있어야 했다.

그래서 미디어 전달 구조는 LiveKit 기반 SFU로 가져갔다. 이 선택 덕분에 각 참가자가 모든 연결을 직접 관리하지 않아도 됐고, 미디어 흐름도 더 안정적으로 다룰 수 있었다.

다만 여기서 끝나지는 않았다. SFU를 쓴다고 애플리케이션 상태 문제가 함께 해결되지는 않았기 때문이다.

원인을 확인한 과정

GRIT를 만들면서 가장 먼저 구분한 것은 두 종류의 상태였다.

첫 번째는 미디어 상태이다. 누가 영상을 보내는지, 오디오 트랙이 살아 있는지, 연결이 유지되는지 같은 WebRTC/LiveKit 계층의 상태가 여기에 들어간다.

두 번째는 애플리케이션 상태이다. 현재 타이머가 어떻게 진행 중인지, 마이크 권한이 누구에게 있는지, 발표 순서가 누구인지 같은 서비스 계층의 상태이다.

처음에는 이 둘이 자연스럽게 함께 움직일 것처럼 보였다. 그런데 실제로는 전혀 아니었다. 영상과 음성은 잘 연결되는데 타이머가 사람마다 다르게 보이거나, 어떤 서버에서는 마이크 권한이 바뀌었는데 다른 서버에서는 그대로면 사용자 입장에서는 바로 이상하게 느껴진다.

처음 보인 문제

서버 인스턴스가 하나뿐이면 상태를 프로세스 메모리에만 두고도 어느 정도 동작한다. 같은 프로세스 안에서 타이머를 갱신하고, 권한을 바꾸고, 연결된 세션에 이벤트를 보내면 되기 때문이다.

문제는 멀티 인스턴스를 전제로 두는 순간부터 달라졌다.

  • 사용자 A는 인스턴스 1에 연결되어 있다.
  • 사용자 B는 인스턴스 2에 연결되어 있다.
  • 발표 권한이 바뀌었는데 한 서버만 그 상태를 알고 있다.
  • 타이머는 한 서버에서만 흘러가고 다른 서버는 예전 상태를 보고 있다.

이런 상황이 생기면 같은 방 안에서도 서로 다른 현재 상태를 공유하게 된다. 실시간 서비스 입장에서는 큰 문제였다.

해결 방안

이 문제를 해결하기 위해 Redis를 공유 상태 레이어처럼 두는 방식을 선택했다. 여기서 중요한 것은 캐시라기보다, 여러 인스턴스가 같은 세션 상태를 빠르게 읽고 바꿀 수 있는 기준점이 필요했다는 점이다.

Redis를 검토할 때 확인한 기준은 이랬다.

  • 빠르게 읽고 쓸 수 있는가
  • 방 단위 상태를 표현하기 쉬운가
  • 순서나 우선순위가 필요한 데이터도 다룰 수 있는가
  • 여러 인스턴스가 같은 상태를 공유할 수 있는가

이 기준에서 Redis는 잘 맞았다. 특히 Hash와 ZSET은 세션 상태를 표현하기에 편했다.

적용 및 구현

실시간 세션 상태라고 해서 전부 같은 성격은 아니었다.

참여자별 현재 상태나 권한 여부는 key-value에 가깝다. 반면 발표 순서나 우선순위는 순서 정보가 중요하다.

그래서 Redis를 단순 값 저장소가 아니라, 상태 성격에 맞는 자료구조를 선택할 수 있는 공유 저장소로 봤다.

Hash가 잘 맞는 경우는 참여자별 마이크 권한, 현재 발표 가능 상태, 방의 진행 모드, 사용자별 세션 속성처럼 필드 단위로 관리되는 상태였다.

ZSET이 잘 맞는 경우는 발표 순서, 특정 이벤트 발생 시각 기준 정렬, 우선순위가 있는 참여자 목록처럼 정렬 기준이 필요한 상태였다.

구현하면서 조심한 부분

실시간 상태 동기화에서 가장 위험한 것은 상태를 너무 쉽게 로컬 메모리에 묶어두는 것이다. 개발할 때는 편하지만, 인스턴스가 여러 개가 되는 순간 바로 어긋난다.

GRIT에서 특히 조심한 것은 다음과 같았다.

이벤트 전송과 상태 저장을 같은 것으로 보지 않았다. 웹소켓이나 실시간 이벤트를 잘 보낸다고 해서 상태가 맞는 것은 아니었다. 이벤트는 전달 수단일 뿐이고, 지금 무엇이 현재 값인지 따로 잡아둘 필요가 있었다.

세션 상태를 한 서버 메모리만 믿고 두지 않았다. 입장, 퇴장, 권한 변경, 타이머 시작 같은 이벤트는 여러 인스턴스에서 비슷한 기준으로 읽혀야 했다.

실시간성과 일관성 사이에서도 계속 타협점을 찾아야 했다. 모든 것을 강하게 동기화하면 느려지고, 너무 느슨하게 처리하면 상태가 어긋난다. 어떤 상태는 즉시 반영돼야 하고, 어떤 상태는 약간의 지연을 감수할 수 있는지 계속 구분했다.

정리한 기준

이 구조를 적용한 뒤 가장 크게 달라진 점은, 사용자가 어느 인스턴스에 연결되어 있든 같은 방 상태를 공유할 수 있는 기반이 생겼다는 점이다.

  • 타이머가 모두에게 같은 기준으로 보였다.
  • 발표 권한 변경이 특정 인스턴스에만 반영되지 않는다.
  • 방 상태를 서버마다 다르게 이해하지 않는다.
  • 인스턴스가 여러 개여도 룸의 규칙은 하나처럼 유지된다.

사용자 입장에서는 뒤에 서버가 몇 개 있든 상관없다. 서비스는 하나의 시스템처럼 동작해야 한다. Redis를 상태 레이어처럼 둔 것은 그런 흐름을 맞추기 위한 선택이었다.

마무리

GRIT를 만들면서 정리한 핵심은, 영상이 정상적으로 보인다고 해서 실시간 서비스가 완성되는 것은 아니라는 점이었다. 화상 연결은 시작일 뿐이고, 실제로 서비스를 서비스답게 만드는 것은 같은 방의 모든 사용자가 같은 현재 상태를 공유하게 만드는 일이었다.

LiveKit은 미디어 전달을 안정적으로 처리해줬지만, 스터디 플랫폼에 필요한 타이머와 권한, 참여자 상태를 멀티 인스턴스 환경에서 어떻게 유지할지는 별도로 해결해야 하는 문제였다. Redis 기반 상태 동기화 구조를 적용하면서, 실시간 서비스를 확인하는 기준도 함께 바뀌었다.