Voice-Link에서 WebRTC 통화를 안정화하는 방법: LiveKit, TURN/TLS, Redis 품질 타임라인, Spring Boot 이벤트 연동

서론

Voice-Link 같은 서비스에서 실시간 음성 통화는 “WebRTC를 붙였다”로 끝나지 않습니다. 실제 운영에서는 연결 성공률, 재접속 복구, 통화 종료 판단, 품질 저하 원인 추적, 관리자용 진단 화면까지 함께 설계해야 합니다. 특히 모바일망, 사내망, 공용 와이파이처럼 NAT 환경이 제각각인 네트워크에서는 단순 시그널링 성공만으로 통화 품질을 보장할 수 없습니다.

이번 글은 Voice-Link 코드베이스에서 실제로 확인되는 구조만 기준으로 정리합니다. 구체적으로는 application.yml의 LiveKit/Redis/매칭/품질 설정, livekit.yamldocs/turn-stun-flow.md의 TURN/STUN 구성, LiveKitWebhookService의 통화 종료 처리, CallQualityService의 Redis 타임라인 저장, CallEventStreamingService의 SSE 이벤트 전파를 중심으로 봅니다.

반대로 STT, VAD, 녹취, 자막, 음성 이벤트 처리 파이프라인은 이번에 확인한 코드 범위 안에서는 뚜렷한 구현 근거가 보이지 않았습니다. 그래서 이 글은 그런 기술을 일반론으로 길게 설명하지 않고, 현재 Voice-Link에 직접 연결되는 WebRTC 운영 구조에 집중합니다. 실무 기술 블로그라면 범위를 넓게 잡기보다, 실제 코드와 설정으로 설명 가능한 지점만 정확히 다루는 편이 더 유용합니다.

문제상황: 음성 서비스는 “연결”보다 “불안정성 처리”가 더 어렵다

실시간 음성 서비스의 첫 번째 착각은 “시그널링이 붙었으니 통화도 붙는다”는 생각입니다. 하지만 Voice-Link처럼 WebRTC 기반 통화를 운영하면 실제 문제는 그다음부터 시작됩니다.

  • 클라이언트가 LiveKit 시그널링 서버에는 접속했는데, 미디어 경로는 열리지 않는 경우
  • 와이파이에서 LTE로 전환되며 ICE 경로가 바뀌고, 통화는 잠깐 멈췄다가 복구되는 경우
  • 사용자가 앱을 백그라운드로 내리거나 네트워크가 흔들려 participant_left 이벤트가 왔지만, 실제로는 재입장 가능한 경우
  • 통화가 끊겼는데 원인이 TURN 미사용인지, packet loss 급증인지, RTT 폭증인지, 서버 종료인지 구분이 안 되는 경우
  • 통화는 끝났는데 프론트는 종료 이벤트를 못 받아 UI가 남아 있는 경우

이런 문제는 단순한 REST API 백엔드만으로는 처리하기 어렵습니다. 음성 시스템은 최소한 다음 네 가지 층을 함께 다뤄야 합니다.

  • 미디어 연결 층: WebRTC, ICE, STUN, TURN, LiveKit
  • 세션 제어 층: 매칭, 재입장, 종료, 연장, 이벤트 전달
  • 품질 관측 층: RTT, jitter, packet loss, reconnect 횟수, candidate type 추적
  • 운영 대응 층: 로그, 경고, Sentry, 관리자 진단 API

Voice-Link 코드베이스의 의미는 여기에 있습니다. 단순히 LiveKit URL을 던지는 것이 아니라, 통화 중 품질 정보를 세션 단위로 쌓고, LiveKit 웹훅으로 종료를 판정하고, 프론트에는 SSE로 종료/연장 이벤트를 따로 밀어주는 구조가 이미 보입니다.

Voice-Link와의 연결: 현재 구조에서 핵심은 “미디어 경로”와 “서비스 제어 경로”를 분리하는 것이다

Voice-Link의 설정을 보면 src/main/resources/application.yml에 다음 항목들이 잡혀 있습니다.


livekit:
  url: ${LIVEKIT_URL}
  api-url: ${LIVEKIT_API_URL:${LIVEKIT_URL}}
app:
  match:
    timeout-ms: 25000
    active-session-stale-ms: 120000
  call-quality:
    retention-seconds: 86400
    max-snapshots-per-session: 300
spring:
  data:
    redis:
      host: ${REDIS_HOST}
      port: ${REDIS_PORT:6379}

이 설정만 봐도 구조가 분명합니다. 음성 미디어 자체는 LiveKit/WebRTC 쪽으로 보내고, 세션 상태와 품질 데이터는 Spring Boot와 Redis가 잡는 방향입니다. 즉, Voice-Link는 “음성 스트림을 백엔드가 직접 중계하는 구조”라기보다, “실시간 미디어는 LiveKit에 맡기고, 백엔드는 세션 제어와 관측을 담당하는 구조”에 가깝습니다.

이 구조가 중요한 이유는 명확합니다. Spring Boot가 RTP 오디오 패킷까지 직접 다루기 시작하면 확장성과 장애 범위가 급격히 나빠집니다. 반면 Voice-Link처럼 백엔드는 토큰 발급, 세션 상태 관리, 통화 종료 판정, 품질 스냅샷 적재, 관리자 진단 API, 사용자 이벤트 전파에 집중하면 역할이 분리됩니다.

실제로 MatchConnectResponse에는 세션 식별자와 미디어 서버 접속에 필요한 최소 정보가 포함됩니다. 이는 매칭이 완료된 뒤 클라이언트가 직접 미디어 서버에 붙는 모델이라는 뜻입니다. 또 관련 서비스는 사용자와 통화방 기준으로 참여 권한을 결정해 전달합니다. 이 점은 Voice-Link에서 “통화 참여 권한은 Spring이 결정하고, 실제 미디어 전송은 LiveKit이 처리한다”는 연결점을 잘 보여줍니다.

한 줄로 정리하면 이렇습니다. Voice-Link에서 WebRTC는 프론트엔드 기능이 아니라, 매칭 결과를 받은 사용자가 LiveKit 룸에 입장하고, 통화 상태를 Spring Boot가 별도 경로로 제어·관측하는 전체 시스템 설계입니다.

구현방법 1: TURN/TLS를 기본 운영 시나리오로 생각해야 한다

docs/turn-stun-flow.mdlivekit.yaml, infra/coturn/turnserver.conf를 보면 Voice-Link는 STUN만 믿는 구성이 아닙니다. 외부 TURN 서버를 두고, UDP 3478, TCP 3478, TLS 443까지 열어두는 방향입니다.


private static final long PARTICIPANT_LEAVE_GRACE_SECONDS = 8L;

즉, 사용자가 잠깐 네트워크를 잃었다가 재입장하는 상황을 고려해 8초 유예 시간을 둡니다. 그리고 유예 시간 뒤에도 RtcRoomStatePort.hasInsufficientParticipants(roomName)가 참일 때만 실제 종료 처리로 넘어갑니다.

이 로직이 왜 Voice-Link에 필요한가? 모바일 통화에서는 짧은 순간의 네트워크 흔들림이 곧바로 실제 통화 종료를 의미하지 않기 때문입니다. 사용자가 엘리베이터를 타거나 와이파이에서 LTE로 전환될 때 participant_disconnected가 먼저 들어올 수 있습니다. 이때 백엔드가 즉시 통화를 종료해 버리면, 사용자 입장에서는 “다시 붙으려 했는데 방이 사라져 버린” 경험을 하게 됩니다.

반대로 유예 없이 종료하면 운영 데이터도 왜곡됩니다. 실제로는 재연결 가능한 순간 단절인데, 모두 “정상 종료”나 “상대방 이탈”처럼 기록될 수 있습니다. Voice-Link처럼 이후 품질 타임라인과 종료 원인을 함께 보고 싶다면, 종료 판정 자체가 신중해야 합니다.

이 구조는 Spring Boot 백엔드와 음성 시스템 연동의 좋은 예입니다. 미디어 서버인 LiveKit는 이벤트를 발행하고, 애플리케이션 서버는 그 이벤트를 도메인 의미로 번역합니다. 즉 “participant left”를 곧바로 “통화 종료”로 보지 않고, 서비스 정책에 맞게 해석하는 것입니다. 실무에서 중요한 것은 미디어 서버 이벤트를 그대로 믿는 것이 아니라, 서비스 레벨의 종료 조건으로 한 번 더 감싸는 일입니다.

구현방법 3: 통화 품질은 평균값보다 “타임라인”으로 남겨야 한다

Voice-Link의 CallQualityService는 통화 품질 데이터를 Redis 리스트에 세션별 타임라인으로 저장합니다. 이것이 매우 실용적입니다. 장애 분석에서 중요한 것은 “평균 RTT 48ms” 같은 숫자 하나가 아니라, 언제부터 흔들렸고, 끊기기 직전에 어떤 candidate/transport 상태였는지를 재구성할 수 있는 자료입니다.

CallQualitySnapshotRequest에는 다음 같은 필드가 들어갑니다.


jitterMs
packetLossRatio, packetsLost, packetsReceived
roundTripTimeMs
publisherLocalCandidateType, subscriberLocalCandidateType
publisherTransport, subscriberTransport
disconnectReason
currentReconnectDurationMs, lastReconnectDurationMs, totalReconnectDurationMs, reconnectCount

이 구성은 좋습니다. 특히 candidate type과 transport를 같이 받는 점이 중요합니다. 왜냐하면 packet loss가 높았다는 사실만으로는 원인을 알기 어렵지만, 그 시점에 relay 경로를 탔는지, UDP가 아니라 TCP/TLS로 내려앉았는지를 함께 보면 해석이 달라지기 때문입니다.

Voice-Link의 저장 방식도 실무적입니다.


redisTemplate.opsForList().rightPush(key, payload);
redisTemplate.opsForList().trim(key, -properties.getMaxSnapshotsPerSession(), -1);
redisTemplate.expire(key, Duration.ofSeconds(properties.getRetentionSeconds()));

즉, 세션별 리스트에 스냅샷을 추가하고, 최대 개수를 넘으면 앞을 잘라내며, TTL을 걸어 오래된 진단 데이터는 자동 정리합니다. application.yml 기준으로 기본 보관 시간은 86400초, 최대 스냅샷 수는 300개입니다.

왜 Voice-Link에서 이 기술이 필요한가? 실시간 음성 장애는 재현이 어렵기 때문입니다. 고객 문의가 들어왔을 때 “그때 조금 끊겼어요”만으로는 아무 것도 못 합니다. 하지만 세션 단위 타임라인이 남아 있으면 다음처럼 질문할 수 있습니다.

  • 끊기기 전 RTT가 급증했는가?
  • packet loss ratio가 임계값을 넘었는가?
  • reconnect가 몇 번 발생했는가?
  • TURN relay가 실제로 사용되었는가?
  • 최종 disconnect reason이 무엇인가?

이건 단순 로그보다 훨씬 강합니다. 왜냐하면 “연결은 됐는데 음질이 나빴다”는 사용자 경험을 세션 단위 품질 데이터로 바꿔주기 때문입니다.

구현방법 4: 품질 데이터는 저장만 하지 말고 진단 규칙으로 바로 연결해야 한다

Voice-Link는 여기서 한 단계 더 갑니다. CallQualitySessionSummaryResponse를 보면 단순 저장이 아니라 세션 요약과 진단 라벨을 계산합니다. 예를 들어 다음 항목을 만듭니다.


worstQualityStatus

finalConnectionState

finalDisconnectReason

maxReconnectCount

totalReconnectDurationMs

peakJitterMs

peakPacketLossRatio

publisherRelayEverUsed, subscriberRelayEverUsed

그리고 이 데이터를 바탕으로 RECONNECT_EXHAUSTED, NETWORK_SEVERELY_DEGRADED, NETWORK_UNSTABLE, TURN_RELAY_USED, RECOVERED_AFTER_RECONNECT 같은 라벨을 붙입니다. 예를 들어 코드상 기준으로 peakJitterMs > 80 또는 peakPacketLossRatio > 0.05면 심각한 품질 저하로 분류합니다.

이 방식이 중요한 이유는 운영팀이 “원본 스냅샷 200개”를 매번 직접 읽을 수 없기 때문입니다. 서비스 운영에서는 1차 판단이 빨라야 합니다. 세션 요약이 있으면 관리자 화면이나 Sentry 경고에서 바로 “이 세션은 재접속 반복 + 심한 네트워크 저하 + TURN relay 사용” 같은 식으로 해석할 수 있습니다.

CallQualityDiagnosisReporter는 특정 조건에서 WARN 로그를 남기고 Sentry에 태그와 extra 데이터를 붙여 보냅니다. 이 점은 굉장히 현실적입니다. 품질 저하를 “통계 집계만 하는 데이터”로 두지 않고, 실제 운영 경고로 연결하고 있기 때문입니다.

이 구조는 Voice-Link에서 특히 유용합니다. 사용자가 “통화가 자꾸 끊긴다”고 말했을 때, 운영자는 단순 장애 여부가 아니라 아래를 빠르게 볼 수 있습니다.

  • 마지막 연결 상태가 정말 DISCONNECTED였는지
  • 클라이언트 종료인지, 서버/원격 종료인지
  • 재접속 후 회복한 세션인지
  • TURN relay를 탔는데도 품질이 나빴는지

구현방법 5: 미디어 이벤트와 사용자 UI 이벤트는 같은 채널로 처리하지 않는 편이 낫다

Voice-Link의 CallEventStreamingService는 통화 종료나 연장 같은 이벤트를 SseEmitter로 전파합니다. 엔드포인트는 /api/match/call-events이고, 20초마다 heartbeat를 보냅니다. 통화 종료 시에는 callEnded, 연장 시에는 callExtended를 전송합니다.

이 구조는 “왜 WebRTC만으로 다 처리하지 않느냐”는 질문에 대한 좋은 답입니다. 미디어 연결과 사용자 인터페이스 제어는 책임이 다르기 때문입니다. 예를 들어 상대방이 통화를 종료했을 때, 프론트는 단순히 오디오 트랙 종료만 알면 되는 것이 아니라 세션 종료 메시지, 연장 상태 정리, 후속 화면 이동까지 알아야 합니다. 이런 제어 이벤트를 WebRTC 데이터 채널에만 의존하면 클라이언트 연결 상태에 따라 누락되거나 복잡도가 커질 수 있습니다.

Voice-Link처럼 SSE를 별도로 두면 백엔드는 도메인 이벤트를 명시적으로 전달할 수 있습니다. 특히 다음 점이 실무적입니다.

  • 통화가 이미 끝난 세션에 뒤늦게 연결한 사용자에게도 종료 이벤트를 즉시 내려줄 수 있다.
  • heartbeat로 끊어진 emitter를 정리할 수 있다.
  • 미디어 연결 문제와 별개로 UI 상태를 정합하게 맞출 수 있다.

즉, Voice-Link의 현재 구조는 “오디오는 LiveKit로, 서비스 상태 변화는 SSE로”라는 분리 원칙을 보여줍니다. 이것이 운영에서 꽤 중요합니다. 미디어 경로가 흔들리는 순간에도 세션 종료 사실이나 연장 사실은 애플리케이션 레벨에서 별도 통제할 수 있어야 하기 때문입니다.

주의점: 현재 구조에서 특히 조심해야 할 운영 포인트

1. TURN 자격 증명과 설정 노출

TURN 관련 설정은 애플리케이션 코드와 분리해 관리하는 편이 안전합니다. 핵심은 민감한 접속 정보 자체를 글이나 저장소에 드러내지 않고, 운영 환경별로 분리된 방식으로 다루는 것입니다.

2. 종료 이벤트의 중복·경합 처리

room_finished, participant_left, participant_disconnected는 서로 다른 타이밍에 거의 동시에 들어올 수 있습니다. 현재 코드에는 pending termination 취소와 토큰 비교가 들어 있어 기본적인 중복 방어는 보입니다. 다만 운영에서는 웹훅 재전송, 순서 역전, 일시적 상태 불일치가 있기 때문에 idempotency를 계속 의식해야 합니다.

3. Redis 타임라인의 보관 정책

retention-seconds가 1일, max-snapshots-per-session가 300이면 대체로 가볍게 운영할 수 있습니다. 하지만 실제 스냅샷 업로드 주기가 짧아지면 세션 후반부만 남을 수 있습니다. 반대로 너무 길게 보관하면 Redis 메모리 비용이 커집니다. 중요한 것은 “몇 초마다 스냅샷을 보내는가”와 “한 통화 길이가 얼마나 긴가”를 같이 봐서 설정을 맞추는 것입니다.

4. 품질 기준값의 절대화

jitter 80ms, packet loss ratio 5% 같은 기준은 진단 출발점으로는 좋지만, 모든 기기·모든 환경에 절대 기준은 아닙니다. 블루투스 오디오, 저사양 단말, 특정 이동통신 환경에서는 체감 품질과 수치가 다르게 나타날 수 있습니다. 따라서 초기에는 경고 기준을 운영용 휴리스틱으로 쓰고, 실제 문의 데이터와 맞춰 보정하는 것이 좋습니다.

5. 미디어 장애와 서비스 장애를 분리해서 봐야 한다

Voice-Link 구조에서는 LiveKit, TURN, Redis, SSE, Spring API가 모두 통화 경험에 영향을 줍니다. 그런데 사용자 입장에서는 전부 그냥 “통화가 이상했다”로 보입니다. 그래서 장애 대응 시에는 아래처럼 분류해야 합니다.

  • 시그널링 장애: LiveKit URL 접속 문제, 토큰 문제
  • 미디어 장애: ICE 실패, TURN 미도달, packet loss/jitter 급증
  • 세션 제어 장애: 종료 처리 지연, 재입장 정책 오동작
  • UI 이벤트 장애: SSE 미수신, heartbeat 끊김, 종료 화면 미전환

이 분류가 없으면 원인을 잘못 잡게 됩니다. 예를 들어 실제로는 TURN/TLS 경로 문제인데, 프론트 버그로 오판할 수 있습니다.

마무리

Voice-Link에서 중요한 기술은 “WebRTC를 쓴다”는 사실 자체가 아닙니다. 더 중요한 것은 WebRTC를 서비스로 운영하기 위해 어떤 보완 구조를 붙였느냐입니다. 현재 코드베이스를 기준으로 보면, Voice-Link는 LiveKit를 미디어 계층으로 사용하고, TURN/TLS로 연결률을 방어하며, Spring Boot는 토큰 발급·웹훅 처리·세션 종료 정책·SSE 이벤트 전송을 맡고, Redis는 통화 품질 타임라인을 저장하는 형태로 역할을 나누고 있습니다.

이 구조는 실무적으로 타당합니다. 특히 participant_left를 바로 종료로 보지 않는 8초 유예, 세션별 품질 타임라인 저장, relay 사용 여부와 reconnect 이력까지 포함한 진단 요약은 실제 운영에서 도움이 되는 설계입니다. Voice-Link가 앞으로 녹취, 자막, STT, VAD 같은 음성 후처리 기능을 확장하더라도, 그 기반은 결국 이런 연결 안정성·관측성 구조 위에 올라가야 합니다.

요약하면, Voice-Link에서 WebRTC는 단순 통화 기능이 아니라 LiveKit, TURN, Redis, SSE, Spring Boot가 함께 움직이는 운영 시스템입니다. 통화 품질을 높이려면 기능 추가보다 먼저 연결 경로, 종료 정책, 품질 타임라인, 관리자 진단 체계를 정확히 설계하는 것이 우선입니다.

댓글 남기기