개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← YouTube Shorts 자동화

썸네일 프록시 — 안정화와 일일 생성 한도

2026년 7월 19일
script-workerwebapi썸네일

같은 날 세 가지를 손봤다. 스크립트 프롬프트, 썸네일 프록시, 그리고 생성 한도.

script-worker — 기승전결 라벨이 그대로 낭독되던 문제

프롬프트 가이드용으로 [기], [승], [전], [결] 구조 라벨을 써놨는데, Gemini가 이걸 실제 script 텍스트에 그대로 포함시켜서 TTS가 "기, 상황 진입" 같은 걸 그대로 읽는 사고가 났다.

프롬프트에 금지 문구를 박고, parseOutput에도 방어 코드를 넣었다.

// 기승전결 구조 라벨이 출력에 포함된 경우 제거 — TTS/자막에 읽히지 않도록
if (parsed.script) {
  parsed.script = parsed.script
    .replace(/\[[기승전결][^\]]*\]/g, '')
    .replace(/\s{2,}/g, ' ')
    .trim();
}

VideoCard — 썸네일을 프록시 URL로

thumbnailUrl이 /jobs/{id}/thumbnail 같은 상대 경로일 때 <img src>에 그대로 박으면 404가 났다. toProxyThumbUrl()을 거치도록 바꿨다.

const thumbnailSrc = toProxyThumbUrl(video.thumbnailUrl);

썸네일 프록시 — 세 번 연달아 고침

프록시 라우트(/api/thumbnail/[id])를 만들자마자 세 가지가 연달아 터졌다.

첫 번째, API_INTERNAL_URL SSM 파라미터가 안 잡혀있어서 localhost:3000으로 폴백되며 500이 났다.

const apiUrl = process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';

두 번째, Route Handler가 RSC prerender 캐시를 타면서 Vary 헤더가 꼬였다. force-dynamic으로 껐다.

세 번째가 제일 골 아팠다. res.arrayBuffer()로 받아서 NextResponse에 넘기면 이미지가 깨졌다. body를 스트림 그대로 넘기니까 해결됐다.

-const buffer = await res.arrayBuffer();
-return new NextResponse(buffer, { ... });
+return new NextResponse(res.body, { ... });

바이너리를 굳이 메모리에 통째로 올렸다가 다시 내보내는 과정에서 뭔가 깨졌던 걸로 보인다. 스트림으로 그냥 넘기면 될 걸 괜히 한 단계 더 거쳤다.

일일 Job 생성 3회 한도

한 채널이 하루에 무한정 Job을 만들 수 있어서, 3회로 막았다. 한국 시간 자정 기준으로 리셋된다.

function getTodayStartSeoul(): Date {
  const seoulDate = new Intl.DateTimeFormat('en-CA', {
    timeZone: 'Asia/Seoul',
    year: 'numeric', month: '2-digit', day: '2-digit',
  }).format(new Date());
  return new Date(`${seoulDate}T00:00:00+09:00`);
}

초과하면 DailyQuotaExceededError를 던지고 컨트롤러에서 429로 매핑한다.

const DAILY_LIMIT = 3;

async create(channelId: string, topic: string) {
  const createdToday = await this.repo.countCreatedToday(channelId, getTodayStartSeoul());
  if (createdToday >= DAILY_LIMIT) throw new DailyQuotaExceededError(channelId);
  ...
}

api.ts에 ApiError 클래스를 새로 만들어서 상태 코드를 그대로 들고 다니게 했다. 프론트에서 429만 따로 걸러서 안내 배너를 띄운다.

if (err instanceof ApiError && err.status === 429) {
  setDailyLimitHit(true);
} else {
  setSubmitError(err instanceof Error ? err.message : '오류가 발생했습니다.');
}

뉴스 자동 수집도 여러 건을 한 번에 요청하는 구조라, 한도 도달하면 나머지는 조용히 스킵하게 했다.

for (const item of items) {
  try {
    results.push(await this.create(dto.channelId, item.title));
  } catch (err) {
    if (err instanceof DailyQuotaExceededError) break;
    throw err;
  }
}
← 이전 글OAuth 안정화 — 배포 환경 인증 흐름
다음 글 →성능 개선 — SSR prefetch와 썸네일 단순화