사용한 기술
- Next.js
- 가장 최신버전인 15 버전을 사용하였습니다.
- App Router 환경에서 페이지를 구현하였습니다.
- gray-matter
- 마크다운 파일의 프론트매터와 내용을 파싱해주는 라이브러리입니다.
- next-mdx-remote
- 마크다운을 html로 변환해주는 라이브러리입니다.
- Vercel
- 간단하게 github 레포지토리를 이용해 배포를 할 수 있는 플랫폼입니다.
- 파일을 불러오는
fs
모듈을 사용하면서 에러가 발생했었는데, 공식문서에 15 버전 기준으로 작성되있지 않아 조금 헤멨습니다.
FSD 아키텍처
FSD 아키텍처는 구조가 명확하고 유지보수가 용이해, 특히 프론트엔드 생태계에 적합하다고 느꼈습니다. Next.js
의 App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트를 분리해서 작성해야 하므로, FSD 아키텍처의 장점이 더욱 빛난다고 생각합니다. 이번 프로젝트에도 이 아키텍처를 선택했습니다.
동적 라우팅
우선 블로그를 마크다운 파일로 작성을 하게 될텐데 이 파일들을 URL path를 통해 구분하고 라우팅하도록 폴더를 만들어주어야 합니다.
저의 경우에는 포스트 리스트 페이지는 /posts
, 포스트 상세 페이지는 /posts/[category]/[id]
의 URL path로 라우팅 할 예정이기 때문에 app
폴더에 아래와 같이 폴더들을 구성하였습니다.
- app
- posts
- page.tsx
- [category]
- [id]
- page.tsx
- [category]
마크다운 파일 관리
이제 각 주소에 접근 했을때 필요한 정보들을 가져와야합니다. 저는 프로젝트 규모가 그렇게 크지는 않아 util
에서 모든 라이브러리들을 모아서 관리하려고 합니다.
interface Utils {
getFullPath: (paths?: string) => string;
getFile: (path: string) => string;
getDirectory: (path: string) => string[];
getMatter: (fileContent: string) => { data: FrontMatter; content: string };
}
export const utils: Utils = {
getFullPath: (paths) => {
if (!!paths) {
return utils.decodeURI(path.join(process.cwd(), paths));
} else {
return process.cwd();
}
},
getFile: (path) => {
if (!fs.existsSync(path)) {
throw new Error(`Directory not found: ${path}`);
} else {
return fs.readFileSync(path, 'utf-8');
}
},
getDirectory: (path) => {
if (!fs.existsSync(path)) {
throw new Error(`Directory not found: ${path}`);
} else {
return fs.readdirSync(path, 'utf-8');
}
},
getMatter: (fileContent) => {
const { data, content } = matter(fileContent);
return { data: data as FrontMatter, content };
}
};
- getFullPath, getFile, getDirectory:
path
,fs
,process
모듈을 사용해 포스트에 필요한 디렉토리와 파일을 읽습니다. - getMatter:
gray-matter
를 사용해 마크다운 파일의 front-matter와 본문을 파싱합니다.
Post 클래스
포스트 상세 정보를 다루기 위해 Post
클래스를 구성했습니다.
export interface Post {
id: string;
category: string;
data: FrontMatter;
content: string;
readingTime: string;
}
export class PostImpl implements Post {
id: string;
category: string;
data: FrontMatter;
content: string;
readingTime: string;
constructor(category: string, id: string) {
const { data, content } = this.getPost(category, id);
this.id = id;
this.category = utils.decodeURI(category);
this.data = data;
this.content = content;
this.readingTime = utils.calculateReadingTimeCeil(content);
}
private getPost(category: string, id: string) {
const fullPath = utils.getFullPath(`${MARKDOWN_PATH}/${category}/${id}.md`);
const fileContents = utils.getFile(fullPath);
const { data, content } = utils.getMatter(fileContents);
return {
data: {
...data,
createdAt: utils.dateFormatter(data.createdAt, 'YYYY-MM-DD')
},
content
};
}
}
Post
클래스는 주어진 category와 id로 특정 마크다운 파일을 읽고 필요한 정보로 변환하여 Next.js
에서 사용할 수 있는 구조로 제공합니다. 파일 경로는 src/shared/markdown/[category]/[id].md
로 되어 있으며, category는 포스트의 카테고리, id는 파일명을 나타냅니다. readingTime
은 본문을 읽는 예상 시간을 나타냅니다.
Posts와 Categories 클래스
이제 포스트 리스트를 위한 클래스들을 작성해봅시다.
포스트 리스트를 위한 Posts
클래스는 포스트 리스트의 필터링과 정렬을 담당하며, Categories
클래스는 카테고리 정보를 제공합니다.
export interface Category {
name: string;
fileCount: number;
}
export interface Categories {
getAll: () => Category[];
getNonEmptyCategories: () => Category[];
}
export class CategoriesImpl implements Categories {
private getAllCategories(): string[] {
return utils.getDirectory(utils.getFullPath(MARKDOWN_PATH));
}
private addFileCount(categories: string[]): Category[] {
return categories.map((category) => {
const categoryPath = utils.getFullPath(`${MARKDOWN_PATH}/${category}`);
return {
name: category,
fileCount: utils.getDirectory(categoryPath).length
};
});
}
private filterValidCategories(categories: string[]): string[] {
return categories.filter((category) => {
const categoryPath = utils.getFullPath(`${MARKDOWN_PATH}/${category}`);
return utils.isDirectory(categoryPath);
});
}
public getNonEmptyCategories(): Category[] {
const allCategories = this.getAllCategories();
const validCategories = this.filterValidCategories(allCategories);
return this.addFileCount(validCategories).filter((category) => category.fileCount > 0);
}
public getAll(): Category[] {
const categoriesWithFileCount = this.getNonEmptyCategories();
const totalFileCount = categoriesWithFileCount.reduce((sum, category) => sum + category.fileCount, 0);
return [{ name: '전체', fileCount: totalFileCount }, ...categoriesWithFileCount];
}
}
Categories
클래스는 shared/markdown/[category]
의 폴더명을 가져와 빈 폴더를 제외한 유효한 카테고리 리스트와 전체 카테고리 정보를 제공합니다.
export interface Posts {
posts: Post[];
}
export class PostsImpl implements Posts {
posts: Post[];
constructor(filter?: string) {
const filteredPosts = this.getFilteredPosts(filter);
const sortedFilteredPosts = this.sortDescByCreatedAt(filteredPosts);
this.posts = sortedFilteredPosts;
}
private sortDescByCreatedAt(posts: Post[]) {
return posts.sort((a, b) => new Date(b.data.createdAt).getTime() - new Date(a.data.createdAt).getTime());
}
private getFilteredPosts(filter?: string): Post[] {
const categories = new CategoriesImpl().getNonEmptyCategories();
return categories
.filter((category) => !filter || category.name === filter)
.reduce((allPosts, category) => {
const posts = this.getPostsByCategory(category.name);
return [...allPosts, ...posts];
}, [] as Post[]);
}
private getPostsByCategory(categoryName: string): Post[] {
const categoryPath = utils.getFullPath(`${MARKDOWN_PATH}/${categoryName}`);
const filesFromDirectory = utils.getDirectory(categoryPath);
return filesFromDirectory.map((file) => {
const idFromCategory = file.replace(/\.md?$/, '');
const { id, category, readingTime, data, content } = new PostImpl(categoryName, idFromCategory);
return {
id,
category,
readingTime,
data,
content
};
});
}
}
Posts
클래스는 다음과 같이 동작합니다.
- 카테고리 필터링:
Categories
클래스의getNonEmptyCategories
메서드를 통해 빈 폴더가 아닌 유효한 카테고리 목록만 가져옵니다. - 카테고리별 게시글 수집: 각 카테고리 폴더 내 마크다운 파일 목록을 유틸리티 함수로 불러와 파일명을 기반으로 고유한 id를 생성합니다.
- 게시글 객체화: 생성한 id를 이용해
Post
인스턴스를 생성하고, 각각의 게시글 정보를 배열 형태로 관리합니다.
이를 통해 Posts 클래스는 모든 카테고리의 게시글을 손쉽게 관리하며, 선택적으로 특정 카테고리의 게시글만 필터링할 수 있습니다.
이를 통해 Posts
클래스는 모든 카테고리의 게시글을 손쉽게 관리하며, 선택적으로 특정 카테고리의 게시글만 필터링할 수 있습니다.
마무리
이렇게 기본적인 마크다운 데이터를 각 페이지에서 불러올 준비를 마쳤습니다. 다음에는 위 클래스들을 이용해 페이지를 구현하는 방법에 대해 기록해보겠습니다!