<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>앨리스의 토끼굴</title>
    <link>https://insight74278.tistory.com/</link>
    <description>코린이의 개발로그입니다</description>
    <language>ko</language>
    <pubDate>Sat, 11 Jul 2026 19:29:13 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>corein</managingEditor>
    <item>
      <title>상태 관리 도구 선택 기준과 Store 분리의 중요성</title>
      <link>https://insight74278.tistory.com/62</link>
      <description>&lt;p&gt;일렉트론 기반의 스크립트와 연동된 에디터 페이지 작업중, 편집을 하다가 화면이 멈추는 현상이 있었다. 사실은 올게 왔다, 라고 생각했다. 회사에서는 ai agent 로 최대한 빨리빨리 진행하기를 원하고, 프론트엔드, 백엔드 할 것 없이 한 프로젝트에 투입되며 규칙은 지켜지지 않고 코드가 꼬이는것은 한순간이었다. 스파게티 그 자체..&lt;/p&gt;
&lt;p&gt;가장 큰 문제는 Context 이다. 모든 상태를 Context 에 넣고 앱 전체를 감싸고 있으니, 거의 대부분의 컴포넌트가 스크립트 한 줄 변경, 리전 드래그 동작 하나마다 모두 리렌더되는 상황.. 이전에 한번 거대 리팩토링을 거쳤지만 또 이런문제가 발생했다는것은 팀전체의 리렌더와 상태관리 이해도 문제라고 생각했다. 이 문제를 풀고, 앞으로도 예방하기위해서 어떤것을 할 수 있을까? &lt;/p&gt;
&lt;p&gt;Zustand, useState 등, 적당한 도구를 적절한 기준으로 나누어서 적용하는것은 당연히 할 일이고, 왜 이렇게 설계가 되었는지, Context를 다룰 때 왜 조심해야하는지에 대한 내용을 전달하기로 마음먹었다. &lt;/p&gt;
&lt;h2&gt;1. 왜 리렌더를 먼저 의심했는가&lt;/h2&gt;
&lt;p&gt;React에서 리렌더(render phase)는 컴포넌트 함수가 다시 실행되어 새로운 UI 트리를 계산하는 과정을 말한다. 여기서 중요한 점은 리렌더 자체가 항상 문제는 아니라는 것이다.&lt;/p&gt;
&lt;p&gt;문제가 되는 경우는 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;변경된 상태와 무관한 컴포넌트까지 다시 계산되는 경우&lt;/li&gt;
&lt;li&gt;리스트나 타임라인처럼 요소 수가 많은 영역이 고빈도로 다시 렌더링되는 경우&lt;/li&gt;
&lt;li&gt;렌더링 중 무거운 계산이 함께 실행되는 경우&lt;/li&gt;
&lt;li&gt;상태 변경이 연쇄적으로 다른 상태 변경을 유발하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 프로젝트에서는 타임라인과 SRT panel이 동시에 넓은 범위로 다시 렌더링되고 있었다. 따라서 키 입력 지연은 원인이라기보다 &lt;strong&gt;과도한 렌더링으로 인해 관찰된 증상&lt;/strong&gt;에 가까웠다.&lt;/p&gt;
&lt;h2&gt;2. 무엇을 확인했는가&lt;/h2&gt;
&lt;p&gt;확인해보니 &lt;code&gt;placement&lt;/code&gt;라는 메타데이터가 리전에 상속되어 있었다. 문제는 이 값이 &lt;code&gt;projectContext&lt;/code&gt; 안에 들어 있었다는 점이다.&lt;/p&gt;
&lt;p&gt;리전 위치를 움직일 때마다 &lt;code&gt;placement&lt;/code&gt; 관련 상태가 바뀌었고, 그 결과 &lt;code&gt;projectContext&lt;/code&gt;를 구독하는 컴포넌트들이 함께 리렌더 대상이 되었다. 타임라인뿐 아니라 SRT panel 안의 고빈도 변경 정보도 같은 &lt;code&gt;projectContext&lt;/code&gt;를 타고 있었다.&lt;/p&gt;
&lt;p&gt;정리하면 확인한 사실은 이렇다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;리전 위치 변경 시 &lt;code&gt;projectContext&lt;/code&gt; 값이 변경되었다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;projectContext&lt;/code&gt;를 타임라인과 SRT panel의 넓은 영역이 사용하고 있었다.&lt;/li&gt;
&lt;li&gt;고빈도 변경 상태와 프로젝트 저장 상태가 같은 경계 안에 섞여 있었다.&lt;/li&gt;
&lt;li&gt;그 결과 실제 변경과 무관한 컴포넌트까지 리렌더 대상이 되었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;여기서 “원인”을 엄밀히 나누면, 1차 원인은 &lt;strong&gt;큰 Context 하나가 앱 전역의 상태를 함께 전달하고 있었다는 구조&lt;/strong&gt;다. 더 본질적인 원인은 팀 안에서 React &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;Context&lt;/code&gt;, &lt;code&gt;Zustand store&lt;/code&gt;의 역할과 선택 기준이 충분히 정리되어 있지 않았다는 점이다.&lt;/p&gt;
&lt;h2&gt;3. store는 무엇인가?&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;store는 상태의 수명과 변경 전파 범위를 관리하는 경계다.&lt;/strong&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;즉 store를 나누는 이유는 단순히 파일을 분리하기 위해서가 아니다. store는 다음 질문에 답해야 한다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;이 상태를 얼마나 오래 보존해야 하는가&lt;/li&gt;
&lt;li&gt;이 상태가 바뀔 때 어디까지 다시 계산되거나 렌더링되어야 하는가&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;4. store를 나누는 기준&lt;/h2&gt;
&lt;p&gt;상태를 나눌 때 기준은 두 가지였다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;프로젝트 저장 시 필요한 상태인가, 아니면 프로젝트 사용 중에만 필요한 상태인가&lt;/li&gt;
&lt;li&gt;이 상태가 변경될 때 함께 다시 계산되거나 렌더링되어야 하는 범위는 어디까지인가&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 기준으로 나누면 저장해야 하는 값과 저장하면 안 되는 값이 섞이지 않는다. dirty 상태나 autosave 기준도 명확해진다. 또한 고빈도 상태 변경이 앱 전체로 전파되는 것을 줄일 수 있다.&lt;/p&gt;
&lt;p&gt;물론 단점도 있다. store 수가 늘어나고, cross-store update를 조심해야 한다. “이 상태는 어디에 둬야 하는가?”라는 팀 규칙도 필요하다. 그래서 구조 변경만 하고 끝내면 같은 문제가 다시 생길 가능성이 높았다.&lt;/p&gt;
&lt;h2&gt;5. 상태 관리 기준 공유하기&lt;/h2&gt;
&lt;p&gt;이 문제를 해결하면서 팀 전체에 &lt;code&gt;useState&lt;/code&gt;, &lt;code&gt;Context&lt;/code&gt;, &lt;code&gt;Zustand&lt;/code&gt;, Vanilla Zustand, Class의 용도를 다시 공유했다.&lt;/p&gt;
&lt;p&gt;간단히 정리하면 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;적합한 용도&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;가까운 UI 상태&lt;/td&gt;
&lt;td&gt;가장 단순하고 지역적이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Context&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;거의 바뀌지 않는 값 전달&lt;/td&gt;
&lt;td&gt;기본적으로 selector가 없다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Zustand&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;여러 React 컴포넌트가 공유하는 고빈도 상태&lt;/td&gt;
&lt;td&gt;selector로 필요한 값만 구독할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vanilla Zustand&lt;/td&gt;
&lt;td&gt;React 밖에서도 필요한 상태&lt;/td&gt;
&lt;td&gt;&lt;code&gt;getState&lt;/code&gt;, &lt;code&gt;setState&lt;/code&gt;, &lt;code&gt;subscribe&lt;/code&gt;를 사용할 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class&lt;/td&gt;
&lt;td&gt;상태보다 규칙과 lifecycle이 중요한 객체&lt;/td&gt;
&lt;td&gt;method 중심으로 동작을 캡슐화한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;특히 Context는 store라기보다 “멀리 있는 컴포넌트에 값을 주입하는 수단”에 가깝다. &lt;code&gt;theme&lt;/code&gt;, &lt;code&gt;locale&lt;/code&gt;, &lt;code&gt;authUser&lt;/code&gt;, &lt;code&gt;apiClient&lt;/code&gt;, &lt;code&gt;storeInstance&lt;/code&gt;처럼 거의 바뀌지 않는 값에는 잘 맞는다.&lt;/p&gt;
&lt;p&gt;반대로 timeline row, region placement, selected track, zoom level처럼 자주 바뀌고 일부 컴포넌트만 알아도 되는 값은 Context에 직접 넣기 어렵다. 기본 Context는 value 단위로 구독되기 때문에, 일부 값만 바뀌어도 해당 Context를 읽는 컴포넌트들이 다시 렌더링 대상이 된다.&lt;/p&gt;
&lt;p&gt;Zustand는 selector를 통해 필요한 값만 구독할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const selectedTrackId = useEditorStore(state =&amp;gt; state.selectedTrackId);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 차이 때문에 자주 바뀌는 timeline 상태나 SRT panel 상태는 Context보다 Zustand가 더 적합했다.&lt;/p&gt;
&lt;h2&gt;5. 결론&lt;/h2&gt;
&lt;p&gt;이번 문제는 단순한 렌더링 최적화 문제가 아니었다. 더 정확히는 &lt;strong&gt;상태의 수명과 변경 전파 범위를 정의하지 않은 구조에서 발생한 성능 문제&lt;/strong&gt;였다.&lt;/p&gt;
&lt;p&gt;Context 하나에 프로젝트 저장 상태, UI 임시 상태, 고빈도 편집 상태가 함께 들어가면 변경의 영향 범위를 예측하기 어렵다. 작은 리전 이동이 타임라인 전체와 SRT panel 전체를 다시 계산하게 만들 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 해결의 핵심은 “Context를 Zustand로 바꿨다”가 아니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;상태가 어디까지 살아야 하고, 어디까지 변경을 전파해야 하는지 먼저 정한 뒤 store 경계를 나누는 것&lt;/strong&gt;이 핵심이었다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이후 이상적인 store 구조를 제안하고 팀과 논의했다. 그리고 같은 문제가 반복되지 않도록 관련 폴더 안에 상태 관리 규칙을 문서화했다. 사람이 읽기 위한 문서이면서, 동시에 자동화된 코드 작성 도구가 프로젝트 규칙을 해석할 수 있도록 명시적인 기준을 남겼다.&lt;/p&gt;</description>
      <category>개발로그</category>
      <category>context</category>
      <category>react</category>
      <category>Store</category>
      <category>상태관리</category>
      <category>프론트엔드</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/62</guid>
      <comments>https://insight74278.tistory.com/62#entry62comment</comments>
      <pubDate>Wed, 1 Jul 2026 11:05:59 +0900</pubDate>
    </item>
    <item>
      <title>Electron에서 여러 창의 상태를 하나의 진실로 맞추기</title>
      <link>https://insight74278.tistory.com/59</link>
      <description>&lt;h2&gt;1. 분리된 창이 같은 상태를 공유하지 못하는 문제&lt;/h2&gt;
&lt;p&gt;이번에 실시간 변경 가능한 srt script panel을 바탕으로 TTS를 생성하여 바로 스튜디오에 연동하여 편집할 수 있는 멀티미디어 에디터를 제작하는 프로젝트를 하면서 일렉트론을 처음 사용해보았다. 그러던 중, 분리된 창에서 수정한 내용이 다른 창에 반영이 되지 않는 버그를 만났다. 일렉트론 앱의 특성과 여러 분리된 창들이 같은 상태를 공유하기 위해 SSOT를 어디에 둘 지 설계하였던 과정들을 공유해보려고 한다.&lt;/p&gt;
&lt;p&gt;Electron 앱에서 프로젝트 저장, SRT script panel, editor 페이지, admin 페이지가 같은 프로젝트 데이터를 다루고 있었다. 문제는 이 데이터들이 하나의 단일 진실 공급원, 즉 &lt;strong&gt;SSOT(Single Source of Truth)&lt;/strong&gt; 를 기준으로 움직이지 않았다는 점이다.&lt;/p&gt;
&lt;p&gt;각 컴포넌트나 페이지가 서로 다른 store를 바라보면, 사용자가 편집한 내용과 실제로 저장되는 내용이 달라질 수 있다. 이 경우 문제의 직접 증상은 “편집한 값과 저장된 값의 불일치”이고, 원인은 “같은 도메인 데이터를 여러 상태 저장소가 독립적으로 보관한 구조”로 볼 수 있다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 점은 단순히 “state가 많아서” 문제가 생긴 것이 아니라는 것이다. state가 여러 개일 수는 있다. 문제는 &lt;strong&gt;같은 데이터를 나타내는 상태가 여러곳에 분산되어있었다는 점&lt;/strong&gt;이다.&lt;/p&gt;
&lt;h2&gt;2. 공유되어야 하는 데이터&lt;/h2&gt;
&lt;p&gt;같은 프로젝트 데이터를 공유하는 기능은 크게 네 가지였다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;프로젝트 저장&lt;/li&gt;
&lt;li&gt;SRT script panel&lt;/li&gt;
&lt;li&gt;editor 페이지&lt;/li&gt;
&lt;li&gt;admin 페이지&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;특히 SRT script panel은 실시간으로 row data가 변경된다. 또 창 분리가 가능하기 때문에, editor 또는 admin 페이지와 동시에 열릴 수 있다. 따라서 SRT panel에서 변경한 내용은 현재 열려 있는 관련 탭과 실시간으로 맞아야 한다.&lt;/p&gt;
&lt;p&gt;반면 editor와 studio 페이지는 동시에 접근할 수 없다는 규칙이 있었다. 이 제약은 동시 편집 충돌 가능성을 줄여주지만, 모든 동기화 문제를 제거하지는 않는다. SRT panel처럼 분리 가능한 창이 있기 때문이다.&lt;/p&gt;
&lt;p&gt;또 하나의 중요한 규칙은, 변경되는 모든 내용이 실시간으로 사용자의 local PC에 저장되어야 한다는 점이었다.&lt;/p&gt;
&lt;h2&gt;3. Electron 구조가 상태 설계에 주는 제약&lt;/h2&gt;
&lt;p&gt;Electron은 main process와 renderer process로 나뉜다. 공식 문서 기준으로 main process는 앱의 entry point이고 Node.js 환경에서 실행된다. 따라서 로컬 파일 시스템 접근이나 프로젝트 저장 같은 작업은 main process에서 처리하는 것이 자연스럽다. renderer process는 각 &lt;code&gt;BrowserWindow&lt;/code&gt;마다 별도로 생성되며, Chromium 기반의 웹 페이지처럼 동작한다. &lt;a href=&quot;https://www.electronjs.org/docs/latest/tutorial/process-model&quot;&gt;Electron Process Model&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;이 구조에서는 여러 renderer process가 같은 JavaScript 메모리나 같은 React store를 직접 공유하지 않는다. 즉, editor 창의 Zustand store와 SRT panel 창의 Zustand store는 같은 이름을 쓰더라도 같은 store 인스턴스가 아니다.&lt;/p&gt;
&lt;p&gt;renderer 간 데이터를 주고받으려면 main process를 경유하는 IPC를 사용하거나, 더 지속적인 양방향 메시지 흐름이 필요할 때 &lt;code&gt;MessageChannelMain&lt;/code&gt; / &lt;code&gt;MessagePortMain&lt;/code&gt; 기반 channel을 열 수 있다. &lt;a href=&quot;https://www.electronjs.org/docs/latest/tutorial/ipc&quot;&gt;Electron IPC&lt;/a&gt;, &lt;a href=&quot;https://www.electronjs.org/docs/latest/api/message-channel-main&quot;&gt;MessageChannelMain&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;4. 단일 진실 공급원은 어디에 두어야 하나&lt;/h2&gt;
&lt;p&gt;결정해야 할 핵심은 하나였다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;프로젝트의 단일 진실 공급원을 어디에 둘 것인가?&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;프로젝트 저장은 어차피 main process를 거쳐야 한다. 여러 renderer 창이 공유해야 하는 실시간 변경 데이터도 최종적으로 프로젝트 파일에 저장되어야 한다. 따라서 이 데이터 역시 main process를 지나야 한다.&lt;/p&gt;
&lt;p&gt;그래서 결론은 main process에 프로젝트의 단일 진실 공급원을 두는 것이다.&lt;/p&gt;
&lt;p&gt;이 결정은 “main process가 항상 모든 상태를 가져야 한다”는 일반 규칙이 아니다. 여기서는 다음 조건들이 동시에 있었기 때문에 main process가 적절했다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;데이터가 로컬 프로젝트 파일에 저장되어야 한다.&lt;/li&gt;
&lt;li&gt;여러 renderer process가 같은 데이터를 공유해야 한다.&lt;/li&gt;
&lt;li&gt;renderer process 간 메모리 store 공유가 불가능하다.&lt;/li&gt;
&lt;li&gt;저장 직전의 최신 snapshot과 화면에 표시되는 snapshot이 일치해야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. main process 내부 상태는 어떻게 관리할까&lt;/h2&gt;
&lt;p&gt;main process에는 React가 없다. 따라서 React &lt;code&gt;state&lt;/code&gt;나 &lt;code&gt;context&lt;/code&gt;는 사용할 수 없다. renderer UI 상태를 위한 Zustand hook도 그대로 쓸 수 없다.&lt;/p&gt;
&lt;p&gt;대신 선택지는 세 가지 정도였다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;plain object&lt;/li&gt;
&lt;li&gt;class&lt;/li&gt;
&lt;li&gt;vanilla Zustand&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;plain object도 가능하지만, 단독으로 쓰면 상태 변경 감지, 외부 변경 차단, 저장 요청, broadcast 같은 동작을 체계적으로 묶기 어렵다.&lt;/p&gt;
&lt;p&gt;vanilla Zustand는 React 없이도 &lt;code&gt;getState&lt;/code&gt;, &lt;code&gt;setState&lt;/code&gt;, &lt;code&gt;subscribe&lt;/code&gt;를 제공한다. 현재 project snapshot을 보관하고 변경을 감지하기에 적합하다.&lt;/p&gt;
&lt;p&gt;class는 프로젝트 변경 규칙과 외부 공개 API를 캡슐화하기에 적합하다. 예를 들어 저장 요청, renderer broadcast, private field를 통한 외부 직접 변경 방지 같은 책임을 class 안에 둘 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 구조는 &lt;code&gt;ProjectSession&lt;/code&gt; class 안에 vanilla Zustand store를 두는 방식이 적절했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class ProjectSession {
  private readonly store = createStore&amp;lt;ProjectSnapshot&amp;gt;(() =&amp;gt; initialProjectSnapshot);

  getSnapshot() {
    return this.store.getState();
  }

  updateScriptRows(rows: SrtRow[]) {
    this.store.setState({ scriptRows: rows });
  }

  subscribe() {
    return this.store.subscribe(snapshot =&amp;gt; {
      this.saveProject(snapshot);
      this.broadcastProjectChanged(snapshot);
    });
  }

  private saveProject(snapshot: ProjectSnapshot) {
    // local PC에 프로젝트 저장
  }

  private broadcastProjectChanged(snapshot: ProjectSnapshot) {
    // 열려 있는 renderer process에 변경 알림
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 구조에서 역할은 분리된다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ProjectSession&lt;/code&gt;은 프로젝트 변경 규칙, 저장 요청, renderer broadcast를 관리한다. vanilla Zustand는 현재 프로젝트 snapshot을 보관하고 &lt;code&gt;getState&lt;/code&gt;, &lt;code&gt;setState&lt;/code&gt;, &lt;code&gt;subscribe&lt;/code&gt;를 제공한다.&lt;/p&gt;
&lt;p&gt;즉, Zustand를 “앱 전체 아키텍처”로 쓰는 것이 아니라, main process 내부의 snapshot store로 제한해서 사용하는 방식이다.&lt;br&gt;(각 상태를 분리한 기준과 어떤 상태저장소를 써야할 지 선택하게 된 기준은 다음 글을 참고: &lt;a href=&quot;https://insight74278.tistory.com/62&quot;&gt;https://insight74278.tistory.com/62&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;6. renderer 화면은 어떻게 갱신할까&lt;/h2&gt;
&lt;p&gt;main process의 snapshot이 바뀌면 renderer 화면도 리렌더링되어야 한다. renderer 입장에서는 main에서 변경 이벤트를 listener로 받고, 그 값을 UI 상태에 반영해야 한다.&lt;/p&gt;
&lt;p&gt;여기서 &lt;code&gt;useSyncExternalStore&lt;/code&gt;를 직접 사용할 수도 있고, renderer 쪽 Zustand store에 반영할 수도 있다. 이 경우 여러 컴포넌트가 같은 데이터를 공유하고, 변경 범위가 일부일 때 해당 데이터만 사용하는 컴포넌트만 리렌더링되는 것이 중요했다.&lt;/p&gt;
&lt;p&gt;따라서 renderer에서는 Zustand selector를 사용하는 편이 적합하다. main process에서 받은 snapshot 또는 patch를 renderer store에 반영하고, 각 컴포넌트는 필요한 slice만 selector로 구독한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const scriptRows = useProjectRendererStore(state =&amp;gt; state.scriptRows);&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 방식은 “main process가 단일 진실 공급원을 가진다”는 규칙과 “renderer는 화면 렌더링에 필요한 state를 가진다”는 규칙을 분리한다.&lt;/p&gt;
&lt;h2&gt;7. admin 탭 진입 시에는 다시 불러오기&lt;/h2&gt;
&lt;p&gt;editor에서 변경한 SRT panel 내용이 admin 탭으로 이동했을 때도 맞아야 했다.&lt;/p&gt;
&lt;p&gt;모든 탭을 항상 실시간 동기화할 수도 있다. 하지만 열려 있지 않거나 활성화되지 않은 화면까지 계속 sync하는 것은 비용 대비 이점이 작다. 또 main이 local project에 저장하기 전 admin 탭이 진입하면, admin이 오래된 renderer state를 기준으로 화면을 그릴 위험이 있다.&lt;/p&gt;
&lt;p&gt;그래서 admin 탭 진입 시점에는 renderer의 기존 state를 신뢰하지 않고, main process의 project snapshot 또는 저장된 project document를 다시 불러오는 방식이 더 안전하다.&lt;/p&gt;
&lt;p&gt;정확히 말하면 이것은 “실시간 동기화”가 아니라 &lt;strong&gt;탭 활성화 시점의 snapshot 재검증&lt;/strong&gt;에 가깝다.&lt;/p&gt;
&lt;h2&gt;8. 예외적으로 저장하지 않는 동기화&lt;/h2&gt;
&lt;p&gt;모든 변경이 프로젝트에 저장되어야 하는 것은 아니다.&lt;/p&gt;
&lt;p&gt;예를 들어 리전을 클릭했을 때 해당 SRT panel script row를 하이라이트하고 그 위치로 스크롤하는 기능은 사용자의 편집 데이터가 아니다. 이는 저장되어야 하는 프로젝트 상태가 아니라, 현재 상호작용을 위한 임시 UI 이벤트다.&lt;/p&gt;
&lt;p&gt;이런 고빈도 임시 동기화는 main process의 project snapshot에 넣지 않는 편이 낫다. 대신 &lt;code&gt;MessagePort&lt;/code&gt; 기반 channel을 열어 실시간 이벤트 stream처럼 전달할 수 있다.&lt;/p&gt;
&lt;p&gt;여기서 구분이 중요하다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;프로젝트에 저장되어야 하는 데이터: &lt;code&gt;ProjectSession&lt;/code&gt;의 snapshot으로 관리&lt;/li&gt;
&lt;li&gt;저장할 필요 없는 고빈도 UI 이벤트: message port로 전달&lt;/li&gt;
&lt;li&gt;renderer 화면 렌더링 상태: renderer store에서 selector로 구독&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;9. 결론&lt;/h2&gt;
&lt;p&gt;이번 구조의 핵심은 “상태를 하나로 합친다”가 아니다.&lt;/p&gt;
&lt;p&gt;정확히는 &lt;strong&gt;프로젝트 데이터의 단일 진실 공급원을 main process에 두고, renderer process는 그 snapshot을 화면 렌더링에 필요한 형태로 구독한다&lt;/strong&gt;는 결정이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;저장되어야 하는 데이터와 화면에서만 필요한 임시 이벤트를 분리해야, 동기화 구조가 단순해진다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Electron에서는 여러 창이 각각 다른 renderer process로 동작한다. 그래서 renderer store를 전역 store처럼 생각하면 편집 내용과 저장 내용이 어긋날 수 있다. 프로젝트 저장, 실시간 변경 반영, 창 간 공유가 모두 필요한 데이터라면 main process에 &lt;code&gt;ProjectSession&lt;/code&gt;을 두고 그 안에서 snapshot, 저장, broadcast 규칙을 함께 관리하는 편이 더 명확하다.&lt;/p&gt;</description>
      <category>개발로그</category>
      <category>Electron</category>
      <category>react</category>
      <category>ssot</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/59</guid>
      <comments>https://insight74278.tistory.com/59#entry59comment</comments>
      <pubDate>Sat, 13 Jun 2026 19:49:37 +0900</pubDate>
    </item>
    <item>
      <title>[드래그 최적화 시도 3] 오버레이 방식으로 드래그 리렌더 제거하기</title>
      <link>https://insight74278.tistory.com/58</link>
      <description>&lt;h2&gt;1. 들어가며&lt;/h2&gt;
&lt;p&gt;타임라인 리전 드래그 성능을 보면서 처음에는 “Canvas redraw를 어떻게 줄일까”를 먼저 생각했다. 이전 실험에서는 &lt;code&gt;requestAnimationFrame&lt;/code&gt;으로 redraw를 묶고, React state 경로를 우회하고, 드래그 중 임시 이동 상태를 별도 저장소에 두는 방식으로 비용을 줄였다. 그 접근은 의미가 있었다. 실제로 Canvas draw 호출 수와 React 렌더 비용이 줄었다.&lt;/p&gt;
&lt;p&gt;그런데 후속 최적화를 하면서 문제를 조금 다르게 보게 됐다. 드래그 중에 Canvas를 더 효율적으로 다시 그리는 것보다, &lt;strong&gt;드래그 중 Canvas가 다시 그릴 필요가 없도록 구조를 바꾸는 편이 더 낫지 않을까&lt;/strong&gt;라는 생각이었다.&lt;/p&gt;
&lt;p&gt;결론부터 적으면, 현재 구조는 드래그 중 원본 리전과 Canvas를 움직이지 않는다. 대신 별도의 overlay가 포인터를 따라가고, 실제 리전 위치는 드래그가 끝난 뒤 한 번만 확정한다. 이 글은 그 판단이 어떻게 나왔고, 왜 오버레이 방식이 적합했는지 정리한 기록이다.&lt;/p&gt;
&lt;h2&gt;2. 이전 실험의 한계&lt;/h2&gt;
&lt;p&gt;이전 실험의 핵심은 드래그 중 임시 이동 상태를 실제 리전 데이터와 분리한 것이었다. 실제 리전 데이터를 바로 바꾸지는 않고, “현재 이만큼 이동 중이다”라는 값을 별도 저장소에 저장했다. Canvas와 SVG는 이 값을 구독해서 화면을 따라갔다. 흐름은 대략 이랬다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;드래그 중 마우스 이동
→ 임시 이동 상태 저장
→ Canvas / SVG가 임시 이동량 구독
→ Canvas redraw 또는 화면 위치 갱신
→ 드래그 완료 시 실제 위치 확정&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방식은 실제 데이터와 임시 화면 표시를 분리했다는 점에서 나쁘지 않았다. 하지만 여전히 드래그 중 타임라인 렌더링 경로가 임시 이동을 따라가야 했다. 즉, draw 횟수를 줄일 수는 있어도 “드래그 중 Canvas나 SVG가 이동을 반영해야 한다”는 전제는 그대로였다. 여기서 다음 질문이 생겼다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;드래그 중 원본 리전이 실제로 움직여야 하는가?
Canvas가 드래그 중 이동을 매번 반영해야 하는가?
사용자에게 필요한 것은 실제 데이터 변경인가, 이동 중이라는 시각적 피드백인가?&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 질문을 기준으로 보면, 드래그 중 필요한 것은 실제 데이터 변경이 아니었다. 사용자는 “내가 잡은 리전이 포인터를 따라 움직이고 있다”는 피드백만 받으면 된다. 실제 위치는 마우스를 놓은 뒤 확정해도 된다.&lt;/p&gt;
&lt;h2&gt;3. 오버레이 방식으로 관점 바꾸기&lt;/h2&gt;
&lt;p&gt;오버레이 방식은 원본 리전을 직접 이동시키지 않는다. 원본 리전은 제자리에 남아 있고, 별도의 시각 요소가 마우스를 따라간다. 실제 데이터는 드래그가 끝난 시점에만 바뀐다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;드래그 시작
→ 원본 리전은 제자리에 남김

드래그 중
→ overlay가 마우스를 따라감
→ 실제 리전 데이터는 아직 변경하지 않음
→ Canvas redraw 없음

드래그 완료
→ 최종 위치 계산
→ 실제 리전 위치 확정
→ 필요한 구간만 Canvas redraw&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조에서 overlay는 실제 데이터가 아니다. 드래그 중이라는 사실을 보여주는 임시 시각 피드백이다. 원본 리전의 실제 시작 위치는 드래그 중에는 바뀌지 않고, 드래그가 끝났을 때만 확정된다. 레이어로 보면 역할은 이렇게 나뉜다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;┌────────────────────────────┐
│ Overlay                    │
│ - 드래그 중 위치 표시       │
│ - 선택 테두리               │
├────────────────────────────┤
│ Canvas                     │
│ - 파형                     │
│ - 썸네일                   │
│ - 확정된 리전              │
└────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Canvas는 파형, 썸네일, 확정된 리전처럼 무거운 시각 결과를 담당한다. Overlay는 드래그 중 위치 표시처럼 자주 바뀌는 조작 상태를 담당한다. 핵심은 간단하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;드래그 중:
→ Overlay만 갱신
→ Canvas redraw 없음
→ 실제 데이터 변경 없음

드래그 완료:
→ 실제 데이터 변경
→ Canvas redraw&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 측정으로 확인한 변화&lt;/h2&gt;
&lt;p&gt;현재 브랜치에서 같은 시나리오로 벤치마크를 돌렸다. 리전 11개를 만들고, 선택 리전을 +120px / 50 step으로 드래그한 뒤 3회 반복한 중앙값을 비교했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;블로그 Before&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;블로그 After, 시도 1&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;현재&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;React commit 수&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;105&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;101&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;actualDuration 평균&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;2.87ms&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1.98ms&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1.46ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;canvas clearRect&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;104&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;55&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;canvas drawImage&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;25,300&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;12,903&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FPS&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;51.6&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;51.2&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;약 60&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;가장 중요한 변화는 &lt;code&gt;clearRect&lt;/code&gt;와 &lt;code&gt;drawImage&lt;/code&gt;가 0회가 됐다는 점이다. 이것은 단순히 redraw를 더 잘 묶었다는 뜻이 아니다. 드래그 move 중 Canvas가 관여하지 않는 구조가 됐다는 뜻이다.&lt;/p&gt;
&lt;p&gt;다만 React commit 수와 &lt;code&gt;actualDuration&lt;/code&gt;은 측정 도구 차이가 있으므로 절대값 비교는 조심해야 한다. 예전 글과 현재 벤치마크는 측정 방식이 완전히 같지 않다. 그래도 드래그 중 Canvas draw가 0으로 떨어진 것은 현재 구조와 직접적으로 맞아떨어진다.&lt;/p&gt;
&lt;h2&gt;5. 오버레이 방식이 해결한 문제&lt;/h2&gt;
&lt;p&gt;이전 실험은 Canvas redraw를 줄였다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;clearRect: 104 → 55
drawImage: 25,300 → 12,903&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;현재 구조는 move 중 Canvas redraw 자체를 없앴다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;clearRect: 0
drawImage: 0&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그래서 이 개선을 단순히 “Canvas redraw 최적화”라고만 부르면 조금 부족하다. 더 정확히는 &lt;strong&gt;드래그 중 시각적 이동을 Canvas의 책임에서 overlay의 책임으로 옮긴 구조 변경&lt;/strong&gt;이다. 비용 구조도 바뀌었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;이전:
무거운 Canvas redraw 중심

현재:
가벼운 overlay 갱신 중심&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;물론 모든 비용이 사라진 것은 아니다. overlay 위치를 갱신하는 비용은 남아 있고, 필요한 경우 React 갱신이나 스타일 업데이트가 발생할 수 있다. 현재 측정에서도 React commit 수는 53회 남아 있다. 다만 이전처럼 Canvas가 드래그 중 계속 다시 그려지는 구조는 아니다.&lt;/p&gt;
&lt;h2&gt;6. 구현하면서 신경 쓴 부분&lt;/h2&gt;
&lt;p&gt;오버레이 방식은 원본 리전과 overlay가 동시에 보인다. 원본 위치를 남겨 두면 출발 위치를 알 수 있지만, 원본과 overlay가 같은 강도로 보이면 두 개의 리전이 있는 것처럼 보일 수 있다.&lt;/p&gt;
&lt;p&gt;그래서 원본 리전은 opacity를 낮춰 정지된 상태로 보여준다. 현재 구현에서는 원본 리전을 &lt;code&gt;opacity: 0.3&lt;/code&gt;으로 낮췄다. 이 처리는 overlay가 현재 조작 중인 대상을 대표하고, 원본은 출발 위치를 나타낸다는 시각적 구분을 만든다.&lt;/p&gt;
&lt;p&gt;또 하나 중요한 부분은 overlay에서 보이는 위치와 실제 확정 위치의 정합성이다. overlay는 어떤 위치에 놓일 것처럼 보였는데, 마우스를 놓은 뒤 다른 위치로 확정되면 사용자는 동작을 신뢰하기 어렵다. 그래서 화면에서 보이는 이동과 실제 확정 결과가 같은 기준을 따라야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;마우스 이동
→ 타임라인 위치로 변환
→ 이동 가능한 위치 계산
→ 실제 리전 위치 확정&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;overlay는 단순히 마우스를 따라가고, 최종 배치 규칙은 드래그 완료 시점에만 적용할 수도 있다. 또는 드래그 중에도 놓일 수 있는 위치를 미리 보여줄 수도 있다. 어느 쪽이든 중요한 것은 사용자가 보는 이동과 실제 확정 결과가 납득 가능해야 한다는 점이다.&lt;/p&gt;
&lt;h2&gt;7. 같은 아이디어로 떠올린 Trim 최적화&lt;/h2&gt;
&lt;p&gt;드래그 오버레이 방식을 정리하다 보니 trim도 같은 원칙으로 볼 수 있었다. trim handle을 움직이는 동안 실제 리전 시작 시간이나 길이를 계속 바꿀 필요가 없다. 사용자에게 필요한 것은 “이만큼 잘릴 예정이다”라는 preview다. 기존 trim 방식은 조작 중 실제 데이터를 계속 바꾸는 흐름에 가까웠다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;trim 중 마우스 이동
→ 실제 리전 시작 위치 / 길이 변경
→ React 리렌더
→ Canvas redraw&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;오버레이 방식으로 보면 trim 중에는 실제 데이터를 바꾸지 않고, overlay에서 잘릴 예정인 상태만 보여주면 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;trim 시작
→ trim 대상과 edge 저장

trim 중
→ 잘릴 예정인 범위 계산
→ overlay에서 잘린 것처럼 표시
→ 실제 region 데이터 변경 없음
→ Canvas redraw 없음

trim 완료
→ 실제 시작 위치 / 길이 갱신
→ 필요한 구간만 Canvas redraw
→ overlay 제거&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;trim 중 미리보기는 실제 데이터를 자른 것이 아니다. 화면에서만 일부를 가리거나 잘린 것처럼 보여주는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;원래 리전:
[====================]

trim 중 미리보기:
     [===============]

trim 완료:
     [===============]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;다만 trim은 드래그보다 미리보기와 실제 결과의 일치가 더 중요하다. 미리보기는 1.2초 위치에서 잘리는 것처럼 보였는데, 마우스를 놓은 뒤 최소 길이 제약 때문에 1.0초로 확정되면 사용자는 조작 결과가 밀렸다고 느낄 수 있다. 그래서 trim overlay에서는 미리보기 계산과 실제 확정 계산이 같은 규칙을 사용해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;trim 중 잘릴 예정인 범위 계산
→ 최소 길이와 경계 조건을 미리보기에도 반영
→ overlay에서 잘린 것처럼 표시
→ trim 완료 시 같은 규칙으로 실제 시작 위치 / 길이 확정&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 글의 주된 주제는 드래그 오버레이 최적화다. Trim 최적화는 드래그에서 세운 원칙을 정리하는 과정에서 함께 떠올린 추가 확장에 가깝다.&lt;/p&gt;
&lt;h2&gt;8. 남은 정리&lt;/h2&gt;
&lt;p&gt;오버레이 방식으로 바뀌면서 예전에 쓰던 임시 이동 상태 저장소는 핵심 경로에서 빠졌다. 현재 코드에는 다음 잔재가 남아 있다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;코드&lt;/th&gt;
&lt;th&gt;현재 상태&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;regionDragVisualStore&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;정의는 있지만 호출 0회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;setRegionDragVisualTransform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;정의는 있지만 호출 0회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dragVisualToken = useSyncExternalStore(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;구독은 남아 있지만 저장소가 갱신되지 않아 실제 효과가 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useSegmentHorizontalDrag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;wiring은 남아 있으나 region body drag는 오버레이 방식이 가로챔&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;이런 코드는 즉시 성능 문제를 만들지 않을 수 있다. 하지만 읽는 사람에게 “아직 이 경로가 쓰이나?”라는 혼란을 준다. 오버레이 방식이 안정화된 뒤에는 호출 경로를 확인하고, 더 이상 쓰지 않는 임시 이동 상태 관련 코드를 제거하는 편이 좋다.&lt;/p&gt;
&lt;h2&gt;9. 마치며&lt;/h2&gt;
&lt;p&gt;이번 작업을 돌아보면 처음에는 문제를 조금 좁게 보고 있었다. “Canvas redraw가 많다”는 사실을 확인하고 나서, 자연스럽게 redraw를 줄이는 방법부터 떠올렸다. &lt;code&gt;requestAnimationFrame&lt;/code&gt;으로 묶고, React 경로를 우회하고, draw 호출 수를 줄이는 방식은 분명히 효과가 있었다. 하지만 그때는 아직 “이 draw가 꼭 필요한가”까지는 충분히 묻지 못했다.&lt;/p&gt;
&lt;p&gt;오버레이 방식으로 바꾸면서 가장 크게 깨달은 점은, 성능 개선이 항상 더 빠르게 처리하는 방향일 필요는 없다는 것이다. 때로는 일을 더 잘하는 것보다, 그 일을 하지 않아도 되는 구조로 바꾸는 편이 더 근본적이다. 이번 경우에는 드래그 중 Canvas를 빠르게 다시 그리는 것이 아니라, 드래그 중 Canvas가 관여하지 않아도 되게 만드는 것이 핵심이었다.&lt;/p&gt;
&lt;p&gt;부족했던 점도 분명하다. 처음부터 드래그 중 필요한 것이 실제 데이터 변경인지, 아니면 임시 시각 피드백인지 더 빨리 나눴다면 시행착오를 줄일 수 있었을 것 같다. 임시 이동 상태를 별도 저장소로 분리한 방식도 의미 있는 중간 단계였지만, 그 구조 안에서는 여전히 타임라인 렌더링 경로가 드래그를 따라가야 했다. 그 전제를 더 일찍 의심했어야 했다.&lt;/p&gt;
&lt;p&gt;또 하나는 정리의 문제다. 오버레이 방식으로 바뀐 뒤에도 예전 임시 이동 상태 관련 코드가 일부 남아 있다. 당장 큰 비용을 만들지 않더라도, 다음에 코드를 읽는 사람에게는 “아직 쓰이는 경로인가?”라는 혼란을 줄 수 있다. 성능 개선은 새 구조를 넣는 것에서 끝나는 게 아니라, 더 이상 중심 경로가 아닌 코드를 정리하는 데까지 이어져야 한다.&lt;/p&gt;
&lt;p&gt;앞으로 비슷한 문제를 만나면 먼저 세 가지를 확인하려고 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;1. 지금 자주 바뀌는 값이 실제 데이터인가, 임시 시각 상태인가?
2. Canvas나 React가 이 변경을 꼭 처리해야 하는가?
3. 조작 중 미리보기와 조작 완료 후 실제 데이터 변경을 분리할 수 있는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정리하자면 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;리전 드래그 중 실제 데이터와 원본 렌더링 경로는 멈춰 두고, 사용자가 필요한 임시 시각 피드백만 overlay로 분리했다.&lt;/strong&gt; 더 짧게 적으면 이렇다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;드래그는 움직이는 척만 한다.
진짜 데이터 변경과 Canvas redraw는 마우스를 놓은 뒤 한 번만 한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 원칙을 정리하고 나서 trim도 같은 방향으로 볼 수 있었다. trim은 잘리는 척만 하고, 실제 시작 위치와 길이 변경은 마우스를 놓은 뒤 한 번만 하면 된다. 다음에는 이런 공통 원칙을 더 빨리 발견하고, 특정 기능 하나의 최적화가 아니라 입력 처리 전체의 구조로 확장할 수 있는지 먼저 살펴보려고 한다.&lt;/p&gt;</description>
      <category>개발로그</category>
      <category>Drag</category>
      <category>overlay</category>
      <category>react</category>
      <category>리렌더</category>
      <category>최적화</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/58</guid>
      <comments>https://insight74278.tistory.com/58#entry58comment</comments>
      <pubDate>Wed, 10 Jun 2026 16:20:13 +0900</pubDate>
    </item>
    <item>
      <title>[드래그 최적화 시도 2] 회고록: 정말 최선이었을까?</title>
      <link>https://insight74278.tistory.com/53</link>
      <description>&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;1. 들어가며&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;타임라인 리전 드래그 성능 개선 작업을 다시 돌아보면, 처음에는 문제를 너무 빨리 해결책 쪽으로 끌고 가려 했던 것 같다. 드래그가 끊기고, 캔버스가 여러 번 다시 그려지고, React 리렌더가 많이 발생한다는 사실을 보자마자&amp;nbsp;requestAnimationFrame이나 ref 패턴을 떠올리기 쉬웠다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 지금 다시 보면 더 중요한 것은 특정 패턴 자체가 아니었다. 어떤 증상이 있었고, 그 증상이 어떤 실행 경로에서 발생했는지, 그리고 각 경로를 어떤 순서로 줄여야 하는지를 나누는 과정이 더 중요했다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 글은 구현 내용을 다시 설명하기보다, 내가 이 문제를 어떤 순서로 풀었어야 더 좋았을지 돌아보는 회고다. 결론부터 적으면&amp;nbsp;&lt;b&gt;성능 개선은 바로 패턴을 넣는 일이 아니라, 증상 관찰, 측정, 병목 분리, 작은 수정, 재측정을 반복하는 일에 가까웠다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2. 당시 보였던 증상&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음 보인 증상은 타임라인에서 리전을 드래그할 때 화면이 매끄럽지 않다는 것이었다. 리전 수와 트랙 수가 늘어날수록 끊김이 더 잘 보였고, 스크롤이나 드래그 중 캔버스 렌더링이 뚝뚝 끊기는 느낌이 있었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 증상만 보면 원인을 하나로 단정하기 쉽다. 예를 들어 &amp;ldquo;Canvas draw가 무겁다&amp;rdquo;거나 &amp;ldquo;React가 느리다&amp;rdquo;라고 말하고 싶어진다. 하지만 측정값을 보면 그렇게 단순하지 않았다. 이전 측정에서 확인한 값은 다음과 같았다.&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%; height: 131px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;지표&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;변경 전&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;변경 후&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;변화&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;React commit 수&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;105&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;101&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;-4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;React actualDuration 평균&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;2.87ms&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;1.98ms&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;-31%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;canvas clearRect&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;104&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;55&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;-47%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;canvas drawImage&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;25,300&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;12,903&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;-49%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;메인 스레드 busy time&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;1,524ms&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;1,325ms&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;-13%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 표에서 직접 확인할 수 있는 사실은 Canvas draw 호출 수와 React actualDuration 평균이 줄었다는 점이다. 반대로 React commit 수는 거의 줄지 않았다. 따라서 이 작업으로 모든 병목이 사라졌다고 말할 수는 없다. 더 정확히는,&amp;nbsp;&lt;b&gt;중복 Canvas redraw와 일부 React 렌더 비용은 줄였지만 React commit을 만드는 다른 경로는 남아 있었다&lt;/b&gt;고 보는 편이 맞다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;3. 처음에 헷갈렸던 부분&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 드래그 중 화면이 끊기니, 화면을 그리는 모든 코드가 같은 문제를 만들고 있다고 생각하기 쉬웠다. 그러나 실제로는 서로 다른 성격의 문제가 섞여 있었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;첫 번째는 같은 프레임 안에서 이벤트가 여러 번 발생하는 문제였다. 스크롤, 리전 변경, 드래그 중 임시 위치 변경이 짧은 시간에 반복되면서 Canvas redraw 요청도 반복됐다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;두 번째는 React가 반드시 필요하지 않은 작업에 개입하는 문제였다. Canvas redraw는&amp;nbsp;ctx.clearRect,&amp;nbsp;ctx.drawImage처럼 캔버스 컨텍스트에 직접 명령을 내리는 작업이다. SVG drag visual도 실제로는&amp;nbsp;&amp;lt;g&amp;gt;&amp;nbsp;노드의&amp;nbsp;transform&amp;nbsp;속성 하나를 바꾸는 작업이었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 drag visual은 프로젝트 내부에서 쓰는 표현이다. 이 글에서는&amp;nbsp;&lt;b&gt;리전을 드래그하는 동안 실제 데이터가 확정되기 전, 화면에만 임시로 보여주는 이동 상태&lt;/b&gt;라는 뜻으로 사용한다. 예를 들어 리전을 오른쪽으로 120px 끌고 있을 때 실제 리전 시작 시간은 아직 확정하지 않고, 화면에서만 120px 이동한 것처럼 보여주는 값이다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;세 번째는 Preview 합성기의 문제였다. 이 문제는 타임라인 드래그와 같은 화면에서 관찰됐지만, 원인은 별도였다. 정지 상태에서도 매 프레임 합성 루프가 돌고 있었기 때문이다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;4. 먼저 했어야 할 일은 재현 조건 고정이었다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이런 문제를 풀 때 가장 먼저 했어야 할 일은 해결책을 고르는 것이 아니라 재현 조건을 고정하는 것이었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 시나리오가 필요했다.&lt;/p&gt;
&lt;pre id=&quot;code_1781056219685&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;영상 업로드
&amp;rarr; 타임라인에 여러 리전 배치
&amp;rarr; 선택 리전을 정해진 거리와 step으로 드래그
&amp;rarr; 같은 시나리오를 여러 번 반복
&amp;rarr; 중앙값으로 비교&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 해야 개선 전후를 같은 조건에서 비교할 수 있다. 성능 개선은 체감이 중요하지만, 체감만으로는 어떤 비용이 줄었는지 설명하기 어렵다. 특히 이 작업처럼 Canvas, SVG, React, Preview 합성기가 함께 얽혀 있으면 측정 기준이 없을 때 판단이 쉽게 흔들린다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 단계에서 봐야 할 값은 하나가 아니었다. React commit 수, React actualDuration, Canvas draw 호출 수, 메인 스레드 busy time을 함께 봐야 했다. FPS도 볼 수 있지만, FPS 하나만으로 병목 경로를 특정하기는 어렵다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;5. 병목을 실행 경로별로 나눴어야 했다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;재현 조건을 고정한 뒤에는 문제를 실행 경로별로 나누는 편이 좋았다. 이 작업에서 나눌 수 있는 경로는 다음과 같았다.&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;경로&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;확인해야 할 문제&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스크롤 이벤트&lt;/td&gt;
&lt;td&gt;같은 프레임 안에서 redraw 요청이 여러 번 발생하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;regionChanged&amp;nbsp;신호&lt;/td&gt;
&lt;td&gt;Canvas redraw를 위해 React state 업데이트가 발생하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drag visual&lt;/td&gt;
&lt;td&gt;드래그 중 임시 위치 표시가 React 리렌더를 유발하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preview 합성기&lt;/td&gt;
&lt;td&gt;정지 상태에서도 매 프레임 렌더링하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrackContextMenu&lt;/td&gt;
&lt;td&gt;row 리렌더가 메뉴 서브트리까지 확산되는가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 나누면 각 문제의 성격이 달라진다. 스크롤 이벤트와 regionChanged 신호는 고빈도 이벤트를 프레임 단위로 묶는 문제가 된다. drag visual은 임시 시각 상태를 React 렌더 경로에 둘 필요가 있는지 판단하는 문제가 된다. Preview 합성기는 재생 중과 정지 중의 렌더링 전략을 나누는 문제가 된다. 이 분리를 하지 않고 한 번에 고치려고 하면, 수정 후에도 어떤 병목이 줄었고 어떤 병목이 남았는지 설명하기 어렵다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;6. 작은 수정은 rAF coalescing부터였어야 했다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;가장 먼저 적용하기 좋은 수정은&amp;nbsp;requestAnimationFrame&amp;nbsp;기반 coalescing이었다. 여기서 coalescing은 같은 프레임 안에서 들어온 여러 redraw 요청을 하나의 rAF 콜백으로 합치는 것을 뜻한다. 데이터 직렬화나 전체 작업 큐의 순차 실행이 아니라, 프레임당 redraw 예약을 1회로 제한하는 스케줄링 방식이다.&lt;/p&gt;
&lt;pre id=&quot;code_1781056267389&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; 이벤트 N회
&amp;rarr; rAF 예약 1회
&amp;rarr; redraw 1회&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;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;구현은 단순하다. 이미 예약된 rAF가 있으면 새로 예약하지 않는다.&lt;/p&gt;
&lt;pre id=&quot;code_1781056310845&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const scheduleRedraw = () =&amp;gt; {
  if (rafRef.current) return;

  rafRef.current = requestAnimationFrame(() =&amp;gt; {
    rafRef.current = 0;
    draw();
  });
};&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 수정은 기존 구조를 크게 바꾸지 않는다. React state 경로가 남아 있어도, 같은 프레임 안에서 중복 실행되는 redraw는 줄일 수 있다. 그래서 첫 번째 수정으로 적합했다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다만 이 수정만으로 React 리렌더가 사라지는 것은 아니다. rAF coalescing은 실행 횟수를 줄이는 장치일 뿐이다. React state 업데이트를 계속 호출한다면 React commit은 여전히 발생할 수 있다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;7. 그 다음 질문은 React가 꼭 필요한가였다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;rAF coalescing 이후에도 React commit이 남아 있다면, 다음 질문은 이거였어야 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1781056333405&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;이 업데이트는 꼭 React state 변경을 거쳐야 하는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;regionChanged&amp;nbsp;신호에서 Canvas redraw만 필요하다면 React 리렌더를 거칠 필요가 없다. Canvas는 React DOM이 아니라 캔버스 컨텍스트에 직접 그리는 명령형 API이기 때문이다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 다음 단계에서는 drawTrackCanvas를 ref에 저장하고, 신호에서 최신 draw 함수를 직접 호출하는 구조가 더 적절했다.&lt;/p&gt;
&lt;pre id=&quot;code_1781056691773&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;변경 전:
regionChanged
&amp;rarr; setRegionVersion
&amp;rarr; React 리렌더
&amp;rarr; useEffect
&amp;rarr; drawTrackCanvas

변경 후:
regionChanged
&amp;rarr; rAF guard
&amp;rarr; drawTrackCanvasRef.current()
&amp;rarr; Canvas redraw&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 수정은 첫 번째 수정보다 더 조심해야 한다. ref가 오래된 함수를 가리키면 최신 props나 viewport 값을 반영하지 못할 수 있다. 이 위험은 latest callback ref 패턴으로 줄일 수 있었다. 즉,&amp;nbsp;drawTrackCanvas가 바뀔 때마다&amp;nbsp;drawTrackCanvasRef.current를 최신 함수로 동기화해야 했다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;8. drag visual은 실제 데이터와 분리해서 봤어야 했다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;drag visual은 드래그 중 화면에 보여주는 임시 위치다. 실제 리전 데이터가 아니다. 사용자가 마우스를 놓기 전까지는 최종 시작 시간이나 좌표를 확정하지 않아도 된다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 특성을 먼저 정리했으면, drag visual을 React state 경로에 두는 것이 꼭 필요한지 더 빨리 의심할 수 있었을 것 같다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기존 흐름은 다음에 가까웠다.&lt;/p&gt;
&lt;pre id=&quot;code_1781056718357&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;drag visual 변경
&amp;rarr; external store notify
&amp;rarr; React 리렌더
&amp;rarr; SVG transform 반영
&amp;rarr; Canvas redraw&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 실제로 필요한 작업은 SVG 노드의 transform 속성을 바꾸고 Canvas를 다시 그리는 것이었다.&lt;/p&gt;
&lt;pre id=&quot;code_1781056737629&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;drag visual 변경
&amp;rarr; subscription notify
&amp;rarr; rAF guard
&amp;rarr; SVG node.setAttribute('transform', ...)
&amp;rarr; Canvas redraw
&amp;rarr; React commit 없음&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이것은 React를 피하려는 목적의 수정이 아니었다. React가 담당해야 할 확정 상태와, 드래그 중 임시 시각 상태를 분리한 것이다. 드래그가 끝난 뒤 실제 리전 위치를 확정할 때는 React가 개입해도 된다. 하지만 드래그 중 매 마우스 이동마다 React 렌더 사이클을 돌릴 필요는 없었다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;9. Preview 합성기는 별도 축으로 다뤘어야 했다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Preview 합성기는 타임라인 드래그 병목과 같은 화면에서 발견됐지만, 같은 원인이라고 보기는 어렵다. 여기서 문제는 고빈도 이벤트보다 상태별 렌더링 전략이었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;재생 중에는 매 프레임 렌더링이 필요하다. 반대로 정지 상태에서는 화면 상태가 바뀔 때만 한 번 렌더링하면 충분하다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 정지 상태에서는 무한 rAF 루프보다 dirty flag 기반 단발 렌더링이 더 알맞았다. 여기서 dirty flag는 &amp;ldquo;다시 그릴 필요가 있음&amp;rdquo;을 표시하는 플래그다. React의 dirty checking과 같은 개념으로 사용한 것은 아니다.&lt;/p&gt;
&lt;pre id=&quot;code_1781056757325&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;재생 중:
rAF loop 유지

정지 중:
상태 변경 시 dirty 표시
&amp;rarr; rAF에서 renderOnce 1회 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 drag visual이나 Canvas redraw와 같은 묶음으로 처리하려 했다면 원인을 흐렸을 것이다. 관찰된 증상은 비슷해도, 해결 기준은 달랐다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;10. 재측정이 남은 문제를 보여 줬다&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;수정 후 Canvas draw 호출과 React actualDuration 평균은 줄었다. 이 부분은 측정값으로 확인할 수 있었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 React commit 수는 105회에서 101회로만 줄었다. 이 수치만 보면 개선이 작아 보일 수 있다. 그러나 commit 수와 commit당 렌더 비용은 다른 지표다. commit 수가 거의 그대로여도 각 commit에서 수행하는 렌더 비용이 줄었다면 일부 개선은 있었다고 볼 수 있다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;동시에 이 결과는 남은 병목이 있다는 신호이기도 했다. 글에서 언급한 후보는 TrackContextMenu였다. 각 row에 메뉴가 마운트되어 있고 row 리렌더가 메뉴 서브트리까지 함께 끌고 간다면, drag visual 경로를 줄여도 React commit 수는 크게 줄지 않을 수 있다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 부분은 추론이다. 측정값은 React commit 수가 크게 줄지 않았다는 사실을 보여 준다. TrackContextMenu가 그 원인이라는 판단은 컴포넌트별 actualDuration과 구조 분석에 근거한 후보이지, 이 글의 수치만으로 단독 원인이라고 단정할 수는 없다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;11. 지금 다시 정리하는 개선 순서&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다시 한다면 순서는 다음이 더 좋았을 것 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1781056797589&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;1. 드래그 끊김 증상을 재현 가능한 시나리오로 고정
2. React Profiler와 Chrome Performance로 기준 수치 측정
3. Canvas redraw, React commit, SVG 갱신, Preview loop를 분리
4. rAF coalescing으로 중복 redraw 억제
5. 재측정
6. Canvas redraw를 React state 경로에서 분리
7. drag visual을 React 렌더 경로에서 분리
8. Preview 정지 상태 rAF loop 제거
9. 재측정
10. TrackContextMenu 같은 남은 병목을 별도 작업으로 분리&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 순서의 장점은 각 수정의 목적이 명확하다는 점이다. rAF coalescing은 중복 실행을 줄인다. ref 기반 draw 호출은 Canvas redraw를 React 리렌더에서 분리한다. SVG DOM 직접 조작은 드래그 중 임시 시각 상태를 React 렌더 경로에서 분리한다. dirty flag는 정지 상태의 불필요한 Preview 루프를 줄인다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;서로 비슷해 보이는 최적화라도 줄이는 비용은 다르다. 이 차이를 구분해야 수정 결과를 제대로 설명할 수 있다.&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;12. 마치며&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번 작업을 돌아보면, 가장 아쉬운 점은 해결책을 떠올리는 속도보다 문제를 나누는 속도가 늦었다는 것이다. rAF나 ref 패턴은 유용했지만, 그 패턴이 왜 필요한지 설명하려면 먼저 실행 경로를 분리해야 했다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;성능 개선은 &amp;ldquo;어떤 최적화 기법을 적용했는가&amp;rdquo;보다 &amp;ldquo;어떤 비용을 줄였는가&amp;rdquo;가 먼저다. 그리고 그 비용은 측정 없이는 분명하게 말하기 어렵다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;앞으로 비슷한 문제를 만나면 바로 패턴을 적용하기보다, 먼저 증상을 고정하고, 기준 수치를 남기고, 병목을 작은 경로로 나누는 순서로 접근하려고 한다. 그래야 개선 후에도 무엇이 줄었고 무엇이 남았는지 솔직하게 설명할 수 있다.&lt;/p&gt;</description>
      <category>개발로그</category>
      <category>react</category>
      <category>문제를 푸는 순서</category>
      <category>최적화</category>
      <category>프론트엔드</category>
      <category>회고록</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/53</guid>
      <comments>https://insight74278.tistory.com/53#entry53comment</comments>
      <pubDate>Wed, 10 Jun 2026 11:00:39 +0900</pubDate>
    </item>
    <item>
      <title>[드래그 최적화 시도 1] React 경로를 줄이고 한 프레임에 한 번만 그리기</title>
      <link>https://insight74278.tistory.com/52</link>
      <description>&lt;h2&gt;1. 들어가며&lt;/h2&gt;
&lt;p&gt;타임라인에서 리전을 드래그할 때 화면이 뚝뚝 끊기는 문제가 있었다. 처음에는 캔버스에 그리는 데이터가 많아서 생기는 문제라고 생각했다. 하지만 React DevTools Profiler와 Chrome Performance로 확인해 보니, 단순히 Canvas draw 비용만의 문제는 아니었다.&lt;/p&gt;
&lt;p&gt;드래그 중 React commit은 105회 발생했고, canvas clearRect는 104회, drawImage는 25,300회 호출됐다. 같은 드래그 구간에서 메인 스레드 busy time은 1,524ms였다. 이 수치는 드래그 중 React와 Canvas가 모두 자주 깨어나고 있었다는 사실을 보여줬다.&lt;/p&gt;
&lt;p&gt;결론부터 적으면, &lt;strong&gt;드래그 중 매번 React를 깨우던 경로를 줄이고, Canvas 다시 그리기와 SVG 위치 이동처럼 직접 처리할 수 있는 작업은 React 밖에서 처리하도록 분리했다.&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;React commit은 React가 계산한 결과를 실제 DOM에 반영하는 단계를 말한다. 이 글에서는 드래그 중 React가 실제 화면 반영 단계까지 몇 번 들어왔는지를 보는 지표로 사용했다.&lt;/p&gt;
&lt;p&gt;출처: &lt;a href=&quot;https://react.dev/learn/render-and-commit&quot;&gt;React 공식 문서 - Render and Commit&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;2. 드래그 중 발생한 두 가지 비용&lt;/h2&gt;
&lt;p&gt;증상은 하나였지만, 내부에서 발생한 비용은 크게 두 가지로 나눌 수 있었다.&lt;/p&gt;
&lt;p&gt;첫 번째는 Canvas를 다시 그리는 비용이었다. 리전 변경 신호가 짧은 시간 안에 여러 번 발생하는데, 기존 구조에서는 각 신호가 다시 그리기로 이어졌다. 같은 프레임 안에서 캔버스를 여러 번 그려도 사용자는 마지막 결과만 보게 된다. 따라서 중간에 실행된 draw는 화면에 보이지 않는 중복 작업에 가까웠다.&lt;/p&gt;
&lt;p&gt;두 번째는 React 리렌더 비용이었다. 리전 드래그 중 임시 위치나 Canvas 갱신처럼 React 렌더 결과가 꼭 필요하지 않은 값도 React state 경로를 거치고 있었다. 그 결과 드래그 중 신호가 빠르게 들어올 때마다 React가 리렌더되고, 일부 컴포넌트가 거의 매 프레임 깨어났다.&lt;/p&gt;
&lt;p&gt;초기 흐름을 단순화하면 다음과 같았다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;regionChanged 또는 drag visual 변경
→ React state 업데이트
→ React 리렌더
→ useEffect 또는 렌더 결과를 통해 Canvas / SVG 갱신&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;여기서 문제는 React가 잘못 동작했다는 것이 아니다. React가 반드시 맡지 않아도 되는 잦은 시각 업데이트까지 React 경로에 연결되어 있었다는 점이 문제였다.&lt;/p&gt;
&lt;h2&gt;3. 병목 지점 확인&lt;/h2&gt;
&lt;p&gt;컴포넌트 단위로 commit 수와 actualDuration을 확인했다. actualDuration은 해당 업데이트에서 React 트리와 하위 컴포넌트를 렌더링하는 데 걸린 시간이다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컴포넌트&lt;/th&gt;
&lt;th&gt;commit 수&lt;/th&gt;
&lt;th&gt;총 actualDuration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;TrackRowComponent&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;489ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrackContextMenu&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;439ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrackRegionSvgLayerComponent&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;203ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;세 컴포넌트가 드래그 내내 거의 매 프레임 리렌더되고 있었다. 특히 TrackRegionSvgLayerComponent가 104회 리렌더된다는 점이 눈에 띄었다. 이 컴포넌트는 드래그 중 임시 위치를 표시하는 drag visual 값을 useSyncExternalStore로 구독하고 있었다.&lt;/p&gt;
&lt;p&gt;useSyncExternalStore 자체가 문제는 아니었다. 이 훅은 React 바깥의 외부 store 값을 React 컴포넌트에서 읽고 구독할 때 사용하는 API다. 문제는 그 store 값이 React 렌더 결과에 반드시 반영되어야 하는 값이었는가였다.&lt;/p&gt;
&lt;p&gt;드래그 중 임시 위치는 실제로 SVG 노드의 transform 속성만 바꾸면 되는 값이었다. 즉, React 트리 전체를 다시 계산하지 않고도 표현할 수 있는 시각 상태였다.&lt;/p&gt;
&lt;h2&gt;4. 여러 리전 신호를 한 번의 그리기로 묶기&lt;/h2&gt;
&lt;p&gt;먼저 regionChanged, regionAdded, regionRemoved 같은 리전 신호를 한 번의 그리기로 묶었다. 여기서 requestAnimationFrame(rAF)은 브라우저가 다음 화면을 그리기 직전에 콜백을 실행하도록 예약하는 기능이다.&lt;/p&gt;
&lt;p&gt;예를 들어 마우스를 한 번 움직이는 동안 리전 변경 신호가 여러 번 올 수 있다. 하지만 브라우저 화면은 프레임 단위로 갱신된다. 그래서 같은 프레임 안에서 들어온 여러 신호를 모두 즉시 그리지 않고, 다음 화면 갱신 직전에 한 번만 그리도록 예약했다.&lt;/p&gt;
&lt;p&gt;핵심은 “이미 그리기가 예약되어 있는지”를 저장하는 값이었다. 이미 예약되어 있으면 새 예약을 만들지 않고 바로 빠져나온다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const scheduleSignalRedraw = () =&amp;gt; {
  if (signalRafRef.current) return; // 이미 예약되어 있으면 건너뛴다

  signalRafRef.current = requestAnimationFrame(() =&amp;gt; {
    signalRafRef.current = 0;
    drawTrackCanvasRef.current?.();
  });
};

playlist.regionAdded.connect(scheduleSignalRedraw);
playlist.regionRemoved.connect(scheduleSignalRedraw);
playlist.regionChanged.connect(scheduleSignalRedraw);&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 구조는 같은 프레임 안의 여러 신호를 하나의 다시 그리기 예약으로 묶는다. 정확히 말하면 이벤트 발생 자체를 줄이는 것은 아니다. 이벤트가 여러 번 발생하더라도 실제 Canvas 다시 그리기를 프레임당 최대 1회로 제한하는 것이다.&lt;/p&gt;
&lt;h2&gt;5. React state만 줄여서는 부족했던 이유&lt;/h2&gt;
&lt;p&gt;처음에는 rAF 안에서 setRegionVersion을 호출하는 방식으로 접근했다. 이 방식은 React state 업데이트를 프레임당 한 번으로 줄이는 데는 도움이 됐다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const bump = () =&amp;gt; {
  if (rafPendingRef.current) return; // 이미 예약되어 있으면 건너뛴다

  rafPendingRef.current = requestAnimationFrame(() =&amp;gt; {
    rafPendingRef.current = 0;
    setRegionVersion((v) =&amp;gt; v + 1);
  });
};&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;하지만 이 구조는 여전히 React 리렌더를 유발했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;regionChanged
→ rAF
→ setRegionVersion
→ React 리렌더
→ drawTrackCanvas 재생성
→ useEffect
→ Canvas 다시 그리기&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 지점에서 문제를 다시 나눠야 했다. rAF로 여러 신호를 한 번으로 묶는 것과 React 리렌더를 제거하는 것은 별개의 문제였다. 전자는 중복 draw를 줄이는 문제이고, 후자는 Canvas 작업을 React 렌더 사이클에서 분리하는 문제였다.&lt;/p&gt;
&lt;h2&gt;6. Canvas 다시 그리기를 React 밖에서 직접 실행하기&lt;/h2&gt;
&lt;p&gt;Canvas는 React DOM이 아니다. React가 DOM을 계산한 뒤 그 결과를 반영하는 방식과 달리, Canvas는 canvas.getContext(&amp;#39;2d&amp;#39;)로 얻은 context에 명령형 API를 호출해 그린다.&lt;/p&gt;
&lt;p&gt;그래서 리전 신호에서 React state를 업데이트하지 않고, 최신 drawTrackCanvas 함수를 따로 보관한 뒤 직접 호출하도록 바꿨다. 여기서 ref는 React 리렌더와 상관없이 값을 보관할 때 쓰는 상자처럼 생각하면 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const drawTrackCanvasRef = useRef&amp;lt;(() =&amp;gt; void) | null&amp;gt;(null);

useEffect(() =&amp;gt; {
  drawTrackCanvasRef.current = drawTrackCanvas;
}, [drawTrackCanvas]);&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그리고 리전 신호는 이 보관된 함수를 rAF 안에서 호출한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const scheduleSignalRedraw = () =&amp;gt; {
  if (signalRafRef.current) return; // 이미 예약되어 있으면 건너뛴다

  signalRafRef.current = requestAnimationFrame(() =&amp;gt; {
    signalRafRef.current = 0;
    drawTrackCanvasRef.current?.();
  });
};&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;여기서 신경 써야 했던 부분은 오래된 함수가 실행되는 문제였다. 보관해 둔 함수가 예전 drawTrackCanvas라면 현재 props나 viewport 상태와 맞지 않는 draw가 실행될 수 있다. 그래서 drawTrackCanvas가 재생성될 때마다 보관된 함수를 최신 함수로 바꿔 줬다. 이 변경으로 React가 레이아웃이나 props 변경을 처리하는 경로와, 리전 신호가 Canvas를 직접 갱신하는 경로가 분리됐다.&lt;/p&gt;
&lt;h2&gt;7. drag visual을 React 밖으로 꺼내기&lt;/h2&gt;
&lt;p&gt;다음으로 drag visual 경로를 정리했다. 여기서 drag visual은 드래그 중에만 보여주는 임시 위치 표시다. 기존에는 이 임시 위치 값이 useSyncExternalStore를 통해 React 컴포넌트에 전달됐다. 이 값이 바뀔 때마다 TrackRegionSvgLayerComponent가 리렌더됐다. 변경 전 흐름은 다음과 같았다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;drag visual 변경
→ useSyncExternalStore
→ React 리렌더
→ SVG / Canvas 갱신&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;하지만 drag visual은 실제 리전 위치가 확정된 값이 아니었다. 드래그 중 사용자에게 보여주기 위한 임시 시각 상태였다. SVG 쪽에서는 노드의 transform만 바꾸면 됐고, Canvas 쪽에서는 다시 그리기만 예약하면 됐다. 그래서 drag visual 변경도 “다음 화면 갱신 직전에 한 번만 실행하는 직접 갱신 경로”에 연결했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;drag visual 변경
→ store가 변경 알림을 보냄
→ 이미 예약되어 있으면 건너뜀
→ Canvas drawTrackCanvasRef.current()
→ SVG node.setAttribute(&amp;#39;transform&amp;#39;, ...)
→ React commit 없음&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;SVG는 각 노드를 Map에 보관하고, 필요한 노드에만 직접 setAttribute를 호출하도록 바꿨다. 여기서 Map은 리전 id와 실제 SVG DOM 노드를 연결해 두는 저장소다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const segmentGroupRefs = useRef(new Map&amp;lt;string, SVGGElement&amp;gt;());

const applyDragVisualSnapshot = useCallback(() =&amp;gt; {
  const snapshot = getRegionDragVisualSnapshot();

  segmentGroupRefs.current.forEach((node, segmentId) =&amp;gt; {
    const deltaPx = regionIdIsInDragVisual(segmentId, snapshot)
      ? snapshot.deltaPx
      : 0;

    if (deltaPx !== 0) {
      node.setAttribute(&amp;#39;transform&amp;#39;, `translate(${deltaPx} 0)`);
    } else {
      node.removeAttribute(&amp;#39;transform&amp;#39;);
    }
  });
}, []);&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이때 오래된 DOM 참조가 남지 않도록, 노드가 생기면 Map에 추가하고 노드가 사라지면 Map에서 제거했다. 이 처리를 하지 않으면 리전이 삭제된 뒤에도 Map이 오래된 노드를 들고 있을 수 있다.&lt;/p&gt;
&lt;h2&gt;8. 변경 후 구조&lt;/h2&gt;
&lt;p&gt;변경 후 드래그 중 시각 업데이트 흐름은 다음과 같이 정리됐다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mousemove
→ regionDragVisualStore.set(deltaPx)
→ store가 변경 알림을 보냄
→ 이미 예약되어 있으면 건너뜀
→ requestAnimationFrame
→ canvas.drawTrackCanvasRef.current()
→ svg node.setAttribute(&amp;#39;transform&amp;#39;, ...)
→ React commit 없음&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;React가 완전히 사라진 것은 아니다. 실제 리전 위치가 확정되거나 레이아웃과 props가 바뀌는 경우에는 React가 여전히 필요하다. 바뀐 것은 드래그 중 자주 발생하는 임시 시각 업데이트를 React state 경로에서 분리했다는 점이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
participant Drag as drag visual 변경
participant Store as React 밖 store
participant Guard as 예약 여부 확인
participant RAF as requestAnimationFrame
participant Canvas as Canvas 다시 그리기
participant SVG as SVG transform

Drag-&amp;gt;&amp;gt;Store: deltaPx 저장
Store-&amp;gt;&amp;gt;Guard: 변경 알림
Guard-&amp;gt;&amp;gt;Guard: 이미 예약되어 있으면 건너뜀
Guard-&amp;gt;&amp;gt;RAF: 1회 예약
RAF-&amp;gt;&amp;gt;Canvas: drawTrackCanvasRef.current()
RAF-&amp;gt;&amp;gt;SVG: node.setAttribute(&amp;#39;transform&amp;#39;, ...)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;9. 변경 결과&lt;/h2&gt;
&lt;p&gt;동일 시나리오를 3회 반복 측정한 뒤 중앙값으로 비교했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;변경 전&lt;/th&gt;
&lt;th&gt;변경 후&lt;/th&gt;
&lt;th&gt;변화&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;React commit 수&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;-4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React actualDuration 평균&lt;/td&gt;
&lt;td&gt;2.87ms&lt;/td&gt;
&lt;td&gt;1.98ms&lt;/td&gt;
&lt;td&gt;-31%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;canvas clearRect&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;-47%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;canvas drawImage&lt;/td&gt;
&lt;td&gt;25,300&lt;/td&gt;
&lt;td&gt;12,903&lt;/td&gt;
&lt;td&gt;-49%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메인 스레드 busy time&lt;/td&gt;
&lt;td&gt;1,524ms&lt;/td&gt;
&lt;td&gt;1,325ms&lt;/td&gt;
&lt;td&gt;-13%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;측정된 사실은 Canvas draw 호출 수와 React 렌더 비용이 줄었다는 것이다. 여기서 draw 호출은 Canvas를 다시 그리기 위해 실행한 함수 호출을 뜻한다. 이 결과는 드래그 중 불필요한 React 경유와 중복 Canvas 다시 그리기가 줄었다는 해석과 일치한다.&lt;/p&gt;
&lt;p&gt;다만 React commit 수는 105회에서 101회로만 줄었다. 따라서 이번 변경으로 React commit 자체가 크게 줄었다고 말하기는 어렵다. 남은 commit은 다른 경로에서 발생하고 있었고, 특히 TrackContextMenu가 각 row에 마운트되어 row 리렌더 비용을 키우는 문제가 남아 있었다.&lt;/p&gt;
&lt;h2&gt;10. 남은 작업&lt;/h2&gt;
&lt;p&gt;이번 작업으로 드래그 중 React 개입과 Canvas 다시 그리기 호출은 줄었다. 하지만 전체 병목이 모두 제거된 것은 아니었다.&lt;/p&gt;
&lt;p&gt;남은 문제 중 하나는 TrackContextMenu였다. 각 row에 메뉴가 마운트되어 있어 row가 리렌더될 때 메뉴 서브트리까지 함께 영향을 받았다. 이 구조를 공유 메뉴 구조로 바꾸면 row 단위 비용을 더 줄일 수 있을 것으로 보인다.&lt;/p&gt;
&lt;p&gt;또 다른 후보는 드래그 확장 값처럼 아직 React state 경로를 거치는 값이다. 이 값도 드래그 중 자주 바뀌면서 React 렌더 결과가 반드시 필요하지 않다면, 같은 원칙으로 직접 갱신 경로를 고려할 수 있다.&lt;/p&gt;
&lt;p&gt;다만 모든 값을 React 밖으로 꺼내는 것이 정답은 아니다. React가 관리해야 하는 상태와 명령형으로 처리할 수 있는 임시 시각 상태를 구분하는 것이 더 중요하다.&lt;/p&gt;
&lt;h2&gt;11. 마치며&lt;/h2&gt;
&lt;p&gt;이번 작업에서 가장 중요했던 점은 “React를 쓰지 말자”가 아니었다. React가 필요한 작업과 필요하지 않은 작업을 구분하는 것이었다. Canvas 다시 그리기와 SVG 위치 이동처럼 직접 처리할 수 있는 드래그 중 시각 업데이트는 React 렌더 사이클을 매번 거치지 않아도 된다. 반대로 실제 데이터가 확정되거나 레이아웃이 바뀌는 작업은 React가 관리하는 편이 자연스럽다.&lt;/p&gt;
&lt;p&gt;처음에는 캔버스가 무거운 문제라고만 봤다. 하지만 측정하고 경로를 따라가 보니, 드래그 중 자주 발생하는 신호가 React 경로와 Canvas 다시 그리기 경로를 반복해서 깨우는 구조가 더 중요한 문제였다. 이번 수정은 그 경계를 다시 그은 작업이었다. 다음에 비슷한 문제를 만나면 먼저 “이 값이 React 렌더 결과에 반드시 필요한가”를 확인할 것 같다. 필요하지 않은 잦은 시각 업데이트라면, 처음부터 React 경로와 직접 갱신 경로를 나눠 설계하는 편이 더 안정적일 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;참고&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/learn/render-and-commit&quot;&gt;React 공식 문서 - Render and Commit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/reference/react/Profiler&quot;&gt;React 공식 문서 - &lt;code&gt;&amp;lt;Profiler&amp;gt;&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/reference/react/useSyncExternalStore&quot;&gt;React 공식 문서 - &lt;code&gt;useSyncExternalStore&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://react.dev/learn/manipulating-the-dom-with-refs&quot;&gt;React 공식 문서 - Manipulating the DOM with Refs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame&quot;&gt;MDN - &lt;code&gt;requestAnimationFrame()&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Glossary/Main_thread&quot;&gt;MDN - Main thread&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발로그</category>
      <category>JS</category>
      <category>RAF</category>
      <category>react</category>
      <category>Timeline</category>
      <category>드래그</category>
      <category>멀티미디어편집기</category>
      <category>웹브라우저</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/52</guid>
      <comments>https://insight74278.tistory.com/52#entry52comment</comments>
      <pubDate>Tue, 9 Jun 2026 20:07:35 +0900</pubDate>
    </item>
    <item>
      <title>[썸네일 생성 최적화 시도 3] Web Worker를 다시 의심하고 검증하기</title>
      <link>https://insight74278.tistory.com/51</link>
      <description>&lt;blockquote style=&quot;background-color: #fdfaf0; color: #586e75; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;썸네일 최적화 시도 시리즈는 총 3편으로, 잘못된 판단으로 삽질을 하고, 스스로에게 질문하고 수정해나가는 과정을 담았습니다. 이전 글들은 아래 링크에서 보실 수 있습니다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p style=&quot;color: #202124; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이전 글:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;[썸네일 생성 최적화 시도1]&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Web Worker 를 이용한 썸네일 생성 최적화&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://insight74278.tistory.com/42&quot;&gt;https://insight74278.tistory.com/42&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;[썸네일 생성 최적화 시도2] 썸네일 생성 구조 재설계하기&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://insight74278.tistory.com/49&quot;&gt;https://insight74278.tistory.com/49&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1. 들어가며&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;앞선 글에서는 비디오 썸네일 생성 구조를 두 번에 걸쳐 정리했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;첫 번째 글에서는 Web Worker를 이용해 썸네일 생성 비용을 메인 스레드에서 분리하는 과정을 다뤘다. 그 과정에서 처음에는 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 가 항상 빠를 것이라고 생각했지만, 실제로는 생성 개수와 측정 조건에 따라 결과가 달라진다는 점을 확인했다. 그래서 seek-first-1-then-worker 전략을 선택했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;두 번째 글에서는 썸네일 생성을 하나의 작업으로 보지 않고&amp;nbsp;poster,&amp;nbsp;visible tile,&amp;nbsp;background prefetch로 나누는 설계를 정리했다. 전체 duration 기준으로 미리 만드는 방식보다, 사용자가 지금 보고 있는 viewport를 먼저 채우는 방식이 더 적합하다고 판단했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이번 글은 그 다음 단계의 고민을 다룬다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;구조를 나누고 나니 새로운 질문이 생겼다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; color: #333333;&quot;&gt;이 정도 구조가 정말 필요한가?&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;직접 써봤을 때 불편하지 않다면 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 를 빼도 되는 것 아닌가?&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 를 유지한다면 그 이유는 속도인가, UI 응답성인가?&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이번 글의 목적은 구현을 더 설명하는 것이 아니다. 이미 만든 구조를 다시 의심하고, 어떤 근거로 유지하거나 단순화할 수 있는지 판단한 과정을 정리하는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;2. 직접 써본 느낌만으로 충분한 경우&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;성능 작업을 하다 보면 숫자를 계속 보고 싶어진다. 하지만 실제 제품에서는 체감도 중요하다. 사용자가 import 직후 타임라인을 스크롤하고, region을 드래그하고, 편집 동작을 했을 때 불편함이 없다면 그 자체로 의미 있는 관찰이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;다만 이 관찰이 충분한 범위는 제한적이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;직접 사용해봤을 때 불편함이 없었다면, 다음 판단에는 충분할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%; height: 143px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;판단&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;체감 가능 여부&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;현재 UX가 당장 문제 없어 보인다&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;가능&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;추가 스케줄링을 당장 넣지 않는다&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;가능&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; &amp;nbsp;제거 판단을 보류한다&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;가능&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;성능 문제가 완전히 없다고 말한다&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;부족&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 를 제거한다&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;부족&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;모든 대용량 영상에서 안전하다고 말한다&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;부족&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;즉, 수동 검증은 제품 판단에는 도움이 된다. 하지만 아키텍처를 제거하거나 복잡도를 줄이는 결정을 하려면 더 좁은 질문에 답해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;내가 확인해야 했던 질문은 이것이었다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;썸네일 생성이 실제 편집 동작의 메인 스레드 응답성을 유의미하게 망가뜨리는가?&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 질문에 답하려면 단순히 전체 생성 시간이 아니라, 편집 중 UI 루프가 얼마나 막히는지를 봐야 했다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;3. 빠르다는 말의 의미를 다시 나누기&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;처음에는 썸네일 생성 경로를 비교할 때 총 생성 시간을 주로 봤다. 몇 장을 몇 초에 만들었는지를 보면 빠른 경로를 고를 수 있을 것 같았다.&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 작업을 진행하면서 빠르다는 말이 너무 넓다는 것을 알게 됐다.&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;여기서는 최소한 세 가지를 분리해야 했다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;기준&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;의미&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;첫 시각적 피드백&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;import 후 사용자가 처음 이미지를 보는 시간&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;전체 생성 시간&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;요청한 썸네일이 모두 준비되는 시간&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;UI 응답성&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;생성 중 스크롤, 드래그, 입력이 얼마나 밀리는지&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;poster-first 구조는 첫 시각적 피드백을 줄이는 선택이다. 실제로 첫 region 표시 시간은 약&amp;nbsp;6.26초에서&amp;nbsp;2.82초로 줄었다. 약&amp;nbsp;55%&amp;nbsp;개선이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 이 개선은 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 때문이라고 말하면 안 된다. 이 수치는 전체 썸네일을 기다리지 않고 poster 1장을 먼저 공유한 구조 변화의 결과에 가깝다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 의 역할은 다른 곳에 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 가 정말 필요한지는 전체 생성 시간이 줄었는가보다 메인 스레드를 얼마나 덜 막았는가로 판단해야 했다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;4. 벤치마크를 다시 잡은 이유&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;초기 벤치마크는 HTMLVideoElement seek와 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs를 비교했다. 이후에는 Main/WebCodecs 경로도 추가했다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 비교가 필요했던 이유는 단순하다. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 가 빠른 이유가 WebCodecs 때문인지, 아니면 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 때문인지 분리해야 했기 때문이다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그래서 세 경로를 나눴다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;경로&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;의미&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;HTMLVideoElement seek&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;브라우저 비디오 요소의&amp;nbsp;currentTime&amp;nbsp;seek 후 frame capture&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Main/WebCodecs&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;메인 스레드에서 ArrayBuffer read, mp4box demux, VideoDecoder decode&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 에서 ArrayBuffer read, mp4box demux, VideoDecoder decode&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Main/WebCodecs와 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs는 가능한 한 같은 방식으로 맞췄다. 파일을 ArrayBuffer로 읽고, mp4box로 demux하고, 선택한 sample을 VideoDecoder로 decode하는 흐름이다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이렇게 해야 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 가 유리한 이유를 더 좁게 볼 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;5. Main busy와 Max stall&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;새로 본 지표는&amp;nbsp;Main busy와&amp;nbsp;Max stall이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 글에서&amp;nbsp;Main busy는 실행 중&amp;nbsp;requestAnimationFrame&amp;nbsp;간격이 16.7ms를 초과한 누적 시간을 실행 시간으로 나눈 값이다. 16.7ms는 60fps 기준 한 프레임 예산이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;정확히 말하면 이 값은 순수 JS 실행 시간만을 뜻하지 않는다. 렌더링, GC, bitmap 수신과 반영, 브라우저 내부 작업까지 섞일 수 있다. 그래서&amp;nbsp;메인 스레드 JS 블로킹이라고 단정하면 부정확하다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;더 정확한 표현은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Main busy는 UI 루프 지연을 추정하는 대리 지표다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Max stall은 측정 구간에서 관찰된 가장 긴 단일 프레임 간격으로, 최대 멈춤 시간을 의미한다. 이 값이 크면 사용자는 순간적인 멈춤으로 느낄 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;즉, 두 지표는 다음 질문에 답하기 위해 추가했다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;썸네일 생성 중 UI 루프가 얼마나 오래, 얼마나 자주 밀리는가?&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;6. 숫자가 바꾼 판단&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;위 내용을 바탕으로 테스트 페이지를 만들어서 다시 검증을 해보았다. 테스트 페이지 작성에는 클로드 AI의 도움을 받았고, 클로드가 작성한 코드를 검수하는 방식으로 진행하였다. (AI 에이전트가 활성화되면서 좋은점 중 하나는, 테스트 페이지 작성에 심적 부담감이 확연히 줄어들었단 점이다.)&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&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-origin-width=&quot;1108&quot; data-origin-height=&quot;1088&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BRlQF/dJMcagsfVaB/WbwsKINIMBq0wbsVOI19M1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BRlQF/dJMcagsfVaB/WbwsKINIMBq0wbsVOI19M1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BRlQF/dJMcagsfVaB/WbwsKINIMBq0wbsVOI19M1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBRlQF%2FdJMcagsfVaB%2FWbwsKINIMBq0wbsVOI19M1%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;1108&quot; height=&quot;1088&quot; data-origin-width=&quot;1108&quot; data-origin-height=&quot;1088&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;100장 생성 기준 결과는 다음과 같았다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%; height: 68px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Mode&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Total&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Avg/frame&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Main busy&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Max stall&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;3936.61ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;39.37ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;11%&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;428.87ms&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Main/WebCodecs&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;4047.95ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;40.48ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;66%&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1251.81ms&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;HTMLVideoElement seek&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;20844.48ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;208.44ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;0%&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;21.05ms&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;span style=&quot;color: #333333;&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 결과에서 중요한 점은 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs와 Main/WebCodecs의 전체 생성 시간이 거의 비슷하다는 것이다. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;가 압도적으로 빠르다고 말하기 어렵다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 Main busy와 Max stall은 다르다. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;/WebCodecs는 Main busy가 11%였고, Main/WebCodecs는 66%였다. Max stall도 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 는 약 429ms, Main은 약 1252ms였다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;따라서 이 결과는 다음 해석과 일치한다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 의 핵심 가치는 전체 처리 속도보다 UI 응답성 보호에 있다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 는 같은 WebCodecs 기반 처리를 메인 스레드 밖으로 옮긴다. 그래서 전체 생성 시간이 비슷하더라도, 사용자가 편집하는 동안 메인 스레드가 덜 막힐 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;7. HTMLVideoElement seek의 위치&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;HTMLVideoElement seek는 poster 1장에는 매우 강했다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%; height: 68px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Mode&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Requested&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;First frame&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Total&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;HTMLVideoElement seek&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;50.29ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;51.2ms&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;3163.29ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;3163.35ms&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Main/WebCodecs&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;6143.84ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;6143.85ms&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 수치는 poster-first 전략을 다시 뒷받침한다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;대표 이미지 1장을 빠르게 보여주는 목적이라면 HTMLVideoElement seek가 가장 적합하다. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs는 초기 demux와 decode 준비 비용이 있어서 1장 작업에는 불리하다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 HTMLVideoElement seek를 여러 장 생성에 쓰는 것은 조심해야 한다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;실험 중 10장 생성에서 912348.63ms라는 비정상값이 나왔다. 이 값은 일반적인 성능 판단에 그대로 넣으면 안 된다. seek queue가 멈췄는지, 측정 로직에 문제가 있었는지, 브라우저 decode stall이 있었는지 추가 확인이 필요하다.&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그럼에도 방향은 분명했다.&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;div&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;용도&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;적합한 경로&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;poster 1장&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;HTMLVideoElement seek&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;visible tile 일부&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;상황에 따라 seek queue 또는 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;bulk thumbnail&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Main/WebCodecs 단독&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;UI 응답성 리스크 있음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;8. 실제 에디터 UI를 기준으로 다시 보기&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;벤치마크 페이지는 경로 비교에는 좋다. 하지만 실제 편집 경험을 완전히 대표하지는 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;특히 region drag는 단순히 DOM element를 움직이는 작업이 아니다. 실제 에디터에서는 dnd-kit, placement 계산, interaction preview store, canvas redraw, commit command가 함께 움직인다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그래서 실제 판단에는 E2E 테스트가 필요했다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;테스트에서 봐야 하는 것은 다음이다.&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;div&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;시나리오&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;확인하려는 것&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;import 직후 region drag&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;thumbnail loading 중 편집이 막히는지&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;thumbnails loaded 후 region drag&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;baseline 편집 성능&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;zoom level별 drag&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;tile 수와 canvas redraw 비용 증가&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;clip count별 drag&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;track 수 증가에 따른 비용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;trim handle drag&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;resize preview와 commit 경로 비용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 테스트는 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 가 필요한지 직접 증명하기보다, 실제 편집 경로에서 jank가 발생하는지 확인하는 장치다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;더 강한 결론을 내려면 같은 E2E를 strategy별로 돌려야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;비교군&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;목적&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;현재 구조&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Main/WebCodecs&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 제거 가능성 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;thumbnail off&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;thumbnail 외 비용 기준선 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 비교가 있어야&amp;nbsp;썸네일 생성 때문에 밀린다와&amp;nbsp;타임라인 자체 redraw 비용 때문에 밀린다를 더 잘 구분할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;9. background prefetch와 메모리 상한&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;두 번째 글에서는 화면 밖 썸네일 생성을 backfill이라고 불렀다. 이후 다시 생각해보니 더 정확한 표현은 background thumbnail prefetch에 가깝다.&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;backfill은 빈 곳을 메운다는 느낌이 강하다. 실제 의도는 사용자가 아직 보지 않는 구간을 낮은 우선순위로 미리 만들어두는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 작업은 반드시 상한이 있어야 한다. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;에서 썸네일을 다 만든 뒤 한 번에 main thread로 보내면 순간 메모리 사용량이 커질 수 있다. 브라우저가 디스크 스왑으로 안전하게 처리해줄 것이라고 기대하면 안 된다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;필요한 방어선은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;방어선&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이유&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;batch 전송&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;ImageBitmap이 한 번에 몰리는 상황 방지&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;cache 상한&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;긴 영상에서 메모리 증가 방지&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;in-flight dedupe&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;같은 timestamp 중복 생성 방지&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;stale result discard&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;scroll, zoom 이후 늦게 온 결과 무시&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;ImageBitmap.close&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사용하지 않는 bitmap 리소스 해제&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;abort 처리&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;import 취소, undo, 프로젝트 전환 대응&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;여기서 중요한 점은 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 를 쓴다고 메모리 문제가 사라지지 않는다는 것이다. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 는 메인 스레드 점유를 줄일 수 있지만, 결과 bitmap과 cache는 여전히 관리해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;10. 최종 선택&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;현재 기준에서 선택한 구조는 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;background-color: oklab(0.22559 -0.00131416 -0.00642684 / 0.00784314);&quot; data-mermaid-theme=&quot;base&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;769&quot; data-origin-height=&quot;504&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzTylK/dJMcadh1F5c/CrDbzjFYyAO9fH3FlLKP3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzTylK/dJMcadh1F5c/CrDbzjFYyAO9fH3FlLKP3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzTylK/dJMcadh1F5c/CrDbzjFYyAO9fH3FlLKP3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzTylK%2FdJMcadh1F5c%2FCrDbzjFYyAO9fH3FlLKP3K%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;769&quot; height=&quot;504&quot; data-origin-width=&quot;769&quot; data-origin-height=&quot;504&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;정리하면 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;선택&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이유&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;비용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;poster-first 유지&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;첫 시각적 피드백이 가장 빠름&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;poster와 thumbnail cache 분리 필요&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs 유지&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Main busy와 Max stall 감소&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; message, abort, memory 관리 필요&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;HTMLVideoElement seek는 poster 중심&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1장 생성이 매우 빠름&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;여러 장 seek는 불안정 가능성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;background prefetch는 낮은 우선순위&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;화면 밖 탐색 경험 보완&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;상한 없으면 메모리 위험&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;E2E로 실제 UI 검증&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;synthetic benchmark 한계 보완&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;테스트 환경과 selector 관리 필요&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 구조는 가장 단순한 구조는 아니다. 하지만 현재 관찰한 수치에서는 Main/WebCodecs 단독 전환보다 더 안전하다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;특히 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; /WebCodecs와 Main/WebCodecs의 전체 처리 시간이 비슷한데도 Main busy가 크게 다르다는 점이 중요했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 는 속도 최적화 도구라기보다 UI 응답성 보호 장치에 가깝다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;11. 아직 단정하지 않은 것&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이번 측정으로 모든 결론이 끝난 것은 아니다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;아직 단정하지 않은 것은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;남은 질문&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;필요한 확인&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;HTMLVideoElement seek 10장 이상치 원인&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;timeout, seek queue, decode stall 분리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 가 모든 환경에서 유리한가&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;저사양 PC, 긴 영상, 4K 영상 측정&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;memory leak이 없는가&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;반복 import, undo, project switch 후 메모리 추적&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;E2E jank의 원인이 thumbnail인가&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt;/Main/off 비교군&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;cache 상한이 적절한가&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333;&quot;&gt;viewport 이동 패턴과 영상 길이별 관찰&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그래서 현재 결론은 단정짓는것이 아닌, 이 정도가 정확한 표현이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;지금 수치에서는 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 제거보다 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt;유지가 더 타당하다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;12. 마치며&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;세번의 썸네일 생성 최적화를 거치며 &lt;/span&gt;가장 크게 바뀐 관점은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&amp;ldquo;썸네일을 얼마나 빨리 많이 만들 것인가&amp;rdquo;에서 &amp;ldquo;사용자가 먼저 체감하는 구간을 어떻게 안정적으로 보여줄 것인가&amp;rdquo;로 이동한 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 를 쓰면 썸네일 생성이 빨라질 것이라고 생각했다. 그래서 관심은 주로 전체 생성 시간에 있었다. 하지만 측정해보니 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 는 항상 빠른 해법이 아니었다. 10장처럼 작업량이 작을 때는 오히려 seek보다 느렸고, 60장처럼 작업량이 커졌을 때 이점이 분명해졌다. 여기서 중요한 기준은 &amp;ldquo;Worker가 빠른가&amp;rdquo;가 아니라,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;어떤 작업량과 어떤 목적에서 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 가 유리한가&lt;/b&gt;였다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그 다음에는 전체 생성 시간보다 첫 시각적 피드백이 더 중요하다는 쪽으로 관점이 바뀌었다. 사용자는 모든 썸네일이 완성되는 시점보다, import 직후 타임라인에 영상이 보이기 시작하는 시점을 먼저 체감한다. 그래서 전체를 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 에 맡기는 것보다, poster 또는 첫 1장을 먼저 보여주고 나머지를 점진적으로 채우는 구조가 더 적합했다. 이때부터 성능 최적화의 기준은 총 처리 시간이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;첫 화면 안정화 시간과 전체 완료 시간 사이의 균형&lt;/b&gt;이 되었다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;두 번째 시도에서는 생성 대상 자체를 다시 보게 되었다. 기존처럼 duration 기준으로 몇 장을 만들지 정하는 방식은 구현은 단순하지만, 사용자가 지금 보는 viewport와 맞지 않을 수 있었다. 그래서 &amp;ldquo;전체 영상에서 몇 장을 만들 것인가&amp;rdquo;가 아니라 &amp;ldquo;현재 화면에 필요한 tile을 먼저 어떻게 채울 것인가&amp;rdquo;가 더 중요한 질문이 되었다. 여기서 poster, visible tile, background prefetch를 분리하는 구조가 나왔다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;세 번째 시도에서는 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 의 의미를 다시 의심했다. 직접 써봤을 때 불편함이 없다면 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 를 빼도 되는 것 아닌지 고민했다. 하지만 Main/WebCodecs와 Worker/WebCodecs를 비교해보니 전체 생성 시간은 비슷해도 main busy와 max stall은 크게 달랐다. 이 결과로 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 의 가치는 단순한 생성 속도가 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;메인 스레드 UI 응답성을 보호하는 것&lt;/b&gt;에 있다는 쪽으로 관점이 정리됐다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;결국 중요한 것은 다음 세 가지였다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;첫째,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;사용자가 먼저 보는 것과 나중에 볼 수 있는 것을 분리하는 것&lt;/b&gt;이다. poster와 visible tile은 빠르게 보여줘야 하고, 화면 밖 썸네일은 낮은 우선순위로 제한적으로 처리해도 된다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;둘째,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;속도 지표를 하나로 보지 않는 것&lt;/b&gt;이다. first-region, generated, main busy, max stall은 서로 다른 질문에 답한다. 전체 생성 시간이 빠르다고 첫 화면이 빠른 것은 아니고, 생성 시간이 비슷하다고 UI 응답성이 같은 것도 아니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;셋째,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;최적화의 이득과 구조적 복잡도를 함께 보는 것&lt;/b&gt;이다. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt;, queue, abort, stale check, cache 상한은 모두 비용이 있다. 따라서 구조를 계속 복잡하게 만드는 것이 목표가 아니라, E2E와 메모리 관찰을 통해 어디까지가 필요한 복잡도인지 판단해야 한다. &lt;br /&gt;&lt;br /&gt;이제&amp;nbsp;남은&amp;nbsp;작업은&amp;nbsp;구조를&amp;nbsp;더&amp;nbsp;복잡하게&amp;nbsp;만드는&amp;nbsp;것이&amp;nbsp;아니다.&amp;nbsp;오히려&amp;nbsp;반대로,&amp;nbsp;E2E와&amp;nbsp;메모리&amp;nbsp;관찰을&amp;nbsp;통해&amp;nbsp;현재&amp;nbsp;구조에서&amp;nbsp;&lt;b&gt;정말 필요한 복잡도와 덜어낼 수 있는 복잡도를 구분해야 한다. &lt;/b&gt;세번의 작업을 통해 성능 최적화는 무조건 더 빠른 코드를 찾는 일이 아니라는 것을 배웠다. 사용자가 먼저 체감하는 구간과 나중에 처리해도 되는 구간을 나누고, 실제로 필요한 최적화인지 판단해야 한다. 동시에 함께 일하는 개발자들과 미래의 내가 이 코드를 다시 봤을 때 쉽게 이해할 수 있는지, 인계 비용과 러닝커브까지 고려해야 한다. 결국 중요한 것은 속도만이 아니라,&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;성능&amp;nbsp;개선이&amp;nbsp;가져오는&amp;nbsp;이득과&amp;nbsp;구조적&amp;nbsp;복잡도&amp;nbsp;사이의&amp;nbsp;트레이드오프를&amp;nbsp;판단하는&amp;nbsp;일이었다.&lt;/span&gt;&lt;/blockquote&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 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이전 글:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;[썸네일 생성 최적화 시도1]&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Web Worker 를 이용한 썸네일 생성 최적화&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://insight74278.tistory.com/42&quot;&gt;https://insight74278.tistory.com/42&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;[썸네일 생성 최적화 시도2] 썸네일 생성 구조 재설계하기&lt;span&gt; &lt;a href=&quot;https://insight74278.tistory.com/49&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://insight74278.tistory.com/49&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <category>개발로그</category>
      <category>JS</category>
      <category>thumbnail</category>
      <category>web codecs</category>
      <category>web worker</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/51</guid>
      <comments>https://insight74278.tistory.com/51#entry51comment</comments>
      <pubDate>Tue, 9 Jun 2026 11:23:22 +0900</pubDate>
    </item>
    <item>
      <title>[썸네일 생성 최적화 시도 2]썸네일 생성 구조 재설계하기</title>
      <link>https://insight74278.tistory.com/49</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비디오 편집 도구에서 타임라인 썸네일은 작은 기능처럼 보이지만, 실제로는 사용자 경험과 성능에 직접 영향을 준다. 사용자는 타임라인 region 안의 이미지를 보고 장면의 흐름을 파악한다. 반대로 썸네일이 늦게 뜨거나 빈 영역이 오래 보이면 업로드가 끝났는지, 영상이 제대로 로드됐는지 판단하기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 영상 길이를 기준으로 일정 개수의 썸네일을 미리 생성하는 방식이 자연스러워 보였다. 예를 들어 짧은 영상에는 최소 20장, 긴 영상에는 최대 60장을 만드는 식이다. 하지만 타임라인이 실제로 그리는 것은 전체 영상이 아니라 현재 화면에 보이는 영역이다. 이 차이를 놓치면 첫 화면에 필요하지 않은 프레임을 먼저 만들 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 설계의 핵심은 썸네일 생성을 하나의 작업으로 보지 않고, 목적에 따라 세 가지로 나누는 것이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초기 표시용 poster&lt;/li&gt;
&lt;li&gt;현재 viewport에 보이는 tile 썸네일&lt;/li&gt;
&lt;li&gt;idle 시간에 실행하는 backfill&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 용어 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설계에서 용어를 명확히 구분해야 한다.&lt;/p&gt;
&lt;table style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;용어&lt;/td&gt;
&lt;td&gt;의미&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;poster&lt;/td&gt;
&lt;td&gt;영상 대표 이미지 1장이다. 특정 timestamp에 정확히 대응하는 썸네일이 아니라 fallback 이미지다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;thumbnail&lt;/td&gt;
&lt;td&gt;특정 source timestamp에 대응하는 비디오 프레임 이미지다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;viewport&lt;/td&gt;
&lt;td&gt;사용자가 현재 화면에서 보고 있는 타임라인 영역이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tile&lt;/td&gt;
&lt;td&gt;타임라인 region 안에서 썸네일 하나가 그려지는 칸이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;backfill&lt;/td&gt;
&lt;td&gt;사용자가 지금 보지 않는 구간의 썸네일을 낮은 우선순위로 미리 채우는 작업이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 중요한 구분은 poster와 thumbnail이다. poster는 &quot;아직 실제 썸네일이 없을 때 보여주는 대표 이미지&quot;이고, thumbnail은 &quot;특정 시간의 프레임&quot;이다. 둘을 같은 캐시에 넣으면 누락 프레임 판단이 부정확해진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 기존 방식의 한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 방식은 영상 길이를 기준으로 baseline 썸네일을 먼저 만들었다. 대략 5초당 1장 수준으로 계산하고, 너무 적거나 많지 않도록 최소값과 최대값을 두는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 구현이 단순하다. 하지만 타임라인 UI와 완전히 맞지는 않는다. 타임라인은 전체 영상의 균등 분포 프레임을 바로 보여주는 것이 아니라, 현재 viewport 안의 tile을 그린다. 즉 사용자가 보는 첫 화면에 필요한 timestamp와, duration 기반 baseline timestamp가 다를 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 긴 영상에서는 문제가 더 커진다. 1시간 영상에 대해 초당 1장씩 썸네일을 만들면 3,600장이 된다. 이는 디코딩 비용, 메모리 사용량, IndexedDB 저장량을 모두 증가시킨다. 반대로 상한을 60장으로 제한하면 전체 비용은 줄지만, 첫 viewport에 필요한 tile이 충분히 채워진다는 보장은 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 질문은 &quot;전체 영상에서 몇 장을 만들 것인가&quot;가 아니라 &quot;사용자가 지금 보는 영역을 어떻게 먼저 채울 것인가&quot;에 가까웠다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 새 전략의 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 전략은 작업을 두 종류로 나눈다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&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;이 기준으로 보면 초기 생성과 backfill을 같은 방식으로 처리하면 안 된다. 현재 viewport의 썸네일은 즉시성이 중요하다. 반면 화면 밖 썸네일은 사용자가 볼 수도 있고 안 볼 수도 있다. 따라서 낮은 우선순위로 처리해도 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. poster를 먼저 생성하는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업로드 직후에는 poster 1장을 먼저 만든다. 이 poster는 Source 패널, Preview fallback, Timeline region fallback에 공통으로 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 선택의 근거는 단순하다. 사용자는 업로드 직후 빈 화면보다 대표 이미지가 보이는 상태를 더 안정적으로 받아들인다. 여러 timestamp 썸네일을 만들기 전에 poster 1장을 먼저 보여주면 초기 표시 지연을 줄일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 poster를 thumbnail처럼 다루면 안 된다. poster를 0초 썸네일처럼 thumbnailCache에 넣으면 시스템은 &quot;0초 근처 프레임이 이미 있다&quot;고 판단할 수 있다. 하지만 poster는 fallback이지 정확한 0초 프레임이라는 보장이 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 poster는 posterCache, 실제 timestamp 썸네일은 thumbnailCache에 둔다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. viewport 기준 생성이 더 적합한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 썸네일은 영상 길이가 아니라 현재 viewport 기준으로 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타임라인 region은 tile 단위로 렌더링된다. 그러면 초기 생성도 현재 viewport 안의 tile 중심 timestamp를 기준으로 하는 편이 자연스럽다. 예를 들어 현재 화면에 tile이 8개 보이면 8개를 먼저 만들고, 좌우로 약간의 preload를 붙일 수 있다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;초기 생성 대상:
  현재 viewport의 visible tile
  + 왼쪽 preload 일부
  + 오른쪽 preload 일부
&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;이 방식은 전체 영상 기준 baseline보다 낭비가 적다. 첫 화면에 필요하지 않은 구간을 먼저 디코딩하지 않기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. visible tile은 메인 스레드 seek가 더 낫다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 viewport에 보이는 tile은 보통 몇 장 되지 않는다. 이 작업은 생성되는 대로 바로 region에 반영되어야 한다. 그래서 Web Worker보다 hidden video element를 이용한 메인 스레드 seek/capture가 더 적합하다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;video.currentTime = targetTime
seek 완료 대기
canvas 또는 createImageBitmap으로 프레임 캡처
thumbnailCache에 저장
canvas redraw
&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;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 는 메인 스레드를 막지 않는 장점이 있다. 하지만 현재 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt; 경로는 파일 전체를 fetch하고, mp4box로 demux한 뒤, VideoDecoder로 프레임을 만든다. 소수의 visible tile을 빠르게 교체하는 용도에는 초기 비용이 클 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 메인 스레드 seek에도 주의점은 있다. 하나의 video element는 여러 seek를 동시에 처리할 수 없다. source별로 순차 queue를 두고, 최신 viewport 요청만 유지해야 한다. scroll이나 zoom 중 이전 요청이 늦게 끝나면 stale 결과로 보고 무시해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt;는 idle backfill에 사용한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt;는 backfill에 더 적합하다. backfill은 사용자가 지금 보는 영역이 아니라, 나중에 볼 가능성이 있는 구간을 미리 채우는 작업이다. 즉시성이 낮기 때문에 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker&lt;/span&gt;의 초기 비용을 감수할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 backfill도 무제한이면 안 된다. 긴 영상에서 화면 밖 모든 구간을 미리 채우려 하면 결국 기존 문제로 돌아간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시 정책은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const idleIntervalSec = 10;
const maxIdleFramesPerVideo = 120;
const maxFramesPerIdleBatch = 4;
const maxIdleWorkMs = 100;
&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;여기서 중요한 것은 하한보다 상한이다. backfill은 필수 작업이 아니라 보조 작업이다. 반드시 몇 장 이상 만들어야 하는 작업이 아니다. 반대로 너무 많이 만들지 않도록 제한하는 것은 필요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. nearest-keyframe 방식의 의미&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Web Worker &lt;/span&gt;방식은 요청한 timestamp의 정확한 프레임을 항상 만들지는 않는다. 요청 시각에 가장 가까운 keyframe을 선택한다. 비디오는 모든 프레임이 독립적으로 저장되지 않는다.&lt;/p&gt;
&lt;table style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;프레임 종류&lt;/td&gt;
&lt;td&gt;의미&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;keyframe&lt;/td&gt;
&lt;td&gt;독립적으로 디코딩 가능한 프레임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;non-keyframe&lt;/td&gt;
&lt;td&gt;주변 프레임 정보를 참조해야 디코딩 가능한 프레임&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 요청 시각이 12.3초이고 keyframe이 10초, 15초에만 있다면 Web Worker는 둘 중 가까운 keyframe을 썸네일로 쓸 수 있다. 따라서 이 결과는 12.3초의 정확한 화면이 아닐 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 backfill에는 허용 가능하다. backfill의 목적은 화면 밖 구간을 대략 채워두는 것이기 때문이다. 하지만 현재 viewport에 보이는 tile에는 정확도가 더 중요할 수 있다. 그래서 visible tile은 메인 스레드 seek/capture로 처리하는 편이 더 합리적이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 요청 관리에서 필요한 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 스레드를 쓰든 Web Worker를 쓰든 요청 관리는 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필수 조건은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이미 캐시에 있는 timestamp는 다시 생성하지 않는다.&lt;/li&gt;
&lt;li&gt;scroll/zoom 중에는 최신 viewport 요청을 우선한다.&lt;/li&gt;
&lt;li&gt;파일 교체, undo, 프로젝트 전환 시 이전 결과를 무시한다.&lt;/li&gt;
&lt;li&gt;visible 요청은 idle backfill보다 항상 우선한다.&lt;/li&gt;
&lt;li&gt;source별 작업 순서를 제어한다.&lt;/li&gt;
&lt;li&gt;stale 결과가 새 파일의 캐시에 들어가지 않게 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Web Worker를 사용한다고 해서 이런 문제가 사라지지는 않는다. Web Worker는 seek event 충돌을 줄일 수 있지만, 결과가 늦게 도착하는 문제는 여전히 있다. 따라서 abort와 stale 체크는 계속 필요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 최종 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 목표 구조는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;업로드 직후:
  poster 1장 생성
  Source, Preview, Timeline fallback에 표시

현재 viewport:
  visible tile timestamp 계산
  누락 tile만 main-thread seek queue로 생성
  생성되는 즉시 poster를 실제 썸네일로 교체

스크롤과 줌:
  새 viewport timestamp 계산
  캐시에 없는 tile만 추가 생성
  이전 viewport 요청은 stale 처리

idle 시간:
  Web Worker로 화면 밖 timestamp를 제한적으로 backfill
  visible 요청이 들어오면 backfill 중단 또는 지연

캐시:
  posterCache = fallback 대표 이미지
  thumbnailCache = timestamp 기반 실제 프레임
  IndexedDB = 안정적인 file id 기준 영속 저장&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;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;558&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4VwaQ/dJMcaaS53JC/O21PIPd5nKHD0FldRKMbtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4VwaQ/dJMcaaS53JC/O21PIPd5nKHD0FldRKMbtk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4VwaQ/dJMcaaS53JC/O21PIPd5nKHD0FldRKMbtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4VwaQ%2FdJMcaaS53JC%2FO21PIPd5nKHD0FldRKMbtk%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;715&quot; height=&quot;558&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;558&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. 마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 설계에서 가장 중요한 변화는 썸네일 생성을 &quot;많이 미리 만들어두는 작업&quot;으로 보지 않은 것이다. 대신 사용자가 보는 화면과 보지 않는 화면을 분리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 화면에 필요한 썸네일은 적게, 빠르게, 가능하면 정확하게 만든다. 화면 밖 썸네일은 Web Worker를 이용해 천천히, 제한적으로, 근사값을 허용하면서 채운다. poster는 이 둘과 별개의 fallback으로 둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나누면 초기 표시 안정성과 리소스 사용량 사이의 균형을 잡을 수 있다. 아직 실제 구현에서는 queue 정책, stale 체크, poster image 로딩 캐시, IndexedDB key 정책을 세밀하게 맞춰야 한다. 하지만 설계 기준은 명확해졌다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;지금 보는 것은 우선 처리하고, 나중에 볼 수도 있는 것은 보조 작업으로 처리한다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&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 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이전 글:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;[썸네일 생성 최적화 시도1]&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Web Worker 를 이용한 썸네일 생성 최적화&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://insight74278.tistory.com/42&quot;&gt;https://insight74278.tistory.com/42&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다음 글:&lt;span&gt;&amp;nbsp;&lt;/span&gt;[썸네일 생성 최적화 시도3] Worker를 다시 의심하고 검증하기&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://insight74278.tistory.com/51&quot;&gt;https://insight74278.tistory.com/51&lt;/a&gt;&lt;/p&gt;</description>
      <category>개발로그</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/49</guid>
      <comments>https://insight74278.tistory.com/49#entry49comment</comments>
      <pubDate>Sat, 6 Jun 2026 00:53:42 +0900</pubDate>
    </item>
    <item>
      <title>Runtime Scope로 프로젝트 전환 Race Condition 정리하기</title>
      <link>https://insight74278.tistory.com/47</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업은 브라우저 기반 멀티미디어 편집기에서 새 프로젝트를 만들 때 발생한 FFmpeg 문제에서 시작했다. 프로젝트 A에서 영상 변환이 진행 중인데 사용자가 새 프로젝트 B를 만들면, A의 변환 결과가 늦게 끝난 뒤 B의 상태에 붙을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 FFmpeg를 제대로 종료하지 못해서 생기는 문제라고 생각했다. 그런데 코드를 따라가다 보니 비슷한 문제가 썸네일 생성, 미디어 import, 오디오 복원, cloud sync에도 있었다. 결국 특정 라이브러리 하나의 문제가 아니었다. 오래 걸리는 작업이 끝났을 때 그 결과가 아직 유효한지 확인하지 않는 구조의 문제였다.&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;이 문제를 처음부터 명확하게 이해한 것은 아니었다. race condition이라는 말도 스레드나 공유 메모리 쪽에서 많이 보던 개념이라, 브라우저의 비동기 작업에도 같은 표현을 써도 되는지 다시 확인해야 했다. 처음에는 동시성 제어 문제와도 헷갈렸다. 작업을 하나씩 실행하면 해결되는 문제인지, 아니면 늦게 끝난 작업의 유효성을 확인해야 하는 문제인지 구분이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글의 목표는 세 가지다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;프로젝트 전환 중 이전 비동기 작업이 왜 문제가 되는지 정리한다.&lt;/li&gt;
&lt;li&gt;localProjectId, epoch, snapshot, scope가 각각 무엇을 보호하는지 설명한다.&lt;/li&gt;
&lt;li&gt;어떤 선택지를 비교했고 왜 현재 구조를 선택했는지 기록한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 적으면, 이 문제는 FFmpeg 단일 버그가 아니라 프로젝트 전환 중 이전 비동기 결과가 늦게 반영되는 async race condition이었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&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://en.wikipedia.org/wiki/Race_condition&quot;&gt;Race condition - Wikipedia&lt;/a&gt;, &lt;a href=&quot;https://wiki.kldp.org/HOWTO/html/Secure-Programs-HOWTO/avoid-race.html&quot;&gt;경쟁 상태 설명 - KLDP&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 async race condition은 별도의 공식 개념이라기보다, 비동기 코드에서 발생한 경쟁 상태를 설명하기 위해 쓰는 표현에 가깝다. 이 글에서는 프로젝트 전환과 이전 비동기 작업의 완료 순서가 엇갈리면서 예전 작업 결과가 현재 프로젝트 상태에 잘못 반영되는 문제를 좁혀 부르기 위해 이 표현을 사용한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제가 발생한 흐름&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-1. 브라우저 멀티미디어 에디터의 상태 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트는 브라우저에서 동작하는 멀티미디어 에디터다. 사용자는 영상, 오디오, 이미지를 가져와 타임라인에 배치하고 미리보기 화면에서 편집 결과를 확인한다. 그래서 하나의 프로젝트는 단순한 문서 하나가 아니라 여러 미디어 파일, 편집 세션, 미리보기 상태, 런타임 리소스를 함께 가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비디오를 import할 때는 영상 파일만 추가하는 것이 아니다. 편집기에서 오디오 파형을 보여주거나 오디오 트랙을 다루려면 영상 안의 오디오도 읽어야 한다. 이 과정에서 FFmpeg가 쓰인다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FFmpeg는 오디오와 비디오를 기록하고 변환하고 스트리밍할 수 있는 멀티미디어 처리 도구다. 이 글에서는 브라우저 안에서 영상 파일의 오디오를 디코딩하거나 파일을 변환하는 도구로 다룬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처: &lt;a href=&quot;https://www.ffmpeg.org/&quot;&gt;FFmpeg 공식 사이트&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대략적인 import 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;사용자가 비디오 선택
  -&amp;gt; media file 등록 준비
  -&amp;gt; FFmpeg로 오디오 디코딩
  -&amp;gt; 오디오 버퍼와 파형 준비
  -&amp;gt; session과 preview state에 반영
  -&amp;gt; thumbnail과 cache 갱신
&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;이 흐름 중 FFmpeg 디코딩과 오디오 버퍼 준비는 시간이 걸릴 수 있다. 그 사이 사용자가 새 프로젝트를 만들면 A 프로젝트에서 시작한 import가 B 프로젝트가 열린 뒤 끝날 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트는 하나의 편집기 화면 안에서 여러 종류의 상태를 함께 다룬다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;상태&lt;/td&gt;
&lt;td&gt;예시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프로젝트 정보&lt;/td&gt;
&lt;td&gt;이름, 로컬 프로젝트 id, 원격 프로젝트 id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;미디어 목록&lt;/td&gt;
&lt;td&gt;영상, 오디오, 이미지 파일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;타임라인 세션&lt;/td&gt;
&lt;td&gt;트랙, 소스, 구간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;미리보기 레이어&lt;/td&gt;
&lt;td&gt;영상, 이미지, 텍스트 레이어&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;런타임 리소스&lt;/td&gt;
&lt;td&gt;오디오 버퍼, 썸네일 캐시, FFmpeg instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;저장 상태&lt;/td&gt;
&lt;td&gt;local draft, cloud revision, upload status&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 새 프로젝트를 만들거나 cloud project를 열거나 revision을 복원하면 이 상태들이 한 번에 바뀐다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;프로젝트 전환
  -&amp;gt; mediaFiles 교체
  -&amp;gt; session 교체
  -&amp;gt; preview layers 교체
  -&amp;gt; thumbnail cache 초기화
  -&amp;gt; audio buffer 초기화
  -&amp;gt; cloud identity 변경
&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;h3 data-ke-size=&quot;size23&quot;&gt;2-2. A 프로젝트 작업이 B 프로젝트에 붙는 시간 순서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 프로젝트 A에서 영상 import를 시작했다고 하자.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 프로젝트 A에서 영상 import 시작
2. FFmpeg 변환 또는 오디오 디코딩 진행
3. 사용자가 새 프로젝트 B 생성
4. 프로젝트 B의 session, mediaFiles, cache 적용
5. 프로젝트 A의 import 결과가 뒤늦게 완료
6. A의 결과가 B에 commit될 수 있음
&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 작업이 시작된 상태와 작업이 끝난 상태가 다를 수 있다는 것이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-3. 이 글에서 쓰는 핵심 용어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 모든 용어를 한 번에 정리하면 오히려 흐름이 무거워진다. 이 글에서 계속 반복되는 핵심 용어만 먼저 정리한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;용어&lt;/td&gt;
&lt;td&gt;설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;async race condition&lt;/td&gt;
&lt;td&gt;비동기 작업의 완료 순서에 따라 결과가 달라지는 경쟁 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stale result&lt;/td&gt;
&lt;td&gt;예전 상태 기준으로 만들어져 지금은 유효하지 않은 결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;commit&lt;/td&gt;
&lt;td&gt;비동기 작업 결과를 실제 store, session, cache, cloud에 반영하는 행위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;live read&lt;/td&gt;
&lt;td&gt;await 이후 현재 열린 프로젝트 상태를 다시 읽는 행위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;snapshot&lt;/td&gt;
&lt;td&gt;작업 시작 시점의 데이터를 고정해 둔 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;epoch&lt;/td&gt;
&lt;td&gt;현재 브라우저 탭 안의 editor runtime 세대 번호&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 중에서 가장 늦게 중요성을 느낀 것은 live read였다. 처음에는 예전 작업이 현재 store에 쓰는 것만 문제라고 생각했다. 하지만 예전 작업이 await 이후 현재 store를 다시 읽는 것도 같은 방식으로 문제가 될 수 있었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. FFmpeg만의 문제가 아니었던 이유&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-1. 늦게 끝난 작업이 현재 프로젝트에 쓰는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 보인 문제는 stale write였다. 예전 프로젝트에서 시작한 작업이 늦게 끝난 뒤 현재 프로젝트에 결과를 쓰는 경우다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 예전 프로젝트 작업이 늦게 끝난 뒤
useProjectStore.getState().addMediaFile(entry);
usePreviewStore.getState().addLayer(layer);
thumbnailCache.set(sourceId, bitmaps);
&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;이런 코드는 현재 열린 프로젝트에 바로 결과를 쓴다. 작업이 시작된 프로젝트가 A였어도 지금 열린 프로젝트가 B라면 B에 결과가 들어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 FFmpeg 변환 결과에만 있지 않았다. 미디어 import가 늦게 끝난 뒤 현재 프로젝트에 media file을 추가할 수 있었다. 썸네일 생성이 늦게 끝난 뒤 현재 cache에 이전 프로젝트 이미지를 저장할 수 있었다. cloud sync가 await 이후 현재 store를 다시 읽으면 다른 프로젝트의 파일을 참조할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통점은 FFmpeg가 아니었다. 공통점은 비동기 작업과 프로젝트 전환이 같은 runtime 상태를 두고 시간 순서 경쟁을 한다는 점이었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-2. await 이후 현재 store를 다시 읽는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 더 늦게 발견한 문제는 stale read였다. 예전 프로젝트 작업이 await 이후 현재 store를 다시 읽는 경우다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;await uploadSomething();
const mediaFile = useProjectStore.getState().mediaFiles.find(...);
&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;이 코드는 자연스러워 보인다. 하지만 await 동안 프로젝트가 바뀌면 getState()는 작업 시작 시점의 프로젝트가 아니라 현재 열린 프로젝트를 읽는다.&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;이 부분이 가장 조심스러웠다. 화면이 잠깐 이상해지는 정도가 아니라 cloud에 잘못된 파일 관계가 저장될 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 async 작업은 시작 시점의 데이터를 snapshot으로 잡아야 한다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;// 나쁜 예
await uploadSomething();
const mediaFiles = useProjectStore.getState().mediaFiles;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;// 좋은 예
const mediaFilesSnapshot = useProjectStore.getState().mediaFiles;
await uploadSomething();
use(mediaFilesSnapshot);
&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;snapshot은 현재 store가 나중에 바뀌더라도 이 작업은 시작 당시의 데이터를 사용한다는 의미다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-3. 동시성 제어와 stale result 방지는 다른 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 이 문제를 동시성 제어 문제로 착각했다. FFmpeg 작업이 겹치니까 하나씩 실행하면 되지 않을까 싶었다. 실제로 FFmpeg는 싱글톤처럼 동작하고 하나의 instance 안에서 실행 상태와 메모리 파일 시스템을 공유하기 때문에 직렬화가 필요했다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 전환 race 방지는 끝난 작업이 아직 반영되어도 되는가에 가깝다. 작업이 하나씩 실행되더라도 사용자가 중간에 프로젝트를 바꾸면 이전 작업 결과는 더 이상 유효하지 않을 수 있다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;구분&lt;/td&gt;
&lt;td&gt;질문&lt;/td&gt;
&lt;td&gt;예시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동시성 제어&lt;/td&gt;
&lt;td&gt;동시에 실행되는 작업을 어떻게 조절할까&lt;/td&gt;
&lt;td&gt;FFmpeg job을 하나씩 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프로젝트 전환 race 방지&lt;/td&gt;
&lt;td&gt;늦게 끝난 결과가 아직 유효할까&lt;/td&gt;
&lt;td&gt;scope가 바뀌었으면 commit하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;그래서 이번 작업에서는 두 문제를 분리해서 봤다. FFmpeg 내부 파일 충돌에는 동시성 제어가 필요했다. 프로젝트 전환에는 runtime scope가 더 중요한 기준이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 유효성을 판단하는 기준 만들기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. useEffect cleanup에서 얻은 힌트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 정리하면서 예전에 React useEffect를 공부할 때 봤던 race condition 예시가 떠올랐다. useEffect 안에서 데이터를 가져오고 state를 업데이트할 때, 이전 요청이 나중에 끝나 최신 상태를 덮어쓰는 문제였다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
    let ignore = false;

    async function load() {
        const result = await fetchData(userId);

        if (!ignore) {
            setData(result);
        }
    }

    load();

    return () =&amp;gt; {
        ignore = true;
    };
}, [userId]);
&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;여기서 핵심은 fetch를 무조건 중단하는 것이 아니다. 이전 effect에서 시작한 비동기 작업이 늦게 끝났을 때, 그 결과를 아직 반영해도 되는지 확인한다는 점이다. cleanup 함수가 실행되면 이전 effect의 ignore 값이 true가 되고, 나중에 도착한 결과는 state에 반영되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 프로젝트 전환 문제를 이해하는 데 힌트가 되었다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;useEffect 예시&lt;/td&gt;
&lt;td&gt;이 프로젝트의 경우&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dependency 변경&lt;/td&gt;
&lt;td&gt;프로젝트 전환 또는 revision restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이전 fetch 요청&lt;/td&gt;
&lt;td&gt;이전 프로젝트의 import, thumbnail, sync 작업&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cleanup의 ignore = true&lt;/td&gt;
&lt;td&gt;이전 scope abort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;if (!ignore) setData()&lt;/td&gt;
&lt;td&gt;assertCurrentProjectScope(scope) 후 commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stale response&lt;/td&gt;
&lt;td&gt;stale import result, stale thumbnail, stale sync result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 React 내부 구현이 이 글의 ProjectRuntimeScope 같은 runtime scope를 사용한다는 뜻은 아니다. useEffect의 cleanup은 React가 effect lifecycle에 맞춰 이전 effect를 정리해주는 메커니즘이고, ignore 값은 JavaScript closure 안에 남아 있는 로컬 변수다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서 가져온 것은 React 내부 구현이 아니라 패턴이었다. 이전 작업의 결과가 늦게 도착할 수 있다면, 완료 시점에 그 결과가 아직 유효한지 확인해야 한다는 생각이다. ProjectRuntimeScope는 이 생각을 컴포넌트 하나가 아니라 편집기 전체 runtime에 적용한 구조에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 관점은 아래 글들을 다시 읽으며 정리했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://romantech.net/1304&quot;&gt;React의 올바른 useEffect 사용팁 - Romantech&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://witch.work/posts/react-useeffect-usage&quot;&gt;React useEffect 사용하기 - Witch-Work&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rinae.dev/posts/a-complete-guide-to-useeffect-ko/&quot;&gt;[번역] useEffect 완벽 가이드 - rinae.dev&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ko.react.dev/learn/synchronizing-with-effects&quot;&gt;Effect로 동기화하기 - React 공식 한국어 문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. localProjectId만으로 부족했던 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 작업 시작 시점의 project id를 저장해두고 나중에 비교하면 된다고 생각했다.&lt;/p&gt;
&lt;pre class=&quot;dns&quot;&gt;&lt;code&gt;내 작업:        프로젝트 A
현재 프로젝트:  프로젝트 A
                 -&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;하지만 같은 프로젝트 안에서도 문서와 runtime이 완전히 갈아엎어질 수 있다. 대표적인 경우가 revision restore다.&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;처음에는 revision과 epoch도 헷갈렸다. 둘 다 버전처럼 보였기 때문이다. 하지만 기준이 다르다. revision은 저장된 문서의 버전이다. cloud history나 restore에서 의미가 있다. epoch는 지금 브라우저 탭 안에서 열린 편집기 runtime의 세대다.&lt;/p&gt;
&lt;pre class=&quot;avrasm&quot;&gt;&lt;code&gt;revision: 저장된 문서의 버전
epoch:    현재 화면 runtime의 세대 번호
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-3. epoch 아이디어를 어디서 얻었는가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;epoch는 갑자기 만든 이름이라기보다 세대 번호를 두고 유효성을 판단하는 패턴에서 힌트를 얻었다. 비동기 요청에서 흔히 쓰는 request id나 generation counter와 비슷한 방식이다.&lt;/p&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;const startedEpoch = currentEpoch;

const result = await longTask();

if (startedEpoch !== currentEpoch) {
    return;
}

commit(result);
&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;이 흐름은 앞에서 본 useEffect cleanup의 ignore 패턴과도 닮아 있다. ignore가 boolean으로 이전 effect의 결과를 무효화한다면, epoch는 숫자로 현재 editor runtime 세대를 구분한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;방식&lt;/td&gt;
&lt;td&gt;의미&lt;/td&gt;
&lt;td&gt;적합 범위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ignore boolean&lt;/td&gt;
&lt;td&gt;이 effect가 무효화됐는가&lt;/td&gt;
&lt;td&gt;컴포넌트 effect 하나&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;request id&lt;/td&gt;
&lt;td&gt;이 요청이 최신 요청인가&lt;/td&gt;
&lt;td&gt;검색, 자동완성, fetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;generation 또는 epoch&lt;/td&gt;
&lt;td&gt;현재 runtime 세대와 같은가&lt;/td&gt;
&lt;td&gt;앱 전체 상태, editor session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;revision&lt;/td&gt;
&lt;td&gt;저장된 문서 버전이 같은가&lt;/td&gt;
&lt;td&gt;서버 저장, cloud history&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영체제나 시스템 쪽에서도 epoch라는 말을 쓰는 경우가 있다. 예를 들어 메모리 관리나 분산 시스템에서는 특정 시점 구간, 세대, term을 구분하기 위해 epoch와 비슷한 개념을 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 글의 epoch가 운영체제나 분산 시스템의 epoch 알고리즘을 그대로 구현한 것은 아니다. 목적도 다르다. 여기서는 이전 비동기 결과가 현재 프로젝트 상태에 반영되지 않게 막는 것이 목적이다. 그래도 상태를 세대로 나누고, 작업이 시작된 세대와 현재 세대를 비교해 유효성을 판단한다는 사고방식은 비슷하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 같은 이름을 쓸 만큼 개념적 유사성은 있지만 같은 알고리즘은 아니다. 이 글의 epoch는 브라우저 탭 안에서 현재 editor runtime 세대를 구분하기 위한 로컬 값이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-4. epoch로 runtime 세대를 구분하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 epoch를 도입했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;epoch는 프로젝트 id가 아니라 현재 편집기 runtime의 세대 번호다.&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 class=&quot;ini&quot;&gt;&lt;code&gt;scope.epoch === activeScope.epoch;
&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;같으면 아직 같은 runtime이다. 다르면 예전 runtime에서 시작한 작업이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서 정보가 핵심이라기보다 같은 세대인가가 핵심이었다. 숫자로 둔 이유는 UUID보다 로그와 테스트에서 순서가 잘 보이기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;epoch 10: import 시작
epoch 11: document apply
epoch 10: stale 처리
&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;중요한 점은 epoch가 서버 revision을 대신하지 않는다는 것이다. 여러 탭에서 같은 프로젝트를 편집할 때 충돌을 판단하는 기준은 revision이나 parentRevisionId 쪽에 가깝다. epoch는 한 탭 안에서 이 작업이 지금 화면의 runtime에서 시작된 작업인가만 판단한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-5. snapshot으로 시작 시점 데이터를 고정하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scope가 commit 시점의 유효성을 판단한다면, snapshot은 작업 중 사용할 데이터를 고정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cloud sync처럼 저장 대상이 명확해야 하는 작업은 current store를 나중에 다시 읽지 않는 것이 중요하다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const scope = getActiveProjectScope();
const mediaFilesSnapshot = useProjectStore.getState().mediaFiles;
const draft = buildDocument();

await syncToCloud(draft, mediaFilesSnapshot);
assertCurrentProjectScope(scope);
commitSyncResult();
&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;이 구조는 아직 계속 다듬어야 하는 부분이다. 다만 방향은 분명해졌다. async 작업은 시작 시점의 기준 데이터를 들고 가야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-6. AbortSignal로 이전 작업에 취소 신호 보내기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AbortSignal은 이전 작업에 취소 신호를 보내기 위한 값이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AbortSignal은 비동기 작업에 취소 요청이 전달되었는지 알리는 신호 객체다. 어떤 작업은 이 신호를 받아 실제로 중단할 수 있다. 어떤 작업은 중단하지 못하더라도 완료 후 결과를 버리는 방식으로 처리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal&quot;&gt;MDN - AbortSignal&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트가 전환되면 이전 scope의 signal을 abort한다.&lt;/p&gt;
&lt;pre class=&quot;d&quot;&gt;&lt;code&gt;if (scope.signal.aborted) {
    return;
}
&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;취소 가능한 작업은 중단한다. 취소하기 어려운 작업은 결과를 commit하기 전에 scope를 확인해 버린다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 구현한 구조&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-1. ProjectRuntimeScope&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scope는 React state가 아니라 module-level runtime 값으로 두었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;module-level runtime은 특정 파일 안에 살아 있는 실행 상태다. React component state처럼 화면 렌더링을 위해 존재하는 값이 아니다. local draft처럼 저장소에 남기는 데이터도 아니다. 앱이 켜져 있는 동안 현재 작업을 제어하기 위해 메모리에 들고 있는 값에 가깝다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// runtime/projectRuntimeScope.ts
let activeScope: ProjectRuntimeScope = createInitialScope();

export function getActiveProjectScope(): ProjectRuntimeScope {
    return activeScope;
}

export function beginNewProjectScope(reason: string): ProjectRuntimeScope {
    activeScope.controller.abort();
    activeScope = createNextScope(reason);
    return activeScope;
}
&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;처음에는 이런 값을 store에 넣어야 하나 고민했다. 하지만 store는 UI가 읽고 반응해야 하는 상태나 저장 가능한 프로젝트 상태에 더 잘 맞는다. 반대로 scope는 이전 작업을 취소하고 현재 작업 세대를 바꾸는 제어용 값이다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// runtime/projectRuntimeScope.ts
export interface ProjectRuntimeScope {
    readonly epoch: number;
    readonly localProjectId: string;
    readonly signal: AbortSignal;
    readonly reason: string;
}
&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;이 값을 module-level로 둔 이유는 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;encoder, cloud sync, FFmpeg service 같은 비-React 모듈에서도 필요했다.&lt;/li&gt;
&lt;li&gt;AbortController는 저장 가능한 상태가 아니다.&lt;/li&gt;
&lt;li&gt;이 값은 UI 상태가 아니라 runtime control state다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 주의할 점도 있다. module-level 값은 숨은 전역 상태처럼 보일 수 있다. 그래서 아무 곳에서나 바꾸면 흐름을 추적하기 어렵다. 이번에는 beginNewProjectScope()와 coordinator를 통해서만 세대를 바꾸도록 제한했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-2. ProjectRuntimeCoordinator&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 전환 시 해야 할 cleanup을 한 곳에 모았다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// runtime/projectRuntimeCoordinator.ts
export function resetProjectRuntimeForDocumentApply(reason: string): ProjectRuntimeScope {
    releaseFFmpeg(reason);
    abortAllThumbnailRequests();
    abortAllThumbnailGeneration();
    thumbnailCache.clear();
    posterCache.clear();
    resetRuntimeForAppliedDocument();

    return beginNewProjectScope(reason);
}
&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;이전에는 applyDocument()가 여러 subsystem의 cleanup을 직접 알고 있었다. coordinator를 두면서 프로젝트 전환 시 runtime을 정리하는 곳이 명확해졌다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-3. FFmpeg 전체 job 직렬화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FFmpeg에서는 직렬화가 필요했다. 하나의 FFmpeg instance가 내부 실행 상태와 메모리 파일 시스템을 공유하고 있었기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 exec만 순서대로 실행하면 충분하다고 생각했다. 하지만 실제 흐름은 더 길었다.&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;writeFile -&amp;gt; exec -&amp;gt; readFile -&amp;gt; cleanup
&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;그래서 전체 흐름을 하나의 job으로 묶었다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// engine/encoder/ffmpegService.ts
export async function runFFmpegJob&amp;lt;T&amp;gt;(
    scope: ProjectRuntimeScope,
    label: string,
    job: (ctx: { ffmpeg: FFmpeg; workPrefix: string }) =&amp;gt; Promise&amp;lt;T&amp;gt;
): Promise&amp;lt;T&amp;gt; {
    const run = jobChain.then(async () =&amp;gt; {
        if (scope.signal.aborted) throw new StaleProjectScopeError(scope.reason);
        const ffmpeg = await getFFmpeg();
        if (scope.signal.aborted) throw new StaleProjectScopeError(scope.reason);

        const workPrefix = `job_${label}_${(jobSeq += 1)}_`;
        try {
            return await job({ ffmpeg, workPrefix });
        } finally {
            await deleteFilesWithPrefix(ffmpeg, workPrefix);
        }
    });

    jobChain = run.then(
        () =&amp;gt; undefined,
        () =&amp;gt; undefined
    );
    return run as Promise&amp;lt;T&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;이 선택은 병렬성을 일부 포기한다. 하지만 브라우저에서 하나의 FFmpeg instance와 메모리 파일 시스템을 공유하는 상황에서는 안정성이 더 중요하다고 판단했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-4. commit 직전 scope 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;긴 작업은 시작 시점에 현재 scope를 캡처한다.&lt;/p&gt;
&lt;pre class=&quot;d&quot;&gt;&lt;code&gt;const scope = getActiveProjectScope();
&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;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;assertCurrentProjectScope(scope);
commitImportedMedia(result);
&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;중요한 것은 확인 위치다. 작업 시작 전에 확인하는 것보다 await 이후 commit 직전에 확인하는 것이 더 중요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 구조 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-1. 프로젝트 전환 시 runtime을 교체하는 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-2. FFmpeg job이 파일을 격리하는 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 변경 결과와 남은 구멍&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-1. 변경 전후 비교&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;항목&lt;/td&gt;
&lt;td&gt;변경 전&lt;/td&gt;
&lt;td&gt;변경 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전환 cleanup&lt;/td&gt;
&lt;td&gt;applyDocument()에 직접 나열&lt;/td&gt;
&lt;td&gt;coordinator로 중앙화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stale 판단&lt;/td&gt;
&lt;td&gt;일부 경로의 localProjectId 비교&lt;/td&gt;
&lt;td&gt;공통 epoch 기반 scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FFmpeg 처리&lt;/td&gt;
&lt;td&gt;exec 중심 직렬화&lt;/td&gt;
&lt;td&gt;전체 job 직렬화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;import guard&lt;/td&gt;
&lt;td&gt;video 중심&lt;/td&gt;
&lt;td&gt;audio, image, replace 계열로 확장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;restore 작업&lt;/td&gt;
&lt;td&gt;일부 localProjectId 기준&lt;/td&gt;
&lt;td&gt;scope 기준으로 통일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cloud sync&lt;/td&gt;
&lt;td&gt;current store read/write 위험&lt;/td&gt;
&lt;td&gt;live write guard와 snapshot 필요성 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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는 파일 작업 전체를 job으로 묶었다. 그 결과 import, export, thumbnail fallback 간의 충돌 가능성을 낮췄다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 아직 완벽하다고 생각하지는 않는다. 리뷰 과정에서 live read가 여전히 문제가 될 수 있다는 점을 다시 확인했다. scope check는 write뿐 아니라 read에도 필요하다. cloud sync는 snapshot 기준을 더 강하게 가져가야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-2. local save의 scope 캡처 시점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;save 작업은 시작 시점에 scope를 더 빨리 잡아야 한다. persistLocalMediaAssets()처럼 await 이후 live store에 쓰는 작업은 같은 문제를 다시 만들 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-3. cloud sync의 snapshot 기준&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cloud sync는 draft뿐 아니라 media file snapshot도 함께 들고 가는 구조가 더 안전하다. stale 상태에서 current mediaFiles를 다시 읽으면 다른 프로젝트 파일을 업로드할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-4. thumbnail cache의 write guard&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;thumbnail cache는 worker, seek, IndexedDB seed, FFmpeg fallback에서 모두 쓰일 수 있다. cache write 직전에 scope를 확인하는 규칙을 더 일관되게 적용해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서 가장 크게 배운 것은 특정 API 사용법이 아니라 느린 작업의 결과가 언제까지 유효한지 명시하는 습관이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 내가 이 문제를 충분히 알고 있다고 생각하지 못했다. race condition이라는 단어를 써도 되는지부터 다시 확인했다. localProjectId와 epoch의 차이도 여러 번 예시를 만들어보며 이해했다. 부족한 상태에서 시작했지만, 그래서 오히려 문제를 더 작게 쪼개서 볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 남은 구멍도 있다. 특히 live read와 snapshot 문제는 계속 신경 써야 한다. 그래도 이번 작업을 통해 비동기 작업은 시작 시점과 완료 시점이 다를 수 있다는 당연한 사실을 코드 구조에 반영하는 방법을 조금 더 이해하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 단계에서는 local save와 cloud sync의 snapshot 기준을 더 명확히 할 예정이다. thumbnail cache write guard도 더 촘촘히 맞출 예정이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고:&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고한 개발 블로그&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://romantech.net/1304&quot;&gt;React의 올바른 useEffect 사용팁 - Romantech&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://witch.work/posts/react-useeffect-usage&quot;&gt;React useEffect 사용하기 - Witch-Work&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rinae.dev/posts/a-complete-guide-to-useeffect-ko/&quot;&gt;[번역] useEffect 완벽 가이드 - rinae.dev&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://medium.com/%401004wipi/race-condition-in-react-bacd518213f2&quot;&gt;Race Condition in React - Medium&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mariais.tistory.com/entry/AbortController-in-UseEffect-UseEffect-%EB%82%B4%EB%B6%80%EC%97%90%EC%84%9C-%EB%B9%84%EB%8F%99%EA%B8%B0%ED%95%A8%EC%88%98-%EC%8B%A4%ED%96%89-%ED%95%A0-%EB%95%8C&quot;&gt;AbortController in UseEffect - Tistory&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://weblivinglog.tistory.com/entry/fetch-%EC%9A%94%EC%B2%AD-%EC%B7%A8%EC%86%8C%ED%95%98%EA%B8%B0-AbortController-%ED%99%9C%EC%9A%A9%EB%B2%95-1&quot;&gt;fetch 요청의 취소와 AbortController - Tistory&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;공식 문서와 개념 자료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://ko.react.dev/learn/synchronizing-with-effects&quot;&gt;Effect로 동기화하기 - React 공식 한국어 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.ffmpeg.org/&quot;&gt;FFmpeg 공식 사이트&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ffmpegwasm.netlify.app/docs/overview/&quot;&gt;ffmpeg.wasm Overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal&quot;&gt;AbortSignal - MDN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/WebAssembly/Guides/Concepts&quot;&gt;WebAssembly의 개념 - MDN 한국어&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Race_condition&quot;&gt;Race condition - Wikipedia&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://wiki.kldp.org/HOWTO/html/Secure-Programs-HOWTO/avoid-race.html&quot;&gt;경쟁 상태 설명 - KLDP&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발로그</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/47</guid>
      <comments>https://insight74278.tistory.com/47#entry47comment</comments>
      <pubDate>Tue, 2 Jun 2026 17:12:13 +0900</pubDate>
    </item>
    <item>
      <title>[썸네일 최적화 시도 1]Web Worker 를 이용한 썸네일 생성 최적화</title>
      <link>https://insight74278.tistory.com/42</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;썸네일 최적화 시도 시리즈는 총 3편으로, 잘못된 판단으로 삽질을 하고, 스스로에게 질문하고 수정해나가는 과정을 담았습니다. 다음 글들은 아래 링크에서 보실 수 있습니다.&amp;nbsp;&lt;/blockquote&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;a href=&quot;https://insight74278.tistory.com/49&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://insight74278.tistory.com/49&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[썸네일 생성 최적화 시도 3] Web Worker를 다시 의심하고 검증하기: &lt;a href=&quot;https://insight74278.tistory.com/51&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://insight74278.tistory.com/51&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 들어가며&lt;/h2&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;썸네일 전체 생성 시간을 줄인다.&lt;/li&gt;
&lt;li&gt;첫 썸네일이 보이는 시점을 앞당긴다.&lt;/li&gt;
&lt;li&gt;빠른 경로가 실패해도 안전하게 복구한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 적으면, &lt;b&gt;처음부터 끝까지 Web Worker만 사용하는 방식보다 &quot;첫 1장은 seek, 나머지는 Web&amp;nbsp;Worker&quot;로 나눈 전략이 가장 균형이 좋았다.&lt;/b&gt; 하지만 여기에 도달하기까지 측정 조건을 두 번 바로잡아야 했고, 중간에 한 번 엎었다. 그 과정을 이 글에 정리했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Web Worker는 웹 애플리케이션의 메인 실행 스레드와 분리된 백그라운드 스레드에서 스크립트를 실행할 수 있게 해주는 간단한 수단이다. 이점은 스레드 자체가 별도로 실행되기 때문에 메인(보통 UI) 스레드의 동작이 느려지거나 중단되지 않는다는 것이다. 출처: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API&quot;&gt;MDN - Web Workers API&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 가설과 측정 조건&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-1. 초기 가설&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 가설은 단순했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;썸네일 생성은 무거운 작업이므로, Web&amp;nbsp;Worker로 옮기면 성능이 개선될 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실험을 진행해보니 이 가설은 일부 조건에서만 맞았다. 핵심은 &quot;항상 빠른가&quot;가 아니라 &quot;어떤 조건에서 빠른가&quot;였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-2. 측정 조건 불일치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Web&amp;nbsp;Worker&amp;nbsp;경로를 붙이고 첫 로그를 확인했을 때, 결과가 기대와 다르게 나왔다. 자세히 보니 비교 기준 자체가 달랐다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;seek 경로: 10장 생성&lt;/li&gt;
&lt;li&gt;Web&amp;nbsp;Worker&amp;nbsp;경로: 60장 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 작업량이 아닌 상태에서 총 시간을 나란히 놓고 비교하고 있었다. &quot;Web&amp;nbsp;Worker가 빠르다&quot;든 &quot;느리다&quot;든 판단할 수 있는 데이터가 아니었다. 성능 실험에서 가장 먼저 의심해야 하는 것은 코드가 아니라 &lt;b&gt;측정 조건&lt;/b&gt;이라는 걸 여기서 다시 확인했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-3. 조건과 지표 재정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 측정 조건부터 다시 잡았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입력 파일: YTDown.com_YouTube_Media_4CLWuZ3403k_001_1080p.mp4&lt;/li&gt;
&lt;li&gt;실행 환경: localhost, 동일 브라우저 세션&lt;/li&gt;
&lt;li&gt;비교 방식: seek/Web&amp;nbsp;Worker 모두 동일한 썸네일 개수로 측정&lt;/li&gt;
&lt;li&gt;관찰 로그: 콘솔 perf 로그 + 성능 패널 장기 작업 구간&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지표도 하나가 아니라 세 개로 분리했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;[thumbnail] perf(generated): 전체 생성 완료 시점&lt;/li&gt;
&lt;li&gt;[thumbnail] perf(first-thumbnail-visible): 첫 썸네일이 화면에 처음 보인 시점&lt;/li&gt;
&lt;li&gt;[thumbnail] perf(first-region-drawn-on-track-row): 트랙 행에 첫 구간이 그려진 시점&lt;/li&gt;
&lt;/ul&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;blockquote data-ke-style=&quot;style2&quot;&gt;개선 전 10장, 60장&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;761&quot; data-origin-height=&quot;140&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsf5RM/dJMcaaSv7Mn/vjtOr1tREE20xb8E9ZxDJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsf5RM/dJMcaaSv7Mn/vjtOr1tREE20xb8E9ZxDJ0/img.png&quot; data-alt=&quot;10장 기준 dom seek 이용 시 콘솔 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsf5RM/dJMcaaSv7Mn/vjtOr1tREE20xb8E9ZxDJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbsf5RM%2FdJMcaaSv7Mn%2FvjtOr1tREE20xb8E9ZxDJ0%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;761&quot; height=&quot;140&quot; data-origin-width=&quot;761&quot; data-origin-height=&quot;140&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;10장 기준 dom seek 이용 시 콘솔 측정 결과&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-origin-width=&quot;1965&quot; data-origin-height=&quot;1270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QVAWr/dJMcahKRXNt/BY1nLazAcqwxC2csLXBM1k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QVAWr/dJMcahKRXNt/BY1nLazAcqwxC2csLXBM1k/img.png&quot; data-alt=&quot;10장 기준 dom seek 이용 시 성능 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QVAWr/dJMcahKRXNt/BY1nLazAcqwxC2csLXBM1k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQVAWr%2FdJMcahKRXNt%2FBY1nLazAcqwxC2csLXBM1k%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;1965&quot; height=&quot;1270&quot; data-origin-width=&quot;1965&quot; data-origin-height=&quot;1270&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;10장 기준 dom seek 이용 시 성능 측정 결과&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-origin-width=&quot;1405&quot; data-origin-height=&quot;144&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxewyn/dJMcadhnH4p/lKNa6NXNNNcuMJI25ALE6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxewyn/dJMcadhnH4p/lKNa6NXNNNcuMJI25ALE6K/img.png&quot; data-alt=&quot;60장 기준 dom seek 이용 시 콘솔 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxewyn/dJMcadhnH4p/lKNa6NXNNNcuMJI25ALE6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbxewyn%2FdJMcadhnH4p%2FlKNa6NXNNNcuMJI25ALE6K%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;1405&quot; height=&quot;144&quot; data-origin-width=&quot;1405&quot; data-origin-height=&quot;144&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;60장 기준 dom seek 이용 시 콘솔 측정 결과&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-origin-width=&quot;2408&quot; data-origin-height=&quot;1266&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2Azu0/dJMcadBBAMe/laUfFzkYKkOkXTutiMf9W1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2Azu0/dJMcadBBAMe/laUfFzkYKkOkXTutiMf9W1/img.png&quot; data-alt=&quot;60장 기준 dom seek 이용 시 성능 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2Azu0/dJMcadBBAMe/laUfFzkYKkOkXTutiMf9W1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2Azu0%2FdJMcadBBAMe%2FlaUfFzkYKkOkXTutiMf9W1%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;2408&quot; height=&quot;1266&quot; data-origin-width=&quot;2408&quot; data-origin-height=&quot;1266&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;60장 기준 dom seek 이용 시 성능 측정 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 측정 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조건을 맞춘 뒤 다시 측정했더니, Web&amp;nbsp;Worker의 성격이 꽤 선명하게 드러났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구간 seek Web&amp;nbsp;Worker 해석&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10장&lt;/td&gt;
&lt;td&gt;4246.2ms&lt;/td&gt;
&lt;td&gt;6381.2ms&lt;/td&gt;
&lt;td&gt;소량에서는 Web&amp;nbsp;Worker 오버헤드가 커서 오히려 불리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60장&lt;/td&gt;
&lt;td&gt;13394.1ms&lt;/td&gt;
&lt;td&gt;7028.9ms&lt;/td&gt;
&lt;td&gt;고처리량에서는 Web&amp;nbsp;Worker가 유리 (약 47.5% 개선)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-1. 10장 비교&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;seek: 4246.2ms&lt;/li&gt;
&lt;li&gt;Web&amp;nbsp;Worker: &lt;b&gt;6381.2ms&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Web Worker 초기화 비용과 메시지 직렬화 비용이 10장을 뽑는 작업량보다 크게 작용했다. 작은 작업을 Web&amp;nbsp;Worker로 옮기면 오히려 손해가 된다는 걸 숫자로 확인한 구간이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선 후 10장&lt;/p&gt;
&lt;/blockquote&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-origin-width=&quot;692&quot; data-origin-height=&quot;172&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eAV4aj/dJMcacW347x/3r6QNpskKY4kVRpJLsMUn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eAV4aj/dJMcacW347x/3r6QNpskKY4kVRpJLsMUn0/img.png&quot; data-alt=&quot;10장 기준 Web Worker 이용 시 콘솔 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eAV4aj/dJMcacW347x/3r6QNpskKY4kVRpJLsMUn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeAV4aj%2FdJMcacW347x%2F3r6QNpskKY4kVRpJLsMUn0%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;692&quot; height=&quot;172&quot; data-origin-width=&quot;692&quot; data-origin-height=&quot;172&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;10장 기준 Web Worker 이용 시 콘솔 측정 결과&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-origin-width=&quot;1880&quot; data-origin-height=&quot;1266&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ySPOM/dJMcacW347M/j5yRCf1Uerf9jlXWqPxc10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ySPOM/dJMcacW347M/j5yRCf1Uerf9jlXWqPxc10/img.png&quot; data-alt=&quot;10장 기준 Web Worker 이용 시 성능 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ySPOM/dJMcacW347M/j5yRCf1Uerf9jlXWqPxc10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FySPOM%2FdJMcacW347M%2Fj5yRCf1Uerf9jlXWqPxc10%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;1880&quot; height=&quot;1266&quot; data-origin-width=&quot;1880&quot; data-origin-height=&quot;1266&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;10장 기준 Web Worker 이용 시 성능 측정 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-2. 60장 비교&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;seek: 13394.1ms&lt;/li&gt;
&lt;li&gt;Web&amp;nbsp;Worker: &lt;b&gt;7028.9ms&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;60장 구간에서는 결과가 뒤집혔다. 약 47.5% 개선, 대략 1.9배 수준이었다. 메인 스레드가 해야 할 일을 Web&amp;nbsp;Worker가 대신 처리하는 구조이므로, 작업량이 많을수록 이득이 커지는 것은 자연스러운 결과였다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선 후 60장&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1449&quot; data-origin-height=&quot;132&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biyBIl/dJMcajhyIuJ/5GLcCk54iwgx3KbzRklLC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biyBIl/dJMcajhyIuJ/5GLcCk54iwgx3KbzRklLC0/img.png&quot; data-alt=&quot;60장 기준 Web Worker 이용 시 콘솔 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biyBIl/dJMcajhyIuJ/5GLcCk54iwgx3KbzRklLC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiyBIl%2FdJMcajhyIuJ%2F5GLcCk54iwgx3KbzRklLC0%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;1449&quot; height=&quot;132&quot; data-origin-width=&quot;1449&quot; data-origin-height=&quot;132&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;60장 기준 Web Worker 이용 시 콘솔 측정 결과&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-origin-width=&quot;2407&quot; data-origin-height=&quot;1271&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NeEfn/dJMcadO931z/9AjAKkcKSERZgj0X2yKra1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NeEfn/dJMcadO931z/9AjAKkcKSERZgj0X2yKra1/img.png&quot; data-alt=&quot;60장 기준 Web Worker 이용 시 성능 측정 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NeEfn/dJMcadO931z/9AjAKkcKSERZgj0X2yKra1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNeEfn%2FdJMcadO931z%2F9AjAKkcKSERZgj0X2yKra1%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;2407&quot; height=&quot;1271&quot; data-origin-width=&quot;2407&quot; data-origin-height=&quot;1271&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;60장 기준 Web Worker 이용 시 성능 측정 결과&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;정리하면, Web&amp;nbsp;Worker&lt;b&gt;는 만능 해법이 아니라 처리량이 커질수록 이득이 커지는 전략&lt;/b&gt;이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 하이브리드 전략 실험&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. 체감 지표 재정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 번 멈춰 생각하게 되었다. 60장을 7초에 끝내든 13초에 끝내든, 사용자가 먼저 체감하는 것은 &quot;내 영상이 타임라인에 보이기 시작한 순간&quot;이다. 총 시간이 아무리 짧아도 첫 화면이 6초 동안 텅 비어 있다면 체감은 느리다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 편집기 관점에서 핵심 지표는 두 가지였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫 화면이 언제 보이는가&lt;/li&gt;
&lt;li&gt;전체 작업이 언제 끝나는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 지표는 트레이드오프 관계에 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. 레퍼런스: veed.io&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점에서 &lt;a href=&quot;https://www.veed.io/&quot;&gt;veed.io&lt;/a&gt;의 업로드 UX를 참고했다. veed.io는 업로드 직후 타임라인이 &lt;b&gt;같은 썸네일 한 장으로 먼저 채워져 보이고&lt;/b&gt;, 이후 실제 프레임이 준비되는 순서대로 한 장씩 교체된다. 핵심은 &quot;완성을 기다리게 하는&quot; 대신 **&quot;레이아웃을 먼저 안정화하고, 내용물은 점진적으로 교체&quot;**하는 방식이었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레퍼런스 - veed.io는 초기에 동일 썸네일로 레이아웃을 먼저 안정화한 뒤, 실제 프레임으로 순차 교체한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;fileblock&quot; data-ke-align=&quot;alignCenter&quot;&gt;&lt;a href=&quot;https://blog.kakaocdn.net/dn/buGjsr/dJMcaadUonj/GzhU0y4bh3yCjt0st7koUK/?attach=1&amp;amp;knm=tfile.mp4&quot; class=&quot;&quot;&gt;
    &lt;div class=&quot;image&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;desc&quot;&gt;&lt;div class=&quot;filename&quot;&gt;&lt;span class=&quot;name&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;size&quot;&gt;다운로드&lt;/div&gt;
&lt;/div&gt;
  &lt;/a&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-3. 세 전략 측정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 UX를 반영하려면 Web Worker에만 전부 맡겨서는 부족했다. 첫 1장 정도는 seek로 먼저 깔고, 나머지를 Web&amp;nbsp;Worker에 맡기는 구조를 실험해봐야 했다. 그래서 전략을 세 가지로 나눠 측정했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;A: worker-first&lt;/b&gt;: 처음부터 끝까지 Web&amp;nbsp;Worker로 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;B: seek-first-1-then-worker&lt;/b&gt;: t=0 기준 1장을 seek로 먼저 생성 후, 이어서 Web&amp;nbsp;Worker로 전체 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;C: seek-first-10-then-worker&lt;/b&gt;: 앞 10장을 seek로 생성 후, 이어서 Web&amp;nbsp;Worker로 나머지 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 다음과 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전략 first-region generated 판단&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A: worker-first&lt;/td&gt;
&lt;td&gt;6262.2ms&lt;/td&gt;
&lt;td&gt;7213.3ms&lt;/td&gt;
&lt;td&gt;총 시간은 좋지만 첫 노출이 늦다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B: seek-first-1-then-worker&lt;/td&gt;
&lt;td&gt;&lt;b&gt;2815.8ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;7352.5ms&lt;/td&gt;
&lt;td&gt;첫 노출 크게 개선 + 총 시간 손해 최소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C: seek-first-10-then-worker&lt;/td&gt;
&lt;td&gt;2513.1ms&lt;/td&gt;
&lt;td&gt;9193.4ms&lt;/td&gt;
&lt;td&gt;첫 노출은 가장 빠르지만 총 시간 손해 큼&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A는 총 시간은 깔끔했지만 첫 화면이 6초 넘게 비어 있었다. C는 첫 화면이 가장 빨랐지만, seek 10장을 선행하는 동안 Web&amp;nbsp;Worker 작업이 밀리면서 전체 시간 손해가 컸다. B는 첫 화면 속도가 절반 이상 당겨지면서, 총 시간 손해는 A 대비 약 1~2% 수준이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 기본 전략을 &lt;b&gt;B (seek-first-1-then-worker)&lt;/b&gt; 로 정했다. 이 선택의 기준은 단일 지표가 아니었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫 화면 안정화 속도 (first-region)&lt;/li&gt;
&lt;li&gt;전체 생성 완료 시간 (generated)&lt;/li&gt;
&lt;li&gt;실패 시 폴백 안정성 (Web&amp;nbsp;Worker &amp;rarr; seek &amp;rarr; FFmpeg)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 기준을 동시에 만족하는 균형점이 B였다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Web&amp;nbsp;Worker&amp;nbsp;만 이용&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;305&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6Sw1A/dJMcaaZfPnj/OlOzFF31vT9JZmFPVEMZjK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6Sw1A/dJMcaaZfPnj/OlOzFF31vT9JZmFPVEMZjK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6Sw1A/dJMcaaZfPnj/OlOzFF31vT9JZmFPVEMZjK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6Sw1A%2FdJMcaaZfPnj%2FOlOzFF31vT9JZmFPVEMZjK%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;673&quot; height=&quot;305&quot; data-origin-width=&quot;673&quot; data-origin-height=&quot;305&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;657&quot; data-origin-height=&quot;176&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2BwK1/dJMcahqzgOH/wlELV5mokkTD3MGN9aIBbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2BwK1/dJMcahqzgOH/wlELV5mokkTD3MGN9aIBbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2BwK1/dJMcahqzgOH/wlELV5mokkTD3MGN9aIBbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2BwK1%2FdJMcahqzgOH%2FwlELV5mokkTD3MGN9aIBbk%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;657&quot; height=&quot;176&quot; data-origin-width=&quot;657&quot; data-origin-height=&quot;176&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;첫 1장만 DOM seek&amp;nbsp;&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;644&quot; data-origin-height=&quot;307&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pSCZ8/dJMcacW39NV/hkwMNS6P8EwCLPkFGnrxs1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pSCZ8/dJMcacW39NV/hkwMNS6P8EwCLPkFGnrxs1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pSCZ8/dJMcacW39NV/hkwMNS6P8EwCLPkFGnrxs1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpSCZ8%2FdJMcacW39NV%2FhkwMNS6P8EwCLPkFGnrxs1%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;644&quot; height=&quot;307&quot; data-origin-width=&quot;644&quot; data-origin-height=&quot;307&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;643&quot; data-origin-height=&quot;180&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEW4fj/dJMcafffctL/tyoKVGRbh9L9h6IVG2eXk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEW4fj/dJMcafffctL/tyoKVGRbh9L9h6IVG2eXk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEW4fj/dJMcafffctL/tyoKVGRbh9L9h6IVG2eXk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEW4fj%2FdJMcafffctL%2FtyoKVGRbh9L9h6IVG2eXk0%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;643&quot; height=&quot;180&quot; data-origin-width=&quot;643&quot; data-origin-height=&quot;180&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;첫 10장만 DOM seek&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;638&quot; data-origin-height=&quot;307&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F1kTg/dJMcaaSwdun/GgWrBjZTakaroS02rM6KfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F1kTg/dJMcaaSwdun/GgWrBjZTakaroS02rM6KfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F1kTg/dJMcaaSwdun/GgWrBjZTakaroS02rM6KfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF1kTg%2FdJMcaaSwdun%2FGgWrBjZTakaroS02rM6KfK%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;638&quot; height=&quot;307&quot; data-origin-width=&quot;638&quot; data-origin-height=&quot;307&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;636&quot; data-origin-height=&quot;181&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Lu0HT/dJMcahYnPtT/nbqBtt6ypceL75wP0FWal1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Lu0HT/dJMcahYnPtT/nbqBtt6ypceL75wP0FWal1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Lu0HT/dJMcahYnPtT/nbqBtt6ypceL75wP0FWal1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLu0HT%2FdJMcahYnPtT%2FnbqBtt6ypceL75wP0FWal1%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;636&quot; height=&quot;181&quot; data-origin-width=&quot;636&quot; data-origin-height=&quot;181&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 구현 원칙&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능 경로를 추가할 때는 빠른 경로 자체보다 &lt;b&gt;실패 시 복구 경로를 함께 설계하는 것&lt;/b&gt;이 중요했다. 코덱과 브라우저 편차를 고려하면, 최고 성능보다 안정적인 완료 경로가 먼저다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-1. 폴백 체인&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;Web Worker 성공 &amp;rarr; 끝
Web Worker 결과가 비거나 실패 &amp;rarr; seek로 전체 복구
seek도 실패 &amp;rarr; FFmpeg로 폴백&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;썸네일이 한 장도 뜨지 않는 것보다, 느리더라도 전부 뜨는 쪽이 낫다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-2. 메시지 필터링&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에디터에서는 임포트/취소/재시작이 자주 반복된다. 여러 요청이 Web&amp;nbsp;Worker에서 동시에 돌 수 있고, 이때 이전 요청의 progress 이벤트가 새 요청에 섞여 들어오면 썸네일이 깨진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 sourceId 기준으로 progress/complete 이벤트를 필터링했다. 단순한 장치지만 이게 없으면 상태 추적이 금방 어려워진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-3. 취소 루틴 일관화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;취소는 기능이 아니라 기본 동작으로 취급했다. 네 단계를 항상 함께 실행해서 누수를 막았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;fetch 취소 (AbortController)&lt;/li&gt;
&lt;li&gt;Web&amp;nbsp;Worker에 abort 메시지 전송&lt;/li&gt;
&lt;li&gt;message listener 제거&lt;/li&gt;
&lt;li&gt;pending promise 종료&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 경로라도 정리가 빠지면 다음 요청에서 이벤트/메모리 누수로 이어진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 구현 상세&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원칙만으로는 감이 잡히지 않기 때문에, 실제 런타임에서 어떤 순서로 동작하는지를 기준으로 정리했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-1. 전략 선택 진입점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;썸네일 생성의 진입점은 useThumbnailGenerator다. 여기서 전략을 선택한 뒤, 결과를 thumbnailCache에 점진적으로 반영한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;worker-first: 처음부터 Web&amp;nbsp;Worker에 전체 생성 요청&lt;/li&gt;
&lt;li&gt;seek-first-1-then-worker: t=0 기준 1장을 seek로 생성 후 Web&amp;nbsp;Worker로 이어서 생성&lt;/li&gt;
&lt;li&gt;공통 규칙: Web Worker 결과가 비거나 실패하면 seek 전체 경로로 복구, seek도 실패하면 FFmpeg로 폴백&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 &lt;b&gt;전략 선택 &amp;rarr; 진행 중 점진 반영 &amp;rarr; 실패 시 단계별 복구&lt;/b&gt;를 한 함수에서 오케스트레이션한다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;if (strategy === 'seek-first-1-then-worker') {
    await generateInitialThumbnailsViaSeek(
        sourceId, objectUrl, 1, abortRef, fallbackDurationSec, markFirstVisible,
    );
}

try {
    result = await generateThumbnailsInWorker({
        sourceId, objectUrl, targetThumbnailCount,
    });
} catch {
    result = await generateFixedCountViaSeek(
        sourceId, objectUrl, abortRef, fallbackDurationSec, markFirstVisible,
    );
}

if (result.length === 0 &amp;amp;&amp;amp; file &amp;amp;&amp;amp; isFFmpegAvailable()) {
    result = await generateFixedCountViaFFmpeg(
        sourceId, objectUrl, file, abortRef, fallbackDurationSec, markFirstVisible,
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-2. 메시지 프로토콜&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 스레드와 Web&amp;nbsp;Worker 사이 메시지는 단순하지만, 동시 요청 충돌을 막는 장치가 핵심이었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청 메시지: generate, abort&lt;/li&gt;
&lt;li&gt;응답 메시지: progress, complete, error&lt;/li&gt;
&lt;li&gt;식별 키: sourceId&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sourceId를 기준으로 수신 이벤트를 필터링해서, 이전 요청의 progress/complete가 현재 요청에 섞이지 않도록 했다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;// main &amp;rarr; Web Worker
w.postMessage(
    { type: 'generate', sourceId, videoBuffer, targetThumbnailCount },
    [videoBuffer],
);
w.postMessage({ type: 'abort', sourceId });

// thumbnailWorkerClient
const onMessage = (event: MessageEvent&amp;lt;ThumbnailWorkerResponse&amp;gt;) =&amp;gt; {
    const msg = event.data;
    if (msg.sourceId !== sourceId) return;
    // progress / complete / error 처리
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-3. Web&amp;nbsp;Worker 내부 파이프라인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Web Worker 내부에서는 대략 아래 순서로 처리한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;비디오 버퍼 입력&lt;/li&gt;
&lt;li&gt;demux / 디코딩&lt;/li&gt;
&lt;li&gt;목표 시점 프레임 추출&lt;/li&gt;
&lt;li&gt;ImageBitmap 생성&lt;/li&gt;
&lt;li&gt;progress 이벤트로 메인 스레드에 전달&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ImageBitmap 인터페이스는 캔버스에 지연 없이 그릴 수 있는 비트맵 이미지를 나타낸다. 낮은 지연의 렌더링을 제공하기 위해 비동기적으로 생성될 수 있다. 출처: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/ImageBitmap&quot;&gt;MDN - ImageBitmap&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전달 비용을 줄이기 위해 가능한 한 Transferable 경로를 사용했다. 또한 모든 프레임을 한 번에 넘기지 않고 중간 결과를 쪼개서 보내, UI가 &quot;완료까지 대기&quot;하지 않도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로, coarse 먼저 + fine으로 채우는 순서로 시점을 뽑는다. 타임라인 전체에 굵직한 간격으로 대표 프레임을 먼저 뿌려놓고, 그 사이 빈 구간을 이후에 채운다. 사용자는 타임라인이 골고루 채워지는 느낌을 먼저 받게 된다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;const coarseInterval = interval * 4;
const coarseTimestamps: number[] = [];
for (let t = 0; t &amp;lt; duration; t += coarseInterval) coarseTimestamps.push(t);

const fineTimestamps: number[] = [];
for (let t = 0; t &amp;lt; duration; t += interval) {
    if (!coarseTimestamps.some((ct) =&amp;gt; Math.abs(ct - t) &amp;lt; interval * 0.5)) {
        fineTimestamps.push(t);
    }
}

self.postMessage(
    { type: 'progress', sourceId, bitmaps: [bitmap], times: [timeSec] },
    [bitmap],
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-4. 취소와 정리&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;const abort = () =&amp;gt; {
    abortController.abort();
    worker.postMessage({ type: 'abort', sourceId });
    worker.removeEventListener('message', onMessage);
    rejectPending(new Error('thumbnail generation aborted'));
};

// worker
if (msg.type === 'abort') {
    abortedSourceIds.add(msg.sourceId);
    return;
}
&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;Web&amp;nbsp;Worker는 abort 메시지를 받으면 해당 sourceId를 무시 목록에 넣고, 이후 progress를 내보내지 않는다. 메인에서는 listener 제거 + pending reject까지 함께 정리해, 중간 취소가 잦은 편집 흐름에서도 상태가 꼬이지 않게 했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-5. 점진 반영 UX&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 UX는 &quot;한 번에 완성&quot;이 아니라 **&quot;빠른 첫 노출 + 순차 치환&quot;**에 맞췄다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫 1장은 seek로 빠르게 채워 레이아웃을 먼저 안정화&lt;/li&gt;
&lt;li&gt;이후 Web Worker progress마다 캐시를 갱신하며 실제 프레임으로 교체&lt;/li&gt;
&lt;li&gt;사용자는 빈 타임라인을 오래 보지 않고, 점점 정밀해지는 결과를 본다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식 덕분에 first-region 지표를 크게 줄이면서도, 전체 생성 시간은 worker-first의 이점을 거의 그대로 유지할 수 있었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 전체 구조&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1196&quot; data-origin-height=&quot;1315&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cObc6z/dJMcabKGYwr/89CBOCMF8X4Snu2Ipx7260/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cObc6z/dJMcabKGYwr/89CBOCMF8X4Snu2Ipx7260/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cObc6z/dJMcabKGYwr/89CBOCMF8X4Snu2Ipx7260/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcObc6z%2FdJMcabKGYwr%2F89CBOCMF8X4Snu2Ipx7260%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;755&quot; height=&quot;830&quot; data-origin-width=&quot;1196&quot; data-origin-height=&quot;1315&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요약하면, &lt;b&gt;초기 노출 속도는 seek로 확보하고 전체 처리량은 Web&amp;nbsp;Worker로 개선하는 구조&lt;/b&gt;다. 어느 한쪽이 실패해도 아래 단계가 받쳐준다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지표 Before (seek 60장) After (seek-first-1-then-worker)&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;first-region 노출&lt;/td&gt;
&lt;td&gt;상대적으로 지연&lt;/td&gt;
&lt;td&gt;&lt;b&gt;2815.8ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;generated 총 시간&lt;/td&gt;
&lt;td&gt;13394.1ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;7352.5ms&lt;/b&gt; (약 45% 단축)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패 복구 경로&lt;/td&gt;
&lt;td&gt;seek 단일 경로&lt;/td&gt;
&lt;td&gt;Worker &amp;rarr; seek &amp;rarr; FFmpeg 3단 폴백&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동시 요청 안전성&lt;/td&gt;
&lt;td&gt;미보장&lt;/td&gt;
&lt;td&gt;sourceId 기반 필터링&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 화면 노출 시점이 당겨지면서 &quot;영상을 올렸는데 타임라인이 비어 있는 시간&quot;이 체감상 크게 줄었고, 총 생성 시간도 절반 가까이 단축됐다. 동시에 Web&amp;nbsp;Worker가 실패해도 seek, FFmpeg로 이어지는 폴백 경로를 갖추게 됐다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실험에서 가장 크게 배운 것은 단일 해법을 찾는 대신, &lt;b&gt;트레이드오프를 수치로 비교하고 제품 맥락에 맞는 균형점을 선택하는 과정&lt;/b&gt;이었다. Web&amp;nbsp;Worker냐 seek냐의 문제가 아니라, &quot;이 에디터에서 사용자가 무엇을 먼저 체감해야 하는가&quot;라는 질문에 가까웠다.&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;추가적으로, 무거운 작업이 많은 웹 에디터 특성상, Web&amp;nbsp;Worker로 메인 스레드 점유를 낮추면 썸네일 생성 속도뿐 아니라 에디터 전체 상호작용의 체감 성능까지 함께 개선된다는 점이 인상적이었다.&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 단계에서는 MOV를 포함한 코덱별 실패 패턴을 더 모아, Web Worker를 언제 조기 중단하고 seek로 전환할지 기준을 자동화할 계획이다. 지금은 Web&amp;nbsp;Worker가 실패한 다음에야 seek로 내려가는데, &quot;성공하지 못할 것 같다&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;또한 이번 작업을 계기로, 앞으로는 프로젝트 전반에서 &lt;b&gt;Web Worker로 옮길 수 있는 작업들을 하나씩 더 찾아볼 생각이다.&lt;/b&gt; 단순히 처리 시간을 줄이는 것보다, 메인 스레드를 오래 붙잡는 구간을 줄여 작업 중 UI가 끊기는 느낌을 덜고, 전체 편집 반응성을 더 매끄럽고 안정적으로 만드는 방향에 집중해보려 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;브라우저 API&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #212529; text-align: start;&quot;&gt;Web Worker를 사용한 이미지 로딩 :&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://blog.rhostem.com/posts/2021-01-03-image-load-by-web-worker&quot;&gt;https://blog.rhostem.com/posts/2021-01-03-image-load-by-web-worker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #212529; text-align: start;&quot;&gt;Web&amp;nbsp;Worker: &lt;/span&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/Worker&quot;&gt;https://developer.mozilla.org/ko/docs/Web/API/Worker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #212529; text-align: start;&quot;&gt;Using web worker :&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API/Using_web_workers&quot;&gt;https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API/Using_web_workers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Web Workers API: &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API&quot;&gt;https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;ImageBitmap: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/ImageBitmap&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/API/ImageBitmap&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Transferable objects: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;AbortController: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/AbortController&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/API/AbortController&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;레퍼런스 서비스&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;veed.io: &lt;a href=&quot;https://www.veed.io&quot;&gt;https://www.veed.io&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;썸네일 최적화 시도 시리즈는 총 3편으로, 잘못된 판단으로 삽질을 하고, 스스로에게 질문하고 수정해나가는 과정을 담았습니다. 다음 글들은 아래 링크에서 보실 수 있습니다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;[썸네일 생성 최적화 시도 2]썸네일 생성 구조 재설계하기:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://insight74278.tistory.com/49&quot;&gt;https://insight74278.tistory.com/49&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;[썸네일 생성 최적화 시도 3] Web Worker를 다시 의심하고 검증하기:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://insight74278.tistory.com/51&quot;&gt;https://insight74278.tistory.com/51&lt;/a&gt;&lt;/p&gt;</description>
      <category>개발로그</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/42</guid>
      <comments>https://insight74278.tistory.com/42#entry42comment</comments>
      <pubDate>Fri, 17 Apr 2026 14:26:22 +0900</pubDate>
    </item>
    <item>
      <title>타임라인 Region 만들기 - Canvas 단일 레이어의 한계와 SVG 분리</title>
      <link>https://insight74278.tistory.com/40</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타임라인을 만들면서 예상치 못 한 버그가 발생했던 이야기를 공유하려고 한다. 줌 레벨을 올리면 리전이 &lt;b&gt;통째로 사라지는 버그&lt;/b&gt;. 특정 줌 이상에서는 캔버스 자체가 아예 렌더되지 않는다. 처음엔 렌더링 로직 어딘가에 조건이 잘못 들어간 줄 알았다. 하지만 원인은 canvas 에 있었다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;리전(Region)이란, 특정 미디어 소스(오디오, 비디오, 이미지 등)의 전체 구간 중 편집 타임라인 상에 배치되어 실제 재생 및 편집 대상이 되는 특정 시간 구간(클립)을 의미한다.&lt;/blockquote&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;lt;canvas&amp;gt; 요소에는 상한값이 존재한다. Chrome 기준 최대 약 16,384px로, 타임라인 전체 너비를 duration * effectivePxPerSec으로 계산해서 캔버스로 그리고 있는데, 이 값이 한계를 넘어서는 순간 캔버스가 생성 자체에 실패하거나 빈 화면을 반환하게 된 것이었다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;canvas.width = totalTimelineWidth * dpr
// &amp;rarr; 줌 시 totalTimelineWidth &amp;gt; 16,384 초과
// &amp;rarr; 캔버스 생성 실패 &amp;rarr; 파형도, 리전도 전부 사라짐
&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;h2 data-ke-size=&quot;size26&quot;&gt;2. 첫 번째 시도 - 캔버스 가상 스크롤&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;가설은 단순했다. 캔버스 물리 크기를 뷰포트 + 버퍼 영역으로 제한하고, 스크롤 시 위치만 이동하면 크기 상한을 피할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;renderStartPx = max(0, scrollLeft - BUFFER_PX)
renderEndPx   = scrollLeft + viewportWidth + BUFFER_PX
renderWidth   = renderEndPx - renderStartPx&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;캔버스 width를 renderWidth * dpr로 고정하고, style.left = renderStartPx로 위치를 옮기면서 스크롤마다 보이는 구간만 다시 그린다. 코드를 짜보니 개념은 깔끔했다. 하지만 막상 실행해보니 문제가 세 가지 터졌다.&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;타이밍 어긋남&lt;/b&gt;: scrollLeft와 캔버스 재그리기 타이밍이 1프레임 어긋나면 리전이 깜빡이거나 위치가 틀어졌다. scrollLeft를 읽고 &amp;rarr; 캔버스 위치를 옮기고 &amp;rarr; 새 구간을 그리는 세 단계가 한 프레임 안에 처리되지 않으면, 브라우저가 중간 상태를 화면에 나타낸다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;경계 계산 오차&lt;/b&gt;: renderStartPx / renderWidth 경계 계산에 조금만 오차가 생겨도 타임라인 끝 리전이 잘렸다. 버퍼를 얼마나 잡아야 하는지 명확한 기준도 없었다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;클릭 테스 좌표 오류&lt;/b&gt;: 사용자가 리전을 클릭하면 offsetLeft를 기반으로 캔버스 로컬 좌표를 계산했는데, 가상 스크롤로 offsetLeft가 renderStartPx에 따라 계속 바뀌다 보니 클릭 위치가 스크롤 위치에 비례해서 어긋났다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 캔버스 크기 문제는 해결했지만 scrollLeft &amp;middot; offsetLeft &amp;middot; 재그리기 타이밍이 한 묶음으로 강결합되면서, 정합 포인트가 너무 많아졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 파형과 리전은 성격 자체가 다르다&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;여기서 한 발 물러서서 생각했다. 왜 이렇게 정합 포인트가 많을까?&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&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;b&gt;벡터 그래픽스(vector graphics)&lt;/b&gt;요소들이다. 이걸 캔버스에 직접 그리고 있으니, 스크롤할 때마다 위치를 수동으로 계산해야 하고, 클릭 이벤트도 좌표 변환을 거쳐야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;벡터&amp;nbsp;그래픽스(vector&amp;nbsp;graphics)는&amp;nbsp;점,&amp;nbsp;선,&amp;nbsp;곡선,&amp;nbsp;다각형과&amp;nbsp;같은&amp;nbsp;직교&amp;nbsp;좌표계에&amp;nbsp;정의된&amp;nbsp;기하학적&amp;nbsp;기본&amp;nbsp;요소(영어판)로부터&amp;nbsp;시각적&amp;nbsp;이미지를&amp;nbsp;직접&amp;nbsp;생성하는&amp;nbsp;컴퓨터&amp;nbsp;그래픽스의&amp;nbsp;한&amp;nbsp;형태다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처: &lt;a href=&quot;https://ko.wikipedia.org/wiki/%EB%B2%A1%ED%84%B0_%EA%B7%B8%EB%9E%98%ED%94%BD%EC%8A%A4&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ko.wikipedia.org/wiki/%EB%B2%A1%ED%84%B0_%EA%B7%B8%EB%9E%98%ED%94%BD%EC%8A%A4&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;&amp;bull; &lt;span data-token-index=&quot;1&quot;&gt;&lt;b&gt;리전을 DOM/SVG로 분리하면 브라우저가 보이는 구간만 렌더링해 보여주기 때문에&lt;/b&gt;, 별도의 가시 영역 처리 로직이 줄어든다.&lt;/span&gt; &amp;nbsp;scrollLeft 정합 문제가 애초에 생기지 않는다. 렌더링 특성에 맞는 기술을 분리하는 것이 중요했다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;파형/썸네일/프리뷰  &amp;rarr;  Canvas (래스터, 고빈도 재렌더)
리전/핸들/라벨     &amp;rarr;  SVG/DOM (벡터 UI, 상호작용)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;3-1. 왜 순수 DOM이 아니라 SVG인가?&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 작업의 핵심 선택은 사실 여기였다. 리전은 시간축 좌표(x, width)를 기준으로 배치되는 사각형+핸들+라벨 묶음인데, 이 구조는 &lt;b&gt;SVG의 rect/text 모델과 거의 1:1로 대응된다.&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;순수 DOM(div)으로도 구현은 가능하다. 하지만 리전 수가 늘어나면 left/width 절대 배치를 계속 갱신해야 하고, 몸체&amp;middot;라벨&amp;middot;선택 테두리&amp;middot;핸들을 여러 요소와 스타일로 분리 관리해야 한다. 클릭 영역을 넓히기 위한 투명 상호작용 레이어까지 얹기 시작하면 구조가 금방 복잡해진다. 반면 SVG는 같은 좌표계 안에서 사각형, 텍스트, 핸들을 한 묶음으로 다루기 쉬워서 코드의 의도와 렌더 결과가 더 직접적으로 연결된다.&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;실무적으로도 이점이 있었다. 루트 SVG는 이벤트를 통과시키고(pointer-events: none), 리전과 핸들만 이벤트를 받게(pointer-events: auto) 설계할 수 있어 빈 영역 클릭 시 시크 동작을 유지하기 쉬웠다. 즉, SVG는 벡터 표현뿐 아니라 상호작용 경계 설계에도 유리했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-2. 왜 파형은 PNG/SVG가 아니라 Canvas인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파형은 리전과 성격이 다르다. 파형은 줌(px/sec)과 스크롤에 따라 화면 밀도가 계속 바뀌는 동적 &lt;span data-token-index=&quot;0&quot;&gt;래스터 데이터(Raster data)&lt;/span&gt; 다. 이걸 PNG로 고정하면 확대 시 블러가 생기고, 품질을 지키려면 다중 해상도 타일 생성/교체 로직이 필요해진다. SVG로 그리면 이론상 확대 품질은 좋지만, 긴 타임라인에서 &lt;b&gt;샘플 기반 경로를 대량으로 유지&amp;middot;갱신할 때 DOM/SVG 노드 관리 비용이 커진다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;래스터 그래픽스(Raster Graphics): &lt;a href=&quot;https://en.wikipedia.org/wiki/Computer_graphics&quot;&gt;컴퓨터 그래픽&lt;/a&gt;&amp;nbsp;과&amp;nbsp;&lt;a href=&quot;https://en.wikipedia.org/wiki/Digital_photography&quot;&gt;디지털 사진&lt;/a&gt;&amp;nbsp;에서&amp;nbsp;래스터&amp;nbsp;그래픽&amp;nbsp;,&amp;nbsp;래스터 이미지&amp;nbsp;또는 간단히&amp;nbsp;래스터는&amp;nbsp;&lt;a href=&quot;https://en.wikipedia.org/wiki/Pixel&quot;&gt;픽셀&lt;/a&gt;&amp;nbsp;이라고 하는 작고 색상이 있는 (일반적으로 정사각형인) 직사각형 격자로 구성된&amp;nbsp;&lt;a href=&quot;https://en.wikipedia.org/wiki/Digital_image&quot;&gt;디지털 이미지&lt;/a&gt;&amp;nbsp;입니다&amp;nbsp;. 모양과 선을 설명하기 위해 수학적 공식을 사용하는&amp;nbsp;&lt;a href=&quot;https://en.wikipedia.org/wiki/Vector_graphics&quot;&gt;벡터 그래픽&lt;/a&gt; 과 달리 래스터 이미지는 각 픽셀의 정확한 색상을 저장하므로 복잡한 색상과 세부 사항이 있는 사진 및 이미지에 이상적입니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처: &lt;a href=&quot;https://en.wikipedia.org/wiki/Raster_graphics&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://en.wikipedia.org/wiki/Raster_graphics&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;Canvas는 현재 뷰포트 구간을 즉시 다시 그리기에 유리하다. 특히 렌더 트리거를 `requestAnimationFrame`으로 묶으면 스크롤/리전 변경 이벤트가 연속으로 들어와도 브라우저 페인트 주기에 맞춰 한 프레임 단위로 합쳐 처리할 수 있어, 불필요한 중복 렌더와 프레임 드랍을 줄일 수 있다. 가상 스크롤과 결합하면 전체 타임라인이 아니라 보이는 구간+버퍼만 렌더링할 수 있어 파형처럼 고빈도 재렌더가 필요한 데이터에 잘 맞는다.&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;middot;썸네일은 Canvas, 리전&amp;middot;핸들&amp;middot;라벨은 SVG로 역할을 분리했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. SVG 기반의 리전 구현하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. TrackRegionSvgLayer 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TrackRegionSvgLayer 컴포넌트를 새로 만들고, 캔버스에서 리전 그리기 코드를 전부 제거했다. TrackRow 컴포넌트에서 audio-like(오디오, 스피커 트랙)와 media-like(비디오, 이미지, 텍스트) 두 variant로 분기한다. audio-like는 solid fill + 라벨 + 선택 테두리, 눈에 보이지 않는 상호작용 영역과 리사이즈 핸들만 그린다. (썸네일과 프리뷰는 캔버스 유지.)&lt;/p&gt;
&lt;pre id=&quot;code_1776235978807&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// TrackRow.tsx
const regionSvgVariant =
  track.type === 'video' || track.type === 'image' || track.type === 'text'
    ? 'media-like'
    : 'audio-like';&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;한 가지 신경 쓴 부분이 있다. 루트 &amp;lt;svg&amp;gt;에 pointer-events: none을 주고, 리전 &amp;lt;rect&amp;gt;와 핸들에만 pointer-events: auto를 설정했다. SVG 레이어가 캔버스 위를 덮고 있기 때문에 아무 설정 없이 두면 SVG가 빈 영역 클릭까지 모두 가로채게 된다. 그러면 사용자가 빈 타임라인 영역을 눌러 재생 위치를 옮기려는 동작(시크)이 아래 캔버스에 전달되지 않는다. 이를 막기 위해 루트 `&amp;lt;svg&amp;gt;`에는 `pointer-events: none`을 적용해 기본적으로 이벤트를 통과시키고, 실제로 상호작용이 필요한 리전 `&amp;lt;rect&amp;gt;`와 리사이즈 핸들에만 `pointer-events: auto`를 적용했다. 결과적으로 리전/핸들 클릭은 SVG가 처리하고, 빈 영역 클릭은 캔버스로 전달되어 기존 시크 동작이 유지된다. (타임라인 클릭이벤트 구현은 추후 따로 다루도록 하겠다.)&lt;/p&gt;
&lt;pre id=&quot;code_1776236132790&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// TrackRegionSvgLayer.tsx
&amp;lt;svg
  width={totalTimelineWidth}
  height={height}
  style={{
    position: 'absolute',
    left: 0,
    top: 0,
    pointerEvents: 'none', // 빈 영역 클릭은 아래 캔버스로 통과
    zIndex: 2,
  }}
&amp;gt;
  &amp;lt;rect
    x={startX}
    y={0}
    width={w}
    height={height}
    style={{ pointerEvents: 'auto', cursor: 'default' }} // 리전만 상호작용
  /&amp;gt;
&amp;lt;/svg&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. 좌표계 통일&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존엔 좌표 변환 경로가 두 개였다. 캔버스 로컬 좌표와 타임라인 절대 좌표. scrollLeft 기반의 가상 스크롤이 들어가면서 캔버스 offsetLeft가 계속 변했기 때문에, 클릭 위치를 실제 타임라인 위치로 변환하는 계산이 복잡해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useTrackMouseHandlers에 timelineContentRef를 넘기고, 좌표기준 timelineXFromClient로 통일했다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;timelineXFromClient(clientX) = clientX - timelineContentRef.getBoundingClientRect().left&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;이 방식은 &amp;ldquo;브라우저 화면 좌표(clientX)&amp;rdquo;에서 &amp;ldquo;타임라인 컨테이너의 화면상 시작점(left)&amp;rdquo;을 빼서 타임라인 내부 좌표를 직접 구한다. 즉, 캔버스가 가상 스크롤로 이동하더라도 기준점이 흔들리지 않기 때문에, 스크롤 위치와 무관하게 일관된 좌표를 얻을 수 있다.&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;또한 이 통일은 SVG 레이어 분리 이후 더 중요해졌다. 리전 선택/리사이즈 이벤트가 SVG와 캔버스 양쪽에서 발생하더라도 동일한 좌표 변환 함수를 사용하므로, 입력 경로가 달라도 같은 타임라인 좌표로 수렴한다.&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;h3 data-ke-size=&quot;size23&quot;&gt;4-3. 캔버스 책임 축소&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useTrackCanvasDrawing에서 세그먼트 fillRect / strokeRect, 라벨 텍스트, drawSegmentHandles를 전부 제거했다. 416줄이었던 파일이 323줄로 줄었다. 캔버스는 이제 파형 렌더링, 비디오 썸네일, 이미지/텍스트 프리뷰만 담당한다.&lt;/p&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;5. 가상 스크롤 재적용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리전을 SVG 기반으로 수정한 다음, 캔버스에는 파형과 썸네일만 남았다. 그리고 1차 시도에서 실패했던 캔버스 가상 스크롤을 이번엔 파형 레이어에 다시 적용했다.파형에&amp;nbsp;가상&amp;nbsp;스크롤을&amp;nbsp;적용하면,&amp;nbsp;스크롤&amp;middot;드래그마다&amp;nbsp;발생하는&amp;nbsp;drawCall&amp;nbsp;범위를&amp;nbsp;뷰포트로&amp;nbsp;제한할&amp;nbsp;수&amp;nbsp;있다.&amp;nbsp;10분짜리&amp;nbsp;타임라인&amp;nbsp;전체를&amp;nbsp;매&amp;nbsp;프레임&amp;nbsp;그리는&amp;nbsp;대신,&amp;nbsp;보이는&amp;nbsp;구간과&amp;nbsp;버퍼만&amp;nbsp;그리면&amp;nbsp;된다.&lt;br /&gt;&lt;br /&gt;1차에서 실패한 이유는 타이밍 어긋남이 리전 클릭 이벤를 망가뜨렸기 때문이었다. 그런데 지금은 리전이 SVG에 있다. 파형 캔버스가 1프레임 늦게 갱신되어도 리전 선택 정확도에는 영향이 없다. 정합 포인트가 없어졌으니, 1차 시도의 실패 원인이 함께 사라진 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776236992162&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// useTrackCanvasDrawing.ts
const isVirtualized = viewportWidth &amp;gt; 0 &amp;amp;&amp;amp; viewportWidth &amp;lt; totalTimelineWidth;
const bufferPx = isVirtualized ? viewportWidth * 0.5 : 0;

const renderStartPx = isVirtualized ? Math.max(0, scrollX - bufferPx) : 0;
const renderWidth = isVirtualized
  ? Math.min(viewportWidth + bufferPx * 2, totalTimelineWidth - renderStartPx)
  : totalTimelineWidth;

canvas.style.left = `${renderStartPx}px`;
canvas.style.width = `${renderWidth}px`;&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;SVG는 totalTimelineWidth 전체를 가지고 있어서 줌을 올려도 리전이 사라지지 않는다. 캔버스는 뷰포트 + 버퍼 크기로 고정되어 있어서 긴 타임라인에서도 drawCall이 일정하다. 두 문제가 동시에 해결됐다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;6. 결과&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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.907%;&quot;&gt;지표&lt;/td&gt;
&lt;td style=&quot;width: 36.1628%;&quot;&gt;Before&lt;/td&gt;
&lt;td style=&quot;width: 40.814%;&quot;&gt;After&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.907%;&quot;&gt;리전 가시성&lt;/td&gt;
&lt;td style=&quot;width: 36.1628%;&quot;&gt;줌 시 전체 미렌더&lt;/td&gt;
&lt;td style=&quot;width: 40.814%;&quot;&gt;항상 가시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.907%;&quot;&gt;클릭 이벤 좌표 오차&lt;/td&gt;
&lt;td style=&quot;width: 36.1628%;&quot;&gt;scrollLeft 비례 증가&lt;/td&gt;
&lt;td style=&quot;width: 40.814%;&quot;&gt;0 (스크롤 무관)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.907%;&quot;&gt;캔버스 코드량&lt;/td&gt;
&lt;td style=&quot;width: 36.1628%;&quot;&gt;416줄&lt;/td&gt;
&lt;td style=&quot;width: 40.814%;&quot;&gt;323줄 (-22%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.907%;&quot;&gt;좌표 변환 경로&lt;/td&gt;
&lt;td style=&quot;width: 36.1628%;&quot;&gt;2개&lt;/td&gt;
&lt;td style=&quot;width: 40.814%;&quot;&gt;1개&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩토링 전후를 간단히 확인했다. useTrackCanvasDrawing.ts에서 리전 fillRect, 핸들, 라벨 텍스트 드로우 코드를 전부 제거하니 416줄에서 323줄로 22% 줄었다. 남은 캔버스 코드는 파형 렌더링에만 집중한다. 좌표 변환 경로도 2개(캔버스 local + absolute)에서 1개(timelineContentRef)로 단일화됐다. 스크롤 후 리전 클릭 오차가 0이 됐고, 10분 타임라인 끝까지 스크롤해도 리전이 항상 제자리에 보였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타임라인 작업은 언제나 어렵다. 그 중 top 5 안에 드는 문제 중 하나가 리전 렌더링 문제다. 역시나 예상과 같이 한번에 해결되지는 않았다. 1차 시도는 캔버스 가상 스크롤로 크기 문제를 해결했지만, 리전 좌표 정합이라는 새 문제를 만들었다. 2차 시도에서 리전을 SVG로 분리하자 두 문제가 동시에 풀렸다. 그리고 SVG 분리 덕분에 1차에서 실패했던 캔버스 가상 스크롤을 파형 레이어에 부작용 없이 적용할 수 있게 됐다.&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차 시도의 실패가 꼭 나쁜 건 아니었다. 캔버스 가상 스크롤 자체는 올바른 방향이었다. 적용할 대상이 달랐을 뿐이다. 리전과 파형의 렌더링 특성 차이를 확인하는 데 그 실패가 필요했다. 이번 작업을 하면서, 실패를 조금은 두려워하지 않게 된 것 같다.&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;또 하나 배운것은, 기술을 고를 때 &quot;어떤 기술이 더 좋은가&quot;보다&lt;b&gt; &quot;이 데이터의 성격에 어떤 기술이 맞는가&quot;&lt;/b&gt;를 먼저 묻는 것이 중요하다는 걸, 이 작업에서 다시 확인했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;b&gt;브라우저 API&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Canvas 크기 제한: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;SVG pointer-events: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/pointer-events&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/pointer-events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;requestAnimationFrame: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그래픽스 개념&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;벡터 그래픽스 (위키백과): &lt;a href=&quot;https://ko.wikipedia.org/wiki/%EB%B2%A1%ED%84%B0_%EA%B7%B8%EB%9E%98%ED%94%BD%EC%8A%A4&quot;&gt;https://ko.wikipedia.org/wiki/%EB%B2%A1%ED%84%B0_%EA%B7%B8%EB%9E%98%ED%94%BD%EC%8A%A4&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;래스터 그래픽스 (Wikipedia): &lt;a href=&quot;https://en.wikipedia.org/wiki/Raster_graphics&quot;&gt;https://en.wikipedia.org/wiki/Raster_graphics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;벡터데이터와 래스터데이터: &lt;a href=&quot;https://clouds-daily.tistory.com/116&quot;&gt;https://clouds-daily.tistory.com/116&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>개발로그</category>
      <author>corein</author>
      <guid isPermaLink="true">https://insight74278.tistory.com/40</guid>
      <comments>https://insight74278.tistory.com/40#entry40comment</comments>
      <pubDate>Wed, 15 Apr 2026 18:04:30 +0900</pubDate>
    </item>
  </channel>
</rss>