결론 먼저 — 미디어 forward의 본질은 한 루프다
| 항목 | 값 |
|---|---|
| 핵심 코드 라인 수 | ~10줄 |
| Track 타입 | TrackLocalStaticRTP |
| 패킷 단위 | RTP 패킷 (재인코딩 없음) |
| 고루틴 수 | 인입 트랙 1개당 1개 |
| 운영 에러의 ~95%가 발생하는 위치 | connection 단계 (forward 코드 자체는 거의 무관) |
“협상은 어렵게, 미디어는 쉽게.” — Pion으로 SFU를 짜본 사람이라면 한 번쯤 떠올리는 한 줄이다.
1. ForwardTrack 루프 — 추상 골격
아래는 Pion 공식 examples를 일반화한 SFU 1방향 forward의 추상 골격이다 (특정 서비스의 운영 코드가 아니라 학습용 형태다).
// 1) 수신측 peerConnection에 새 outgoing track을 추가한다
outTrack, _ := webrtc.NewTrackLocalStaticRTP(
inTrack.Codec().RTPCodecCapability,
"audio", "stream-A",
)
peerB.AddTrack(outTrack)
// 2) 송신측 inTrack에서 RTP를 읽어 outTrack으로 그대로 쓴다
go func() {
defer recoverPanic()
for {
pkt, _, err := inTrack.ReadRTP()
if err != nil { return }
if err := outTrack.WriteRTP(pkt); err != nil { return }
}
}()
핵심은 ReadRTP → WriteRTP 두 함수가 짝지어진 단 하나의 루프다. 인코딩·디코딩·패킷 형식 변환은 SFU의 책임이 아니다. 이게 “Dumb Pipe SFU”라는 이름의 출발점이다.
2. RTP 패킷 한 개의 여정
| 단계 | 주체 | 일어나는 일 |
|---|---|---|
| 1 | 송신 단말 | 오디오 인코딩 → RTP 패킷화 → SRTP 암호화 → ICE 채널로 송신 |
| 2 | SFU(Pion) | ICE/DTLS/SRTP 처리 후 RTP로 표면화 → ReadRTP() 반환 |
| 3 | SFU 코드 | 받은 패킷을 outTrack.WriteRTP()로 그대로 쓴다 |
| 4 | SFU(Pion) | RTP를 다시 SRTP로 묶어 수신측 ICE 채널로 송출 |
| 5 | 수신 단말 | SRTP 풀고 RTP 디패킷 → 디코딩 → 오디오 출력 |
SFU 코드가 직접 책임지는 건 단계 3 하나뿐이다. 1·2·4·5는 모두 Pion 또는 단말 책임이다.
3. 흔히 빠지는 함정 3가지
- ReadRTP 에러 분류 —
io.EOF는 트랙 종료(정상)이고, 그 외 에러는 비정상 종료다. 둘을 구분해서 메트릭과 로그를 분리하지 않으면 “왜 끊겼는지 알 수 없는” 운영이 된다. - panic 격리 — forward 고루틴 안에서 panic이 나면 프로세스 전체가 죽는다.
defer func(){ recover() }()를 표준으로 깔아두는 편이 안전하다. 트래픽이 늘면 단말이 보내는 비정상 RTP 패킷도 늘기 때문에, 이 가드는 결국 한 번은 동작한다. - codec MIME 일치 — outTrack의 MimeType이 inTrack과 다르면 수신측 디코더가 실패한다.
inTrack.Codec().RTPCodecCapability를 그대로 outTrack 생성자에 넘기는 패턴이 가장 안전하다.
4. “끊긴다” 현상이 보일 때 확인 순서
데모를 디버깅하다 “미디어가 끊긴다”는 현상이 보일 때, forward 코드를 의심하는 건 마지막이다. 자료들과 직접 실험에서 반복적으로 확인되는 점은, 원인 대부분이 forward 루프가 아닌 connection 단계에 있다는 것이다. 다음 순서로 좁히면 빠르다.
- 송신측
RTCStatsReport의outbound-rtp.packetsSent가 증가하는가? - 수신측
inbound-rtp.packetsReceived가 증가하는가? - 둘 다 증가하지만 jitter/loss가 높다면 → 네트워크 또는 단말 문제
- 송신은 늘지만 수신이 안 늘면 → SFU 또는 ICE 경로 문제
- 그 다음에야 forward 루프 코드와 panic 로그를 확인한다
개인 메모 — 코드의 양이 운영 부담의 분포를 알려준다
처음 Pion 코드베이스를 열어봤을 때, ICE·DTLS·SRTP 협상 코드의 압도적인 양에 비해 미디어 forward 부분이 너무 가볍다는 게 낯설었다. “이 비대칭이 맞나” 싶었지만, 공식 예제를 며칠 돌려보고 직접 작은 데모를 만들어 보니 자연스러운 분배라는 걸 알게 됐다.
직접 돌려보면서 마주친 에러도 거의 모두 connection 단계에 몰려 있었고, 미디어 자체에서 비롯된 문제는 codec 협상 미스 또는 panic 누락 같은 좁은 카테고리에 한정됐다. 코드의 양이 곧 어려움의 분포를 알려주는 셈이었다 — 라이브러리가 무거운 곳은 본질적으로 어려운 영역이고, 가벼운 곳은 비교적 단순하다는 신호로 읽으면 디버깅 순서가 자연스럽게 잡힌다.
참고
- Pion WebRTC examples — broadcast, simulcast, sfu 등 forward 패턴별 공식 예제
- Pion WebRTC API 문서 — TrackLocalStaticRTP, ReadRTP, WriteRTP 시그니처
- RFC 3550 — RTP: A Transport Protocol for Real-Time Applications
- W3C WebRTC Statistics — getStats() 표준 메트릭 정의
다음 글
이 짧은 forward 루프가 가능한 이유는 “SFU를 Dumb Pipe로 두자”는 결정 때문이었다. MCU와 SFU 사이 어디쯤이 1:1 음성 상담에 적절한가 — 다음 글에서 그 결정의 트레이드오프를 다룬다.
답글 남기기