RTCP 완전정복 시리즈 #1
화상회의 중에 상대방 영상이 1초간 멈췄다. 그런데 보낸 쪽은 이걸 모른다.
UDP는 "보내면 끝"이다. 패킷이 도착했는지, 순서가 맞는지, 몇 개나 빠졌는지 — 아무것도 알려주지 않는다. TCP처럼 ACK을 주고받는 구조가 아니기 때문이다.
RTP도 마찬가지다. RTP는 미디어 패킷을 실어 나르는 역할만 한다. 영상 프레임을 쪼개서 UDP에 얹어 보내되, 그게 잘 도착했는지는 관심 없다.
문제는, 실시간 통신에서 "잘 도착했는지"를 모르면 품질 제어가 불가능하다는 것이다. 해상도를 낮춰야 할지, 비트레이트를 줄여야 할지, 특정 패킷을 재전송해야 할지 — 판단의 근거가 없다.
그래서 RTP에는 짝꿍이 있다. RTCP (RTP Control Protocol). 미디어를 나르는 건 RTP가 하고, "잘 받았는지" 피드백하는 건 RTCP가 한다. RFC 3550에서 RTP와 함께 정의된, 같은 세션의 제어 채널이다.
택배에 비유하면 이렇다. RTP는 택배 트럭이고, RTCP는 수령 확인서다. 트럭은 상자를 실어 나르기만 하고, 수령 확인서가 "12개 중 10개 받았고 2개 파손"이라고 보고한다. 이 보고가 없으면 택배 회사는 배송 품질을 개선할 방법이 없다.
RTCP에는 여러 종류의 메시지가 있다. 이번 글에서는 실시간 통신에서 가장 자주 만나는 4가지를 다룬다.
화상회의에서 상대방의 입이 움직이는데 목소리가 0.5초 늦게 들린다고 상상해보자. 영상과 음성이 따로 논다. 이걸 막으려면, 받는 쪽이 "오디오 타임스탬프 X와 비디오 타임스탬프 Y가 실제로는 같은 시각이다"라는 기준점을 알아야 한다.
이 기준점을 알려주는 게 SR(Sender Report)이다. 보내는 쪽이 주기적으로 "지금 벽시계 시각은 14:30:05.123이고, 이 시점의 RTP 타임스탬프는 928,000이다"라고 보고한다.
[보내는 쪽] [받는 쪽]
| |
| RTP 패킷 (영상/음성) |
|----------------------------------->|
| |
| SR (Sender Report) |
|----------------------------------->|
| "지금 시각은 14:30:05.123, |
| 총 4,521패킷 보냈어" |
| |
오디오와 비디오는 각자 다른 클록 주파수를 쓴다(오디오 48kHz, 비디오 90kHz). 타임스탬프 숫자만 보면 둘을 맞출 방법이 없다. SR이 "벽시계 기준 몇 시 몇 분"이라는 공통 시간을 찍어주기 때문에, 받는 쪽이 오디오와 비디오의 싱크를 맞출 수 있는 거다. 흔히 말하는 lip sync의 기준점이 바로 이 SR이다.
SR에는 "지금까지 총 몇 패킷, 몇 바이트 보냈다"는 통계도 들어있다. 받는 쪽은 이 숫자와 자기가 실제로 받은 수를 비교해서 손실률을 계산한다.
SR은 보내는 쪽이 자기 상태를 알려주는 거다. 그런데 보내는 쪽은 정작 가장 중요한 걸 모른다 — "내가 보낸 패킷이 상대방한테 잘 도착하고 있는가?"
이걸 알려주는 게 RR(Receiver Report)이다. 받는 쪽이 보내는 쪽에게 "네가 보낸 거 이렇게 받고 있어"라고 보고한다.
[보내는 쪽] [받는 쪽]
| |
| RTP 패킷 (영상/음성) |
|----------------------------------->|
| |
| RR (Receiver Report) |
|<-----------------------------------|
| "loss: 3%, jitter: 12ms, |
| 마지막 SR 받고 40ms 걸렸어" |
| |
RR이 전달하는 건 결국 "네트워크 상태의 성적표"다.
패킷 손실률 — "직전 보고 이후 3%가 안 왔어." 이 수치가 올라가면 네트워크가 혼잡하다는 신호다. 보내는 쪽은 이걸 보고 비트레이트를 낮추는 판단을 할 수 있다.
지터(jitter) — 패킷 도착 간격의 흔들림이다. 오디오 패킷이 20ms 간격으로 와야 하는데, 15ms 만에 오다가 35ms 만에 오다가 하면 지터가 높은 것이다. 받는 쪽의 버퍼(jitter buffer)가 이 흔들림을 흡수하지만, 한계를 넘으면 소리가 끊긴다.
RTT 계산용 타임스탬프 — SR을 받은 시각과 RR을 보내는 시각을 같이 기록해서, 보내는 쪽이 왕복 지연(Round Trip Time)을 역산할 수 있게 한다.
SR과 RR이 짝으로 오가는 구조다. SR은 "내가 뭘 보냈는지", RR은 "네가 보낸 게 어떻게 도착했는지". 이 두 보고서가 보통 5초 간격으로 교환되면서, 양쪽이 네트워크 상태를 공유한다.
SR과 RR은 전체적인 품질 보고서다. "지난 5초간 3% 손실이 있었어." 유용하지만 느리다. 패킷이 하나 빠졌을 때 5초를 기다렸다가 보고하면, 그 사이에 화면은 이미 깨져 있다.
NACK(Negative Acknowledgement)은 즉각적이다. 특정 패킷 번호를 지목해서 "이거 안 왔으니 지금 다시 보내"라고 요청한다.
[보내는 쪽] [받는 쪽]
| pkt #100 |
|----------------------------------->| ✓ 수신
| pkt #101 |
|----------X (손실) |
| pkt #102 |
|----------------------------------->| ✓ 수신
| |
| "pkt #101 다시 보내줘" (NACK) |
|<-----------------------------------|
| |
| pkt #101 (재전송, RTX) |
|----------------------------------->| ✓ 복구
받는 쪽은 패킷 번호(sequence number)를 보고 있다가, #100 다음에 #102가 오면 #101이 빠진 걸 즉시 안다. 그러면 NACK을 보내고, 보낸 쪽은 최근 패킷을 캐시해뒀다가 해당 패킷을 재전송한다. 이 재전송 패킷을 RTX(Retransmission)라고 부른다.
주의할 점이 하나 있다. NACK으로 복구할 수 있는 건 단발성 손실뿐이다. 네트워크가 심하게 혼잡해서 연속으로 수십 패킷이 날아가면, 하나하나 재전송 요청하는 건 불가능하다. 재전송 자체가 대역폭을 잡아먹어서 혼잡을 악화시킬 수도 있다.
그래서 "NACK으로 못 살리는 손실"이 있다. 특히 비디오에서.
비디오 코덱(VP8, H.264 등)은 매 프레임을 완전한 이미지로 보내지 않는다. 첫 프레임(I-frame, 키프레임)만 전체 이미지를 담고, 이후 프레임(P-frame)은 이전 프레임과의 차이만 담는다. 이렇게 하면 데이터양이 크게 줄어든다.
문제는, 이 구조가 체인처럼 연결되어 있다는 것이다.
[I-frame] → [P] → [P] → [P] → [P] → [P] → ...
전체 차이 차이 차이 차이 차이
이미지
P-frame 하나가 빠지면? 그 뒤의 모든 P-frame이 "이전 프레임"을 참조할 수 없으므로 디코딩이 깨진다. 화면에 녹색 블록이 뜨거나, 일부가 뭉개지거나, 아예 멈춘다.
NACK으로 빠진 패킷을 복구하면 해결되지만, 패킷이 여러 개 연속으로 빠지거나 I-frame 자체가 유실되면 NACK만으로는 답이 없다. 이때 쓰는 게 PLI(Picture Loss Indication)다.
[보내는 쪽] [받는 쪽]
| I-frame |
|----------------------------------->| ✓ 디코딩
| P-frame |
|----------X (손실) |
| P-frame |
|----------------------------------->| ✗ 참조 프레임 없음
| | → 화면 깨짐
| "I-frame 하나 보내줘" (PLI) |
|<-----------------------------------|
| |
| I-frame (새로 생성) |
|----------------------------------->| ✓ 화면 복구
PLI를 받으면 보내는 쪽의 인코더가 새 키프레임을 즉시 생성한다. 받는 쪽은 이 키프레임부터 다시 참조 체인을 시작하므로, 화면이 복구된다.
대신 비용이 있다. 키프레임은 P-frame보다 5~10배 크다. PLI를 너무 자주 보내면 갑자기 대역폭이 치솟고, 다른 사람의 품질까지 영향을 받는다. 이건 뒤에서 자세히 다루겠지만, "PLI는 공짜가 아니다"라는 점만 기억해두면 된다.
| 메시지 | 방향 | 역할 | 주기 | 비용 |
|---|---|---|---|---|
| SR | 송신→수신 | 송신 통계 + 시간 매핑 | 주기적 (~5초) | 낮음 |
| RR | 수신→송신 | 수신 품질 (loss, jitter) | 주기적 (~5초) | 낮음 |
| NACK | 수신→송신 | 특정 패킷 재전송 요청 | 즉시 (손실 감지 시) | 중간 |
| PLI | 수신→송신 | 키프레임 재생성 요청 | 즉시 (디코딩 실패 시) | 높음 |
SR/RR은 전체 건강 상태 보고서이고, NACK/PLI는 즉각적인 응급 처치다.
실제 통화에서는 이 네 가지가 동시에 돌아간다. 5초마다 SR/RR이 전체 현황을 보고하고, 그 사이에 패킷이 빠지면 NACK이 즉시 재전송을 요청하고, NACK으로 안 되면 PLI가 키프레임을 요청한다.
P2P 통화에서는 간단하다. A가 B에게 보내고, B가 A에게 RTCP를 돌려보내면 끝이다.
P2P: 깔끔한 1:1 피드백
[A] ---- RTP ----> [B]
[A] <--- RTCP ---- [B]
그런데 SFU가 중간에 있으면 상황이 복잡해진다. A가 SFU에 보내고, SFU가 B, C, D에게 복사 전달한다. B가 "패킷 3개 잃어버렸어"라고 RTCP를 보내면 — 이걸 누가 받아야 하는가? SFU인가, A인가?
SFU: 누가 RTCP를 처리하는가?
[A] ---- RTP ----> [SFU] ---- RTP ----> [B]
| |
| ---- RTP ----> [C]
| |
| ??? <--- RTCP ---|
B의 RR을 A에게 그대로 전달하면 될까? B가 보낸 NACK을 SFU가 직접 처리해야 할까? A가 보낸 SR을 B에게 그대로 릴레이하면 될까?
답은 "그대로 전달하면 안 된다"이다. SFU는 단순 릴레이가 아니라, 두 독립 세션의 종단점이다. 이게 무슨 의미인지, 왜 "그대로 전달"이 문제를 일으키는지 — 다음 편에서 다룬다.
다음 편: [RTCP 완전정복 #2] SFU는 relay가 아니다 — RTCP Terminator 설계
이 글은 OxLens — Rust로 만드는 경량 SFU 서버 프로젝트를 개발하면서 배운 것들을 정리한 시리즈입니다.