완성도(完成島): 회의 기능 구현 - 왜 LiveKit(SFU)을 선택했나

2026. 4. 30. 20:49·개발/프로젝트

스터디, 프로젝트 모임 서비스 완성도에 실시간 화상 회의를 단독으로 설계, 구현했다. WebRTC의 세 가지 구현 방식(P2P Mesh / MCU / SFU) 트레이드오프를 분석하고, SFU 중에서 LiveKit을 고른 이유, 실제 구현, 남은 기술 부채까지 정리한다.

들어가며

완성도는 기존 모집 플랫폼(홀라IT, OKKY 등)이 팀원 모집까지만 다루고 끊기는 한계를 보완해, 모집부터 협업 관리까지 한 서비스 안에서 완결되는 사이클을 목표로 한다.

처음엔 회의를 디스코드로 진행했다. 하지만 외부 화상회의 서비스(Discord, Zoom 등)에 의존하면 두 가지 문제가 있다.

  • 도구가 분산된다: 모집은 완성도에서, 회의는 디스코드에서, 회의 데이터는 또 다른 곳에 흩어진다
  • 회의 데이터가 외부 SaaS에 종속된다: 회의 기록·참가자·시간 같은 데이터를 우리 서비스가 활용할 수 없다

완성도의 "한 서비스 안에서 완결되는 사이클"이라는 비전과 맞지 않았다. 그래서 화상 회의를 서비스 내부에 통합하는 방향을 선제적으로 제안하고 단독 구현을 맡았다.

제약 조건은 명확했다.

  • 제한된 일정: 프로젝트 최종 발표까지 5일, 다른 기능과 병행하면서 회의는 한 명이 책임
  • 단독 구현: 인프라부터 클라이언트 UI까지 전부
  • 안정성 필수: 모임 시작 시간에 끊기면 안 됨

이 세 가지가 이후 모든 기술 선택의 채점 기준이 됐다.

WebRTC가 두 사람을 연결하는 과정

비교에 들어가기 전에 WebRTC가 P2P 연결을 맺는 과정을 먼저 짚어야 한다. 이걸 모르면 다음 섹션의 "P2P Mesh가 왜 빠르게 한계에 부딪히는가"가 와닿지 않는다.

시그널링: 일단 통로부터

WebRTC는 미디어를 P2P로 흘려보내는 기술이다. 그런데 두 브라우저가 처음부터 서로의 존재를 알 리는 없다. "여기 너랑 통화하고 싶은 사람이 있다"를 알려줄 별도 채널이 필요한데, 이게 시그널링(signaling)이다.

시그널링 자체는 WebRTC의 일부가 아니다. HTTP 폴링으로도 되고, WebSocket으로도 되고, Firebase Realtime Database로도 된다. 다만 화상 회의는 양방향·저지연이 필수라 거의 항상 WebSocket을 쓴다. HTTP 폴링은 "1초마다 새 메시지 있어?"를 묻는 방식이라 지연이 생긴다.

SDP 교환: 서로 뭘 보낼 수 있는지 합의

시그널링 통로가 열리면 양쪽은 SDP(Session Description Protocol)라는 텍스트 명세를 주고받는다. SDP에는 이런 정보가 들어 있다.

  • 어떤 비디오 코덱(H.264, VP8, VP9, AV1...)을 지원하는가
  • 오디오 샘플레이트는 얼마인가
  • 어느 미디어 트랙을 송출/수신할 것인가

흐름은 다음과 같다.

  1. Caller(먼저 거는 쪽)가 RTCPeerConnection.createOffer()로 SDP offer 생성
  2. WebSocket으로 offer를 Callee에게 전달
  3. Callee가 setRemoteDescription(offer) → createAnswer()로 SDP answer 생성
  4. WebSocket으로 answer를 다시 Caller에게 전달
  5. Caller가 setRemoteDescription(answer) 호출

이 시점에서 양쪽은 "어떤 미디어를 어떤 코덱으로 주고받을지" 합의한 상태가 된다. 하지만 어떤 IP, 포트로 연결할지는 아직 안 정해졌다.

ICE: 어떻게 만나지

여기서 진짜 어려운 문제가 시작된다. 대부분의 사용자는 공유기, 회사 방화벽, 모바일 캐리어 NAT 뒤에 있어서 자기 공인 IP를 모른다. 상대방도 마찬가지다. 두 사람이 서로의 사설 IP 192.168.0.X를 알아봐야 의미가 없다.

이걸 푸는 메커니즘이 ICE(Interactive Connectivity Establishment)다. ICE는 알고리즘 이름이고, 내부적으로 두 가지 외부 서버를 쓴다.

  • STUN: "지금 너한테 보이는 내 공인 IP가 뭐야?"를 물어보는 가벼운 서버. 대부분의 NAT 환경은 이걸로 해결된다.
  • TURN: STUN으로도 안 뚫리는 환경(대칭형 NAT, 엄격한 회사 방화벽)을 위한 중계 서버. 양쪽이 직접 연결할 수 없을 때 TURN이 미디어를 받아서 다시 흘려준다. 사실상 더 이상 P2P가 아니다. 대역폭·비용이 TURN 운영자 몫이다.

각 클라이언트는 가능한 모든 후보(사설 IP, STUN으로 알아낸 공인 IP, TURN 중계 IP)를 ICE candidate로 만들어 시그널링 채널로 상대방에게 던진다. 양쪽은 받은 후보들로 연결성 체크를 돌려서 통하는 경로 중 가장 빠른 걸 고른다.

이 과정이 끝나야 비로소 두 브라우저 사이에 미디어가 흐른다.

정리

기억해둘 건 하나. 이 과정은 "한 쌍"마다 처음부터 끝까지 다시 해야 한다. 4명이 한 방에 모이면 6쌍이고, 각 쌍이 위 과정을 독립적으로 거친다.

P2P Mesh vs MCU vs SFU

같은 화상 회의라도 누가 미디어를 받고 누가 보내는지의 토폴로지에 따라 세 가지로 갈린다.

P2P Mesh: 전부 직접 연결

가장 단순하고 가장 빨리 무너지는 방식이다. 참여자 N명이 서로 전부 직접 연결을 맺는다. 연결 수는 N × (N-1) / 2.

참여자 연결 수 클라이언트가 송출하는 스트림 수
4명 6개 3개
8명 28개 7개
12명 66개 11개

문제는 각 클라이언트가 자기 카메라 영상을 N-1번 별도로 인코딩해서 N-1명에게 보낸다는 점이다. CPU, 업로드 대역폭, 배터리가 인원 수에 비례해서 깎인다. 모바일에서 4명만 모여도 폰이 뜨거워진다. 시그널링 서버 부담은 거의 없지만, 클라이언트가 죽는다.

MCU: 서버가 다 받아서 섞어서 다시 송출

MCU(Multi-point Control Unit)는 정반대 방식이다. 서버가 모든 참여자의 스트림을 받아서 하나의 합성 영상으로 믹싱한 뒤, 각 클라이언트에 단일 스트림으로 보낸다.

  • 서버: N개 디코딩 → 믹싱 → 다시 인코딩 → N명에게 송출
  • 클라이언트: 단 하나의 스트림만 디코딩 (저사양 디바이스에 유리)
  • 비용: 실시간 트랜스코딩이 비싸다. GPU 인스턴스 청구서가 무겁고, 합성된 영상이라 클라이언트에서 레이아웃을 바꿀 수도 없다

전화 회의 시스템, 구형 화상 회의 장비에서 쓰던 방식인데, 웹/모바일에서는 거의 죽은 선택지다.

SFU: 서버는 라우터

SFU(Selective Forwarding Unit)가 P2P와 MCU의 중간이다. 서버는 모든 참여자의 스트림을 받기만 하고, 각 참여자에게 필요한 스트림만 그대로 포워드한다. 디코딩·믹싱·재인코딩을 안 하니까 서버 CPU 부하가 MCU와 비교가 안 되게 가볍다.

  • 클라이언트 송출: 자기 영상 1개만 (서버에 한 번)
  • 클라이언트 수신: 봐야 하는 peer 수만큼 (구독 단위)
  • 서버 작업: 패킷 라우팅만, 디코딩 없음

추가로 SFU는 simulcast/SVC(Scalable Video Coding)를 활용해 한 명이 서버에 여러 화질 레이어를 동시에 올리고, 서버가 수신자 환경에 맞춰 적절한 레이어만 골라 보내는 최적화도 가능하다. 화면 작은 썸네일에 4K를 보내지 않는다.

비교

참여자 P2P 연결 수 MCU 비용 SFU 적합도
4명 6개 저 👍🏻👍🏻👍🏻
8명 28개 중 👍🏻👍🏻👍🏻👍🏻
12명 66개 고 👍🏻👍🏻👍🏻👍🏻👍🏻

완성도의 모임 규모는 대개 4~10명이다. 이 구간에서 P2P Mesh는 클라이언트가 못 버티고, MCU는 비용이 안 맞는다. SFU가 사실상 유일한 합리적 선택이었다.

SFU 중에서 왜 LiveKit인가

SFU로 좁힌 뒤에도 후보는 여러 개였다.

  • mediasoup (Node.js): 직접 SFU를 굴리는 오픈소스 라이브러리, 자유도 최고, 운영 부담 최고
  • Janus: C 기반 범용 WebRTC 게이트웨이, 강력하지만 학습 곡선이 가파르다
  • LiveKit: Go 기반 오픈소스 SFU + 매니지드 클라우드 + 풍부한 SDK

세 후보를 제약 조건(제한된 일정, 단독 구현, 안정성)에 대고 채점했다.

항목 mediasoup Janus LiveKit Cloud
인프라 운영 부담 높음 높음 O
React SDK 완성도 직접 작성 부분 공식 SDK
단독 구현 적합성 🥲 😭 👍🏻
셀프 호스팅 마이그레이션 가능성 ✔️ ✔️ ✔️ (오픈 소스)

LiveKit을 고른 이유는 네 가지다.

  1. Cloud 호스팅으로 인프라 시간을 0으로: STUN·TURN·SFU 서버를 직접 굴리지 않는다. 환경변수(LIVEKIT_URL) 하나 설정하면 된다.
  2. @livekit/components-react로 UI 70%가 끝남: LiveKitRoom, GridLayout, ControlBar, RoomAudioRenderer만 조합해도 회의실이 굴러간다. 단독 구현 일정에 가장 큰 가속도였다.
  3. 프로덕션 검증: Figma, Descript 등 큰 서비스에서 운영 중, 안정성 리스크가 낮다.
  4. 출구 전략: LiveKit Server가 오픈소스(Go)라서 나중에 비용, 커스터마이징 이슈가 생기면 셀프호스팅으로 마이그레이션 가능, 의사결정이 가역적이다.

실제 구현 파이프라인

토큰 발급 BFF: 시크릿을 클라이언트에 노출하지 않기

LiveKit은 클라이언트가 룸에 join할 때 JWT 액세스 토큰을 요구한다. 이 토큰은 LIVEKIT_API_KEY/LIVEKIT_API_SECRET으로 서명되는데, 시크릿을 클라이언트에 노출하면 안 된다. 그래서 Next.js Route Handler로 BFF 엔드포인트를 만들어 서버 측에서 발급한다.

export const GET = withBffErrorHandling(async (req: NextRequest) => {
  const room = req.nextUrl.searchParams.get('room');

  // 1. 룸 이름 형식 검증 — 임의 룸 발급 차단
  const roomPattern = /^gathering-\d+$/;
  if (!roomPattern.test(room)) {
    return NextResponse.json({ error: 'Invalid room format' }, { status: 400 });
  }

  // 2. 멤버십 + 인증 병렬 검증
  const gatheringId = parseInt(room.split('-')[1], 10);
  const [memberResponse, userResponse] = await Promise.all([
    requestBackend({ request: req, endpoint: `v1/gatherings/${gatheringId}/members`, ... }),
    requestBackend({ request: req, endpoint: 'v1/users/me', ... }),
  ]);
  // ... 권한 체크 생략

  // 3. AccessToken 생성 + roomJoin grant
  const at = new AccessToken(apiKey, apiSecret, {
    identity: `user-${userId}`,
    name: nickname,
    ttl: '1h',
  });
  at.addGrant({ roomJoin: true, room: room });

  return NextResponse.json({ token: await at.toJwt() });
}, 'GET /api/livekit/token');

세 가지 가드를 넣었다.

  • 룸 이름 정규식: ^gathering-\d+$ 패턴으로 임의의 룸 발급을 차단, 누가 room=admin-only 같은 걸 끼워 넣어도 통과 못한다
  • 멤버십 검증: 해당 모임의 멤버여야만 토큰을 받는다. Promise.all로 인증 검증과 병렬화해서 레이턴시 손해를 최소화
  • TTL 1시간: 토큰이 영원히 살지 않는다. 회의 한 세션 단위

전체 핸들러를 withBffErrorHandling HOF로 감싸서 발급 실패 시 자동으로 Sentry에 에러가 기록되게 했다. 토큰 발급은 회의 기능의 first hop이라 여기가 죽으면 회의 자체가 안 열린다.

회의실 UI — LiveKitRoom + 커스텀 헤더/푸터

토큰을 받은 클라이언트는 <LiveKitRoom>으로 룸에 connect한다.

<LiveKitRoom
  audio={false}
  video={false}
  token={token}
  serverUrl={serverUrl}
  connectOptions={{ autoSubscribe: true }}
  options={{
    adaptiveStream: true,
    dynacast: true,
    audioCaptureDefaults: {
      autoGainControl: true,
      echoCancellation: true,
      noiseSuppression: true,
      voiceIsolation: true,
    },
  }}
  onDisconnected={onDisconnected}
>
  <MeetingGrid />
  <ControlBar variation='minimal' controls={{ camera: true, microphone: true, screenShare: true }} />
  <RoomAudioRenderer />
</LiveKitRoom>

옵션 두 개가 핵심이다.

  • adaptiveStream: true: 수신자의 뷰포트 크기·대역폭을 보고 자동으로 화질을 낮춘다. 썸네일에는 저화질, 풀 화면에는 고화질
  • dynacast: true: 아무도 구독하지 않는 화질 레이어는 송출 자체를 멈춘다. 8명이 모여도 모든 사람이 모든 사람의 4K를 받을 일은 없으니까, 안 보는 레이어는 인코딩·송출 둘 다 스킵한다. 클라이언트·서버 양쪽 부하를 동시에 줄인다

비디오 그리드는 별도 컴포넌트로 분리했다. LiveKit의 useTracks 훅이 카메라 트랙과 화면공유 트랙을 한 번에 구독해줘서 레이아웃 코드가 거의 안 든다.

export function MeetingGrid() {
  const tracks = useTracks([
    { source: Track.Source.Camera, withPlaceholder: true },
    { source: Track.Source.ScreenShare, withPlaceholder: false },
  ]);

  return (
    <GridLayout tracks={tracks} style={{ height: '100%' }}>
      <ParticipantTile />
    </GridLayout>
  );
}

Supabase Realtime Presence: "회의 참가 전"의 라이브 참여자

여기가 가장 신경 쓴 부분이다. 사용자 시나리오는 이렇다.

A가 회의실 로비에 들어왔다. 로비에서 지금 회의에 누가 들어가 있는지를 보고 결정해야 한다.

LiveKit의 participants API는 토큰으로 룸에 join한 이후부터 보인다. LiveKit만으로는 "참가 전"의 라이브 상태를 알 수 없다. 그래서 회의 채널은 LiveKit으로 굴리되, 존재 여부 추적은 Supabase Realtime Presence를 별도 채널로 사용했다.

이 훅은 세 가지 케이스를 해결해야 했다.

Stale closure 방지: useRef로 최신 상태 참조

const isJoinedRef = useRef(isJoined);

useEffect(() => {
  isJoinedRef.current = isJoined;
}, [isJoined]);

.subscribe(async (status) => {
  if (status === 'SUBSCRIBED') {
    if (isJoinedRef.current) {  // state가 아니라 ref를 본다
      await channel.track({ userId, nickname, onlineAt: new Date().toISOString() });
    }
  }
});

subscribe 콜백은 비동기로 실행되는데, 이때 React 클로저가 캡처한 isJoined는 콜백이 만들어진 시점의 옛 값이다. 사용자가 그 사이에 회의를 나갔다 들어오면 isJoined가 바뀌어도 클로저는 그걸 모른다. useRef의 .current는 클로저에 갇히지 않으니까 ref를 통해 항상 최신 상태를 본다.

토글 폭주 방지: debounced track

const timeout = setTimeout(updateTrackStatus, PRESENCE_TRACK_DELAY_MS); // 100ms
return () => clearTimeout(timeout);

isJoined가 빠르게 토글될 때(예: 사용자가 입장 버튼을 빠르게 두 번 누름) cleanup의 clearTimeout이 이전 setTimeout을 취소한다. 마지막 토글 후 100ms 안정화된 시점에 한 번만 track/untrack이 호출되므로 Supabase 채널에 race condition이 생기지 않는다.

남은 기술 부채와 다음 단계

LiveKit을 골랐기 때문에 받아들인 트레이드오프가 있다.

SDK 의존으로 인한 자유도 제한

  • STT 통합은 Egress 기반 후속 작업으로: AutoMinutes의 STT 파이프라인(Whisper + Gemini) 통합 시 LiveKit Egress API로 audio-only 추출이 가능하다. Egress 사용량에 비례한 비용과 webhook·저장소 관리가 새로 들어오지만, 자체 SFU(mediasoup 등)를 굴리면 raw 트랙 직접 접근으로 더 유연하더라도 인프라 운영 부담이 훨씬 커서 현 규모에서는 Egress가 합리적이다. 다만 도입 시점은 사용자 기반 확보 후로 보고 있다
  • 레이아웃 커스터마이징: 기본 컴포넌트에서 벗어나려면 LiveKit의 hook을 직접 조립해야 한다. 가능하지만 학습이 필요하다

다음 단계 — Native WebRTC P2P 비교 (검토 중)

"왜 SFU가 맞았는가"를 수치로 입증하려면 동일 조건에서 Native WebRTC P2P를 직접 구현해 CPU, 메모리, 대역폭을 비교해야 한다. 다만 시그널링 서버, STUN/TURN 인프라까지 직접 셋업하는 학습 비용이 크다.

현재는 팀원 6명과 완성도 개선 작업을 진행 중이다. SEO 세팅(글로벌/동적 메타데이터, sitemap, robots, JSON-LD)을 마쳤고, GA와 Beusable로 데이터 인프라를 구축해 수집되는 지표를 기반으로 디자이너와 함께 사용성 테스트, AB 테스트 전략을 수립하는 단계다.

사용자가 모이기 전까지는 백엔드 개발자와 협업해 현재 구조의 부채를 정리할 예정이다. 일부 쿼리만 staleTime이 설정되어 나머지가 글로벌 1분 후 재페치되며 ISR 효율이 무력화되는 문제는 프론트 단독으로 해결 가능하지만, 내 모임 섹션처럼 유저별 데이터를 모임별 멤버 API 병렬 호출(useSuspenseQueries)로 조립하는 구조는 응답 시간이 모임 수에 비례해 늘어나고 한 호출 실패가 섹션 전체 ErrorBoundary로 전파된다. 호출 횟수 자체를 줄이려면 백엔드 API 설계 변경이 필요해, "로그인 사용자가 가입한 모임 목록을 단일 응답으로 반환하는 API" 신규 추가를 백엔드 개발자에게 제안할 계획이다.

이 개선 사이클이 안정화되는 대로 P2P 비교 사이드 프로젝트에 착수할 예정이다.

의사결정의 가역성

LiveKit Server가 오픈소스라는 점이 안전망이다. 비용이나 커스터마이징 한계가 부딪히면 셀프호스팅 LiveKit으로 옮기거나, 그래도 안 되면 mediasoup으로 갈아탈 수 있다. STT 파이프라인 통합이 본격화되는 시점에 이 결정을 다시 한 번 점검할 예정이다. 이를 계기로 백엔드 공부의 시작점이 되서 언젠가 풀스택 개발자가 되고 싶다. (지금은 프론트 잘하는 것도 어렵다..)

'개발 > 프로젝트' 카테고리의 다른 글

AutoMeeting: 매일 20분 걸리던 회의 기록을 0으로 만든 AI 파이프라인  (0) 2026.04.30
AutoMeeting: 매일 쓰는 팀회의 STT, 왜 Cloud API 대신 로컬 Whisper를 골랐을까?  (0) 2026.04.30
완성도(完成島): 페어프로그래밍으로 만든 GatheringCard  (0) 2026.03.30
완성도(完成島): Toss에서는 이렇게 쓰려나? Modal 설계  (0) 2026.03.19
'개발/프로젝트' 카테고리의 다른 글
  • AutoMeeting: 매일 20분 걸리던 회의 기록을 0으로 만든 AI 파이프라인
  • AutoMeeting: 매일 쓰는 팀회의 STT, 왜 Cloud API 대신 로컬 Whisper를 골랐을까?
  • 완성도(完成島): 페어프로그래밍으로 만든 GatheringCard
  • 완성도(完成島): Toss에서는 이렇게 쓰려나? Modal 설계
최권진
최권진
나의 개발 성장 일지
  • 최권진
    kwonjin's
    최권진
  • 전체
    오늘
    어제
    • 분류 전체보기 (37)
      • 개발 (36)
        • 프로젝트 (5)
        • 프로젝트 회고 (3)
        • computerScience (6)
        • 취준 회고 (2)
      • 다짐 (1)
  • 링크

    • 깃허브
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
최권진
완성도(完成島): 회의 기능 구현 - 왜 LiveKit(SFU)을 선택했나
상단으로

티스토리툴바