같은 날 세 가지를 손봤다. 스크립트 프롬프트, 썸네일 프록시, 그리고 생성 한도.
프롬프트 가이드용으로 [기], [승], [전], [결] 구조 라벨을 써놨는데, Gemini가 이걸 실제 script 텍스트에 그대로 포함시켜서 TTS가 "기, 상황 진입" 같은 걸 그대로 읽는 사고가 났다.
프롬프트에 금지 문구를 박고, parseOutput에도 방어 코드를 넣었다.
// 기승전결 구조 라벨이 출력에 포함된 경우 제거 — TTS/자막에 읽히지 않도록
if (parsed.script) {
parsed.script = parsed.script
.replace(/\[[기승전결][^\]]*\]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
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회로 막았다. 한국 시간 자정 기준으로 리셋된다.
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;
}
}