4. Database Schema & Data Layer Architecture (TMDB & Prisma 연동)

정운·2026년 8월 25일

ReelTrailer

목록 보기
5/10

지난 글에서는 App Router 기반의 전체 시스템 아키텍처와 라우팅 구조, 그리고 마이그레이션 전략을 수립했습니다. 클라이언트에서 직접 호출하는 방식을 벗어나 Supabase DB 중심의 서버 사이드 데이터 파이프라인을 구축하기로 방향을 잡았습니다.

이번 글에서는

  1. Prisma ORM을 활용한 DB 스키마 설계 및 구현
  2. TMDB API를 통한 OTT 전용 콘텐츠 필터링 파이프라인
  3. Data Sync Route Handler Implementation
  4. Trouble Shotting 기록
  5. Server-Side Data Layer * Type Definiton
    을 기록하려 합니다.

1. Prisma Schema & N:M WatchProvider Modeling

영화, 일반 프로그램, 그리고 제공되는 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])
}
  • Index 최적화: 인기도 및 개봉/방영일 기준의 조회가 많이 일어날것으로 예상되어 단일 인덱스를 추가하여 쿼리 성능을 확보했습니다.

2. TMDB API Provider

모든 TV 프로그램을 DB에 무분별하게 저장할 경우 불필요한 DB 용량 낭비와 굳이 filter를 통해 프론트에서 걸러야 할 것으로 예상 되어서 TMDB Discover API레벨 에서 한국내 구독형 OTT로 제공되는 TvShow만 선별 수신하도록 파라미터를 최적화했습니다.

  • API Endpoint: GET /3/discover/tv
  • 주요 Query Parameters:
    • watch_region=KR: 대한민국 지역 기준 필터링
    • with_watch_monetization_types=flatrate: 구매/대여가 아닌 구독형 OTT 콘텐츠만 추출
    • with_watch_providers= 8|337|97|356|1796: 국내 주요 5개 OTT ID만 필터링(8:Netflix, 337:Disney+, 97:Watcha, 356:Wavve, 1796:Tving)

3. Data Sync Route Handler Implementation

수집한 외부 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 });
  }
}

4. Trobuleshooting & Resoultion

동기화 파이프라인 구축 및 DB 마이그레이션 과정에서 발생학 에러 2가지를 분석하고 해결했습니다.

Isuue 1.Migration Failure due to Non-nullable Field Addition

  • 문제 상황:
    기존에 데이터가 존재하는 WatchProvider 테이블에 기본값(@default)이 없는 필수 컬럼 updatedAt을 추가한 후 prisma migrate을 실행했을때 마이그레이션 중단 에서
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.
  • 원인 분석:
    PostgreSQL DB의 기존 행(Row)에 이미 데이터가 등록된게 있어 기본값이 지정되지 않은 NOT NULL 컬럼을 새로 추가할 수 없었던 데이터 정합성 문제였습니다.
  • 해결 방법:
    schema.prisma 스키마 정의에서 updatedAt 필드에 @default(now())를 함께 명시하여 마이그레이션 수행 시 기존 데이터 레코드에도 현재 시간이 기본값으로 적용되도록 수정했습니다.
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()) 추가
}

Issue 2. Prisma Client Validation Error on Schema Interface Mismatch

  • 문제 상황: Sync-tmdb API 실행 중 TvShow 테이블에 데이터를 upsert 하는 과정에서 필수 필드가 누락되었다는 PrismaClientValidationError가 발생했습니다.
Invalid `prisma.tvShow.upsert()` invocation:
Argument `name` is missing.

  update: {
    name: undefined,
    originalName: undefined, ...
  }
  • 원인 분석:
    TMDB API 응답 구조에서 영화는 tite, TV 프로그램은 name으로 프로퍼티명이 서로 다릅니다. TV 프로그램 수집 루프에서 속성명을 잘못 참조하여 tvShow.title(즉 undefined)를 전달했고, 스키마 상 필수 입력값인 name 필드가 누락되어 데이터 유입 시점에 Validation 에러가 발생했습니다.
  • 해결 방법:
    TV 프로그램 응답 프로퍼티를 name 및 original_name으로 매핑 하고, 만약의 경우를 대비해 ?? 및 Fallbavk(|| "제목 없음")을 추가해 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 ?? "제목 없음",
      // ...
    },
  });
}

5. Server-Side Data Layer & Type Definition

프론트엔드 개발 단계에서 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,
  });
}

6. 작업 결과 및 다음 단계

  • 데이터베이스 동기화 완료: 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) 컴포넌트를 본격적으로 개발해 보겠습니다.

profile
Git: https://github.com/JeongUn1028

0개의 댓글