이전 글에서는 Gatsby 기본 기능을 구현을 위한 튜토리얼을 소개했고 태그 기능을 구현했으며, MDX 글 안에 인라인 이미지와 테이블을 삽입했다.

오늘은 시리즈 기능을 구현해보자.

1. 개요

시리즈 기능은 여러 글들을 하나로 묶어주는 기능이다. 카테고리와 유사한 기능이지만, 같은 종류의 글을 묶는 카테고리와 달리 이어지는 글들을 하나로 묶어주는 기능이라고 보면 된다. 이전에 사용하던 블로그 서비스인 velog 에서 해당 기능을 확인할 수 있다.

Gatsby 의 GraphQL 과 mdx, gatsby-node 기능을 사용해 시리즈를 구현해보겠다.

2. 구현

구현을 위해 해야 할 일은 아래와 같다.

  1. mdx 파일에 시리즈 정보 넣기
  2. 시리즈 목록 페이지 구현
  3. 시리즈 상세 페이지 구현
  4. 글 상세 페이지에 해당 글이 속한 시리즈의 글 목록 추가

2.1. mdx frontmatter 에 시리즈 필드 추가

mdx 파일에 시리즈를 위한 필드를 추가하자. 이름은 series 로 하겠다. 이제 같은 시리즈로 포함시킬 글들은 series 필드에 같은 이름을 넣어주면 된다.

---
title: # ...
date: # ...
slug: # ...
tags: # ...
series: "Gatsby 블로그 만들기"
---

2.2. 시리즈 목록 페이지 구현

시리즈 목록을 위한 페이지를 src/pages/series/index.tsx 에 만들어보자.

2.2.1. 기본 페이지 추가

기본 구현은 태그 목록을 위한 페이지 구현 방법과 같다.

// src/pages/series/index.tsx

import * as React from "react";
import { graphql, Link, PageProps } from "gatsby";
import Layout from "../../components/Layout";
import Seo from "../../components/Seo";

type SeriesPageData = {
  allMdx: {
    group: {
      fieldValue: string;
      totalCount: number;
    }[];
  };
};
const SeriesPage = ({ data }: PageProps<SeriesPageData>) => {
  const series = data.allMdx.group;
  return (
    <Layout>
      <h1>시리즈</h1>
      <ul>
        {series.map((sr) => (
          <li key={sr.fieldValue}>
            <Link to={`/series/${sr.fieldValue}/`}>{`${sr.fieldValue}`}</Link>{" "}
            <small>{sr.totalCount}</small>
          </li>
        ))}
      </ul>
    </Layout>
  );
};

export const query = graphql`
  query {
    allMdx(limit: 2000) {
      group(field: { frontmatter: { series: SELECT } }) {
        fieldValue
        totalCount
      }
    }
  }
`;

export const Head = () => <Seo title="시리즈 목록" />;

export default SeriesPage;

이렇게 구현하면 시리즈 목록 페이지가 완성된다.

하지만 완성된 모습이 다소 허전하다. velog 의 시리즈 목록 페이지를 보면 해당 시리즈의 썸네일과 마지막 작성글 날짜까지 보인다. 하지만 우리가 만든 페이지에는 시리즈들의 이름과 글 수만 덩그러니 있을 뿐이다.

우리도 썸네일과 마지막 작성글 날짜를 추가해보자.

2.2.2. GraphQL 쿼리 수정

쿼리에 edges 를 추가하자. 이렇게 하면 시리즈별로 시리즈에 속한 글들을 (모두) 가져올 수 있다.

// src/pages/series/index.tsx

export const query = graphql`
  query {
    allMdx(limit: 2000) {
      group(field: { frontmatter: { series: SELECT } }) {
        fieldValue
        totalCount
        edges {
          node {
            frontmatter {
              title
              originalDate: date
              date(formatString: "YYYY. M. D.")
              heroImageAlt
              heroImage {
                childImageSharp {
                  gatsbyImageData
                }
              }
            }
          }
        }
      }
    }
  }
`;

2.2.3. 데이터 정렬 및 시리즈별 마지막 글 선택

아쉽게도 GraphQL 쿼리로 해당 시리즈에 속한 글 중 최신글만 골라서 가져올 수는 없다. (정말 불가능한 건지는, GraphQL 을 잘 몰라서 확실치 않다.)

또한 GraphQL 의 group 쿼리에는 정렬을 적용할 수 없다. (이것 또한, GraphQL 의 한계인지 Gatsby 의 한계인지는 잘 모르겠다.)

따라서 가져온 데이터를 코드로 직접 정렬하고, 시리즈별로 마지막 글만을 골라내야 한다.

// src/pages/series/index.tsx

const SeriesPage = ({ data }: PageProps<SeriesPageData>) => {
  const series = data.allMdx.group
    .map(({ edges, ...sr }) => ({
      ...sr,
      // 시리즈별로 가장 최신 글만 골라서 `node` 필드에 넣자
      node: edges.sort((a, b) =>
        b.node.frontmatter.originalDate.localeCompare(
          a.node.frontmatter.originalDate
        )
      )[0].node,
    }))
    // 최신 글의 작성일 기준으로 시리즈를 내림차순 정렬하자.
    .sort((a, b) =>
      b.node.frontmatter.originalDate.localeCompare(
        a.node.frontmatter.originalDate
      )
    );
  // ...

2.2.4. 뷰 업데이트

데이터를 가져왔으니 뷰를 업데이트 해보자.

// src/pages/series/index.tsx
import SeriesItem from "../../components/SeriesItem";

// ...

const SeriesPage = ({ data }: PageProps<SeriesPageData>) => {
  // ...

  return (
    <Layout>
      <h1>시리즈</h1>
      <SeriesList>
        {series.map((sr) => (
          // 코드가 길어져서 별도의 컴포넌트로 분리
          <SeriesItem key={sr.fieldValue} {...sr} />
        ))}
      </SeriesList>
    </Layout>
  );
};
// src/components/SeriesItem.tsx
import { GatsbyImage, getImage } from "gatsby-plugin-image";
import { navigate } from "gatsby";

// ...

function SeriesItem({ fieldValue, totalCount, node }: SeriesItemProps) {
  const { date, heroImage, heroImageAlt } = node.frontmatter;

  const image = heroImage ? getImage(heroImage) : null;

  return (
    // 링크를 연결하고
    <Container onClick={() => navigate(`/series/${fieldValue}`)}>
      <ThumbnailWrapper>
        {/* 썸네일이 있으면 썸네일을 그리고 */}
        {!!image && <ThumbnailImage image={image} alt={heroImageAlt ?? ""} />}
        {/* 시리즈에 포함된 글 수를 표시하고 */}
        <small>{`${totalCount}`}</small>
      </ThumbnailWrapper>
      <Info>
        {/* 시리즈 이름을 보여주고 */}
        <h3>{fieldValue}</h3>
        {/* 마지막 글의 작성일을 표시한다 */}
        <small>{`~ ${date}`}</small>
      </Info>
    </Container>
  );
}

// ...

const ThumbnailImage = styled(GatsbyImage)`
  width: 100%;
  height: 100%;
  object-fit: cover;
`;

// ...

그럼 이제 시리즈 목록 페이지는 완성이다. 아래처럼 보일 것이다.

2.2.5. 상세 코드

위 코드 조각들은 생략된 부분이 있으므로, 코드 전문을 보고 싶다면 아래를 참고하자.

2.3. 시리즈 상세 페이지 구현

시리즈 상세 페이지도 구현해보자.

(이건 이전에 작성했던 Gatsby 블로그 만들기 1 - 기본 구현 및 태그 기능 구현 글에서 태그의 상세 페이지를 만드는 방법과 동일하다.)

빌드 타임에 현재 존재하는 모든 시리즈 이름을 GraphQL 로 가져와서, 시리즈 별로 상세 페이지를 만들어 줄 것이다. 상세 페이지에는 해당 시리즈에 속한 글들이 작성일자 순으로 정렬되어 보일 것이다.

이것을 위해 페이지를 만들 때 쓰일 템플릿을 만들고, 빌드 타임에 실행될 스크립트를 작성하자.

2.3.1. 탬플릿 작성

// src/templates/SeriesDetailPageTemplates.ts
import * as React from "react";
import { graphql, Link, PageProps } from "gatsby";
import Layout from "../components/Layout";

type SeriesDetailPageTemplateData = {
  // ...
};
type SeriesDetailPageTemplateContext = {
  series: string;
};
const SeriesDetailPageTemplate = ({
  // `pageContext` 에는 gatsby-node.ts 의 createPages 에서 넘겨주는 값들이 들어있다.
  pageContext,
  // `data` 에는 아래 쪽에 있는 pageQuery 쿼리의 결과값이 들어있다.
  data,
}: PageProps<
  SeriesDetailPageTemplateData,
  SeriesDetailPageTemplateContext
>) => {
  const { series } = pageContext;
  const { totalCount, edges } = data.allMdx;

  return (
    <Layout>
      <h1>{`시리즈 "${series}"`}</h1>
      <ol>
        {edges.map(({ node: { frontmatter } }) => (
          <li key={frontmatter.slug}>
            <Link to={`/posts/${frontmatter.slug}`}>{frontmatter.title}</Link>
          </li>
        ))}
      </ol>
    </Layout>
  );
};

// $series 는 gatsby-node.ts 의 createPages 로부터 넘겨받는다.
export const pageQuery = graphql`
  query ($series: String) {
    allMdx(
      limit: 2000
      sort: { frontmatter: { date: ASC } }
      filter: { frontmatter: { series: { in: [$series] } } }
    ) {
      totalCount
      edges {
        node {
          frontmatter {
            title
            slug
          }
        }
      }
    }
  }
`;

export default SeriesDetailPageTemplate;

2.3.2. gatsby-node.tscreatePages 수정

gatsby-node.tscreatePages 함수에서는 빌드 타임에 페이지를 생성할 수 있다. 만약 기존에 해당 함수를 구현한 적이 없었다면, 아래 코드를 gatsby-node.ts 에 그대로 추가해주면 된다. 만약 해당 함수가 이미 있다면, 아래 내용의 함수 안 코드를 기존의 createPages 안에 적당히 끼워넣어주자.

// gatsby-node.ts
export const createPages: GatsbyNode["createPages"] = async ({
  actions,
  graphql,
  reporter,
}) => {
  // 존재하는 모든 시리즈를 가져온다.
  const result = await graphql<TagGroupsQueryData>(`
    {
      seriesGroup: allMdx(limit: 2000) {
        group(field: { frontmatter: { series: SELECT } }) {
          fieldValue
        }
      }
    }
  `);

  if (result.errors || !result.data) {
    reporter.panicOnBuild(`Error while running GraphQL query.`);
    return;
  }

  // 상세 페이지 생성에 쓰일 템플릿 컴포넌트의 경로를 가져온다.
  const seriesTemplatePath = path.resolve(
    "src/templates/SeriesDetailPageTemplate.tsx"
  );

  const { seriesGroup } = result.data;

  // 시리즈 별로 페이지를 생성한다.
  // `context` 를 통해 시리즈 이름을 템플릿에 넘겨주자.
  seriesGroup.group.forEach((series) => {
    actions.createPage({
      path: `/series/${series.fieldValue}/`,
      component: seriesTemplatePath,
      context: { series: series.fieldValue },
    });
  });
};

이제 개발 서버를 껐다 켜면, 시리즈 상세 페이지가 잘 생성된 것을 확인할 수 있다.

취향에 따라 스타일을 바꾸고 글의 썸네일이나 작성일 등을 넣어주자. (나중에 진행할 예정)

2.3.3. 상세 코드

위 코드 조각들의 원문을 보고 싶다면 아래를 참고하자.

2.4. 글 상세 페이지에 해당 글이 속한 시리즈의 글 목록 추가

글이 특정 시리즈에 속해있다면, 해당 글의 상세 페이지 상단에 시리즈의 글 목록을 넣어주자.

상세 페이지(src/pages/posts/{mdx.frontmatter__slug}.tsx)에서 해당 글이 속한 시리즈의 글 목록을 얻기 위해, 일단 gatsby-node.ts 를 또 수정할 것이다.

2.4.1. gatsby-node.tscreateResolvers 수정

createResolvers 에서는 GraphQL 쿼리에서 사용할 수 있는 필드를 추가할 수 있다. 우리는 sameSeriesPosts 필드를 추가할 것이다.

// gatsby-node.ts

export const createResolvers: GatsbyNode["createResolvers"] = ({
  createResolvers,
}) => {
  createResolvers({
    Mdx: {
      sameSeriesPosts: {
        type: ["Mdx"],
        resolve: async (source, args, context, info) => {
          if (!source.frontmatter.series) {
            return;
          }

          const { entries } = await context.nodeModel.findAll({
            query: {
              filter: {
                frontmatter: {
                  series: {
                    eq: source.frontmatter.series,
                  },
                },
              },
            },
            type: "Mdx",
          });

          //  공식 문서에 의하면 createResolvers 내에서
          // GraphQL 기능으로 정렬하는 건 불가능하다고 한다.
          // 스크립트 코드로 직접 정렬해주자
          return [...entries].sort((a, b) =>
            a.frontmatter.date.localeCompare(b.frontmatter.date)
          );
        },
      },
    },
  });
};

이제 GraphQL 쿼리로 시리즈의 글 목록을 가져올 수 있다.

2.4.2. 글 상세 페이지에 시리즈의 글 목록 추가

// src/pages/posts/{mdx.frontmatter__slug}.tsx
import SameSeriesPosts from "../../components/SameSeriesPosts";

// ...

const PostDetailPage = ({ data, children }: PageProps<PostDetailPageData>) => {
  const {
    frontmatter,
    //  아까 `createResolvers` 에서 추가한 필드인
    // `sameSeriesPosts` 를, GraphQL 쿼리로 가져올 수 있다.
    sameSeriesPosts,
  } = data.mdx;
  const { title, slug, date, tags, series, heroImage, heroImageAlt } =
    frontmatter;
  const image = heroImage ? getImage(heroImage) : null;

  return (
    <Layout>
      <Header>
        <h1>{title}</h1>
        <WrittenDate>{date}</WrittenDate>
        <Tags>
          {tags.map((tag) => (
            <li key={tag}>
              <Link to={`/tags/${tag}`}>{tag}</Link>
            </li>
          ))}
        </Tags>
      </Header>

      {/* 뷰 상세 코드는 생략 */}
      <SameSeriesPosts name={series} data={sameSeriesPosts} current={slug} />

      {!!image && <ThumbnailImage image={image} alt={heroImageAlt ?? ""} />}
      <MdxContent>{children}</MdxContent>
      <Comments />
    </Layout>
  );
};

// ...

export const query = graphql`
  query ($id: String) {
    mdx(id: { eq: $id }) {
      # ... 다른 필드는 생략 ...
      # 이렇게 가져오면 된다.
      sameSeriesPosts {
        frontmatter {
          title
          slug
        }
      }
    }
  }
`;

이렇게 하면 아래처럼 글 상세 페이지에 시리즈의 글 목록을 넣을 수 있다.

2.4.3. 상세 코드

위 코드 조각들은 생략된 부분이 있으므로, 코드 전문을 보고 싶다면 아래를 참고하자.

3. 다음

이로서 시리즈 기능을 구현해보았다. 다음으로 구현 및 정리할 것들은 아래와 같다.

  • 댓글 기능 구현
  • 코드블록 문법 하이라이팅 기능 구현
  • 글의 목차 기능 구현
  • 임시글 기능 구현
  • GitHub Pages 배포
  • 등등

순서는 미정이다.

4. 참고