AI가 작성한 코드라도 미리보기와 최종 출력을 보장하는 방법: MeltLyric의 결정론과 격리 실행
요약
AI 작곡 MV 제작 툴 MeltLyric은 미리보기와 최종 출력 간의 불일치(Drift) 문제를 해결했습니다. 핵심은 모든 연출 함수가 오직 시간(t)만을 인수로 사용하도록 통일하여 결정론적 원칙을 확립한 것입니다. 이를 통해 실행 경로가 달라져도 일관되고 정확한 결과물을 보장합니다.
핵심 포인트
- 미리보기와 출력이 어긋나는 현상(Drift)은 서로 다른 실행 경로 때문입니다.
- 모든 영상 연출 함수는 시간(t)만을 유일한 인수로 사용하도록 통일했습니다.
- 미리보기 전용 가이드는 별도의 함수로 분리하여 최종 출력 루프에서 제외합니다.
- 움직임 등 모든 연출은 난수 대신 시간을 기반으로 결정론적 원칙을 적용합니다.
Phase 1과 Phase 2는 '자체 OS'라는 내부 시스템에 대한 이야기였습니다. 여기부터는 Phase 3, 즉 실제로 제작하고 있는 프로덕트에 대한 이야기로 넘어갑니다. 첫 번째 작품은 MeltLyric입니다.
(멜트리릭) — AI가 작곡한 곡에 가사 애니메이션 MV를 입히는 데스크톱 툴입니다. 곡과 가사를 입력하면 타이밍을 맞춰 MP4 파일로 출력합니다. 이 글은 개발 중인 프로덕트를 다루고 있으며, 작성 시점은 2026년 10월 기준 내용입니다.
이 글에서 다룰 내용은 기능 소개가 아니라 하나의 질문입니다. AI에게 연출 코드를 작성하게 하더라도, 미리보기로 본 영상과 최종 출력된 MP4 파일이 일치할까요?
결론부터 말씀드리자면, 일치시키기 위한 메커니즘을 코드 작성을 하기 전에 먼저 만들었습니다. 그 내용을 살펴보겠습니다.
미리보기와 출력이 어긋나는 경우 (Drift)
영상/애니메이션 관련 툴에서 흔히 발생하는 문제가 있습니다. 화면의 미리보기에서는 깨끗하게 작동하는데, 실제로 출력된 파일을 열어보면 내용이 다른 경우입니다.
MeltLyric에서도 실제로 한 번 오류가 발생했습니다. 미리보기용 분홍색 비트 프레임이 최종 출력 MP4에 혼입된 것입니다. 미리보기와 출력이 같은 캔버스를 공유하고 있었기 때문에, 미리보기 전용 표시까지 영상에 새겨진 것입니다. 설계 기록에는 '가이드의 가시성은 모드에서 반드시 제거한다'는 원칙을 철저히 남겼습니다.
이 원인을 일반화하면, 미리보기와 출력이 서로 다른 실행 경로를 거치기 때문입니다. 따라서 경로가 다르더라도, 그리는 함수(drawing function)는 하나로 통일했습니다.
render(t): 같은 함수를, 다른 시계로 구동하기
영상을 그리는 함수는 오직 시간(time)만을 인수로 받습니다.
render(t: number): void {
// ...
const feature = this.featureTrack.at(t);
...
}
인수는 절대 시간 t (초)만 사용합니다. 음성 특징량(음량이나 비트 등) 역시 t로 미리 계산된 배열에서 가져옵니다. 벽시계의 시간 변화나, 드로잉 타이밍의 차이에 의존하지 않습니다.
미리보기는 오디오의 현재 시간을 전달받습니다.
renderer?.render(audio.currentTime);
renderPreviewOverlay(audio.currentTime);
출력은 시작 위치에 프레임 번호를 더해 시간으로 변환하여 전달합니다.
const frameTime = safeRangeStart + frame / opts.fps;
await renderer.prepareFrame?.(frameTime, { strict: true });
renderer.render(frameTime);
호출 방식만 다를 뿐, 영상을 결정하는 것은 동일한 render 함수입니다. 미리보기 전용 가이드는 renderPreviewOverlay라는 별도의 함수로 덧씌우는 형태로 처리되어 있으며, 출력 루프에는 이 호출이 없습니다. 앞서 언급된 분홍색 프레임 문제는 이러한 분리(separation)를 통해 해결했습니다.
연출에도 규칙이 있습니다. 흔들림 같은 움직임은 난수(random number)가 아닌 시간에서 결정되는 값으로 만듭니다. '미리와 출력은 다른 타이밍에 구동되므로, 비결정적인 연출은 미리보기와 다른 영상을 만들어낼 수 있다'는 이유로 모든 연출에 적용하는 원칙을 세웠습니다.
실제 화면에서 비교해 보겠습니다. 동일한 시간(t=03:21)의 에디터 미리보기와 출력된 MP4의 1프레임입니다.

에디터 미리보기 (t=03:21). 하늘색 프레임은 미리보기 전용 가이드입니다.

출력된 MP4의 동일 시간 1프레임. 가이드 프레임은 나타나지 않습니다.
축소 비율이 다르고 이미지도 압축되어 있어, 픽셀 하나하나를 비교하는 것은 아닙니다. 구도가 갖춰져 있고, 가이드만 사라진 것을 눈으로 확인할 수 있는 정도의 비교입니다. '일치'의 근거는 이 이미지가 아니라, 동일한 함수를 거친 구조 자체에 있습니다.
FeatureTrack: 실시간 분석을 미리 끝내두기
소리에 반응하여 글자가 움직이는 연출에는 음성 분석 결과가 필요합니다. Web Audio API의 AnalyserNode는 현재 재생되는 소리를 분석하는 API로, 출력처럼 '소리를 재생하지 않고 고속으로 프레임을 처리'해야 하는 상황에서는 동일하게 사용할 수 없습니다.
설계 방향은 분석을 미리 완료해 두는 것입니다. 모든 곡을 오프라인(offline)에서 분석하여, 프레임 단위의 특징량(feature quantity)을 하나의 배열로 만듭니다. 미리보기든 출력이든, featureTrack.at(t)를 통해 이 배열을 인수로 사용합니다. 겉으로는 실시간으로 소리에 반응하는 것처럼 보이지만, 실제로는 '미리 녹화된 반응'을 재생하고 있는 것입니다.
6월: 로컬 LLM에게 코드를 작성하게 하여 허위 정보를 만들다
6월에 로컬에서 구동되는 Qwen2.5-Coder(14B, Ollama 경유)를 시도했다. 형식을 제한한 JSON(프로젝트 조작 패치)을 생성하도록 하는 것까지는 성공했다. 하지만 코드 편집의 dry run 과정에서 존재하지 않는 스크립트를 지정하거나, 이 프로젝트에서는 사용하지 않는 CommonJS 문법을 가져오는 문제가 발생했다.
결정 기록 작성 방식은 '금지'된 것은 아니었다. 조건부 판단이었다. 즉, '더 엄격한 패치 하네스(patch harness)가 만들어질 때까지는 제안 생성에만 한정한다'는 것이었다.
아울러, 효과 추가는 JSON 데이터(Motion Combo pack)만으로 완결되도록 했다. 렌더러(renderer) 코드를 건드리지 않기 때문에 기계적으로 검증이 가능하다. 6월 기준으로 AI가 접촉할 수 있는 범위는 데이터에 한정되어 있었다.
(추신: 여기서 언급된 Motion Combo와 MeltLyric 내장 AI 편집 기능은 2026년 10월 17일에 제거되었다. 현재의 MeltLyric에는 AI가 탑재되어 있지 않으며, 외부 에이전트가 MCP를 통해 조작하는 형태로 변경되었다.)
9월: 코드를 작성하도록 결정하다
9월 24일, 방침을 바꿨다. 계기는 제품상의 필요성에서 비롯된 나의 판단이었다. '똑같은 효과만 만들 수 있다'는 것은 의미가 없다는 것이다. 곡마다 에이전트가 효과 구성 요소(component)를 작성할 수 있도록 한다. 이 구성 요소의 내용은 코드가 된다.
즉, AI에게 코드를 작성하도록 시킨다. 여기서 먼저 만든 것이 '멈추게 하는 메커니즘'이다.
배치 위치와 진입점. 작성된 코드 구성 요소는 제작자의 PC 컴포넌트 폴더에만 배치한다. 프로젝트에는 구성 요소의 ID만 포함하고, 코드는 넣지 않는다. 작성을 위한 진입점은 MCP의 write_part이며, 화면상에 코드를 작성하는 칸은 만들지 않았다.
격리 실행(Isolated execution). 구성 요소는 Web Worker 내에서 구동된다. fetch, XMLHttpRequest, WebSocket, setTimeout, Date, eval, WebAssembly 등을 차단하고, Math.random은 수정 불가능한 형태로 무력화했다.
Object.defineProperty(Math, 'random', { value: blocked('Math.random'), writable: false, configurable: false });
난수가 필요하다면, 시드(seed)를 전달하는 util.rand(seed)를 사용한다. import는 금지하며, 단일 파일 내에서 완결되도록 한다. 캔버스 크기 변경이나 내장 구성 요소와 동일한 ID도 거부된다. 무한 루프는 호스트 측이 시간으로 감시하여 중단시킨다.
결정론 검증(Determinism verification). 구성 요소를 작성한 후, validate_part로 검사한다. 곡 내 10개 지점에서 실제 라인이나 구간을 입력으로 넣어 그려보게 한다. 예외가 발생하면 불합격이다. 그리고 같은 입력을 두 번 그려보고, 픽셀이 다르면 불합격이다. 단순히 사용을 차단하는 것을 넘어, 같은 입력이 같은 그림이 되는지 실제로 그려서 확인하고 있다. 드로잉 시간의 중앙값이 16ms를 초과하면 불합격이고, 8ms를 초과하면 경고가 발생한다.
테스트. verify-part-sandbox.mjs는 악의적인 구성 요소 17종류를 흘려보내 모든 것이 blocked되는지 확인한다. 여기에는 fetch, XHR, Math.random, Date, DOM, eval, 캔버스 확대 등이 포함된다. 무한 루프는 멈추고, 중단된 후에도 복구된다. (테스트의 존재와 내용은 직접 읽어 확인했다. 이 기사를 위해 재실행하지는 않았다.)
움직인 선과, 움직이지 않은 선
이 부분은 사실과 해석을 분리하여 작성한다. 사실은 6월에 '제안 생성까지'로 결정했고, 9월에 코드를 작성하도록 결정했다는 것이다. 그리고 9월의 기록에는 6월의 조건을 충족했기 때문이라고 쓰여 있지 않다. 9월의 동기는 제품으로서의 필요성이었다.
해석은 나중에 되돌아본 것이다. 6월에 작성한 '더 엄격한 하네스'에 해당하는 것이 결과적으로 9월에 만들어진 것이다. 하지만, 같은 선 위를 움직인 것은 아니다. 6월은 앱 본체의 코드를 LLM에게 재작성하도록 하는 이야기였다. 9월은 격리된 단일 파일 내에서 구성 요소를 작성하게 하는 이야기다. 접촉하는 장소만 바꿨을 뿐, 다른 문제를 해결하고 있다.
움직이지 않은 선도 있다. 미리보기 = 출력이다. 코드를 작성하도록 하더라도, 시간과 입력이 같다면 그림은 같아야 한다는 전제는 양보할 수 없었다. 이 전제를 지키기 위해, 작성하게 하기 전에 검증을 만들었다.
다만, 완벽하게 일치한다고 말할 수 없는 부분도 있다. 코드 컴포넌트 레이어는 미리보기가 무거울 경우 해상도를 절반으로 낮춰 따라가지만(출력은 항상 등배율), 보장하는 것은 '같은 입력에는 같은 결과'라는 것이지, 미리보기의 픽셀이 출력과 동일하다는 의미는 아니다.
본 기사의 코드는 Claude와 함께 읽으면서 설계서의 기술 내용과 실제 코드가 일치하는지 확인하며 작성했다. 실제로 설계서에는 render(t, features)라고 적혀 있었지만, 코드는 render(t)였다.
요약
AI에게 코드를 작성하게 하려면, 작성하기 전에 멈추는 메커니즘을 만들어야 한다. 결정론을 지키고 싶다면, 지키는 쪽을 실행 환경에 두어야 한다. 인간의 주의력에 의존하면 언젠가 깨진다.
다음 시간에는 이 컴포넌트의 기반을 사용하여 곡별 연출을 설계하는 이야기를 쓸 것이다.
토론

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기