Sangcho.log
dev

OpenCut 탐구 1 — 폴더 구조로 읽는 브라우저 비디오 에디터의 설계도

9 min read|

브라우저에서 돌아가는 오픈소스 비디오 에디터 OpenCut의 코드를 6편에 걸쳐 탐구합니다. 이 글은 그 첫 번째로, 코드를 한 줄도 읽기 전에 해야 할 일 — 지도 펼치기입니다. 낯선 도시에 도착하면 골목부터 헤매는 게 아니라 지도에서 "역이 어디고, 시청이 어디고, 어느 동네에 뭐가 모여 있는지"를 먼저 봅니다. 코드베이스도 똑같습니다. 폴더 구조는 그 프로젝트가 스스로를 어떻게 이해하고 있는지를 보여주는 설계도입니다.

전체 지도

OpenCut의 웹 앱은 모노레포의 apps/web/에 있고, 소스는 src/ 아래 이렇게 나뉩니다.

apps/web/
├── content/          # 마크다운 콘텐츠 (블로그, 변경 로그)
├── migrations/       # DB 스키마 마이그레이션
├── public/           # 정적 자산 (ffmpeg WASM, 폰트, 셰이더...)
├── scripts/          # 빌드/유틸리티 스크립트
└── src/
    ├── app/          # Next.js App Router 페이지
    ├── components/   # React 컴포넌트
    ├── core/         # EditorCore 싱글턴 (에디터의 심장)
    ├── hooks/        # Custom Hooks
    ├── lib/          # 앱 도메인 로직
    ├── services/     # 외부 서비스 추상화
    ├── stores/       # Zustand 상태 스토어
    ├── types/        # TypeScript 타입 정의
    ├── utils/        # 제네릭 유틸리티
    ├── constants/    # 전역 상수
    └── data/         # 정적 데이터

여기서 눈에 띄는 건 일반적인 Next.js 앱에는 없는 두 동네입니다. core/services/. 이 프로젝트의 정체성이 바로 여기 있습니다. OpenCut은 "페이지 몇 개 있는 웹사이트"가 아니라, React 바깥에 편집 엔진을 두고 React는 그 엔진의 화면 역할만 하는 구조입니다.

src/app/ — 관문: Next.js 라우팅

파일 기반 라우팅으로 구성된 페이지 레이어입니다. 랜딩(page.tsx), 프로젝트 목록(projects/), 블로그, 로드맵 같은 일반 페이지들 사이에서 핵심은 하나입니다.

경로 역할
editor/[project_id]/ 핵심 — 비디오 에디터 페이지
projects/ 프로젝트 목록
api/auth/[...all]/ Better Auth 인증 API 핸들러
api/sounds/search/ 사운드 검색 API
rss.xml/, sitemap.ts, robots.ts SEO·피드 생성

에디터가 URL에 project_id를 받는 동적 라우트 하나로 존재한다는 것, 그리고 서버 API가 인증·사운드 검색 정도로 얇다는 것이 포인트입니다. 무거운 일은 전부 브라우저 안에서 일어납니다.

src/core/ — 심장: EditorCore 싱글턴

에디터의 모든 상태를 관리하는 중심부입니다. 도시로 치면 시청이죠. 하나의 싱글턴 아래 도메인별 매니저가 트리로 매달려 있습니다.

EditorCore (singleton)
├── command:   CommandManager    # Undo/Redo 히스토리 관리
├── playback:  PlaybackManager   # 재생/일시정지/시간 관리
├── timeline:  TimelineManager   # 트랙/클립 상태 관리
├── scenes:    ScenesManager     # 씬(장면) 관리
├── project:   ProjectManager    # 프로젝트 메타데이터 관리
├── media:     MediaManager      # 미디어 파일 관리
├── renderer:  RendererManager   # 렌더링 조율
├── save:      SaveManager       # 자동 저장
├── audio:     AudioManager      # 오디오 처리
└── selection: SelectionManager  # 선택 상태 관리

React 컴포넌트에서는 훅으로, React 바깥에서는 싱글턴으로 접근합니다.

// React 안: 구독까지 자동 처리
import { useEditor } from '@/hooks/use-editor';
function MyComponent() {
  const editor = useEditor();
  const tracks = editor.timeline.getTracks();
  return <div>{tracks.length} tracks</div>;
}

// React 밖: 싱글턴 직접 접근
import { EditorCore } from "@/core";
const editor = EditorCore.getInstance();
editor.timeline.addTrack({ type: 'media' });

각 매니저의 속사정은 시리즈 3편에서 하나씩 뜯어봅니다.

src/lib/ — 두뇌: 도메인 로직

lib/에는 타임라인 드래그·스냅·줌, 키프레임, 이펙트 파라미터, 자막 생성 같은 도메인 로직이 모여 있는데, 그중 두 폴더가 아키텍처의 뼈대입니다.

lib/commands/ — Undo/Redo의 커맨드 패턴. 모든 편집 행위가 Command 객체로 캡슐화됩니다.

export abstract class Command {
  abstract execute(): void;
  undo(): void {
    throw new Error("Undo not implemented for this command");
  }
  redo(): void {
    this.execute(); // 기본 redo = execute 재호출
  }
}

timeline/element/, scene/, media/, project/처럼 도메인 폴더별로 커맨드가 정리되어 있고, 여러 커맨드를 하나의 undo 단위로 묶는 BatchCommand도 있습니다. 자세한 해부는 시리즈 4편에서 다룹니다.

lib/actions/ — 액션 시스템. 단축키든 버튼 클릭이든 컨텍스트 메뉴든, 모든 사용자 동작이 하나의 경로로 수렴하는 단일 진실 공급원입니다.

키보드 입력 (S)                    컨텍스트 메뉴 클릭
        ↓                                ↓
        └────── invokeAction("split") ───┘
                        ↓
        useEditorActions() 내 handler
                        ↓
        editor.timeline.splitElements()
                        ↓
        new SplitElementsCommand()
                        ↓
        editor.command.execute({ command })

어떤 입구로 들어와도 같은 길을 걷기 때문에 Undo/Redo가 일관되게 보장됩니다. 트리거(액션)와 실행·되돌리기(커맨드)의 관심사가 깔끔하게 나뉜 설계입니다.

src/services/ — 공장 지대: 외부 기술 추상화

브라우저의 저수준 API를 감싸는 계층입니다.

폴더 역할
renderer/ Canvas 2D + WebGL 렌더링 엔진, 씬 빌더, 영상 내보내기
storage/ IndexedDB + OPFS 로컬 스토리지 레이어 (v0~v9 마이그레이션 포함)
transcription/ Whisper 기반 음성→자막 변환 (Web Worker로 백그라운드 처리)
video-cache/ 비디오 디코딩 캐시

lib/가 "비디오 에디터란 무엇인가"를 안다면, services/는 "브라우저에서 그걸 어떻게 구현하는가"를 압니다. 도메인과 인프라의 분리입니다.

src/components/, stores/, hooks/ — 얼굴과 신경

components/editor/는 에디터 화면의 4개 패널(좌측 에셋, 중앙 프리뷰, 우측 속성, 하단 타임라인)로 나뉘고, components/ui/에는 Shadcn/UI 기반 공용 컴포넌트가 약 30개 있습니다.

stores/의 Zustand 스토어들은 이름을 보면 성격이 보입니다 — timeline-store(줌/스크롤/스냅 설정), panel-store(패널 레이아웃), keybindings-store(단축키 설정). 편집 데이터가 아니라 UI 상태입니다. 트랙과 클립 같은 진짜 데이터는 전부 core/가 쥐고 있습니다.

hooks/는 둘을 잇는 신경입니다. use-editor.ts가 EditorCore를 React에 연결하고, use-keybindings.ts가 키 입력을 액션으로 변환합니다.

전체 데이터 흐름 — 한 바퀴 돌기

사용자가 키를 누른 순간부터 저장까지, 지도 위에 경로를 그리면 이렇습니다.

사용자 인터랙션 (키보드 / 버튼 클릭)
        ↓
invokeAction("action-name")               ← lib/actions
        ↓
hooks/actions/use-editor-actions.ts       ← 액션 핸들러
        ↓
core/managers/*.ts                        ← Manager가 커맨드 생성
        ↓
lib/commands/*.ts                         ← Command.execute()
        ↓
core/managers/commands.ts                 ← history 스택에 push
        ↓
subscribe/notify → React 컴포넌트 리렌더
        ↓
services/renderer/                        ← Canvas/WebGL 화면 출력
        ↓
services/storage/                         ← IndexedDB/OPFS 자동 저장

더 깊이: 왜 Zustand와 EditorCore가 공존하는가

"상태 관리 라이브러리가 있는데 왜 자체 싱글턴을 또 만들었지?"라는 의문이 들 수 있습니다. 힌트는 두 저장소에 담긴 내용물의 차이에 있습니다.

  • EditorCore(core/) — 트랙, 클립, 씬, 프로젝트. 저장되고, Undo되고, 내보내기의 대상이 되는 도메인 데이터
  • Zustand(stores/) — 줌 레벨, 패널 크기, 활성 탭. 새로고침하면 초기화되어도 아무도 슬프지 않은 UI 상태

도메인 데이터는 커맨드 패턴을 거쳐야 하고, 자동 저장이 구독해야 하고, React 밖(오디오 스케줄러, 익스포터)에서도 접근해야 합니다. 이 요구사항들은 React 트리에 묶인 스토어보다 순수 클래스 계층이 다루기 쉽습니다. 반대로 패널 폭 같은 값에 커맨드와 히스토리를 태우는 건 과잉이죠. 저장소를 하나로 통일하는 것보다, 데이터의 성격에 따라 저장소를 나누는 것이 더 단순해지는 지점이 있습니다.


한 줄 요약 — OpenCut은 React 앱이 아니라 "React를 화면으로 쓰는 편집 엔진"이며, 폴더 구조가 그 사실을 그대로 드러낸다: 엔진은 core/, 도메인 규칙은 lib/, 브라우저 기술은 services/, UI 상태만 stores/.

스스로 점검할 질문

  1. 새 편집 기능(예: 클립 회전)을 추가한다면 어떤 폴더들을 어떤 순서로 건드리게 될까요?
  2. "패널 접힘 상태"와 "클립 트림 값"은 각각 어디에 저장되어야 하고, 그 기준은 무엇인가요?
  3. 단축키와 버튼 클릭이 서로 다른 코드 경로를 탄다면 어떤 버그가 생길 수 있을까요?

참고 링크