실전 자동화 튜토리얼

폰에서 탭 한 번으로 영상 만들기: 웹훅 서버 구축기 (Shorts 자동화 8편-마지막)

정데비 2026. 7. 18. 20:00

도입

7편까지는 대본 JSON을 준비해도 SSH로 접속해서 python3 run_pipeline.py를 직접 실행해야 했습니다.

 

명령어 한 줄로 끝내기: 전체 파이프라인 자동화 스크립트 (Shorts 자동화 7편)

도입6편까지 4개의 스크립트(이미지 생성, TTS 생성 × 2, 씬 조립, Remotion 렌더링)을 사람이 순서대로 손으로 실행했습니다. Oracle A1 서버에 배포하기: 헤드리스 렌더링과 한글 폰트 이슈 (Shorts 자동

blog.jdevy.com

 

이번 편은 이 시리즈의 마지막 편으로, SSH 없이도 폰 브라우저에서 탭 한 번으로 실행할 수 있는 웹훅 서버를 만듭니다.

왜 이 구조를 골랐는지

SSH 접속 없이 실행하는 방법으로 GitHub Actions, Notion 폴링, 웹훅 이 세 가지를 검토했습니다.

  • GitHub Actions: 폰/웹에서 버튼 클릭으로 트리거할 수 있지만, 매번 클라우드 러너에 Node/Chromium 의존성을 새로 깔아야 해서 느릴 수 있고, 애써 확보한 Oracle 서버가 놀게 됩니다.
  • Notion 폴링: 서버가 Notion 캘린더 상태를 주기적으로 체크하는 방식으로 트리거조차 필요없어지지만, 폴링 로직 자체를 새로 구현·유지보수해야 해서 관리 포인트가 늘어납니다.
  • 웹훅 서버: 이미 다 세팅된 Oracle A1 서버 위에 얇은 API 한 겹만 올리면 되는 구조라 SSH도 폴링도 불필요하고, 폰 브라우저 북마크나 iOS 단축어로 홈화면 아이콘 하나만 만들어두면 탭 한 번으로 실행 가능합니다.

지금 상태(서버가 이미 다 세팅되어 있고, 관리 포인트를 늘리고 싶지 않음)에 가장 잘 맞는 웹훅 서버를 채택했습니다.

구성 요소

  • server.py (Flask): GET /는 텍스트박스+실행버튼 페이지, POST /run은 대본(script.json)을 projects/<슬러그>/script.json으로 저장한 뒤 백그라운드로 run_pipeline.py를 실행, GET /status/<job_id>는 진행 상황을 조회합니다.
  • 인증: 서버 코드에는 로그인 로직이 전혀 없습니다. Cloudflare Access(이메일로 일회용 PIN을 받는 방식)가 서버 앞단에서 인증을 대신 처리해줍니다.
  • gunicorn + systemd: Flask 개발 서버 대신 gunicorn으로 실행하고, systemd에 등록해 서버가 재부팅되어도 자동으로 다시 켜지게 합니다.
  • 도메인: 기존 blog.jdevy.com과 별도로 서브도메인(sub.jdevy.com)을 만들고, nginx 리버스 프록시 + Cloudflare 프록시(오렌지 구름)로 HTTPS를 적용합니다.

핵심 흐름 (개념 예시)

아래는 실제 server.py 전체 코드가 아니라, 엔드포인트 3개가 서로 어떻게 연결되는지 보여주는 핵심 구조입니다.

from flask import Flask, request, jsonify, send_from_directory
import subprocess, json, os
from pathlib import Path

app = Flask(__name__)

@app.route("/")
def home():
    return """<textarea id="script"></textarea><button onclick="run()">실행</button>..."""

@app.route("/run", methods=["POST"])
def run_job():
    data = request.json
    slug = data["slug"]
    script_path = Path("projects") / slug / "script.json"
    script_path.parent.mkdir(parents=True, exist_ok=True)
    script_path.write_text(json.dumps(data, ensure_ascii=False))

    env = os.environ.copy()
    env["PYTHONUNBUFFERED"] = "1"
    subprocess.Popen(
        ["python3", "run_pipeline.py", str(script_path)],
        env=env,
        stdout=open(f"logs/{slug}.log", "w"),
        stderr=subprocess.STDOUT,
    )
    return jsonify({"job_id": slug})

@app.route("/status/<job_id>")
def status(job_id):
    log = Path(f"logs/{job_id}.log").read_text(errors="ignore")
    return jsonify({"log": log})

이 구조에서 핵심은 subprocess.Popen입니다. 웹요청이 들어오면 그 자리에서 기다리지 않고 백그라운드로 파이프라인을 다 돌려놓고 바로 응답해버립니다. 진행 상황은 로그 파일을 따로 확인하는 구조입니다.

실제로 겪은 이슈 7가지

1) gunicorn 경로가 systemd 기본값과 다름

which gunicorn 결과가 /usr/bin/gunicorn이 아니라 /home/ubuntu/.local/bin/gunicorn(pip3 --user 설치 경로)였습니다. systemd 서비스 파일의 ExecStart를 이 실제 경로로 명시해야 합니다.

2) Cloudflare 522 에러 (Connection timed out)

원인: Oracle A1이 외부 인바운드 요청을 받아본 적이 없어, 서버 자체 방화벽(iptables)에 22번(SSH)만 허용되어 있고 80/443이 막혀있었습니다.

sudo iptables -I INPUT 4 -p tcp --dport 80 -j ACCEPT
sudo iptables -I INPUT 5 -p tcp --dport 443 -j ACCEPT
sudo apt install -y iptables-persistent
sudo netfilter-persistent save

3) Oracle Cloud Security List도 별도로 차단

iptables를 열어도 여전히 안 되었습니다. Oracle Cloud의 VCN(Virtual Cloud Network) Security List가 별도로 22번만 허용하고 있었기 때문입니다. Networking > Virtual Cloud Networks > Security Lists > Ingress Rules에서 소스 0.0.0.0/0, TCP, 대상 포트 80과 443을 각각 추가해야 합니다. 즉 서버 내부(iptables)와 클라우드 콘솔(Security List) 두 군데 다 열어야 합니다.

4) Cloudflare 521 에러 (Connection refused)

방화벽을 다 열어도 여전히 에러가 났습니다. 원인은 Cloudflare SSL/TLS 모드가 계정 전체 기준 Full로 설정되어 있어서, Cloudflare가 오리진(원본 서버)에도 HTTPS(443)로 접속을 시도하는데 nginx가 아직 80번만 듣고 있었기 때문입니다.

주의: SSL/TLS 모드를 Flexible로 바꾸는 건 잘못된 해결책입니다 — 도메인 구성에 따라 리다이렉트 루프 같은 다른 문제가 생길 수 있으므로(본인은 기존 blog.jdevy.com이 Vercel에 연결되어 있어 Flexible로 바꾸면 문제가 생길 수 있었음), 전체 모드는 건드리지 않는 것이 안전합니다. 대신 Cloudflare Origin Certificate(SSL/TLS > 원본 서버에서 무료 발급)를 nginx에 설치해 443을 직접 듣도록 했습니다.

5) Cloudflare Access가 자체 계정 로그인으로 뜨는 문제

애플리케이션의 "사용 가능한 모든 ID 공급자 수락"이 켜져 있었는데, 계정에 이미 "Cloudflare" ID 공급자가 등록되어 있어 이것이 우선 적용되면서, 방문자 이메일 입력 대신 Cloudflare 계정 자체 로그인 화면이 떴습니다. 통합 > ID 공급자에서 One-time PIN을 별도 ID 공급자로 명시적으로 추가하고, 애플리케이션 설정 > 로그인 방법 탭에서 "사용 가능한 모든 ID 공급자 수락"을 끄고 onetimepin만 명시적으로 선택해야 해결됩니다.

6) 파이프라인 로그가 비어있음 (출력 버퍼링)

웹훅으로 실행한 run_pipeline.py가 ps aux로는 정상 실행 중인데 tail -f logs/<job_id>.log가 아무것도 보여주지 않았습니다. 파이썬 출력이 파일로 리다이렉트될 때는 터미널과 달리 버퍼링되는데, python3 -u 플래그는 그 프로세스 자체에만 적용되고 자식 프로세스(generate_images.py 등)에는 전파되지 않습니다. PYTHONUNBUFFERED 환경변수를 subprocess.Popen 호출 시 env로 전달해야 모든 하위 파이썬 프로세스에 일괄 적용됩니다.

7) FileNotFoundError: 'npx'

systemd로 등록된 서비스는 일반 SSH 쉘과 달리 ~/.bashrc를 읽지 않아 nvm으로 설치한 npx/node를 못 찾습니다. systemd 서비스 파일에 nvm node bin 경로를 포함한 Environment="PATH=..."를 명시적으로 추가해야 합니다.

개선 — 진행률 표시와 다운로드

완료 여부만 알려주던 데서, 버튼 하나로 현재 단계와 진행률을 보여주고 완료 시 바로 mp4를 받을 수 있도록 확장했습니다.

  • /status/<job_id>가 로그 파일을 읽어 [단계명] 패턴을 정규식으로 파싱 — run_pipeline.py가 이미 각 단계를 대괄호로 출력하고 있어서 별도 코드 변경 없이 그대로 활용
  • 총 단계 수는 /run 요청 시 장면 개수로 계산해서 미리 저장, 현재단계/총단계로 진행률 표시
  • /download/<slug>: send_from_directory로 out 폴더의 mp4 파일을 직접 제공
  • 프론트엔드 JS가 3초마다 status를 폴링해서 진행상황을 갱신하다가, 완료되면 다운로드 링크로 자동 전환

최종 검증 결과

장면 2개짜리 대본을 웹페이지에 붙여넣고 실행 버튼만 누르는 것으로 이미지 생성부터 렌더링까지 에러 없이 완주되었고, 진행률 표시와 다운로드 링크가 정상 작동하는 것을 확인했습니다.

주의사항 / 흔한 실수

  • 방화벽은 서버 자체(iptables)와 클라우드 콘솔(Security List) 두 군데를 다 확인해야 합니다. 하나만 열면 여전히 안 됩니다.
  • Cloudflare SSL/TLS 모드를 임의로 Flexible로 낮추지 마세요. 다른 도메인/서비스와 연결된 계정이라면 예상치 못한 문제가 생길 수 있습니다.
  • systemd 서비스는 로그인 셸의 PATH나 환경변수를 물려받지 않으므로, nvm으로 설치한 도구를 쓸 때는 경로를 명시적으로 적어줘야 합니다.

마무리

1편에서 Vrew의 한계를 짚고 Remotion을 선택한 이야기로 시작해서, 이번 8편에서는 SSH 접속조차 필요 없이 폰 탭 한 번으로 영상이 나오는 데까지 왔습니다. 대본 텍스트를 쓰는 일부터 완성된 mp4를 받는 일까지, 코드 한 줄 안 써도 되는 구조가 이제 끝났습니다.


[핵심요약] SSH 없이 폰에서 버튼 한 번으로 Shorts 자동 생성하기

반응형