OpenCut 탐구 1 — 폴더 구조로 읽는 브라우저 비디오 에디터의 설계도
브라우저에서 돌아가는 오픈소스 비디오 에디터 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/.
스스로 점검할 질문
- 새 편집 기능(예: 클립 회전)을 추가한다면 어떤 폴더들을 어떤 순서로 건드리게 될까요?
- "패널 접힘 상태"와 "클립 트림 값"은 각각 어디에 저장되어야 하고, 그 기준은 무엇인가요?
- 단축키와 버튼 클릭이 서로 다른 코드 경로를 탄다면 어떤 버그가 생길 수 있을까요?
참고 링크