다른 프로젝트에서 Spotify API를 사용하여 노래 사이트를 제작했었는데 안 좋은 점이 API 사이트도 너무 오래된 느낌이 심하고 코드도 최신 버전이 아니라 어려운 부분이 많았다. 추가로 프리미엄 요금제를 가입하지 않으면 제한이 생기기 때문에 다음에는 공개 API를 사용하여 프로젝트를 진행할 때는 API 공식 문서를 잘 살펴보고 데이터 결괏값도 잘 나오는지 살펴보고 하자는 마음이 컸었다.
이번에는 요즘에 OTT 사이트가 많다 보니 나도 한 번 영화나 TV 프로그램 정보를 나타내는 사이트를 오픈 API를 이용하여 만들어보자고 생각하고 영화 관련 API를 찾다가 TMDB를 알게 되어 진행하게 되었다.
우선 인증 관련 부분을 처리한 것을 적기 전에 TMDB(영화 데이터베이스, The Movie Database)는 영화 및 TV 프로그램과 관련된 정보와 데이터를 제공하는 온라인 데이터베이스이다.
당연히 무료이고 영화, TV 프로그램, 출연진, 제작진 등에 대한 상세한 정보를 많이 제공하기 때문에 많은 정보를 활용하여 웹을 제작할 수 있다.
기본적인 영화나 TV 프로그램에 대한 데이터는 Session Id가 없어도 가져올 수는 있지만 제공되는 콘텐츠에 좋아요 기능, 시청 목록에 추가, 영화 평가 등 기능을 실행시키려면 Session Id가 필요하다.
공식 API 참조문서를 보면 설명이 잘 나와있다.
실제 코드에 어떻게 적용했는지 작성해보겠다.
Create Request Token API 참조 문서
참고로 회원가입하는 과정은 포함하지 않기 때문에 회원가입을 했다는 가정하에 자신의 프로필 버튼 클릭 -> 설정 -> 좌측 패널 API 버튼 클릭 -> API 읽기 액세스 토큰 복사 과정이 필요하다. 왜냐하면 API 데이터를 요청할 때 액세스 토큰이 필요하기 때문이다.
.env.local 파일을 생성하여 TMDB_ACCESS_TOKEN = <자신의 액세스 토큰 값> 을 저장해 준다. 나는 Navbar 컴포넌트의 로그인 버튼을 클릭하면 최종적으로 Session id를 생성할 것이기 때문에 Navbar OnClick 속성에 전달한 handleLogin 함수안에 로직을 작성했다.
handleLogin 함수
const handleLogin = async () => {
try {
const response = await fetch("/api/getRequestToken");
if (!response.ok) throw new Error("Failed to fetch request token");
const data = await response.json();
const requestToken = data.request_token;
const redirectUrl = `${process.env.NEXT_PUBLIC_REDIRECT_URL}?token=${requestToken}`;
router.push(
`https://www.themoviedb.org/authenticate/${requestToken}?redirect_to=${redirectUrl}`
);
} catch (error) {
console.error("Error during login:", error);
}
};
app/api/getRequestToken/route.ts
아래 noStore()를 사용한 이유는 여기서 자세히 설명한 것은 아니지만 Request token은 유효 시간이 1시간인데 Next.js 라우트 핸들러는 라우트 핸들러 GET 요청을 기본적으로 캐싱 하기 때문에 요청마다 새로운 Request token을 발급하기 위해서 사용했다고 간단하게 짚고 넘어가겠다.
import { NextResponse } from "next/server";
import { unstable_noStore as noStore } from "next/cache";
export async function GET(request: Request) {
noStore();
try {
const response = await fetch(
"https://api.themoviedb.org/3/authentication/token/new",
{
method: "GET",
headers: {
accept: "application/json",
Authorization: `Bearer ${process.env.TMDB_ACCESS_TOKEN}`,
},
}
);
if (!response.ok) throw new Error("Failed to fetch request token");
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 500 });
} else {
// Error 인스턴스가 아닐 경우, 기본 메시지를 사용
return NextResponse.json(
{ error: "An unknown error occurred" },
{ status: 500 }
);
}
}
}
Navbar 컴포넌트가 'use client' 지시문을 추가함으로써 클라이언트 컴포넌트로 동작하고 API AceessToken에 접근하기 때문에 라우트 핸들러를 이용했다.
요청한 Request Token을 얻고 router.push()를 실행하는데 타사 인증 요청 사이트로 가서 허가를 클릭하면 redirect_to에 명시된 주소로 이동한다. 타사 인증 요청을 허가해야 Request Token을 이용하여 Session Id이 생성이 가능하다.
NEXT_PUBLIC_REDIRECT_URL=http://localhost:3000/authenticate
const redirectUrl = `${process.env.NEXT_PUBLIC_REDIRECT_URL}?token=${requestToken}`;
router.push(`https://www.themoviedb.org/authenticate/${requestToken}?redirect_to=${redirectUrl}`)
인증 요청을 허가한 Request Token을 사용해야 하기 때문에 redirect_to url에 쿼리로 token 값을 전달해 주었다.
url에 전달된 token 값을 얻어서 이번에는 /api/create/createSession/route.ts 라우트 핸들러에 요청한다. Request token 값이 필요하기 때문에 body에 값을 전달해준다.
마찬가지로 router.push("/)를 사용하여 기존 Home.page로 돌아간다. authenticate/page.tsx는 인증을 위한 경로라고 생각하면 된다.
app/authenticate/page.tsx
"use client";
import { useAuth } from "@/context/AuthContext";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { Suspense } from "react";
const Authenticate = () => {
const searchParams = useSearchParams();
const router = useRouter();
const { fetchUserInfo } = useAuth();
useEffect(() => {
const fetchSessionId = async () => {
const token = searchParams.get("token");
if (!token) return;
try {
const response = await fetch("/api/createSession", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
});
const data = await response.json();
await fetchUserInfo();
router.push("/");
} catch (error) {
console.error("Error during session creation:", error);
}
};
fetchSessionId();
}, [searchParams, router, fetchUserInfo]);
return <div>Session Id 생성 성공</div>;
};
const AuthenticatePage = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<Authenticate />
</Suspense>
);
};
export default AuthenticatePage;
실제 TMDB API를 이용하여 데이터를 요청할 때 body에 token 값을 전달하는 것을 잊으면 안 된다.
받아온 Session Id 값은 쿠키에 저장하였다.
/api/create/createSession/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { token } = await request.json();
const cookieStore = cookies();
const existingSessionId = cookieStore.get("session_id");
if (existingSessionId) {
return NextResponse.json(
{ message: "Session ID already exists" },
{ status: 200 }
);
}
try {
const response = await fetch(
"https://api.themoviedb.org/3/authentication/session/new",
{
method: "POST",
headers: {
accept: "application/json",
"Content-type": "application/json",
Authorization: `Bearer ${process.env.TMDB_ACCESS_TOKEN}`,
},
body: JSON.stringify({
request_token: token,
}),
}
);
if (!response.ok) throw new Error("Failed to create session ID");
const data = await response.json();
// response 객체 생성 session ID 쿠키 설정
const res = NextResponse.json(data);
res.cookies.set("session_id", data.session_id, {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/",
});
return res;
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 500 });
} else {
// Error 인스턴스가 아닐 경우, 기본 메시지를 사용
return NextResponse.json(
{ error: "An unknown error occurred" },
{ status: 500 }
);
}
}
}
마지막 단계인 사용자 이름을 얻어오는 것이다. 나는 AuthContext를 이용해서 얻어온 username을 localStorage에 저장하여 사용했다. 중요한 부분은 정보를 얻어오는 /api/getUserInfo/route.ts 라우트 핸들러에 요청하는 fetchUserInfo 함수를 위에 redirect_to 경로로 이용했던 app/authenticate/page.tsx 파일에서 호출한다는 것이다. 왜냐면 session id가 있어야 사용자 정보를 얻어올 수 있기 때문에 세션을 생성하는 로직 이후에 호출을 해줘야 한다.
app/context/AuthContext.tsx
"use client";
import { deleteSessionId } from "@/lib/cookies";
import { AuthContextType } from "@/types";
import { createContext, useContext, useEffect, useState } from "react";
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [username, setUserName] = useState<string | null>(null);
useEffect(() => {
const storedUsername = localStorage.getItem("username");
if (storedUsername) {
setUserName(storedUsername);
}
}, []);
useEffect(() => {
if (username) {
localStorage.setItem("username", username);
} else {
localStorage.removeItem("username");
}
}, [username]);
const fetchUserInfo = async () => {
try {
const response = await fetch("/api/getUserInfo");
if (response.ok) {
const data = await response.json();
setUserName(data.username);
}
} catch (error) {
console.error("Error fetching user info:", error);
}
};
const signOut = () => {
setUserName(null);
deleteSessionId();
localStorage.removeItem("username");
};
return (
<AuthContext.Provider value={{ username, fetchUserInfo, signOut }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export default AuthProvider;
/api/getUserInfo/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(request: Request) {
const cookieStore = cookies();
const sessionId = cookieStore.get("session_id")?.value;
try {
const response = await fetch(
`https://api.themoviedb.org/3/account/21416335?session_id=${sessionId}`,
{
method: "GET",
headers: {
accept: "application/json",
Authorization: `Bearer ${process.env.TMDB_ACCESS_TOKEN}`,
},
}
);
if (!response.ok) throw new Error("Failed to fetch request token");
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 500 });
} else {
// Error 인스턴스가 아닐 경우, 기본 메시지를 사용
return NextResponse.json(
{ error: "An unknown error occurred" },
{ status: 500 }
);
}
}
}
공식 문서를 처음 읽었을 때 인증과 정을 3단계로 설명해 주는 글을 읽고 엄청 간단하고 잘 나와있다고 생각하였다. 이것은 사실이다. 하지만 redirect_to 부분에 대한 authenticate/page.tsx 처리와 환경 변수인 TMDB_ACCESS_TOKEN 같은 중요한 정보들을 클라이언트 측에서 접근하여 사용하는 것보다 보안을 위해 라우트 핸들러를 사용하는 것이 유용하다고 나와있기에 라우트 핸들러를 이용하는 부분이 익숙하지 않아서 공식 문서를 보며 어떻게 데이터를 저장하고 어디서 호출해야 하는지 많은 고민을 했다.
다음 글은 경로를 설정하여 데이터를 받아와서 보여주는 과정을 작성할 것 같다.