지난 글에서는 App Router 기반의 전체 시스템 아키텍처와 라우팅 구조, 그리고 마이그레이션 전략을 수립했습니다. 클라이언트에서 직접 호출하는 방식을 벗어나 Supabase DB 중심의 서버 사이드 데이터 파이프라인을 구축하기로 방향을 잡았습니다.
이번 글에서는
영화, 일반 프로그램, 그리고 제공되는 OTT 플랫폼 간의 다대다 관계를 처리할 수 있도록 데이터베이스 스키마를 설계했습니다.
특히 지상파/종편 콘텐츠를 포함한 일반 TV 콘텐츠와 달리 구독형 OTT 전용 프로그램을 관리하기 위해 TvShow 테이블을 독립적으로 분리하고 OTT 회사와의 매핑 테이블을 구성했습니다.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model Movie {
id Int @id // TMDB Movie ID
title String
originalTitle String?
overview String? @db.Text
posterPath String?
backdropPath String?
releaseDate DateTime?
voteAverage Float @default(0.0)
voteCount Int @default(0)
popularity Float @default(0.0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
providers MoviesOnWatchProviders[]
@@index([popularity])
@@index([releaseDate])
}
model TvShow {
id Int @id // TMDB TV ID
name String
originalName String?
overview String? @db.Text
posterPath String?
backdropPath String?
firstAirDate DateTime?
voteAverage Float @default(0.0)
voteCount Int @default(0)
popularity Float @default(0.0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
providers TvShowsOnWatchProviders[]
@@index([popularity])
@@index([firstAirDate])
}
model WatchProvider {
id Int @id // TMDB Provider ID (8: Netflix, 337: Disney+ 등)
providerName String
logoPath String?
displayPriority Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
movies MoviesOnWatchProviders[]
tvShows TvShowsOnWatchProviders[]
}
model MoviesOnWatchProviders {
movieId Int
providerId Int
movie Movie @relation(fields: [movieId], references: [id], onDelete: Cascade)
provider WatchProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
@@id([movieId, providerId])
@@index([movieId])
@@index([providerId])
}
model TvShowsOnWatchProviders {
tvShowId Int
providerId Int
tvShow TvShow @relation(fields: [tvShowId], references: [id], onDelete: Cascade)
provider WatchProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
@@id([tvShowId, providerId])
@@index([tvShowId])
@@index([providerId])
}
모든 TV 프로그램을 DB에 무분별하게 저장할 경우 불필요한 DB 용량 낭비와 굳이 filter를 통해 프론트에서 걸러야 할 것으로 예상 되어서 TMDB Discover API레벨 에서 한국내 구독형 OTT로 제공되는 TvShow만 선별 수신하도록 파라미터를 최적화했습니다.
수집한 외부 API 데이터를 DB에 주기적으로 최신화 할 수 있도록 Next.js Route Handler(src/app/api/cron/sync-tmdb/route.ts)를 작성했습니다.
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
const TMDB_API_KEY = process.env.TMDB_API_KEY;
const TMDB_BASE_URL = "https://api.themoviedb.org/3";
//* 주요 OTT Providers IDs (Netflix: 8, Disney+: 337, Watcha: 97, Wavve: 356, Tving: 1796)
const OTT_PROVIDERS_IDS = "8|337|97|356|1796";
// 날짜 유효성 검사 및 Date 객체 변환 헬퍼 함수
const parseDate = (dateString: string): Date | null => {
if (!dateString) return null;
const date = new Date(dateString);
return isNaN(date.getTime()) ? null : date;
};
interface TMDBMovie {
id: number;
title: string;
original_title: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
release_date: string;
vote_average: number;
vote_count: number;
popularity: number;
}
interface TMDBProvider {
provider_name: string;
provider_id: number;
logo_path: string | null;
display_priority: number;
}
interface TMDBTVShow {
id: number;
name: string;
original_name: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
first_air_date: string;
vote_average: number;
vote_count: number;
popularity: number;
}
interface TMDBTVProvider {
provider_name: string;
provider_id: number;
logo_path: string | null;
display_priority: number;
}
export async function GET(request: Request) {
//* 1. Vercel Cron Security Key 검증
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET_KEY}`) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
if (!TMDB_API_KEY) {
return NextResponse.json(
{ message: "TMDB API Key is not set" },
{ status: 500 },
);
}
try {
// -----------------------------------------
// TMDB 인기 영화 목록 Fetch
// -----------------------------------------
const moviesRes = await fetch(
`${TMDB_BASE_URL}/movie/popular?api_key=${TMDB_API_KEY}&language=ko-KR&page=1`,
{ cache: "no-store" },
);
const movieData = await moviesRes.json();
const movies: TMDBMovie[] = movieData.results;
for (const movie of movies) {
// 3. Movie 데이터 Upsert
await prisma.movie.upsert({
where: { id: movie.id },
update: {
title: movie.title,
originalTitle: movie.original_title,
overview: movie.overview,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
releaseDate: parseDate(movie.release_date),
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
popularity: movie.popularity,
},
create: {
id: movie.id,
title: movie.title,
originalTitle: movie.original_title,
overview: movie.overview,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
releaseDate: parseDate(movie.release_date),
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
popularity: movie.popularity,
},
});
// 4. Movie의 Watch Providers Fetch
const providersRes = await fetch(
`${TMDB_BASE_URL}/movie/${movie.id}/watch/providers?api_key=${TMDB_API_KEY}`,
{ cache: "no-store" },
);
const providersData = await providersRes.json();
const providers: TMDBProvider[] =
providersData.results?.KR?.flatrate || [];
for (const provider of providers) {
// 5. Provider 데이터 Upsert
await prisma.watchProvider.upsert({
where: { id: provider.provider_id },
update: {
providerName: provider.provider_name,
logoPath: provider.logo_path,
displayPriority: provider.display_priority,
},
create: {
id: provider.provider_id,
providerName: provider.provider_name,
logoPath: provider.logo_path,
displayPriority: provider.display_priority,
},
});
await prisma.moviesOnWatchProviders.upsert({
where: {
movieId_providerId: {
movieId: movie.id,
providerId: provider.provider_id,
},
},
update: {},
create: {
movieId: movie.id,
providerId: provider.provider_id,
},
});
}
// -----------------------------------------
// OTT 전용 TV 프로그램 Fetch
// -----------------------------------------
const tvRes = await fetch(
`${TMDB_BASE_URL}/discover/tv?api_key=${TMDB_API_KEY}&language=ko-KR&watch_region=KR&with_watch_monetization_types=flatrate&with_watch_providers=${OTT_PROVIDERS_IDS}&sort_by=popularity.desc&page=1`,
{ cache: "no-store" },
);
const tvData = await tvRes.json();
const tvShows: TMDBTVShow[] = tvData.results;
for (const tvShow of tvShows) {
// 6. TV Show 데이터 Upsert
await prisma.tvShow.upsert({
where: { id: tvShow.id },
update: {
name: tvShow.name,
originalName: tvShow.original_name,
overview: tvShow.overview,
posterPath: tvShow.poster_path,
backdropPath: tvShow.backdrop_path,
firstAirDate: parseDate(tvShow.first_air_date),
voteAverage: tvShow.vote_average,
voteCount: tvShow.vote_count,
popularity: tvShow.popularity,
},
create: {
id: tvShow.id,
name: tvShow.name,
originalName: tvShow.original_name,
overview: tvShow.overview,
posterPath: tvShow.poster_path,
backdropPath: tvShow.backdrop_path,
firstAirDate: parseDate(tvShow.first_air_date),
voteAverage: tvShow.vote_average,
voteCount: tvShow.vote_count,
popularity: tvShow.popularity,
},
});
// Program 별 OTT Providers Fetch
const tvProvidersRes = await fetch(
`${TMDB_BASE_URL}/tv/${tvShow.id}/watch/providers?api_key=${TMDB_API_KEY}`,
{ cache: "no-store" },
);
const tvProvidersData = await tvProvidersRes.json();
const tvProviders: TMDBProvider[] =
tvProvidersData.results?.KR?.flatrate || [];
for (const provider of tvProviders) {
// 7. Provider 데이터 Upsert
await prisma.watchProvider.upsert({
where: { id: provider.provider_id },
update: {
providerName: provider.provider_name,
logoPath: provider.logo_path,
displayPriority: provider.display_priority,
},
create: {
id: provider.provider_id,
providerName: provider.provider_name,
logoPath: provider.logo_path,
displayPriority: provider.display_priority,
},
});
await prisma.tvShowsOnWatchProviders.upsert({
where: {
tvShowId_providerId: {
tvShowId: tvShow.id,
providerId: provider.provider_id,
},
},
update: {},
create: {
tvShowId: tvShow.id,
providerId: provider.provider_id,
},
});
}
}
}
return NextResponse.json({
success: true,
syncedMoviesCount: movies.length,
timestamp: new Date().toISOString(),
message: "TMDB Sync Completed",
});
} catch (error) {
console.error("TMDB Sync Failed:", error);
return NextResponse.json({ message: "TMDB Sync Failed" }, { status: 500 });
}
}
동기화 파이프라인 구축 및 DB 마이그레이션 과정에서 발생학 에러 2가지를 분석하고 해결했습니다.
Error: ⚠️ We found changes that cannot be executed:
• Step 0 Added the required column `updatedAt` to the `WatchProvider` table without a default value.
There are 2 rows in this table, it is not possible to execute this step.
schema.prisma
model WatchProvider {
id Int @id
providerName String
logoPath String?
displayPriority Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt // @default(now()) 추가
}
Invalid `prisma.tvShow.upsert()` invocation:
Argument `name` is missing.
update: {
name: undefined,
originalName: undefined, ...
}
// route.ts
for (const tvShow of tvShows) {
// TMDB TV 객체 필드명(name) 맵핑 및 예외 방어
const tvName = tvShow.name || tvShow.original_name || '제목 없음';
await prisma.tvShow.upsert({
where: { id: tvShow.id },
update: {
name: tvName,
originalName: tvShow.original_name ?? "제목 없음",
// ...
},
create: {
id: tvShow.id,
name: tvName,
originalName: tvShow.original_name ?? "제목 없음",
// ...
},
});
}
프론트엔드 개발 단계에서 Mock 데이터 의존성을 줄이고 컴포넌트 개발 시 실데이터의 Type Safety를 확보 할 수 있도록 Data Access Layer를 먼저 구축했습니다.
Prisma의 findMany에 include 옵션을 적용해 SQL JOIN 절을 객체 형태로 추상화했으며, Prisma.Payload를 통해 프론트엔드에 전달될 관계형 데이터 타입을 자동으로 추론했습니다.
// src/lib/contents.ts
import prisma from '@/lib/prisma';
import { Prisma } from '@prisma/client';
// Prisma Schema의 모델명(TvShow)과 일치시켜 Payload 타입 추출
export type MovieWithProviders = Prisma.MovieGetPayload<{
include: { providers: { include: { provider: true } } };
}>;
export type TvShowWithProviders = Prisma.TvShowGetPayload<{
include: { providers: { include: { provider: true } } };
}>;
export interface GetContentsParams {
providerId?: number;
limit?: number;
page?: number;
}
// OTT TV 쇼 목록 조회 (Prisma JOIN & Pagination)
export async function getOttTvShows({
providerId,
limit = 20,
page = 1,
}: GetContentsParams = {}): Promise<TvShowWithProviders[]> {
const skip = (page - 1) * limit;
return await prisma.tvShow.findMany({
where: providerId
? {
providers: {
some: { providerId },
},
}
: {},
include: {
providers: {
include: {
provider: true,
},
},
},
orderBy: { popularity: 'desc' },
skip,
take: limit,
});
}
데이터베이스 동기화 완료: GET /api/cron/sync-tmdb 호출을 통해 영화 및 한국 OTT 전용 TV 프로그램 정보와 브랜드별 매핑 관계가 PostgreSQL DB에 올바르게 반영된 것을 Prisma Studio로 검증했습니다.
백엔드 인프라 세팅 완료: 외부 API 페칭부터 DB Upsert, 그리고 Server Component에서 직접 호출 가능한 Data Layer까지의 전체 파이프라인 세팅이 마무리되었습니다.
다음 글에서는 이번에 구축한 Data Layer를 기반으로 Next.js App Router 메인 페이지 연동, 영화/OTT TV 탭 전환, OTT 브랜드별 필터링 UI, 그리고 콘텐츠 상세 모달(Intercepting Routes) 컴포넌트를 본격적으로 개발해 보겠습니다.