SR Translation — 서버 클록의 함정

kodeholic·2026년 3월 28일

RTCP 완전정복

목록 보기
3/5
post-thumbnail

RTCP 완전정복 시리즈 #3

지난 편에서 SFU는 RTCP를 그대로 릴레이하면 안 되고, 양쪽 세션에 맞는 RTCP를 직접 처리해야 한다고 했다. RR은 SFU가 직접 생성하고, subscriber의 RR은 publisher에게 전달하지 않는다. 여기까지는 깔끔하다.

그런데 SR(Sender Report)은 상황이 다르다. SR에는 보내는 사람의 시계 정보가 들어있다. 이걸 SFU가 자기 시계로 바꿔치면, 처음엔 괜찮아 보이다가 시간이 지나면서 서서히 깨진다.

이 글은 그게 뭔지, 그리고 SFU는 SR을 어떻게 다뤄야 하는지에 대한 이야기다.


1. SR이 왜 필요한가 — 영화 자막으로 이해하기

영화를 볼 때 자막이 1초 늦게 뜨면 어떻게 되는가? 배우가 입을 벌리고 한참 후에 자막이 나온다. 못 볼 정도는 아니지만 굉장히 거슬린다.

화상회의에서도 같은 문제가 있다. 오디오와 비디오는 별도의 스트림으로 전송된다. 각각 따로 패킷을 만들어서 따로 보낸다. 받는 쪽에서 이 둘을 합쳐서 재생하는데, 타이밍이 안 맞으면 "입이 움직이는데 소리가 늦게 나오는" 현상이 생긴다. 이걸 lip sync(입술 싱크) 문제라고 한다.

그런데 오디오와 비디오의 타임스탬프는 서로 비교할 수가 없다. 오디오는 48kHz 클록, 비디오는 90kHz 클록을 쓴다. 1초에 오디오는 타임스탬프가 48,000 증가하고 비디오는 90,000 증가한다는 뜻이다. 단위가 다르니까, 오디오 타임스탬프 96,000과 비디오 타임스탬프 180,000이 같은 시점인지 다른 시점인지 알 방법이 없다.

여기서 SR이 등장한다. SR은 이렇게 말한다.

"지금 벽시계 기준 14시 30분 5.123초야. 그리고 이 시각에 오디오 타임스탬프는 96,000이었어."

비디오 쪽 SR도 같은 방식으로 말한다.

"지금 벽시계 기준 14시 30분 5.123초야. 이 시각에 비디오 타임스탬프는 180,000이었어."

받는 쪽은 이 두 SR을 비교한다. "아, 벽시계 14:30:05.123일 때 오디오는 96,000이고 비디오는 180,000이구나." 이제 공통 기준점이 생겼으니, 이후에 오는 오디오/비디오 패킷의 타이밍을 맞출 수 있다.

  영화 자막에 비유하면:

  SR = "이 장면은 영화 시작 후 01:23:45 시점이야"
       → 자막 파일과 영상 파일이 각자 다른 시간축을 쓰더라도,
          이 공통 기준점으로 둘을 정렬할 수 있다

  실제 SR:
  오디오 SR: "벽시계 14:30:05 = RTP ts 96,000"   (48kHz)
  비디오 SR: "벽시계 14:30:05 = RTP ts 180,000"  (90kHz)
       → 벽시계가 공통 기준점. 이걸로 둘을 정렬

SR에는 시간 매핑 말고도 "지금까지 총 몇 패킷, 몇 바이트 보냈다"는 통계가 들어있다. 받는 쪽은 이 숫자를 보고 "10,000개 보냈다는데 나는 9,500개 받았네 → 5% 손실"을 계산한다.


2. SFU가 자기 시계로 SR을 찍으면 어떻게 되는가

지난 편에서 SFU는 subscriber 입장에서 "보내는 쪽"이라고 했다. 보내는 쪽이니까 SR도 SFU가 만들어야 한다. 자연스러운 발상은 이거다.

"SFU 서버 시계 기준으로 벽시계 시각을 찍고, 방금 보낸 패킷의 타임스탬프를 넣으면 되지 않나?"

이게 왜 문제인지, 시계 두 개를 놓고 생각해보자.

Publisher A의 노트북에는 A의 시계가 있다. SFU 서버에는 SFU의 시계가 있다. 둘 다 NTP로 인터넷 시각 동기화를 하지만, 완벽히 같지는 않다. 수ms~수십ms 정도 차이가 있고, 1초가 흐를 때 한쪽은 정확히 1,000ms이지만 다른 쪽은 1,000.3ms일 수 있다.

일상에서는 이 정도 차이를 느낄 수 없다. 하지만 RTP 타임스탬프의 세계에서는 다르다.

핵심을 비유로 설명하면 이렇다.

기차역에 시간표가 붙어있다. "다음 열차는 14:30에 도착합니다." 승객(jitter buffer)은 이 시간표를 보고 14:30에 맞춰 플랫폼에 나간다.

그런데 시간표를 쓴 사람(SFU)의 시계가 역에 걸린 시계(publisher 시계)보다 0.3초 빠르다. 첫 번째 열차는 시간표보다 0.3초 늦게 도착한다. "뭐, 0.3초면 괜찮지." 두 번째 열차는 0.6초 늦게, 세 번째는 0.9초 늦게.

승객은 점점 불안해진다. "열차가 갈수록 늦어지네. 다음 열차는 더 늦을 수 있으니 넉넉하게(^^;) 기다리자." 대기 시간이 점점 늘어난다. 이게 jitter buffer가 점점 커지는 현상이다. 패킷이 예상보다 늦게 도착하는 패턴이 보이면, 재생 전에 더 오래 쌓아두고 기다린다. 안 그러면 늦게 온 패킷을 놓치니까. 대신 그만큼 재생이 늦어진다 — 이게 지연이다.

  시간표 쓴 사람(SFU) 시계 vs 실제 열차(RTP 패킷) 기준 시계(Publisher)

  1번째 열차:  시간표 14:30:00  →  실제 도착 14:30:00.3   (0.3초 차이)
  2번째 열차:  시간표 14:35:00  →  실제 도착 14:35:01.5   (1.5초 차이)
  3번째 열차:  시간표 14:40:00  →  실제 도착 14:40:02.7   (2.7초 차이)
                                                           ↑
                                              차이가 계속 벌어진다!

실제 수치로 보면 이렇다.

  Jitter Buffer Delay 추이 (서버 클록 SR 사용 시)

  통화 시작   10분 후    30분 후    1시간 후
    20ms   →   45ms   →  120ms  →   350ms
                                      ↑
                              소리가 영상보다 한참 뒤에 들림

처음 5분은 괜찮다. 그래서 테스트할 때 못 잡는다. 개발자가 5분 통화하고 "잘 되네" 하고 넘어가면, 실제 사용자는 30분 통화하면서 "소리가 점점 이상해지는데?"를 경험한다.


3. 해결 — SR은 "생성"이 아니라 "번역"

문제의 원인이 명확하니 해결도 명확하다. SFU가 자기 시계를 쓰지 않으면 된다.

Publisher A가 보낸 SR에는 A의 시계 기준으로 벽시계 시각이 찍혀있다. 그리고 A가 보내는 RTP 패킷의 타임스탬프도 A의 시계 기준이다. 이 둘은 같은 시계에서 나왔으니 항상 일관된다. SFU가 이 시각 정보를 그대로 subscriber에게 전달하면, 시계 차이 문제가 사라진다.

기차역 비유로 돌아가면 — "시간표를 역에 걸린 시계(publisher 시계)를 보고 쓴다." 시간표와 실제 열차가 같은 시계 기준이니까, 아무리 시간이 지나도 어긋나지 않는다.

그런데 SR에는 시각 정보 말고도 통계 정보가 있다. "총 10,521패킷 보냈다"는 숫자. 이건 A가 SFU에 보낸 총량이지, SFU가 subscriber B에게 보낸 총량이 아니다. 이 숫자를 그대로 전달하면 B가 손실률을 잘못 계산한다.

그래서 일부는 그대로 살리고, 일부는 바꾼다. 이게 "번역(translation)"이다.

  Publisher A가 보낸 원본 SR
  ┌──────────────────────────────────────────────┐
  │  벽시계 시각(NTP):   14:30:05.123            │  → 그대로 유지 ✓
  │  RTP 타임스탬프:     928,000                 │  → 그대로 유지 ✓
  │  총 보낸 패킷 수:    10,521                  │  → 교체 필요
  │  총 보낸 바이트 수:  8,416,800               │  → 교체 필요
  └──────────────────────────────────────────────┘

                    ↓  SFU가 번역

  SFU가 B에게 보내는 SR (번역 후)
  ┌──────────────────────────────────────────────┐
  │  벽시계 시각(NTP):   14:30:05.123            │  ← A의 원본 그대로
  │  RTP 타임스탬프:     928,000                 │  ← A의 원본 그대로
  │  총 보낸 패킷 수:    9,800                   │  ← SFU→B 실제 송신량
  │  총 보낸 바이트 수:  7,840,000               │  ← SFU→B 실제 송신량
  └──────────────────────────────────────────────┘

위쪽 두 줄(시각 정보)은 원본 그대로 통과시킨다. 이게 시계 기준이니까 건드리면 안 된다.

아래쪽 두 줄(통계 정보)은 SFU → B 구간의 실제 수치로 교체한다. B가 이 숫자로 손실률을 계산하니까, B가 실제로 받아야 할 양과 맞아야 한다.

이 방식이면 시간이 아무리 지나도 시계 차이 문제가 없다. 시각은 A의 시계 기준이고, RTP 패킷도 A의 시계 기준이고, 같은 시계를 쓰니까 일관된다.


4. Simulcast에서 한 겹 더

여기까지는 비교적 간단하다. 그런데 Simulcast을 쓰면 번역할 때 신경 쓸 게 하나 더 생긴다.

Simulcast을 간단히 설명하면, publisher가 같은 영상을 고화질(h)저화질(l) 두 벌로 동시에 보내는 것이다. SFU는 subscriber의 네트워크 상태에 따라 적절한 화질을 골라서 전달한다. 네트워크 좋으면 h, 나쁘면 l.

문제는 레이어를 전환할 때 발생한다. 비유하면 이렇다.

고속도로에서 1차선(h)을 달리다가 2차선(l)으로 차선을 바꿨다. 그런데 1차선 이정표에는 "서울까지 100km"라고 적혀있고, 2차선 이정표에는 같은 지점에서 "서울까지 50km"라고 적혀있다. 이정표 체계가 차선마다 다른 것이다.

RTP 타임스탬프가 이 이정표에 해당한다. 같은 시점의 영상인데도, h 레이어와 l 레이어는 각자 독립적으로 타임스탬프를 매긴다. 값이 다르다.

SFU가 h에서 l로 전환하면서 이 차이를 보정하는 장치가 있다. RTP 패킷의 타임스탬프에 오프셋(차이값)을 더해서, subscriber 입장에서는 타임스탬프가 부드럽게 이어지도록 만든다.

SR의 RTP 타임스탬프에도 같은 오프셋을 적용해야 한다. 안 그러면 SR이 가리키는 시점과 실제 RTP 패킷의 시점이 따로 놀아서, 다시 jitter buffer가 혼란에 빠진다.

  레이어 전환 전후의 SR 번역

  h 레이어 SR:  벽시계 14:30:05,  RTP ts 928,000
  l 레이어 SR:  벽시계 14:30:05,  RTP ts 460,000  ← 같은 시점인데 값이 다름!

  SFU가 적용하는 오프셋: +468,000

  B에게 보내는 SR:
    벽시계 14:30:05,  RTP ts 460,000 + 468,000 = 928,000
                                       ↑
                         RTP 패킷과 일관된 타임스탬프 유지

그리고 한 가지 더. B가 현재 l 화질을 받고 있는데, SFU에 h 화질의 SR이 도착하면? 이건 그냥 버린다. B가 받고 있는 레이어의 SR만 번역해서 전달한다. 다른 레이어의 시간 정보가 섞이면 jitter buffer가 또 혼란에 빠지기 때문이다.


업계 선례

이 문제는 OxLens에서만 발생하는 게 아니다.

Janus Gateway가 대표적인 사례다. 한때 서버 클록으로 SR을 생성했다가, 장시간 통화에서 jitter buffer가 폭주하는 문제를 겪었다. GitHub PR #2007에서 수정됐다. mediasoup은 처음부터 publisher SR을 subscriber에게 번역해서 전달하는 방식을 택했고, LiveKit도 동일하다.

결국 상용 SFU는 전부 같은 결론에 도달한다 — 시각 정보는 원본을 유지하고, 통계 필드만 교체한다.


정리

항목ConferenceSimulcast
벽시계 시각(NTP)원본 유지원본 유지
RTP 타임스탬프원본 유지오프셋 적용
패킷 수SFU→Sub 기준SFU→Sub 기준
바이트 수SFU→Sub 기준SFU→Sub 기준

SR Translation의 핵심 원칙은 하나다. 시각 정보는 원래 보낸 사람의 시계 기준을 유지한다. SFU가 자기 시계를 끼워넣는 순간, 시계 차이가 조금씩 쌓이고, 시간이 지나면 깨진다.

다음 편에서는 RTCP 메시지 중 가장 비용이 비싼 PLI를 다룬다. 1편에서 키프레임이 P-frame보다 5~10배 크다고 했는데, SFU에서 PLI가 폭주하면 어떤 일이 벌어지는지, 그리고 이걸 어떻게 제어하는지.

다음 편: [RTCP 완전정복 #4] PLI Governor — 인과관계 기반 PLI 평탄화


참고 자료


이 글은 OxLens — Rust로 만드는 경량 SFU 서버 프로젝트를 개발하면서 배운 것들을 정리한 시리즈입니다.

profile
Creator of 0xLENS — the world's first AI-native, Rust-built SFU with real-time PTT Floor Control. No fork. No framework. Just Rust and resolve.

0개의 댓글