Next.js와 MongoDB 환경에서 레거시 수동 URL 필드를 YouTube API 자동화 및 다국어(Locale) 확장 구조로 리팩토링하는 개발 가이드입니다. search API 대신 playlistItems를 활용해 하루 10,000 유닛의 API 쿼터 비용을 99% 최적화하는 백엔드 아키텍처 실무 노하우를 코드가 포함된 예제로 확인해 보세요.
팀원이 남겨둔 '수동 관리'의 불씨, 그리고 글로벌 확장
"어차피 브랜드 유튜브 채널 몇 개 없는데, 어드민에서 주소 직접 복사해서 넣으면 안 되나?"라는 생각 말이죠. 최근 저희 팀원도 브랜드 유튜브 필드를 추가하면서 youtubeUrl과 mainVideoUrl이라는 단순 문자열 필드 2개로 수동 관리 화면을 뚝딱 만들어왔습니다.
당장 돌아가는 것처럼 보였지만, 제 눈에는 거대한 시한폭탄으로 보였습니다. 서비스가 글로벌 무대로 확장하려는 타이밍이었거든요. "한국, 영어권, 일본 공식 유튜브 채널이 다 다르면 이 구조로 어떻게 감당하지?"라는 의문과 함께, 저는 코드를 완전히 뜯어고치기로 결심했습니다.
무엇이 기존 구조를 '죽은 코드'로 만들었는가?
기존 구조는 크게 네 가지 치명적인 한계가 있었습니다.
-
데이터 무결성 붕괴: 사람이 직접 URL을 치다 보니 오타가 나면 링크가 깨졌습니다.
-
풍부한 데이터 활용 불가: 정적 주소만 있으니 구독자 수나 실시간 채널 정보를 가져올 수 없었습니다.
-
죽은 필드의 양산:
mainVideoUrl은 DB에 저장만 되고 화면 어디에도 렌더링되지 않는, 실데이터 0건의 유령 필드였습니다. -
다국어 확장 차단: 국가별로 마케팅 채널을 따로 파는 글로벌 브랜드들의 특성을 전혀 반영할 수 없었습니다.
해결 방향 관리자는 채널 정보(URL, 핸들, ID 중 어떤 형태든)만 입력하면, 백엔드가 YouTube API를 통해 유일한 식별자인 Channel ID, 구독자 수, 최신 영상 3개를 자동으로 파싱해 내재화하는 구조로 전환합니다.
Locale별 유연한 구조와 하이브리드 고정 기능 도입
가장 먼저 다국어(ko, en, jp) 대응이 가능하면서도, 프론트엔드가 페이지를 렌더링할 때마다 외부 API를 찔러 속도가 느려지지 않도록 데이터를 내재화하는 Mongoose 스키마를 설계했습니다.
// src/lib/models/Brand.ts
import { Schema, model, models } from 'mongoose';
const BrandSchema = new Schema({
name: { type: String, required: true },
// 🚀 리팩토링된 다국어 유튜브 채널 구조
youtubeChannels: {
ko: { type: Object, default: null },
en: { type: Object, default: null },
jp: { type: Object, default: null },
}
});
export const Brand = models.Brand || model('Brand', BrandSchema);
이 구조 내부에는 불변의 앵커가 될 channelId와 유려한 뷰티풀 URL을 위한 handle, 그리고 동기화 시 최대 3개의 영상 목록을 담도록 인터페이스를 정의했습니다. 특히 영상 스키마에는 isPinned: boolean 플래그를 도입하여, 관리자가 수동으로 고정한 메인 영상은 동기화 중에도 보존되고 나머지 칸만 최신 영상으로 채워지는 하이브리드 구조를 구현했습니다.
하루 100 유닛짜리 폭탄 API를 1 유닛으로 줄이는 팁
구현 과정에서 가장 주의해야 할 점은 YouTube API의 쿼터(Quota) 관리입니다. 구글은 무료 계정에 하루 10,000 유닛의 제한을 둡니다. 그런데 채널의 최신 영상을 찾겠다고 search.list API를 호출하면 1회당 무려 100 유닛이 차감됩니다. 브랜드가 수십 개만 되어도 하루 만에 한도가 바닥나겠죠.
쿼터 99% 아끼는 치트키 코드 모든 유튜브 채널은 고유한 '업로드 재생목록 ID'를 가집니다. 신기하게도 채널 ID의 앞 두 글자 UC...를 UU...로 바꾸면 그게 바로 업로드 재생목록 ID가 됩니다. 이를 이용해 playlistItems.list API를 호출하면 단 1 유닛의 비용으로 최신 영상을 완벽하게 긁어올 수 있습니다.
// src/lib/brand-youtube.ts
import { fetchYoutubeChannelMetadata, fetchLatestYoutubeVideos } from './youtube';
/**
* 채널 입력값을 받아 API 동기화 및 고정 영상을 보존하는 헬퍼 함수
*/
export async function syncBrandYoutube(
channelInput: string,
existingPinnedVideos: any[] = []
) {
if (!channelInput?.trim()) return null;
// 1. 입력된 값(URL, 핸들 등)을 해석해 채널 ID와 구독자 수 추출 (channels.list 사용: 1~2 유닛)
const meta = await fetchYoutubeChannelMetadata(channelInput);
if (!meta) throw new Error('유효하지 않은 유튜브 채널입니다.');
// 2. 최신 영상 조회 (playlistItems.list 활용 기법: 단 1 유닛!)
const latestVideos = await fetchLatestYoutubeVideos(meta.channelId, 3);
// 3. 기존 고정(Pinned) 영상 보존 로직
const pinned = existingPinnedVideos.filter(v => v.isPinned);
const normalVideos = latestVideos
.filter(lv => !pinned.some(pv => pv.videoId === lv.videoId))
.map(v => ({ ...v, isPinned: false }));
// 고정 영상 우선 결합 후 최대 3개 슬라이싱
const mergedVideos = [...pinned, ...normalVideos].slice(0, 3);
return {
channelId: meta.channelId,
handle: meta.handle,
subscriberCount: meta.subscriberCount,
videos: mergedVideos,
lastSyncedAt: new Date(),
};
}
빈 값은 $unset으로, 새 값은 '저장=동기화' 메커니즘으로
이제 어드민 페이지에서 관리자가 저장을 누를 때 작동할 Next.js Server Action 로직입니다. 폼 데이터에 언어별 채널 값이 비어있다면 DB 용량 최적화를 위해 $unset 연산자로 필드 자체를 날려버리고, 주소가 있다면 자동화 헬퍼를 태워 upsert 해줍니다.
// src/app/(main)/[locale]/admin/actions.ts
'use server';
import { Brand } from '@/lib/models/Brand';
import { syncBrandYoutube } from '@/lib/brand-youtube';
export async function updateBrandChannels(brandId: string, formData: FormData) {
const locales = ['ko', 'en', 'jp'];
const setQuery: any = {};
const unsetQuery: any = {};
const currentBrand = await Brand.findById(brandId);
for (const locale of locales) {
const input = formData.get(`youtubeChannelInput_${locale}`) as string;
if (!input || input.trim() === '') {
// 주소가 지워졌다면 DB에서도 깔끔하게 제거
unsetQuery[`youtubeChannels.${locale}`] = 1;
} else {
const existingVideos = currentBrand?.youtubeChannels?.[locale]?.videos || [];
// 저장하는 순간 자동으로 API를 호출해 무결한 데이터를 채움
const syncedData = await syncBrandYoutube(input, existingVideos);
setQuery[`youtubeChannels.${locale}`] = syncedData;
}
}
const updatePayload: any = {};
if (Object.keys(setQuery).length > 0) updatePayload.$set = setQuery;
if (Object.keys(unsetQuery).length > 0) updatePayload.$unset = unsetQuery;
await Brand.findByIdAndUpdate(brandId, updatePayload);
}
예외 처리 전략과 이번 리팩토링이 남긴 교훈
이렇게 설계하면 사용자 화면(프론트엔드)에서는 어떤 이점이 있을까요? 만약 어떤 브랜드가 글로벌 시장에 막 진입해서 영어(en) 채널은 없고 한국어(ko) 채널만 가지고 있다면, 서비스 레이어에서 유연하게 체인형 Fallback을 먹여줄 수 있습니다.
export function pickYoutubeChannelForLocale(youtubeChannels: any, currentLocale: string) {
if (!youtubeChannels) return null;
if (youtubeChannels[currentLocale]) return youtubeChannels[currentLocale];
// 현재 언어 채널이 없으면 한국어 -> 영어 -> 일본어 순으로 가용한 채널을 탐색
const fallbackOrder = ['ko', 'en', 'jp'];
for (const locale of fallbackOrder) {
if (youtubeChannels[locale]) return youtubeChannels[locale];
}
return null;
}
최종 요약 처음 팀원이 짜온 수동 방식은 당장 30분 만에 끝나는 작업이었을 겁니다. 하지만 자동화 구조로 리팩토링한 덕분에 휴먼 에러 완벽 차단, API 비용 99% 절감, 그리고 글로벌 인프라 확장성이라는 세 마리 토끼를 모두 잡았습니다.
코드는 깔끔하게 머지되었고, 기존에 등록되어 있던 레거시 데이터 65건은 스크립트를 통해 안전하게 마이그레이션을 마쳤습니다. 여러분의 프로젝트도 수동의 늪에 빠져있다면, 지금 당장 자동화의 세계로 리팩토링해 보세요!
Q1. YouTube API의 일일 할당량 제한(10,000 쿼터)을 초과하면 어떻게 되나요?
A1. 할당량을 초과하면 API 호출 시 403 Quota Exceeded 에러가 발생합니다. 이를 방지하기 위해 본문에서 소개한 playlistItems 목록 조회 방식을 사용해 비용을 100분의 1로 줄이거나, 갱신 주기를 크론(Cron)을 통해 하루 1~2회로 제한하는 것이 좋습니다.
Q2. 구독자 수를 비공개로 설정한 채널은 데이터가 어떻게 저장되나요?
A2. YouTube API는 구독자 비공개 채널의 경우 subscriberCount 필드를 반환하지 않거나 숨김 처리합니다. 따라서 스키마 설계 시 해당 필드를 optional(number | undefined)로 처리하고, UI 단에서 미표시되도록 예외 코드를 작성해야 크래시를 방지할 수 있습니다.
Q3. Next.js App Router 환경에서 ISR과 이 구조를 어떻게 연동하나요?
A3. 관리자가 어드민 페이지에서 유튜브 채널을 저장(동기화)하는 Server Action이 성공적으로 끝나는 시점에 revalidatePath('/brand/[id]') 또는 revalidateTag('brand-detail')를 호출해 주면, 유저는 외부 API 지연 없이 캐시된 고속의 페이지를 보면서도 항상 최신 데이터를 제공받을 수 있습니다.