
[Blueprint L1] PonsLink P2P Room — WebRTC 화상회의를 BFF로 감싼 이유
React SPA와 Express BFF가 Socket.IO로 시그널링하고, 미디어는 mesh로 가는 2티어. TURN 그랜트와 presence lease로 룸을 만든 화상회의.

# [Blueprint L1] PonsLink P2P Room — WebRTC 화상회의를 BFF로 감싼 이유
> Series: `blueprint-ponslink` (1/3) · Category: `P2P/분산기술` + `개발 회고` · Slug: `blueprint-ponslink-06-p2p-webrtc-room`
> Source: `blueprints/specs/P02_ponslink_web.md` §06 + `blueprints/06-pons-p2p.png` + `pons_p2p/ponslink-room-frontend + ponslink-api-infra`
—
## 0. TL;DR
`pons_p2p`는 `React 18 SPA`와 `Express BFF`가 `Socket.IO`로 시그널링을 주고받고, 미디어는 브라우저끼리 `WebRTC mesh`로 간다. `BFF`가 `TURN 자격증명`과 `R2 presigned URL`을 발급하고, `Postgres(hydration 게이트)`와 `Redis(presence lease)`가 룸 상태를 들고 있다. 서버는 릴레이가 아니라 `그랜트`를 주는 곳이다.

—
## 1. 문제 정의 — 왜 룸에 BFF가 필요한가
화상회의에서 서버가 할 일은 두 가지: “누가 들어올 수 있는가”와 “연결을 어떻게 돕는가”. `PonsLink`는 `desks`와 `rooms`를 오가는 서비스라, 룸마다 `기능 그랜트`가 다르다. `프론트`가 직접 `TURN` 서버에 계정을 만들면 남용된다. 그래서 `BFF(api-infra)`가 `그랜트`를 발급하고, 시그널링은 `Socket.IO`로, 미디어는 `mesh`로 위임했다.
—
## 2. 설계도 한눈에 — 2티어
왼쪽 `React SPA(Zustand)` ↔ 중앙 `Express BFF + Socket.IO(Node20)` ↔ 오른쪽 두 브라우저 사이 `WebRTC mesh` 굵은 화살표. `coturn TURN`은 점선으로만 경유. `BFF` 아래 `Postgres(hydration 게이트)`와 `Redis(presence lease TTL)`와 `R2`가 있다. 전체를 `Docker Compose(coturn)`이 감싸고, `BFF 컨트롤 / 메시 데이터`로 2티어를 구분.
* **시그널링:** `offer/answer/ICE relay`는 `Socket.IO`로만. `BFF`가 `그랜트`를 확인한 뒤에만 `relay`.
* **미디어:** `BFF`를 안 거치고 브라우저끼리 직접. `TURN`은 NAT가 막혔을 때만.
* **데이터:** `Postgres`는 `hydration 게이트` — DB 상태 확인 후 UI 복원. `Redis`는 `presence lease` — TTL로 “누가 지금 방에 있는가”를 들고, 끊기면 자동 만료.
—
## 3. 아키텍처 해부 — BFF가 하는 3가지
“`
room-frontend(React) –Socket.IO–> api-infra(Express)
├── grant check
├── TURN cred 발급
└── R2 presigned URL
–WebRTC mesh–> peer
“`
* **grant check:** `roomId`와 `userId`로 `Postgres`를 조회해 “입장 가능”을 확인. `hydration 게이트`를 통과해야 `Socket.IO`에 `join`.
* **TURN cred:** `coturn`용 `username:expiry:hmac`를 1시간 TTL로 발급. `BFF`가 아니면 발급 불가.
* **R2 presigned:** 파일 업로드는 `BFF`가 `R2 presigned PUT`을 발급하고, 브라우저가 `R2`에 직접 `PUT`. 서버는 바이트를 안 본다.
—
## 4. 로직 딥다이브 — 2개만 깊게
### 4.1 presence lease (Redis TTL)
* `join` 시 `SET presence:{roomId}:{userId} 1 EX 30` — 30초 lease. 클라이언트가 20초마다 `heartbeat`로 갱신. 끊기면 30초 뒤 자동 삭제. `replay`는 최근 `presence`를 `Redis`에서 읽어 복원.
### 4.2 hydration 게이트 (Postgres)
* `room-frontend`가 `Socket.IO`에 연결해도 바로 방을 그리지 않는다. `BFF`가 `Postgres`에서 `room`과 `grants`를 읽어 `hydration` 데이터를 내려줘야 `Zustand`가 `UI`를 복원. 새로고침해도 방 상태가 유지되는 이유.
—
## 5. 왜 이 선택인가
| 선택 | 채택 | 버린 대안 | 버린 이유 |
|—|—|—|—|
| 시그널링 | Socket.IO + BFF 그랜트 | 프론트 직접 WebSocket | 그랜트 없이 누구나 `offer`를 쏠 수 있음 |
| 미디어 | mesh 직접 | SFU 서버 릴레이 | 비용, E2E 암호화 불가 |
| presence | Redis lease | DB 폴링 | TTL 자동 만료, `replay`가 간단 |
—
## 6. 실패와 복구
* **실패:** 처음엔 `presence`를 `Postgres`에 두었다. 그러나 `heartbeat`가 20초마다 `UPDATE`를 쏘자 `DB`가 버티지 못했다. `Redis`로 옮기자 `QPS` 100배 감소.
* **복구:** `TURN` 자격증명을 24시간으로 길게 줬다가 남용. 1시간으로 줄이고, `BFF`가 `그랜트` 없으면 발급 거부.
—
## 7. 운영 증거
* `ponslink-room-frontend` — `React 18, Vite 5, Zustand, socket.io-client`
* `ponslink-api-infra` — `Node 20, Express, Socket.IO, Postgres, Redis, R2, coturn, Docker Compose`
* `hydration 게이트` — `Postgres` 상태 확인 후 `UI` 복원, `presence lease` 30초
—
## 8. 다음 편 예고
**L2: `control-tower` — Threads SNS 자동발행.** `cardnews` 재사용→생성 폴백, `n8n` 워크플로 패치, `dry-run` 기본 게이트를 다룬다.
*부록: `blueprints/06-pons-p2p.png` 프롬프트 `prompts.part1.json#06`, 코드 진입점 `pons_p2p/ponslink-api-infra/src/socket/roomHandler.ts`*

댓글