Sangcho.log
dev

OpenCut 탐구 3 — EditorCore와 10개의 매니저, 부서로 돌아가는 회사

13 min read|

1편에서 봤듯 OpenCut의 편집 엔진은 싱글턴 EditorCore 아래 10개의 매니저로 구성됩니다. 이번 편에서는 그 매니저들을 하나씩 방문합니다.

구조를 이해하는 가장 쉬운 틀은 회사입니다. EditorCore는 본사 건물이고, 각 매니저는 부서입니다. 재생 담당, 타임라인 담당, 저장 담당… 부서마다 맡은 도메인이 하나씩 있고, 다른 부서 일이 필요하면 직접 하지 않고 그 부서를 부릅니다. 그리고 모든 부서가 같은 방식의 사내 방송 시스템(subscribe/notify)으로 바깥세상(React)에 소식을 알립니다.

EditorCore (싱글턴)
├── command:    CommandManager      — Undo/Redo 이력 관리
├── playback:   PlaybackManager     — 재생/정지/시간/볼륨
├── timeline:   TimelineManager     — 트랙·엘리먼트 CRUD
├── scenes:     ScenesManager       — 씬 목록·전환·북마크
├── project:    ProjectManager      — 프로젝트 CRUD·내보내기
├── media:      MediaManager        — 미디어 에셋 관리
├── renderer:   RendererManager     — 렌더 트리·스냅샷·내보내기
├── save:       SaveManager         — 디바운스 자동 저장
├── audio:      AudioManager        — 실시간 오디오 재생
└── selection:  SelectionManager    — 엘리먼트/키프레임 선택

모든 부서의 공통 규약 — subscribe/notify

열 개 매니저가 전부 공유하는 코드가 있습니다.

subscribe(listener: () => void): () => void {
  this.listeners.add(listener);
  return () => this.listeners.delete(listener);
}
private notify(): void {
  this.listeners.forEach((fn) => fn());
}

외부 상태 라이브러리 없이 구현한 자체 Pub/Sub입니다. 매니저는 React 바깥의 순수 클래스이므로, React가 변화를 감지하는 유일한 통로가 이 구독입니다. subscribe가 해제 함수를 반환하는 형태는 React의 useSyncExternalStore·useEffect cleanup과 정확히 맞물리도록 설계된 것이죠.

이 규약에는 실무적인 함의가 하나 있습니다. 상태를 바꾸는 메서드에서 notify()를 빠뜨리면 UI가 조용히 갱신을 멈춥니다. 에러도 없이요. 이 코드베이스에 기여한다면 가장 먼저 몸에 새겨야 할 규칙입니다.

PlaybackManager — 두 개의 방송 채널

재생 담당 부서의 가장 흥미로운 설계는 통신 채널이 둘이라는 점입니다.

채널 대상 전달 내용
subscribe/notify React 컴포넌트 "뭔가 바뀜" (인자 없음)
window CustomEvent 비-React 시스템 (AudioManager 등) 구체적인 time

React에게는 "바뀌었으니 다시 그려"라고만 하면 됩니다(리렌더 후 어차피 최신 값을 읽으니까). 하지만 AudioManager는 정확히 몇 초로 이동했는지를 알아야 오디오를 재동기화할 수 있어서, CustomEvent({ detail: { time } })로 값을 실어 보냅니다. 수신자를 몰라도 되는 fire-and-forget이라 결합도도 없습니다.

시간 엔진은 setInterval이 아니라 requestAnimationFrame입니다.

private updateTime = (): void => {
  if (!this.isPlaying) return;
  const now = performance.now();
  const delta = (now - this.lastUpdate) / 1000; // 실제 경과 시간
  this.lastUpdate = now;
  const newTime = this.currentTime + delta;
  // ... duration 도달 시 pause(), 아니면 notify() ...
  this.playbackTimer = requestAnimationFrame(this.updateTime);
};
  • rAF는 브라우저 렌더링 주기에 동기화되어 부드럽고, 탭이 비활성화되면 자동으로 멈춰 리소스를 아낍니다.
  • 시간은 프레임 횟수가 아니라 performance.now() 기반 delta로 계산하므로, 프레임이 드랍되어도 시간은 정확하게 흐릅니다.
  • 화살표 함수로 선언한 것도 우연이 아닙니다. rAF 콜백으로 넘겨질 때 this 바인딩을 유지하기 위해서죠.

작지만 UX를 살리는 디테일도 있습니다. mute()는 현재 볼륨을 previousVolume에 백업해 두고, unmute()하면 이전 볼륨 그대로 복원합니다.

TimelineManager — 데이터가 없는 데이터 부서

타임라인 부서의 반전: 트랙 데이터를 직접 소유하지 않습니다.

getTracks(): TimelineTrack[] {
  return this.editor.scenes.getActiveScene()?.tracks ?? [];
}

진짜 주인은 ScenesManager이고, TimelineManager는 매번 활성 씬에서 트랙을 빌려옵니다. 이 부서의 정체는 타임라인 조작을 위한 편의 API 계층입니다. 대신 두 가지 정교한 메커니즘을 제공합니다.

pushHistory 패턴 — 드래그 중 vs 확정. 드래그하는 동안 매 프레임 undo 기록을 남기면 히스토리가 수백 개 쌓입니다. 그래서 옵션으로 분기합니다.

if (pushHistory) {
  this.editor.command.execute({ command }); // 확정: undo 스택에 기록
} else {
  command.execute();                        // 드래그 중: 화면만 갱신
}

② Preview 시스템 — 3단계 라이프사이클. 더 안전한 임시 변경 메커니즘입니다.

previewElements()   # 현재 상태를 스냅샷으로 저장 + 임시 변경 반영
   ├→ commitPreview()   # 확정: 히스토리에만 기록 (이미 반영됐으니 재실행 안 함)
   └→ discardPreview()  # 취소: 스냅샷으로 롤백

pushHistory가 "기록할지 말지"만 정한다면, Preview는 원본을 들고 있다가 되돌릴 수 있는 구조입니다. ESC로 드래그를 취소하는 UX가 이걸로 구현됩니다.

또 하나 — 여러 엘리먼트를 동시에 수정할 때는 BatchCommand로 묶어 Ctrl+Z 한 번에 전부 되돌아가게 하고, 1개일 때는 래핑하지 않는 최적화도 있습니다.

ScenesManager — 진짜 금고, 그리고 의심 많은 관리인

트랙 데이터의 실제 소유자입니다. 설계에서 눈에 띄는 건 방어적 태도입니다.

  • Main 씬은 절대 삭제할 수 없습니다. isMain: true 씬은 duration 계산·내보내기 등 핵심 로직의 기준점이라, 삭제 시도는 canDeleteScene()에서 거부됩니다.
  • 초기화에 방어 로직이 2겹입니다. ensureMainScene()이 Main 씬 누락을 복구하고, ensureScenesHaveMainTrack()이 각 씬의 video Main 트랙 누락을 복구합니다. 복구가 일어나면 markDirty({ force: true })로 즉시 저장까지 합니다. 스토리지나 마이그레이션에서 온 외부 데이터를 절대 신뢰하지 않는 구조입니다.
  • 씬 상태 변경은 전부 Command를 거칩니다. 직접 this.list를 조작하면 Undo/Redo가 깨집니다. 유일한 예외는 switchToScene() — 네비게이션은 되돌릴 필요가 없으니까요.

주의점도 있습니다. 씬 상태를 바꿀 때마다 editor.project.setActiveProject()로 프로젝트에 수동 동기화하는 패턴이 거의 모든 메서드에 반복되는데, 새 메서드에서 이걸 빠뜨리면 씬과 프로젝트 상태가 어긋납니다. 그리고 북마크 시간은 부동소수점 오차(0.033333... vs 0.033334)를 피하려고 FPS 기준 프레임 경계로 스냅해 비교합니다.

MediaManager — 낙관적 업데이트와 연쇄 청소

미디어 추가는 낙관적 업데이트 + 롤백 패턴입니다.

this.assets = [...this.assets, newAsset];
this.notify();                          // ← 저장을 기다리지 않고 먼저 UI 반영
try {
  await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
} catch (error) {
  this.assets = this.assets.filter((a) => a.id !== newAsset.id);
  this.notify();                        // ← 실패하면 조용히 롤백
}

삭제할 때는 세 곳을 반드시 함께 청소해야 합니다.

정리 대상 빠뜨리면
URL.revokeObjectURL() Blob URL이 메모리에 계속 남음
videoCache.clearVideo() 디코딩된 프레임 캐시로 메모리 폭증
editor.timeline.deleteElements() 유령 참조가 남아 재생 시 크래시

이 연쇄 정리 덕분에 미디어-타임라인 간 정합성이 유지됩니다(그리고 이 삭제 경로의 타이밍 문제는 2편에서 다뤘습니다).

RendererManager — 예외 대신 결과를 돌려주는 부서

렌더 트리 관리, 스냅샷 캡처, 내보내기를 담당합니다. 패턴 몇 가지가 알찹니다.

  • 오프스크린 렌더링 — 스냅샷은 화면의 프리뷰 캔버스가 아니라 임시 document.createElement("canvas")에 원본 해상도로 그려서 캡처합니다.
  • 프레임 경계 클램핑 — 재생 헤드가 duration을 넘을 수 있으므로 Math.min(currentTime, lastFrameTime)으로 보정합니다. 30fps·10초 영상의 마지막 유효 프레임은 10초가 아니라 9.9667초(299번째)입니다.
  • 진행률 통합 — 오디오 믹싱(05%)과 영상 인코딩(5100%)의 진행률을 0.05 + progress * 0.95로 이어 붙여, 프로그레스 바가 끊기지 않게 합니다.
  • Result 타입throw 대신 { success, error }를 반환해, 호출부가 try-catch 없이 분기하고 타입 시스템이 에러 케이스를 강제하게 합니다.

SelectionManager — 가장 작지만 가장 명확한 부서

EditorCore 참조조차 쓰지 않는(constructor(editor) { void editor; }) 독립 부서입니다. 규칙 두 가지가 핵심입니다.

① 엘리먼트 선택과 키프레임 선택은 상호 배타적입니다. 엘리먼트를 선택하면 키프레임 선택이 자동 초기화됩니다. 반대로 키프레임 선택은 엘리먼트 선택을 건드리지 않는데, 키프레임이 이미 선택된 엘리먼트의 하위 속성이기 때문입니다.

② Anchor 패턴으로 Shift+클릭 범위 선택을 구현합니다. anchorKeyframe을 명시하면 앵커 갱신, undefined면 기존 앵커 유지, 선택이 0개면 앵커 자동 정리 — 이 3단 분기 하나로 단일 선택/범위 선택/전체 해제를 처리합니다.

엘리먼트 식별자가 { trackId, elementId } 복합 키인 것도 눈여겨볼 만합니다. 같은 미디어가 여러 트랙에 존재할 수 있는 타임라인에서는 id 하나로 부족합니다.

AudioManager — 아무도 부르지 않는 부서

가장 특이한 부서입니다. 외부에서 직접 호출하는 메서드가 하나도 없습니다. 생성자에서 playback·timeline·media를 구독하고, 재생 상태가 바뀌면 스스로 오디오 클립을 수집해 Web Audio API에 공급합니다. 완전히 자율적으로 도는 부서죠. 내부의 정밀한 스케줄링과 지연 보상은 다음 편(5편)에서 통째로 다룹니다.

부서 간 조직도와 네 가지 핵심 흐름

ProjectManager ──────→ ScenesManager ←──── TimelineManager
    │                      ↑                     ↑
    ├→ MediaManager        │                     │
    ├→ SaveManager ────────┴─────────────────────┘ (subscribe)
    └→ RendererManager
PlaybackManager ──→ TimelineManager (duration 조회)
AudioManager ──→ PlaybackManager + TimelineManager + MediaManager (구독)
SelectionManager (독립)
  1. 편집: UI → Action → TimelineManager → Command → ScenesManager.updateSceneTracks()
  2. 저장: scenes/timeline notify() → SaveManager markDirty()ProjectManager.saveCurrentProject()
  3. 재생: PlaybackManager.play()notify() → AudioManager(소리) + 프리뷰 UI(화면)
  4. 로드: ProjectManager.loadProject()ScenesManager.initializeScenes() + MediaManager.loadProjectMedia()

더 깊이: useSyncExternalStore와 이 패턴의 궁합

매니저들의 subscribe(listener) → unsubscribe 시그니처는 React 18의 useSyncExternalStore가 요구하는 계약과 정확히 일치합니다.

const state = useSyncExternalStore(
  store.subscribe,      // (listener) => unsubscribe
  store.getSnapshot     // () => 현재 값
);

이 훅이 존재하는 이유는 tearing 때문입니다. React 18의 동시성 렌더링에서는 렌더링이 중단·재개될 수 있어서, 외부 스토어 값이 렌더링 도중 바뀌면 화면의 절반은 옛 값, 절반은 새 값으로 그려질 수 있습니다. useSyncExternalStore는 이런 불일치를 감지하면 동기로 다시 렌더링해 일관성을 보장합니다. OpenCut처럼 React 바깥에 진실의 원천(EditorCore)을 두는 아키텍처라면, useEffect + setState로 직접 구독을 흉내 내는 것보다 이 훅을 쓰는 것이 정석입니다. Zustand 같은 라이브러리도 내부적으로 같은 훅 위에 서 있습니다.


한 줄 요약 — OpenCut의 편집 엔진은 "도메인당 부서 하나, 데이터 주인은 명확히, 소통은 subscribe/notify로 통일"이라는 규약으로 돌아가는 10개 매니저의 연합체다.

스스로 점검할 질문

  1. TimelineManager가 트랙을 직접 소유하지 않고 ScenesManager에 위임해서 얻는 이점은 무엇일까요? (힌트: 멀티 씬)
  2. 드래그 인터랙션에서 pushHistory: false와 Preview 시스템 중 무엇을 언제 골라야 할까요?
  3. 여러분의 프로젝트에서 React 바깥의 상태를 구독할 때 tearing이 생길 수 있는 지점이 있나요?

참고 링크