지난 글에서는 Prisma 실제 DB 데이터 연동과 Dynamic OTT 필터링, 그리고 연동 과정에서 발생했던 데이터 타입 매핑과 React 연쇄 렌더링 트러블 슈팅을 다루었습니다.
이번 글에서는 ReelTrailer의 주요 기능적 개발을 마무리짓는 단계인, 카테고리별 Recommend Program 다중 필터링 구현, Next.js의 Parallel/Intercepring Routes를 이용한 모달 연동, 404 예외 처리, 그리고 컴포넌트 구조 재설계 트러블슈팅 을 정리해보려 합니다.
이번 글에서 다룰 내용:
단일 추천 목록 구조를 확장하여, 사용자가 선택한 미디어 타입(Movie, TvShow) 및 장르(Genre) 조건에 맞게 추천 프로그램 데이터를 동적으로 필터링하도록 구현했습니다.
주요 연동 포인트
미디어 타입 구분: Movie와 TvShow 엔티티를 각각 분리하여 조회하거나 통합 추천 스코어를 기준으로 데이터를 추출합니다.
Prisma 다중 조건 필터링: 선택된 장르 ID 및 타입 파라미터가 유효할 경우, Prisma where 절에 조건부 쿼리를 바인딩하여 원하는 데이터셋을 정확히 가져옵니다.
UI 레벨 탭/필터 상태 동기화: 프론트엔드 필터 버튼 선택에 따라 API 요청 쿼리를 변경하고, 가로 스크롤 카드 레일 데이터가 갱신되도록 연결했습니다.
export async function getMovies({
providerId,
limit = 20,
page = 1,
}: GetContentsParams = {}): Promise<ProgramType[]> {
const skip = (page - 1) * limit;
const movies = await prisma.movie.findMany({
where: providerId ? { providers: { some: { providerId } } } : {},
include: {
providers: {
where: providerId ? { providerId } : undefined,
include: {
provider: true,
},
},
genres: {
include: {
genre: true,
},
},
},
orderBy: {
popularity: "desc",
},
take: limit,
skip,
});
return movies.map((movie) => ({
...movie,
mediaType: "movie" as const,
providers: movie.providers.map((p) => p.provider),
genres: movie.genres.map((g) => g.genre),
}));
}
포스터를 클릭했을 때 기존 화면을 유지한 채 모달을 띄우고, 공유나 새로고침 시에도 독립적인 상세 페이지로 진입할 수 있도록 Next.js App Router의 Parallel Routes(@modal)와 Intercepting Routes((...)contents/[id]) 패턴을 도입했습니다.
@modal/(...)programs/[programId]/page.tsx: 클라이언트 라우팅 중 콘텐츠 카드 클릭 시 현재 라우트 경로를 인터셉트하여 @modal 슬롯 영역에 오버레이 형태로 상세 모달을 렌더링합니다.
@modal/default.tsx: 모달이 활성화되지 않은 일반 상태에서 슬롯 영역에 아무것도 출력하지 않도록 null을 반환합니다.
program/[programId]/page.tsx: 모달 링크를 새 탭으로 열거나 페이지를 새로고침할 경우, 인터셉트가 발생하지 않고 온전한 독립 상세 페이지로 이동하도록 보장합니다.
URL 상태 보존 & 뒤로가기 자연스러운 UX: 단순 React State가 아닌 실제 URL 경로가 변경되므로 모달이 열린 상태에서 브라우저 뒤로가기(Back) 버튼 클릭 시 자연스럽게 모달만 닫힙니다.
상세 정보 및 트레일러 바인딩: DB에서 가져온 개요(Overview), 평점(Vote Average), 개봉/첫 방영일, 제공 OTT 프로바이더 로고, 그리고 YouTube trailerKey 기반 비디오 플레이어를 모달 내부에 배치했습니다.
UX/UI 개선: 모달 외부 영역(Backdrop) 클릭 시 router.back()을 호출하여 모달을 닫고 이전 UI 상태로 복귀하도록 처리했습니다.
detail.tsx
import Image from "next/image";
import { notFound } from "next/navigation";
import type { ProgramType } from "@/app/types/types";
import { normalizeProviderName } from "@/app/lib/normalizeProviderName";
import styles from "./programDetail.module.css";
export default async function ProgramDetail({
programId,
kind,
}: {
programId: string;
kind?: string;
}) {
if (
!/^[1-9]\d*$/.test(programId) ||
!["movie", "tvshow"].includes(kind ?? "")
) {
notFound();
}
const program = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/getProgramById?id=${programId}&kind=${kind}`,
);
if (!program.ok) {
if (program.status === 404) {
notFound();
}
throw new Error(`Failed to fetch program: ${program.status}`);
}
const programData: ProgramType = await program.json();
const date = programData.releaseDate ?? programData.firstAirDate;
const releaseYear = date ? new Date(date).getFullYear() : null;
const providers = programData.providers
.map((provider) => provider.providerName)
.filter(Boolean);
const posterSrc = programData.posterPath
? `https://image.tmdb.org/t/p/w780${programData.posterPath}`
: null;
const genreDetails = programData.genres.map((genre) => genre.genre?.name);
const normalizedProviders = providers.map(normalizeProviderName);
return (
<article className={styles.detail}>
<div className={styles.hero}>
<div className={styles.posterFrame}>
{posterSrc ? (
<Image
src={posterSrc}
alt={`${programData.title} 포스터`}
fill
sizes="(max-width: 700px) 42vw, 260px"
className={styles.poster}
priority
/>
) : (
<div className={styles.posterFallback}>NO IMAGE</div>
)}
</div>
<div className={styles.heading}>
<p className={styles.kicker}>
{kind === "movie" ? "MOVIE" : "TV SHOW"}
</p>
<h1>{programData.title}</h1>
{programData.originalTitle && (
<p className={styles.originalTitle}>{programData.originalTitle}</p>
)}
<div className={styles.meta}>
<span className={styles.rating}>
<span aria-hidden="true">★</span>{" "}
{programData.voteAverage.toFixed(1)}
</span>
{releaseYear && <span>{releaseYear}</span>}
{genreDetails.length > 0 && (
<span>{genreDetails.slice(0, 2).join(" · ")}</span>
)}
</div>
</div>
</div>
<div className={styles.body}>
<section className={styles.overviewSection}>
<p className={styles.sectionLabel}>STORY</p>
<p className={styles.overview}>
{programData.overview || "등록된 줄거리 정보가 없습니다."}
</p>
</section>
<div className={styles.infoGrid}>
<section>
<p className={styles.sectionLabel}>GENRES</p>
<p className={styles.infoValue}>
{genreDetails.join(" · ") || "정보 없음"}
</p>
</section>
<section>
<p className={styles.sectionLabel}>WATCH ON</p>
<p className={styles.infoValue}>
{normalizedProviders.join(" · ") || "정보 없음"}
</p>
</section>
</div>
</div>
</article>
);
}
존재하지 않는 콘텐츠 ID나 잘못된 URL 경로로 접근했을 때 발생할 수 있는 클라이언트 에러를 방지하고, 유저가 자연스럽게 메인 화면으로 돌아올 수 있도록 404 예외 처리 로직을 구축했습니다.
주요 연동 포인트
Next.js 커스텀 not-found.tsx 구현: ReelTrailer의 전역 테마(다크 모드 / 넷플릭스 스타일 UI)에 맞춘 404 전용 안내 페이지를 제작했습니다.
서버 단 존재 여부 검증 및 리다이렉트: Dynamic Route(/contents/[id] 등)에서 DB 조회 결과가 null인 경우, Next.js의 notFound() 함수를 호출하여 404 페이지로 명확히 리다이렉트시켰습니다.
안전한 복귀 동선 제공: 404 페이지 내에 메인 페이지(/)로 바로 이동할 수 있는 CTA 버튼을 배치하여 이탈률을 줄였습니다.
import Link from "next/link";
import styles from "./not-found.module.css";
export default function NotFound() {
return (
<main className={styles.page}>
<div className={styles.content}>
<p className={styles.eyebrow}>REELTRAILER / ERROR 404</p>
<p className={styles.code}>404</p>
<h1>페이지를 찾을 수 없습니다</h1>
<p className={styles.description}>
요청하신 콘텐츠가 삭제되었거나, 주소가 변경되었을 수 있습니다.
</p>
<Link className={styles.homeLink} href="/">
홈으로 돌아가기
<span aria-hidden="true">-></span>
</Link>
</div>
</main>
);
}
문제 상황: 추천 프로그램을 구현하는 과정에서 검색 영역을 그대로 보여주기 위해 RecommendSection 컴포넌트를 재사용하려 했으나, 컴포넌트 내부 데이터 페칭 로직과 UI 매핑 방식이 엉키면서 메인 화면과 검색 화면 양쪽 모두의 코드가 꼬이는 상황이 발생했습니다.
원인 분석 (교훈: 개발 순서와 아키텍처 수립의 중요성):
컴포넌트 단위를 명확히 분리하지 않은 채 마구잡이로 기능을 우선 구현하다 보니 발생한 문제였습니다.
RecommendSection 내부에서 데이터 페칭(API 호출)과 Presentation(UI 표시) 역할이 하나로 단일화되어 있었습니다.
메인 페이지 전용 데이터 페칭 로직이 컴포넌트 내부에 결합되어 있어, 검색 결과 화면처럼 별도의 필터링된 데이터셋을 주입받아야 하는 상황에 유연하게 대응하지 못했습니다.
해결 방법:
개발 순서를 재정비하고, 데이터 페칭과 UI 렌더링 역할을 구분하는 방식으로 진행했습니다.
공통으로 넘겨주는 인수를 만들고 해당 인수로 컴포넌트에서 데이터 페칭을 하도록 했습니다.
export default async function Programs({
title,
providerId,
}: {
title: string;
providerId?: string;
}) {
const programs: Array<ProgramType> | null = await getPrograms(
title,
providerId,
);
//* 결과가 없으면 null 반환
if (!programs || programs.length === 0) {
return null;
}
const displayTitle =
title !== "영화" && title !== "프로그램" ? ` ${title} 장르` : ` ${title}`;
return (
<div className={styles.container}>
<div>
<div className={styles.title}>
추천하는
{displayTitle}
</div>
</div>
<div className={styles.programContainer}>
{programs?.map((program) => (
<Program
key={`${program.mediaType}-${program.id}`}
props={program}
title={title}
/>
))}
</div>
</div>
);
}
영화/TV/장르별 Recommend Program 필터링 API 및 UI 연동 완료
Intercepting & Parallel Routes를 활용한 Soft/Hard Navigation 호환 Detail Modal 구현
커스텀 404 not-found 페이지 구축 및 존재하지 않는 콘텐츠 접근 시 리다이렉트 예외 처리
기능적인 구현은 끝났지만, 실제 서비스 완성도와 사용자 경험(UX)을 끌어올리기 위한 서버 아키텍처 및 성능 최적화 작업이 남아있습니다.
API 호출 구조 최적화: Server Component 내부에서 불필요하게 내부 API Route를 요청하던 방식을 배제하고, DB 조회 로직을 서버에서 직접 실행하도록 변경하여 불필요한 네트워크 요청 감소
React Suspense & Skeleton UI 도입: 서버 사이드 데이터 페칭 과정에서 발생하는 화면 멈춤 현상을 방지하고 점진적 스트리밍 렌더링을 적용하여 UX 향상
SEO 최적화: Dynamic Metadata, OpenGraph, 시맨틱 태그 설정을 통해 검색 엔진 노출 및 공유 가시성 강화