프로젝트와 주제별로 설계 기록을 탐색합니다.
![[PonsWarp 개념 교실 25] 멱등성 — 같은 결제 이벤트를 두 번 처리하지 않는 법](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-idempotent-webhook-cover-imagegen.webp)
쉽게 말하면, **멱등성은 같은 요청을 한 번 처리하든 열 번 처리하든 최종 결과가 한 번 처리한 것과 같게 만드는 성질**이다.  결제 Webhook은 네트워크 오류
![[PonsWarp 개념 교실 24] 토큰버킷 — API 요청을 어떻게 일정하게 제한하나](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-token-bucket-cover-imagegen.webp)
쉽게 말하면, **토큰버킷은 요청이 들어올 때마다 토큰을 하나씩 꺼내고, 토큰이 없으면 잠시 기다리거나 거부하는 요청 제한 장치**다.  무조건 초당 N개만 허용하는 고정 창보다 순간 bur
![[PonsWarp 개념 교실 26] 헥사고날 아키텍처 — 외부 기술을 갈아끼우는 구조](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-26-hexagonal-cover-imagegen.webp)
쉽게 말하면, **헥사고날 아키텍처는 핵심 업무 로직이 WebRTC·파일시스템·클라우드 같은 외부 기술을 직접 알지 않게 만드는 구조**다.  외부 기술을 바꾸더라도 안쪽
![[PonsWarp 개념 교실 23] presigned URL — 서버를 거치지 않고 R2에 올리는 법](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-23-presigned-url-cover-imagegen.webp)
Cloud Drop은 서버가 파일을 받아 저장하는 방식이 아니다. 서버가 잠깐 유효한 업로드 URL을 발급하고, 브라우저가 R2에 직접 PUT한다.
![[PonsWarp 개념 교실 22] TURN 자격증명 — coturn REST API와 HMAC-SHA1](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-22-turn-credential-cover-imagegen.webp)
쉽게 말하면, **TURN 자격증명은 "잠깐만 쓸 수 있는 릴레이 이용권"이고, username 안에 만료시각을 넣고 그 전체를 HMAC-SHA1으로 서명한다.**  직접 WebRTC 연결이
![[PonsWarp 개념 교실 21] 시그널링 — 방 모델과 메시지 라우팅](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-21-signaling-room-cover-imagegen.webp)
쉽게 말하면, **시그널링 서버는 파일 운반자가 아니라 두 브라우저를 소개하는 교환원**이다.  파일 바이트는 WebRTC DataChannel로 직접 흐르고, 서버는
![[PonsWarp 개념 교실 20] Resume 커서 — 끊긴 전송을 중간부터 이어가는 법](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-20-resume-cursor-cover-imagegen.webp)
전송이 끊겼을 때 처음부터 다시 보내는 것은 가장 비싼 복구다. PonsWarp는 global offset을 file offset·sequence·next partition으로 바꿔 정확한 위치부터 재개한다.
![[PonsWarp 개념 교실 19] 백프레셔 — high/low watermark로 전송을 보호하는 법](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-19-backpressure-cover-imagegen.webp)
받는 쪽 디스크가 느린데 보내는 쪽이 계속 달리면 버퍼가 쌓인다. PonsWarp는 48MiB에서 PAUSE하고 16MiB까지 내려오면 RESUME하는 high/low watermark를 사용한다.
![[PonsWarp 개념 교실 18] AIMD — 혼잡 제어의 고전](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-18-aimd-cover-imagegen.webp)
네트워크가 괜찮을 때는 조금씩 키우고, 혼잡해지면 한 번에 줄인다. AIMD는 이 단순한 규칙으로 전송량을 안정화한다. PonsWarp는 RTT와 bufferedAmount를 보고 cwnd를 조절한다.
![[PonsWarp 개념 교실 17] 파티션 배리어 — PARTITION_ACK와 연속 프론티어](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-17-partition-barrier-cover-imagegen.webp)
암호화된 파일 조각을 끝없이 보내면 수신측 writer가 따라가지 못한다. PonsWarp는 일정 구간마다 PARTITION 마커를 보내고, 수신측의 연속 오프셋이 그 지점에 도달했을 때 다음 구간을 보낸다.
![[PonsWarp 개념 교실 16] in-flight 윈도우 — BDP와 슬라이딩 윈도우](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-16-sliding-window-cover-imagegen.webp)
보내지 않은 데이터를 무한히 큐에 넣으면 브라우저 버퍼가 터진다. PonsWarp는 네트워크 경로와 RTT를 보고 동시에 날아갈 수 있는 바이트를 정하고, bufferedAmount를 뺀 만큼만 더 요청한다.
![[PonsWarp 개념 교실 15] prepare-ahead 크레딧 — 생산자·소비자 큐](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-15-prepare-ahead-cover-imagegen.webp)
암호화 워커가 파일을 너무 빨리 읽으면 준비된 암호문이 메모리에 쌓인다. PonsWarp는 12MiB 크레딧을 두고 소비된 만큼만 다시 만들게 하는 생산자·소비자 흐름을 구현했다.
![[PonsWarp 개념 교실 14] WebRTC DataChannel — SCTP 신뢰성과 bufferedAmount](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-14-datachannel-cover-imagegen.webp)
WebRTC 연결이 됐다고 파일이 자동으로 안전하게 흐르는 것은 아니다. DataChannel은 SCTP 위에서 신뢰성과 순서를 제공하지만, 애플리케이션은 control/bulk 분리와 bufferedAmount 흐름 제어를 직접 설계해야 한다.
![[PonsWarp 개념 교실 13] Reed-Solomon — GF(2^8) 이레이저 코딩](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-13-reed-solomon-cover-imagegen.webp)
패킷이 손실돼도 원본을 복구할 수 있다면 재전송을 기다릴 필요가 없다. Reed-Solomon은 데이터에 패리티를 더해 일부를 잃어도 나머지로 복원하는 FEC다.
![[PonsWarp 개념 교실 12] LZ4 블록 압축 — 속도를 위해 만든 비표준 포맷](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-12-lz4-block-cover-imagegen.webp)
DEFLATE보다 훨씬 빠른 압축이 필요한 순간이 있다. LZ4는 압축률을 희생하고 속도에 올인한 블록 압축이다. PonsWarp는 LZ4를 표준 프레임이 아닌 자체 블록 포맷으로 구현했는데, 그 선택의 이유와 대가를 파헤친다.
![[PonsWarp 개념 교실 11] DEFLATE — zip64 속의 압축 (miniz_oxide)](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-11-deflate-cover-imagegen.webp)
ZIP64 편에서 압축은 옵션이라고 했다. 그 옵션을 켰을 때 실제로 데이터를 줄이는 DEFLATE는 어떤 원리로 동작하고, 스트리밍에서 어떻게 조각 단위로 압축을 이어가는지 파헤친다.
![[PonsWarp 개념 교실 10] ZIP64 스트리밍 — LFH·CEN·EOCD64 구조](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-10-zip64-stream-cover-imagegen.webp)
여러 파일을 묶어 하나의 다운로드로 만들 때, 전체를 메모리에 올리지 않고 스트리밍으로 ZIP을 쓸 수 있다. PonsWarp의 수신측은 ZIP64 구조(LFH→데이터→CEN→EOCD64)를 조각 단위로 만들어 4GB 이상도 무손실로 표현한다.
![[PonsWarp 개념 교실 9] 재정렬 버퍼 — Arena·free-list·BTreeMap·LRU](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-09-reordering-buffer-cover-imagegen.webp)
조각이 순서대로 도착한다는 보장은 없다. 수신측은 뒤섞인 조각을 정렬해 파일 순서대로 디스크에 써야 한다. PonsWarp는 Arena+free-list로 메모리를 관리하고, BTreeMap으로 인덱스를 유지하며, 128MiB 한도를 넘으면 LRU로 밀어낸다.
![[PonsWarp 개념 교실 8] 청크 크기 16~192KiB — 어떻게 정해졌나](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-08-chunk-size-cover-imagegen.webp)
청크가 크면 패킷 수가 줄어 빨라질 것 같지만, SCTP 메시지 상한과 메모리·재전송 비용이 가로막는다. PonsWarp가 경로별로 16~192KiB를 고르는 기준과, 크면 무조건 빠르다가 아닌 이유를 파헤친다.
![[PonsWarp 개념 교실 7] 22바이트 패킷 헤더 — 한 조각이 자신을 설명하는 법](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-07-packet-header-cover-imagegen.webp)
파일을 조각내 보내면 수신측은 이 조각이 파일의 어디에 붙는지 알아야 한다. PonsWarp는 22바이트 고정 헤더에 그 정보를 담는다. 각 필드가 왜 그 크기인지, 왜 그 순서인지, 그리고 CRC 필드가 0으로 비워진 사연까지 파헤친다.
![[PonsWarp 개념 교실 6] 무결성 3총사 — CRC32 vs SHA-256 vs Merkle Tree](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-06-integrity-trio-cover-imagegen.webp)
데이터가 전송 중 변조되지 않았는지 확인하는 방법은 세 가지 층위가 있다. CRC32는 빠르지만 약하고, SHA-256은 강하지만 무겁고, Merkle 트리는 부분 검증이 가능하다. PonsWarp가 실제로 고른 조합과 그 이유를 파헤친다.
![[PonsWarp 개념 교실 5] HMAC과 상수시간 비교 — 키 확인과 타이밍 공격](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-05-hmac-constant-time-cover-imagegen.webp)
두 브라우저가 같은 키를 가졌는지 확인하려면 문자열 비교로 충분할 것 같지만, 그 비교 방식이 보안 구멍이 될 수 있다. HMAC과 상수시간 비교가 왜 함께 필요한지 파헤친다.
![[PonsWarp 구현 분석 2] 복사하지 않는 게 제일 빠르다 — WASM 메모리와 제로카피](/tistory/ponswarp/implementation/2026-08-21-ponswarp-impl-02-wasm-memory-zero-copy-cover-imagegen.webp)
JS↔WASM 경계를 넘을 때 바이트를 몇 번 복사해야 할까? PonsWarp는 64KB 슬롯 풀을 WASM 메모리에 미리 할당해 쓰기 0회, 읽기 1회 복사를 실현했지만, 실제로는 절반만 제로카피인 지점도 있다.
![[PonsWarp 구현 분석 6] 브라우저끼리 키를 나누는 법 — E2E 암호화의 실제 적용](/tistory/ponswarp/implementation/2026-08-21-ponswarp-impl-06-e2e-crypto-nonce-cover-imagegen.webp)
개념 글에서 다룬 ECDH, HKDF, AES-256-GCM, nonce가 실제 PonsWarp 코드에서 하나의 파이프라인으로 어떻게 연결되는지, 전송 전부터 전송 중, 재개까지 추적한다.
![[PonsWarp 개념 교실 4] GCM nonce — 왜 4B 카운터 + 8B random prefix인가](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-04-gcm-nonce-cover-imagegen.webp)
GCM에서 nonce가 두 번 재사용되면 암호문 두 개를 XOR하는 것만으로 키를 역산할 수 있다. PonsWarp가 nonce를 4바이트 카운터와 8바이트 랜덤 프리픽스로 쪼갠 이유와, 그 설계에 남은 위험이 무엇인지 파헤친다.
![[PonsWarp 개념 교실 3] AES-256-GCM — 암호화의 구조 (CTR + GHASH + tag)](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-03-aes-256-gcm-cover-imagegen.webp)
AES-256-GCM은 암호화와 위변조 감지를 한 번에 해결한다. CTR 모드로 데이터를 섞고, GHASH로 인증 태그를 만들어 붙인다. 이 태그가 16바이트로 파일 무결성을 지키는 이유를 파헤친다.
![[PonsWarp 개념 교실 2] HKDF-SHA256 — 공동 비밀을 안전한 키로 가공하기](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-02-hkdf-sha256-cover-imagegen.webp)
ECDH가 만든 공동 비밀은 좋은 AES 키라고 바로 가정하면 안 된다. 그 사이에 키 제조 공장 하나를 두는데, 그게 HKDF-SHA256이다.
![[PonsWarp 개념 교실 1] ECDH P-256 — 둘만 아는 공동 비밀 만들기](/tistory/ponswarp/concept/2026-08-21-ponswarp-concept-01-ecdh-p256-cover-imagegen.webp)
서로 비밀키를 직접 보내지 않고, 두 기기만 똑같은 암호화 키를 만들어내는 방법.

선언 실행일지 2026-08-08

세션 로그 자동 추출 기반 회고. 포털/데스크 개편, 해외 수집, 선언 시스템.

외주로 살아간다는 다섯 가지 약속. 매일의 실행일지가 이 선언의 증거다.
![[WarpInfra] 남은 일 — mTLS·20GB 실측·Control MVP·그리고 컴퓨트 그리드](/tistory/body-images/2026-08-07-warpinfra-10-remaining-work/cover.webp)
시리즈의 마지막 편은 백로그다. 지금 남은 일의 순서는 우연이 아니라 원칙으로 고정되어 있다. 이 편은 RC1 게이트 뒤의 작업을 Now/Next/Later로 나눠 읽고, 시리즈를 닫는다.
![[WarpInfra] RC1 — 20GB·다중 머신·SHA-256 없으면 출시가 아니다](/tistory/body-images/2026-08-07-warpinfra-09-rc1-gate/cover.webp)
제품이 "완료됐다"고 말하는 순간은 기능이 끝났을 때가 아니라 증거가 갖춰졌을 때다. WarpInfra의 RC1 문서는 이 원칙을 게이트로 박아두었다. 이 편은 그 게이트와 현재 통과 상태를 대조한다.
![[WarpInfra] Phase A를 95%까지 올린 구현 묶음](/tistory/body-images/2026-08-07-warpinfra-08-phase-a-95/cover.webp)
Phase ALAN Grid MVP의 목표는 단순하다. 코디네이터 없이, 같은 LAN에서 앱들이 release를 공유하고 메시 수신한다. 2026-07-18 기준 이 단계는 약 95%다. 이 편은 그 95%를 만든 부품 묶음을 정리한다.
![[WarpInfra] 희소한 조각부터, 여러 피어에게 동시에](/tistory/body-images/2026-08-07-warpinfra-07-rarest-first-piece-race/cover.webp)
여러 Node가 한 파일을 나눠 받을 때, 스케줄러가 "어느 조각을 먼저 요청할지" 고르는 기준이 결과를 가른다. WarpInfra는 rarest-first를 택하고, Have fanout으로 조각 보유 정보를 퍼뜨리고, multi-peer piece race로 지연을 줄였다.
![[WarpInfra] 폴더가 단일 스트림으로 줄줄이 나가던 버그](/tistory/body-images/2026-08-07-warpinfra-06-zip-streaming-folder/cover.webp)
폴더 전송은 겉보기엔 단순하다. "파일을 묶어서 보내면 된다." 그런데 zip으로 묶는 순간 메모리와 디스크와 타이밍이 한꺼번에 어긋난다. 이 편은 zip 패키징 실패가 단일 스트림 연속 송신으로 바뀌기까지의 커밋을 읽는다.
![[WarpInfra] WASM을 버리고 네이티브 QUIC로 갈아탄 날](/tistory/body-images/2026-08-07-warpinfra-05-native-quic/cover.webp)
한때 전송 코어는 WASM에 얹혀 있었다. 웹과 데스크톱이 같은 코어를 쓰려는 의도였다. 그런데 데스크톱에선 그 선택이 오히려 발목을 잡았다. 이 편은 WASM 의존을 제거하고 네이티브 QUIC로 전환한 커밋들을 추적한다.
![[WarpInfra] CLI를 1등 시민으로 둔 이유 — CI와 AI 에이전트](/tistory/body-images/2026-08-07-warpinfra-04-cli-first-ai-agents/cover.webp)
GUI를 먼저 만들고 CLI를 "개발자용 부가기능"으로 두는 제품이 많다. WarpInfra는 정반대다. CLIwarp가 1등 시민이고, GUI와 agentd가 같은 코어를 공유한다. 이 선택의 근거는 단순하다. "운영자는 사람이 아니라 파이프라인이다."
![[WarpInfra] 컴퓨트 그리드를 일부러 뒤로 미룬 로드맵](/tistory/body-images/2026-08-07-warpinfra-03-roadmap-data-grid-first/cover.webp)
"그리드"라는 단어를 들으면 대부분 분산 연산을 떠올린다. WarpInfra는 반대로 갔다. 분산 전송데이터 그리드을 먼저 끝내고, 분산 연산컴퓨트 그리드은 데이터 그리드가 안정화된 뒤에만 설계하기로 했다. 이 순서 고정이 이 시리즈의 로드맵 편이다.
![[WarpInfra] 파일 바이트는 Control을 지나지 않는다](/tistory/body-images/2026-08-07-warpinfra-02-control-vs-data-plane/cover.webp)
그리드 아키텍처에서 가장 먼저 고정한 원칙이 하나 있다. "파일 payload는 Control이 아니라 Node 간 P2P로 이동한다." 이 한 줄이 coordinator 설계, 라우트 노출, 보안 경계를 모두 결정했다.
![[WarpInfra] 레지스트리 옆 CLI release get — 복제하지 않는 니치](/tistory/body-images/2026-08-07-warpinfra-01-niche-cli-release-get/cover.webp)
분산 파일 전송 제품은 이미 많다. Resilio, Aspera, Signiant. 그런데도 WarpInfra를 처음부터 만들기로 한 이유는 무엇일까. 한 줄로: 전체 미러링 제품을 복제하는 대신, "레지스트리 옆에서 release를 받는 CLI"라는 좁은 일감을 골랐기 때문이다.
![[WarpInfra] 데이터 그리드 시리즈를 시작하며 — desktop과 grid, 그리고 이름](/tistory/body-images/2026-08-07-warpinfra-00-series-map/cover.webp)
레포는 두 개다. 이름은 하나다. 이 시리즈는 DeclanJeon/ponswarp-desktop146커밋과 DeclanJeon/ponswarp-grid29커밋의 커밋 이력을 시간순이 아니라 설계 → 구현 → 남은 일 순서로 다시 읽는 기록이다. 이 글이 그 지도다.
![[PonsWarp Grid] 방을 먼저 열고, direct와 grid를 가르다](/tistory/body-images/2026-08-07-ponswarp-grid-03-room-first-direct-grid/cover.webp)
파일을 고르는 순간 방이 열린다. 전송은 direct직접와 grid그리드로 갈린다. 사용자는 경로를 고르지 않는다. 제품이 고른다. 이 편은 room-first 메시 전송과 direct/grid 분기, 그리고 브라우저 대용량 export가 Blob에서 터진 사건을 함께 읽는다.
![[PonsWarp Grid] coordinator를 "안전하게" 노출한다는 것](/tistory/body-images/2026-08-07-ponswarp-grid-02-coordinator-routes/cover.webp)
제어면이 바이트를 안 다루기로 했으니, coordinator가 노출하는 라우트는 메타만 다뤄야 한다. 이 편은 ponswarp-grid에서 coordinator 라우트를 공개하면서 무엇을 넣고 무엇을 금지했는지, 그리고 mesh 연산을 CLI로 묶은 커밋을 본다.
![[PonsWarp Grid] 브라우저 그리드 엔진을 처음부터 다시 짜다](/tistory/body-images/2026-08-07-ponswarp-grid-01-engine-from-zero/cover.webp)
desktop이 네이티브 그리드로 가는 동안, 브라우저 쪽에는 별도의 엔진이 필요했다. ponswarp-grid 레포다. 이 편은 그 엔진을 처음부터제로베이스 짠 29개 커밋의 시작점을 읽는다.
![[PonsLink] WebRTC 시그널링과 TURN 인프라 구축 회고](/tistory/body-images/2026-08-07-ponslink-webrtc-signaling-turn/cover.webp)
실시간 제품을 만들면 처음엔 영상이 붙는 순간에 환호한다. 그다음 주부터는 NAT 뒤의 사용자, 회사 방화벽, 모바일 전환, 재연결 폭풍을 상대한다. PonsLink의 시그널링·TURN·SFU 구성은 그 실패를 제품 상태로 다루기 위한 결과다.
![[PonsLink] 요청-세션 라이프사이클 설계 — 공개 데스크에서 자동 아카이브까지](/tistory/body-images/2026-08-07-ponslink-request-session-lifecycle/cover.webp)
PonsLink의 핵심 UX 원칙은 다섯 단계다. 방룸은 네 번째 단계에 불과하다. 이 글은 그 다섯 단계가 API·DB·프론트 라우트에 어떻게 내려앉는지 정리한다.
![[PonsLink] 결제 연동과 웹훅 안정성 회고 — 다중 PG와 멱등성](/tistory/body-images/2026-08-07-ponslink-payment-webhook-reliability/cover.webp)
결제는 "성공 페이지를 보여주면 끝나는 일"이 아니다. PonsLink는 Polar를 활성 공급자로 두고, PayPal·LemonSqueezy·Stripe 웹훅 경로를 함께 유지한다. 공급자가 늘수록 문제는 기능이 아니라 중복·지연·재시도다.
![[PonsLink] 데이터 모델 설계 회고 — Postgres 두 개 DB, 37개 테이블](/tistory/body-images/2026-08-07-ponslink-erd-data-model/cover.webp)
서비스를 오래 굴리면 스키마가 제품 결정을 배신한다. PonsLink도 예외가 아니었다. 한때는 "users 하나면 되겠지"로 시작했다가, 지금은 같은 Postgres 인스턴스 안에 두 개의 DB를 둔다. ponslink22테이블와 ponswarp15테이블. 이 글은 2026-08-07 실측 덤프를 기준으로 그 구조를 해설한다.
![[PonsLink] desk.ponslink.com은 어떻게 돌아가는가 — 배포 토폴로지 회고](/tistory/body-images/2026-08-07-ponslink-architecture-how-desk-runs/cover.webp)
PonsLink를 운영하면서 가장 자주 받는 질문은 단순하다. "이 서비스, 실제로 어떻게 붙어 있어요?" 마케팅 문구로는 "요청-우선 세션 데스크"라고 말하지만, 그 문장 뒤에는 nginx 한 줄, Node 컨테이너 한 개, Postgres 두 개의 DB, Redis, coturn, SFU, R2, 결제 공급자 네 곳이 동시에 움직인다. 이 글은 2026-08-07 기준 ssh pon...
![[PonsWarp] 트래픽 숫자보다 먼저 고정한 측정 단위](/tistory/body-images/2026-07-28-ponswarp-what-we-measure-before-traffic/cover.webp)
사용자 수와 평균 파일 크기보다 먼저 정한 것은 크기 구간, 경로 라벨, 완료의 정의였다.
![[PonsWarp] 전송 성공률을 시작 횟수로 세면 안 되는 이유](/tistory/body-images/2026-07-28-ponswarp-transfer-success-is-not-start/cover.webp)
보내기를 누른 횟수는 출발이다. 성공은 파일이 닫히고 내용까지 맞을 때다. 성공률은 그 차이를 드러내는 렌즈여야 한다.
![[PonsWarp] 전송 중 암호화 키가 사라지자 재개가 막혔다](/tistory/body-images/2026-07-28-ponswarp-crypto-resume-incident/cover.webp)
이어받기 버튼은 있었는데 문을 열 열쇠(암호화 세션)가 먼저 사라진 장애였다. 속도 문제가 아니라 복구 조건 문제였다.

OHTTP 같은 복잡한 프라이버시 프로토콜을 테스트할 때 겪는 어려움을 해결하기 위해 등장한 pvcli의 설계와 공개 배경을 살펴본다.
![[PonsWarp] LAN에서 WebRTC가 한계에 부딪혔을 때, stripe가 아니라 cloud로 갈라졌다](/tistory/body-images/2026-07-20-ponswarp-lan-webrtc-stripe-vs-cloud/cover.webp)
대용량 파일을 브라우저끼리 직접 보내는 작업을 하면서 속도 문제를 만났을 때 가장 처음 떠올린 것은 더 빠른 채널을 여는 일이었다. 하지만 LAN에서도 WebRTC DataChannel은 일정 수준 이상으로 올라가지 않았고, 멀티 PC에서 striping을 시도했을 때
![[PonsLink] 특정 Wi-Fi에서 파일 전송이 느려지는 문제를 추적하다](/tistory/body-images/2026-07-20-ponslink-wifi-nat-transfer-slowdown/cover.webp)
PonsWarp의 파일 전송 기능이 완성되어 가던 어느 날, 특정 Wi-Fi 환경에서 전송 속도가 현저히 떨어지는 보고가 나왔다. 같은 파일을 같은 브라우저에서 보내도, 어떤 네트워크에서는 정상이지만 다른 네트워크에서는 마지막 몇 퍼센트에서 멈추거나 수 초 동안 진행률
![[HeartFeed] 고전 문장을 데이팅 조언으로 바꾸는 작업에서 배운 것](/tistory/body-images/2026-07-20-heartfeed-classic-text-dating-advice/cover.webp)
HeartFeed를 처음 만들 때 목표는 단순했다. 사람들의 연애 관련 고민에 AI가 답변해 주는 서비스. 그런데 만들다 보니 "답변해 주는 것" 자체가 문제가 되었다. AI가 너무 빨리, 너무 유창하게 답하면 사용자는 자기 생각을 멈춘다.

개발 문서를 찾아보기 시작하면 약어가 끝없이 나온다. PRD, SRS, SDD 정도만 알아도 복잡한데 BRD, MRD, NFR, ADR, RFC, API Spec, ERD, Runbook, Postmortem까지 이어진다. 처음에는 이걸 전부 외워야 하나 싶다. 로그를 다시 펼쳤을 때 눈에 들어온 건 예쁜 성공 화면이 아니라, 개발 주변에서 반복되던 작은 끊김이었다. 문서를 종류별로 외우면 금방 막힌다. 회사마다 이름이 다르고, 같은 약어도 다르게 쓰인다. TDD만 해도 어떤 팀은 Technical Design Document라고…

제품 판단 문서는 구현 방법을 정하려고 쓰는 문서가 아니다. 왜 이 문제를 풀어야 하는지, 누구에게 가치가 있는지, 어떤 결과를 성공으로 볼지를 정한다. 여기에는 Product Brief, BRD, MRD, PRD, Roadmap이 들어간다. 로그를 다시 펼쳤을 때 눈에 들어온 건 예쁜 성공 화면이 아니라, 제품 주변에서 반복되던 작은 끊김이었다.

요구사항 문서는 “좋게 만들자”를 “무엇을 만족해야 통과인가”로 바꾸는 문서다. 이 층위에는 SRS, SyRS, FRD, NFR, Acceptance Criteria, Traceability Matrix가 들어간다. 같은 단어를 쓰더라도 제품 문맥이 다르면 답이 갈렸다. 그 갈림길을 숨기지 않는 쪽으로 썼다. ISO/IEC/IEEE 29148은 요구사항 공학과 요구사항 산출물의 기준을 다룬다. NASA의 요구사항 작성 가이드는 좋은 요구사항이 necessary, verifiable, attainable, clear,…

구조와 설계 문서는 “무엇을 만족해야 하는가”에서 “어떤 구조로 풀 것인가”로 내려간다. SDD, Technical Design Doc, Architecture Doc, HLD, LLD, C4, arc42, UML이 여기에 들어간다. ```md - Redis를 사용한다. - WebSocket으로 실시간 알림을 보낸다. - PostgreSQL에 저장한다. arc42도 비슷하다. Introduction & Goals, Constraints, Context & Scope, Solution Strategy, Building Block…

큰 설계문서는 자주 낡는다. 처음에는 열심히 쓰지만 코드가 바뀌고 일정이 밀리면 업데이트가 멈춘다. 시간이 지나면 남는 것은 최신 코드와 오래된 문서 사이의 불신이다. 같은 단어를 쓰더라도 제품 문맥이 다르면 답이 갈렸다. 그 갈림길을 숨기지 않는 쪽으로 썼다. 참고: [Michael Nygard, Documenting Architecture Decisions](https://www.cognitect. 좋은 RFC는 찬성표를 모으는 문서가 아니다. 논쟁을 좁히는 문서다. 예를 들면 이렇게 좁힐 수 있다. ADR은…

서비스가 커질수록 내부 구현보다 경계의 약속이 중요해진다. 이 층위에는 API Spec, OpenAPI, AsyncAPI, ERD, Data Dictionary, Schema, ICD가 들어간다. 그리고 배포 뒤에는 Threat Model, Release Plan, Migration Plan, Rollback Plan, Runbook, SLI/SLO/SLA, Postmortem이 필요해진다. 운영만 있으면 나머지는 따라올 거라고 믿었다. 이런 문서는 평소에는 지루하다. 하지만 장애가 나면 가장 빨리 읽히는 문서가 된다. 이…
![[명경] Navid는 왜 실패했고 FateMirror는 어떻게 남았나](/tistory/body-images/2026-07-06-navid-fatemirror-failed-prototype/cover.webp)
처음 만들고 싶었던 건 단순한 사주 결과 페이지가 아니었다. 사주, 동의보감, 별자리, 궁합, 주역을 한 번에 엮어 사용자의 상태와 방향을 종합적으로 보여주는 제품을 만들고 싶었다. 말로만 들으면 그럴듯했다. 한 사람을 한 가지 관점으로만 보지 않고 여러 층으로 읽어주는 개인 분석 도구처럼 보였기 때문이다.
![[P2P 설계] edge와 serverless를 P2P와 섞으면 무엇이 달라질까](/tistory/body-images/2026-07-04-p2p-protocol-09-edge-serverless-hybrid-p2p/cover.webp)
순수 P2P 그림은 매력적이다. 서버는 연결만 돕고 payload는 피어끼리 직접 보낸다. 다이어그램도 깔끔하고 비용 설명도 쉽다. 그런데 제품 화면으로 내려오면 금방 예외가 생긴다. 사용자는 동시에 접속하지 않을 수 있고, 모바일은 중간에 잠들 수 있고, 회사망에서는 TURN이 필요하고, 권한과 결제는 어딘가에서 확인해야 한다.
![[P2P 브라우저] 모바일과 백그라운드는 왜 P2P 전송을 흔들까](/tistory/body-images/2026-07-04-p2p-protocol-08-mobile-browser-background-limits/cover.webp)
데스크톱에서 파일 전송 데모가 잘 돌면 모바일도 비슷할 것 같았다. 같은 WebRTC이고 같은 DataChannel이면 차이는 화면 크기 정도라고 생각하기 쉽다. 실제로는 사용자가 잠깐 메신저를 확인하는 순간부터 조건이 바뀐다. 화면이 꺼지고, 탭이 뒤로 밀리고, OS는 배터리를 아끼고, 저장 권한은 브라우저마다 다르게 보인다. 브라우저만 있으면 나머지는 따라올 거라고 믿었다. 아래에서는 P2P을 중심으로, 깨진 가정과 다시 그은 경계를 순서대로 남긴다.
![[P2P 파일 무결성] chunk hash와 Merkle tree는 왜 필요할까](/tistory/body-images/2026-07-04-p2p-protocol-07-merkle-chunk-integrity/cover.webp)
파일 전송 화면에서 진행률이 96%까지 올라갔다. 사용자는 거의 끝났다고 느끼고, 개발자는 마지막 checksum만 맞으면 된다고 생각한다. 그런데 마지막 hash가 틀리면 제품은 갑자기 난처해진다. 어떤 조각이 깨졌는지 모르니 다시 받을 범위도 말하지 못한다. 로그 보존 기간도 작게나마 정해야 한다. hash 값 자체를 오래 남기는 것이 부담스럽다면, root version과 실패한 chunk index, retry 결과만 남길 수 있다. 사용자가 문의했을 때 필요한 것은 파일 내용이 아니라 어떤 상태에서 멈췄는지다.…
![[TURN 운영] TURN 서버 비용은 어떻게 계산해야 할까](/tistory/body-images/2026-07-04-p2p-protocol-06-turn-server-cost-model/cover.webp)
PonsWarp에서 큰 파일 하나를 보내는데 회사망과 모바일망이 섞여 있다고 해 보자. 화면에는 “연결 중”이라고만 보이지만, 내부에서는 direct가 실패하고 TURN relay로 넘어갈 수 있다. 사용자는 성공 여부와 속도를 본다. 운영자는 relay egress와 region 비용을 본다. **P2P 제품은 어느 순간부터 TURN을 예외가 아니라 비용 모델로 계산해야 할까?**
![[P2P 보안] P2P에서 trust boundary는 어디에 그어야 할까](/tistory/body-images/2026-07-04-p2p-protocol-05-p2p-security-trust-boundary/cover.webp)
PonsWarp에서 초대 링크를 눌러 파일을 받는 장면을 떠올려 본다. 브라우저끼리 직접 연결됐고, 채널은 암호화되어 있고, 서버를 덜 거쳤다. 그래도 안심하기에는 이르다. 상대 peer가 어떤 파일을 요청할 수 있는지, 방 안에서 어떤 행동을 할 수 있는지, 잘못된 요청을 얼마나 자주 보낼 수 있는지는 아직 따로 정해야 한다. **직접 연결이 열렸을 때 제품은 무엇을 믿고, 무엇을 끝까지 의심해야 할까?**
![[P2P 프로토콜] CRDT와 P2P 협업은 왜 같이 이야기될까](/tistory/body-images/2026-07-04-p2p-protocol-04-crdt-p2p-collaboration/cover.webp)
PonsLink 화이트보드에서 두 사람이 같은 도형을 고친다고 상상해 본다. 한 사람은 지하철 안에서 잠깐 오프라인이 되고, 다른 사람은 데스크톱에서 선을 지운다. 몇 분 뒤 두 브라우저가 다시 만나면 화면은 하나로 합쳐져야 한다. 여기서 어려운 질문은 “누가 이겼는가?”가 아니다. **서버가 모든 순서를 정하지 못할 때, 제품은 어떤 변경을 자동으로 합치고 어떤 변경을 설명해야 할까?**
![[P2P 프로토콜] WebTransport와 QUIC은 WebRTC를 대체할까](/tistory/body-images/2026-07-04-p2p-protocol-03-webtransport-quic-vs-webrtc/cover.webp)
대용량 파일을 보내는 화면을 만들고 있다고 생각한다. 첫 번째 시도는 브라우저끼리 직접 붙는다. 두 번째 시도는 서버를 거쳐 빠르게 붙는다. 둘 다 bytes는 흐를 수 있지만 사용자가 동의한 약속은 같지 않다. 실험 결과를 읽을 때도 마지막 성공 여부만 보지 않는다. direct 경로가 성공했지만 ICE 후보 수집에 오래 걸렸다면 사용자는 이미 불안해졌을 수 있다. 서버 endpoint는 빨리 붙었지만 relay byte가 크게 늘었다면 비용 모델이 바뀐다. TURN 전환이 잦다면 WebRTC를 고집한 것이 아니라…
![[P2P 프로토콜] libp2p는 라이브러리보다 경계 묶음에 가깝다](/tistory/body-images/2026-07-04-p2p-protocol-02-libp2p-stack-boundaries/cover.webp)
처음 libp2p를 봤을 때는 P2P를 한 번에 해결해 주는 큰 라이브러리처럼 읽었다. import하고 설정 몇 줄을 넣으면 peer가 생기고, 연결되고, 메시지가 흐를 것 같은 느낌이었다. libp2p가 답을 하나로 고정하지 않는다는 점은 장점이다. TCP, QUIC, WebSocket, WebTransport 같은 transport가 다르고, Noise 같은 보안 계층과 mplex/yamux 같은 stream 경계가 따로 있다. 대신 어떤 모듈을 왜 쓰는지 적지 않으면 선택 비용이 제품 뒤쪽에서 터진다.…
![[P2P 프로토콜] DHT와 BitTorrent식 peer discovery를 다시 보기](/tistory/body-images/2026-07-04-p2p-protocol-01-dht-bittorrent-peer-discovery/cover.webp)
파일 전송 버튼을 눌렀는데 전송률 그래프가 움직이지 않는 장면을 먼저 떠올린다. 사용자는 “느리다”고 말하지만, 실제로는 아직 보낼 상대를 찾지 못한 상태일 수 있다. bytes가 흐르기 전에도 P2P는 이미 실패할 수 있다. 아래에서는 P2P을 중심으로, 깨진 가정과 다시 그은 경계를 순서대로 남긴다.
![[Backpressure 딥다이브] bufferedAmount와 backpressure가 파일 전송을 지킨다](/tistory/body-images/2026-07-04-realtime-network-22-bufferedamount-backpressure-file-transfer/cover.webp)
파일 전송을 만들 때 처음 떠오르는 질문은 속도다. 얼마나 빨리 보낼 수 있는지, chunk를 얼마나 크게 잡아야 하는지, send를 얼마나 자주 호출할 수 있는지부터 궁금해진다. 그런데 브라우저 DataChannel로 큰 파일을 보내려면 속도보다 먼저 봐야 할 값이 있다. 상대와 내 런타임이 지금 이 속도를 받을 수 있는가. 로그를 다시 펼쳤을 때 눈에 들어온 건 예쁜 성공 화면이 아니라, Backpressure 주변에서 반복되던 작은 끊김이었다.
![[DataChannel 딥다이브] reliable과 partially reliable을 어떻게 이해할까](/tistory/body-images/2026-07-04-realtime-network-21-reliable-vs-partially-reliable/cover.webp)
`reliable`이라는 단어는 이름만 보면 늘 좋은 선택처럼 들린다. 손실된 메시지를 다시 보내 준다니 안전해 보이고, 실시간 제품에서도 일단 reliable로 두면 문제가 줄 것 같다. 나도 처음에는 그렇게 이해했다. 그런데 DataChannel을 파일, 커서, 진행률, 제어 명령에 나눠 대입해 보니 질문이 달라졌다. 모든 데이터가 늦게라도 반드시 도착해야 할까.
![[DataChannel 딥다이브] Ordered와 Unordered 전송은 언제 갈라지는가](/tistory/body-images/2026-07-04-realtime-network-20-ordered-vs-unordered-datachannel/cover.webp)
DataChannel 옵션에서 `ordered`를 처음 봤을 때는 순서를 지켜 주는 쪽이 당연히 더 안전하다고 생각했다. 메시지가 보낸 순서대로 도착하면 애플리케이션이 덜 헷갈릴 것 같았고, 반대로 unordered는 뭔가를 포기한 설정처럼 보였다. 그런데 실시간 전송을 조금만 오래 들여다보면 이 감각이 바로 흔들린다. 어떤 데이터는 순서가 생명이고, 어떤 데이터는 늦게라도 순서대로 오는 순간 이미 쓸모가 줄어든다. Ordered만 있으면 나머지는 따라올 거라고 믿었다.
![[DataChannel 딥다이브] WebRTC DataChannel은 파일 전송 API가 아니다](/tistory/body-images/2026-07-04-realtime-network-19-datachannel-is-not-file-api/cover.webp)
DataChannel로 파일을 보낼 수 있다는 말을 처음 들으면 브라우저가 파일 전송 API를 제공하는 것처럼 느껴진다. 실제로는 bytes를 주고받는 통로를 받은 것에 가깝다. 파일 이름, 크기, MIME 타입, chunk 순서, 무결성, 재시도, 저장 위치, 진행률은 DataChannel이 알아서 처리하지 않는다. 이 부분을 애플리케이션 프로토콜로 설계해야 파일 전송 기능이 된다.
![[TURN 딥다이브] P2P인데 왜 TURN 비용이 발생하는가](/tistory/body-images/2026-07-04-realtime-network-18-why-turn-costs-money-in-p2p/cover.webp)
P2P 제품을 만들면서 TURN 비용을 계산한다는 말은 처음엔 이상하게 들린다. 사용자는 피어끼리 직접 보내는 기능을 기대하고, 개발자도 서버 대역폭을 줄이려고 WebRTC를 본다. 그런데 직접 경로가 막히면 TURN 서버가 실제 payload를 대신 받아서 다시 보내야 한다. 그 순간 P2P라는 말만으로는 비용을 설명할 수 없다. TURN은 무료 안전망이 아니라 사용량이 찍히는 릴레이 경로다. 로그를 다시 펼쳤을 때 눈에 들어온 건 예쁜 성공 화면이 아니라, TURN 주변에서 반복되던 작은 끊김이었다. 아래에서는 TURN을…
![[NAT Traversal] NAT traversal은 왜 실패할 수밖에 없는가](/tistory/body-images/2026-07-04-realtime-network-17-why-nat-traversal-fails/cover.webp)
NAT traversal이라는 말을 처음 보면 NAT를 뚫는 기술처럼 들린다. 실제로는 그렇게 단단한 약속이 아니다. 사설망 뒤에 있는 두 장치가 서로 닿을 수 있는 후보를 찾고, 포트 매핑을 이용해 보고, 안 되면 릴레이로 돌아가는 시도에 가깝다. 이 차이를 놓치면 "WebRTC를 쓰면 직접 연결이 된다"는 말이 너무 쉽게 나온다. 좋은 네트워크에서는 맞아 보이지만 회사망, 학교망, 호텔 Wi-Fi, 일부 모바일망에서는 바로 깨진다. Traversal만 있으면 나머지는 따라올 거라고 믿었다.
![[WebRTC 딥다이브] STUN, TURN, ICE를 헷갈리지 않게 정리하기](/tistory/body-images/2026-07-04-realtime-network-16-stun-turn-ice-without-confusion/cover.webp)
WebRTC 연결 로그를 처음 오래 들여다봤을 때 가장 헷갈린 단어가 STUN, TURN, ICE였다. 셋 다 연결을 도와주는 것처럼 보였고 실제 문서에서도 거의 붙어 다닌다. 그래서 초반에는 STUN 서버를 중계 서버처럼 말하거나, TURN을 넣었으니 ICE 과정은 대충 넘어가도 된다고 생각했다. 로그에 `host`, `srflx`, `relay` 후보가 같이 찍히기 시작하자 그 착각이 바로 드러났다. 이름이 비슷해서가 아니라 각자 맡은 일이 다른데 내가 한 덩어리로 부르고 있었던 것이다.
![[WebRTC 딥다이브] WebRTC 연결은 왜 그냥 열리지 않을까](/tistory/body-images/2026-07-04-realtime-network-15-why-webrtc-connection-is-hard/cover.webp)
WebRTC 연결은 버튼 하나로 열리는 것처럼 보인다. 브라우저 API를 호출하고 상대에게 offer와 answer를 주고받으면 끝날 것 같다. 실제로는 그 사이에 꽤 많은 조건이 숨어 있다. 두 사용자가 모두 인터넷에 접속해 있다는 사실과, 서로에게 패킷을 보낼 수 있다는 사실은 다르다. NAT, 방화벽, 회사망, 모바일망, 브라우저 권한, UDP 차단이 모두 연결을 흔든다.
![[SFU vs MCU] SFU와 MCU는 경쟁 관계가 아니라 선택지다](/tistory/body-images/2026-07-04-realtime-network-14-sfu-and-mcu-are-choices/cover.webp)
SFU와 MCU를 비교하면 둘 중 하나를 골라야 할 것처럼 보인다. 나도 처음에는 SFU가 현대적이고 MCU는 예전 방식이라고 단순하게 나눴다. 하지만 실제 제품에서는 그렇게 정리하면 금방 막힌다. SFU는 낮은 지연과 확장에 강하고, MCU는 합성 결과물과 단순한 수신 경험에 강하다. 경쟁이라기보다 서로 다른 위치에 놓는 선택지다. 로그를 다시 펼쳤을 때 눈에 들어온 건 예쁜 성공 화면이 아니라, SFU 주변에서 반복되던 작은 끊김이었다.
![[MCU 딥다이브] MCU는 왜 비싸지만 여전히 필요한가](/tistory/body-images/2026-07-04-realtime-network-13-why-mcu-is-expensive-but-useful/cover.webp)
MCU는 요즘 SFU보다 덜 자주 들린다. 그래서 처음에는 낡은 방식 정도로 생각했다. 그런데 녹화, 방송, 고정 레이아웃, 저성능 단말 지원을 떠올리면 MCU를 그렇게 쉽게 버릴 수 없다. SFU는 미디어를 열어 보지 않고 필요한 곳으로 전달하는 데 가깝고, MCU는 서버가 미디어를 디코딩하고 섞고 다시 인코딩한다. 비싸지만 그만큼 서버가 결과물을 책임질 수 있다. 비싸지만만 있으면 나머지는 따라올 거라고 믿었다.
![[SFU 딥다이브] Simulcast와 SVC는 왜 SFU 옆에 따라붙을까](/tistory/body-images/2026-07-04-realtime-network-12-simulcast-svc-near-sfu/cover.webp)
Simulcast와 SVC를 처음 봤을 때는 코덱 쪽의 어려운 옵션처럼 느껴졌다. 그런데 SFU를 공부하고 나니 둘은 품질을 누가, 어떤 단위로 고를 수 있느냐의 문제였다. SFU가 수신자마다 다른 품질을 전달하려면 고를 재료가 있어야 한다. 송신자가 한 가지 품질만 보내면 SFU는 그것을 전달하거나 끊는 정도밖에 못 한다. 그래서 SFU 옆에는 자연스럽게 simulcast와 SVC가 따라붙는다. 같은 단어를 쓰더라도 제품 문맥이 다르면 답이 갈렸다. 그 갈림길을 숨기지 않는 쪽으로 썼다.

SFU가 Mesh보다 확장에 강하다는 말은 맞다. 다만 이유를 “서버를 쓰니까”로 외우면 핵심이 빠진다. *SFU의 실패는 서버 하나로 단순화되지 않는다. 업로드, 서버 전달, 지역, 품질 선택을 나눠서 봐야 한다. | 구조 | 업로드 책임 | 좋아지는 점 | 새로 생기는 책임 | | --- | --- | --- | --- | | Mesh | 각 참여자가 상대마다 복제 | 서버 미디어 비용이 작고 1:1이 단순하다 | 방이 커질수록 클라이언트 업로드와 CPU가 무거워진다 | | SFU | 서버가 수신자별로 복제 | 참여자 업로드…
![[SFU 딥다이브] SFU는 영상을 섞지 않고 중계한다](/tistory/body-images/2026-07-04-realtime-network-10-sfu-selective-forwarding/cover.webp)
SFU를 처음 공부할 때 가장 헷갈렸던 단어는 미디어 서버였다. 서버가 가운데 있으니 당연히 모든 영상을 받아 새 화면으로 만들어 준다고 생각했다. 그런데 SFU를 따라가 보니 핵심은 합성이 아니라 선택이었다. SFU를 붙인 뒤에도 제품 질문은 남는다. 어떤 subscriber에게 어떤 layer를 보냈는지, 그 선택이 네트워크 때문인지 화면 크기 때문인지, 서버 region 때문인지 알 수 있어야 한다. 앱 로그에는 방 상태와 사용자 행동이 남고, 미디어 서버 로그에는 publisher, subscriber, bitrate,…
![[비교 정리] P2P, Mesh, SFU, MCU를 한 장으로 비교하면](/tistory/body-images/2026-07-04-realtime-network-09-p2p-mesh-sfu-mcu-comparison/cover.webp)
회의 구조를 정리하던 노트에서 P2P, Mesh, SFU, MCU가 같은 줄에 놓였다. 이름만 보면 네 가지 후보를 놓고 하나를 고르는 문제처럼 보인다. 그런데 실제 설계에서는 질문의 층위가 다르다. 로그를 다시 펼쳤을 때 눈에 들어온 건 예쁜 성공 화면이 아니라, 비교 주변에서 반복되던 작은 끊김이었다. 이 질문은 구현 세부보다 앞에 있어야 한다. 나중에 코드가 커진 뒤 상태 이름을 고치려면 로그, 화면 문구, 고객 지원 문장, 알림 정책을 같이 바꿔야 한다. - [ signaling 서버와 media/payload 경로를…
![[Mesh 딥다이브] 연결 수 공식으로 보는 Mesh의 비용](/tistory/body-images/2026-07-04-realtime-network-08-mesh-cost-by-connection-formula/cover.webp)
n(n-1)/2라는 공식은 칠판에서는 가볍다. n명이 들어오면 n개 노드 중 두 노드를 고르는 조합이 몇 개인지만 세면 된다. 그런데 브라우저 네 개를 실제 방에 붙여 보면 숫자의 표정이 달라진다. 선은 여섯 개뿐인데 offer, answer, candidate, 연결 상태, 재시도 로그가 한꺼번에 움직인다. 로그를 다시 펼쳤을 때 눈에 들어온 건 예쁜 성공 화면이 아니라, Mesh 주변에서 반복되던 작은 끊김이었다. 같은 단어를 쓰더라도 제품 문맥이 다르면 답이 갈렸다. 그 갈림길을 숨기지 않는 쪽으로 썼다.
![[WebRTC Mesh] P2P Mesh가 화상통화에서 무너지는 순간](/tistory/body-images/2026-07-04-realtime-network-07-p2p-mesh-breaks-video-call/cover.webp)
피어 투 피어 그물은 처음 보면 정직하다. 서버가 미디어를 안 나르고 브라우저끼리 직접 붙는다. 구조가 단순하니 구현도 단순해 보인다. 작은 방 데모에서는 그 정직함이 매력으로 남는다. 화상통화에서 그 단순함은 빨리 부메랑이 된다. 연결은 됐는데 화면이 얼고 연결됨 표시와 실제 통화 체감이 어긋난다. 이 글은 그물이 왜 화상통화에서 특히 취약한지, 어느 순간 연결 성공이 체감 실패로 바뀌는지 정리한다.
![[Topology 딥다이브] Full Mesh, Partial Mesh, Star 구조를 나눠서 보기](/tistory/body-images/2026-07-04-realtime-network-06-full-partial-mesh-star/cover.webp)
Topology를 처음 그릴 때는 선이 많을수록 더 분산된 구조라고 생각했다. 모두가 모두에게 직접 연결되면 중앙 서버 부담이 줄고, 덜 연결되면 어딘가에 의존한다고 느꼈다. 그런데 실제 제품 관점에서 보니 질문은 조금 달랐다. 로그를 다시 펼쳤을 때 눈에 들어온 건 예쁜 성공 화면이 아니라, Topology 주변에서 반복되던 작은 끊김이었다.
![[Mesh 딥다이브] Mesh는 왜 사람이 늘수록 급격히 무거워질까](/tistory/body-images/2026-07-04-realtime-network-05-mesh-gets-heavy-with-people/cover.webp)
Mesh를 처음 볼 때는 가장 자연스러운 P2P 구조처럼 보였다. 모든 참여자가 서로 직접 연결되어 각자에게 미디어나 데이터를 보내면 중앙 서버는 가벼워진다. 두 명일 때는 설명도 쉽고 구현도 비교적 직관적이다. 문제는 사람이 한 명씩 늘어날 때마다 연결과 업로드 부담이 조용히 곱해진다는 점이다. Mesh는 소규모에서 단순하지만, 규모가 커질수록 각 클라이언트가 작은 서버처럼 일해야 한다. 아래에서는 Mesh을 중심으로, 깨진 가정과 다시 그은 경계를 순서대로 남긴다.
![[WebRTC 딥다이브] P2P에서 signaling 서버는 왜 필요한가](/tistory/body-images/2026-07-04-realtime-network-04-why-p2p-needs-signaling/cover.webp)
WebRTC 예제를 처음 따라 칠 때 막힌 곳은 카메라나 파일 전송이 아니었다. 두 브라우저가 서로 어떤 약속을 주고받아야 하는지, 그 약속을 어디로 보내야 하는지가 먼저 막혔다. WebRTC가 연결과 미디어 처리의 큰 틀을 제공해도 signaling 서버를 대신 만들어 주지는 않는다. 어떤 정보를 어떤 프로토콜로 누구에게 전달할지는 애플리케이션이 정해야 한다. 로그를 다시 펼쳤을 때 눈에 들어온 건 예쁜 성공 화면이 아니라, WebRTC 주변에서 반복되던 작은 끊김이었다.
![[P2P 딥다이브] P2P가 강한 경우와 약한 경우](/tistory/body-images/2026-07-04-realtime-network-03-p2p-strengths-and-limits/cover.webp)
P2P를 공부하다 보면 한동안 모든 문제를 직접 연결로 풀고 싶어진다. 서버 대역폭을 줄이고, 사용자끼리 바로 주고받고, 중앙 병목도 피한다는 설명은 꽤 매력적이다. 하지만 제품에 넣을 기능을 고를 때는 그 매력부터 의심해야 했다. P2P가 강한 장면은 분명히 있지만, 약한 장면에서 억지로 쓰면 서버 비용보다 더 비싼 실패 처리와 운영 복잡도가 돌아온다.
![[P2P 딥다이브] 클라이언트끼리 직접 연결한다는 말의 정확한 의미](/tistory/body-images/2026-07-04-realtime-network-02-direct-connection-meaning/cover.webp)
직접 연결이라는 말은 보기보다 위험하게 단순하다. 처음에는 두 브라우저가 서로의 IP를 알고 곧장 소켓을 여는 장면을 떠올렸다. 같은 공유기 안에 있으면 맞는 그림처럼 보이기도 한다. 하지만 WebRTC를 따라가다 보면 직접 연결은 “주소를 안다”가 아니라 “여러 후보 경로를 모으고 시험한 뒤 실제 패킷이 오갈 수 있는 길을 고른다”에 가깝다.
![[P2P 딥다이브] P2P는 서버가 없다는 뜻이 아니다](/tistory/body-images/2026-07-04-realtime-network-01-p2p-is-not-serverless/cover.webp)
P2P를 처음 공부할 때 제일 먼저 버린 문장은 “서버가 없다”였다. 그 말은 설명을 쉽게 시작하게 해 주지만 실제 설계에는 거의 도움이 되지 않았다. 로그인은 누가 확인하는지, 방은 누가 만들고 닫는지, 링크 권한은 어디에 남는지, 결제나 감사 로그는 누가 책임지는지 묻기 시작하면 서버는 여전히 한가운데에 있다. 달라지는 것은 서버의 존재가 아니라 서버가 모든 바이트를 떠안느냐의 문제였다.
![[명경] 운세가 아니라 자기 해석의 거울로 만들고 싶었다](/tistory/body-images/2026-06-29-main-fatemirror-01-mirror-not-fortune/cover.webp)
사주 서비스를 만들면 쉽게 운세 콘텐츠로 흘러간다. 오늘의 운이나 재물운처럼 짧고 단정적인 문장을 보여 주면 사용자는 곧바로 반응한다. 명경에서 만들고 싶었던 것은 그 반대편이었다. 계산 결과를 맞히는 게임보다, 사용자가 자기 경험을 다른 언어로 살펴보는 시간을 만들고 싶었다.
![[Ruminate] 고전 문장은 답이 아니라 질문으로 쓰여야 했다](/tistory/body-images/2026-06-29-main-ruminate-02-classic-text-as-question/cover.webp)
고전 문장을 쓰는 목적은 결론을 대신하는 데 있지 않았다. “이렇게 살아라”라고 말하기보다 “너는 지금 무엇을 붙잡고 있나”라고 되묻는 쪽에 가까웠다. 사용자가 자기 말을 다시 보고 선택을 스스로 정리할 여지를 남겨야 했다. 오래된 문장을 가져오는 일보다, 그 문장이 현재의 고민을 덮어쓰지 않게 배치하는 일이 더 어려웠다.
![[Ruminate] AI가 너무 빨리 답을 주는 게 오히려 위험했다](/tistory/body-images/2026-06-29-main-ruminate-01-ai-should-not-answer-too-fast/cover.webp)
AI 상담 서비스를 만들 때 가장 쉬운 방향은 빠르게 답을 주는 것이다. 고민을 입력하면 그럴듯한 조언을 만들고, 위로를 붙이고, 다음 행동을 제안한다. 하지만 사람의 고민은 그렇게 빨리 닫아도 되는 문제가 아니었다. 유창한 답은 사용자의 상황을 너무 쉽게 단정하고, 복잡한 감정을 한 문장으로 정리해 버릴 수 있다. Ruminate는 그 위험에서 출발했다.
![[DocuFlow] 민감한 문서는 어디서 처리되는지가 먼저 보여야 했다](/tistory/body-images/2026-06-29-main-docuflow-03-local-security-boundary/cover.webp)
민감한 문서를 다루는 제품에서 “안전하다”는 문장만 크게 보여 주는 방식은 오래 버티지 못한다. 사용자는 파일이 어디에서 열리고, 어떤 처리가 브라우저 안에서 끝나며, 어느 단계에서 서버가 필요한지 알고 싶어 한다. DocuFlow의 문제는 보안 원칙이 없어서가 아니라 그 경계가 화면에 충분히 드러나지 않는다는 데 있었다.
![[PDF마스터] 한국 문서 업무에는 PDF만으로 부족했다](/tistory/body-images/2026-06-29-main-docuflow-02-korean-document-context/cover.webp)
PDF 도구를 만들면서 처음에는 파일을 합치고 나누는 기능이면 충분하다고 생각했다. 실제 문서를 들여다보니 금방 생각이 달라졌다. 한국의 문서 업무에는 HWP와 HWPX가 있고, 스캔본에는 도장과 인감이 들어가며, 제출 직전에는 주민번호나 연락처를 가려야 한다. PDF는 최종 형식일 뿐, 사용자가 해결해야 하는 일 전체를 설명하지 못했다.
![[DocuFlow] PDF 도구를 만들다가 문서 흐름을 보게 됐다](/tistory/body-images/2026-06-29-main-docuflow-01-tools-to-flow/cover.webp)
PDF 도구를 처음 만들 때는 기능 단위로 생각했다. 합치기, 나누기, 회전, 압축, OCR, 마스킹. 각각은 쓸모가 분명했다. 그런데 사용자가 남기는 말은 기능 이름이 아니었다. “또 올려야 해요”, “중간 결과가 맞는지 모르겠어요”, “주민번호 가렸는지 불안해요.” 기능 목록이 늘어날수록 문서 작업은 더 분절됐다. 그 지점에서 시선이 바뀌었다. **도구가 아니라 흐름을 설계해야 한다.**
![[PonsWarp] 대용량 전송에서는 속도보다 흐름 제어가 먼저였다](/tistory/body-images/2026-06-29-main-ponswarp-03-backpressure-before-speed/cover.webp)
대용량 파일 전송을 처음 볼 때는 속도가 가장 중요한 숫자처럼 보였다. 하지만 큰 파일을 실제 브라우저로 보내 보니 문제는 전송률이 아니었다. 보내는 쪽이 계속 밀어 넣는 동안 받는 쪽의 메모리와 저장 속도가 따라오지 못했고, 실패는 마지막 몇 퍼센트에서야 드러났다. PonsWarp에서 ACK는 “받았다”는 표시만을 위한 메시지가 아니었다. 수신자가 다음 데이터를 받을 준비가 됐다는 신호로 써야 했다. 보내는 쪽이 자기 속도만 믿고 앞서가면 전송은 잠깐 빨라 보이지만, 브라우저가 버티지 못하는 순간 전체 작업이 무너진다.
![[PonsWarp] Signaling 서버는 파일 운반자가 아니라 소개자였다](/tistory/body-images/2026-06-29-main-ponswarp-02-signaling-is-matchmaker/cover.webp)
처음에는 PonsLink 안에 있는 파일 전송 기능이었다. 방 안에서 파일을 보내면 충분할 줄 알았는데, 파일 크기가 커질수록 회의방의 리듬까지 흔들렸다. 브라우저끼리 직접 붙이면 서버 부담이 줄고 구조도 단순해질 줄 알았다. 그런데 WebRTC는 연결의 문만 열어줬고, 질서는 제품이 직접 만들어야 했다.
![[PonsWarp] 서버가 파일을 갖지 않는 전송을 만들고 싶었다](/tistory/body-images/2026-06-29-main-ponswarp-01-server-does-not-own-file/cover.webp)
큰 파일을 보낼 때 사람들은 보통 클라우드를 떠올린다. 올리면 링크가 생기고, 상대가 받는다. 익숙하지만 대가는 분명하다. 서버가 파일을 잠시라도 갖게 되고, 권한·보관·삭제·비용 문제가 따라온다. PonsWarp에서 붙잡은 질문은 단순했다. **서버가 파일을 소유하지 않는 전송을 만들 수 있는가.**
![[PonsLink] 실시간 방에서 상태가 어긋나는 순간을 먼저 봐야 했다](/tistory/body-images/2026-06-29-main-ponslink-03-state-resync/cover.webp)
실시간 방은 처음 들어갈 때보다 다시 들어올 때 더 어렵다. 처음 입장은 빈 상태를 만들면 된다. 이미 채팅이 쌓이고 파일이 공유되고 화면 권한이 바뀐 뒤에 돌아오는 사람은, 연결만 성공하면 되는 게 아니라 같은 맥락으로 돌아와야 한다. 처음에는 그 차이를 얕봤다. 카메라와 마이크가 켜지는지, ICE가 붙는지처럼 눈에 보이는 연결 문제부터 고쳤다. 로그에 재연결 성공이 찍히면 일단 안심했다. 그런데 사용자 기억에 남는 사고는 다른 얼굴이었다. 한 사람은 새 파일을 보고, 다른 사람은 옛 목록을 본다. 권한은 서버에서 바뀌었는데…
![[PonsLink] WebRTC Mesh는 단순했지만 한계도 빨리 드러났다](/tistory/body-images/2026-06-29-main-ponslink-02-mesh-limits/cover.webp)
WebRTC Mesh는 단순했지만 한계도 빨리 드러났다. 작은 방에서는 매력적이지만, 방이 커지고 기능이 붙으면 브라우저 부담과 signaling 복잡도가 같이 올라간다. Mesh 화상회의를 볼 때는 연결 모양보다 어느 자원이 어느 참여자에게 쌓이는지가 중요하다. Mesh에서 한 참여자는 자기 카메라 영상을 상대마다 보내야 한다. 참여자가 늘수록 업로드 부담이 선형 이상으로 커진다.
![[PonsLink] 화상회의 앱을 만들려다 협업 방을 만들게 됐다](/tistory/body-images/2026-06-29-main-ponslink-01-room-not-call/cover.webp)
처음에는 화상회의 앱을 만들면 된다고 생각했다. 카메라와 마이크가 켜지고 링크를 보내면 만남은 시작된다. 데모로는 충분했다. 그런데 실제로 사람을 만나게 해 보니 통화 전후의 빈칸이 더 크게 보였다. 상대는 방에 들어오기 전에 용건과 자료를 보내고, 통화가 끝난 뒤에는 다음 약속과 기록을 찾았다. 화면 안에서 대화가 잘 된 것만으로는 일이 이어지지 않았다.
![[PonsWarp] 결국 내가 만든 건 파일 전송 버튼이 아니라 실패를 견디는 흐름이었다](/tistory/body-images/2026-06-29-ponswarp-12b-flow-that-survives-failure/cover.webp)
결국 만든 것은 파일 전송 버튼이 아니라 실패를 견디는 흐름이었다. 처음에는 PonsLink 방 안에서 파일을 보내면 충분할 줄 알았다. 파일이 커지자 전송은 방의 부가기능이 아니라 별도 제품처럼 행동했다. 브라우저 메모리, 수신 속도, 저장 방식, ACK, 백프레셔가 한꺼번에 얽혔다. 보내는 일과 받는 일과 저장하는 일이 같은 속도로 진행되지 않으면 사용자는 전체 전송이 실패했다고 느꼈다.
![[PonsWarp] 결제 성공보다 권한 증명이 먼저였다](/tistory/body-images/2026-06-29-ponswarp-12-entitlement-before-payment/cover.webp)
PonsWarp에서 파일 전송을 별도 제품으로 분리한 뒤에도 결제와 입장 권한의 문제가 남았다. 결제 성공 화면을 보여 주는 것과 실제로 세션에 들어갈 권한을 증명하는 것은 같은 사건이 아니었다. 브라우저의 화면만 믿으면 권한이 확정되기 전에 입장을 열 수 있다. 결제 성공, 권한 부여, 세션 입장은 사용자에게는 한 번의 흐름처럼 보이지만 운영상 분리해야 했다. 결제 상태를 서버가 확인하고, 그 결과로 입장 권한을 발급해야 한다. 화면이 먼저 초록색으로 바뀌었다고 방을 열 수는 없다.
![[PonsWarp] Zero-Copy Pool은 빠른 전송보다 GC를 피하기 위한 선택이었다](/tistory/body-images/2026-06-29-ponswarp-11-zero-copy-pool/cover.webp)
Zero-Copy Pool을 고른 이유는 전송 속도를 더 내고 싶어서가 아니었다. 브라우저에서 큰 파일을 다룰 때 복사와 가비지 컬렉션이 전송의 마지막 구간을 망가뜨리는 일이 무서워졌기 때문이다. 처음에는 PonsLink 방 안에서 파일을 보내면 충분할 줄 알았다. 파일 크기가 커지면서 회의방의 리듬까지 흔들렸다. 파일을 보내는 일과 대화하는 일이 같은 메모리 압박을 공유하고 있었다.
![[PonsWarp] 속도를 올리기 전에 파이프라인의 한계를 먼저 정했다](/tistory/body-images/2026-06-29-ponswarp-10-pipeline-limits-before-speed/cover.webp)
아래에서는 PonsWarp을 중심으로, 깨진 가정과 다시 그은 경계를 순서대로 남긴다. 처음 기대와 실제 제약을 한 번 충돌시킨 뒤 선택 이유가 드러났다. 이 흐름은 브라우저끼리 파일을 직접 보내면서 부딪힌 전송 엔진의 벽을 따라간다. 속도를 올리기 전에 파이 처음에는 PonsLink 안에 있는 파일 전송 기능이었다. 방 안에서 파일을 보내면 충분할 줄 알았는데, 파일 크기가 커질수록 회의방의 리듬까지 흔들렸다. 처음에는 쉽게 정리할 수 있는 문제처럼 보였다. 그런데 직접 만들고 운영해보니 문장 하나로 접히지 않는 갈등이 남았다.…
![[PonsWarp] 브라우저마다 파일 저장 방식이 다르게 깨졌다](/tistory/body-images/2026-06-29-ponswarp-09-browser-download-strategy/cover.webp)
처음에는 PonsLink 안에 있는 파일 전송 기능이었다. 방 안에서 파일을 보내면 충분할 줄 알았는데, 파일 크기가 커질수록 회의방의 리듬까지 흔들렸다. 파일 전송은 처음엔 방 안의 작은 부가기능처럼 보였다. 그런데 파일이 커질수록 이 기능은 방 전체를 흔드는 별도 제품이 됐다. 다음 글에서는 OPFS, ZIP64, 메모리 경계 중 실제 저장 안정화 질문을 넘어간다. 특히 PonsWarp에서는 재현 조건을 같이 적어야 의미가 있었다. 브라우저마다 파일 저장 전송 프로토콜이 같아도 마지막 저장 UX는 브라우저마다 다르고, 그…
![[PonsWarp] 모바일 브라우저가 잠들면 전송은 어떻게 이어져야 하나](/tistory/body-images/2026-06-29-ponswarp-08-mobile-background-resume/cover.webp)
정성적으로는 사용자가 '거의 다 됐는데 왜 깨졌지'라는 불신을 덜 느끼게 하는 게 목표였다. 이때부터 PonsWarp의 기준은 최고 속도가 아니라 끝까지 살아남는 흐름이 됐다. 다음 글에서는 OPFS, ZIP64, 메모리 경계 중 실제 저장 안정화 질문을 넘어간다. 방 안에서 파일을 보내면 충분할 줄 알았는데, 파일 크기가 커질수록 회의방의 리듬까지 흔들렸다. 처음에는 빨리 보내는 쪽이 이기는 줄 알았다. 그런데 속도를 올릴수록 받는 쪽 버퍼가 먼저 비명을 질렀다. 특히 PonsWarp에서는 재현 조건을 같이 적어야 의미가 있었다.
![[PonsWarp] 끊긴 전송에서 partial file을 남기지 않기로 했다](/tistory/body-images/2026-06-29-ponswarp-07-incomplete-transfer-recovery/cover.webp)
전송이 끊기면 처음부터 다시 보내는 게 정석처럼 느껴졌다. 상태도 적고 디버깅도 쉽고 실패하면 재시도라는 문장도 깔끔했다. 작은 파일에서는 그 전략이 실제로 잘 작동했다. 문제는 파일이 커지고 네트워크가 불안정해질 때 시작됐다. 대용량 앞에서 처음부터 다시는 단순한 재시도가 아니라 기다림의 몰수다. 사용자가 길게 쌓은 진행률이 0으로 돌아가면 제품은 기능 실패가 아니라 약속 파기로 읽힌다. 그래서 끊긴 전송에서 부분 파일을 어떻게 다룰지가 복구 품질의 중심이 됐다.
![[PonsWarp] Rust와 WASM은 속도 욕심보다 메모리 생존을 위한 선택이었다](/tistory/body-images/2026-06-29-ponswarp-06b-rust-wasm-memory-survival/cover.webp)
작은 파일에서는 멀쩡하던 전송이 큰 파일에서 브라우저 메모리에 그대로 쌓이던 발견이 먼저 떠오른다. 아래에서는 PonsWarp을 중심으로, 깨진 가정과 다시 그은 경계를 순서대로 남긴다. 그래서 PonsWarp는 별도 제품처럼 분리됐다. 서버가 파일을 들고 있지 않는다는 원칙을 유지하면서도 브라우저가 버틸 수 있는 흐름 제어와 저장 전략을 먼저 세워야 했다. 그래서 대용량 전송을 '더 빠르게'가 아니라 '덜 무너지게' 만드는 과정으로 다시 써야 한다. 특히 PonsWarp에서는 재현 조건을 같이 적어야 의미가 있었다. Rust와…
![[PonsWarp] Cloud Drop은 P2P의 대체재가 아니라 보완재다](/tistory/body-images/2026-06-29-ponswarp-06-cloud-drop-complement/cover.webp)
다음에 남는 질문은 한 문장으로 충분하다. 처음에는 PonsLink 안에 있는 파일 전송 기능이었다. 방 안에서 파일을 보내면 충분할 줄 알았는데, 파일 크기가 커질수록 회의방의 리듬까지 흔들렸다. 처음에는 쉽게 정리할 수 있는 문제처럼 보였다. 그런데 직접 만들고 운영해보니 문장 하나로 접히지 않는 갈등이 남았다. 전송은 단순히 빨리 보내는 문제가 아니었다.
![[PonsWarp] OPFS는 만능키가 아니라 마지막 안전망이었다](/tistory/body-images/2026-06-29-ponswarp-05c-opfs-safety-net/cover.webp)
IndexedDB를 떠올렸다가 브라우저가 제공하는 파일 시스템 경계를 다시 살펴보게 된 흐름같은 단어를 쓰더라도 제품 문맥이 다르면 답이 갈렸다. 그 갈림길을 숨기지 않는 쪽으로 썼다. 정성적으로는 사용자가 '거의 다 됐는데 왜 깨졌지'라는 불신을 덜 느끼게 하는 게 목표였다. 처음에는 PonsLink 안에 있는 파일 전송 기능이었다. 방 안에서 파일을 보내면 충분할 줄 알았는데, 파일 크기가 커질수록 회의방의 리듬까지 흔들렸다. 큰 파일이 브라우저 메모리에 그대로 쌓이는 걸 보고 나서 저장소를 다시 봤다. 처음에는…
![[PonsWarp] 2GB를 넘기자 브라우저 메모리가 먼저 무너졌다](/tistory/body-images/2026-06-29-ponswarp-05b-browser-memory-2gb/cover.webp)
작은 파일에서는 멀쩡하던 전송이 큰 파일에서 브라우저 메모리에 그대로 쌓이던 발견이 먼저 떠오른다. 그래서 대용량 전송을 '더 빠르게'가 아니라 '덜 무너지게' 만드는 과정으로 다시 써야 한다. 전송은 단순히 빨리 보내는 문제가 아니었다. 브라우저 메모리, 수신 속도, 저장 방식, ACK, 백프레셔가 한꺼번에 걸렸다. 특히 큰 파일에서는 마지막 몇 퍼센트에서 실패하는 순간이 제일 잔인했다. 전송 성공과 저장 성공은 같은 말이 아니었다. 2GB 근처에서 깨지는 문제는 속도 문제가 아니라 제품이 파일을 어디에 들고 있느냐의 문제였다.
![[PonsWarp] ZIP64가 필요해진 순간](/tistory/body-images/2026-06-29-ponswarp-05-zip64-streaming/cover.webp)
속도를 더 내고 싶어서가 아니라 브라우저 메모리와 큰 파일 경계가 무서워진 순간이었다. ZIP64만 있으면 나머지는 따라올 거라고 믿었다. 이 흐름은 큰 파일이 브라우저 메모리에서 무너지지 않게 저장 경계를 다시 잡는 과정이다. 방 안에서 파일을 보내면 충분할 줄 알았는데, 파일 크기가 커질수록 회의방의 리듬까지 흔들렸다. Rust와 WASM은 멋있어서 붙인 장식이 아니었다. 브라우저 안에서 큰 파일을 다루다 보니 복사와 메모리 경계가 계속 무서워졌다.
![[PonsWarp] ACK 하나 때문에 전송이 멈추고 살아났다](/tistory/body-images/2026-06-29-ponswarp-04b-ack-backpressure-battle/cover.webp)
전송이 빠른데도 수신 쪽이 밀리면서 멈추는 장면이 있었다. 처음에는 더 빠르게 보내면 해결될 것 같았다. 실제로는 속도를 올릴수록 브라우저 버퍼가 먼저 한계에 닿았다. 큰 파일에서 마지막 몇 퍼센트가 깨지는 이유도 그곳에 있었다. ACK는 받았다는 표시만이 아니었다. 수신자가 다음 조각을 받을 준비가 됐다는 신호로 사용해야 했다. 보내는 쪽이 자기 속도만 믿고 계속 밀어 넣으면 전송은 빨라 보이지만 실제로는 더 빨리 깨진다.
![[PonsWarp] 백프레셔는 기다림이 아니라 보호 장치였다](/tistory/body-images/2026-06-29-ponswarp-04-backpressure-protects-transfer/cover.webp)
빠르게 보내는 것보다 채널이 받아줄 수 있는 만큼만 보내는 일이 안정적인 전송을 만들었다. 백프레셔를 제품 기준으로 삼은 이유를 적었다.
![[PonsWarp] WebRTC는 파일을 보내주지 않는다, 길만 열어준다](/tistory/body-images/2026-06-29-ponswarp-03-webrtc-opens-the-road/cover.webp)
WebRTC를 처음 붙잡을 때는 약간의 낙관브라우저끼리 직접 연결되고, data channel이 열리고, 그 위로 바이트를 보내면 파일 전송의 큰 문제가 해결될 것 같았다. 서버에 파일을 올리지 않아도 되고, 두 사람이 바 는 점이 매력적이었다. 그런데 PonsWarp를 정리하면서 더 분명해진 건 WebRTC가 파일을 보내주는 기술이 아니라 길을 열어주는 기술이라는 점이었다. 아래에서는 PonsWarp을 중심으로, 깨진 가정과 다시 그은 경계를 순서대로 남긴다.
![[PonsWarp] 데스크탑 앱까지 갔지만, 테스트할 기기가 없었다](/tistory/body-images/2026-06-29-ponswarp-02b-desktop-testing-fatigue/cover.webp)
처음 기대와 실제 제약을 한 번 충돌시킨 뒤 선택 이유가 드러났다. 데스크탑 앱까지 갔지만, 그래서 요청 흐름은 PonsLink가 회의 도구보다 연결 설계에 가까워진 이유를 보여준다. 정성적으로는 사용자가 '거의 다 됐는데 왜 깨졌지'라는 불신을 덜 느끼게 하는 게 목표였다. 전송은 단순히 빨리 보내는 문제가 아니었다. 브라우저 메모리, 수신 속도, 저장 방식, ACK, 백프레셔가 한꺼번에 걸렸다. 특히 큰 파일에서는 마지막 몇 퍼센트에서 실패하는 순간이 제일 잔인했다. 요청을 먼저 받는 일은 사람을 거르는 장치가 아니었다.…
![[PonsWarp] Direct와 Cloud Drop을 나눈 이유](/tistory/body-images/2026-06-29-ponswarp-02-direct-cloud-drop-modes/cover.webp)
처음에는 클라우드로 보낸다면 동작이 하나일 거라고 생각했다. 파일을 집어서 올리면 끝. 직접 전송이 어려울 때 쓰는 보조 레일 정도로 보였다. 실제로는 폴더인지 파일인지, 구조를 보존하는지, 이름 충돌을 어떻게 처리하는지, 실패 시 부분 결과를 남기는지에 따라 완전히 다른 제품이 된다. 모드를 안 가르면 사용자는 끝까지 올라갔나를 확신하지 못한다.
![[PonsWarp] TB급 전송을 꿈꾸자 데이터 그리드가 필요해졌다](/tistory/body-images/2026-06-29-ponswarp-01b-data-grid-tb-experiment/cover.webp)
처음에는 PonsLink 안에 있는 파일 전송 기능이었다. 방 안에서 파일을 보내면 충분할 줄 알았는데, 파일 크기가 커질수록 회의방의 리듬까지 흔들렸다. 파일 전송은 처음엔 방 안의 작은 부가기능처럼 보였다. 그런데 파일이 커질수록 이 기능은 방 전체를 흔드는 별도 제품이 됐다. 전송은 단순히 빨리 보내는 문제가 아니었다. 브라우저 메모리, 수신 속도, 저장 방식, ACK, 백프레셔가 한꺼번에 걸렸다.
![[PonsWarp] 브라우저끼리 대용량 파일을 직접 보내고 싶었다](/tistory/body-images/2026-06-29-ponswarp-01-browser-direct-transfer/cover.webp)
정성적으로는 사용자가 '거의 다 됐는데 왜 깨졌지'라는 불신을 덜 느끼게 하는 게 목표였다. 다음 글에서는 OPFS, ZIP64, 메모리 경계 중 실제 저장 안정화 질문을 넘어간다. 특히 PonsWarp에서는 재현 조건을 같이 적어야 의미가 있었다. 전송 프로토콜이 같아도 마지막 저장 UX는 브라우저마다 다르고, 그 차이가 “전송 성공” 판단을 흔들었다는 문제가 있었다. 브라우저끼리 대용량 파일 특히 PonsWarp에서는 재현 조건을 같이 적어야 의미가 있었다.
![[PonsWarp] 파일 전송은 PonsLink 안에서 먼저 고장났다](/tistory/body-images/2026-06-29-ponswarp-00-file-transfer-broke-in-ponslink/cover.webp)
파일 전송을 처음부터 별도 제품으로 생각한 건 아니었다. PonsLink 방에 사람이 붙어 있으니, 파일 하나 보내는 버튼 정도는 자연스러워 보였다. 붙여 보니 먼저 무너진 것은 버튼 위치가 아니라 **약속의 크기**였다. 사용자는 “파일 전송” 한 줄로 이해하지만, 제품은 연결·진행률·메모리·저장 완료·실패 후 재시도를 모두 설명해야 했다.
![[PonsLink] Product Hunt보다 먼저 직접 판매를 봐야 한다고 판단했다](/tistory/body-images/2026-06-28-ponslink-product-24-direct-sales-before-product-hunt/cover.webp)
Product Hunt에 올리고 싶은 마음은 있었다. 공개 런치는 빠르게 반응을 모으고, 만든 사람에게는 출시했다는 확신을 준다. 하지만 그 확신이 실제 구매 의사와 같은지는 알 수 없다. 그래서 먼저 직접 팔아 보기로 했다. 한 사람에게 제품을 설명하고 가격을 말하는 동안, 랜딩 페이지의 문장만으로는 보이지 않던 질문이 나왔다. 누구에게 필요한지, 어떤 약속에 돈을 내는지, 설명이 막히는 지점이 어디인지였다.
![[PonsLink] DM은 워크플로가 아니라는 문장을 붙잡게 된 이유](/tistory/body-images/2026-06-28-ponslink-product-23-dm-is-not-workflow/cover.webp)
DM은 시작점으로는 훌륭하다. 가볍게 말을 걸고, 상대의 온도를 확인하고, 링크 하나를 건네기에는 가장 빠른 채널이다. 문제는 그 다음이었다. 상담 요청이 DM 안에서 계속 이어지면 상태와 권한, 기록과 결제, 재방문 약속이 대화의 스크롤 속에 묻힌다. 사람이 기억하는 동안에는 굴러가지만, 문의가 겹치는 순간 운영은 기억력 게임이 된다.
![[PonsLink] 첫 고객은 넓은 미팅 시장이 아니라 DM 상담을 받는 1인 전문가였다](/tistory/body-images/2026-06-28-ponslink-product-22-first-customer-definition/cover.webp)
첫 고객을 넓은 미팅 시장으로 잡으면 메시지가 바로 흐려진다. 상담도 하고, 회의도 하고, 파일도 주고받는 모든 사람을 고객이라고 부르는 순간 랜딩과 폼과 가격이 서로 다른 사람을 부른다. 나는 첫 고객을 DM 상담을 받는 1인 전문가로 좁혔다. 혼자 상담하고, 채널은 DM이며, 일정과 사전 질문을 정리하고 싶은 사람이다. 시장을 포기한 것이 아니라 지금 당장 관찰할 장면을 하나로 고른 것이다.
![[PonsLink] 정식 유료 출시를 No-Go로 판단한 이유](/tistory/body-images/2026-06-28-ponslink-product-21-no-go-paid-launch/cover.webp)
정식 유료 출시를 앞두고 No-Go를 적었다. 기능이 모자라서가 아니었다. 결제는 붙었고 방도 열렸고 랜딩도 있었다. 겉으로는 Go라고 부를 근거가 충분했다. 하지만 돈이 움직인 뒤 운영자가 어떤 문장으로 설명할지는 정해지지 않았다. 실패하면 누구에게 무엇을 알려야 하는지, 환불과 보류를 어디에서 나눌지, 첫 고객을 누구라고 부를지 흔들렸다. 출시 버튼을 누르는 것보다 그 질문에 답하는 일이 먼저였다.
![[PonsLink] 랜딩 페이지를 미팅 앱 소개에서 요청 선별 이야기로 바꿨다](/tistory/body-images/2026-06-28-ponslink-product-20-landing-repositioning/cover.webp)
랜딩 페이지를 미팅 앱 소개처럼 만들고 있었다. 기능 목록과 화면 캡처, 연결이 안정적이라는 설명을 앞에 놓으면 사람들이 PonsLink를 이해할 거라고 생각했다. 그런데 실제 사용 이유는 통화 품질이 아니었다. DM으로 들어온 요청을 정리하고, 누구와 어떤 이야기를 해야 하는지 미리 맞춘 뒤 덜 민망하게 만나는 일이었다. 랜딩이 제품을 설명하지 못하니 방문자는 익숙한 미팅 앱과 비교했고, PonsLink가 풀려는 문제는 첫 화면에서 사라졌다.
![[PonsLink] 요청 상태 토큰과 세션 입장 토큰을 같은 것으로 둘 수 없었다](/tistory/body-images/2026-06-28-ponslink-product-19-token-permission-boundaries/cover.webp)
요청 상태를 보는 토큰과 세션에 입장하는 토큰을 처음에는 비슷하게 취급했다. 하지만 상태 링크가 유출됐다고 입장 권한까지 열려서는 안 된다. 입장 토큰이 오래 살아 있으면 끝난 방에 다시 들어오는 문제도 생긴다. 상태 토큰은 읽기와 짧은 수명, 좁은 스코프를 가져야 했다. 입장 토큰은 승인과 결제 이후에 세션 단위로 발급하고, 방이 끝나거나 권한이 바뀌면 폐기할 수 있어야 했다. 이름이 비슷해도 책임은 달랐다.
![[PonsLink] 요청에서 룸까지 가는 길을 줄이는 것이 가장 어려웠다](/tistory/body-images/2026-06-28-ponslink-product-18-meeting-flow-simplification/cover.webp)
요청에서 룸까지 가는 길을 줄이는 일이 기능 추가보다 어려웠다. 결제, 보류, 재요청, 일정 변경은 각각 필요한 예외였지만, 하나씩 붙이다 보니 사용자가 지나야 할 단계가 계속 늘었다. 각 단계는 옳아 보이는데 전체 경로는 길어졌다. 사용자는 지금 무엇을 결정해야 하는지 잃었고, 운영자는 예외를 설명하는 데 시간을 썼다.
![[PonsLink] 관리자 OTP 대시보드는 운영 책임을 화면으로 옮기는 일이었다](/tistory/body-images/2026-06-28-ponslink-product-17-admin-otp-ops/cover.webp)
관리자 OTP 대시보드는 보안 기능처럼 보이지만 본질은 운영 책임을 화면으로 옮기는 일이었다. 예전에는 코드 발급과 예외 승인, 긴급 입장이 메신저에 흩어질 수 있었다. 그러면 기록이 남지 않고 누가 어떤 판단을 했는지도 흐려진다. OTP를 만들 수 있는 것만으로는 운영 도구가 되지 않는다. 언제 발급됐는지, 만료됐는지, 사용됐는지, 어떤 액터가 실행했는지가 한곳에서 보여야 한다. 그래야 예외를 허용한 뒤에도 그 예외가 어떻게 끝났는지 확인할 수 있다.
![[PonsLink] 결제 성공 화면만으로 세션을 열 수는 없었다](/tistory/body-images/2026-06-28-ponslink-product-16-webhook-pending-access/cover.webp)
결제 성공 화면은 기분 좋은 초록색이다. 하지만 그 화면만 보고 세션을 열면 안 됐다. 브라우저는 성공을 먼저 보여줄 수 있고, 웹훅은 늦게 도착할 수 있다. 같은 이벤트가 중복으로 들어오는 경우도 있다. 화면과 서버 상태를 같은 진실로 취급하면 권한 없는 입장이 열릴 수 있다. 성공 UI 뒤에는 pending access 상태가 필요했다. 서버가 결제 확정을 반영하기 전까지는 방을 열지 않고, 사용자는 지금 결제 확인 중이라는 사실을 알아야 한다. “결제 확인 중”과 “입장 가능”을 같은 화면 문장으로 뭉개면 기다림이 오류처럼…
![[PonsLink] Polar 결제 런칭은 체크아웃 버튼보다 준비 게이트가 더 중요했다](/tistory/body-images/2026-06-28-ponslink-product-15-polar-paid-launch/cover.webp)
Polar 결제 런칭을 앞두고 체크아웃 버튼에 눈이 갔다. 버튼이 눌리는 순간을 런칭으로 생각하기 쉽지만, 실제 준비는 그보다 길었다. 요청의 질, 환불 정책 문장, 결제 실패 상태, 관리자가 멈출 수 있는 경로가 비어 있으면 결제만 먼저 열리는 셈이다. 체크아웃 전에 미완 요청을 결제해도 되는지, 성공과 실패와 대기를 어떻게 구분할지 확인했다. 결제가 성공해도 권한 부여가 끝나지 않을 수 있으므로 운영자가 상태를 확인하고 중단할 수 있어야 했다. 버튼은 마지막에 붙는 표면일 뿐, 그 앞의 조건이 런칭의 본체였다.
![[PonsLink] 플랜 집행은 버튼 비활성화가 아니라 제품 신뢰의 문제였다](/tistory/body-images/2026-06-28-ponslink-product-14-plan-enforcement-trust/cover.webp)
플랜 집행을 버튼 비활성화로만 처리하면 화면은 조용해 보인다. 사용자는 오히려 왜 안 되는지 알 수 없어서 제품을 버그처럼 느낀다. 한도에 도달했는지, 결제가 필요한지, 현재 권한으로는 할 수 없는지에 따라 다음 행동도 달라져야 했다. 플랜 경계는 백엔드의 판정만으로 끝나지 않는다. UI에는 상태와 이유, 사용자가 선택할 수 있는 다음 행동이 함께 있어야 한다. 같은 비활성 버튼이라도 한도 소진과 권한 부족은 서로 다른 문제다. 둘을 같은 문장으로 처리하면 사용자는 무엇을 고쳐야 하는지 알 수 없다.
![[PonsLink] 한도를 넘겨도 방문자 요청은 버리지 않기로 했다](/tistory/body-images/2026-06-28-ponslink-product-13-limit-without-dropping-requests/cover.webp)
한도에 닿으면 요청을 막아 버리기 쉽다. 구현도 단순하다. 더 이상 받지 않는다는 문장을 보여 주고 입력을 닫으면 된다. 나는 한도를 넘어도 방문자의 요청은 버리지 않기로 했다. 한도는 내 처리 용량이지, 상대가 가진 용건의 가치가 아니기 때문이다. 방문자는 입력창이 닫히면 서비스가 끝났다고 생각한다. 운영자는 그 기간에 들어온 요청이 있었다는 사실조차 보지 못한다. 용량을 보호하려다 수요의 신호까지 버리는 셈이다.
![[PonsLink] Free 10건과 Pro 구조를 이렇게 나눈 이유](/tistory/body-images/2026-06-28-ponslink-product-12-pricing-free-pro/cover.webp)
Free 10건과 Pro를 나눌 때 먼저 정한 것은 가격이 아니라 무료 사용자가 어디까지 제품을 이해해야 하는가였다. 기능을 잠가 결제를 유도하면 사용자는 제품을 경험하기 전에 벽을 만난다. 반대로 모든 것을 열어 두면 운영 비용과 요청 품질을 감당하기 어려워진다. Free는 요청을 보내고, 선별을 거쳐, 방에 들어가는 한 사이클을 끝까지 돌릴 수 있어야 했다. 그래야 사용자가 PonsLink의 약속을 판단할 수 있다. 제한은 기능을 숨기는 대신 사용량의 속도를 조절하는 쪽에 두었다. 10건이라는 숫자는 제품의 핵심 경험을 막지…
![[PonsLink] Google Form은 수집하고, PonsLink는 흐름을 이어야 했다](/tistory/body-images/2026-06-28-ponslink-product-11-google-form-flow/cover.webp)
Google Form은 수집에 강하다. 배포가 쉽고 응답이 쌓인다. 부족했던 것은 그다음이었다. 응답은 스프레드시트에 남고, 일정은 다른 탭에 있고, 방은 또 다른 링크가 되면 사람이 직접 흐름을 이어 붙여야 한다. 사람이 여유가 있을 때는 스프레드시트와 일정과 방을 연결할 수 있다. 하지만 응답이 늘거나 담당자가 바뀌면 누가 다음 행동을 해야 하는지가 흐려진다. 수집 도구가 부족해서가 아니라, 수집 이후의 승인과 상태와 입장이 다른 곳에 흩어져 있기 때문이다.
![[PonsLink] Calendly와 PonsLink는 경쟁이 아니라 순서가 다르다](/tistory/body-images/2026-06-28-ponslink-product-10-calendly-sequence/cover.webp)
처음 기대와 실제 제약을 한 번 충돌시킨 뒤 선택 이유가 드러났다. Calendly와 Pon 다음에 남는 질문은 한 문장으로 충분하다. 일정을 먼저 잡는 방식과 요청을 먼저 확인하는 방식은 순서부터 달랐다. 일정을 먼저 잡는 방식과 요청을 먼저 확인하는 방식은 순서부터 달랐다. 처음에는 회의방 하나면 충분할 줄 알았다. 링크를 만들고, 상대에게 보내고, 같은 화면 안에서 말하고 파일을 주고받으면 된다고 봤다. 돈을 받는 순간 제품의 무게가 달라졌다. 버튼 하나를 붙이면 끝날 줄 알았지만, 실제로는 누가 어떤 권한으로 언제 들어올…
![[PonsLink] 멘토링과 코칭 요청 폼에는 다른 질문이 필요했다](/tistory/body-images/2026-06-28-ponslink-product-09-mentoring-coaching-template/cover.webp)
멘토링 요청과 코칭 요청을 같은 폼에 넣었다가 후회했다. 둘 다 대화인데, 필요한 재료가 달랐다. 멘토링은 경험 공유와 방향 제안이 중심이다. 코칭은 상대의 목표·장애물·다음 행동이 중심이다. 템플릿을 둘로 나눴다. 멘토링: 맥락, 이미 시도한 것, 기대하는 조언 범위. 코칭: 목표, 성공 기준, 지금 막힌 한 지점.
![[PonsLink] 유료 상담은 결제보다 요청의 질을 먼저 봐야 했다](/tistory/body-images/2026-06-28-ponslink-product-08-paid-consultation-quality/cover.webp)
유료 상담을 붙이면 결제 연동부터 보고 싶어진다. 체크아웃과 영수증, 웹훅이 연결되면 제품이 완성된 것처럼 보인다. 현장에서는 결제가 되어도 요청의 질이 낮으면 상담이 망가졌다. 운영자는 준비되지 않은 만남을 수습하고, 상대는 돈을 냈는데 원하는 대화를 하지 못했다고 느낀다.
![[PonsLink] Meeting Records는 회의록이 아니라 요청 맥락의 연장이다](/tistory/body-images/2026-06-28-ponslink-product-07-meeting-records/cover.webp)
내가 필요했던 건 그전에 있었다. 요청 맥락이 회의 뒤로 이어지는 자리. 방에 들어가기 전 용건이 비면 회의록은 빈 대화를 예쁘게 정리할 뿐이다. 요청에 적힌 목표, 제약, 기대가 기록의 뼈대가 되어야 한다. 기록 화면 상단에 요청 요약을 고정했다. 회의 중 메모는 그 아래에 쌓인다. 끝난 뒤에도 "왜 만났는지"가 먼저 보인다.
![[PonsLink] 승인과 입장을 같은 순간으로 만들지 않기로 했다](/tistory/body-images/2026-06-28-ponslink-product-06-session-access/cover.webp)
요청을 수락했다고 바로 방에 들여보내면 편하다. 한동안 그렇게 했다. 승인은 "이 사람과 일해볼 만하다"는 결정이다. 입장은 "지금 이 세션에 들어와도 된다"는 결정이다. 둘을 묶으면 일정 전 링크 유출, 재입장 혼선, 결제 전 입장 같은 예외를 설명하기 어려워진다. 승인 후에도 입장 토큰은 따로 발급한다. 시간창, 세션 상태, 결제 조건을 통과해야 문이 열린다. 사용자는 단계가 늘어 보이지만, 막혔을 때 이유가 분리되어 보인다.
![[PonsLink] Request Status는 요청 제출 뒤의 침묵을 줄이기 위한 화면이었다](/tistory/body-images/2026-06-28-ponslink-product-05-request-status/cover.webp)
요청을 보내고 나면 방문자는 조용한 화면에 남는다. 접수됐는지, 읽혔는지, 보류인지, 거절인지 알 수 없다. 내부에서는 한 단계만 밀린 것일 수 있다. 바깥에서는 무시당한 느낌이 된다. 같은 지연도 상태를 보여 주느냐에 따라 전혀 다르게 해석된다. 요청이 접수됐다는 안내만으로는 부족했다. 방문자는 다음 행동을 알 수 없고, 운영자는 같은 질문에 계속 답해야 했다. “확인됐나요?”라는 DM은 내부에서 실제로 어떤 일이 진행되는지 보여 주지 못한다.
![[PonsLink] 좋은 요청, 보류할 요청, 거절할 요청은 문장 몇 줄에서 갈렸다](/tistory/body-images/2026-06-28-ponslink-product-04-approve-hold-decline/cover.webp)
처음에는 편의를 위한 상태값처럼 보였다. 실제로는 문장 몇 줄로 사람을 나누는 운영 도구였다. 전부 수락하면 일정이 터진다. 전부 거절하면 문이 닫힌다. 문제는 중간이다. 정보가 부족한 요청을 수락해 버리면 방 안에서 다시 질문부터 시작한다. 보류를 "나중에"가 아니라 제품 상태로 올렸다. 무엇을 더 알아야 하는지, 상대에게 어떤 한 줄을 돌려줄지 보류 사유를 남기게 했다. 거절도 차갑지 않게, 분명하게.
![[PonsLink] 공개 요청 링크는 어디에 붙이느냐보다 앞 문장이 더 중요했다](/tistory/body-images/2026-06-28-ponslink-product-03-public-request-link-setup/cover.webp)
공개 요청 링크를 어디에 붙일지 한참 고민했다. 프로필 바이오, 카톡 자동응답, 이메일 서명. 링크만 덩그러니 있으면 사람들은 자기 방식으로 쓴다. 긴 하소연, 한 줄 영업, 의미 불명 테스트 제출. 링크 앞에 기대 행동을 적었다. 무엇을 남기면 좋은지, 언제 답을 주는지, 어떤 요청은 받지 않는지.
![[PonsLink] Public Desk는 문의 폼이 아니라 첫 번째 운영 게이트다](/tistory/body-images/2026-06-28-ponslink-product-02-public-desk-gate/cover.webp)
Public Desk를 처음 붙일 때는 문의 폼이라고 불렀다. 이름부터 틀렸다. 폼은 제출을 받지만, 제출된 요청을 어떻게 판단할지는 말해 주지 않는다. 운영자는 메일함에서 다시 읽고, 캘린더를 열고, 별도 링크를 보냈다. 방문자는 보냈으니 끝났다고 생각했지만 내부에서는 아직 분류도 되지 않은 상태였다.
![[PonsLink] DM 상담 요청을 바로 예약으로 보내지 않기로 한 이유](/tistory/body-images/2026-06-28-ponslink-product-01-dm-screening/cover.webp)
DM을 곧바로 캘린더 예약으로 보내면 시간은 잡히지만 상담의 용건은 비어 있었다. 요청을 먼저 확인해야 하는 이유를 정리했다.
![[PonsLink] 결국 남은 것은 제품을 버티게 한 작은 경계들](/tistory/body-images/2026-06-18-ponslink-deep-dive-10-patterns-tests/cover.webp)
겉으로 보이는 선택은 단순했지만, 안쪽에는 실패를 줄이려는 기준그 기준을 보여주지 않으면 글은 금방 보고서처럼 마른다. 다음에 남는 질문은 한 문장으로 충분하다. 결국 남은 것은 제품을 그래서 결론보다 그 결론까지 밀려간 과정을 먼저 보여주는 쪽으로 다시 잡는다. 처음에는 회의방 하나면 충분할 줄 알았다. 링크를 만들고, 상대에게 보내고, 같은 화면 안에서 말하고 파일을 주고받으면 된다고 봤다. 처음에는 쉽게 정리할 수 있는 문제처럼 보였다. 그런데 직접 만들고 운영해보니 문장 하나로 접히지 않는 갈등이 남았다. 패턴을 테스트로…
![[PonsLink] PonsCast가 화면 공유 대신 DataChannel을 택한 이유](/tistory/body-images/2026-06-18-ponslink-algorithm-10-ponscast-tradeoff/cover.webp)
PonsCast를 만들며 화면 공유를 먼저 떠올린 것은 자연스러웠다. 화면 공유는 상대가 무엇을 보는지 바로 맞출 수 있다. 하지만 우리가 해결하려던 문제는 같은 화면을 복제하는 일이 아니었다. 각자가 가진 파일을 상대의 언어와 시간에 맞춰 함께 읽는 일이었다. 화면 공유는 쉬운 답처럼 보였지만 파일의 구조와 상태를 직접 다루기 어렵다. 보는 사람은 화면을 받지만 파일을 함께 읽고 조작하는 흐름과는 거리가 있다. 네트워크가 흔들리면 화면 전체가 영향을 받고 무엇을 기준으로 다시 맞출지도 분명하지 않다.
![[PonsLink] 결제 버튼보다 어려운 것은 입장 권한이다](/tistory/body-images/2026-06-18-ponslink-deep-dive-09-payment-access/cover.webp)
돈을 받는 순간 제품의 무게가 달라졌다. 결제 버튼을 붙이면 끝날 줄 알았지만 실제로는 누가 어떤 권한으로 언제 세션에 들어올 수 있는지가 더 어려웠다. 결제 성공과 권한 부여, 세션 입장은 사용자에게 한 번의 흐름처럼 보인다. 운영에서는 서로 다른 사건으로 다뤄야 했다. 결제가 확인되지 않았는데 입장이 열리거나 결제는 됐는데 권한이 늦게 생기면 어느 화면을 진실로 볼지 설명할 수 없기 때문이다.
![[PonsLink] 마이크와 PonsCast 오디오를 함께 살리는 라우팅](/tistory/body-images/2026-06-18-ponslink-algorithm-09-ponscast-audio/cover.webp)
PonsCast 오디오를 방 안의 마이크와 함께 다루면서 소리를 한 줄의 미디어 스트림으로 볼 수 없다는 걸 알게 됐다. 사용자의 목소리와 파일에서 재생되는 소리는 출처와 역할이 다르다. 둘을 같은 방식으로 섞으면 말하는 사람의 목소리가 묻히거나 재생 소리가 예상하지 못한 방식으로 전달될 수 있다.
![[PonsLink] 방을 만들기 전에 요청을 먼저 받는 이유](/tistory/body-images/2026-06-18-ponslink-deep-dive-08-request-first/cover.webp)
DM이나 문의가 흩어져 들어오고, 정작 회의 전에 맥락이 사라지던 일방을 먼저 만들면 그 맥락을 제품 안에 담을 곳이 없다. 링크를 먼저 보내고 안에서 해결하자는 패턴은 빠르지만, 기본 경로가 복구 모드가 된다. host는 매번 "무슨 일로 오셨죠?"를 다시 묻는다. 에서 보면 요청 흐름은 PonsLink가 회의 도구보다 연결 설계에 가까워진 이유를 보여준다. 방은 결과일 뿐, 시작점이 아니다.
![[PonsLink] PonsCast 파일 감지와 캐시 전략의 기준](/tistory/body-images/2026-06-18-ponslink-algorithm-08-ponscast-cache/cover.webp)
화면 공유와 파일 동기화는 비슷해 보이지만 “같은 시간을 공유한다”는 요구는 완전히 달랐다는 문제가 있었다. PonsCast 캐시는 같은 파일을 다시 읽는 비용과 재생의 흐름을 함께 봐야 했다. 링크를 만들고, 상대에게 보내고, 같은 화면 안에서 말하고 파일을 주고받으면 된다고 봤다. PonsCast는 기능표에서 나온 아이디어가 아니었다. 언어가 잘 통하지 않는 사람과 같은 파일을 같은 시간에 보고 싶다는 꽤 개인적인 욕심에서 시작됐다.
![[PonsLink] DataChannel 하나에 모든 메시지를 태우면 왜 위험할까](/tistory/body-images/2026-06-18-ponslink-deep-dive-07-data-channel-file-transfer/cover.webp)
처음에는 DataChannel을 하나만 열어도 충분하다고 생각했다. 브라우저끼리 직접 붙고, 그 통로로 채팅도 보내고 파일 조각도 보내고 PonsCast 제어 메시지도 보내면 구조가 단순해 보였다. 방 안의 모든 실시간 메시지가 같은 길을 지나가니 디버깅도 쉬울 거라고 봤다. 그제야 제목으로 남긴 '[PonsLink DataChannel 하나에 모든 메시지를 태우면 왜 위험할까'이 기능 홍보가 아니라, 내가 붙잡고 있던 판단 기준에 가깝다는 걸 인정했다.
![[PonsLink] DataChannel 재생을 버티게 하는 백프레셔와 지터 버퍼](/tistory/body-images/2026-06-18-ponslink-algorithm-07-ponscast-backpressure/cover.webp)
PonsCast는 파일을 보내는 기능보다 같은 시간을 공유하는 방식에 가까웠다. 화면 공유는 쉽게 떠올릴 수 있는 답이었지만, 내가 가진 파일을 상대의 언어와 속도에 맞춰 함께 보는 일은 화면을 복제하는 것과 달랐다. DataChannel 재생이 끊기면 이 차이가 바로 드러났다. 보내는 쪽이 계속 데이터를 밀어 넣으면 받는 쪽은 잠깐 뒤처진다. 그 차이가 쌓이면 재생은 끊기고, 같은 장면을 보고 있다는 약속도 무너진다. 내부에서는 버퍼가 조금 늘어난 것처럼 보여도 사용자에게는 불안정한 경험으로 남는다.
![[PonsLink] Mesh 연결은 단순해 보여도 순서가 제품 품질을 만든다](/tistory/body-images/2026-06-18-ponslink-deep-dive-06-webrtc-mesh/cover.webp)
Mesh 연결은 단순해 보였다. 참여자끼리 직접 연결하면 작은 방에서는 구조를 이해하기 쉽다. 하지만 한 단계의 실패가 사용자에게는 연결 전체의 불안으로 보인다는 점이 곧 드러났다. 신호 교환과 입장 순서, 메시지 채널과 파일 채널이 정확히 맞아야 했다. 내부에서는 이벤트 하나가 늦은 것처럼 보여도 사용자는 연결됐다고 느끼지 못한다. Mesh의 단순함은 연결 수를 줄이는 데서 끝나지 않고 실패했을 때 무엇을 다시 시도할지까지 포함해야 했다.
![[PonsLink] DataChannel 위에 PonsCast 프로토콜을 올린 이유](/tistory/body-images/2026-06-18-ponslink-algorithm-06-ponscast-protocol/cover.webp)
PonsCast는 방 안에 붙인 미디어 기능으로 시작하지 않았다. 언어가 잘 통하지 않는 사람과 같은 파일을 같은 시간에 보고 싶다는 개인적인 필요에서 출발했다. 화면 공유를 쓰면 화면은 같아진다. 하지만 상대가 파일의 어느 부분을 보고 있는지, 같은 속도로 따라오고 있는지, 한쪽이 바뀐 내용을 다른 쪽이 놓치지 않는지는 별개의 문제였다. 내가 해결하고 싶었던 것은 같은 화면보다 같은 시간을 공유하는 일이었다.
![[PonsLink] WebRTC 전에 신호 경계가 먼저 막아야 하는 것](/tistory/body-images/2026-06-18-ponslink-deep-dive-05-signaling-broker/cover.webp)
WebRTC는 연결의 문을 열어 주지만 그 뒤의 질서까지 만들어 주지는 않는다. 신호 교환과 입장 순서, 메시지 채널과 파일 채널이 조금만 어긋나도 사용자는 연결 자체를 불안하게 느낀다. 브라우저가 직접 연결되면 서버 부담도 줄고 구조도 단순해질 것이라 기대했다. 그러나 연결 뒤의 상태 관리와 순서 보장, 실패 복구는 여전히 제품의 몫이었다. 연결이 성립했다는 이벤트만 전달해서는 방이 안정적으로 움직이지 않았다.
![[PonsLink] 여러 내부 상태를 하나의 사용자 문장으로 접기](/tistory/body-images/2026-06-18-ponslink-algorithm-05-request-state/cover.webp)
회의는 방에 들어온 뒤 시작되는 줄 알았다. 실제로는 그보다 앞에서 이미 시작된다. 누가, 왜, 언제 만나고 싶은지 모르면 좋은 방도 금방 비어 보인다. 그래서 요청 흐름을 먼저 받는 구조로 바꿨다. 요청을 받는 일은 사람을 거르는 장치가 아니라 서로의 시간을 덜 낭비하게 만드는 완충지대였다. 문제는 내부에 여러 상태가 생겼다는 점이다. 사용자에게 그 상태를 그대로 보여 주면 제품은 곧바로 내부 시스템처럼 보인다.
![[PonsLink] 서버가 미디어를 나르지 않을 때 남는 책임](/tistory/body-images/2026-06-18-ponslink-deep-dive-04-bff-control-plane/cover.webp)
서버가 미디어를 나르지 않으면 편해질 줄 알았다. 트래픽 비용이 줄고 인코딩 부담도 클라이언트 쪽으로 가니 운영이 단순해 보였다. 그 문장은 절반만 맞았다. 막상 해보니 사라지는 건 미디어 운반뿐이었다. 세션 권한, 기능 허용, 중계 자격, 업로드 주소, 방 권한, 실패 문장 번역은 그대로 남았다. 미디어를 안 나를수록 제어면 경계가 더 선명해야 제품이 안전해졌다.
![[PonsLink] 놓친 이벤트와 중복 요청을 동시에 줄이는 법](/tistory/body-images/2026-06-18-ponslink-algorithm-04-replay-idempotency/cover.webp)
놓친 이벤트와 중복 요청은 서로 다른 오류처럼 보이지만 사용자에게는 같은 결과로 남는다. 한 번 보낸 요청이 사라지거나, 같은 요청이 두 번 처리되면 화면의 상태와 실제 상태가 어긋난다. PonsLink의 요청 흐름을 만들면서 이 문제를 뒤늦게 분리했다. 요청을 먼저 받고, 상태를 바꾸고, 방이나 다음 단계로 넘기는 과정에서 이벤트가 한 번 빠져도 다시 확인할 수 있어야 했다.
![[PonsLink] 전역 상태처럼 보였던 런타임 버스](/tistory/body-images/2026-06-18-ponslink-deep-dive-03-zustand-runtime/cover.webp)
처음 기대와 실제 제약을 한 번 충돌시킨 뒤 선택 이유가 드러났다. 런타임 상태를 한곳에서 다루려면 각 상태의 책임과 경계를 먼저 정해야 했다. 다음에 남는 질문은 한 문장으로 충분하다. 처음에는 회의방 하나면 충분할 줄 알았다. 링크를 만들고, 상대에게 보내고, 같은 화면 안에서 말하고 파일을 주고받으면 된다고 봤다. 처음에는 쉽게 정리할 수 있는 문제처럼 보였다. 그런데 직접 만들고 운영해보니 문장 하나로 접히지 않는 갈등이 남았다. 전역 상태처럼 보였던 런 겉으로 보이는 선택은 단순했지만, 안쪽에는 실패를 줄이려는 기준그…
![[PonsLink] P2P 파일 전송에 작은 TCP가 필요했던 이유](/tistory/body-images/2026-06-18-ponslink-algorithm-03-file-transfer/cover.webp)
P2P만 있으면 나머지는 따라올 거라고 믿었다. 같은 단어를 쓰더라도 제품 문맥이 다르면 답이 갈렸다. 그 갈림길을 숨기지 않는 쪽으로 썼다. 파일 전송은 보내고 받는 속도가 다르다는 사실부터 인정해야 했다. 처음 기대와 실제 제약을 한 번 충돌시킨 뒤 선택 이유가 드러났다. 보내는 일, 받는 일, 저장하는 일은 같은 줄에 서 있지 않았다. 하나라도 밀리면 사용자는 전체 전송이 실패했다고 느꼈다.
![[PonsLink] 입장 버튼 뒤에는 어떤 조건들이 숨어 있을까](/tistory/body-images/2026-06-18-ponslink-deep-dive-02-entry-runtime/cover.webp)
문제를 크게 키운 건 복잡한 신기술보다, 책임을 어디에 둘지 흐린 채로 넘어간 작은 결정들이었다. 아래에서는 PonsLink을 중심으로, 깨진 가정과 다시 그은 경계를 순서대로 남긴다. 연결 뒤에도 방의 기준은 따로 필요했다. 상태, 권한, 기능의 무게를 제품이 대신 기억해야 비로소 방처럼 느껴졌다. 다음에 남는 질문은 한 문장으로 충분하다.
![[PonsLink] 실시간 메시지는 모두 같은 줄에 서면 안 된다](/tistory/body-images/2026-06-18-ponslink-algorithm-02-realtime-queue/cover.webp)
실시간 메시지를 한 줄에 세우면 안 됐다. 채팅과 제어 메시지와 파일 조각은 모두 메시지이지만, 늦어졌을 때 사용자가 느끼는 피해가 같지 않다. 처음에는 단일 큐로 충분하다고 생각했다. 도착한 순서대로 처리하면 구현이 단순하고, 순서를 설명하기도 쉬워 보였다. 혼잡 구간에서 이 가정이 깨졌다. 파일 전송이 밀리면 제어 메시지까지 늦어지고, 사용자는 방 전체가 멈춘 것으로 느꼈다.
![[PonsLink] PonsLink 기술 회고: 33편의 아카이브를 읽는 지도](/tistory/body-images/2026-06-18-ponslink-tech-retrospective/cover.webp)
PonsLink의 기술 회고를 여러 편 쓰고 나서야 반복되는 질문이 보였다. 새 기능을 붙였는가보다, 어느 책임을 어느 계층에 둘 것인가가 매번 더 어려운 결정이었다. 방, 요청, 결제, 실시간 연결은 겉으로는 서로 다른 주제다. 하지만 실제 운영에서는 한 사용자의 흐름으로 이어진다. 한 곳의 이름을 쉽게 정하면 다른 곳의 상태와 권한 설명도 함께 흔들렸다.
![[PonsLink] 처음 열린 저장소에서 제품의 경계를 읽는 법](/tistory/body-images/2026-06-18-ponslink-deep-dive-01-map/cover.webp)
처음 열린 저장소를 보면 제품의 경계가 한눈에 보일 거라고 생각했다. 폴더 이름을 읽고, 기능을 분류하고, 용어 사전을 만들면 PonsLink가 무엇인지 설명할 수 있을 줄 알았다. 실제로는 반대였다. 같은 기능이 화면과 API, 연결 계층과 운영 문서에 서로 다른 이름으로 남아 있었다. 무엇을 만들었는지보다 왜 그 위치에 두었는지를 따라가야 전체 제품의 모양이 보였다.
![[PonsLink] WebRTC offer 충돌을 피하는 상태 머신 설계](/tistory/body-images/2026-06-18-ponslink-algorithm-01-negotiation/cover.webp)
WebRTC offer 충돌은 내부의 상태 문제였지만 사용자에게는 연결이 멈춘 화면으로 보였다. 이를 상태 머신과 명시적인 복구 규칙으로 다룬 기록이다.
실시간 제품의 안정성은 멋진 프레임워크보다 상태 전이, 흐름 제어, 재시도 가능한 명령에서 나온다.
WebRTC 연결 실패의 대부분은 미디어 API가 아니라 서로를 찾고 통과하는 과정에서 생긴다.
그리드 컴퓨팅은 여러 장치의 남는 계산 자원을 하나의 큰 작업장처럼 쓰려는 시도다.
P2P는 화상회의만을 위한 기술이 아니다. 파일 전송, 협업, 엣지 연산, 로컬 우선 제품까지 이어지는 설계 선택이다.
Mesh, SFU, MCU는 화상회의 용어처럼 보이지만 본질은 데이터 경로와 비용, 품질, 제어권의 선택이다.
![[PonsLink] 작업 기록을 다시 보니, 내가 만든 건 회의 앱보다 연결 방식에 가까웠다](/tistory/body-images/2026-06-16-ponslink-12b-connection-method/cover.webp)
PonsLink를 만들 때 처음에는 회의방 하나면 충분하다고 생각했다. 링크를 만들고 상대를 초대하고, 같은 화면에서 대화와 파일 전송을 제공하면 된다고 봤다. 연결 자체는 빠르게 만들 수 있었지만, 사람이 방에 들어온 뒤 무엇을 믿고 어떤 상태를 따라야 하는지는 별도의 문제였다. 사람은 링크만 타고 들어오지 않는다. 초대받은 방이 어떤 상태인지, 내가 무엇을 할 수 있는지, 상대가 지금 연결돼 있는지를 함께 확인한다. 연결에 성공했다는 표시만으로는 부족했다. 상태와 권한, 기능의 무게를 제품이 기억해 줘야 방이 안정적으로…
![[PonsLink] 작업 기록을 따라가니 아직 진행형인 이유가 보였다](/tistory/body-images/2026-06-16-ponslink-12-reading-the-commit-log/cover.webp)
작업 기록을 다시 읽으며 PonsLink가 아직 진행형인 이유를 알게 됐다. 회의방 하나를 만들면 링크를 보내고 같은 화면에서 대화와 파일을 주고받을 수 있을 거라 생각했다. 연결 자체는 열렸지만 그 뒤의 순서와 상태, 실패 복구는 제품이 직접 책임져야 했다. 신호 교환과 입장 순서, 메시지 채널과 파일 채널이 조금만 어긋나도 사용자는 연결됐다고 느끼지 못했다. 내부에서는 한 단계가 늦어진 것처럼 보여도 바깥에서는 기다림과 불신으로 남았다. 문제는 특정 기술 하나보다 책임을 정하지 않은 채 다음 작업으로 넘어간 데 있었다.
P2P를 이해하려면 먼저 누가 중심을 갖고, 누가 데이터를 들고, 누가 장애 지점이 되는지를 구분해야 한다.
![[PonsLink] 회의방보다 먼저 요청과 운영의 혼란을 정리해야 했다](/tistory/body-images/2026-06-16-ponslink-11-ponswarp-split/cover.webp)
처음에는 회의방 하나면 충분할 줄 알았다. 링크를 만들고, 상대를 불러 말하고, 같은 화면에서 파일도 주고받으면 된다고 봤다. 실제 운영에서는 방이 열리기 훨씬 앞에서 제품이 무너졌다. 누가, 왜, 언제 만나려는지 정리되지 않은 채 DM과 문의가 흩어졌고, 정작 방에 들어가면 맥락이 비어 있었다.
![[PonsLink] 보여 줄 것만 보여 주고 덜 부담스럽게 들어오게 하기](/tistory/body-images/2026-06-16-ponslink-10-request-first-desk/cover.webp)
요청을 먼저 받는 일은 사람을 거르는 장치가 아니었다. 서로의 시간을 덜 낭비하게 만드는 완충지대였다. 요청 흐름은 PonsLink가 회의 도구보다 연결 설계에 가까워진 이유를 보여준다. 링크를 만들고 상대에게 보내는 순간부터 이미 회의가 시작된다. 누가, 왜, 언제 만나고 싶은지 모르면 좋은 방도 금방 비어 보였다.
P2P를 파일 전송이나 화상회의 기능으로만 보지 않고, 분산 작업과 그리드 컴퓨팅의 기반으로 다시 정리한다.
![[PonsLink] 파일 전송은 결국 방 밖으로 독립해야 했다](/tistory/body-images/2026-06-16-ponslink-09b-file-transfer-left-room/cover.webp)
파일 전송은 결국 방 밖으로 독립해야 했다. 처음에는 방 안에 두는 게 자연스러워 보였다. 사람이 붙어 있는데 파일 하나쯤 같이 보내면 될 줄 알았다. 방 UI 안의 파일 전송은 금방 이런 질문에 부딪혔다. 지금 업로드 중인가 peer 전송 중인가, 브라우저를 닫으면 이어받을 수 있는가, 상대가 새로고침하면 진행률은 어디로 가나.
![[PonsLink] 파일 전송은 부가 기능이 아니라 제품의 균형 문제였다](/tistory/body-images/2026-06-16-ponslink-09-no-go/cover.webp)
파일이 커질수록 전송은 방 안의 부가기능이 아니라 별도 제품이 됐다. 보내고 받고 저장하는 세 부분의 균형을 다시 잡은 기록이다.
![[PonsLink] 다시 P2P로 돌아오며 PonsLink의 중심이 선명해졌다](/tistory/body-images/2026-06-16-ponslink-08-the-big-pivot/cover.webp)
중계 서버에 맡기던 것들을 클라이언트와 signaling 경계로 옮기자 실패가 "방이 안 열림" 하나로만 보이기 시작했다. P2P 데모는 두 브라우저에서 예쁘게 붙는데 실제는 권한 지연, 재입장, 네트워크 전환이 섞인다. 데모 성공률만 보면 제품이 완성된 것처럼 보이지만 사용자는 매번 다른 실패를 만난다.
![[PonsLink] 좋은 방만으로는 실제 약속이 굴러가지 않았다](/tistory/body-images/2026-06-16-ponslink-07b-good-room-not-enough/cover.webp)
그래서 PonsLink의 방은 기능 묶음이 아니라 사용자의 부담을 줄이는 약속에 가까웠다. 좋은 방을 만드는 것만으로는 실제 약속이 굴러가지 않았다. 내부에서는 한 단계가 삐끗한 것처럼 보여도, 바깥에서는 기다림과 불신으로 남는다. 그래서 단순한 구현보다 실패했을 때의 흐름을 먼저 잡아야 했다. 링크를 만들고, 상대에게 보내고, 같은 화면 안에서 말하고 파일을 주고받으면 된다고 봤다. 처음에는 방 하나를 잘 만들면 된다고 생각했다. 그런데 사람은 링크만 타고 들어오는 게 아니라 기대와 불안도 같이 들고 들어왔다.
![[PonsLink] 멋진 분산 구조보다 먼저 감당할 수 있는 제품이 필요했다](/tistory/body-images/2026-06-16-ponslink-07-requests-payments-ops/cover.webp)
회의 링크를 만들고 상대에게 보내기만 하면 끝날 줄 알았다. 실제로는 그보다 앞에서 시작됐다. 누가, 왜, 언제 만나고 싶은지 모르면 방은 비어 있었다. 요청을 먼저 받는 일은 사람을 거르는 장치가 아니었다. 서로의 시간을 덜 낭비하게 만드는 완충지대였다. 요청을 받는 쪽에서 상태와 권한, 흐름을 먼저 정리해야 이후 회의가 성립했다.
![[PonsLink] 더 큰 방을 꿈꾸자 PonsCast가 먼저 떠올랐다](/tistory/body-images/2026-06-16-ponslink-06-audio-pivot/cover.webp)
영상 회의만 붙잡고 있으면 PonsLink는 작은 방에서 멈춘다. 더 큰 방을 상상하면 모든 사람이 카메라를 켜는 구조보다 말, 듣기, 방송, 다시 재생하는 흐름이 먼저 필요해진다. 이 판단은 ponslink-room-frontend/src/components/media/PonsCastReceiverViewer.tsx와 ponslink-room-frontend/src/hooks/usePonsCastReceiver.test.tsx를 함께 볼 때 더 분명해진다. 화면에서 보이는 기능은 결과이고, 실제 제품성은 그 기능이 열리는 조건과…
![[PonsLink] 방은 있었지만 제품을 다시 지어야 했다](/tistory/body-images/2026-06-16-ponslink-05-winter-rebuild/cover.webp)
겨울에 다시 지어야 했던 건 UI가 낡아서가 아니었다. 방을 열 수는 있었지만 권한, 결제, 요청, 기록, 배포가 따로 움직이면 운영 가능한 제품이라고 부르기 어려웠다. 이 판단은 ponslink-room-frontend/src/pages/RequestAction.tsx와 ponslink-room-frontend/src/pages/SessionAccess.tsx를 함께 볼 때 더 분명해진다. 화면에서 보이는 기능은 결과이고, 실제 제품성은 그 기능이 열리는 조건과 실패했을 때 돌아갈 길에 있다. 같은 단어를 쓰더라도 제품 문맥이…
![[PonsLink] PonsCast는 같은 시간을 공유하고 싶어서 만든 기능이었다](/tistory/body-images/2026-06-16-ponslink-04c-ponscast-same-time/cover.webp)
처음에는 회의방 하나면 충분할 줄 알았다. 링크를 만들고, 상대에게 보내고, 같은 화면 안에서 말하고 파일을 주고받으면 된다고 봤다. PonsCast는 기능표에서 나온 아이디어가 아니었다. 언어가 잘 통하지 않는 사람과 같은 파일을 같은 시간에 보고 싶다는 꽤 개인적인 욕심에서 시작됐다. PonsCast가 방의 기능 확장과 파일 전송 분리 사이의 다리였음을 이어진다. 화면 공유는 쉬운 답처럼 보였지만 내가 가진 파일을 상대의 언어와 시간에 맞춰 함께 본다는 문제는 달랐다. 같은 화면보다 같은 시간이 더 중요했다.
![[PonsLink] 말로 부족한 순간마다 방은 맥락을 더 품어야 했다](/tistory/body-images/2026-06-16-ponslink-04b-room-grew-with-context/cover.webp)
방은 영상 영역 하나가 아니라 대화가 흘러간 맥락을 담는 컨테이너였다. 누가 들어왔고, 무엇을 공유했고, 어떤 결정을 남겼는지가 이어지지 않으면 다음 접속은 다시 처음부터 시작된다. 이 판단은 ponslink-room-frontend/src/pages/LoungeRequests.tsx와 ponslink-room-frontend/src/pages/LoungeMeetingRecords.tsx를 함께 볼 때 더 분명해진다. 화면에서 보이는 기능은 결과이고, 실제 제품성은 그 기능이 열리는 조건과 실패했을 때 돌아갈 길에 있다.
![[PonsLink] 말만으로 부족한 순간, 기능은 계속 늘어났다](/tistory/body-images/2026-06-16-ponslink-04-feature-sprawl/cover.webp)
PonsLink가 위험해진 순간은 기능이 부족할 때가 아니라 기능이 너무 쉽게 붙을 때였다. 채팅, 파일, 화이트보드, CoWatch, PonsCast는 각각 좋아 보였지만, 방의 이유를 설명하지 못하면 전부 잡음이 된다. 이 판단은 ponslink-room-frontend/src/pages/Room.tsx와 ponslink-room-frontend/src/components/functions/chat/ChatPanel.tsx를 함께 볼 때 더 분명해진다. 화면에서 보이는 기능은 결과이고, 실제 제품성은 그 기능이 열리는 조건과…
![[PonsLink] 연결은 붙었지만 신뢰는 아직 만들어지지 않았다](/tistory/body-images/2026-06-16-ponslink-03-beyond-calls/cover.webp)
영상이 켜지는 순간을 성공으로 보면 PonsLink는 너무 일찍 끝난다. 실제 상담이나 협업에서는 방에 들어온 사람이 누구인지, 어떤 권한을 받았는지, 끊겼다가 돌아왔을 때 무엇을 이어받는지가 더 오래 남는다. 이 판단은 ponslink-room-frontend/src/pages/Lobby.tsx와 ponslink-room-frontend/src/pages/Room.tsx를 함께 볼 때 더 분명해진다. 화면에서 보이는 기능은 결과이고, 실제 제품성은 그 기능이 열리는 조건과 실패했을 때 돌아갈 길에 있다.
![[PonsLink] 링크는 단순했지만, 뒤에서는 신호가 계속 엉켰다](/tistory/body-images/2026-06-16-ponslink-02b-signal-behind-link/cover.webp)
PonsLink에서 가장 오래 남은 대비는 겉과 속의 차이였다. 사용자는 링크 하나를 클릭한다. 화면은 로비를 보여주고, 장치 권한을 묻고, 입장 버튼을 보여준다. 그러나 뒤에서는 SDP offer, SDP answer, ICE candidate, signaling room, peer identity, retry timer, TURN fallback이 계속 움직인다. 링크는 단순했지만, 그 단순함을 유지하려면 신호가 엉켰을 때도 제품이 침착해야 했다. [의 세부 기록이다. WebRTC 개념 자체는 [에서 더 넓게 다뤘다. 링크가…
![[PonsLink] 두 달 동안 방 하나에 매달리며 배운 제품의 첫 얼굴](/tistory/body-images/2026-06-16-ponslink-02-webrtc-first-hell/cover.webp)
WebRTC를 처음 제품에 넣을 때는 “브라우저끼리 직접 붙는다”는 말이 거의 마법처럼 들렸다. 서버가 미디어를 전부 중계하지 않아도 되고, 작은 팀도 실시간 방을 만들 수 있을 것 같았다. 실제로 WebRTC는 강력했다. 다만 두 달 동안 방 하나에 매달리며 배운 것은 반대편이었다. **WebRTC는 연결의 문을 열어줄 뿐, 제품의 질서는 만들어주지 않는다.**
![[PonsLink] 나는 먼저 방을 만들었다, 제품 설명은 그다음이었다](/tistory/body-images/2026-06-16-ponslink-01b-room-before-product/cover.webp)
방을 만들면 제품 설명은 나중에 따라올 줄 알았다. 영상이 되고, 채팅이 되고, 파일을 주고받고, 화이트보드까지 붙으면 사람들이 자연스럽게 이해할 거라고 생각했다. 기능이 쌓이면 제품이 된다고 본 셈이다. 실제로는 반대였다. 누가 왜 들어오는지, 어떤 요청이 먼저 있었는지, 돈을 받는다면 무엇에 대해 받는지, 결제 후 입장 권한이 어떻게 바뀌는지가 더 중요했다. 방은 보였는데 제품 설명은 계속 비었다.
![[PonsLink] 한 번 놓친 연결에서 PonsLink가 시작된 이유](/tistory/body-images/2026-06-16-ponslink-01-why-i-came-back-to-connection/cover.webp)
PonsLink를 만들기 시작한 이유는 기술 호기심보다 불편함에 더 가까웠다. 누군가를 다시 만나야 하는 순간이 오면, 지난 대화의 맥락을 처음부터 복구해야 했다. 메시지를 찾고, 파일을 다시 보내고, 왜 만나려 했는지 다시 설명하는 일이 반복됐다. 처음에는 이 문제를 “좋은 회의 링크가 없어서”라고 짧게 정리했다. 틀렸다. 링크는 쉽게 만들 수 있었다. 진짜 문제는 연결이 끊긴 뒤에 **맥락이 같이 사라진다**는 점이었다.
![[PonsLink] 계정 없이 링크 하나로 만나는 방을 만들고 싶었다](/tistory/body-images/2026-06-16-ponslink-00-link-only-room/cover.webp)
처음 PonsLink를 붙잡았을 때 내가 원했던 장면은 거창하지 않았다. 링크 하나만 던지면 상대가 별도 계정 없이 들어와서, 필요한 이야기를 나누면 끝나는 방. 회원가입 장벽이 만남의 속도를 늦추는 걸 보고 싶지 않았다. 그런데 계정 없는 링크를 "그냥 열린 방"으로 두면 금방 무너진다. 누구나 들어올 수 있으면, 권한·보관·악용 방지가 전부 제품 책임으로 남는다. 나는 이 지점에서 링크의 개방성과 권한의 명확함을 한덤어리로 푸는 착각을 했다.

서로 다른 분야의 일을 오가다 보면 기능보다 먼저 이름과 기록이 문제를 만든다는 걸 알게 된다. PonsLink와 PonsWarp를 만들면서도 그랬다. 기술적으로는 다른 프로젝트였지만, 무엇을 만들고 왜 방향을 바꿨는지 설명하는 문장이 흐려지면 다음 판단도 같이 흐려졌다. 같은 단어를 써도 제품 문맥이 달라지면 뜻이 달라진다. 연결은 네트워크의 성공일 수도 있고, 사용자가 상대를 믿고 다음 행동을 하는 상태일 수도 있다. 전송은 바이트가 도착하는 일일 수도 있고, 중간에 실패해도 다시 이어지는 경험일 수도 있다.

처음에는 사소한 생각처럼 보였다. 이름 하나, 작은 도구 하나, 느린 인터넷 하루가 프로젝트 전체의 방향을 바꾸리라고는 생각하지 못했다. 처음에는 쉽게 정리할 수 있는 문제처럼 보였다. 그런데 직접 만들고 운영해보니 문장 하나로 접히지 않는 갈등이 남았다. 같은 단어를 쓰더라도 제품 문맥이 다르면 답이 갈렸다. 그 갈림길을 숨기지 않는 쪽으로 썼다.

처음에는 사소한 생각처럼 보였다. 이름 하나, 작은 도구 하나, 느린 인터넷 하루가 프로젝트 전체의 방향을 바꾸리라고는 생각하지 못했다. 돈을 받는 순간 제품의 무게가 달라졌다. 버튼 하나를 붙이면 끝날 줄 알았지만, 실제로는 누가 어떤 권한으로 언제 들어올 수 있는지가 더 어려웠다.

처음에는 사소한 생각처럼 보였다. 이름 하나, 작은 도구 하나, 느린 인터넷 하루가 프로젝트 전체의 방향을 바꾸리라고는 생각하지 못했다. 처음에는 쉽게 정리할 수 있는 문제처럼 보였다. 그런데 직접 만들고 운영해보니 문장 하나로 접히지 않는 갈등이 남았다. 인터넷만 있으면 나머지는 따라올 거라고 믿었다.

처음에는 사소한 생각처럼 보였다. 이름 하나, 작은 도구 하나, 느린 인터넷 하루가 프로젝트 전체의 방향을 바꾸리라고는 생각하지 못했다. 처음에는 쉽게 정리할 수 있는 문제처럼 보였다. 그런데 직접 만들고 운영해보니 문장 하나로 접히지 않는 갈등이 남았다. 아래에서는 이름은을 중심으로, 깨진 가정과 다시 그은 경계를 순서대로 남긴다.