새로운 블로그 포스트 카테고리를 만들었습니다.
books(독서) 카테고리인데, 책의 제목, 저자, 출판사, 표지 이미지와 같은 정보들과 더불어 저의 짧은 감상문을 적는 곳입니다.
DB에 직접 도서 정보를 저장하는 방식은 번거로울 뿐 아니라 확장성도 떨어집니다.
그래서 외부 도서 검색 API를 활용해 필요한 책 정보를 불러오는 방식을 선택하였습니다.
이번 포스트에서는 네이버 도서 검색 API를 활용하여 책 정보를 가져오고, 이를 Nextjs에서 구현하는 방법을 정리해보았습니다.
구글과 카카오에서도 도서 검색 API를 제공하며, 반환되는 데이터 구조는 조금씩 다르지만 기본적인 사용 방식은 크게 다르지 않습니다.
1. 네이버 개발자 센터에서 API 신청하기
가장 먼저 네이버 개발자 센터에서 애플리케이션을 등록해야 합니다.
- 내 애플리케이션 → 애플리케이션 추가
- 내어플리케이션 개용에서 발급된 Client ID와 Client Secret을 확인합니다
2. 환경 변수 설정 (.env.local)
발급받은 키를 보안을 위해 환경 변수로 저장합니다.
NAVER_CLIENT_ID=여러분의_클라이언트_ID
NAVER_CLIENT_SECRET=여러분의_클라이언트_시크릿
3. API 호출 함수 구현
Next.js의 Server Side에서 API를 호출하는 함수를 만듭니다. isbn을 쿼리로 전달하여 특정 도서의 정보를 가져오도록 구현했습니다.
// lib/supabase/service/posts.ts
export const fetchBook = async (isbn: number) => {
const res = await fetch(
`https://openapi.naver.com/v1/search/book.json?query=${isbn}&d_isbn=${isbn}&display=1`,
{
headers: {
"X-Naver-Client-Id": process.env.NAVER_CLIENT_ID!,
"X-Naver-Client-Secret": process.env.NAVER_CLIENT_SECRET!,
},
next: { revalidate: 60 * 60 * 24 * 365 }, // 1년 캐싱
},
);
const data = await res.json();
if (!res.ok) {
console.error("Naver API Error:", data);
throw new Error(`Naver API Error: ${res.status}`);
}
return data.items?.[0] ?? null;
};
4. 데이터 타입 정의
// types/post.ts
export type Book = {
title: string;
link: string;
image: string;
author: string;
publisher: string;
pubdate: string;
isbn: string;
description: string;
discount: string; // 할인가(사용하지 않음)
};
5. 도서 정보 카드 UI 구현
네이버 API는 <b> 같은 HTML 태그가 포함된 문자열을 반환하기도 하므로,
이를 제거하는 정제 함수(cleanTitle)를 포함했습니다.
// components/common/posts/book-info.tsx
import type { Book } from "@/types/post";
import { ExternalLinkIcon } from "lucide-react";
export default function BookInfo({ book }: { book: Book }) {
// HTML 태그 제거 함수
const cleanTitle = (str: string) =>
str ? str.replace(/<[^>]*>?/gm, "") : "";
const title = cleanTitle(book.title);
const author = cleanTitle(book.author);
const publisher = cleanTitle(book.publisher);
const description = cleanTitle(book.description);
// 날짜 포맷팅 (YYYYMMDD -> YYYY.MM.DD)
const year = book.pubdate.slice(0, 4);
const month = book.pubdate.slice(4, 6);
const day = book.pubdate.slice(6, 8);
return (
<a
href={book.link}
target="_blank"
rel="noopener noreferrer"
className="group bg-card/50 hover:bg-card relative block overflow-hidden rounded-2xl border p-1 transition-all hover:shadow-lg"
>
<div className="flex flex-col gap-6 p-4 sm:flex-row sm:items-start">
{/* 도서 표지 */}
{book.image && (
<div className="bg-muted relative h-48 w-36 shrink-0 overflow-hidden rounded-lg border shadow-md">
<img
src={book.image}
alt={title}
className="h-full w-full object-cover"
/>
</div>
)}
{/* 도서 상세 정보 */}
<div className="flex flex-1 flex-col space-y-4">
<div>
<h3 className="text-foreground group-hover:text-primary text-xl font-bold transition-colors">
{title} <ExternalLinkIcon className="inline-block h-4 w-4" />
</h3>
<div className="text-sm text-gray-500">
{author} | {publisher}
</div>
</div>
<p className="text-muted-foreground line-clamp-3 text-sm">
{description}
</p>
<div className="mt-auto border-t pt-4 text-xs text-gray-400">
발행일: {year}.{month}.{day} | ISBN: {book.isbn}
</div>
</div>
</div>
</a>
);
}
6. 포스트에서 활용하기
동일한 posts 테이블을 사용하므로 isbn의 존재여부로 api 호출 분기를 만들어야 함.
// app/books/[slug]/page.tsx
import CommentSection from "@/components/common/posts/comment/comment-section";
import ShareSection from "@/components/common/posts/share-section";
import SinglePost from "@/components/common/posts/single-post";
import ProgressBar from "@/components/common/progress-bar";
import ScrollToTopButton from "@/components/common/scroll-to-top-button";
import { fetchBook, fetchPost, fetchPosts } from "@/lib/supabase/service/posts";
import type { Book } from "@/types/post";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
// generateStaticParams 로 미리 생성되지 않은 포스트 접속시 허용되지 않도록 설정
// default true
export const dynamicParams = true;
export const revalidate = 43200; // 12 hours
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateStaticParams(): Promise<{ slug: string }[]> {
const posts = await fetchPosts("books");
return posts.map((post) => ({ slug: post.slug }));
}
export default async function SingleBooksPostPage({ params }: Props) {
const { slug } = await params;
const post = await fetchPost(slug);
let bookData: Book | null = null;
if (!post) {
notFound();
}
const isbn = post.isbn;
if (isbn) {
bookData = await fetchBook(isbn);
}
return (
<div className="space-y-10">
<ProgressBar />
<SinglePost post={post} bookData={bookData} />
<ShareSection category="books" slug={post.slug} />
<CommentSection postId={post.post_id} />
<ScrollToTopButton />
</div>
);
}
개별 포스트 컴포넌트에서 bookData가 있을 경우 BookInfo를 렌더링하도록 설정합니다.
// components/common/posts/single-post.tsx
import BookInfo from "./book-info";
type Props = {
post: Post;
bookData?: Book | null;
};
export default function SinglePost({ post, bookData } : Props) {
return (
<article className="space-y-4">
<h1 className="text-3xl font-bold">{post.title}</h1>
{/* 도서 정보가 있다면 상단에 노출 */}
{bookData && <BookInfo book={bookData} />}
<div className="prose dark:prose-invert">
{post.contents}
</div>
</article>
);
}
마치며
네이버 도서 검색 API는 책의 이미지, 저자, 출판사, 설명 등 풍부한 데이터를 제공하여 독서 기록이나 서평 블로그를 만들 때 매우 유용합니다.
주요 포인트는
- 네이버 API 공식문서에서 사용법 확인
- 정규표현식을 활용한 HTML 태그 제거
- next: { revalidate }를 활용한 API 호출 횟수 관리
도움이 되셨길 바랍니다.