MediaInsert 확장과 Scene 통합 - 또 다른 리팩토링의 여정
MediaInsert 확장과 Scene 통합 - 또 다른 리팩토링의 여정
또 다른 바퀴의 재발명
트랜지션 모듈화를 끝내고 나니 또 다른 문제가 보였다. MediaInsert라는 멀쩡한 컴포넌트가 있는데, Scene 시스템을 만들면서 비슷한 기능을 또 구현하고 있었다는 것이다. 이미지 표시하고, 오디오 재생하고, 자막 넣고… 이런 건 MediaInsert가 이미 다 하고 있는 일 아닌가?
문제는 MediaInsert는 팟캐스트용으로 설계되어 있고, Scene은 미디어 삽입 프레젠테이션으로 만들어졌다는 점이다. 같은 일을 하지만 인터페이스가 다르다. 전형적인 중복 코드의 냄새가 났다.
그래서 MediaInsert를 확장해서 Scene 시스템에도 쓸 수 있게 만들어보자.
확장의 딜레마: 상속 vs 컴포지션
처음엔 단순하게 생각했다. SceneInsert 컴포넌트를 만들어서 MediaInsert를 감싸면 되겠다고. 하지만 막상 구현해보니 생각보다 복잡했다.
const SceneInsert: React.FC<{ config: SimpleSceneConfig }> = ({ config }) => {
const { sceneNumber, text, image, audio, startFrame, duration } = config;
// MediaInsert 설정으로 변환
const mediaConfig: MediaInsertConfig = {
type: 'image',
src: image,
startFrame,
duration,
style: {
size: 'fullscreen',
zIndex: 10,
opacity: 1
}
};
return (
<Sequence from={startFrame} durationInFrames={duration}>
<MediaInsert config={mediaConfig} />
<Audio src={staticFile(audio)} volume={1.0} />
<SemiGreyCaption text={text} isActive={true} />
</Sequence>
);
};
이게 바로 컴포지션 패턴이다. MediaInsert를 상속받는 게 아니라, 내부에서 사용하는 것이다. 기존 기능은 그대로 두고 필요한 부분만 추가한다. 오디오와 자막을 덧붙이는 식으로 말이다.
처음엔 이게 맞나 싶었다. 단순히 래퍼를 하나 더 만드는 게 아닌가? 하지만 써보니 장점이 명확했다. MediaInsert의 모든 기능을 그대로 쓸 수 있으면서도, Scene에 필요한 추가 기능들을 자연스럽게 붙일 수 있었다.
트랜지션의 선택적 적용
Scene 시스템을 만들면서 또 다른 고민이 생겼다. 트랜지션을 항상 써야 하나? 때로는 단순하게 장면만 바뀌는 게 나을 수도 있지 않나?
그래서 두 가지 버전을 만들었다. 트랜지션이 있는 버전과 없는 버전.
// 트랜지션 없는 단순한 버전
export const SimpleScenePlayer: React.FC<SimpleScenePlayerProps> = ({ scenes }) => {
return (
<AbsoluteFill>
{scenes.map((scene) => (
<SceneInsert
key={`scene-${scene.sceneNumber}`}
config={scene}
/>
))}
</AbsoluteFill>
);
};
// 트랜지션이 있는 버전
export const ScenePlayerWithTransitions: React.FC<ScenePlayerWithTransitionsProps> = ({
scenes,
transitionType = 'fade',
transitionDuration = 30
}) => {
const TransitionComponent = getTransitionComponent();
return (
<AbsoluteFill>
<TransitionSeries>
{scenes.map((scene, index) => (
<React.Fragment key={`scene-group-${scene.sceneNumber}`}>
<TransitionSeries.Sequence durationInFrames={scene.duration}>
<SceneInsert config={scene} />
</TransitionSeries.Sequence>
{index < scenes.length - 1 && (
<TransitionSeries.Transition
durationInFrames={transitionDuration}
transitionComponent={TransitionComponent}
/>
)}
</React.Fragment>
))}
</TransitionSeries>
</AbsoluteFill>
);
};
이렇게 하니 상황에 맞게 선택할 수 있다. 빠르게 프로토타입을 만들 때는 SimpleScenePlayer를, 완성도 높은 영상을 만들 때는 ScenePlayerWithTransitions를 쓰면 된다.
타입 시스템의 진화
Scene 시스템을 만들면서 타입 정의도 계속 진화했다. 처음엔 단순한 이미지 Scene만 생각했는데, 나중에 섹션 구분자 같은 것도 필요해졌다. 그래서 Union 타입을 써서 확장 가능한 구조로 만들었다.
export interface BaseSceneConfig {
sceneNumber: number;
startFrame: number;
duration: number;
}
export interface ImageSceneConfig extends BaseSceneConfig {
type: 'scene';
text: string;
image: string;
audio: string;
}
export interface SectionSceneConfig extends BaseSceneConfig {
type: 'section';
component: () => React.ReactElement;
}
export type UnifiedSceneConfig = ImageSceneConfig | SectionSceneConfig;
이제 Scene 배열에 이미지 Scene과 섹션 구분자를 섞어서 넣을 수 있다. 타입 시스템이 알아서 구분해주니까 런타임 에러도 줄어든다.
설계 철학의 변화: 단순함에서 복잡함으로
처음 Scene 시스템을 구상할 때는 정말 단순했다. “이미지 하나, 오디오 하나, 자막 하나 넣으면 끝”이라고 생각했다. 그래서 초기 타입도 이랬다:
// 초기 버전 - 너무 단순했던 시절
interface SimpleScene {
image: string;
audio: string;
text: string;
}
하지만 실제로 영상을 만들다 보니 요구사항이 계속 늘어났다. “섹션 구분자도 넣어야 하고, 이미지 크기도 조절해야 하고, 오디오 볼륨도 다르게 해야 하고…” 그러다 보니 타입이 점점 복잡해졌다.
가장 큰 깨달음은 “확장 가능한 설계”라는 게 생각보다 어렵다는 것이었다. 처음부터 모든 걸 예상할 수는 없으니까. 그래서 Union 타입을 쓰게 됐다.
// 진화한 버전 - 현실을 받아들인 후
export type UnifiedSceneConfig = ImageSceneConfig | SectionSceneConfig | VideoSceneConfig | AnimationSceneConfig;
이 과정에서 깨달은 건, 완벽한 설계는 없다는 것이다. 그냥 변화에 잘 적응할 수 있는 설계가 좋은 설계다.
실제 사용 경험: 이론과 현실의 괴리
새로운 Scene 시스템을 실제 프로젝트에 적용해보니 예상치 못한 문제들이 터져나왔다. 가장 큰 문제는 타이밍이었다.
TransitionSeries를 쓸 때는 모든 Scene의 startFrame을 0으로 설정해야 한다. 시스템이 알아서 순차적으로 배치하기 때문이다. 하지만 SimpleScenePlayer를 쓸 때는 실제 startFrame을 계산해서 넣어야 한다. 같은 데이터인데 사용하는 Player에 따라 다르게 설정해야 하는 것이다.
// TransitionSeries용 - startFrame은 모두 0
const scenesForTransition = [
{ sceneNumber: 1, startFrame: 0, duration: 120 },
{ sceneNumber: 2, startFrame: 0, duration: 90 }
];
// SimpleScenePlayer용 - 실제 startFrame 계산 필요
const scenesForSimple = [
{ sceneNumber: 1, startFrame: 0, duration: 120 },
{ sceneNumber: 2, startFrame: 120, duration: 90 }
];
이런 불일치 때문에 실수가 자주 발생했다. Player를 바꿨는데 Scene 설정을 안 바꿔서 타이밍이 엉망이 되는 경우가 많았다.
또 다른 문제는 디버깅이었다. Scene이 제대로 렌더링되지 않을 때 원인을 찾기가 어려웠다. MediaInsert 내부 문제인지, SceneInsert 래퍼 문제인지, 아니면 Player 문제인지 구분하기 힘들었다. 레이어가 많아질수록 디버깅은 어려워진다.
성능과 복잡성의 트레이드오프
기능이 늘어나면서 성능 이슈도 생겼다. 특히 트랜지션이 있는 버전에서 메모리 사용량이 급격히 증가했다. TransitionSeries가 모든 Scene을 메모리에 로드해두기 때문이다.
// 메모리 사용량 모니터링을 위한 디버깅 코드
{process.env.NODE_ENV === 'development' && (
<div style={{ /* 스타일 */ }}>
메모리 사용량: {(performance as any).memory?.usedJSHeapSize || 'N/A'}<br />
활성 Scene: {activeScenes.length}개<br />
로드된 이미지: {loadedImages.length}개
</div>
)}
해결책으로 lazy loading을 시도해봤지만, Remotion의 특성상 모든 프레임을 미리 계산해야 해서 효과가 제한적이었다. 결국 Scene 개수가 많은 프로젝트에서는 SimpleScenePlayer를 쓰는 게 나았다.
이게 바로 트레이드오프다. 멋진 트랜지션을 원하면 성능을 포기해야 하고, 성능을 원하면 트랜지션을 포기해야 한다. 완벽한 해결책은 없다.
개발자 경험(DX): 편의성 vs 복잡성
새로운 시스템이 실제로 개발 생산성을 높였는가? 솔직히 말하면 반반이다.
좋아진 점:
- MediaInsert의 검증된 로직을 재사용할 수 있어서 버그가 줄었다
- 타입 시스템 덕분에 실수를 컴파일 타임에 잡을 수 있다
- 트랜지션을 쉽게 적용할 수 있어서 영상 품질이 올라갔다
나빠진 점:
- 설정해야 할 옵션이 너무 많아졌다
- 어떤 Player를 써야 할지 매번 고민해야 한다
- 문제가 생겼을 때 원인을 찾기가 어려워졌다
특히 새로운 팀원이 들어왔을 때 설명하기가 힘들어졌다. “MediaInsert가 있고, SceneInsert가 있고, SimpleScenePlayer와 ScenePlayerWithTransitions가 있는데…” 이런 식으로 설명하다 보면 상대방 눈이 돌아간다.
// 옵션이 너무 많아진 컴포넌트
<YTep1Shorts2Main
useSceneMode={true}
useTransitions={true}
transitionType="fade"
transitionDuration={30}
enableDebug={true}
optimizeMemory={false}
// ... 더 많은 옵션들
/>
편의성을 높이려다가 복잡성만 증가시킨 건 아닌가 하는 생각이 든다.
미래 확장성에 대한 고민
현재 구조가 미래에도 잘 작동할까? 이게 가장 큰 고민이다.
지금은 이미지와 섹션 구분자만 있지만, 앞으로 비디오 Scene, 애니메이션 Scene, 인터랙티브 Scene 등이 추가될 수 있다. Union 타입으로 확장은 가능하지만, Player 로직은 점점 복잡해질 것이다.
// 미래의 모습 - 이렇게 될까 봐 무섭다
export const UniversalScenePlayer = ({ scenes, options }) => {
return (
<AbsoluteFill>
{scenes.map(scene => {
switch (scene.type) {
case 'image': return <ImageSceneRenderer />;
case 'video': return <VideoSceneRenderer />;
case 'animation': return <AnimationSceneRenderer />;
case 'interactive': return <InteractiveSceneRenderer />;
case 'ar': return <ARSceneRenderer />;
case 'vr': return <VRSceneRenderer />;
// ... 끝없는 case문들
}
})}
</AbsoluteFill>
);
};
이런 식으로 가면 결국 거대한 switch문 덩어리가 될 것이다. 그때 가서 다시 리팩토링해야 할까? 아니면 지금부터 다른 패턴을 고민해야 할까?
가장 무서운 건 지금 내린 설계 결정들이 미래에 발목을 잡을 수도 있다는 점이다. MediaInsert를 확장하는 방식이 맞았을까? 아니면 처음부터 새로운 시스템을 만드는 게 나았을까?
현실과의 타협
결국 완벽한 설계는 없다는 걸 받아들여야 한다. 지금 당장 필요한 기능을 만족시키면서, 미래 변화에도 어느 정도 대응할 수 있는 구조면 충분하다.
중요한 건 언제든 갈아엎을 수 있다는 마음가짐이다(ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ). 지금 구조에 너무 애착을 갖지 말고, 더 나은 방법이 생기면 과감히 바꿀 수 있어야 한다. 그게 바로 개발의 현실이다(ㅅㅂㅋㅋㅋㅋㅋㅋㅋ).
MediaInsert 확장이 완벽한 해답은 아니지만, 지금 상황에서는 합리적인 선택이었다. 기존 코드를 재사용하면서도 새로운 기능을 추가할 수 있었으니까. 미래에 더 나은 방법이 생기면 그때 다시 고민하면 된다.
끝없는 리팩토링의 여정 말이다.