재사용된 것은 내가 추상화하지 않은 쪽이었다 — 두 번째 제품이 드러낸 재사용의 경계
문제: 예측한 교체 지점과 실제 재사용 지점이 달랐다
첫 AI 영상 제작 파이프라인에는 음성 합성(TTS, Text-to-Speech)과 이미지·영상 생성의 구현체를 교체하기 위한 추상 계층이 있었다. BaseTtsClient와 BaseVideoClient를 두고 각 API의 호출 코드를 구현체에 넣었다. 모델이나 제공 서비스가 바뀌어도 호출부를 유지하려는 구조였다.
다음은 현재 남아 있는 BaseVideoClient에서 결과 모델을 제외한 인터페이스다. 이미지 생성에는 레퍼런스 정보인 elements와 생성에서 제외할 내용을 전달하는 negative_prompt도 들어간다.
from abc import ABC, abstractmethod
class BaseVideoClient(ABC):
@abstractmethod
def generate_image(
self,
prompt: str,
width: int = 1920,
height: int = 1080,
elements: list[dict] | None = None,
negative_prompt: str = "",
) -> str:
...
@abstractmethod
def image_to_video(
self, image_url: str, prompt: str, duration: int = 5
) -> str:
...
첫 제품에는 fal.ai를 거치는 KlingClient와 Kling API를 직접 호출하는 KlingDirectClient가 있다. 호출 경로와 인증 방식의 차이도 추상화가 다룰 수 있는 변화다. 다만 구현체가 두 개 있다는 사실만으로 다른 제품의 요구까지 흡수할 수 있다고 판단할 수는 없다. 반대로 두 번째 제품에 가져가지 않았다는 이유만으로 첫 제품 안의 효용이 없었다고 단정할 수도 없다.
내가 확인하고 싶었던 것은 다른 도메인에서도 이 파이프라인을 활용할 수 있는지였다. 두 번째 제품을 만들면서 재사용 대상으로 택한 것은 예상했던 프로바이더 계층보다 영상 조립 쪽에 가까웠다.
접근: 코드를 복사해 수정하며 경계를 확인했다
첫 제품이 한국어 영상을 다뤘다면, 두 번째 제품은 영어권 시장을 겨냥한 다른 주제와 캐릭터를 사용했다. 새 시장을 시도하는 동시에 기존 엔진을 가져다 쓸 수 있는지 확인하려는 목적이었다. 두 제품은 병행한 프로젝트다. 첫 제품을 끝내고 다음 제품으로 넘어간 순서로 해석하지 않는다.
여기서 말하는 fork는 GitHub의 저장소 기능이 아니라 코드를 복사한 뒤 별도로 수정한 방식이다. 공통 코드를 패키지로 추출해 함께 쓰는 방법도 있었지만, 실제 선택은 복사 후 편집이었다. 두 제품의 변경을 따로 진행할 수 있는 대신, 공통 버그를 고쳐도 다른 쪽에 자동 반영되지는 않는다.
공유 패키지를 만들었다면 어떤 변경을 양쪽에 적용할지, 버전과 호환성을 어떻게 관리할지도 정해야 한다. 그렇다고 공유 패키지가 반드시 두 제품을 함께 수정하게 만드는 것은 아니다. 이 사례에서는 공통부를 먼저 확정하기보다 가져온 코드 중 무엇을 유지하고 바꿨는지 살펴볼 수 있었다.
비교에는 한계가 있다. 두 프로젝트는 이후에도 수정됐고, 현재 확인한 폴더에는 초기 복사 시점의 Git 이력이 없다. 현재 파일 간 차이를 모두 첫날의 수정량으로 계산할 수는 없다. 그래서 당시 작업 기록에서 확인되는 변경과 현재 코드에서 확인되는 구조를 구분했다.
구현: 조립기를 기반으로 삼고 생성 경로를 바꿨다
관련 디렉터리만 비교하면 아래와 같다. 첫 제품의 core/에는 프로바이더 인터페이스뿐 아니라 조립·큐 코드도 있으므로, 폴더 전체를 프로바이더 교체 계층과 같은 뜻으로 쓰지는 않는다.
# 첫 제품 — 관련 경로 발췌
core-worker/
├── core/
│ ├── tts/ # BaseTtsClient와 구현체
│ ├── video_engine/ # BaseVideoClient와 구현체
│ ├── assembly/
│ └── queue/
└── scripts/
├── capcut_assembler.py
├── presets/
└── generate_*.py
# 두 번째 제품 — 관련 경로 발췌, core/ 없음
core-worker/
└── scripts/
├── capcut_assembler.py # 복사한 조립기에 기능 추가
├── presets/ # 구성 재사용, 일부 설정 변경
├── generate_tts.py # 음성 API 직접 호출
├── generate_images.py # Flux + LoRA 이미지 생성
├── train_lora.py
└── generate_hook_videos.py
조립기와 프리셋: 그대로 복사한 뒤 필요한 부분을 바꿨다
capcut_assembler.py는 음성, 이미지, 자막 데이터를 받아 CapCut 편집 프로젝트를 구성한다. 전환, 필터, 자막 스타일, 이미지에 확대·이동을 주는 Ken Burns 효과는 presets/에서 설정한다. 이 조립 구조를 가져와 두 번째 제품의 기반으로 삼았다.
다만 무수정 재사용은 아니었다. 당시 조립 작업 기록에는 TTS 출력 필드 sceneNum을 조립기가 기대하는 sceneOrder로 맞추고, 영어 자막에 맞춰 최대 글자 수를 30에서 55로 바꾼 내용이 있다. 인트로 파일이 없는 상황에 맞춰 --no-intro 옵션도 사용했다. 효과음과 도입부 영상 처리 역시 추가됐다.
현재 두 폴더를 비교해도 프리셋의 파일명은 같지만 내용까지 모두 같지는 않다. filters.py와 transitions.py는 동일하고, config.py, ken_burns.py, subtitle_style.py에는 차이가 있다. 따라서 재사용한 것은 완성된 코드를 수정 없이 붙이는 방식이 아니라, 조립 구조와 기존 설정을 출발점으로 삼는 방식이었다.
이 코드를 추상화가 전혀 없는 영역이라고 부르기도 어렵다. 조립 함수와 프리셋 분리 자체가 역할을 나눈 설계다. 이 글에서 대비하는 것은 프로바이더 교체용 상속 계층과, 실제 작업을 수행하는 조립 코드의 이식성이다.
생성 경로: 기존 인터페이스를 가져오지 않았다
두 번째 제품의 관련 스크립트는 BaseVideoClient나 BaseTtsClient를 가져오는 대신 API를 직접 호출한다. 이미지 생성에는 Flux와 학습한 LoRA(Low-Rank Adaptation) 가중치를 사용했다. 아래는 generate_images.py의 요청에서 핵심 필드만 남긴 예제다. 실제 파일에는 생성 옵션과 재시도, 다운로드 처리도 있다.
result = fal_client.subscribe(
"fal-ai/flux-lora",
arguments={
"prompt": prompt,
"loras": [{"path": lora_url, "scale": 1.0}],
"image_size": "landscape_16_9",
},
)
image_url = result["images"][0]["url"]
이 호출은 LoRA 가중치를 적용하는 텍스트 기반 이미지 생성 API다. 첫 제품의 인터페이스에도 이미지 생성이 있었으므로, 이미지 기반 영상 생성 전체를 이미지 생성으로 바꿨다는 설명은 정확하지 않다. 여기서 바뀐 것은 주로 장면 이미지를 만드는 모델과 캐릭터 표현 방식이었다.
기존 서명에는 LoRA 경로를 직접 받는 매개변수가 없었다. 그러나 생성자 설정이나 별도 요청 객체, 새 구현체로 확장할 여지는 있다. 직접 호출을 선택했다는 결과만으로 기존 인터페이스로 구현이 불가능했다고 증명할 수는 없다. 코드에서 확인되는 사실은 그 계층을 두 번째 제품에 가져오지 않았다는 것이다.
캐릭터 변형 규칙과 LoRA 학습 절차는 두 번째 제품에 맞게 만들었다. TTS 응답의 글자별 타임스탬프도 조립기가 읽는 characters, start_times, end_times 형태로 정규화했다. 글자별 타임스탬프 자체는 첫 제품에도 있었으므로 두 번째 제품에서 처음 생긴 기능으로 세지는 않는다. 앞단의 생성 방식을 바꿔도 조립기에 넘기는 데이터 형식을 맞추는 작업이 필요했다.
결과: 하루 만에 동작시킨 파이프라인
당시 작업을 마친 뒤 나는 두 번째 파이프라인이 하루 만에 동작했다고 회고했다. 기존 조립기를 기반으로 영상을 만들 수 있었고, fork 과정이 매끄러웠다는 경험도 남겼다. 조립 기능을 처음부터 다시 구현하지 않고 새 도메인에 필요한 부분을 더한 결과였다.
하루라는 값은 당시의 회고이며 재사용 효과만 분리해 측정한 실험 결과는 아니다. 새로 만드는 경우와 비교한 시간이 없고, 이미 쌓인 제작 경험도 영향을 줬을 수 있다. 여기서 확인한 성과는 기존 조립 구조로 두 번째 사용처를 만들었다는 점이다. 생성 계층을 가져오지 않은 사실과 조립 코드를 수정해 재사용한 사실이 함께 남았다.
학습: 교체할 지점과 재사용할 자산을 따로 본다
내가 우선한 경계와 실제 가져간 경계가 달랐다. 프로바이더 교체를 예상해 만든 상속 계층보다 조립기와 프리셋이 두 번째 제품의 출발점이 됐다. 이 경험을 나는 추상화의 우선순위를 다시 보는 근거로 삼는다. 모든 추상 계층이 낭비였다는 결론까지 내리지는 않는다.
재사용할 때는 입력 계약도 함께 옮긴다. 파일을 복사한 뒤에도 필드 이름, 자막 길이, 타임스탬프 형식을 맞춰야 했다. 조립기 자체의 기능과 그 조립기가 기대하는 데이터 형태를 함께 확인해야 같은 작업을 이어 갈 수 있다.
공통 모듈 추출은 반복되는 변경을 보고 판단한다. 다음 제품에서도 먼저 공통 동작과 달라지는 요구를 확인하려 한다. 복사한 코드에 같은 수정이 반복된다면 공통화할 근거가 생긴다. 그때 독립 배포와 버전 관리 비용까지 비교해 공유할 경계를 정하겠다.