jay.log

posts.

portfolio.

기록

Next.js 15 App Router 환경에서 블로그 만들기 (1)

작성한 포스트의 마크다운 파일 관리하는 방법에 대해 알아봅시다.

9분

2024-11-13

사용한 기술

  1. Next.js
    • 가장 최신버전인 15 버전을 사용하였습니다.
    • App Router 환경에서 페이지를 구현하였습니다.
  2. gray-matter
    • 마크다운 파일의 프론트매터내용을 파싱해주는 라이브러리입니다.
  3. next-mdx-remote
    • 마크다운html변환해주는 라이브러리입니다.
  4. Vercel
    • 간단하게 github 레포지토리를 이용해 배포를 할 수 있는 플랫폼입니다.
    • 파일을 불러오는 fs 모듈을 사용하면서 에러가 발생했었는데, 공식문서에 15 버전 기준으로 작성되있지 않아 조금 헤멨습니다.

FSD 아키텍처

FSD 아키텍처는 구조가 명확하고 유지보수가 용이해, 특히 프론트엔드 생태계에 적합하다고 느꼈습니다. Next.jsApp Router 환경에서는 서버 컴포넌트클라이언트 컴포넌트를 분리해서 작성해야 하므로, FSD 아키텍처의 장점이 더욱 빛난다고 생각합니다. 이번 프로젝트에도 이 아키텍처를 선택했습니다.

동적 라우팅

우선 블로그를 마크다운 파일로 작성을 하게 될텐데 이 파일들을 URL path를 통해 구분하고 라우팅하도록 폴더를 만들어주어야 합니다.

저의 경우에는 포스트 리스트 페이지는 /posts, 포스트 상세 페이지는 /posts/[category]/[id]URL path로 라우팅 할 예정이기 때문에 app 폴더에 아래와 같이 폴더들을 구성하였습니다.

  • app
    • posts
    • page.tsx
      • [category]
        • [id]
        • page.tsx

마크다운 파일 관리

이제 각 주소에 접근 했을때 필요한 정보들을 가져와야합니다. 저는 프로젝트 규모가 그렇게 크지는 않아 util에서 모든 라이브러리들을 모아서 관리하려고 합니다.

shared/utils.ts
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 클래스를 구성했습니다.

entities/Post.ts
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 클래스는 주어진 categoryid로 특정 마크다운 파일을 읽고 필요한 정보로 변환하여 Next.js에서 사용할 수 있는 구조로 제공합니다. 파일 경로는 src/shared/markdown/[category]/[id].md로 되어 있으며, category는 포스트의 카테고리, id는 파일명을 나타냅니다. readingTime은 본문을 읽는 예상 시간을 나타냅니다.

Posts와 Categories 클래스

이제 포스트 리스트를 위한 클래스들을 작성해봅시다.

포스트 리스트를 위한 Posts 클래스는 포스트 리스트의 필터링과 정렬을 담당하며, Categories 클래스는 카테고리 정보를 제공합니다.

entities/Categories.ts
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]의 폴더명을 가져와 빈 폴더를 제외한 유효한 카테고리 리스트와 전체 카테고리 정보를 제공합니다.

entities/Posts.ts
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 클래스는 다음과 같이 동작합니다.

  1. 카테고리 필터링: Categories 클래스의 getNonEmptyCategories 메서드를 통해 빈 폴더가 아닌 유효한 카테고리 목록만 가져옵니다.
  2. 카테고리별 게시글 수집: 각 카테고리 폴더 내 마크다운 파일 목록을 유틸리티 함수로 불러와 파일명을 기반으로 고유한 id를 생성합니다.
  3. 게시글 객체화: 생성한 id를 이용해 Post 인스턴스를 생성하고, 각각의 게시글 정보를 배열 형태로 관리합니다.
    이를 통해 Posts 클래스는 모든 카테고리의 게시글을 손쉽게 관리하며, 선택적으로 특정 카테고리의 게시글만 필터링할 수 있습니다.

이를 통해 Posts 클래스는 모든 카테고리의 게시글을 손쉽게 관리하며, 선택적으로 특정 카테고리의 게시글필터링할 수 있습니다.

마무리

이렇게 기본적인 마크다운 데이터를 각 페이지에서 불러올 준비를 마쳤습니다. 다음에는 위 클래스들을 이용해 페이지를 구현하는 방법에 대해 기록해보겠습니다!

안녕하세요, 프론트엔드 개발자 이진웅입니다!

확장성이 뛰어나고 유지보수가 용이한 개발 방법론에 큰 관심을 가지고 있습니다.

© jay.log powered by Next.js, Vercel