GRIT는 Spring Boot와 LiveKit 기반의 실시간 화상 스터디 플랫폼이다. 영상과 음성은 LiveKit으로 처리했지만, 방 안에서 쓰는 포모도로 타이머나 이모지 리액션은 별도의 데이터 전달 흐름이 필요했다.
이 글은 GRIT에서 LiveKit SendData를 사용해 이런 방 이벤트를 처리한 내용을 정리한 글이다.
문제 상황
LiveKit은 영상과 음성을 전달하는 WebRTC 기반 구조를 제공한다. 하지만 스터디 서비스에서 필요한 상호작용이 전부 미디어 트랙으로 표현되는 것은 아니다.
GRIT에서는 방 안에서 아래와 같은 이벤트가 필요했다.
- 이모지 리액션 전송
- 포모도로 타이머 상태 동기화
이 이벤트들은 영상이나 음성 자체가 아니라, 방 화면을 갱신하기 위한 데이터에 가깝다.
처음에는 일반 WebSocket을 따로 두는 방식도 생각했다. 미디어는 LiveKit으로 처리하고, 타이머나 리액션은 백엔드 WebSocket으로 보내는 구조다.
하지만 이미 방 단위 연결과 참여자 관리는 LiveKit 위에서 이루어지고 있었다. 여기에 이벤트 채널을 하나 더 만들면 클라이언트가 LiveKit 연결과 WebSocket 연결을 둘 다 관리해야 했다.
그래서 GRIT에서는 LiveKit이 제공하는 SendData로 방 안의 데이터 이벤트를 보내는 쪽으로 정리했다.
해결 방법
LiveKit SendData는 같은 Room 안의 participant에게 데이터를 보낼 수 있는 기능이다. GRIT에서는 클라이언트가 직접 데이터 이벤트를 발행하지 않고, 서버가 권한을 확인한 뒤 같은 방에 이벤트를 보내도록 했다.
토큰을 발급할 때는 방 참가와 미디어 publish/subscribe는 허용하되, 데이터 publish 권한은 막았다.
token.addGrants(
new RoomJoin(true),
new RoomName(roomName(group.getCode())),
new CanPublish(true),
new CanSubscribe(true),
new CanPublishData(false)
);
이렇게 하면 클라이언트는 LiveKit Room에 들어가 영상과 음성을 사용할 수 있지만, 방 이벤트는 서버 API를 거쳐서만 전송된다. 서버는 요청한 사용자가 그룹 멤버인지 확인한 뒤 RoomServiceClient.sendData로 같은 방에 데이터를 보낸다.
이모지 리액션은 아래처럼 보냈다.
{
"type": "reaction",
"emoji": "CLAP",
"emojiChar": "...",
"senderNickname": "jun0"
}
포모도로 타이머는 서버 시간을 기준으로 계산한 현재 상태를 함께 보냈다.
{
"type": "pomodoro.sync",
"timer": {
"status": "RUNNING",
"phase": "FOCUS",
"serverNow": "2026-04-14T10:00:00Z",
"focusEndsAt": "2026-04-14T10:25:00Z",
"breakEndsAt": null,
"pausedAt": null,
"focusMinutes": 25,
"breakMinutes": 5,
"currentRound": 1,
"totalRounds": 4
},
"senderNickname": "jun0"
}
클라이언트는 type을 보고 리액션 표시와 타이머 UI 갱신을 나누어 처리했다.
적용 내용
sequenceDiagram
participant ClientA as Client A
participant Backend as Spring Boot Backend
participant LiveKit as LiveKit Room
participant ClientB as Client B
ClientA ->> Backend: 리액션 또는 포모도로 요청
Backend ->> Backend: 그룹 멤버 권한 확인
Backend ->> Backend: 이벤트 payload 생성
Backend ->> LiveKit: sendData(roomName, json, RELIABLE)
LiveKit -->> ClientA: reaction 또는 pomodoro.sync 수신
LiveKit -->> ClientB: reaction 또는 pomodoro.sync 수신
ClientA ->> ClientA: UI 갱신
ClientB ->> ClientB: UI 갱신
이모지 리액션은 사용자가 보낸 값을 서버에서 확인한 뒤 reaction 타입으로 보냈다. 메시지에는 이모지 이름, 화면에 표시할 문자, 보낸 사람 닉네임을 담았다.
포모도로 타이머는 단순히 "시작했다"는 이벤트만 보내지 않았다. 서버 기준 현재 시간으로 상태, phase, 종료 시각, 일시정지 시각, 현재 라운드 등을 계산해 pomodoro.sync 타입으로 보냈다. 클라이언트마다 로컬 시간이 조금씩 달라도 서버가 내려준 값으로 화면을 맞추기 위해서였다.
구현할 때 본 부분
SendData를 사용할 때는 이벤트 형식을 먼저 정했다. 이벤트 타입이 늘어날수록 클라이언트와 서버가 같은 규칙으로 메시지를 해석해야 하기 때문이다. 그래서 reaction, pomodoro.sync처럼 타입을 나누고, 클라이언트에서는 타입별 handler를 분리했다.
다만 이벤트 전송은 상태 저장과는 다르다. SendData는 실시간 전달에 가깝기 때문에, 나중에 다시 들어온 사용자가 과거 이벤트를 모두 복구하는 구조로 보기는 어렵다. 그래서 현재 방 상태가 필요한 부분은 별도 상태 관리와 함께 봐야 했다.
정리
GRIT에서는 LiveKit을 영상과 음성 전달에만 쓰지 않고, SendData로 방 안의 데이터 이벤트도 처리했다. 서버가 그룹 권한을 확인하고, reaction과 pomodoro.sync 이벤트를 같은 Room에 보내는 구조다.
SendData는 이벤트 전달에는 적합하지만, 상태 저장을 대신하지는 않는다. 현재 방 상태가 필요한 부분은 별도 상태 관리와 함께 봐야 했다.