오늘은 /projects로 접속했을 때 노션 데이터베이스에 있는 프로젝트 목록을 조회해서
화면에 보여주도록 구현하는 과정을 정리해보려고 한다.
우선 노션에 Database를 생성한 후 아래와 같이 프로퍼티들을 추가한다.
category는 select 타입, description과 slug는 text 타입, order는 number타입, skills는 multi-select 타입이다.
그리고 해당 데이터베이스의 ID를 조회하여 .env에 다음과같이 추가한다.
NOTION_PROJECT_DATABASE_ID=DB아이디
데이터베이스 ID는 데이터베이스의 링크를 복사하여 조회할 수 있다.
https://www.notion.so/<DB아이디>?v=<~~>&source=copy_link
우선 /types/project.ts파일을 생성한 후 아래와 같이 프로젝트 목록에 해당하는 type을 정의하였다.
export interface MultiSelect {
id: string;
name: string;
color?: string;
}
export interface ProjectItem {
id: string;
title: string;
coverImage?: string;
skills: MultiSelect[];
slug: string;
description?: string;
}
노션API를 사용하여 데이터베이스를 조회하고 그 결과들을 ProjectItem 타입으로 변환하여 반환하는 함수를 구현해보자.
나는 /lib/apis/projects.ts 파일을 생성한 후 작성하였다.
import type {PageObjectResponse} from "@notionhq/client/build/src/api-endpoints";
import {ProjectItem} from "@/types/project";
import { notion } from "@/lib/notion";
export const getProjectListByCategory = async (category: string): Promise<ProjectItem[]> => {
const response = await notion.databases.query({
database_id: process.env.NOTION_PROJECT_DATABASE_ID!,
filter: {
and: [
{
property: 'category',
select: {
equals: category,
},
},
],
},
sorts: [
{
property: 'order',
direction: 'ascending',
},
],
page_size: 4,
});
const projects = response.results
.filter((page): page is PageObjectResponse => 'properties' in page)
.map(convertToProjectItem);
return projects;
};
코드를 살펴보면 notion.databases.query 함수를 사용하여 데이터베이스를 조회하는 것을 확인할 수 있다.
만약 getProjectListByCategory('work')를 호출하면 category 프로퍼티의 값이 work인 목록만 필터링되어 조회된다.
나는 카테고리별 프로젝트 개수가 4개를 넘지 않아서 page_size를 4로 설정하였는데, 자유롭게 변경하여도 된다.
이제 PageObjectResponse 타입인 객체를 ProjectItem 타입의 객체로 변환하는 함수를 작성해보자.
function convertToProjectItem(page: PageObjectResponse): ProjectItem {
const { properties } = page;
return {
id: page.id,
title: getTextByPropertyName(properties, 'Name'),
coverImage: getCoverImage(page.cover),
skills: getMultiSelectByPropertyName(properties, 'skills'),
description: getTextByPropertyName(properties, 'description'),
slug: getTextByPropertyName(properties, 'slug'),
};
}
convertToProjectItem 함수에서 사용된 함수들은 다음과 같다.
export const getCoverImage = (cover: PageObjectResponse['cover']) => {
if (!cover) return '';
switch (cover.type) {
case 'external':
return cover.external.url;
case 'file':
return cover.file.url;
default:
return '';
}
};
export function getTextByPropertyName(properties: PageObjectResponse['properties'], propertyName:string): string {
if (!properties[propertyName]) {
return '';
}
const type = properties[propertyName].type;
if (type === 'title' && properties[propertyName].title.length > 0) {
return properties[propertyName].title[0].plain_text ?? '';
} else if (type === 'rich_text' && properties[propertyName].rich_text.length > 0){
return properties[propertyName].rich_text[0].plain_text ?? '';
}
return '';
}
export function getSelectByPropertyName(properties: PageObjectResponse['properties'], propertyName:string): null | MultiSelect {
if (!properties[propertyName] || properties[propertyName].type !== 'select' ) {
return null;
}
return properties[propertyName].select;
}
export function getMultiSelectByPropertyName(properties: PageObjectResponse['properties'], propertyName:string): MultiSelect[] {
if (!properties[propertyName] || properties[propertyName].type !== 'multi_select' ) {
return [];
}
const multiSelects: MultiSelect[] = properties[propertyName].multi_select.map((item: MultiSelect) => {
return { id: item.id, name: item.name, color: item.color }
}
)
return multiSelects;
}
앞서 구현한 getProjectListByCategory 함수를 사용하여 프로젝트 목록을 조회하고, 보여주는 화면을 구현해보자.
/app/projects/page.tsx 파일을 생성한 후 다음과 같이 작성한다.
export default async function ProjectList() {
const workProjects = await getProjectListByCategory('work');
const toyProjects = await getProjectListByCategory('toy');
const sections = [
{ title: 'Work', projects: workProjects },
{ title: 'Toy Projects', projects: toyProjects }
];
return (
<PageLayout>
{sections.map((section, index) => (
<div key={index}>
<ProjectSection title={section.title} projects={section.projects} />
{index < sections.length - 1 && <Separator className="my-10" />}
</div>
))}
</PageLayout>
);
}
ProjectList 컴포넌트의 자식 컴포넌트는 다음과 같다.
export default function PageLayout({ children, leftSidebar, rightSidebar }: PageLayoutProps) {
return (
<div className="container py-6 md:py-8 lg:py-12">
<div className="grid grid-cols-1 gap-4 md:grid-cols-[240px_1fr_240px] md:gap-8">
{/* 왼쪽 사이드바 */}
<div className="hidden md:block">
<div className="sticky top-16">
{leftSidebar}
</div>
</div>
{/* 메인 컨텐츠 */}
<div>
{children}
</div>
{/* 오른쪽 사이드바 */}
<div className="hidden md:block">
<div className="top-4 space-y-6">
{rightSidebar}
</div>
</div>
</div>
</div>
);
}
function ProjectSection({ title, projects }: { title: string; projects: ProjectItem[] }) {
return (
<div>
<h1 className="text-3xl font-bold mb-4">{title}</h1>
<div>
{projects.map((project) => (
<ProjectLink key={project.id} item={project} />
))}
</div>
</div>
);
}
function ProjectLink({ item }: { item: ProjectItem }) {
return (
<Link
href={`/projects/${item.slug}`}
className="w-[100%] mx-auto mb-6 block hover:opacity-80 transition-opacity"
>
<div className="flex gap-6 p-4 rounded-lg border border-border">
{/* 왼쪽 섹션 - 커버 이미지 */}
<div className="w-[35%] relative flex-shrink-0">
{item.coverImage ? (
<Image
src={item.coverImage}
alt={item.title}
fill
className="object-cover rounded-md"
/>
) : (
<div className="w-full h-full bg-muted rounded-md" />
)}
</div>
{/* 오른쪽 섹션 - 제목, 스킬, 설명 */}
<div className="flex-1 space-y-3">
<h3 className="text-lg font-bold">{item.title}</h3>
<div className="flex flex-wrap gap-2">
{item.skills.map((skill) => (
<span
key={skill.id}
className="px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
>
{skill.name}
</span>
))}
</div>
{item.description && (
<p className="text-muted-foreground line-clamp-2 text-sm">
{item.description}
</p>
)}
</div>
</div>
</Link>
);
}
다음과같이 next.config.js를 수정하여야 이미지가 정상적으로 조회된다.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['prod-files-secure.s3.us-west-2.amazonaws.com'],
},
}
module.exports = nextConfig
이제 프로젝트 목록 화면 개발이 끝났다.
다음에는 /projects/프로젝트명 경로로 접속했을 때 프로젝트의 상세 내용을 조회하는 페이지를 구현해보도록 하겠다.
[Next.js] 동적 라우팅 페이지에 SSG 적용해서 페이지 로딩 속도 개선하기 (1) | 2025.07.16 |
---|---|
[개발자 포트폴리오 > AboutMe] 목차 네비게이션 구현 | 마크다운 컴파일, withSlugs, withToc, withTocExport (0) | 2025.06.09 |
[개발자 포트폴리오 > AboutMe] 마크다운 화면에 뿌리기, word highlighting 적용 | MDXRemote, Tailwind (1) | 2025.06.05 |
[개발자 포트폴리오 > AboutMe] 노션 페이지 내용 조회 함수 구현 | pageToMarkdown (1) | 2025.06.04 |
[개발자 포트폴리오] 프로젝트 개요, 기술 스택 및 선정 이유, 핵심 기능 (2) | 2025.06.02 |