n8n으로 유튜브 썰쇼츠 자동화 구축하기 1
요즘 자동화에 관심이 생겼다. 특히 youtube 채널을 자동화하고 싶은 마음이 들어서, n8n을 이용하여 youtube 영상을 자동으로 만들고, 업로드하는 워크플로우를 구축해보기로 했다.
어떤 영상을 만들어볼까 하다가 흔히 양산형 쇼츠라고 부르는, 커뮤니티 ui 레이아웃 안에 그림을 바꿔가며 썰을 푸는 썰쇼츠를 만들어보기로 결심했다.
사용하는 기술 스택은 Docker, n8n, JSON2Video, 구글 제미나이 모델이다.
1. Docker Desktop 설치
가장 먼저 Docker를 설치한다. Docker Desktop을 설치하면 컨테이너 활성화 상태도 쉽게 볼 수 있고 알아서 wsl 설치도 해줘서 간편 하다. Docker 사이트에 들어가면 쉽게 다운로드 받을 수 있다.
나는 가상화 설정이 안되어있어서 바로 실행되지는 않았다. 껐다가 켜면서 바이오스 설정으로 들어가 SVM Mode를 Enable로 바꿔주면 Docker가 정상적으로 동작한다. (인텔은 바이오스 메뉴 이름이 다른 듯 하다.)
2. n8n 컨테이너 생성
그 다음에는 Docker에 n8n 컨테이너를 올릴 차례다. 폴더를 하나 만들고, 그 안에 docker-compose.yml 파일을 만들어서 내용을 입력해준다. 텍스트 파일을 하나 추가해서 내용을 입력하고 이름과 확장자를 바꿔주면 쉽게 만들 수 있다.
services:
n8n:
image: docker.n8n.io/n8nio/n8n
container_name: n8n
restart: always
ports:
- "5678:5678"
environment:
- GENERIC_TIMEZONE=Asia/Seoul
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:
그리고 docker-compose.yml 파일을 만든 폴더에서 cmd를 실행해, 컨테이너를 생성하는 명령어를 입력해준다.
$ docker-compose up -d
명령어 실행이 완료되면 다시 Docker Desktop을 실행해서 컨테이너가 잘 만들어졌는지 확인하면 되는데, 무슨 버그가 있는건지 Docker Desktop을 닫고 다시 열려고 하면 안 열릴 때가 종종 있다. 이 떄는 작업 관리자에서 실행되고 있는 Docker Desktop 관련 프로세스를 모두 찾아 종료해주면 정상적으로 실행이 된다.
컨테이너가 잘 올라가서 Docker Desktop에서 확인이 되면 오른쪽 Port(s)의 5678:5678 링크를 눌러 n8n 페이지를 열어준다.
3. AI 모델 연결(Gemini)
n8n에 첫 노드로 Trigger manually 노드를 연결하고 나면 AI 모델을 연결할 차례다. 나는 Gemini 모델을 연결했다. 사실 AI 모델 비용을 내기 싫어서 알파카인지 뭐시긴지 로컬 AI 모델도 한 번 올려서 사용해봤는데, 생각보다 너무 바보여서 그냥 돈을 내기로 결정했다…
n8n에 Gemini 노드를 연결하려면 일단 구글 API Key를 만들어 Credential을 연결해야한다. 구글 AI 스튜디오에 접속해 왼쪽 아래의 Get API Key 버튼을 눌러 API Key를 생성해 Credential 세팅을 완료해준다. (비용이 얼마나오는지 정확히 계산해보고 싶었는데, 너무 테스트를 많이해서 알아보지를 못해 포기했다.)
썰을 만들어주는 모델은 2.5 flash 모델을 선택했다. 그냥 테스트 용으로 붙여봤는데, 사실 썰 만드는 용도로 쓰기에 그렇게 똑똑하지는 않다. 제대로 된 재밌는 썰을 뽑으려면 더 좋은 모델을 쓰거나, 아니면 다른 커뮤니티해서 크롤링하는 방법을 써야할 것 같다.
Gemini 노드를 붙이고 나면 썰을 생성할 프롬프트를 입력 해 준다. 나중에 썰 쇼츠를 만들 때 문장 단위로 잘라 이미지를 생성하고 tts가 읽게 할 것이기 때문에, 문장을 자르는 기준(구두점)과 문장의 글자 제한 수 등등을 자세히 작성했다. 그리고 tts는 남성 목소리로 고정을 할 예정이기 때문에 화자는 남성으로 고정했다.
유튜브 썰 스크립트를 작성해줘
[출력 형식 및 구성 원칙]
반드시 아래 JSON 형식을 유지하며, full_script는 다음의 서사 구조를 따른다.
1. 기 (1~3문장): 시청자의 호기심을 자극하는 강렬한 후킹 문장으로 시작.
2. 승 (4~7문장): 상황의 전개와 구체적인 사건 발생.
3. 전 (8~12문장): 예상치 못한 반전이나 갈등의 최고조.
4. 결 (13~16문장): 교훈이나 깔끔한 마무리.
[작성 규칙 - 엄격 준수]
- 시점: 1인칭 남성 화자('나')의 말투로 고정한다.
- 문장 길이: 한 문장은 공백 포함 30자에서 40자 사이로 작성한다.
- 문장 개수: 전체 이야기는 13문장 이상 16문장 이하로 구성한다.
- 구분자: 문장 끝은 오직 마침표(.), 물음표(?), 느낌표(!)로만 끝낸다.
- 금지: 쉼표(,)는 필수적일 때만 쓰고, 짧은 감탄사, 괄호체, 생각(' ') 묘사는 삭제한다.
- characters: 성별, 연령대, 헤어스타일, 인상, 복장을 서술한다
- tags: 배열이 아닌 하나의 문자열 내에 쉼표(,)로만 구분하여 나열한다.
[JSON 출력 형태]
{
"title": "시청자를 유혹하는 자극적인 제목",
"full_script": "후킹문장으로 시작하는 13~16개의 문장들...",
"characters": "화자인 남성의 구체적인 외형 및 특징 묘사",
"tags": "태그1, 태그2, 태그3, 태그4, 태그5"
}
이렇게 입력하고 Excute step 버튼을 클릭하면 썰이 완성된다!
4. 코드 노드 작업
이제 완성된 썰을 파싱 해야한다. 자바스크립트 코드 노드를 만들어 파싱 로직을 붙였다.
// 1. 이전 노드(Gemini)에서 넘어온 데이터를 가져옵니다.
const rawData = items[0].json;
// 2. Gemini의 응답 텍스트를 추출합니다.
// (제시해주신 입력 구조: content.parts[0].text)
const rawString = rawData.content.parts[0].text;
try {
// 3. 문자열 내의 불필요한 공백이나 줄바꿈을 처리하며 JSON으로 파싱합니다.
// 가끔 Gemini가 마크다운 코드 블록(```json)을 포함할 때를 대비해 정규식 처리를 추가했습니다.
const cleanString = rawString.replace(/```json|```/g, "").trim();
const parsedData = JSON.parse(cleanString);
// 4. 다음 노드에서 사용하기 좋게 객체 형태로 반환합니다.
return [
{
json: {
title: parsedData.title,
full_script: parsedData.full_script,
characters: parsedData.characters,
tags: parsedData.tags,
// (선택사항) 문장 단위로 분할하여 배열로 만들고 싶다면 아래 주석을 해제하세요.
// script_segments: parsedData.full_script.split(/[.?!]\s*/).filter(s => s.length > 0)
}
}
];
} catch (error) {
// 에러 발생 시 디버깅을 위해 원본 데이터를 포함해 반환합니다.
return [
{
json: {
error: "JSON 파싱에 실패했습니다.",
message: error.message,
raw_content: rawString
}
}
];
}
그 다음에는 full_script를 문장 별로 split 하는 코드 노드를 붙였다.
// 1. 이전 노드에서 데이터를 가져옵니다.
const item = items[0].json;
const fullScript = item.full_script;
if (fullScript) {
// 2. 구두점(. ? !)을 기준으로 문장을 분리합니다.
// (?<=[.?!]) : 구두점 뒤의 위치를 찾습니다 (구두점은 포함됨).
// \s+ : 그 뒤에 오는 하나 이상의 공백을 기준으로 자릅니다.
const sentences = fullScript.split(/(?<=[.?!])\s+/);
// 3. 혹시 모를 양 끝 공백을 제거하고, 빈 문장이 있다면 제외합니다.
const cleanedSentences = sentences
.map(sentence => sentence.trim())
.filter(sentence => sentence.length > 0);
// 4. 기존 데이터에 'sentences'라는 새로운 키로 배열을 추가하여 반환합니다.
return [
{
json: {
...item,
script: cleanedSentences
}
}
];
}
// 스크립트가 없을 경우 원본 데이터를 반환합니다.
return [ { json: item } ];
full_script를 split해서 배열로 만들었기 때문에, Split Out 노드를 사용해서 배열에 들어있는 각 요소를 n8n의 아이템 형식인 객체로 만들어 주었다. 확실히 다른 노드에서 값을 받아 사용할 때 객체로 만든 데이터를 쓰는것이 더 편했다.
우선 이 정도 까지 완료하고 이미지와 영상 생성하는 것은 다음에 또 해보려고 한다!