Remotion Studio가 답답해서 직접 만들기로 했는데, 옛날 코드 분석하다가 깨달은 것들. 의존성 지옥부터 아키텍처 설계까지 기록
Remotion 기반 커스텀 에디터 만들기: 남의 코드 뜯어보며 깨달은 현실
시작은 언제나 불만에서
Remotion에서 제공하는 Studio가 내 용도에 맞지 않아서 결국 직접 만들기로 했다. 뭐, 세상에 완벽한 도구는 없으니까. 그런데 막상 시작하려니 막막하더라. 그래서 찾아낸 게 옛날에 만들어진 Remotion 기반 에디터의 참고 자료였다.
문제는 이 코드가 구석기 시대에 만들어진 것 같다는 점이다. 의존성부터 시작해서 아키텍처까지, 현재 내가 설치한 패키지들과는 천지차이다.
의존성 분석: 옛날 것 vs 현재 것
🗑️ 버릴 것들 (jQuery 시대의 유물들)
참고 코드를 보니 jQuery부터 시작해서 온갖 레거시 라이브러리들이 잔뜩 들어있다:
<script src="jquery/3.5.1/jquery.min.js"></script>
<script src="js/libraries/sortable.min.js"></script>
<script src="js/libraries/range-slider.min.js"></script>
<script src="js/libraries/anime.min.js"></script>
이런 걸 보면 정말 격세지감이 든다. 2024년에 jQuery를 쓴다고? React의 가상 DOM이 있는데 왜 직접 DOM을 조작하겠는가.
| 옛날 라이브러리 | 현재 대체재 | 비고 |
|---|---|---|
| jQuery | React + React DOM | 당연히 React 쓸 거다 |
| sortable.min.js | react-draggable, react-moveable | 이미 설치되어 있음 |
| range-slider.min.js | HTML5 input[type=“range”] | 네이티브가 최고다 |
| anime.min.js | GSAP | 이미 GSAP 있으니 굳이? |
고민해볼 것들
// Fabric.js - 캔버스 라이브러리
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js"></script>
Fabric.js는 강력한 캔버스 라이브러리다. 하지만 Remotion에서는 필요 없다. Remotion이 렌더링을 담당하니까. 캔버스 조작 대신 Remotion 컴포넌트(<AbsoluteFill>, <Sequence> 등)와 GSAP을 쓸 거다.
graph TD
A[기존 Motionity] --> B[Fabric.js Canvas]
B --> C[직접 캔버스 조작]
D[내 Remotion 에디터] --> E[Remotion Components]
E --> F[GSAP 애니메이션]
F --> G[Remotion 렌더링]
HTML 구조 분석: 이게 뭘 하는 건지 알아보자
참고 코드의 HTML 구조를 뜯어보니 전형적인 비디오 에디터 레이아웃이다:
<div id="toolbar" class="noselect">
<!-- 도구 버튼들 -->
</div>
<div id="properties">
<!-- 속성 패널 -->
<div class="property-name" data-property="position">
<span class="property-keyframe"></span>Position
<img class="freeze-prop" src="assets/freeze.svg">
</div>
</div>
<div id="canvas-area">
<canvas id="canvas"></canvas>
</div>
<div id="bottom-area">
<!-- 타임라인, 레이어 리스트 -->
</div>
핵심 컴포넌트들
- Toolbar: 도구 선택 (텍스트, 도형, 미디어 등)
- Properties Panel: 선택된 객체의 속성 편집
- Canvas Area: 메인 작업 영역 (내 경우엔 Remotion Player)
- Timeline: 키프레임과 애니메이션 관리
키프레임 시스템: 이게 진짜 핵심이다
참고 코드에서 가장 중요한 부분은 키프레임 시스템이다. 어떻게 작동하는지 보자:
// ui.js - 패널 업데이트
function updatePanel(selection) {
if (selection && canvas.getActiveObjects().length == 1) {
$('#object-specific').html(object_panel);
updatePanelValues(); // 현재 값으로 입력 필드 업데이트
}
}
// events.js - 객체 수정 시 자동 키프레임
canvas.on('object:modified', function(e) {
autoKeyframe(canvas.getActiveObject(), e, selection);
});
이 구조를 React + Remotion으로 옮기면:
// useKeyframes.ts
export const useKeyframes = () => {
const { currentFrame } = useCurrentFrame();
const addKeyframe = (property: string, value: any) => {
// 현재 프레임에 키프레임 추가
keyframeStore.addKeyframe(currentFrame, property, value);
};
const autoKeyframe = (object: any, property: string) => {
// 객체 수정 시 자동으로 키프레임 생성
if (shouldCreateKeyframe(currentFrame, property)) {
addKeyframe(property, object[property]);
}
};
};
아키텍처 설계: 현실적인 접근
기존 코드를 분석한 결과, 다음과 같은 구조로 가는 게 좋겠다:
src/
├── editor/
│ ├── components/
│ │ ├── toolbar/ # 도구 모음
│ │ ├── properties/ # 속성 패널 (Leva 활용)
│ │ ├── timeline/ # 타임라인 + 키프레임
│ │ └── preview/ # Remotion Player
│ ├── store/ # Zustand 상태 관리
│ ├── hooks/ # 커스텀 훅들
│ └── utils/ # 유틸리티 함수들
└── remotion/
├── components/ # Remotion 컴포넌트들
└── compositions/ # 메인 컴포지션
데이터 플로우
sequenceDiagram
participant U as User
participant E as Editor
participant S as Store
participant R as Remotion
U->>E: 객체 수정
E->>S: 상태 업데이트
S->>E: 키프레임 생성
E->>R: 컴포지션 업데이트
R->>U: 미리보기 렌더링
현실적인 구현 순서
- 기본 구조 잡기: Remotion Player + 기본 UI
- 속성 패널: Leva로 간단한 컨트롤부터
- 타임라인: 스크러버와 기본 재생 기능
- 키프레임: 수동 추가부터 시작
- 자동 키프레임: 객체 수정 시 자동 생성
- 렌더링: Remotion API로 최종 출력
Remotion 기반 커스텀 에디터 만들기: 남의 코드 뜯어보며 깨달은 현실
시작은 언제나 불만에서
Remotion에서 제공하는 Studio가 내 용도에 맞지 않아서 결국 직접 만들기로 했다. 뭐, 세상에 완벽한 도구는 없으니까. 그런데 막상 시작하려니 막막하더라. 그래서 찾아낸 게 옛날에 만들어진 Remotion 기반 에디터의 참고 자료였다.
문제는 이 코드가 구석기 시대에 만들어진 것 같다는 점이다. 의존성부터 시작해서 아키텍처까지, 현재 내가 설치한 패키지들과는 천지차이다.
의존성 분석: 옛날 것 vs 현재 것
🗑️ 버릴 것들 (jQuery 시대의 유물들)
참고 코드를 보니 jQuery부터 시작해서 온갖 레거시 라이브러리들이 잔뜩 들어있다:
<script src="jquery/3.5.1/jquery.min.js"></script>
<script src="js/libraries/sortable.min.js"></script>
<script src="js/libraries/range-slider.min.js"></script>
<script src="js/libraries/anime.min.js"></script>
이런 걸 보면 정말 격세지감이 든다. 2024년에 jQuery를 쓴다고? React의 가상 DOM이 있는데 왜 직접 DOM을 조작하겠는가.
| 옛날 라이브러리 | 현재 대체재 | 비고 |
|---|---|---|
| jQuery | React + React DOM | 당연히 React 쓸 거다 |
| sortable.min.js | react-draggable, react-moveable | 이미 설치되어 있음 |
| range-slider.min.js | HTML5 input[type=“range”] | 네이티브가 최고다 |
| anime.min.js | GSAP | 이미 GSAP 있으니 굳이? |
고민해볼 것들
// Fabric.js - 캔버스 라이브러리
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js"></script>
Fabric.js는 강력한 캔버스 라이브러리다. 하지만 Remotion에서는 필요 없다. Remotion이 렌더링을 담당하니까. 캔버스 조작 대신 Remotion 컴포넌트(<AbsoluteFill>, <Sequence> 등)와 GSAP을 쓸 거다.
graph TD
A[기존 Motionity] --> B[Fabric.js Canvas]
B --> C[직접 캔버스 조작]
D[내 Remotion 에디터] --> E[Remotion Components]
E --> F[GSAP 애니메이션]
F --> G[Remotion 렌더링]
HTML 구조 분석: 이게 뭘 하는 건지 알아보자
참고 코드의 HTML 구조를 뜯어보니 전형적인 비디오 에디터 레이아웃이다:
<div id="toolbar" class="noselect">
<!-- 도구 버튼들 -->
</div>
<div id="properties">
<!-- 속성 패널 -->
<div class="property-name" data-property="position">
<span class="property-keyframe"></span>Position
<img class="freeze-prop" src="assets/freeze.svg">
</div>
</div>
<div id="canvas-area">
<canvas id="canvas"></canvas>
</div>
<div id="bottom-area">
<!-- 타임라인, 레이어 리스트 -->
</div>
핵심 컴포넌트들
- Toolbar: 도구 선택 (텍스트, 도형, 미디어 등)
- Properties Panel: 선택된 객체의 속성 편집
- Canvas Area: 메인 작업 영역 (내 경우엔 Remotion Player)
- Timeline: 키프레임과 애니메이션 관리
키프레임 시스템: 이게 진짜 핵심이다
참고 코드에서 가장 중요한 부분은 키프레임 시스템이다. 어떻게 작동하는지 보자:
// ui.js - 패널 업데이트
function updatePanel(selection) {
if (selection && canvas.getActiveObjects().length == 1) {
$('#object-specific').html(object_panel);
updatePanelValues(); // 현재 값으로 입력 필드 업데이트
}
}
// events.js - 객체 수정 시 자동 키프레임
canvas.on('object:modified', function(e) {
autoKeyframe(canvas.getActiveObject(), e, selection);
});
실제 구현: 이론과 현실의 괴리
처음엔 이 구조를 그대로 React로 옮기면 될 줄 알았다. 완전히 틀렸다.
jQuery 기반 코드는 DOM을 직접 조작하는 명령형 프로그래밍이다. 반면 React는 상태 기반의 선언형 프로그래밍이다. 이 둘의 패러다임 차이를 무시하고 단순 포팅을 시도했다가 며칠을 날렸다.
// 첫 번째 시도 - 실패작
const PropertyPanel = () => {
const [selectedObject, setSelectedObject] = useState(null);
// jQuery 스타일로 DOM 직접 조작 시도
useEffect(() => {
if (selectedObject) {
document.getElementById('position-input').value = selectedObject.position;
document.getElementById('scale-input').value = selectedObject.scale;
}
}, [selectedObject]);
// 이런 식으로 하면 안 된다!
};
문제는 React의 상태 관리와 DOM 직접 조작이 충돌한다는 점이다. 입력 필드의 값이 상태와 동기화되지 않아서 예상치 못한 버그들이 속출했다.
올바른 접근: Leva + Zustand 조합
결국 Leva를 활용한 속성 패널과 Zustand로 전역 상태 관리를 하는 방향으로 선회했다:
// 올바른 접근
const PropertyPanel = () => {
const { selectedObject, updateObject } = useEditorStore();
const { addKeyframe } = useKeyframes();
const propertyControls = useMemo(() => {
if (!selectedObject) return {};
return {
position: {
value: selectedObject.position,
min: -1000,
max: 1000,
onChange: (value) => {
updateObject(selectedObject.id, { position: value });
addKeyframe(selectedObject.id, 'position', value);
}
},
scale: {
value: selectedObject.scale,
min: 0.1,
max: 5,
step: 0.1,
onChange: (value) => {
updateObject(selectedObject.id, { scale: value });
addKeyframe(selectedObject.id, 'scale', value);
}
}
};
}, [selectedObject]);
return <Leva controls={propertyControls} />;
};
이렇게 하니까 상태 동기화 문제가 말끔히 해결됐다. Leva가 입력 필드와 상태를 자동으로 동기화해주니까.
타임라인 구현: 생각보다 복잡한 놈
참고 코드의 타임라인을 보면 단순해 보인다. 그냥 시간축에 키프레임 점들이 찍혀있는 것 같은데, 막상 구현하려니 지옥이었다.
첫 번째 난관: 시간과 프레임의 변환
// 이런 간단한 계산도 버그 투성이였다
const timeToFrame = (time: number, fps: number) => {
return Math.round(time * fps);
};
const frameToTime = (frame: number, fps: number) => {
return frame / fps;
};
문제는 부동소수점 연산의 정밀도다. 0.1 + 0.2 !== 0.3 같은 JavaScript의 고질적인 문제가 타임라인에서 키프레임 위치 계산을 망쳤다.
두 번째 난관: 드래그 앤 드롭
키프레임을 드래그해서 시간을 조정하는 기능을 구현하려고 했는데, 이게 생각보다 까다롭다:
const KeyframeComponent = ({ keyframe, onTimeChange }) => {
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState(0);
const handleMouseDown = (e) => {
setIsDragging(true);
setDragStart(e.clientX);
// 전역 마우스 이벤트 리스너 등록
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e) => {
if (!isDragging) return;
const deltaX = e.clientX - dragStart;
const timelineWidth = timelineRef.current.offsetWidth;
const totalDuration = composition.durationInFrames / composition.fps;
// 픽셀을 시간으로 변환
const deltaTime = (deltaX / timelineWidth) * totalDuration;
const newTime = Math.max(0, keyframe.time + deltaTime);
onTimeChange(keyframe.id, newTime);
};
// 메모리 누수 방지를 위한 cleanup
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
return (
<div
className="keyframe"
onMouseDown={handleMouseDown}
style={{ left: `${(keyframe.time / totalDuration) * 100}%` }}
/>
);
};
이런 식으로 구현했는데, 실제로 써보니 끔찍했다. 드래그할 때 버벅거리고, 가끔 키프레임이 엉뚱한 곳으로 튀어나가고, 마우스를 빠르게 움직이면 드래그가 끊어지기도 했다.
해결책: react-draggable 활용
결국 직접 구현하는 걸 포기하고 react-draggable을 썼다:
import Draggable from 'react-draggable';
const KeyframeComponent = ({ keyframe, onTimeChange }) => {
const timelineWidth = useTimelineWidth();
const totalDuration = useCompositionDuration();
const handleDrag = (e, data) => {
const timelineRect = timelineRef.current.getBoundingClientRect();
const relativeX = data.x;
const newTime = (relativeX / timelineWidth) * totalDuration;
onTimeChange(keyframe.id, Math.max(0, newTime));
};
return (
<Draggable
axis="x"
bounds="parent"
onDrag={handleDrag}
position={{ x: (keyframe.time / totalDuration) * timelineWidth, y: 0 }}
>
<div className="keyframe" />
</Draggable>
);
};
훨씬 깔끔하고 안정적이다. 역시 바퀴를 다시 발명할 필요는 없다.
사용자 경험: 이론과 현실의 또 다른 괴리
코드 구현은 어찌저찌 됐는데, 실제로 써보니 UX가 재앙이었다.
키프레임 추가: 언제, 어떻게?
처음엔 After Effects처럼 속성을 변경할 때마다 자동으로 키프레임을 추가하도록 했다:
const handlePropertyChange = (property, value) => {
updateObject(selectedObject.id, { [property]: value });
addKeyframe(selectedObject.id, property, value); // 항상 키프레임 추가
};
결과는? 키프레임 지옥이었다. 슬라이더를 조금만 움직여도 키프레임이 수십 개씩 생성됐다. 타임라인이 점들로 도배되더라.
해결책: 의도적 키프레임 생성
결국 사용자가 명시적으로 키프레임을 추가할 때만 생성하도록 바꿨다:
const PropertyPanel = () => {
const [isKeyframeMode, setIsKeyframeMode] = useState(false);
const handlePropertyChange = (property, value) => {
updateObject(selectedObject.id, { [property]: value });
// 키프레임 모드일 때만 키프레임 추가
if (isKeyframeMode) {
addKeyframe(selectedObject.id, property, value);
}
};
return (
<div>
<button
className={`keyframe-toggle ${isKeyframeMode ? 'active' : ''}`}
onClick={() => setIsKeyframeMode(!isKeyframeMode)}
>
🔑 키프레임 모드
</button>
<Leva controls={propertyControls} />
</div>
);
};
이렇게 하니까 훨씬 제어 가능해졌다. 사용자가 애니메이션을 만들고 싶을 때만 키프레임 모드를 켜고 작업하면 된다.
성능 최적화: 렌더링 지옥 탈출기
초기 버전을 테스트해보니 끔찍하게 느렸다. 속성 하나 바꿀 때마다 전체 컴포지션이 다시 렌더링됐다.
문제 진단: 불필요한 리렌더링
// 문제가 된 코드
const VideoEditor = () => {
const [objects, setObjects] = useState([]);
const [currentFrame, setCurrentFrame] = useState(0);
// 이렇게 하면 objects가 바뀔 때마다 전체 리렌더링
const composition = useMemo(() => {
return (
<Composition
id="main"
component={MainComposition}
durationInFrames={300}
fps={30}
width={1920}
height={1080}
defaultProps={{ objects, currentFrame }}
/>
);
}, [objects, currentFrame]); // 의존성이 너무 자주 바뀜
return <Player component={composition} />;
};
objects 배열이 바뀔 때마다 전체 컴포지션이 새로 생성됐다. 객체 하나의 위치만 바뀌어도 말이다.
해결책: 세밀한 상태 분리
// 개선된 코드
const VideoEditor = () => {
const composition = useMemo(() => (
<Composition
id="main"
component={MainComposition}
durationInFrames={300}
fps={30}
width={1920}
height={1080}
/>
), []); // 의존성 없음 - 한 번만 생성
return <Player component={composition} />;
};
// 컴포지션 내부에서 상태 구독
const MainComposition = () => {
const objects = useEditorStore(state => state.objects);
const currentFrame = useCurrentFrame();
return (
<AbsoluteFill>
{objects.map(obj => (
<ObjectRenderer
key={obj.id}
object={obj}
frame={currentFrame}
/>
))}
</AbsoluteFill>
);
};
이렇게 하니까 컴포지션 자체는 한 번만 생성되고, 내부에서만 상태 변화에 반응하게 됐다.
React.memo로 추가 최적화
const ObjectRenderer = React.memo(({ object, frame }) => {
const animatedProps = useAnimatedProperties(object, frame);
return (
<AbsoluteFill style={animatedProps}>
{object.type === 'text' && <TextObject {...object} />}
{object.type === 'image' && <ImageObject {...object} />}
{object.type === 'shape' && <ShapeObject {...object} />}
</AbsoluteFill>
);
}, (prevProps, nextProps) => {
// 객체나 프레임이 바뀌지 않으면 리렌더링 안 함
return prevProps.object === nextProps.object &&
prevProps.frame === nextProps.frame;
});
애니메이션 보간: GSAP과 Remotion의 만남
키프레임 사이의 애니메이션을 어떻게 처리할지가 가장 큰 고민이었다. Remotion의 interpolate를 쓸지, GSAP의 gsap.utils.interpolate를 쓸지.
Remotion 방식
import { interpolate, useCurrentFrame } from 'remotion';
const AnimatedObject = ({ keyframes }) => {
const frame = useCurrentFrame();
const position = interpolate(
frame,
keyframes.map(kf => kf.frame),
keyframes.map(kf => kf.position),
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
return <div style={{ transform: `translateX(${position}px)` }} />;
};
GSAP 방식
import { gsap } from 'gsap';
const useAnimatedProperties = (object, frame) => {
return useMemo(() => {
const timeline = gsap.timeline();
object.keyframes.forEach((keyframe, index) => {
if (index === 0) {
timeline.set({}, keyframe.properties, keyframe.time);
} else {
timeline.to({}, keyframe.properties, keyframe.time);
}
});
// 현재 프레임에서의 값 계산
timeline.progress(frame / totalFrames);
return timeline.getChildren()[0].vars;
}, [object.keyframes, frame]);
};
결론: 하이브리드 접근
결국 둘 다 쓰기로 했다:
- 단순한 보간: Remotion의
interpolate(성능이 좋음) - 복잡한 애니메이션: GSAP (이징, 베지어 곡선 등)
const getAnimatedValue = (keyframes, frame, property) => {
// 키프레임이 2개 이하면 Remotion interpolate 사용
if (keyframes.length <= 2) {
return interpolate(
frame,
keyframes.map(kf => kf.frame),
keyframes.map(kf => kf[property])
);
}
// 복잡한 애니메이션은 GSAP 사용
return gsapInterpolate(keyframes, frame, property);
};
실제 사용해보니: 예상치 못한 문제들
1. 메모리 누수
타임라인을 스크럽할 때마다 메모리 사용량이 계속 증가했다. 원인은 GSAP 타임라인을 제대로 정리하지 않았기 때문이었다:
// 문제가 된 코드
const useAnimatedProperties = (object, frame) => {
const timeline = gsap.timeline(); // 매번 새로 생성
// ... 애니메이션 설정
return animatedProps;
};
// 해결책
const useAnimatedProperties = (object, frame) => {
const timelineRef = useRef();
useEffect(() => {
if (timelineRef.current) {
timelineRef.current.kill(); // 이전 타임라인 정리
}
timelineRef.current = gsap.timeline();
// ... 애니메이션 설정
return () => {
if (timelineRef.current) {
timelineRef.current.kill();
}
};
}, [object.keyframes]);
// ...
};
2. 키프레임 정렬 문제
사용자가 키프레임을 드래그해서 순서를 바꿀 때, 시간 순으로 정렬되지 않으면 애니메이션이 이상하게 작동했다:
const addKeyframe = (objectId, property, value) => {
const newKeyframe = {
id: generateId(),
time: currentTime,
[property]: value
};
setObjects(objects =>
objects.map(obj => {
if (obj.id === objectId) {
const updatedKeyframes = [...obj.keyframes, newKeyframe]
.sort((a, b) => a.time - b.time); // 시간 순 정렬 필수
return { ...obj, keyframes: updatedKeyframes };
}
return obj;
})
);
};
3. 실시간 미리보기 성능
속성을 조정할 때 실시간으로 미리보기를 보여주려고 했는데, 너무 자주 업데이트되면 브라우저가 버벅거렸다:
// 디바운싱으로 해결
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};
const PropertyPanel = () => {
const [tempValue, setTempValue] = useState(0);
const debouncedValue = useDebounce(tempValue, 100);
useEffect(() => {
updateObject(selectedObject.id, { position: debouncedValue });
}, [debouncedValue]);
// ...
};
결론: 완벽한 도구는 없다
몇 주간의 삽질 끝에 어느 정도 쓸 만한 에디터가 나왔다. 하지만 여전히 부족한 점이 많다:
잘된 점들
- 모듈화된 구조: 각 기능이 독립적으로 작동
- 성능 최적화: 불필요한 리렌더링 최소화
- 사용자 경험: 직관적인 키프레임 관리
아직 부족한 점들
- 복잡한 애니메이션: 베지어 곡선, 이징 함수 지원 부족
- 실행 취소/다시 실행: 상태 관리가 복잡해서 미완성
- 파일 저장/불러오기: JSON 직렬화 문제
깨달은 점들
- 남의 코드의 구조를 그대로 차용하면 안 된다: 패러다임이 다르면 완전히 다시 설계해야 함
- 성능은 나중에 생각하면 안 된다: 처음부터 고려해야 함
- 사용자 경험이 가장 중요하다: 기능이 많아도 쓰기 어려우면 의미 없음
결국 완벽한 도구는 없다는 걸 다시 한 번 깨달았다. Remotion Studio가 답답해서 시작한 프로젝트였는데, 막상 만들어보니 왜 기존 도구들이 그런 식으로 만들어졌는지 이해가 됐다.
그래도 이 과정에서 Remotion과 React, GSAP에 대해 많이 배웠으니 의미 있는 삽질이었다고 생각한다. 언젠가는 정말 쓸 만한 에디터를 만들 수 있을 거다… 아마도.
아.마.도