
이전 글에서 n8n으로 PDF 번역 서비스를 만들면서 OOM과 씨름했던 이야기를 했다. 이번에는 동영상 자막 생성 서비스를 만들 차례였는데, 같은 실수를 또 하게 될 줄은 몰랐다.
동영상 자막 생성 서비스의 백엔드는 이미 있었다. GPU 서버에서 돌아가는 ASR 파이프라인이 오디오 추출부터 화자분리, 텍스트 교정, SRT 생성까지 전부 처리한다. 필요한 건 사용자가 동영상을 올리고 결과를 받을 수 있는 웹 인터페이스뿐이었다.
PDF 번역 서비스를 n8n으로 만들었으니 이것도 같은 패턴으로 가면 되겠다 싶었다. 파일 업로드 받고, 비밀번호 검증하고, GPU 서버에 자막 요청 보내고, 상태 폴링하고, 결과 다운로드까지. 노드 15개짜리 워크플로가 완성됐다.
테스트로 300MB짜리 동영상을 올리자마자 n8n 파드가 죽었다. PDF 때와 같은 문제다. n8n이 K8s에서 1Gi 메모리로 돌아가고 있는데 동영상 파일을 Base64로 인코딩하면 크기가 2.3배로 불어난다. multipart로 포워딩해도 마찬가지. 수백MB 파일을 1Gi 메모리 안에서 중계하는 건 애초에 불가능한 구조였다.
PDF 때는 파일 크기가 수십MB 수준이라 메모리 최적화로 버텼지만 동영상은 그 선을 넘어섰다. n8n이 파일을 만지게 두면 안 된다는 걸 깨달았다.
발상을 뒤집었다. n8n이 파일을 중계할 필요가 없다면? 브라우저가 GPU 서버와 직접 통신하면 된다. n8n은 그 브라우저 페이지만 내려주면 끝이다.
15개 노드가 2개로 줄었다. Webhook GET 요청을 받으면 HTML 페이지를 응답하는 게 전부다. 파일 업로드, 자막 요청, 상태 폴링, 결과 다운로드는 전부 HTML 안의 JavaScript가 GPU 서버 API를 직접 호출한다.
한 가지 문제가 남았다. 브라우저는 asr.museck.com 하나만 알고 있는데 n8n과 GPU 서버라는 두 개의 백엔드가 있다. Traefik IngressRoute에서 PathPrefix로 해결했다.
# /webhook/* -> n8n (HTML 페이지만)
# /api/* -> GPU 서버 (strip-api-prefix Middleware)
# / -> /webhook/video-transcribe-public (리다이렉트)/webhook/* 경로는 n8n으로, /api/* 경로는 GPU 서버로 보낸다. GPU 서버 쪽에는 strip-api-prefix 미들웨어를 달아서 /api prefix를 벗긴 뒤 전달한다. GPU 서버는 K8s 클러스터 밖에 있지만 headless Service와 Endpoints로 K8s 내부 서비스처럼 추상화해놨기 때문에 Traefik에서 바로 라우팅할 수 있다.
브라우저에서 수백MB 파일을 직접 올리다 보니 업로드 진행률이 필수가 됐다. fetch API로는 업로드 progress 이벤트를 받을 수 없어서 XMLHttpRequest를 썼다.
const apiBase = '/api'; // Traefik이 GPU 서버로 라우팅
function uploadForm(url, fd) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100);
updateProgress('업로드 중... ' + pct + '%');
}
};
xhr.send(fd);
});
}리팩터링 끝나고 배포했더니 API 호출이 전부 CORS 에러로 막혔다. n8n v1.103.0부터 XSS 방지를 위해 Webhook HTML 응답을 sandbox iframe으로 감싸는데, 이러면 Origin이 null이 된다. GPU 서버에 Access-Control-Allow-Origin: *를 설정하거나, N8N_INSECURE_DISABLE_WEBHOOK_IFRAME_SANDBOX=true 환경변수로 sandbox를 비활성화해야 한다.
n8n 문서 어디에도 나오지 않는 동작이라 디버깅에 시간을 꽤 썼다. iframe 안에서 DevTools 열어보고 나서야 원인을 알았다.
n8n은 노코드 도구다. 뭐든 할 수 있을 것 같은 느낌을 준다. 실제로 PDF 번역 서비스는 n8n이 API 프록시 역할까지 잘 해냈다. 그래서 동영상도 같은 패턴으로 가려 했는데 파일 크기라는 물리적 한계 앞에서 무너졌다.
돌이켜보면 n8n에서 파일을 만지게 한 것 자체가 설계 실수였다. 메모리 1Gi짜리 컨테이너에 수백MB 파일 프록시를 시키면 안 된다. 노코드 도구든 마이크로서비스든 각 컴포넌트가 감당할 수 있는 범위를 먼저 따져야 한다. 메모리 한도, 파일 크기, 처리 시간. 이 세 가지가 역할 경계를 정하는 기준이 됐다.
15개 노드에서 2개로 줄인 건 기능을 줄인 게 아니다. 책임을 적절히 분리한 것이다. n8n은 HTML만 내려주고 무거운 일은 GPU 서버가 직접 받는다. 오히려 더 깔끔해졌다.