Next.js, Clerk, TailwindCSS로 YouTube Live 클론 만들기 - Part Two
Source: Dev.to
개요
이 2부 시리즈의 첫 번째 파트에서는 YouTube Live 클론의 기본을 설정했습니다:
- 데이터베이스용 Prisma 구성
- Clerk를 이용한 인증 추가
- 앱에 Stream 통합
- 홈 페이지 구축
두 번째 파트에서는 Stream React Video SDK를 사용해 라이브스트림 생성 및 시청 페이지를 만들고, React Chat SDK를 활용해 실시간 채팅 기능을 추가합니다.
실제 데모와 전체 소스 코드는 GitHub에서 확인할 수 있습니다.
반응 시스템 구축 (좋아요/싫어요)
YouTube와 유사한 반응 시스템을 구현해 시청자들이 라이브스트림에 참여할 수 있게 합니다. 이를 위해 프론트엔드 상태를 관리하는 커스텀 훅과 Prisma를 통해 데이터를 영구 저장하는 API 라우트를 만들겠습니다.
useReactions 훅 만들기
useReactions 훅은 초기 카운트를 가져오고, 낙관적 UI 업데이트를 처리하며, 백엔드와 동기화합니다.
hooks/useReactions.tsx 파일을 생성합니다:
'use client';
import { useEffect, useState } from 'react';
type MyReaction = 'LIKE' | 'DISLIKE' | null;
export function useReactions(livestreamId: string) {
const [likes, setLikes] = useState(0);
const [dislikes, setDislikes] = useState(0);
const [myReaction, setMyReaction] = useState<MyReaction>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
(async () => {
const res = await fetch(`/api/livestreams/${livestreamId}/reactions`, {
cache: 'no-store',
});
const data = await res.json();
if (!alive) return;
setLikes(data.likes);
setDislikes(data.dislikes);
setMyReaction(data.myReaction);
setLoading(false);
})();
return () => {
alive = false;
};
}, [livestreamId]);
const send = async (type: 'LIKE' | 'DISLIKE') => {
// optimistic update
const prev = { likes, dislikes, myReaction };
if (type === 'LIKE') {
if (myReaction === 'LIKE') {
setLikes(likes - 1);
setMyReaction(null);
} else if (myReaction === 'DISLIKE') {
setDislikes(dislikes - 1);
setLikes(likes + 1);
setMyReaction('LIKE');
} else {
setLikes(likes + 1);
setMyReaction('LIKE');
}
} else {
if (myReaction === 'DISLIKE') {
setDislikes(dislikes - 1);
setMyReaction(null);
} else if (myReaction === 'LIKE') {
setLikes(likes - 1);
setDislikes(dislikes + 1);
setMyReaction('DISLIKE');
} else {
setDislikes(dislikes + 1);
setMyReaction('DISLIKE');
}
}
try {
const res = await fetch(`/api/livestreams/${livestreamId}/reactions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type }),
});
if (!res.ok) throw new Error('Failed');
const data = await res.json();
setLikes(data.likes);
setDislikes(data.dislikes);
setMyReaction(data.myReaction);
} catch {
// revert on error
setLikes(prev.likes);
setDislikes(prev.dislikes);
setMyReaction(prev.myReaction);
}
};
return {
likes,
dislikes,
myReaction,
like: () => send('LIKE'),
dislike: () => send('DISLIKE'),
loading,
};
}
핵심 포인트
useEffect는 컴포넌트가 마운트될 때 초기 반응 수와 현재 사용자의 반응을 가져옵니다.send함수는 API 요청이 완료되기 전에 로컬 상태를 즉시 업데이트해 낙관적 UI를 구현합니다.- 요청이 실패하면
catch블록에서 이전 상태로 되돌려 UI와 데이터베이스가 일치하도록 합니다.
반응 API 라우트 만들기
app/api/livestreams/[livestreamId]/reactions/route.ts 파일을 생성합니다:
import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import prisma from '@/lib/prisma';
export async function GET(
_req: Request,
{ params }: { params: Promise<any> }
) {
const { userId } = await auth();
const { livestreamId } = await params;
const [likes, dislikes, mine] = await Promise.all([
prisma.reaction.count({ where: { livestreamId, type: 'LIKE' } }),
prisma.reaction.count({ where: { livestreamId, type: 'DISLIKE' } }),
userId
? prisma.reaction.findUnique({
where: { userId_livestreamId: { userId, livestreamId } },
select: { type: true },
})
: null,
]);
return NextResponse.json({
likes,
dislikes,
myReaction: mine?.type ?? null,
});
}
export async function POST(
req: Request,
{ params }: { params: Promise<any> }
) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { livestreamId } = await params;
const { type } = (await req.json()) as { type: 'LIKE' | 'DISLIKE' };
if (type !== 'LIKE' && type !== 'DISLIKE') {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
}
// Toggle or upsert reaction
const existing = await prisma.reaction.findUnique({
where: { userId_livestreamId: { userId, livestreamId } },
});
if (existing && existing.type === type) {
await prisma.reaction.delete({
where: { userId_livestreamId: { userId, livestreamId } },
});
} else {
await prisma.reaction.upsert({
where: { userId_livestreamId: { userId, livestreamId } },
create: { userId, livestreamId, type },
update: { type },
});
}
// Return fresh counts and current reaction
const [likes, dislikes, mine] = await Promise.all([
prisma.reaction.count({ where: { livestreamId, type: 'LIKE' } }),
prisma.reaction.count({ where: { livestreamId, type: 'DISLIKE' } }),
prisma.reaction.findUnique({
where: { userId_livestreamId: { userId, livestreamId } },
select: { type: true },
}),
]);
return NextResponse.json({
likes,
dislikes,
myReaction: mine?.type ?? null,
});
}
이 라우트는 다음을 제공합니다:
- GET – 전체 좋아요·싫어요 수와 인증된 사용자의 현재 반응(
null가능)을 반환합니다. - POST – 반응을 토글하거나 업데이트하고, 최신 카운트와 현재 반응을 반환합니다.
라이브스트림 스튜디오 구축
라이브스트림 스튜디오는 방송자가 스트림을 생성하고, OBS 키를 받아오며, 방송을 관리하는 명령 센터입니다.
라이브스트림 레이아웃 만들기
레이아웃은 Stream 호출을 초기화하고, 사용자가 스튜디오에 들어갈 때 채팅 채널이 준비되도록 합니다.
app/(home)/livestreaming/layout.tsx 파일을 생성합니다:
'use client';
import { ReactNode, useEffect, useState } from 'react';
import {
Call,
StreamCall,
useStreamVideoClient,
} from '@stream-io/video-react-sdk';
import { nanoid } from 'nanoid';
import { useUser } from '@clerk/nextjs';
import { useChatContext } from 'stream-chat-react';
import Spinner from '@/components/Spinner';
import { joinCall } from '@/lib/utils';
interface LivestreamLayoutProps {
children: ReactNode;
}
const LivestreamLayout = ({ children }: LivestreamLayoutProps) => {
// implementation details...
return (
<>
{/* layout markup */}
{children}
</>
);
};
export default LivestreamLayout;
(전체 구현 내용은 레포지토리에서 확인할 수 있으며, 위 코드는 파일 구조와 import 구문을 보여줍니다.)