배경
영천중앙교회의 공식 홈페이지 제작을 의뢰받았습니다. 요구사항 정리, 정보 구조 설계, UI, 백엔드, 데이터베이스, 인프라, 배포와 운영까지 전 과정을 혼자 수행했습니다.
공개 페이지는 예배·설교·찬양·특별행사·주보·소식·갤러리·새가족 안내를 담고, 운영자는 /admin에서 소식과 주보, 갤러리 앨범을 직접 관리합니다.
문제
의뢰 단계에서 확인한 운영 부담은 세 가지였습니다.
- 새 설교 영상이 올라와도 운영자가 손으로 등록해야 했습니다. 영상 링크를 복사해 제목·설교자·날짜를 매주 입력하는 일이 반복됐습니다.
- 주보가 HWP 첨부라 모바일에서 열어보기 어려웠습니다. 파일을 내려받아 뷰어 앱으로 열어야 했고, 검색도 되지 않았습니다.
- 유튜브 원본 썸네일이 목사님 정면 1프레임이라 설교 목록이 구분되지 않았습니다. 모든 카드가 거의 같은 그림이라 어떤 설교인지 알아볼 수 없었습니다.
아키텍처
새 설교 영상이 YouTube에 올라오면 폴링 없이 실시간으로 등록·자막화·요약까지 자동 진행됩니다.
[YouTube 업로드]
│ (WebSub 푸시)
▼
/api/youtube/websub ── 서명검증(HMAC-SHA1) → Atom 파싱(yt:videoId)
│ publishJob
▼
QStash 큐 ── delay/cron ──▶ /api/jobs/ingest-video
│
▼
/api/jobs/fetch-transcript (RapidAPI yt-api 자막)
│
▼
/api/jobs/summarize (Gemini 구조화 요약)
│
실패 시 ◀┘ 지수 백오프 재발행 / retry-summaries cron
파이프라인 설계
ingest-video → fetch-transcript → summarize를 하나의 긴 함수로 묶지 않고, 각각 독립한 서버리스 함수로 분리한 뒤 QStash 메시지로 연결했습니다.
이렇게 나눈 이유는 실패 단위를 좁히기 위해서입니다. 자막 수집이 실패했을 때 영상 등록까지 되돌리거나 다시 실행할 필요가 없고, 그 단계만 재시도하면 됩니다. 서버리스 함수의 실행 시간 제한 안에 각 단계가 넉넉히 들어간다는 이점도 있습니다.
대신 잡 엔드포인트가 외부에 열리므로, 전 엔드포인트가 QStash Receiver 서명 검증을 거칩니다. WebSub 콜백도 두 겹으로 막았습니다. 구독 검증(GET)은 우리 채널 토픽일 때만 hub.challenge를 에코해 임의 토픽 구독을 차단하고, 알림(POST)은 X-Hub-Signature(HMAC-SHA1)를 원문 바이트 기준 timingSafeEqual로 비교합니다.
설계 결정
폴링 대신 WebSub 푸시
새 영상을 감지하는 방법은 주기적 폴링과 푸시 구독 두 가지였습니다. WebSub(PubSubHubbub) 푸시를 선택했습니다. 채널 피드를 Google 허브에 구독해 업로드 순간에만 콜백을 받으므로, 평소 YouTube API 쿼터와 함수 호출이 0입니다.
트레이드오프는 구독 lease가 만료된다는 점입니다. QStash cron으로 약 2일마다 재구독하고, 놓친 영상은 일일 정합성 cron(reconcile-sermons)이 채널 재생목록과 DB를 대조해 백필합니다. 관리 대상이 늘었지만 평소 비용을 0으로 유지하는 편이 낫다고 판단했습니다.
프로세스 sleep 대신 QStash 지연 발행
요약이 실패하면 간격을 늘려가며 재시도해야 하는데, 서버리스 함수는 프로세스를 붙잡고 sleep할 수 없습니다. 백오프를 QStash 지연 발행(delay)으로 외부에 위임했습니다. 간격은 5 × 3ⁿ분으로 늘리고 attempts < 3 한도를 뒀으며, 자막이 영구히 없는 건은 재시도 후보에서 제외해 API 쿼터 소진을 막습니다.
Postgres CTE로 원자적 claim
WebSub 중복 알림, 재시도 cron, 관리자 수동 트리거가 겹치면 같은 설교가 여러 번 요약될 수 있습니다. Postgres CTE UPDATE ... RETURNING으로 선점 가능한 상태일 때만 원자적으로 1건을 선점하게 했습니다. 애플리케이션 레벨 락 없이 DB 한 번의 왕복으로 끝납니다. pending 상태가 10분 이상 멈추면 죽은 워커로 보고 회수합니다.
실전 제약과 대응
가장 큰 제약은 기술이 아니라 계정이었습니다. 교회 유튜브 채널이 담당 권사님의 개인 Google 계정으로 운영되고 있었습니다. 공식 YouTube Data API는 채널 소유자 OAuth 인증이 필요한데, 개인 계정 자격을 넘겨받는 것은 적절하지 않았습니다.
그래서 크롤링 기반 RapidAPI yt-api로 전환했습니다. 대신 공식 API보다 쿼터와 안정성 제약이 생겼고, 이를 앞서 말한 일일 정합성 cron과 지수 백오프 재시도로 보완했습니다.
트러블슈팅 기록
HWP 인라인 컨트롤 ID가 본문에 섞여 나온 문제
- 증상 — 주보를 업로드하면 본문 문단에
tbl,gso같은 문자열이 섞여 나왔습니다. - 원인 — HWP 5.0 레코드를 UTF-16LE로 디코딩할 때, 인라인·확장 컨트롤이 차지하는 8 WCHAR 구간을 텍스트로 함께 읽고 있었습니다. 그 구간에는 표·도형 컨트롤의 내부 ID가 들어 있습니다.
- 수정 — 컨트롤 문자를 만나면 해당 8 WCHAR 구간을 건너뛰도록 파서를 고쳤습니다 (
d8b4625).
설교 등록이 채널 목록 조회에 의존해 실패한 문제
- 증상 — WebSub 알림은 정상 수신되는데 설교 등록이 간헐적으로 실패했습니다.
- 원인 —
ingest-video가 영상 ID를 이미 알고 있음에도 채널 영상 목록을 조회해 그 안에서 대상을 찾는 구조였습니다. 목록 응답이 지연되거나 페이지에 아직 반영되지 않으면 그대로 실패했습니다. - 수정 —
video/info단건 조회로 전환했습니다 (d7588c4). 호출량도 함께 줄었습니다.
프론트 구현 하이라이트
- SSE 실시간 진행 표시 — 썸네일 생성과 채널 동기화는 여러 외부 API를 거쳐 수 초에서 수십 초가 걸립니다. 관리자가 멈춘 화면을 보지 않도록 진행 상황을 SSE로 스트리밍해 단계별로 표시합니다.
- 설교 요약 글자 크기 3단계 조절 — 주 이용자가 고령 성도라, AI 요약 본문의 글자 크기를 3단계로 조절하고 선택값을 유지하도록 했습니다.
실제 화면


운영에서 배운 것
이 프로젝트는 착수 16일 만에 CI를 세웠고, 그 무렵 Vitest 단위 테스트와 PGlite 기반 DB 통합 테스트, Playwright E2E를 함께 붙였습니다. 앞선 두 프로젝트가 각각 87일, 73일 걸렸던 것에 비하면 크게 앞당긴 시점입니다.
이유는 분명했습니다. 파이프라인이 자동으로 도는 구조라 깨져도 아무도 바로 알아채지 못합니다. 요약 claim이 중복되거나 백오프가 무한 재시도로 도는 문제는 브라우저에서 눈으로 확인할 수 없어서, 테스트로 고정하지 않으면 검증 자체가 불가능했습니다.
여기서 얻은 감각이 다음 프로젝트에서 "첫날 CI"로 이어졌습니다.