Sentry에서 rrweb 세션 리플레이를 MP4로 뽑아야 할 일이 생겼는데 마땅한 도구가 없어서 직접 제작하면서 느낀 삽질을 기록으로 남긴다.
Replays MP4 저장을 지원하지 않는 Sentry
Sentry는 세션 리플레이를 rrweb JSON 포맷으로 저장한다. 브라우저에서 재생은 가능하나 팀 내 공유나 보고용으로 MP4 파일이 필요한 상황이 생겼다. 서치 결과 Sentry 공식에서도 MP4 내보내기 기능이 없었다. 관련 GitHub 이슈를 보면 꽤 많은 사람들이 같은 필요성을 느끼고 있었고 2022년부터 올라온 요청인데도 아직까지 공식 지원이 안 되고 있었다. 결국 기다리는 것보단 직접 만드는 게 빠르겠다 싶어서 웹 앱을 만들기 시작했다.
Sentry 팀이 공식으로 만든 @rrweb/rrvideo라는 패키지는 Playwright로 Chromium을 띄워서 rrweb을 실제로 재생하면서 녹화하는 방식으로 이 패키지를 백엔드로 그대로 쓰고 그 위에 드래그앤드롭 웹 UI만 얹는 형태로 초안 파이프 라인을 구성했다.
메모리 OOM 용량 문제
첫 번째로 맞은 문제는 메모리였다. rrweb JSON 파일을 통째로 불러와서 Chromium에서 리플레이를 돌리고, 동시에 영상을 녹화하려 했더니 메모리가 폭발했다.
Railway 무료 플랜으로 배포했는데 세션 길이가 좀 되는 파일을 올리자마자 프로세스가 조용히 죽어버렸다. 로그를 보니 OOM이었다.
rrweb 이벤트가 많은 세션일수록 JSON 자체도 무겁고 리플레이어가 DOM을 계속 누적해서 들고 있으면서 Playwright 녹화 버퍼까지 쌓여 결국 OOM가 났다. Railway 무료 플랜은 RAM이 512MB 남짓이라 이 조합에선 버틸 수가 없었다.
따라서 해결 방안으로
- 청크 단위 처리: 전체 이벤트를 한 번에 rrvideo에 넣지 않고, 시간 구간별로 잘라서 순차 처리
- Chromium 재시작: 구간이 바뀔 때마다 Chromium을 완전히 재시작해서 이전 구간의 메모리를 해제
청크 단위로 쪼개어 메모리 압력을 줄였다. 덕분에 1GB RAM 환경(Railway)에서도 30~60분 세션을 처리할 수 있게 됐다. 하지만 구간을 나누면 나누는 대로 새로운 문제가 생겼다.
세그먼트 분할과 FullSnapshot prefix 함정
rrweb 이벤트를 시간 기준으로 자르면, 중간 구간에는 FullSnapshot 이벤트가 없을 수 있다. rrweb 리플레이어는 반드시 FullSnapshot으로 시작해야 초기 DOM을 구성할 수 있는데, 중간 구간부터 던져주면 빈 화면만 나오거나 에러가 난다.
그래서 각 세그먼트를 처리할 때 반드시 해당 구간 직전의 가장 최근 FullSnapshot 이벤트를 prefix로 붙여줘야 한다.
그런데 여기서 또 문제가 생긴다. 시간으로 슬라이싱만 했다가 2번째 세그먼트부터 계속 빈 화면이 나왔다. 원인은 prefix로 붙인 FullSnapshot 때문에 변환된 영상 앞쪽에 윈도우 범위 밖의 내용이 포함된다. 예를 들어 15~30분 구간을 처리하려는데, 직전 FullSnapshot이 t=18분에 있다면 영상이 18분부터 시작해버린다. 그래서 변환 후 ffmpeg -ss로 앞부분을 잘라내고, 마지막에 모든 세그먼트를 concat하는 과정이 추가로 필요했다.

세션 리플레이 길이에 따른 전략 추가
또 하나 마주친 함정은 25분 임계값 문제였다. 반드시 25분이란건 아니지만 세션이 25분 정도를 넘어가면 변환 과정에서 특정 시점 이후 이벤트가 제대로 처리되지 않는 현상이 있었다.
또한 세그먼트 방식은 속도 면에서 단점이 있다. Chromium 콜드 부팅이 윈도우마다 반복되는 게 생각보다 꽤 무거웠다. 짧은 세션에 세그먼트 방식을 쓰면 오히려 단일 변환보다 느려지는 역전 현상이 생겼다. 거기다 세그먼트 수가 많아질수록 ffmpeg concat 단계도 늘어나니, 전체 처리 시간이 선형 이상으로 늘어나는 느낌이었다.
그래서 결국 두 가지 경로로 분기하는 방식을 택했다. 25분 이하 세션은 단일 변환(Chromium 1번 부팅)으로 처리하고, 그보다 긴 세션은 15분 윈도우 단위로 세그먼트를 나눠 각각 변환한 뒤 ffmpeg으로 합치는 방식이다.
SSE 기반 진행률 UX
변환이 오래 걸리다 보니 사용자가 어디까지 진행되었는지에 대한 진행도 프로그레스 바가 필요했다. 사용자 피드백 UI를 만들기 위해서는 실시간으로 서버에서 얼마나 진행되었는지에 대한 데이터가 필요했다.
WebSocket은 양방향이 필요할 때 쓰는 건데, 여기선 서버에서 클라이언트로만 보내면 충분해서 SSE(Server-Sent Events) 를 선택했다. 서버에서 변환 진행 중에 이벤트를 단방향으로 스트리밍해주는 방식이라 구현이 심플했다.
구체적으로는 서버에서 변환 단계가 바뀔 때마다 data: ... 형식의 텍스트를 HTTP 응답 스트림으로 흘려보내는 방식이다. 연결은 변환이 끝날 때까지 끊지 않고 유지하면서, 진행률(percent)과 현재 단계 메시지(message)를 JSON으로 실어 보낸다. 클라이언트에서는 브라우저 내장 API인 EventSource로 이 스트림을 구독해서, 메시지가 올 때마다 진행률 바와 상태 메시지를 업데이트한다. percent가 100이 되면 EventSource를 닫는 것으로 흐름이 마무리된다.
// 서버 측 (Node.js 예시)
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const sendProgress = (percent: number, message: string) => {
res.write(`data: ${JSON.stringify({ percent, message })}\n\n`);
};
// 세그먼트 처리 중간중간 호출
sendProgress(10, '이벤트 파싱 중...');
sendProgress(30, '세그먼트 분할 완료');
sendProgress(60, '인코딩 진행 중...');
sendProgress(100, '완료!');
res.end();
// 클라이언트 측
const eventSource = new EventSource('/api/convert');
eventSource.onmessage = (e) => {
const { percent, message } = JSON.parse(e.data);
setProgress(percent);
setStatusMessage(message);
if (percent === 100) {
eventSource.close();
}
};
이 방식이 생각보다 훨씬 깔끔했다. HTTP 위에서 동작하니까 별도 인프라도 필요 없고, 클라이언트에서 EventSource 하나로 끝난다. 진행률 바와 현재 단계 메시지를 같이 보여주니까 사용자 입장에서 기다리는 게 훨씬 덜 답답해졌다.
마치며
직접 만들면서 삽질을 많이 했지만, 덕분에 rrweb 내부 동작 방식이나 브라우저 메모리 관리, SSE 같은 것들을 훨씬 깊이 이해하게 됐다.
아직 25분 임계값 같은 엣지 케이스는 완벽하게 해결한 게 아니라서, 기회가 되면 더 깊이 파고들어 보고 싶다. 비슷한 문제를 겪고 있는 사람이 있다면 이 글이 조금이나마 도움이 됐으면 한다.