현대의 디지털 환경은 즉각적인 상호작용을 요구합니다. 사용자는 지연 없는 화상 회의, 끊김 없는 라이브 스트리밍, 실시간 멀티플레이어 게임을 당연하게 기대합니다. 이러한 요구사항을 충족시키기 위해 개발자들은 복잡하고 까다로운 실시간 통신(Real-Time Communication, RTC) 기술의 세계를 탐색해야 합니다. 이 분야에서 가장 지배적인 두 가지 기술, WebRTC와 gRPC는 각기 다른 영역에서 강력한 솔루션을 제공합니다. WebRTC는 브라우저 기반의 피어 투 피어(Peer-to-Peer, P2P) 미디어 통신에 독보적인 위치를 차지하고 있으며, gRPC는 고성능 마이크로서비스 통신을 위한 표준으로 자리 잡았습니다.
언뜻 보기에 이 두 기술은 서로 다른 문제 공간을 해결하는 것처럼 보일 수 있습니다. 하나는 브라우저 간의 미디어 스트리밍에, 다른 하나는 백엔드 서비스 간의 원격 프로시저 호출(Remote Procedure Call, RPC)에 중점을 둡니다. 하지만 이 두 기술을 결합했을 때 발생하는 시너지는 단순한 합을 넘어섭니다. gRPC의 강력한 성능, 명확한 API 계약, 양방향 스트리밍 기능을 WebRTC의 시그널링(Signaling) 프로세스에 통합함으로써, 우리는 기존의 방식을 뛰어넘는 견고하고 확장 가능하며 효율적인 실시간 통신 아키텍처를 설계할 수 있습니다. 이 글에서는 WebRTC와 gRPC의 핵심 원리를 깊이 있게 파고들고, 이 둘을 결합하여 현대적인 실시간 애플리케이션을 구축하는 구체적인 방법과 아키텍처 패턴, 그리고 그 과정에서 마주할 수 있는 기술적 고려사항들을 심도 있게 탐구할 것입니다.
1. WebRTC의 본질: 브라우저 속 실시간 통신 엔진
WebRTC(Web Real-Time Communication)는 별도의 플러그인이나 소프트웨어 설치 없이 웹 브라우저만으로 음성, 영상, 데이터를 실시간으로 주고받을 수 있도록 하는 오픈 소스 프로젝트이자 W3C와 IETF의 표준 기술입니다. 그 핵심은 P2P 통신에 있으며, 이는 중앙 서버를 거치지 않고 클라이언트(피어)들이 직접 연결하여 데이터를 교환하는 방식을 의미합니다. 이로 인해 지연 시간이 최소화되고 서버 부하가 줄어드는 큰 이점을 가집니다.
1.1. WebRTC의 핵심 구성 요소
WebRTC의 마법은 여러 API와 프로토콜의 정교한 조합으로 이루어집니다. 주요 구성 요소를 이해하는 것은 전체 아키텍처를 파악하는 첫걸음입니다.
-
RTCPeerConnection: WebRTC의 심장부입니다. 두 피어 간의 연결을 생성하고 관리하는 역할을 합니다. 이 객체를 통해 미디어 스트림을 추가하고, 연결 상태를 모니터링하며, 시그널링 과정에서 생성된 정보를 교환합니다. 효율적인 연결을 설정하고 유지하며, 데이터 암호화(DTLS-SRTP)와 대역폭 관리까지 책임지는 복합적인 인터페이스입니다. -
getUserMedia: 사용자의 카메라나 마이크와 같은 미디어 장치에 접근하여 오디오 및 비디오 스트림(MediaStream)을 가져오는 API입니다. 이렇게 얻어진 미디어 스트림을RTCPeerConnection에 추가함으로써 상대방에게 전송할 수 있습니다. -
RTCDataChannel: 미디어 스트림 외에 임의의 데이터를 P2P로 전송할 수 있는 채널을 제공합니다. 이는 파일 전송, 채팅 메시지, 게임 상태 동기화 등 다양한 용도로 활용될 수 있습니다. TCP와 유사한 신뢰성 있는 전송(SCTP 기반)과 UDP와 유사한 비신뢰적이지만 빠른 전송을 모두 지원하여 애플리케이션의 요구에 맞게 유연하게 설정할 수 있습니다.
1.2. 연결의 역설: 시그널링(Signaling)의 필요성
WebRTC는 P2P 통신을 지향하지만, 역설적이게도 두 피어가 서로를 '발견'하고 연결을 '협상'하기 위해서는 중앙의 서버가 반드시 필요합니다. P2P 연결을 시작하기 전에, 피어들은 다음과 같은 메타데이터를 교환해야만 합니다.
- 세션 제어 메시지: 통신을 시작하거나, 종료하거나, 에러를 처리하는 데 사용됩니다.
- 네트워크 구성 정보: 각 피어의 IP 주소와 포트 정보 등 네트워크 상에서 서로를 찾기 위한 정보입니다.
- 미디어 정보: 전송하려는 오디오/비디오의 코덱, 해상도, 비트레이트 등 미디어 스트림의 세부 사양입니다.
이러한 메타데이터를 교환하는 과정을 시그널링(Signaling)이라고 부릅니다. 중요한 점은, WebRTC 표준은 시그널링을 어떻게 구현해야 하는지에 대해 전혀 정의하지 않는다는 사실입니다. 개발자는 WebSocket, HTTP Long-polling, 혹은 다른 어떤 통신 메커니즘이든 자유롭게 선택하여 시그널링 서버를 구축해야 합니다. 바로 이 지점에서 gRPC가 강력한 대안으로 부상하게 됩니다.
1.3. NAT 통과와 ICE 프레임워크
현실 세계의 대부분의 장치는 공유기(NAT)나 방화벽 뒤에 위치하여 공인 IP 주소를 직접 갖지 않습니다. 이로 인해 P2P 연결을 맺는 데 큰 어려움이 따릅니다. WebRTC는 이 문제를 해결하기 위해 ICE(Interactive Connectivity Establishment)라는 프레임워크를 사용합니다. ICE는 다음과 같은 기술들을 조합하여 최적의 통신 경로를 찾아냅니다.
- STUN (Session Traversal Utilities for NAT): 피어가 자신의 공인 IP 주소와 포트를 알아내도록 돕는 서버입니다. 공유기 외부에서 자신을 어떻게 볼 수 있는지 알려주는 '거울' 역할을 합니다.
- TURN (Traversal Using Relays around NAT): 두 피어 간의 직접 연결이 방화벽 정책 등으로 인해 불가능할 경우, 모든 트래픽을 중계해주는 릴레이 서버입니다. 이는 최후의 수단으로 사용되며, 서버의 대역폭을 소모하므로 비용이 발생할 수 있습니다.
ICE 프로세스는 각 피어가 연결 가능한 모든 네트워크 경로(로컬 IP, STUN을 통해 얻은 공인 IP, TURN 서버를 통한 릴레이 주소 등)를 ICE 후보(ICE Candidate)로 수집하고, 이를 시그널링 서버를 통해 상대방과 교환합니다. 이후 양측은 수신한 후보들을 대상으로 연결 테스트(Connectivity Checks)를 수행하여 가장 효율적인 경로를 찾아내어 최종적인 P2P 연결을 확립합니다.
2. gRPC의 힘: 고성능 마이크로서비스 통신
gRPC(Google Remote Procedure Call)는 구글이 개발한 고성능 오픈 소스 RPC 프레임워크입니다. 마이크로서비스 아키텍처가 보편화되면서 서비스 간의 효율적이고 안정적인 통신이 중요해졌고, gRPC는 이러한 요구에 부응하기 위해 탄생했습니다. 전통적인 REST API가 JSON과 HTTP/1.1에 기반하는 것과 달리, gRPC는 더 낮은 수준에서 통신을 최적화하여 압도적인 성능을 제공합니다.
2.1. gRPC의 핵심 철학: 프로토콜 버퍼와 HTTP/2
gRPC의 강력함은 두 가지 핵심 기술에서 비롯됩니다.
-
프로토콜 버퍼 (Protocol Buffers, Protobuf): 구글이 개발한 언어 및 플랫폼 중립적인 데이터 직렬화 메커니즘입니다. 개발자는
.proto라는 간단한 형식의 파일에 데이터 구조(메시지)와 서비스 인터페이스(API)를 정의합니다. 이.proto파일을 프로토콜 버퍼 컴파일러(protoc)로 컴파일하면, C++, Java, Python, Go, JavaScript 등 다양한 언어에 대한 클라이언트 및 서버 코드가 자동으로 생성됩니다.
프로토콜 버퍼는 텍스트 기반의 JSON이나 XML과 달리, 데이터를 매우 압축된 바이너리 형식으로 직렬화합니다. 이로 인해 메시지 크기가 현저히 작아지고, 파싱 속도가 매우 빨라져 네트워크 대역폭과 CPU 사용량을 크게 절약할 수 있습니다. 또한, 엄격한 스키마 정의를 통해 데이터의 유효성을 보장하고 API의 하위 호환성을 관리하기 용이합니다. -
HTTP/2: gRPC는 통신 프로토콜로 HTTP/2를 사용합니다. HTTP/1.1의 여러 한계를 극복한 HTTP/2는 다음과 같은 강력한 기능을 제공합니다.
- 스트림 멀티플렉싱(Stream Multiplexing): 하나의 TCP 연결 내에서 여러 개의 독립적인 요청과 응답 스트림을 동시에 처리할 수 있습니다. 이로 인해 Head-of-line blocking 문제가 해결되고, 여러 요청을 병렬로 처리하여 지연 시간을 크게 줄입니다.
- 양방향 스트리밍(Bidirectional Streaming): 클라이언트와 서버가 하나의 연결 위에서 동시에 서로에게 독립적으로 데이터를 스트리밍할 수 있습니다. 이는 클라이언트 스트리밍, 서버 스트리밍, 그리고 양방향 스트리밍이라는 gRPC의 유연한 통신 모델을 가능하게 합니다.
- 헤더 압축(Header Compression): HPACK 압축 알고리즘을 사용하여 중복되는 HTTP 헤더 정보를 효과적으로 제거함으로써 전송 오버헤드를 줄입니다.
- 서버 푸시(Server Push): 클라이언트가 요청하지 않은 리소스를 서버가 미리 푸시하여 응답 시간을 단축시킬 수 있습니다.
2.2. gRPC의 네 가지 통신 방식
HTTP/2의 강력한 스트리밍 기능을 바탕으로, gRPC는 네 가지 유형의 서비스 메서드를 제공하여 다양한 상호작용 시나리오에 대응합니다.
- 단항(Unary) RPC: 가장 기본적인 RPC 형태로, 클라이언트가 단일 요청을 보내고 서버가 단일 응답을 반환합니다. 전통적인 함수 호출과 유사합니다.
- 서버 스트리밍(Server streaming) RPC: 클라이언트가 단일 요청을 보내면, 서버가 여러 개의 메시지를 순차적으로 스트리밍하여 응답합니다. 대규모 데이터셋을 클라이언트에게 점진적으로 전송하는 데 유용합니다.
- 클라이언트 스트리밍(Client streaming) RPC: 클라이언트가 여러 개의 메시지를 순차적으로 스트리밍하여 서버에 전송하고, 모든 메시지 전송이 완료되면 서버가 단일 응답을 반환합니다. 대용량 파일 업로드나 실시간 로그 전송 등에 사용될 수 있습니다.
- 양방향 스트리밍(Bidirectional streaming) RPC: 클라이언트와 서버가 독립적으로 메시지 스트림을 주고받습니다. 연결이 수립된 후, 양측은 원하는 시점에 원하는 만큼 메시지를 보낼 수 있습니다. 이 방식은 실시간 채팅, 지속적인 상태 동기화 등 연속적인 양방향 통신이 필요한 시나리오에 이상적이며, 바로 이 점이 WebRTC 시그널링에 gRPC를 적용하는 핵심적인 이유가 됩니다.
3. 시너지의 발현: 왜 WebRTC 시그널링에 gRPC를 사용하는가?
앞서 언급했듯이 WebRTC는 시그널링 프로토콜을 정의하지 않습니다. 전통적으로 개발자들은 이 역할을 위해 WebSocket을 주로 사용해왔습니다. WebSocket은 양방향 통신을 제공하며 널리 지원된다는 장점이 있지만, gRPC를 시그널링 계층으로 채택할 때 얻을 수 있는 이점은 훨씬 더 강력하고 구조적입니다.
3.1. 성능과 효율성: Protobuf vs. JSON
WebSocket 통신은 대부분 텍스트 기반의 JSON 페이로드를 사용합니다. 반면 gRPC는 바이너리 기반의 프로토콜 버퍼를 사용합니다. WebRTC 시그널링 과정에서는 SDP(Session Description Protocol) 정보와 여러 개의 ICE 후보를 교환해야 하는데, 이 데이터는 텍스트 형식으로 표현하면 상당히 길어질 수 있습니다. 프로토콜 버퍼는 이 데이터를 훨씬 더 작고 효율적인 바이너리 형식으로 인코딩합니다.
이 차이는 연결 설정 시간에 직접적인 영향을 미칩니다. 더 작은 페이로드는 네트워크를 통해 더 빨리 전송되고, 바이너리 파싱은 JSON 파싱보다 CPU 부담이 적습니다. 특히 모바일 환경이나 네트워크 상태가 좋지 않은 곳에서는 이러한 미세한 차이가 쌓여 사용자가 체감하는 연결 속도를 크게 개선할 수 있습니다.
3.2. 강력한 타입 시스템과 API 계약
WebSocket은 메시지 형식에 대한 어떠한 제약도 두지 않습니다. 개발자는 JSON 스키마를 정의하고 검증하는 로직을 직접 구현해야 하며, 클라이언트와 서버 간의 메시지 형식이 맞지 않아 발생하는 런타임 오류에 취약합니다.
반면 gRPC는 .proto 파일을 통해 서비스와 메시지 구조를 엄격하게 정의합니다. 이 파일은 클라이언트와 서버 간의 'API 계약' 역할을 합니다. 시그널링에 필요한 모든 메시지(예: `Offer`, `Answer`, `IceCandidate`, `JoinRoomRequest`)를 프로토콜 버퍼 메시지로 명확하게 정의할 수 있습니다. 컴파일 시점에 타입 체크가 이루어지므로, 데이터 필드의 이름이 틀리거나 타입이 맞지 않는 등의 실수를 사전에 방지할 수 있습니다. 이는 특히 여러 팀이 협업하거나 클라이언트(웹, 모바일)와 서버의 개발 주기가 다른 대규모 프로젝트에서 엄청난 안정성을 제공합니다.
3.3. 양방향 스트리밍의 완벽한 조화
WebRTC 시그널링은 본질적으로 비동기적이고 양방향적인 이벤트의 연속입니다.
- Alice가 방에 입장한다.
- Bob이 방에 입장한다. 서버는 Alice에게 Bob이 입장했음을 알린다.
- Alice가 Bob에게 `Offer`를 보낸다.
- Bob이 `Offer`를 받고 `Answer`를 보낸다.
- 그 동안 Alice와 Bob은 각자 발견한 `ICE Candidate`를 계속해서 서로에게 보낸다.
이러한 상호작용은 gRPC의 양방향 스트리밍 모델과 완벽하게 일치합니다. 클라이언트와 서버는 하나의 스트리밍 RPC 연결을 열어두고, 필요할 때마다 `Offer`, `Answer`, `IceCandidate` 등의 메시지를 비동기적으로 주고받을 수 있습니다. 이는 요청-응답 모델을 억지로 적용하거나 WebSocket 위에서 복잡한 상태 관리를 하는 것보다 훨씬 더 자연스럽고 효율적인 모델입니다.
3.4. 폴리글랏(Polyglot) 환경의 이점
gRPC는 다양한 프로그래밍 언어를 지원하며, .proto 파일 하나로 모든 언어에 대한 코드를 생성할 수 있습니다. 시그널링 서버는 고성능이 요구되므로 Go나 Rust로 작성하고, 웹 클라이언트는 JavaScript/TypeScript, 모바일 클라이언트는 Swift/Kotlin, 그리고 백엔드의 다른 서비스는 Java나 Python으로 작성하는 폴리글랏 마이크로서비스 환경을 손쉽게 구축할 수 있습니다. 모든 컴포넌트가 동일한 API 정의를 공유하므로 상호 운용성이 보장됩니다.
4. 아키텍처 설계: gRPC 기반 WebRTC 시그널링 서버 구축
이제 이론을 바탕으로 실제 gRPC를 사용한 WebRTC 시그널링 시스템을 어떻게 설계하고 구현하는지 단계별로 살펴보겠습니다. 이 과정은 크게 세 부분으로 나뉩니다: Protobuf 정의, gRPC 서버 구현, 그리고 웹 클라이언트 연동.
4.1. 1단계: .proto 파일로 시그널링 프로토콜 정의하기
가장 먼저 할 일은 시그널링에 필요한 모든 데이터 구조와 서비스 인터페이스를 .proto 파일로 정의하는 것입니다. 이는 전체 시스템의 청사진이 됩니다.
syntax = "proto3";
package signaling;
option go_package = "github.com/my-project/signaling";
// 시그널링 서비스 정의
service Signaling {
// 양방향 스트림을 통해 시그널링 메시지를 교환합니다.
// 클라이언트는 연결을 시작하고, 서버와 클라이언트는 이 스트림을 통해 메시지를 주고받습니다.
rpc Signal(stream SignalRequest) returns (stream SignalResponse);
}
// 클라이언트가 서버로 보내는 요청 메시지
message SignalRequest {
// 각 요청은 고유 ID를 가질 수 있습니다.
string id = 1;
oneof payload {
// 사용자가 특정 방에 참여 요청
JoinRequest join = 2;
// WebRTC 세션 기술자 (Offer 또는 Answer)
SessionDescription description = 3;
// ICE 후보
IceCandidate ice_candidate = 4;
// 연결 상태 확인을 위한 핑
Ping ping = 5;
}
}
// 서버가 클라이언트로 보내는 응답 메시지
message SignalResponse {
// 각 응답은 고유 ID를 가질 수 있습니다.
string id = 1;
oneof payload {
// 방 참여 결과
JoinResponse join = 2;
// 다른 피어로부터 받은 세션 기술자
SessionDescription description = 3;
// 다른 피어로부터 받은 ICE 후보
IceCandidate ice_candidate = 4;
// 방에서 피어가 나갔음을 알림
PeerLeft peer_left = 5;
// 핑에 대한 응답
Pong pong = 6;
}
}
// 메시지 세부 정의
message JoinRequest {
string room_id = 1;
// 클라이언트의 고유 식별자
string client_id = 2;
}
message JoinResponse {
bool success = 1;
string error_message = 2;
// 방에 이미 참여해 있는 다른 피어들의 목록
repeated string existing_peers = 3;
}
message SessionDescription {
string target_id = 1; // 메시지를 받을 피어의 ID
string sdp = 2; // SDP 문자열
string type = 3; // "offer" 또는 "answer"
}
message IceCandidate {
string target_id = 1; // 메시지를 받을 피어의 ID
string candidate = 2; // candidate 문자열
string sdp_mid = 3; // sdpMid
int32 sdp_m_line_index = 4; // sdpMLineIndex
}
message PeerLeft {
string peer_id = 1; // 방을 나간 피어의 ID
}
message Ping {}
message Pong {}
이 .proto 파일은 다음과 같은 특징을 가집니다.
Signal서비스는 단 하나의rpc메서드를 가지며, 이는stream키워드를 통해 양방향 스트리밍으로 정의됩니다.SignalRequest와SignalResponse는oneof키워드를 사용하여 여러 종류의 메시지(참여, SDP, ICE 후보 등)를 하나의 래퍼(wrapper) 메시지로 캡슐화합니다. 이는 스트림을 통해 다양한 유형의 이벤트를 효율적으로 처리할 수 있게 해줍니다.- 메시지 필드는 명확하고 구체적으로 정의되어 있어, 어떤 데이터가 필요한지 코드를 보지 않고도 알 수 있습니다.
4.2. 2단계: gRPC 서버 구현 (Go 예시)
정의된 .proto 파일로부터 Go 코드를 생성한 후, Signaling 서비스 인터페이스를 구현하는 서버 로직을 작성합니다. 서버는 다음 역할을 수행해야 합니다.
- 클라이언트 연결 관리
- '방(Room)' 개념을 통해 사용자 세션 관리
- 한 클라이언트로부터 받은 메시지를 해당 방의 다른 클라이언트(들)에게 중계(relay)
package main
import (
"context"
"io"
"log"
"net"
"sync"
"google.golang.org/grpc"
pb "github.com/my-project/signaling" // 생성된 Go 패키지
)
// 클라이언트 연결을 나타내는 구조체
type clientStream struct {
stream pb.Signaling_SignalServer
// 에러 채널 또는 종료 신호를 위한 채널
done chan bool
}
// 서버 구조체
type signalingServer struct {
pb.UnimplementedSignalingServer
// 스레드 안전한 맵을 위해 뮤텍스 사용
mu sync.Mutex
// 방 ID를 키로, 클라이언트 맵을 값으로 가짐
rooms map[string]map[string]clientStream
}
func newSignalingServer() *signalingServer {
return &signalingServer{
rooms: make(map[string]map[string]clientStream),
}
}
// Signal RPC 메서드 구현
func (s *signalingServer) Signal(stream pb.Signaling_SignalServer) error {
var currentRoomID string
var currentClientID string
ctx := stream.Context()
// 클라이언트로부터 메시지를 지속적으로 수신하는 루프
for {
req, err := stream.Recv()
if err == io.EOF {
log.Printf("Client %s in room %s disconnected", currentClientID, currentRoomID)
break
}
if err != nil {
log.Printf("Error receiving from client %s: %v", currentClientID, err)
break
}
switch payload := req.Payload.(type) {
case *pb.SignalRequest_Join:
roomID := payload.Join.RoomId
clientID := payload.Join.ClientId
currentRoomID = roomID
currentClientID = clientID
s.mu.Lock()
if _, ok := s.rooms[roomID]; !ok {
s.rooms[roomID] = make(map[string]clientStream)
}
// 새로운 클라이언트를 방에 추가
s.rooms[roomID][clientID] = clientStream{stream: stream, done: make(chan bool)}
log.Printf("Client %s joined room %s", clientID, roomID)
s.mu.Unlock()
// 참여 응답 전송
if err := stream.Send(&pb.SignalResponse{
Payload: &pb.SignalResponse_Join{
Join: &pb.JoinResponse{Success: true},
},
}); err != nil {
log.Printf("Error sending join response to %s: %v", clientID, err)
}
case *pb.SignalRequest_Description:
// SDP 메시지를 타겟 클라이언트에게 중계
targetID := payload.Description.TargetId
s.relayMessage(currentRoomID, currentClientID, targetID, &pb.SignalResponse{
Payload: &pb.SignalResponse_Description{Description: payload.Description},
})
case *pb.SignalRequest_IceCandidate:
// ICE 후보를 타겟 클라이언트에게 중계
targetID := payload.IceCandidate.TargetId
s.relayMessage(currentRoomID, currentClientID, targetID, &pb.SignalResponse{
Payload: &pb.SignalResponse_IceCandidate{IceCandidate: payload.IceCandidate},
})
}
}
// 클라이언트 연결 종료 시 정리 작업
s.cleanupClient(currentRoomID, currentClientID)
return nil
}
// 메시지 중계 헬퍼 함수
func (s *signalingServer) relayMessage(roomID, senderID, targetID string, msg *pb.SignalResponse) {
s.mu.Lock()
defer s.mu.Unlock()
if room, ok := s.rooms[roomID]; ok {
if client, ok := room[targetID]; ok {
if err := client.stream.Send(msg); err != nil {
log.Printf("Error relaying message from %s to %s: %v", senderID, targetID, err)
} else {
log.Printf("Relayed message from %s to %s", senderID, targetID)
}
}
}
}
// 클라이언트 정리 헬퍼 함수
func (s *signalingServer) cleanupClient(roomID, clientID string) {
s.mu.Lock()
defer s.mu.Unlock()
if room, ok := s.rooms[roomID]; ok {
delete(room, clientID)
if len(room) == 0 {
delete(s.rooms, roomID)
} else {
// 방에 남은 다른 피어들에게 이 피어가 나갔음을 알림
for otherClientID := range room {
s.relayMessage(roomID, clientID, otherClientID, &pb.SignalResponse{
Payload: &pb.SignalResponse_PeerLeft{PeerLeft: &pb.PeerLeft{PeerId: clientID}},
})
}
}
}
log.Printf("Cleaned up client %s from room %s", clientID, roomID)
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
pb.RegisterSignalingServer(grpcServer, newSignalingServer())
log.Println("gRPC server listening on :50051")
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
4.3. 3단계: 웹 클라이언트 구현 (JavaScript/TypeScript)
브라우저 환경에서 gRPC를 직접 사용하는 것은 표준 HTTP/2와 약간의 차이로 인해 불가능합니다. 이를 해결하기 위해 gRPC-Web이라는 기술을 사용합니다. gRPC-Web은 브라우저에서 gRPC 호출을 할 수 있도록 해주며, 중간에 Envoy나 Nginx 같은 프록시 또는 gRPC-Web을 직접 지원하는 서버 구현을 필요로 합니다.
클라이언트 측 코드는 다음 로직을 포함합니다.
- gRPC-Web 클라이언트를 생성하고 서버의
Signal스트림에 연결합니다. RTCPeerConnection객체를 생성하고 이벤트 핸들러(onicecandidate,ontrack등)를 등록합니다.onicecandidate이벤트가 발생하면, 수집된 ICE 후보를 gRPC 스트림을 통해 서버로 전송합니다.- gRPC 스트림을 통해 서버로부터 메시지(다른 피어의 `Offer`, `Answer`, `ICE Candidate`)를 수신하고, 이를
RTCPeerConnection에 적용합니다(setRemoteDescription,addIceCandidate).
import { SignalingClient } from './generated/SignalingServiceClientPb';
import { SignalRequest, SignalResponse, JoinRequest, SessionDescription, IceCandidate } from './generated/signaling_pb';
import { grpc } from '@improbable-eng/grpc-web';
const signalingClient = new SignalingClient('http://localhost:8080'); // gRPC-Web 프록시 주소
const myId = 'user-' + Math.random().toString(36).substr(2, 9);
const roomId = 'test-room';
let peerConnections = {}; // 상대방 ID를 키로 RTCPeerConnection 객체를 저장
// gRPC 양방향 스트림 시작
const stream = signalingClient.signal();
stream.on('data', (response) => {
// 서버로부터 메시지 수신
if (response.hasDescription()) {
handleDescription(response.getDescription());
} else if (response.hasIceCandidate()) {
handleIceCandidate(response.getIceCandidate());
} else if (response.hasPeerLeft()) {
handlePeerLeft(response.getPeerLeft());
} else if (response.hasJoin()) {
// 방에 새로 들어온 피어 처리 (서버가 알려줄 경우)
// 예: response.getJoin().getNewPeerId()
}
});
stream.on('end', () => {
console.log("Stream ended");
});
// 1. 방에 참여 요청
const joinReq = new JoinRequest();
joinReq.setRoomId(roomId);
joinReq.setClientId(myId);
const signalReq = new SignalRequest();
signalReq.setJoin(joinReq);
stream.write(signalReq);
// RTCPeerConnection 설정
async function createPeerConnection(targetId) {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
pc.onicecandidate = (event) => {
if (event.candidate) {
const iceCandidate = new IceCandidate();
iceCandidate.setTargetId(targetId);
iceCandidate.setCandidate(event.candidate.candidate);
iceCandidate.setSdpMid(event.candidate.sdpMid);
iceCandidate.setSdpMLineIndex(event.candidate.sdpMLineIndex);
const req = new SignalRequest();
req.setIceCandidate(iceCandidate);
stream.write(req);
}
};
pc.ontrack = (event) => {
// 상대방의 미디어 스트림을 받아서 비디오 엘리먼트에 연결
const remoteVideo = document.getElementById('remoteVideo');
if (remoteVideo) {
remoteVideo.srcObject = event.streams[0];
}
};
peerConnections[targetId] = pc;
return pc;
}
// Offer 생성 및 전송 (호출하는 측)
async function callUser(targetId) {
const pc = await createPeerConnection(targetId);
// 로컬 미디어 스트림을 PeerConnection에 추가
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sd = new SessionDescription();
sd.setTargetId(targetId);
sd.setSdp(offer.sdp);
sd.setType(offer.type);
const req = new SignalRequest();
req.setDescription(sd);
stream.write(req);
}
// 수신된 SDP 처리 (Offer 또는 Answer)
async function handleDescription(desc) {
const targetId = desc.getTargetId(); // 이 메시지를 보낸 피어의 ID
let pc = peerConnections[targetId];
if (!pc) {
pc = await createPeerConnection(targetId);
}
const remoteDescription = new RTCSessionDescription({
sdp: desc.getSdp(),
type: desc.getType()
});
await pc.setRemoteDescription(remoteDescription);
if (remoteDescription.type === 'offer') {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
const sd = new SessionDescription();
sd.setTargetId(targetId);
sd.setSdp(answer.sdp);
sd.setType(answer.type);
const req = new SignalRequest();
req.setDescription(sd);
stream.write(req);
}
}
// 수신된 ICE 후보 처리
async function handleIceCandidate(candidate) {
const targetId = candidate.getTargetId();
const pc = peerConnections[targetId];
if (pc) {
await pc.addIceCandidate(new RTCIceCandidate({
candidate: candidate.getCandidate(),
sdpMid: candidate.getSdpMid(),
sdpMLineIndex: candidate.getSdpMLineIndex(),
}));
}
}
// 피어가 나갔을 때 처리
function handlePeerLeft(peer) {
const peerId = peer.getPeerId();
if (peerConnections[peerId]) {
peerConnections[peerId].close();
delete peerConnections[peerId];
console.log(`Connection with ${peerId} closed.`);
}
}
이 아키텍처는 WebRTC의 복잡한 연결 과정을 gRPC의 구조화되고 효율적인 통신 모델 위에 올려놓음으로써, 전체 시스템의 안정성과 확장성, 유지보수성을 크게 향상시킵니다.
5. 심화 주제 및 실제적 고려사항
앞서 설명한 기본 아키텍처 외에도, 실제 프로덕션 환경에 이 시스템을 배포하기 위해서는 몇 가지 추가적인 요소들을 고려해야 합니다.
5.1. 확장성: 시그널링 서버의 스케일 아웃
단일 시그널링 서버는 수용할 수 있는 동시 접속자 수에 한계가 있습니다. 사용자가 증가함에 따라 서버를 수평적으로 확장(스케일 아웃)해야 합니다. 하지만 상태를 가지는(stateful) 시그널링 서버를 확장하는 것은 간단하지 않습니다. 같은 방에 있는 사용자들이 서로 다른 서버 인스턴스에 접속할 수 있기 때문입니다.
이 문제를 해결하기 위해 Redis, Kafka, NATS와 같은 메시지 큐 또는 Pub/Sub 시스템을 도입할 수 있습니다. 각 시그널링 서버 인스턴스는 클라이언트로부터 메시지를 받으면, 이를 중앙 메시징 시스템의 특정 토픽(예: 방 ID)으로 발행(publish)합니다. 그리고 모든 서버 인스턴스는 자신이 담당하는 방의 토픽을 구독(subscribe)하고 있다가, 메시지가 발행되면 이를 수신하여 자신이 연결하고 있는 클라이언트에게 전달합니다. 이러한 '팬아웃(fan-out)' 패턴을 통해 시그널링 서버를 상태 비저장(stateless)에 가깝게 만들어 수평적 확장을 용이하게 할 수 있습니다.
5.2. 보안: 인증과 권한 부여
프로덕션 환경에서는 아무나 시그널링 서버에 접속하여 임의의 방에 참여하도록 둘 수 없습니다. gRPC는 강력한 인증 메커니즘을 내장하고 있습니다.
- TLS (Transport Layer Security): gRPC 통신 채널 자체를 암호화하여 중간자 공격(Man-in-the-middle attack)을 방지합니다. 프로덕션 환경에서는 반드시 TLS를 활성화해야 합니다.
- 토큰 기반 인증 (Token-based Authentication): 클라이언트는 gRPC 요청의 메타데이터에 JWT(JSON Web Token)와 같은 인증 토큰을 포함하여 전송할 수 있습니다. 서버는 인터셉터(interceptor)를 사용하여 모든 요청을 가로채 토큰의 유효성을 검증하고, 인증된 사용자인지, 해당 방에 참여할 권한이 있는지를 확인할 수 있습니다.
5.3. 미디어 서버(SFU/MCU)와의 통합
순수한 P2P 방식은 3~4명 이상의 다자간 통화에서는 비효율적입니다. 각 피어가 다른 모든 피어와 개별적인 연결을 맺어야 하므로, 참여자 수가 늘어날수록 업로드 대역폭과 CPU 사용량이 기하급수적으로 증가합니다.
이 문제를 해결하기 위해 SFU(Selective Forwarding Unit)나 MCU(Multipoint Conferencing Unit)와 같은 미디어 서버를 사용합니다.
- SFU: 각 피어는 자신의 미디어 스트림을 SFU에 한 번만 업로드합니다. SFU는 이 스트림을 받아서 해당 방에 있는 다른 모든 피어에게 전달(forwarding)합니다. 클라이언트의 업로드 부담을 크게 줄여주어 대규모 다자간 통화에 효과적입니다. - MCU: 모든 피어의 스트림을 서버에서 하나로 혼합(mixing/composing)하여 단일 스트림으로 만들어 각 피어에게 전송합니다. 클라이언트의 디코딩 부담을 줄여주지만, 서버의 CPU 부하가 매우 높습니다.
이러한 미디어 서버 아키텍처에서도 gRPC 시그널링은 핵심적인 역할을 합니다. 클라이언트는 gRPC를 통해 시그널링 서버와 통신하여 "어떤 SFU에 연결해야 하는지", "어떤 스트림을 발행(publish)하고 구독(subscribe)할 것인지" 등의 정보를 교환합니다. 시그널링 서버와 미디어 서버 간의 통신(부하 분산, 상태 동기화 등) 역시 gRPC를 사용하면 효율적으로 구현할 수 있습니다.
6. 활용 사례: gRPC와 WebRTC가 만들어내는 혁신
이 강력한 기술 조합은 기존의 실시간 통신 애플리케이션을 개선할 뿐만 아니라, 이전에는 구현하기 어려웠던 새로운 유형의 서비스를 가능하게 합니다.
- 클라우드 게이밍 및 원격 데스크톱: 사용자의 입력(키보드, 마우스)은 지연에 매우 민감합니다. gRPC의 양방향 스트림을 통해 이러한 제어 신호를 낮은 지연 시간으로 서버에 전송하고, 서버는 렌더링된 고화질 비디오/오디오를 WebRTC 미디어 스트림을 통해 실시간으로 사용자에게 스트리밍할 수 있습니다.
- IoT 및 원격 로봇 제어: 원격지의 로봇이나 드론을 제어하는 시나리오를 생각해볼 수 있습니다. 제어 명령(예: '좌회전', '카메라 각도 조절')은 프로토콜 버퍼로 정의된 구조화된 메시지로 gRPC를 통해 안정적으로 전송됩니다. 로봇의 카메라가 촬영하는 영상은 WebRTC를 통해 실시간으로 관제 센터에 스트리밍됩니다.
- 쌍방향 라이브 커머스: 수천 명의 시청자가 라이브 스트리밍을 시청하는 동안, 일부 VIP 고객이 판매자와 실시간 1:1 영상 통화를 요청할 수 있습니다. 대규모 스트리밍은 CDN을 통해 전달하되, 1:1 통화가 필요할 때 gRPC 시그널링을 통해 동적으로 P2P WebRTC 세션을 수립하여 쌍방향 소통을 구현할 수 있습니다.
- 차세대 협업 도구: 단순한 화상 회의를 넘어, 여러 사용자가 공유된 가상 공간(예: 화이트보드, 3D 모델 뷰어)에서 상호작용하는 애플리케이션을 만들 수 있습니다. 미디어는 WebRTC로, 모든 상호작용 이벤트(커서 위치, 객체 조작 등)는 RTCDataChannel과 함께 gRPC를 통한 상태 동기화로 처리하여 빠르고 일관성 있는 사용자 경험을 제공할 수 있습니다.
결론: 미래를 향한 아키텍처
WebRTC와 gRPC는 각각의 영역에서 실시간 통신과 마이크로서비스의 패러다임을 바꾼 혁신적인 기술입니다. 이 두 기술을 결합하는 것은 단순히 두 개의 도구를 함께 사용하는 것을 넘어, 실시간 애플리케이션을 설계하고 구축하는 방식에 대한 근본적인 전환을 의미합니다.
gRPC를 WebRTC의 시그널링 계층으로 도입함으로써 우리는 WebSocket 기반의 전통적인 접근 방식이 가진 모호함과 비효율성을 극복할 수 있습니다. 프로토콜 버퍼가 제공하는 강력한 API 계약과 바이너리 직렬화의 성능, 그리고 HTTP/2 기반 양방향 스트리밍의 유연성은 WebRTC 연결 설정 과정을 더 빠르고, 더 안정적이며, 더 예측 가능하게 만듭니다. 이는 개발자에게는 더 나은 생산성과 유지보수성을, 사용자에게는 더 나은 품질의 실시간 경험을 제공합니다.
물론, 이 아키텍처를 도입하기 위해서는 gRPC-Web 프록시 설정, 프로토콜 버퍼 학습, 분산 환경에서의 상태 관리 등 추가적인 학습 곡선과 기술적 과제들이 존재합니다. 하지만 이러한 초기 투자는 대규모의 복잡한 실시간 통신 시스템을 구축하고 운영하는 데 있어 장기적으로 엄청난 가치를 제공할 것입니다. WebRTC와 gRPC의 조합은 단순한 기술적 트렌드가 아니라, 미래의 실시간 웹을 만들어나갈 견고하고 검증된 아키텍처 패턴으로 자리매김하고 있습니다.