<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Never give up</title>
    <link>https://devmemory.tistory.com/</link>
    <description>This blog is to store and share my memory while developing.</description>
    <language>ko</language>
    <pubDate>Tue, 7 Jul 2026 08:19:54 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>대기만성 개발자</managingEditor>
    <image>
      <title>Never give up</title>
      <url>https://tistory1.daumcdn.net/tistory/4094024/attach/7f8b505900234bf1a50e17f17e8b8526</url>
      <link>https://devmemory.tistory.com</link>
    </image>
    <item>
      <title>High-Performance Camera Handling: FFmpeg Pipeline &amp;amp; Codec Hell</title>
      <link>https://devmemory.tistory.com/141</link>
      <description>&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;이번에는 하드웨어의 냉혹한 현실에 대해 다루고자 합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;화면도 없고 리소스가 제한된 기기에서 고화질 카메라 피드를 끊김 없이 뽑아내려면 하드웨어 프레임을 캡처하고&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;극도로 제한된 CPU 바운드 내에서 압축하여 실시간으로 WebRTC 미디어 파이프라인에 밀어 넣어야 합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;만약 인코딩 설정이 아주 조금이라도 어긋나면 기기의 CPU 점유율은 순식간에 100%를 찍어버리고&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;프레임 드랍, 발열 스로틀링, 그리고 커넥션 타임아웃등이 발생합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 FFmpeg Child Processes, 플랫폼 독립적인 하드웨어 드라이버 추상화&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;그리고 초저지연 UDP 루프백을 이용해 오버헤드가 거의 없는 비디오 파이프라인을 구축한 과정을 공유해 보겠습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;크로스 플랫폼 제약: 하드웨어 디바이스 노드의 추상화&lt;/h3&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;로컬 서버를 다양한 환경에 배포할 때, 물리적인 카메라 주변기기와 인터페이스를 맞추는 것은 시작부터 큰 산이었습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;(운영체제마다 카메라 하드웨어를 처리하는 커널 레이어와 포맷 파라미터가 완전히 다름)&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;이러한 디바이스 노드에서 압축되지 않은 원시 프레임(YUV나 MJPEG)을 직접 읽어올 수는 있지만&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;이를 WebRTC 피어 연결에 그대로 쏘아 보내는 것은 불가능합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;WebRTC는 정확한 RTP패킷 안에 특정 압축 포맷이 담겨있어야 하고&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;타임스탬프, 컬러 스페이스, 페이로드 타입까지 아주 엄격하게 통제 됩니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;그렇다고 이 고빈도 프레임들을 Node.js의 메인 이벤트 루프 안에서 파싱하는 것은 범죄 행위(?)입니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;JS 단에서 무거운 행렬 변환이나 비디오 패킷 파싱을 시도하는 순간 이벤트 루프는 완전히 멈춰버리고&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;기기는 네트워크 heartbeat나 시그널링 메시지를 처리하지 못해 뻗어버릴 겁니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;해결책은 최적화된 네이티브 바이너리인 &lt;b data-index-in-node=&quot;48&quot; data-path-to-node=&quot;13&quot;&gt;FFmpeg 자식 프로세스&lt;/b&gt;에&amp;nbsp;이 무거운 짐을 전부 떠넘기는 것이었죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;(필자는 라즈베리 파이를 가지고있지 않아 우분투 노트북에서 전체 런타임을 검증했습니다)&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;OS별로 argument(Windows, macOS, Linux)를 주입하고 있어서 다른 기기에서도 문제없이 잘 작동합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size23&quot;&gt;코덱 미스매치 정복: H.264의 패배와 VP8의 승리&lt;/h3&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 하드웨어 가속 지원이 빵빵한 H.264(libx264)를 사용하려고 했습니다만&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;89&quot; data-path-to-node=&quot;18&quot;&gt;심각한 프레임 드롭과 블랙 스크린&lt;/b&gt;이 발생했습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;WebRTC 시그널링 SDP negotiation은 분명 성공적으로 끝나는데&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;실제 미디어가 시작되자마자 검은 화면만 출력되더군요&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;근본적인 원인은 Node.js WebRTC 미디어 엔진과 들어오는 RTP 스트림 프로필 간의 엄격한 코덱 미스매칭이었습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;웹 브라우저에서는 히든 레이어가 알아서 처리하지만&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;Node.js WebRTC 런타임은 H.264 프로필 변형이나 패킷화 불일치는 가차 없습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;인코딩된 RTP 페이로드가 미디어 파이프라인이 예상한 값과 아주 미세하게라도 다르면&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;커넥션은 붙어있는데 비디오 화면만 깜깜하게 나오는 상황이 되는 거죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(브라우저님 감사합니다..)&lt;/s&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;이 삽질을 끝내고 크로스 플랫폼에서의 확실한 예측 가능성을 확보하기 위해(귀찮아서)&lt;/p&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;저는 전체 생태계를 &lt;b data-index-in-node=&quot;53&quot; data-path-to-node=&quot;21&quot;&gt;VP8 코덱(libvpx)으로 통일&lt;/b&gt;하기로 전략적 결정을 내렸습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;사실 H.264를 탐색할 때는 하드웨어 가속 프로필을 쓰는 게 인코딩 효율 면에서 무조건 우월해 보였습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;하지만 두 코덱을 비교 해보니 지뢰가 숨어있더군요&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;23&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,0,0&quot;&gt;SDP 협상의 지옥화:&lt;/b&gt; 파편화가 극심한 브라우저 클라이언트 환경 전반에 걸쳐 명시적인 H.264 하드웨어 가속 profile-level-ids를 강제하는 것은, SDP 협상 부분 유지보수 지옥&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,1,0&quot;&gt;라이선스 및 저작권:&lt;/b&gt; 프로덕트 규모가 커질 때 H.264 인코더를 임베딩하면 엄격한 MPEG-LA 저작권료 및 라이선스 비용 의무가 발생&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;반면 WebRTC의 근본이자 오픈 소스인 &lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;24&quot;&gt;VP8&lt;/b&gt;을 선택함으로써 협상 위험과 법적 리스크로부터 회피했습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;(물론 소프트웨어 기반 인코딩이 기기 발열 한계를 넘지 않도록 FFmpeg 최적화 튜닝이 필요했습니다)&lt;/p&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;UDP 위에서 VP8 FFmpeg 파이프라인 구동하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;미디어 인코딩 오버헤드를 철저히 격리하기 위해 child_process.spawn을 이용해 CPU saturation을 방지했습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;이때 raw 버퍼를 표준 I/O 스트림(Pipe)으로 넘기면 V8 엔진에 엄청난 가비지 컬렉션(GC) 부하를 주게 됩니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;이를 피하기 위해 패킷 크기가 고정된 초저지연 UDP 루프백 채널(127.0.0.1)을 활용하여&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;패킷화가 완료된 VP8 RTP 스트림을 Node.js 런타임으로 다이렉트 패스했습니다&lt;/p&gt;
&lt;pre id=&quot;code_1780968763097&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Physical Camera Hardware] 
              │
              ▼ (Dynamic Driver Abstraction)
       [FFmpeg Process] (Spawned via child_process)
              │  (VP8 Real-Time Encoding + RTP Packetization)
              ▼
   [UDP Loopback] (127.0.0.1:${this.UDP_PORT}?pkt_size=1200)
              │
              ▼
     [Node.js WebRTC Runtime] (media plane)
              │
              ▼  (Outbound Encrypted Stream)
       [Remote Browser]&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size20&quot;&gt;통제된 스폰(Spawn) 패턴&lt;/h4&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;시그널링 핸드셰이크가 끝나면 최적화된 동적 VP8 실행 파이프라인을 가동합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiI09jG6PiUAxUAAAAAHQAAAAAQ0QE&quot; data-hveid=&quot;0&quot;&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;import { spawn } from 'child_process';

// 현재 구동 중인 OS 플랫폼 명세를 다이렉트 추상화
const { formatDriver, formatParam, pixelFormat, device } = getPlatformSpecs();

const threads = getThreads();

this.ffmpegProcess = spawn(&quot;ffmpeg&quot;, [
  &quot;-loglevel&quot;, &quot;error&quot;,       // 자질구레한 로그는 끄고, 치명적인 실패만 파이프

  &quot;-f&quot;, formatDriver,         // 플랫폼별 드라이버 (예: v4l2, avfoundation 등)
  formatParam, &quot;mjpeg&quot;,       // 입력 포맷 제약 조건
  &quot;-video_size&quot;, &quot;1280x720&quot;,  // 캡처 해상도 베이스라인 (720p)
  &quot;-framerate&quot;, &quot;30&quot;,         // 타겟 FPS 제한
  &quot;-i&quot;, device,               // 동적 하드웨어 소스 노드 경로

  &quot;-pix_fmt&quot;, &quot;yuv420p&quot;,      // WebRTC 표준인 YUV420 Planar 컬러 스페이스 강제
  &quot;-vcodec&quot;, &quot;libvpx&quot;,        // 순수 VP8 인코딩 엔진 적용
  &quot;-deadline&quot;, &quot;realtime&quot;,    // 버퍼 없는 실시간 인코딩 강제
  &quot;-cpu-used&quot;, threads,       // performance 부분
  &quot;-threads&quot;, threads,
  &quot;-g&quot;, &quot;30&quot;,                 // 정기적인 키프레임 생성을 위한 GOP 사이즈 설정
  &quot;-keyint_min&quot;, &quot;30&quot;,        // IDR/Keyframe 사이의 최소 인터벌
  &quot;-f&quot;, &quot;rtp&quot;,                // 아웃풋 포맷을 raw RTP로 지정
  &quot;-payload_type&quot;, &quot;96&quot;,      // WebRTC 바인딩을 위한 동적 RTP 페이로드 타입 매핑
  `rtp://127.0.0.1:${this.UDP_PORT}?pkt_size=1200`, // 최적화된 로컬 UDP MTU 스트리밍
]);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 data-path-to-node=&quot;32&quot; data-ke-size=&quot;size20&quot;&gt;WebRTC 안정성을 위한 하부 파라미터 미세 조정&lt;/h4&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;FFmpeg의 로우 레벨 플래그들을 딥하게 파고들며 패킷 드롭을 지워버린 핵심 튜닝 포인트들입니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;34&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;-pix_fmt yuv420p&lt;/b&gt;: 카메라의 원시 MJPEG 스트림을 거부하고, YUV422을 YUV420샘플링으로 강제 전환해 주어야만 WebRTC 디코딩 정합성이 보장&lt;/li&gt;
&lt;li&gt;&lt;b&gt;-deadline realtime -cpu-used &lt;/b&gt;: VP8 인코더에게 무거운 공간 압축 제거, 압축 밀도에서 아주 미세한 손해를 보는 대신 &lt;b data-index-in-node=&quot;98&quot; data-path-to-node=&quot;34,1,0&quot;&gt;CPU 오버헤드를 극적으로 낮춰&lt;/b&gt; 성능 및 발열 제어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;-g 30 -keyint_min 30&lt;/b&gt;: 30프레임마다 엄격하게 키프레임을 강제 주입해서 무선 네트워크 환경에서 패킷 하나가 깨지더라도 화면이 길게 뭉개지는 현상을 막고, 1초 이내에 스트림 화질을 유지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;pkt_size=1200&lt;/b&gt;: 나가는 RTP 패킷 크기를 1200 바이트로 제한. 일반적인 네트워크 MTU 한계선(1500 바이트)보다 넉넉히 낮추어, 네트워크 레이어에서의 &lt;b data-index-in-node=&quot;98&quot; data-path-to-node=&quot;34,3,0&quot;&gt;IP 단편화를 원천 차단&lt;/b&gt;하고 패킷 손실과 지터를 줄임(모바일 환경 고려)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;37&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;37&quot; data-ke-size=&quot;size23&quot;&gt;초저지연 UDP 루프백 리스닝&lt;/h3&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;루프백 채널의 반대편에서는 동적 로컬 포트에 바인딩된 전용 리스닝 소켓을 열고 대기합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;FFmpeg이 원시 카메라 비트를 규격에 맞는 완벽한 VP8 RTP 페이로드(타입 96)로 압축하는&lt;/p&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;무거운 작업을 다 처리해 주기 때문에 Node.js의 이벤트루프가 뻗는 현상은 없어집니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;그저 패킷을 받아서 전달만 해주는 역할만 수행하면 되죠&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiI09jG6PiUAxUAAAAAHQAAAAAQ0gE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;import * as dgram from 'dgram';

const udpSocket = dgram.createSocket('udp4');

udpSocket.on('message', (rtpPacket) =&amp;gt; {
  if (this.webRTCController.isConnected) {
    // 이미 패킷화가 완료된 VP8 RTP 프레임을 피어 커넥션 트랙에 그대로 다이렉트 라이팅
    this.videoTrack.writeRtp(rtpPacket);
  }
});

udpSocket.bind(this.UDP_PORT, '127.0.0.1');
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 메인 Node.js 이벤트 루프는 아무런 방해를 받지 않습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;빠른 로컬 UDP 포트로 패킷을 흡수해 WebRTC 전송 파이프라인으로 토스해 줄 뿐이죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;결과적으로 &lt;b data-index-in-node=&quot;128&quot; data-path-to-node=&quot;41&quot;&gt;720p @ 30 FPS 스트림을 고정&lt;/b&gt;하면서도 &lt;b data-index-in-node=&quot;186&quot; data-path-to-node=&quot;41&quot;&gt;200ms 미만의 초저지연&lt;/b&gt;과 프레임 드롭 0을 달성할 수 있었습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;(turn서버를 통하는 경우, 물리적 거리에 따른 지연 발생함)&lt;/p&gt;</description>
      <category>Side project</category>
      <category>CODEC</category>
      <category>ffmpeg</category>
      <category>h264</category>
      <category>UDP</category>
      <category>VP8</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/141</guid>
      <comments>https://devmemory.tistory.com/141#entry141comment</comments>
      <pubDate>Tue, 9 Jun 2026 10:40:05 +0900</pubDate>
    </item>
    <item>
      <title>WebRTC Race Conditions &amp;amp; Strict SDP Alignment</title>
      <link>https://devmemory.tistory.com/140</link>
      <description>&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;일반적인 웹 브라우저 간(Web-to-Web)의 WebRTC 애플리케이션이라면&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;브라우저의 네이티브 런타임에서는 관대하게 작동합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;패킷 순서가 조금 뒤바뀌어 오더라도 자체적으로 버퍼링을 하거나&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;암호화 핸드셰이크 과정에서 일어나는 일시적인 상태 미스매치를 알아서 복구해 주죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 피어 연결을 브라우저 밖으로 꺼내서 에이전트에 올리고 비동기 메시지 브로커와 동기화하는 순간&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;그 자비로움(?)은 눈 씻고 찾아볼 수 없습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;(Node.js 미디어 런타임은 자비가 없습니다)&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;ICE Candidate나 SDP Offer가 단 1밀리초라도 순서가 뒤바뀌어 도착하면&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;엔진은 기다려주지 않고 즉시 invalid state 예외를 뿜으며 미디어 세션을 통째로 날려버립니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;이 무자비한 비동기 시그널링의 포화 속에서 살아남기 위해 제가 어떻게 상태 정렬 라이프사이클을 설계했는지 풀어보겠습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;근본적인 원인: 비동기 브로커 vs 동기식 핸드셰이크&lt;/h3&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;제가 설계한 하이브리드 아키텍처는 클라이언트에 웹소켓을, 기기에 AMQP/MQTT를 사용합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;인프라 간의 연결은 완벽하지만, 이 구조는 &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;83&quot; data-path-to-node=&quot;10&quot;&gt;네트워크 Jitter와 비동기성&lt;/b&gt;&lt;/span&gt;이라는 위험한 부작용이 있죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;기본적으로 WebRTC 핸드셰이크는 다음과 같은 완벽한 순서가 보장되어야 하죠&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;로컬 미디어 드라이버(FFmpeg, UDP 소켓) 초기화&lt;/li&gt;
&lt;li&gt;원격(Remote) SDP Offer 등록&lt;/li&gt;
&lt;li&gt;로컬(Local) SDP Answer 생성&lt;/li&gt;
&lt;li&gt;검증된 ICE Candidate의 지속적인 Trickle 교환&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;하지만 클라우드 환경에서는 대참사가 일어날 수 있습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;클라이언트가 SDP Offer를 보내자마자 10개의 ICE Candidate를 연달아 쏜다고 가정해보죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;만약 클라우드에서 무거운 SDP 페이로드를 보내는 동안&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;RabbitMQ가 가벼운 Candidate 패킷들을 로컬 서버에 먼저 배달해 버린다면&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;로컬 서버는는 로컬 피어 연결이 아직 stable 상태에서 벗어나기도 전에 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;ICE Candidate를 받게 되고 실패합니다&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;(sdp 협상 전에 icecandidate를 받은 상태)&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size20&quot;&gt;메시지 브로커 튜닝: prefetch(1)의 함정 탈출하기&lt;/h4&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;이 실시간 패킷 교환은 메시지 브로커 세팅에도 큰 영향을 주었습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;RabbitMQ의 Consumer Prefetch Count를 1로 설정하는 것이 불문율입니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 시그널링 제어 평면에서 prefetch(1) 제약은 피어 연결 단계의 치명적인 병목 구간이 됩니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;핵심 SDP 협상이 완전히 마무리되고 나면 연결 경로를 미친 듯이 주고받는 상황으로 진입하게 됩니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;브라우저가 클라우드 게이트웨이를 거쳐 RabbitMQ 큐로 고빈도의 가벼운 ICE Candidate 스트림을 쏟아내는데&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;이 Candidate 패킷을 딱 하나씩만 가져와서 처리하고 확인(ack)하도록 강제하면&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;엄청난 직렬화 지연(Serialization Lag)이 발생하기 때문에 조절이 필요합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;그렇다고 Look-ahead 파이프라인을 무작정 늘리면 메시지가 꼬일 위험이 있습니다&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;결국 저는 단일 전송 파이프라인의 구조적 제약을 지키기 위해&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;RabbitMQ의 원칙인 prefetch(1)을 그대로 유지하기로 하고&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;로컬 서버 런타임에서 잘 처리할수 있도록 할 것인가에 대한 고민을 했습니다&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiI09jG6PiUAxUAAAAAHQAAAAAQswE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// 순차적 흡수 제어를 보장하기 위해 엄격한 단일 메시지 전송(prefetch 1)을 강제합니다
await this.channel.prefetch(1);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size23&quot;&gt;클라이언트 사이드 파이프라인&lt;/h3&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;원격 에이전트가 완벽히 준비되었음을 보장하기 위해, 클라이언트 사이드는 통제된 흐름을 구현합니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;client side.webp&quot; data-origin-width=&quot;201&quot; data-origin-height=&quot;930&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QTtqk/dJMcadh1C8K/kvkGFVTfEskXWb4sawnvv0/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QTtqk/dJMcadh1C8K/kvkGFVTfEskXWb4sawnvv0/img.webp&quot; data-alt=&quot;&amp;amp;lt; client flow &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QTtqk/dJMcadh1C8K/kvkGFVTfEskXWb4sawnvv0/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQTtqk%2FdJMcadh1C8K%2FkvkGFVTfEskXWb4sawnvv0%2Fimg.webp&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;201&quot; height=&quot;930&quot; data-filename=&quot;client side.webp&quot; data-origin-width=&quot;201&quot; data-origin-height=&quot;930&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; client flow &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;25&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0,0&quot;&gt;소켓 안정화:&lt;/b&gt; 클라이언트는 먼저 Socket.IO 게이트웨이에 연결하고 격리된 룸에 조인&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,1,0&quot;&gt;시그널 훅:&lt;/b&gt; 조건 없이 Offer를 눈감고 쏘는 게 아니라, 클라우드 서버가 &quot;기기 온라인&quot; 컨펌할 때까지 대기&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,2,0&quot;&gt;Receive-Only Offer 전략:&lt;/b&gt; 클라이언트는 SDP Offer를 생성할 때 속성을 명시적으로 recvonly(수신 전용)로 플래그를 지정해 전송해서, 브라우저가 순수한 소비자임을 기기에 알려줌으로써 네트워크 리소스 할당을 시작부터 최적화&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,3,0&quot;&gt;Answer 및 Trickle ICE 시작:&lt;/b&gt; 들어오는 SDP Answer를 성공적으로 캡처하고 디코딩한 이후에만, 클라이언트는 P2P 연결을 바인딩하기 위해 본격적인 ICE Candidate 공유를 시작&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size23&quot;&gt;서버 사이드 파이프라인: 엄격한 미디어 정렬&lt;/h3&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;로컬 서버 단에서는 로우 레벨 미디어 파이프라인(FFmpeg, UDP)과 WebRTC을 조율하기 위해 훨씬 더 최적화가 필요합니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;server side.webp&quot; data-origin-width=&quot;203&quot; data-origin-height=&quot;1113&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HcM34/dJMcajbl8DS/W9yJLdO7YBcayeXzFC8YA1/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HcM34/dJMcajbl8DS/W9yJLdO7YBcayeXzFC8YA1/img.webp&quot; data-alt=&quot;&amp;amp;lt; server flow &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HcM34/dJMcajbl8DS/W9yJLdO7YBcayeXzFC8YA1/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHcM34%2FdJMcajbl8DS%2FW9yJLdO7YBcayeXzFC8YA1%2Fimg.webp&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;203&quot; height=&quot;1113&quot; data-filename=&quot;server side.webp&quot; data-origin-width=&quot;203&quot; data-origin-height=&quot;1113&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; server flow &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;29&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,0,0&quot;&gt;인프라 동기화:&lt;/b&gt; 에이전트는 AMQP/MQTT 브로커에 훅을 걸고 초기화 페이로드 대기&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,1,0&quot;&gt;하드웨어 Pre-flight 초기화:&lt;/b&gt; 시그널링 메시지가 안착하는 순간, 에이전트는 하부 비디오 스트림 원시 객체들을 동시에 초기화 진행, OS 레벨의 FFmpeg 프로세스를 올리고, 로컬 UDP 소켓을 바인딩하며, WebRTC 피어를 인스턴스화하고, 비디오 코덱세팅&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,2,0&quot;&gt;엄격한 상태 전환:&lt;/b&gt; SDP Offer(send-only)가 시그널링 레이어에 안전하게 주입될 때까지 에이전트는 모든 외부 입력을 차단&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,3,0&quot;&gt;액티브 파이프라인 전송:&lt;/b&gt; 매칭되는 SDP Answer를 생성하고, UDP-to-FFmpeg 스트림 파이프라인을 부팅한 뒤, Answer를 아웃바운드 라우팅 키로 전송&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,4,0&quot;&gt;Answer 이후 ICE 소비:&lt;/b&gt; Answer가 완전히 네트워크 선로 위에 올라간 후에야 에이전트는 ICE Candidate를 송수신하고, 조기 네트워크 파싱 에러를 예방&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size23&quot;&gt;코드 전략: Node.js에서 상태 드리프트 막기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;엄격한 선형적 흐름을 설계함으로써, 무겁고 복잡한 상태 잠금 메커니즘을 굳이 도입할 필요가 없어졌습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;클라이언트가 SDP Answer를 명확히 기다렸다가 ICE Candidate를 쏟아내기 때문에&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;에지 에이전트는 언제나 기분 좋은 상태에서 패킷을 맞이할 수 있습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;혹시 모를 극한의 네트워크 지터 때문에 패킷이 교차하더라도 깔끔한 조건부 파이프라인 체크로 해결합니다&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size20&quot;&gt;1. Ice candidate queuing&lt;/h4&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiI09jG6PiUAxUAAAAAHQAAAAAQtAE&quot; data-hveid=&quot;0&quot;&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;private _onIceCandidate = async () =&amp;gt; {
  this.peerConnection!.onicecandidate = (e) =&amp;gt; {
    if (!e.candidate) {
      return;
    }

    const { candidate, sdpMid, sdpMLineIndex } = e.candidate;
    console.log(&quot;[iceCandidate]&quot;, { candidate, sdpMid, sdpMLineIndex });

    if (sdpMid === undefined || sdpMLineIndex === undefined) {
      return;
    }

    // 즉시 브로드캐스팅하는 대신, 로컬 큐에 candidate를 캡처하고 고립시킴
    this.iceCandidateQueue.push({ candidate, sdpMid, sdpMLineIndex });
  };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size20&quot;&gt;2. debouncing&lt;/h4&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;인바운드 Candidate가 도착하면 에이전트는 즉시 브로커에 ack를 전송하여 prefetch(1) 루프를 풀고&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;다음 메시지를 받을 수 있도록 길을 열어줍니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;동시에 페이로드를 로컬 WebRTC 런타임에 비동기적으로 주입합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;이때, 중복으로 동일 ice candidate를 전송하는건 절대 해서는 안되는 안티패턴이니&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;디바운스를 이용해서 딱 한번 정확하게 처리해주도록 합니다&lt;/p&gt;
&lt;/div&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiI09jG6PiUAxUAAAAAHQAAAAAQtQE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;case MQ_MSG.CANDIDATE:
  // 1. ack first
  this.channel.ack(msg);

  // 2. set remote ice candidate
  await webRTCController.receiveIceCandidate(payload.data);

  // 3. debounce clear
  this._clearCandidateDebounce();

  // 4. sending ice candidates
  this.candidateDebounce = setTimeout(() =&amp;gt; {
    webRTCController.iceCandidateQueue.forEach((model) =&amp;gt; {
      if (!webRTCController.isConnected) {
        this._publish({
          type: MQ_MSG.CANDIDATE,
          data: model,
        });
      }
    });
    // Reset local tracking queue after successful dispatch
    webRTCController.iceCandidateQueue = [];
  }, 100);
  break;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;45&quot; data-ke-size=&quot;size20&quot;&gt;전용 TURN 서버와 동적 TTL 자격 증명을 통한 Symmetric NAT 극복&lt;/h4&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;이 아키텍처의 핵심 하이라이트 중 하나는 시그널링과 미디어 스트림이&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;46&quot;&gt;상용 이동통신사 모바일 네트워크(LTE/5G)&lt;/b&gt;&lt;/span&gt; 위에서 온전히 테스트되었다는 점입니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;모바일 네트워크는 악명 높은 NAT와 촘촘한 방화벽을 사용하는 것으로 잘 알려져 있습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;(Symmetric NAT / Carrier-Grade NAT)&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;일반적인 STUN 기반의 P2P 홀펀칭은 이 엄격한 이중 NAT 포트 매핑 장벽에 막혀&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;통계적으로 100%에 가깝게 실패하는 척박한 환경이죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;어떤 환경에서도 실패 없는 연결을 보장하기 위해, &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;28&quot; data-path-to-node=&quot;47&quot;&gt;전용 TURN 서버&lt;/b&gt;&lt;/span&gt;를 미디어 Fallback 파이프라인에 완전히 통합했습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;하지만 고정된 TURN 서버 설정을 클라이언트 사이드 React 소스 코드에 그대로 노출하는 것은 심각한 보안 리스크입니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;악의적인 해커가 자격 증명을 긁어가서 제 미디어 릴레이 인프라를 악용할 수 있으니까요&lt;/p&gt;
&lt;p data-path-to-node=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;강력한 &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;4&quot; data-path-to-node=&quot;48&quot;&gt;Zero-Trust&lt;/b&gt;&lt;/span&gt;&amp;nbsp;네트워크 아키텍처를 구현하기 위해, 자격 증명 생성을 철저히 서버 사이드로 추상화하는 연결 시퀀스를 재설계했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;49&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;클라이언트는 WebRTC 핸드셰이크를 시작하기전, NestJS 클라우드 게이트웨이에 임시 접근 권한을 명시적으로 요청&lt;/li&gt;
&lt;li&gt;클라우드 서버는 암호화 모듈을 사용하여 &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;31&quot; data-path-to-node=&quot;49,1,0&quot;&gt;엄격한 TTL 메커니즘이 바인딩된, 암호학적으로 안전한 시한부 TURN 자격 증명&lt;/b&gt;&lt;/span&gt;을 실시간으로 생성&lt;/li&gt;
&lt;li&gt;클라이언트는 미디어 세션이 유지되는 동안 이 임시 토큰을 소비. 핸드셰이크가 마무리되거나 지정된 TTL이 만료되면 토큰은 자동으로 휘발되어 사라지며, 자격 증명 유출 취약점을 완벽히 지워버리는 동시에 강력한 NAT 통과율을 보장&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;live demo.gif&quot; data-origin-width=&quot;220&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CJXZQ/dJMcagFQuK4/6Me4j6Fqcnsv9quqMsuVk0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CJXZQ/dJMcagFQuK4/6Me4j6Fqcnsv9quqMsuVk0/img.gif&quot; data-alt=&quot;&amp;amp;lt; 모바일 네트워크로 테스트한 연결 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CJXZQ/dJMcagFQuK4/6Me4j6Fqcnsv9quqMsuVk0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/CJXZQ/dJMcagFQuK4/6Me4j6Fqcnsv9quqMsuVk0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;220&quot; height=&quot;480&quot; data-filename=&quot;live demo.gif&quot; data-origin-width=&quot;220&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 모바일 네트워크로 테스트한 연결 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;51&quot; data-ke-size=&quot;size23&quot;&gt;마치며&lt;/h3&gt;
&lt;p data-path-to-node=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;비동기적인 시스템을 타이트하게 정렬된 동기식 핸드셰이크 흐름으로 강제해 시그널링을 완벽히 수행하고&lt;/p&gt;
&lt;p data-path-to-node=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;53&quot; data-ke-size=&quot;size16&quot;&gt;클라우드에서 엄격한 보안 문지기 역할을 수행합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;55&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;55&quot; data-ke-size=&quot;size16&quot;&gt;다음에는 히든레이어가 없는 엄격한 환경에서 코덱 세팅은 어떻게 진행하는지 다룹니다&lt;/p&gt;</description>
      <category>Side project</category>
      <category>debouncing</category>
      <category>race condition</category>
      <category>SDP</category>
      <category>Signaling</category>
      <category>STUN</category>
      <category>turn</category>
      <category>webrtc</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/140</guid>
      <comments>https://devmemory.tistory.com/140#entry140comment</comments>
      <pubDate>Tue, 9 Jun 2026 10:16:35 +0900</pubDate>
    </item>
    <item>
      <title>Hybrid Signaling Topology: WebSockets Meets AMQP/MQTT</title>
      <link>https://devmemory.tistory.com/139</link>
      <description>&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;지난 포스팅에서 로컬 기기 페어링 지옥을 다뤘는데&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;이번에는 패킷을 안전하게 주고받기 위한 시그널링 서버의 구성에 대해 짚고 넘어가보자 합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;WebRTC를 구현할 때 브라우저와 기기 간에 SDP나 ICE Candidate 같은 메타데이터를 교환하는 시그널링은 필수죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;하지만 클라우드 환경에서 이를 안정적으로 설계하는 것은 완전히 다른 문제입니다&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size23&quot;&gt;Reverse Tunneling: 공유기 설정 없이 방화벽 우회하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;프로토콜을 고민하기 전에 가장 먼저 마주한 제약은 바로 네트워크 접근성이었습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;전통적인 방식대로 원격 기기에 접속하려면 고정 IP를 쓰거나&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;방화벽 인바운드 규칙을 바꾸고 포트 포워딩을 설정해줘야 됩니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;하지만 일반 유저에게 이런 복잡한 설정을 요구하는 순간 유저들은 떨어져 나가죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;(실제 많은 유저가 공유기 비밀번호조차 모르는 경우가 허다하니까요)&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;유저가 기기를 꽂자마자 바로 작동하는 진정한 zero config을 구현하기 위해&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;저는 &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;70&quot; data-path-to-node=&quot;10&quot;&gt;Reverse Tunneling&lt;/b&gt;&lt;/span&gt;&amp;nbsp;모델로 설계했습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;로컬 서버가 클라우드 게이트웨이를 향해 먼저 아웃바운드 연결 하도록 해서&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;유저는 공유기 설정에 손 하나 대지 않고도 기기를 안전하게 구동할 수 있게 된 거죠&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;프로토콜의 미스매치: 브라우저의 제약 vs 기기의 네트워크 불안정성&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;보통 웹 애플리케이션에서 양방향 실시간 통신이라고 하면&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;브라우저가 기본 지원하는 WebSockets(Socket.IO)가 사실상 표준이죠. 가볍고 반응성도 좋으니까요&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 &quot;가벼운&quot; 웹소켓 클라이언트를 화면도 없는 headless 기기에 그대로 올리면&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;프로덕션 환경에서는 심각한 운영 취약점이 발생합니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;16&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,0,0&quot;&gt;Connection Drops:&lt;/b&gt; 로컬 서버가 Wi-Fi 음영 지역 등으로 아주 잠깐이라도 연결을 놓치면, 그 찰나의 다운타임 동안 클라우드가 보낸 중요한 시그널링 패킷은 영원히 증발합니다&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,1,0&quot;&gt;Tight Coupling:&lt;/b&gt; 웹 클라이언트와 기기가 정확히 동일한 소켓 세션에 강하게 묶여 버립니다. 이렇게 되면 나중에 클라우드 게이트웨이를 서버 여러 대로 수평 확장할 때 세션 공유 구조가 지저분해질 수 있죠&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 클라이언트와 로컬 서버 사이에 비동기 버퍼 역할을 해줄 메시지 브로커를 쓰기로 했습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;MQTT를 쓸지 AMQP를 쓸지 표로 간단히 보고 다음으로 넘어가도록 하죠&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.6356%;&quot;&gt;비교 항목&lt;/td&gt;
&lt;td style=&quot;width: 36.5891%;&quot;&gt;MQTT (Message queuing telemry transport)&lt;/td&gt;
&lt;td style=&quot;width: 40.7752%;&quot;&gt;AMQP (Advanced message queuing protocol)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.6356%;&quot;&gt;&lt;b&gt;네트워크 토폴로지&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 36.5891%;&quot;&gt;&lt;span&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,2,1,0&quot;&gt;가벼운 Pub/Sub 구조&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;Broker가 단순 메시지 중계 역할만 수행&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.7752%;&quot;&gt;&lt;span&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,2,2,0&quot;&gt;Queue 기반 메일박스 구조&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;Exchange와 Queue를 통한 정교한 라우팅&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.6356%;&quot;&gt;&lt;b&gt;데이터 라이브 사이클&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 36.5891%;&quot;&gt;&lt;span&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,4,1,0&quot;&gt;Publish 이후 Subscribe(Consume)가 없으면 사라짐&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;(기본 사양, 단 Session 유지 및 Retained 메시지 설정 시 예외)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.7752%;&quot;&gt;&lt;span&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,4,2,0&quot;&gt;Ack(수신 확인) 및 TTL 만료 전까지 큐에 보유&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;소비자가 오프라인이어도 안전하게 메시지 보존&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.6356%;&quot;&gt;&lt;b&gt; 연결 &amp;amp; 라우팅 방식 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 36.5891%;&quot;&gt;&lt;span&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,3,1,0&quot;&gt;Topic 기반 라우팅&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;단순한 문자열 매칭 (/home/sensor/temp)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.7752%;&quot;&gt;&lt;span&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,3,2,0&quot;&gt;Queue Name 및 Binding Key 기반 라우팅&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;Direct, Fanout, Topic, Headers 등 다양한 방식 지원&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size20&quot;&gt;1. MQTT가 엄격한 시그널링 파이프라인에서 실패하는 이유&lt;/h4&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;MQTT는 가볍고 오버헤드가 적어 고빈도 계측 데이터를 브로드캐스팅하는 데는 타의 추종을 불허합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;하지만 MQTT는 본질적으로 엄격한 메시지 큐가 아닙니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;로컬 서버가 잠깐 터널을 재연결하는 동안 클라이언트가 SDP Offer를 토픽에 발행해 버리면&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;그 메시지는 증발하고 연결이 안되는 상황이 생기죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size20&quot;&gt;2. AMQP가 유리했던 이유&lt;/h4&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;결국 저는 RabbitMQ(AMQP)를 채택하여 우편함 기능을 활용했습니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;22&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,0,0&quot;&gt;확실한 배달 보장(ACK):&lt;/b&gt; 클라우드 게이트웨이가 시그널링 이벤트를 쏘면, RabbitMQ는 이를 물리 큐(메모리)에 보관 합니다. 네트워크가 불안정해도 기기가 다시 연결되는 순간 자신의 개인 우편함에서 밀린 시그널링 페이로드를 한 번에 가져가므로, 패킷 유실로 시스템 상태가 꼬이는 일이 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,1,0&quot;&gt;구독 편의성(Zero-Knowledge Subscriptions):&lt;/b&gt; 로컬 서버가 상위 라우팅 구조나 복잡한 토픽을 찾아 다이나믹하게 구독할 필요가 없습니다. 기기마다 전용 큐를 딱 하나씩 배정해 두고, 로컬서버는 오직 자기 우편함만 바라보게 만들었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;비동기 백프레셔 전략: 1분 QoS 드롭 정책 (Message TTL)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;비동기 우편함 구조가 일시적인 네트워크 순단은 완벽하게 해결해 주지만&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;새로운 아키텍처적 취약점을 만들어냅니다. 바로 &lt;b data-index-in-node=&quot;66&quot; data-path-to-node=&quot;25&quot;&gt;큐 부하&lt;/b&gt; 문제입니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;만약 로컬 서버가 몇 시간 동안 오프라인 상태가 되었는데&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;클라이언트가 웹소켓을 통해 고빈도의 WebRTC 시그널링 이벤트를 계속 밀어 넣는다고 생각해보죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(어우.. 살려줘요..)&lt;/s&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;RabbitMQ 큐에는 계속 쌓이고 시스템 메모리를 갉아먹겠죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;나중에 기기가 겨우 연결되었을 때 패킷들이 뒤섞여 쏟아지며 대참사가 일어나겠죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;이를 제어하기 위해 RabbitMQ의 &lt;b data-index-in-node=&quot;21&quot; data-path-to-node=&quot;27&quot;&gt;Per-Queue Message TTL(Time-To-Live)&lt;/b&gt; 기능을 이용해&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;엄격한 &lt;b data-index-in-node=&quot;69&quot; data-path-to-node=&quot;27&quot;&gt;1분 QoS 드롭 정책&lt;/b&gt;을 걸어두었습니다.&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;28&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;28,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,0&quot;&gt;핵심 설계 원칙:&lt;/b&gt; &amp;gt; &quot;실시간 네트워킹 기기가 액티브 세션 중에 1분 이상 시그널링이 끊겼다면, 그 패킷은 이미 죽은 패킷이다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;네트워크가 끊긴 기기가 60초 이내에 복구되어 우편함을 비우지 못하면&lt;/p&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;큐에 쌓여있던 오래된 패킷들을 폐기시켜 재연결 시 과부하로 죽는 현상을 막고&lt;/p&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;유효한 패킷들만 처리하게 하는거죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;31&quot; data-ke-size=&quot;size23&quot;&gt;인바운드 공격 표면을 원천 차단하는 아키텍처&lt;/h3&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;로컬 공유기의 인바운드 포트를 절대 열지 않기 위해 완성된 &lt;b data-index-in-node=&quot;33&quot; data-path-to-node=&quot;32&quot;&gt;아웃바운드 전용 릴레이 모델&lt;/b&gt;의 전체 파이프라인은 다음과 같습니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;connection.webp&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;179&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dLQvOI/dJMcabEtnHK/90putYCEiNQg5SrQLPDDq1/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dLQvOI/dJMcabEtnHK/90putYCEiNQg5SrQLPDDq1/img.webp&quot; data-alt=&quot;&amp;amp;lt; reverse tunneling 구조 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dLQvOI/dJMcabEtnHK/90putYCEiNQg5SrQLPDDq1/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdLQvOI%2FdJMcabEtnHK%2F90putYCEiNQg5SrQLPDDq1%2Fimg.webp&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;750&quot; height=&quot;179&quot; data-filename=&quot;connection.webp&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;179&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; reverse tunneling 구조 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;33&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;33,0,0&quot;&gt;React 클라이언트&lt;/b&gt;가 NestJS 백엔드가 관리하는 보안 웹소켓 룸을 통해 시그널링 이벤트를 트리거&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;33,1,0&quot;&gt;NestJS 클라우드 게이트웨이&lt;/b&gt;가 이 페이로드를 가로채 비즈니스 로직을 수행 후, 소켓 이벤트를 AMQP 라우팅 키(devices.{machineId}.inbound)로 변환&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;33,2,0&quot;&gt;RabbitMQ&lt;/b&gt;가 메시지를 해당 기기의 격리된 전용 큐로 전달&lt;/li&gt;
&lt;li&gt;로컬 서버는 오직 아웃바운드 연결만을 통해 이 메시지를 Consume하고, SDP를 처리한 뒤 응답을 다시 아웃바운드 파이프라인으로 밀어 넣습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;35&quot; data-ke-size=&quot;size23&quot;&gt;이중 레이어 보안 (WebSockets &amp;amp; AMQPS)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;오픈소스 레포지토리나 로컬 서버의 런타임 환경에 고정된 자격 증명을 하드코딩하는 것은 그야말로 보안 안티 패턴입니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;완벽한 Zero-Trust를 달성하기 위해, 웹소켓과 AMQP 각각에 인증 라이프사이클을 적용했습니다&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;37&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-path-to-node=&quot;37&quot; data-ke-size=&quot;size20&quot;&gt;1. 클라이언트 사이드 보호: 웹소켓 룸을 위한 상태 없는 JWT&lt;/h4&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;1. 유저가 로그인하고 React 대시보드를 열면, 클라이언트는 NestJS 인증 플레인으로부터 수명이 짧은 JWT를 발급받습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;2. Socket.IO 핸드셰이크 과정에서 게이트웨이가 이 토큰을 검증하고, 확인이 끝나면 해당 소켓 세션을 유저의 ID와 특정 machineId로 묶인 격리된 룸에 동적으로 진입시킵니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;3. 악성 클라이언트가 다른 기기의 채널을 훔쳐보거나 패킷을 주입하는 행위를 원천 차단합니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;39&quot; data-ke-size=&quot;size20&quot;&gt;2. 기기 사이드 보호: RabbitMQ ACL을 통한 계정 생성&lt;/h4&gt;
&lt;p data-path-to-node=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;기기마다 고정된 브로커 계정을 공유하는 대신, RabbitMQ Management HTTP API를 활용해 &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;60&quot; data-path-to-node=&quot;40&quot;&gt;온디맨드 자격 증명 라이프사이클&lt;/b&gt;&lt;/span&gt;을 만들었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;로컬 서버가 페어링을 시작하는 순간, NestJS서버는 crypto를 이용해 안전한 ID/PW 패이로드를 생성합니다. 그리고 브로커에 전달하기 전에 &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;128&quot; data-path-to-node=&quot;41&quot;&gt;엄격한 정규식 기반의 ACL 권한&lt;/b&gt;&lt;/span&gt;을 주입합니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwiI09jG6PiUAxUAAAAAHQAAAAAQnAE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;const permissionRegex = `^(amq\\.default|q_device_${machineId})$`;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;이 규칙 덕분에 에지 노드는 가상 호스트의 다른 어떤 설정도 건드릴 수 없고&lt;/p&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;오직 자신에게 할당된 명시적 큐와 기본 익스체인지에만 접근(Read/Write)할 수 있습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;설령 물리적인 탈취로 인해 기기 하나가 해킹당하더라도 공격자는 철저히 격리된 샌드박스에 갇히게 되고&lt;/p&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;클러스터 내의 다른 기기 트래픽을 감지하거나 손댈 수 없습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;기기가 연결 해제되거나 해제 요청이 들어오면 Nest서버에서 즉시 브로커 API에 DELETE 콜을 날려 계정을 흔적도 없이 지워버립니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;44&quot; data-ke-size=&quot;size20&quot;&gt;3. 전송 계층 보안(TLS): Nginx 리버스 프록시 &amp;amp; AMQPS (5671)&lt;/h4&gt;
&lt;p data-path-to-node=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;네트워크 패킷이 평문으로 노출된다면 어플리케이션 레이어의 토큰들은 아무런 의미가 없겠죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;게다가 WebRTC는 보안 컨텍스트를 엄격히 요구하기 때문에 unencrypted HTTP 환경에서는&lt;/p&gt;
&lt;p data-path-to-node=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;브라우저 카메라 권한(getUserMedia)을 싹 차단해 버립니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;그래서 토폴로지 전반에 걸쳐 TLS 암호화를 강제했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;46&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;46,0,0&quot;&gt;Nginx를 통한 TLS 설정:&lt;/b&gt; NestJS 클라우드가 직접 TLS 터미네이션을 처리하지 않도록 하고, 앞단에 자동 갱신되는 Let's Encrypt 인증서 기반의 &lt;b data-index-in-node=&quot;93&quot; data-path-to-node=&quot;46,0,0&quot;&gt;Nginx 리버스 프록시&lt;/b&gt;를 두었습니다. 덕분에 민감한 SDP와 ICE 정보가 담긴 일반 REST API(https://) 및 고빈도 Socket.IO 패킷(wss://)은 공용 인터넷을 지나기 전에 완전히 암호화됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;46,1,0&quot;&gt;AMQPS(포트 5671)로 브로커:&lt;/b&gt; 로컬 에이전트와 RabbitMQ 간의 아웃바운드 동기화 터널은 평문 AMQP(포트 5672)를 아예 거부합니다. 브로커가 오직 AMQPS(포트 5671)로만 통신하도록 매핑했고, 로컬 에이전트는 브로커의 인증 기관 체인에 대해 엄격한 TLS 핸드셰이크를 수행합니다. 로컬 공유기 단에서 일어날 수 있는 중간자 공격이나 패킷 인젝션을 예방했습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;48&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;48&quot; data-ke-size=&quot;size23&quot;&gt;마치며&lt;/h3&gt;
&lt;p data-path-to-node=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;연결성과 보안이 강화된 동적 아키텍처를 결합함으로써 &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;69&quot; data-path-to-node=&quot;49&quot;&gt;유저 경험과 인프라 보안을 모두 챙긴 시그널링&lt;/b&gt;&lt;/span&gt;을 완성할 수 있었습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;하지만 안전하고 비동기적인 브로커 레이어를 구축했다고 해서 끝난 것은 아닙니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;메시지 큐가 도입되는 순간, 우리는 또 다른 변수인 비동기성을 마주하게 되기 때문이죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;RabbitMQ 익스체인지를 타고 SDP Offer, Answer, 그리고 수많은 Trickle ICE Candidate들이&lt;/p&gt;
&lt;p data-path-to-node=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;무서운 속도로 몰아치기 시작하면 필연적으로 레이스 컨디션(경쟁 상태)이 발생합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;히든레이어에서 핸들링하는 웹 브라우저와 달리 엄격한 Node.js 미디어 런타임은 핸드셰이크 패킷이&lt;/p&gt;
&lt;p data-path-to-node=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;단 1밀리초라도 순서가 뒤바뀌어 도착하면 가차 없이 크래시를 내며 뻗어버리니까요&lt;/p&gt;
&lt;p data-path-to-node=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;다음 부분에서는 이 비동기 시그널링을 다룹니다&lt;/p&gt;</description>
      <category>Side project</category>
      <category>MQTT</category>
      <category>nginx</category>
      <category>QoS</category>
      <category>RabbitMQ</category>
      <category>race condition</category>
      <category>Reverse tunneling</category>
      <category>Signaling server</category>
      <category>tls</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/139</guid>
      <comments>https://devmemory.tistory.com/139#entry139comment</comments>
      <pubDate>Tue, 9 Jun 2026 09:47:07 +0900</pubDate>
    </item>
    <item>
      <title>Local Device Pairing: From Hell to Heaven (feat. mDNS)</title>
      <link>https://devmemory.tistory.com/138</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 클라우드 애플리케이션과 상호작용해야 하는 로컬 IoT나 미디어 서버를 배포할 일이 있었는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 과정에서 가장 큰 복병을 만났습니다. 바로 &lt;b data-index-in-node=&quot;82&quot; data-path-to-node=&quot;3&quot;&gt;&lt;span style=&quot;color: #f89009;&quot;&gt;기기 검색&lt;/span&gt;&lt;/b&gt;과&lt;b data-index-in-node=&quot;82&quot; data-path-to-node=&quot;3&quot;&gt; &lt;span style=&quot;color: #f89009;&quot;&gt;페어링(Pairing)&lt;/span&gt;&lt;/b&gt; 문제입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;로컬 네트워크 공유기에서 포트 포워딩(Port Forwarding)을 따로 설정하지 않으면&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;클라우드 서버, 클라이언트는 로컬에 있는 기기에 직접 접근할 수가 없습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;하지만 포트 포워딩에 의존하게 되면 여러 가지 골치 아픈 문제들이 생깁니다&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;6&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;유저가 공유기 접근 권한이 없을 때&lt;/li&gt;
&lt;li&gt;권한이 있다고 해도, 로컬 포트를 외부 인터넷에 그대로 노출할 때 발생하는 보안 취약점&lt;/li&gt;
&lt;li&gt;게다가 비전문가인 일반 유저에게 로컬 IP 주소를 직접 찾아서 입력하라고 할 때 저급한 UX&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;이런 네트워크 설정 지옥을 피하기 위해, 여러 가지 대안 페어링 전략들을 고민해 봤습니다&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;대안 1: BLE (Bluetooth Low Energy)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;모바일 앱이라면 BLE가 좋은 대안이 됩니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;(프레임워크에서 강력한 스캔 및 페어링 API를 제공합니다)&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;기기가 퍼블리싱(브로드캐스트)을 하고, 앱이 스캔해서 페어링하면 끝납니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;그런데 웹 애플리케이션에서는 조금 다른 이야기가 됩니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;Web Bluetooth API는 브라우저 보안 정책에 의해 제약이 많고, 크로스 플랫폼 안정성도 떨어집니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;궁극적으로 이번 프로젝트는 &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;102&quot; data-path-to-node=&quot;11&quot;&gt;웹에서 매끄러운 경험을 제공하는 것이 목표&lt;/b&gt;&lt;/span&gt;였기 때문에 BLE는 과감히 제외했습니다&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;대안 2: mDNS (Multicast DNS)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;mDNS는 설정이 필요 없는 네트워크를 보장한다는 점에서 정말 매력적인 카드였습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;처음 샌드박스 단계에서 웹 클라이언트와 로컬 서버를 같은 PC에 띄워두고 테스트했을 때는&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;아무런 설정 없이도 완벽하게 잘 작동했습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 제로 세팅은 실제 환경에서 생각대로 작동하지 않더군요&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;(동일 Wi-Fi 환경에서 폰으로 pc를 찾으려고 할 때)&lt;/p&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;공유기를 별도로 세팅해주지 않으면 mDNS는 기본적으로 기기를 제대로 찾지 못하는 경향이 있습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;요즘 나오는 대부분의 기업용/가정용 공유기들은 대역폭 최적화와 보안을 이유로 &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b&gt;AP 격리(AP Isolation)&lt;/b&gt;&lt;/span&gt;나&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b data-index-in-node=&quot;128&quot; data-path-to-node=&quot;16&quot;&gt;IGMP 스누핑(IGMP Snooping)&lt;/b&gt;&lt;/span&gt; 차단 기능을 켜두기 때문에, 멀티캐스트 포워딩이 기본적으로 막혀있습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;일반 유저에게 공유기 어드민 페이지에 접속하라고 강요할 수는 없는 노릇이니&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;mDNS 하나만 믿고 가기에는 무리가 있습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;확실한 Fallback 전략이 필수적인 상황이죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;그래도 웹 개발자로서 클라우드에 올려놓은 클라이언트가&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;기기에 올라가있는 서버에 접근하는건 충분히 매력적이어서 한번 해봤습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;해당 플로우를 보면&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;mdns.webp&quot; data-origin-width=&quot;960&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmbIdl/dJMcabYParF/uOTOevr8mNKdGR5stxhGX1/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmbIdl/dJMcabYParF/uOTOevr8mNKdGR5stxhGX1/img.webp&quot; data-alt=&quot;&amp;amp;lt; 로컬 서버 연결 flow chart &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmbIdl/dJMcabYParF/uOTOevr8mNKdGR5stxhGX1/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmbIdl%2FdJMcabYParF%2FuOTOevr8mNKdGR5stxhGX1%2Fimg.webp&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;960&quot; height=&quot;720&quot; data-filename=&quot;mdns.webp&quot; data-origin-width=&quot;960&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 로컬 서버 연결 flow chart &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;20&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;로컬 서버가 부팅되면 클라우드에 쿼리를 날려 페어링 상태 확인&lt;/li&gt;
&lt;li&gt;아직 페어링되지 않은 상태라면, mDNS를 구동하여 특정 호스트 네임으로 브로드캐스트&lt;/li&gt;
&lt;li&gt;클라이언트가 이 브로드캐스트를 감지하면, 보안 핸드셰이크 토큰을 통해 페어링 시작&lt;/li&gt;
&lt;li&gt;페어링 트랜잭션이 완료되면, 로컬 서버는 mDNS 브로드캐스트를 안전하게 내리고 클라우드에 요청&lt;/li&gt;
&lt;li&gt;이후 클라우드는 MQTT 브로커를 위한 증명을 발급하고, 기기는 Standby 상태로 전환되어 클라이언트의 명령을 구독&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size23&quot;&gt;클라이언트 사이드 Fallback의 필요성&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;client_fallback.webp&quot; data-origin-width=&quot;620&quot; data-origin-height=&quot;795&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VYARX/dJMcag6QSll/LWfXES0xg64W4LgMTKTNkK/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VYARX/dJMcag6QSll/LWfXES0xg64W4LgMTKTNkK/img.webp&quot; data-alt=&quot;&amp;amp;lt; 클라이언트 paring flow chart &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VYARX/dJMcag6QSll/LWfXES0xg64W4LgMTKTNkK/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVYARX%2FdJMcag6QSll%2FLWfXES0xg64W4LgMTKTNkK%2Fimg.webp&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;620&quot; height=&quot;795&quot; data-filename=&quot;client_fallback.webp&quot; data-origin-width=&quot;620&quot; data-origin-height=&quot;795&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 클라이언트 paring flow chart &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-path-to-node=&quot;25&quot;&gt;클라이언트는 먼저 지정된 로컬 mDNS 호스트 네임을 스캔하며 낙관적인 조회를 시도&lt;/li&gt;
&lt;li data-path-to-node=&quot;25&quot;&gt;기기가 발견되면 상태를 확인하고, 클라우드에 보안 페어링 토큰을 요청&lt;/li&gt;
&lt;li data-path-to-node=&quot;25&quot;&gt;토큰 데이터를 로컬 서버로 직접 보내 보내 페어링을 완료&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;하지만 앞서 말씀드렸듯이, 현실의 Wi-Fi 환경에서 mDNS는 예상치 못한 이유로 실패하곤 합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;서비스가 완전히 먹통이 되는 걸 막으려면 강력한 대체 메커니즘이 필요하겠죠&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;그래서 대부분 QR, pin code입력 등으로 대체를 합니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;사실 대다수의 IoT 기업들이 기본 세팅으로 QR 코드, pin code를 사용하는 이유가 바로 이것 때문입니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;(로컬 네트워크의 인프라 제약을 완전히 우회하면서 가장 쉽고 깔끔하게 해결 가능)&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;유저가 이를 스캔하면 앱은 막혀있는 로컬 멀티캐스트 레이어를 싹 건너뛰고&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;Cloud에 유저 아이디랑 기기 일련번호만 넘겨주면 되니까요&lt;/p&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size23&quot;&gt;마치며&lt;/h3&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;스마트홈 허브부터 스트리밍 하드웨어까지 대부분 대형 IoT 기업들이&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;왜 더 간단한 pairing방법을 채택했는지 알게되는 좋은 계기가 된거 같습니다&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;1. 처음에는 mDNS라는 기술적인 우아함에 이끌려 시작했다가&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;2. 파편화된 공유기 설정이라는 혹독한 현실을 마주하고&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;3. 결국에는 QR 코드나 pin code 기반의 클라우드 페어링으로 정착&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;이 변화를 받아들인 이유는 프로덕트 개발에 있어 변하지 않는 단 하나의 진리 때문이 아닐까 싶습니다&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;33&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;33,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;33,0&quot;&gt;&quot;비즈니스에서 유저 경험(UX)은 개발자 경험(DX)보다 훨씬 더 중요하다.&quot;&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;36&quot; data-ke-size=&quot;size23&quot;&gt; ️ 아키텍처 및 소스 코드 구경하기&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;38&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;38,0,0&quot;&gt;전체 소스 (GitHub)&lt;/b&gt;   &lt;a href=&quot;https://github.com/devmemory/portfolio-device-sync&quot;&gt;https://github.com/devmemory/portfolio-device-sync&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;38,1,0&quot;&gt;배포된 사이트&lt;/b&gt;&amp;nbsp;  &lt;a href=&quot;https://www.devmemory.xyz/&quot;&gt;https://www.devmemory.xyz/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;real.gif&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;816&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MHgnp/dJMcaar4uQX/8EEJjostuPBhEOkTnYyBD1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MHgnp/dJMcaar4uQX/8EEJjostuPBhEOkTnYyBD1/img.gif&quot; data-alt=&quot;&amp;amp;lt; 테스트 화면 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MHgnp/dJMcaar4uQX/8EEJjostuPBhEOkTnYyBD1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/MHgnp/dJMcaar4uQX/8EEJjostuPBhEOkTnYyBD1/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1244&quot; height=&quot;816&quot; data-filename=&quot;real.gif&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;816&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 테스트 화면 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;42&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;42,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;42,0&quot;&gt;참고:&lt;/b&gt; 현재 클라우드 서버와 클라이언트는 호스팅되어 있지만, 실제 실시간 스트리밍 세션을 연결하려면 카메라 옆에서 작동하는 로컬 서버가 필요합니다. 하드웨어 인프라 보호를 위해 로컬 런타임 환경의 동적 액세스 자격 증명(app-auth 설정)은 엄격히 제한해 둔 상태입니다. 채용 목적 제외하고는 오픈을 제한하고 있습니다&lt;s&gt;(서버비용 많이내고싶지 않아요...)&lt;/s&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;42,1&quot; data-ke-size=&quot;size16&quot;&gt;추가로, 현재 클라우드 인프라는 글로벌 배포 최적화를 위해 &lt;b data-index-in-node=&quot;33&quot; data-path-to-node=&quot;42,1&quot;&gt;독일&lt;/b&gt;에 호스팅되어 있습니다. 이 때문에 한국에서 촬영한 라이브 데모 영상에서는 물리적 거리로 인한 대륙 간 네트워크 레이턴시와 약간의 렌더링 끊김 현상이 보일 수 있습니다. 유럽에서 대시보드에 직접 접속하시면 훨씬 더 매끄럽고 지연 없는 환경을 확인하실 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Side project</category>
      <category>cloud</category>
      <category>Latency</category>
      <category>mDNS</category>
      <category>pairing</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/138</guid>
      <comments>https://devmemory.tistory.com/138#entry138comment</comments>
      <pubDate>Tue, 9 Jun 2026 09:12:40 +0900</pubDate>
    </item>
    <item>
      <title>Node - appending row to Google spread sheet</title>
      <link>https://devmemory.tistory.com/137</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근에&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. aws s3에 업로드 되는 파일이 보기 편하면 좋겠다(링크, 날짜, 해당 파일 정보)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 에러 발생시 에러대응 가능하도록 트래킹 할수있게 해달라&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라는 요구를 받아서 비개발자가 보기에 가장 편한방식이 뭐가 있을까 고민해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 개발진행된 상황으로는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 특정 pc에만 배포되어있는 react, node&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 외부 서버 없음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 해당 pc는 유저들이 사용하는 pc&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 고민해봤던게 2가지로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;slack을 사용하는 환경으로 webhook추가(빠른 알람 및 대응)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;google spread sheet를 사용해 줄추가를 해보게 됐습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;webhook은 url이랑 data약속만 지키면 되니 건너뛰고 예제를 만들어봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(사실 대부분이 url이랑 약속만 잘지키면 됩..)&lt;/s&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1758006177038&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const appendToGoogleSheet = async ({
  state,
  songInfo,
  uuid,
}: {
  state: number;
  songInfo?: string[];
  uuid: string;
}) =&amp;gt; {
  try {
    const client = (await auth.getClient()) as any;
    const sheets = google.sheets({ version: &quot;v4&quot;, auth: client });

    const s3Link = process.env.S3_BUCKET_FOLDER + uuid + &quot;/&quot;;

    let values: string[][];
    let range: string;

    if (state === MEDIA_STATE.error) {
      values = [[s3Link, getCurrentDate()]];
      range = getRange(&quot;error&quot;, values[0].length);
    } else {
      values = [[...songInfo!, s3Link, getCurrentDate()]];
      range = getRange(&quot;log&quot;, values[0].length);
    }

    await sheets.spreadsheets.values.append({
      spreadsheetId: SPREADSHEET_ID,
      range,
      valueInputOption: &quot;RAW&quot;,
      requestBody: {
        values,
      },
    });
  } catch (err) {
    logger.error(`[google] error: ${getErrMsg(err)}`);
  }
};

const getRange = (sheet: string, columnCount: number) =&amp;gt; {
  const lastColumn = String.fromCharCode(&quot;A&quot;.charCodeAt(0) + columnCount - 1);
  return `${sheet}!A:${lastColumn}`;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;google api를 사용하다보니 서비스계정이 필요하고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;api 설정, 편집자 등록 후 GoogleAuth를 콜해서 인증을 해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 values에 넣을 데이터를 적어줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 range에 &quot;sheet name!첫번째행:마지막행&quot;으로 적어주시면 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;multiple row를 appending하고 싶은 경우&lt;/p&gt;
&lt;pre id=&quot;code_1759194147431&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let values:string[][] = []

for(const e of list){
  const value = [...e.songInfo, s3Link, getCurrentDate()]
  values.push(value)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;request body의 values 부분만 변경해 넣어주시면 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 후 확인해보면 정상적으로 작동되는데, 한가지 아쉬움이 있다면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최신 데이터를 맨 위로 쌓는 기능은 제공하지 않더군요&lt;/p&gt;</description>
      <category>WEB</category>
      <category>append row</category>
      <category>google api</category>
      <category>node</category>
      <category>spread sheet</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/137</guid>
      <comments>https://devmemory.tistory.com/137#entry137comment</comments>
      <pubDate>Tue, 16 Sep 2025 16:17:28 +0900</pubDate>
    </item>
    <item>
      <title>React, Next - localization with google spread sheet</title>
      <link>https://devmemory.tistory.com/136</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 다국어 지원 서비스를 개발할 때, 비개발자랑 같이 진행하게 되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;json만 던져주고 알아서 수정하라기에는 난이도가 있다보니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;google spread sheet를 공유하면서 작업하는게 더 생산성이 높다 생각합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 간단하게 예제를 만들어봤는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(오랜만에 localization 작업하다가 정리 안해놨더니 다시 작업할 때 삽질 포인트를 반복해서 적는건 안비밀)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 필자가 원하는 저장경로는 대략 /[lang code]/common.json으로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로가 다르다면 구현할 때 해당 부분을 조금 신경써주시면 될거 같습니다&lt;/p&gt;
&lt;pre id=&quot;code_1758003206787&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const Papa = require(&quot;papaparse&quot;);
const fs = require(&quot;fs/promises&quot;);

async function fetchTranslations() {
  const url = 'https://docs.google.com/spreadsheets/d/e/[id]/pub?output=csv';

  console.log(&quot;Fetching translations from Google Sheets...&quot;, { url });
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Failed to fetch sheet: ${res.statusText}`);

  const csvText = await res.text();
  const parsed = Papa.parse(csvText, { header: true });

  const rows = parsed.data;
  if (!rows.length) throw new Error(&quot;No data found in the sheet.&quot;);

  const languages = Object.keys(rows[0]).filter((col) =&amp;gt; col !== &quot;key&quot;);

  const translations = {};
  languages.forEach((lang) =&amp;gt; {
    translations[lang] = {};
  });

  rows.forEach((row) =&amp;gt; {
    const key = row.key;
    if (!key) return;
    languages.forEach((lang) =&amp;gt; {
      translations[lang][key] = row[lang] || &quot;&quot;;
    });
  });
  
  for (const lang of languages) {
    await fs.mkdir(`./src/i18n/${lang}`, { recursive: true });
    const filePath = `./src/i18n/${lang}/common.json`;
    await fs.writeFile(
      filePath,
      JSON.stringify(translations[lang], null, 2),
      &quot;utf-8&quot;
    );
    console.log(`Saved ${filePath}`);
  }

  console.log(&quot;All translation files saved successfully!&quot;);
}

fetchTranslations().catch((err) =&amp;gt; {
  console.error(&quot;Error fetching translations:&quot;, err);
  process.exit(1);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 spread sheet에서 웹에 게시를 해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(google api를 사용하실 예정이면 서비스계정으로 진행하시면 되겠습니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;google api를 사용하지 않은 이유는 중요한정보를 글로서리로 관리하지 않았고 렌딩페이지 전용이었습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(가장 중요한 이유는.. 권한을 안주셨어요)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fetch로 csv파일 형태로 가져오고, papaparse라는 csv 파싱 라이브러리를 이용해 작업을 하는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 헤더에서 lang code를 추출하고, key value 형태로 저장합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(excel 형태에 따라 변경될 수 있습니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 각각 언어에 맞게 폴더 생성 및 저장을 진행해주면 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후에 해당 파일을 node를 이용해서 해당 코드를 실행해주시면 되겠습니다&lt;/p&gt;
&lt;pre id=&quot;code_1758065937567&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;download:lang&quot;: &quot;node src/i18n/index.js&quot;,
    &quot;build&quot;: &quot;yarn download:lang &amp;amp;&amp;amp; next build&quot;,
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런식으로 말이죠&lt;/p&gt;</description>
      <category>WEB</category>
      <category>Google sheet</category>
      <category>localization</category>
      <category>react</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/136</guid>
      <comments>https://devmemory.tistory.com/136#entry136comment</comments>
      <pubDate>Tue, 16 Sep 2025 15:32:13 +0900</pubDate>
    </item>
    <item>
      <title>React - Multiple camera recording</title>
      <link>https://devmemory.tistory.com/135</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 여러개의 카메라를 제어할 일이 생겼는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한번에 모든 카메라를 보여주는 형태는 아니고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하는 시점에 카메라를 스위칭 후 화면에 보여주고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 화면을 원하는 형태로 편집 후 녹화해 주는 기능이 필요했습니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;output.gif&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ucdn4/btsQAhcR6cp/u90Iq8ErQeMP3iRwX0SOik/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ucdn4/btsQAhcR6cp/u90Iq8ErQeMP3iRwX0SOik/img.gif&quot; data-alt=&quot;&amp;amp;lt; 3초마다 변경되는 화면 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ucdn4/btsQAhcR6cp/u90Iq8ErQeMP3iRwX0SOik/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/ucdn4/btsQAhcR6cp/u90Iq8ErQeMP3iRwX0SOik/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;405&quot; data-filename=&quot;output.gif&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 3초마다 변경되는 화면 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 고민했던 부분은 카메라를 껏다 켯다 해보는거 였는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;media recorder n개를 사용해서 각각 녹화 후 timeline에 맞게 ffmpeg으로 붙여주는 번거로운 작업이 예상 되었고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;지연이랑 플리커링 부분 &lt;/span&gt;그리고 만약 카메라가 원하는 시점에 안켜지면 서비스 품질이 떨어질거라 예상해 다른 방법을 선택했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. streaming은 계속 진행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. canvas를 이용해서 카메라 보여주기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 시간 혹은 특정 이벤트에 따른 video 스위칭&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. canvas를 media recorder로 녹화&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 코드로 확인해보겠습니다&lt;/p&gt;
&lt;pre id=&quot;code_1757991987514&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;export const getMultipleMedia = async () =&amp;gt; {
  const list = await getMultipleCameras();

  let streams: MediaStream[] = [];

  for (let i = 0; i &amp;lt; list.length; i++) {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: i === 0,
      video: {
        deviceId: { exact: list[i] },
        width: VIDEO_SIZE.width,
        height: VIDEO_SIZE.height,
        aspectRatio: VIDEO_SIZE.aspectRatio,
        frameRate: { ideal: 30, max: 60 },
      },
    });

    streams = [...streams, stream];
  }

  return streams;
};

const getMultipleCameras = async () =&amp;gt; {
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    return [];
  }

  // Request permission
  const stream = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true,
  });

  try {
    const devices = await navigator.mediaDevices.enumerateDevices();

    // 비디오 입력 장치 (카메라) 필터링
    const videoDevices = devices.filter(
      (device) =&amp;gt; device.kind === &quot;videoinput&quot;
    );

    let list: string[] = [];

    for (const e of videoDevices) {
      const label = e.label.toLowerCase();

      if (사용하는 카메라 label) {
        list = [...list, e.deviceId];
      }
    }

    return list;
  } finally {
    // Stop all tracks to turn off camera/mic
    stream.getTracks().forEach((track) =&amp;gt; track.stop());
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용전에 먼저 permission을 가져와야되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 사용하던 request permission은 deprecated돼서, 껏다가 켜는 형태로 진행 했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 사용 가능한 기기들을 검색 후, 원하는 카메라를 가져옵니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 카메라 id를 이용해서 stream들을 불러옵니다&lt;/p&gt;
&lt;pre id=&quot;code_1757991609039&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const useTestRecord = () =&amp;gt; {
  const videoRefList = useRef&amp;lt;HTMLVideoElement[]&amp;gt;([]);

  const frameId = useRef&amp;lt;number&amp;gt;(null);

  const canvasRef = useRef&amp;lt;HTMLCanvasElement&amp;gt;(null);

  const [streams, setStreams] = useState&amp;lt;MediaStream[]&amp;gt;([]);

  const activeIdx = useRef&amp;lt;number&amp;gt;(0);

  const { startInterval, stopInterval } = useInterval(() =&amp;gt; {
    activeIdx.current = (activeIdx.current + 1) % streams.length;
  }, 3000);

  console.log(videoRefList.current, activeIdx);

  const {
    videoBlob,
    setVideoBlob,
    setRecordVideo,
    startRecordingVideo,
    stopRecordingVideo,
  } = useTestVideo();

  useEffect(() =&amp;gt; {
    init();

    commonUtil.delay(5000).then(() =&amp;gt; {
      onStartRecording();
    });
    return () =&amp;gt; {
      onTurnOffMedia();
    };
  }, []);

  useEffect(() =&amp;gt; {
    onStreamMedia();
  }, [streams]);

  useEffect(() =&amp;gt; {
    if (videoBlob) {
      downloadBlob(videoBlob, &quot;test.webm&quot;);
    }
  }, [videoBlob]);

  const downloadBlob = (blob: Blob, filename: string) =&amp;gt; {
    const url = URL.createObjectURL(blob);
    const a = document.createElement(&quot;a&quot;);
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  };

  const init = async () =&amp;gt; {
    const streams = await getMultipleMedia();

    setStreams(streams);
  };

  const onStreamMedia = () =&amp;gt; {
    if (streams.length === 0) {
      return;
    }

    for (let i = 0; i &amp;lt; streams.length; i++) {
      videoRefList.current[i].srcObject = streams[i];
    }

    videoRefList.current[0].onloadedmetadata = () =&amp;gt; {
      drawFrame();
      startInterval();
    };

    const videoStream = canvasRef.current!.captureStream(30);

    setRecordVideo(videoStream);
  };

  const onTurnOffMedia = () =&amp;gt; {
    for (let i = 0; i &amp;lt; streams.length; i++) {
      const mediaStream = streams[i];
      mediaStream.getTracks().forEach((track) =&amp;gt; {
        track.stop();
      });
    }

    videoRefList.current.forEach((video) =&amp;gt; {
      video.pause();
      video.srcObject = null;
    });

    stopRecordingVideo();

    stopInterval();
    setVideoBlob(null);

    if (frameId.current) {
      cancelAnimationFrame(frameId.current);
      frameId.current = null;
    }
  };

  const onStartRecording = () =&amp;gt; {
    startRecordingVideo();
  };

  const drawFrame = () =&amp;gt; {
    drawCanvas(canvasRef.current, videoRefList.current[activeIdx.current]);

    frameId.current = requestAnimationFrame(drawFrame);
  };

  return {
    videoRefList,
    onTurnOffMedia,
    canvasRef,
    streams,
  };
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 hook에서는 canvas를 이용해서 video를 그려서 출력해주는 부분을 진행해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적인 video 세팅 후 interval이 지나면 active index를 바꿔주면서 canvas에 그려주는 작업을 수행합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;drawCanvas는 여러가지 세팅이 들어갈텐데&amp;nbsp; 필요한 부분만 보자면&lt;/p&gt;
&lt;pre id=&quot;code_1757992721735&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const ctx = canvas.getContext(&quot;2d&quot;);
ctx.drawImage(video, ...기타 크기 세팅);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;video 태그에 출력되는 부분을 canvas에 그려주는 형태입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 해당 부분은 media recorder를 통해 record하는 부분인데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 코드는 예제가 많이 있을테니 생략하도록 하겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 지금 현재 고민되는 포인트는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 여러 카메라의 스트리밍을 한 pc에서 받기에 더 나은 쿨링 솔루션이 필요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. media tracking을 중단했다가 시작하는게 성능적으로 문제 없고, 발열제어에 도움이 될지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. deviceId 지정 불가능 및 가변적&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. canvas이용시 화질저하 문제(실제품에는 video tag교체형으로 변경)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3번 부분은 gpt신에게 물어보니&lt;/p&gt;
&lt;pre id=&quot;code_1757996500165&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;IDs are only unstable if: unplug/replug, switch ports, or site loses camera permission.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 작동 시키고 별도로 코드분리, 전원 on/off, permission변경만 없으면 동일하다 합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 시나리오로 전원켜기 -&amp;gt; 어드민에서 지정 -&amp;gt; 시작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 부분을 자동화할 수 있을지 확인해봐야될거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 프로젝트를 더 진행해가면서 맞춰봐야될거 같습니다&lt;/p&gt;</description>
      <category>WEB</category>
      <category>Camera</category>
      <category>Canvas</category>
      <category>getUserMedia</category>
      <category>MediaDevices</category>
      <category>multiple camera</category>
      <category>react</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/135</guid>
      <comments>https://devmemory.tistory.com/135#entry135comment</comments>
      <pubDate>Tue, 16 Sep 2025 12:21:34 +0900</pubDate>
    </item>
    <item>
      <title>Ubuntu - install cursor easily</title>
      <link>https://devmemory.tistory.com/134</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;(해당 작업은 1.3 .AppImage만 다운로드 가능한 시점에 만들어졌습니다)&lt;br /&gt;&lt;br /&gt;사용하는 우분투 pc에 cursor ide를 설치하려고 보니 조금 귀찮은 설정이 필요하더군요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 프로세스를 누가 정리해놔서 확인해보니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(링크 : &lt;a href=&quot;https://gist.github.com/evgenyneu/5c5c37ca68886bf1bea38026f60603b6&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://gist.github.com/evgenyneu/5c5c37ca68886bf1bea38026f60603b6&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. appImage 설치&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. libfuse2 설치&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. appImage 실행 가능하도록 변경&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4.1 script로 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4.2 파일로 만들기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭐 한번 해놓으면 문제 없지만 앞으로 사용할 pc마다 하는건 여간 귀찮은일이 아닐 수 없습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 자동화를 한번 해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로세스를 간단히 설명드리자면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. curl을 이용해 cursor appimage download&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. cursor 아이콘 임시 폴더에 설치 후 특정 폴더로 이동(해당 폴더로 다운로드시 permission문제 발생)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. appimage, 아이콘 permission 변경&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 앱 표시부분에 등록&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 임시폴더 삭제&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 삭제할 때는 설치된 경로에 있는 파일 제거로 진행을 해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용법은 다음과 같고&lt;/p&gt;
&lt;pre id=&quot;code_1752047248667&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//설치시
curl -fsSL https://raw.githubusercontent.com/devmemory/cursor-ubuntu-installer/main/scripts/cursor.sh | sudo bash -s -- install

//삭제시
curl -fsSL https://raw.githubusercontent.com/devmemory/cursor-ubuntu-installer/main/scripts/cursor.sh | sudo bash -s -- uninstall&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드가 궁금하신 분들은 아래 링크를 참고해주세요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(링크: &lt;a href=&quot;https://github.com/devmemory/cursor-ubuntu-installer&quot;&gt;https://github.com/devmemory/cursor-ubuntu-installer&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-07-09 16-49-02.png&quot; data-origin-width=&quot;229&quot; data-origin-height=&quot;217&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LTnwM/btsPaZEL6zY/G8MbU8omjLMgt0WkVoOyLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LTnwM/btsPaZEL6zY/G8MbU8omjLMgt0WkVoOyLK/img.png&quot; data-alt=&quot;&amp;amp;lt; 설치 후 등록된 아이콘 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LTnwM/btsPaZEL6zY/G8MbU8omjLMgt0WkVoOyLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLTnwM%2FbtsPaZEL6zY%2FG8MbU8omjLMgt0WkVoOyLK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;229&quot; height=&quot;217&quot; data-filename=&quot;스크린샷 2025-07-09 16-49-02.png&quot; data-origin-width=&quot;229&quot; data-origin-height=&quot;217&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 설치 후 등록된 아이콘 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;-- 이하 필자의 헛소리가 있으니 필요하신부분을 얻으셨다면 그냥 넘겨주시면 됩니다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 필자는 cursor를 한번도 안사용해봤고 cody ai라는 vs code extension을 사용했었는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(한참 일할 때 회사에서 안사줬어요..)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쓰다보니까 이런 무료 extension도 나름 훌륭(?)한데 ide로 내놓는건 얼마나 훌륭할까 해서 써보려하니..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치부터 귀찮아보이고 삭제도 귀찮아보였습니다 개발자는 귀찮은 일을 해결하는게 주 업무다보니(?)&lt;s&gt;&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런거 하나 만들어볼까 해서 만들어봤는데 GPT가 잘못알려줘서 삽질한 부분이 있었던거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커서 설치후 이것저것 테스트 해봐야되는데 셀 스크립트만 열심히 만진건 안비밀..&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends2&quot; data-emoticon-name=&quot;068&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends2/large/068.png&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends2/large/068.png&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;</description>
      <category>해왔던 삽질..</category>
      <category>command</category>
      <category>cursor</category>
      <category>cursor-ubuntu-installer</category>
      <category>how to install cursor on ubuntu</category>
      <category>ubuntu</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/134</guid>
      <comments>https://devmemory.tistory.com/134#entry134comment</comments>
      <pubDate>Wed, 9 Jul 2025 16:44:30 +0900</pubDate>
    </item>
    <item>
      <title>HLS - 4. outro</title>
      <link>https://devmemory.tistory.com/133</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;해당 부분에서는 구현한 코드 깃헙 주소와 결과물 gif&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 작업하던중 발생한 문제에 대해 공유해보고자 합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;core.gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPUukS/btsMUzgbQhT/3ciIN1Doku1k5cK9RcpNMk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPUukS/btsMUzgbQhT/3ciIN1Doku1k5cK9RcpNMk/img.gif&quot; data-alt=&quot;&amp;amp;lt; upload UI 개선 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPUukS/btsMUzgbQhT/3ciIN1Doku1k5cK9RcpNMk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bPUukS/btsMUzgbQhT/3ciIN1Doku1k5cK9RcpNMk/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;720&quot; data-filename=&quot;core.gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; upload UI 개선 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;hls.gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JzhRb/btsMP1YBHtk/cKxo7oV5qKJ0SfRUlFlqp0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JzhRb/btsMP1YBHtk/cKxo7oV5qKJ0SfRUlFlqp0/img.gif&quot; data-alt=&quot;&amp;amp;lt; upload video, streaming HLS&amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JzhRb/btsMP1YBHtk/cKxo7oV5qKJ0SfRUlFlqp0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/JzhRb/btsMP1YBHtk/cKxo7oV5qKJ0SfRUlFlqp0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;720&quot; data-filename=&quot;hls.gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; upload video, streaming HLS&amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버쪽 작업하면서 가장 시간 많이잡아먹은 부분은 다름아닌 nodemon문제 였습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;nodemon default가 js나 ts가 변경될 때 재시작 되는 특성을 가지는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ts도 아닌 바이너리 파일(segment.ts)가 변경될때마다 서버가 재시작이 돼서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;api는 cancel됐는데 영상은 남아있는 상황이 생겼던거죠..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이거 원인 찾느라 들인 시간이 구현하는 부분보다 더 많이들었었습니다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 클라이언트쪽에서 테스트하는데 serving url잘못 잡아놔서 발생했던 문제가 조금 있었습니다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 부분을 조금 더 빠르게 지나갔으면 더 빨리 완성했을텐데 하는 아쉬움이 남았습니다..&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;015&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/015.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/015.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 예제 깃헙 주소 : &lt;a href=&quot;https://github.com/devmemory/hls_example&quot;&gt;https://github.com/devmemory/hls_example&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Intro : &lt;a href=&quot;https://devmemory.tistory.com/130&quot;&gt;https://devmemory.tistory.com/130&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node server :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/131&quot;&gt;https://devmemory.tistory.com/131&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;React client :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/132&quot;&gt;https://devmemory.tistory.com/132&lt;/a&gt;&lt;/p&gt;</description>
      <category>HLS</category>
      <category>HLS</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/133</guid>
      <comments>https://devmemory.tistory.com/133#entry133comment</comments>
      <pubDate>Wed, 19 Mar 2025 18:06:14 +0900</pubDate>
    </item>
    <item>
      <title>HLS - 3. HLS player client</title>
      <link>https://devmemory.tistory.com/132</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트쪽은 코드가 상대적으로 많을예정인데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 영상 플레이어 자체가 이런저런 기능이 들어가다보니 상대적으로 많아진것도 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 api 구현부분부터 보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;services/api.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372238625&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default class Api {
  private instance: AxiosInstance;

  constructor(
    baseURL = &quot;/&quot;,
    timeout = 60000
  ) {
    this.instance = axios.create({
      baseURL,
      timeout,
    });

    this.instance.interceptors.request.use(
      (config) =&amp;gt; {
        return config;
      },
      (error: AxiosError) =&amp;gt; {
        return Promise.reject(error);
      }
    );

    this.instance.interceptors.response.use(
      (response) =&amp;gt; {
        return response;
      },
      async (error: AxiosError) =&amp;gt; {
        return Promise.reject(error)
      }
    );
  }

  /** - params setting : get(url, {a:1,b:2}) =&amp;gt; url?a=1&amp;amp;b=2 */
  protected async get&amp;lt;T&amp;gt;(url: string, params?: any) {
    const res = await this.instance.get&amp;lt;T&amp;gt;(url, { params });

    const rm = res.data;

    return rm;
  }

  protected async post&amp;lt;T&amp;gt;(url: string, data: any, config?: AxiosRequestConfig) {
    const res = await this.instance.post&amp;lt;T&amp;gt;(url, data, config);

    const rm = res.data;

    return rm;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언제나 사용하는 공통 axios 셋팅.. 여기에 인증인가 부분이랑 re issue부분은 생략했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;services/videoApi.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372306278&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class VideoApi extends Api {
  async getVideoList() {
    return await super.get&amp;lt;string[]&amp;gt;(&quot;/api/video/list&quot;);
  }

  async uploadVideo(
    file: File,
    onUploadProgress?: (e: AxiosProgressEvent) =&amp;gt; void
  ) {
    const formData = new FormData();
    formData.append(&quot;file&quot;, file);

    const res = await super.post&amp;lt;{ msg: string; url: string }&amp;gt;(
      &quot;/api/video/upload&quot;,
      formData,
      {
        headers: { &quot;Content-Type&quot;: &quot;multipart/form-data&quot; },
        onUploadProgress,
      }
    );

    return res.msg;
  }
}

export const getVideoList = async () =&amp;gt; {
  const api = new VideoApi();

  return await api.getVideoList();
};

export const uploadVideo = async (model: {
  file: File;
  onUploadProgress?: (e: AxiosProgressEvent) =&amp;gt; void;
}) =&amp;gt; {
  const api = new VideoApi();

  return await api.uploadVideo(model.file, model.onUploadProgress);
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 tanstack query를 이용하고 있어서 선언부 클래스 따로, 사용하는 함수로 정의하고 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예제에 사용하는 api는 2개로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. video 리스트 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 비디오 업로드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;route/upload/index.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372483334&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const UploadPage = () =&amp;gt; {
  const controller = useUploadController();

  return (
    &amp;lt;div className={styles.div_container}&amp;gt;
      &amp;lt;label
        className={`${styles.label_file} ${controller.isIn ? styles.in : &quot;&quot;} ${controller.isLoading ? styles.pending : &quot;&quot;}`}
        onClick={controller.onClickPrevent}
        onDragOver={controller.onDragEnter}
        onDragLeave={controller.onDragLeave}
        onDrop={controller.onDropFiles}&amp;gt;
        &amp;lt;LabelComponent.Loading when={controller.isLoading}&amp;gt;
          &amp;lt;Loading centerFloat={false} /&amp;gt;
        &amp;lt;/LabelComponent.Loading&amp;gt;
        &amp;lt;LabelComponent.Indicator when={controller.percent !== 0}&amp;gt;
          &amp;lt;div&amp;gt;uploading - {controller.percent}%&amp;lt;/div&amp;gt;
        &amp;lt;/LabelComponent.Indicator&amp;gt;
        &amp;lt;LabelComponent.Text
          when={!controller.isLoading &amp;amp;&amp;amp; controller.percent === 0}&amp;gt;
          &amp;lt;div className={styles.div_upload_btn}&amp;gt;
            Click here to upload video file
            &amp;lt;img
              className={styles.img_upload}
              width={30}
              height={30}
              src=&quot;/assets/images/upload.svg&quot;
              onClick={controller.onOpenExplorer}
            /&amp;gt;
          &amp;lt;/div&amp;gt;
          &amp;lt;p&amp;gt;Or&amp;lt;/p&amp;gt;
          &amp;lt;div&amp;gt;Video file in box&amp;lt;/div&amp;gt;
          {controller.errMsg &amp;amp;&amp;amp; (
            &amp;lt;p className={styles.p_error}&amp;gt;{controller.errMsg}&amp;lt;/p&amp;gt;
          )}
        &amp;lt;/LabelComponent.Text&amp;gt;
      &amp;lt;/label&amp;gt;
      &amp;lt;input
        ref={controller.ref}
        className=&quot;hidden&quot;
        type=&quot;file&quot;
        onChange={controller.onChangeFile}
      /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;hooks/useUploadController.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372634244&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const useUploadController = () =&amp;gt; {
  const ref = useRef&amp;lt;HTMLInputElement&amp;gt;(null);

  const [isIn, setIsIn] = useState&amp;lt;boolean&amp;gt;(false);
  const [errMsg, setErrMsg] = useState&amp;lt;string | null&amp;gt;(null);
  const [percent, setPercent] = useState&amp;lt;number&amp;gt;(0);

  const mutation = useMutation({
    mutationFn: uploadVideo,
    onSuccess(result) {
      toast.success(result);
    },
    onError(error) {
      toast.error(error.message);
      setPercent(0);
      ref.current!.value = &quot;&quot;;
    },
  });

  const onDropFiles = (e: DragEvent&amp;lt;HTMLLabelElement&amp;gt;) =&amp;gt; {
    e.preventDefault();

    const fileList = e.dataTransfer.files;
    const file = _validateFile(fileList);

    if (file) {
      onUploadFile(file);
    }

    onDragLeave();
  };

  const onChangeFile = (e: ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const files = e.target.files;

    const file = _validateFile(files);

    console.log({ file });

    if (file) {
      onUploadFile(file);
    }
  };

  const onUploadFile = (file: File) =&amp;gt; {
    try {
      mutation.mutate({ file, onUploadProgress });
    } catch (e) {
      ref.current!.value = &quot;&quot;;
      setErrMsg(`${e}`);
    }
  };

  const checkFileSize = (file: File) =&amp;gt; {
    if (file.size &amp;gt; MAX_FILE_SIZE) {
      const max = Math.round(MAX_FILE_SIZE / 1024 / 1024);
      const uploaded = Math.round(file.size / 1024 / 1024);
      return `File size is too big. Maximum size is ${max}Mb, uploaded file size is ${uploaded}Mb`;
    }

    return null;
  };

  const onUploadProgress = (e: AxiosProgressEvent) =&amp;gt; {
    const uploadPercent = Math.round((e.loaded * 100) / (e.total ?? 1));

    if (uploadPercent &amp;lt; 100) {
      setPercent(uploadPercent);
    } else {
      setTimeout(() =&amp;gt; {
        ref.current!.value = &quot;&quot;;
        setPercent(0);
      }, 3000);
    }
  };

  const _validateFile = (list: FileList | null) =&amp;gt; {
    if (list === null) {
      setErrMsg(&quot;File doesn't exist&quot;);
      return;
    }

    if (list.length &amp;gt; 1) {
      setErrMsg(&quot;Please drop only one zip file&quot;);
      return;
    }

    const file = list[0];

    if (!file.type.startsWith(&quot;video/&quot;)) {
      setErrMsg(&quot;Only video files are allowed.&quot;);
      ref.current!.value = &quot;&quot;;

      return;
    } else {
      const msg = checkFileSize(file);

      setErrMsg(msg);
    }

    return file;
  };

  const onOpenExplorer = () =&amp;gt; {
    ref.current?.click();
  };

  const onClickPrevent = (e: MouseEvent) =&amp;gt; {
    e.preventDefault();
  };

  const onDragEnter = (e: MouseEvent) =&amp;gt; {
    onClickPrevent(e);
    if (!isIn) {
      setIsIn(true);
    }
  };

  const onDragLeave = () =&amp;gt; {
    if (isIn) {
      setIsIn(false);
    }
  };

  return {
    ref,
    onDropFiles,
    onChangeFile,
    onOpenExplorer,
    onClickPrevent,
    onDragEnter,
    onDragLeave,
    isIn,
    errMsg,
    percent,
    isLoading: mutation.isPending
  };
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(css를 생략해도 view 코드가 참 많은거 같습니다..)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 input 제어하는부분이랑 서버에 업로드 하는 부분만 구현해놨고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서브 컴포넌트를 통해, 어떤 상태일 떄 보여지는지 구분을 해봤는데 조금 더 가독성이 나은거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 업로드 완료시 toast호출로 표시되는부분도 만들어봤습니다&lt;s&gt;&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업로드 시간 표시는 용량 작은거로 테스트할때는 거의 %가 안보이고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 response pending상태로 들어가게 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작은 동영상 파일도 FFmpeg로 변환시 생각보다 오래 걸립니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;route/viewer/index.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372925605&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const ViewerPage = () =&amp;gt; {
  const controller = useVideoController();

  return (
    &amp;lt;div&amp;gt;
      {controller.videoError !== undefined ? (
        &amp;lt;p className={styles.p_error}&amp;gt;{controller.videoError}&amp;lt;/p&amp;gt;
      ) : (
        &amp;lt;div
          ref={controller.container}
          className={
            controller.isFullScreen
              ? `${styles.div_container} ${styles.full_screen}`
              : styles.div_container
          }&amp;gt;
          {controller.loading &amp;amp;&amp;amp; &amp;lt;Loading /&amp;gt;}
          &amp;lt;video ref={controller.videoRef} onClick={controller.togglePlay} /&amp;gt;
          &amp;lt;Overlay
            isPlaying={controller.isPlaying}
            saveMode={controller.saveMode.start}
            startFlag={controller.startFlag.current}
          /&amp;gt;
          &amp;lt;Control
            {...controller}
            saveMode={controller.saveMode.start}
            onChangeTime={controller.seek}
          /&amp;gt;
          &amp;lt;VideoList
            {...controller}
            onChangeTime={controller.seek}
            onChangeAutoPlay={controller.setAutoPlay}
            onClickVideo={controller.setVideoIdx}
          /&amp;gt;
        &amp;lt;/div&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;container ref부분은 전체화면 처리 용도로 사용하고 있고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Overlay는 좌우 이동, 타임라인 저장시 화면에 표시 등을 위해 만들어봤고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Control은 재생/정지 토글버튼, 음량, 비디오 앞으로/뒤로, 타임라인 저장, 전체화면, 화질 조정 등이 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VideoList는 영상들, 저장된 타임라인을 보여주는 부분으로 구성해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;outro에서 github 링크부분에서 확인해주시면 될거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(전부다 넣기에는 너무 많아서...)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;hooks/useVideoController.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742373222237&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const useVideoController = () =&amp;gt; {
  const { data } = useQuery({
    queryKey: [&quot;video_list&quot;],
    queryFn: getVideoList,
  });

  const videoList = data ?? [];

  const videoRef = useRef&amp;lt;HTMLVideoElement&amp;gt;(null);

  /** - video, control element */
  const container = useRef&amp;lt;HTMLDivElement&amp;gt;(null);

  /** - hls */
  const hlsRef = useRef&amp;lt;Hls&amp;gt;(null);

  /** - throttle */
  const isCalled = useRef&amp;lt;boolean&amp;gt;(false);

  /** - to prevent auto play */
  const startFlag = useRef&amp;lt;boolean&amp;gt;(false);

  /** - video quality levels */
  const [levelList, setLevelList] = useState&amp;lt;Level[]&amp;gt;([]);

  /** - video total and current duration */
  const [duration, setDuration] = useState&amp;lt;DurationModel&amp;gt;({
    current: 0,
    total: 0,
  });

  /** - loading for video */
  const [loading, setLoading] = useState&amp;lt;boolean&amp;gt;(false);

  /** - selected video idx */
  const [videoIdx, setVideoIdx] = useState&amp;lt;number&amp;gt;(0);

  /** - quality level idx */
  const [levelIdx, setLevelIdx] = useState&amp;lt;number&amp;gt;(0);

  /** - to toggle button */
  const [isPlaying, setIsPlaying] = useState&amp;lt;boolean&amp;gt;(false);

  /** - check screen mode */
  const [isFullScreen, setIsFullScreen] = useState&amp;lt;boolean&amp;gt;(false);

  /** - video error msg */
  const [videoError, setVideoError] = useState&amp;lt;string&amp;gt;();

  /** - play next video automatically */
  const [autoPlay, setAutoPlay] = useState&amp;lt;boolean&amp;gt;(true);

  /** - saved time line */
  const [savedTimes, setSavedTimes] = useState&amp;lt;TimelineModel[]&amp;gt;([]);

  /** - save mode on/off */
  const [saveMode, setSaveMode] = useState&amp;lt;SaveTimeModel&amp;gt;({
    start: false,
    startTime: 0,
    endTime: 0,
  });

  // init media and listener
  useEffect(() =&amp;gt; {
    if (videoList.length &amp;gt; 0) {
      _setVideoListener();
      _initMedia();
    }

    return () =&amp;gt; {
      _setVideoListener(true);
      _reset();
    };
  }, [videoList, videoIdx]);

  // screen mode change
  useEffect(() =&amp;gt; {
    document.onfullscreenchange = () =&amp;gt; {
      setIsFullScreen(!isFullScreen);
    };

    return () =&amp;gt; {
      document.onfullscreenchange = null;
    };
  }, [isFullScreen]);

  // save timeline
  useEffect(() =&amp;gt; {
    if (saveMode.startTime &amp;lt; saveMode.endTime) {
      const { startTime, endTime, img } = saveMode;

      setSavedTimes([...savedTimes, { startTime, endTime, img }]);
    }
  }, [saveMode]);

  // space : play/pause toggle, left/right arrow : move video time
  useEffect(() =&amp;gt; {
    document.addEventListener(&quot;keydown&quot;, _onKeyDown);

    return () =&amp;gt; {
      document.removeEventListener(&quot;keydown&quot;, _onKeyDown);
    };
  }, [isPlaying, duration.current]);

  /** - set video url */
  const _initMedia = async () =&amp;gt; {
    setLoading(true);

    const url = `/api/video/${videoList[videoIdx]}/master.m3u8`;

    console.log({ url, videoList });

    if (Hls.isSupported()) {
      hlsRef.current = new Hls();
      hlsRef.current.loadSource(url);
      hlsRef.current.attachMedia(videoRef.current!);

      hlsRef.current.on(Events.MANIFEST_PARSED, (_, data) =&amp;gt; {
        console.log({ data });
        setLevelIdx(data.firstLevel);
        setLevelList(data.levels);
      });
    } else {
      toast.error(&quot;This browser doesn't support HLS&quot;);
      videoRef.current!.src = url;
    }
  };

  /** - set video listeners */
  const _setVideoListener = (reset = false) =&amp;gt; {
    if (reset) {
      if(videoRef.current){
        videoRef.current.onloadedmetadata = null;
        videoRef.current.oncanplay = null;
        videoRef.current.ontimeupdate = null;
        videoRef.current.onended = null;
        videoRef.current.onplay = null;
        videoRef.current.onpause = null;
        videoRef.current.onerror = null;
      }
    } else {
      videoRef.current!.onloadedmetadata = _onLoadMetaData;
      videoRef.current!.oncanplay = _canPlayVideo;
      videoRef.current!.ontimeupdate = _onPlayingTimeUpdate;
      videoRef.current!.onended = onNextVideo;
      videoRef.current!.onplay = () =&amp;gt; {
        setIsPlaying(true);
      };
      videoRef.current!.onpause = () =&amp;gt; {
        setIsPlaying(false);
      };
      videoRef.current!.onerror = (e) =&amp;gt; {
        setVideoError(`[Error] ${JSON.stringify(e)}`);
      };
    }
  };

  /** - setting duration */
  const _onLoadMetaData = (e: Event) =&amp;gt; {
    const target = e.target as HTMLVideoElement;

    setDuration((state) =&amp;gt; {
      return { ...state, total: target.duration };
    });

    if (startFlag.current &amp;amp;&amp;amp; autoPlay) {
      _play();
    }
  };

  /** - check if video is playable */
  const _canPlayVideo = () =&amp;gt; {
    if (videoRef.current!.readyState === 4) {
      setLoading(false);
    } else {
      setLoading(true);
    }
  };

  /** - move to the next video */
  const onNextVideo = () =&amp;gt; {
    const nextIdx = videoIdx + 1;

    if (nextIdx &amp;lt; videoList.length) {
      setVideoIdx(nextIdx);
    } else {
      setVideoIdx(0);
    }
  };

  /** - move to previous video */
  const onPreviousVideo = () =&amp;gt; {
    const previousIdx = videoIdx - 1;

    if (previousIdx &amp;gt;= 0) {
      setVideoIdx(previousIdx);
    } else {
      setVideoIdx(videoList.length - 1);
    }
  };

  /** - update playing time */
  const _onPlayingTimeUpdate = (e: Event) =&amp;gt; {
    // when level is changed, stop updating
    if (videoRef.current?.paused) {
      return;
    }

    const target = e.target as HTMLVideoElement;

    setDuration((state) =&amp;gt; {
      return { ...state, current: target.currentTime };
    });
  };

  /** - reset video data and remove listener, saved times */
  const _reset = () =&amp;gt; {
    hlsRef.current?.destroy();

    setLevelList([]);
    setSavedTimes([]);
    setSaveMode({ start: false, startTime: 0, endTime: 0 });
    setDuration({ current: 0, total: 0 });
  };

  /** - keyboard event */
  const _onKeyDown = (e: KeyboardEvent) =&amp;gt; {
    switch (e.code) {
      case KEY_CODE.space:
        togglePlay();
        break;
      case KEY_CODE.arrowRight:
        seek(duration.current + 1);
        break;
      case KEY_CODE.arrowLeft:
        seek(duration.current - 1);
        break;
    }
  };

  /** - play video */
  const _play = async () =&amp;gt; {
    if (videoRef.current!.paused) {
      await videoRef.current?.play();
    }
  };

  /** - pause video */
  const _pause = () =&amp;gt; {
    if (!videoRef.current!.paused) {
      videoRef.current?.pause();
    }
  };

  /** - move to position */
  const seek = (time: number) =&amp;gt; {
    if (isCalled.current) {
      return;
    } else {
      isCalled.current = true;

      commonUtil.delay(100).then(() =&amp;gt; {
        isCalled.current = false;
      });
    }

    videoRef.current!.currentTime = time;

    setLoading(true);

    setDuration((state) =&amp;gt; {
      return { ...state, current: time };
    });
  };

  /** - change volume */
  const onChangeVolume = (value: number) =&amp;gt; {
    videoRef.current!.volume = value;
  };

  /** - toggle save time mode */
  const toggleSaveMode = async () =&amp;gt; {
    let img: string | undefined;
    if (!saveMode.start) {
      img = await _getThumbnail();
      console.log({ img });
    }

    setSaveMode((state) =&amp;gt; {
      if (state.start) {
        return { ...state, start: !state.start, endTime: duration.current };
      } else {
        return {
          ...state,
          start: !state.start,
          startTime: duration.current,
          img,
        };
      }
    });
  };

  /** - toggle play/pause */
  const togglePlay = () =&amp;gt; {
    if (isPlaying) {
      _pause();
    } else {
      if (!startFlag.current) {
        startFlag.current = true;
      }
      _play();
    }
  };

  /** - toggle save time mode */
  const toggleSaveMode = async () =&amp;gt; {
    let img: string | undefined;
    if (!saveMode.start) {
      img = await _getThumbnail();
      console.log({ img });
    }

    setSaveMode((state) =&amp;gt; {
      if (state.start) {
        state.endTime = duration.current;
      } else {
        state.startTime = duration.current;
        state.img = img;
      }

      return { ...state, start: !state.start };
    });
  };

  /** - get thumbnail from video */
  const _getThumbnail = async () =&amp;gt; {
    const canvas = await html2canvas(videoRef.current!, { scale: 0.1 });
    const imageFile = canvas.toDataURL(&quot;image/jpg&quot;, 0.1);

    return imageFile;
  };

  /** - change quality level */
  const onChangeLevel = (i: number) =&amp;gt; {
    setLevelIdx(i);

    hlsRef.current!.currentLevel = i;

    if (startFlag.current) {
      hlsRef.current!.detachMedia();

      hlsRef.current!.attachMedia(videoRef.current!);

      setLoading(true);
      _pause();
    }
  };

  return {
    container,
    videoRef,
    levelList,
    levelIdx,
    videoList,
    videoIdx,
    setVideoIdx,
    duration,
    isFullScreen,
    toggleScreenMode,
    togglePlay,
    toggleSaveMode,
    saveMode,
    savedTimes,
    seek,
    loading,
    onChangeVolume,
    isPlaying,
    onNextVideo,
    onPreviousVideo,
    videoError,
    autoPlay,
    setAutoPlay,
    onChangeLevel,
    startFlag,
  };
};

export default useVideoController;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;편의 기능들이 많이 있어서 해당 부분에서는 HLS 사용 부분&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 thumbnail생성 부분만 다뤄봐도 될거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 setVideoListener부터 보면, HLS관련 이벤트 설정/해제 부분으로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상 시간 셋팅, play/stop/update 등 영상 시간과 관련된 부분과 실행 가능여부 콜백들을 설정해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 initMedia부분을 보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HLS에 master.m3u8을 조회하고, level(화질 리스트)를 설정해주면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;attachMedia로 설정된 video 태그에 영상이 전달되게 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크에 따른 화질을 자동으로 셋팅하고 싶으면 다음과 같게 하면 됩니다&lt;/p&gt;
&lt;pre id=&quot;code_1742373931075&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const hls = new Hls({
  startLevel: -1, // -1이면 가장 적절한 화질을 자동 선택
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 영상을 변경할 때는 onChangeLevel쪽을 보면 되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 HLS 레벨 설정, detachMedia, attachMedia순으로 콜 해주면 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 특정 타임라인 저장하는 부분을 보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캡쳐하는 duration 저장 및 html2canvs라이브러리를 이용해서 해당 돔을 이미지로 추출합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 timeline과 같이 저장해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나머지는 간단한 키보드 이벤트, duration 계산 기타 등등이니 넘어가도록 하겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Intro : &lt;a href=&quot;https://devmemory.tistory.com/130&quot;&gt;https://devmemory.tistory.com/130&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node server :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/131&quot;&gt;https://devmemory.tistory.com/131&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Outro :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/133&quot;&gt;https://devmemory.tistory.com/133&lt;/a&gt;&lt;/p&gt;</description>
      <category>HLS</category>
      <category>hls.js</category>
      <category>html2canvas</category>
      <category>react</category>
      <category>thumbnail</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/132</guid>
      <comments>https://devmemory.tistory.com/132#entry132comment</comments>
      <pubDate>Wed, 19 Mar 2025 17:58:21 +0900</pubDate>
    </item>
  </channel>
</rss>