Next.js 블로그 만들기 - (2) Next API, Sqlite

shorecrab·2022년 6월 15일
1

지난 번에는 마크다운을 보여주는 페이지를 빌드하고 보여줄 수 있도록 개발을 진행했다. 그런데 마크다운에 대한 메타데이터를 저장하는 DB가 없어서 파일 시스템을 직접 검사하고 마크다운 파일이 있을 경우 렌더링하도록 했다. 오늘은 이 부분을 개선하여 글을 작성할 것이다.

Sqlite

지금까지 프로젝트를 해오면서 다양한 DB를 사용해봤지만 SQLite는 처음 사용하게 되었다. SQLite는 경량화된 데이터베이스로, DB를 하나의 파일에서 관리한다는 특징이 있다. 블로그 특성상 나만 글을 작성할 것이고 데이터 저장량이 많지 않을 것 같아서 쉽고 빠르게 사용할 수 있는 SQLite를 선택하게 되었다.

SQLite를 Node에서 사용하기 위해서 기본적으로는 sqlite3 패키지를 설치해야 한다. 그런데 해당 패키지에는 문제가 있다. Promise를 지원하지 않고 콜백을 통해서 비동기 처리를 해야한다는 점이다. 그래서 sqlite 라는 패키지를 추가로 설치하였다. 이 패키지는 es6 Promise를 사용할 수 있도록 sqlite3패키지를 래핑한 함수를 제공한다. 이 덕분에 쉽게 개발을 할 수 있었다.
(안 그랬으면 콜백 지옥을 맛보거나 프로미스를 직접 모든 호출마다 생성했을 것이다...)

구성

아직 마크다운 에디터까지 추가하지는 않을 것이기 때문에, 마크다운 파일과 대표 이미지를 form으로 업로드해서 DB에 메타데이터를 저장하고 서버의 public 디렉토리 안에 파일을 저장할 것이다.

업로드 Form

우선은 프론트엔드에서 업로드 폼을 만들어서 전송해야 한다. 아래 코드를 보자. (중간중간 생략한 부분이 있다.)

// stories/components/post-form/index.tsx
const PostForm = () => {
  /* ... */
  const submitForm = async (e: React.MouseEvent<HTMLButtonElement>) => {
    try {
      const formData = new FormData();
      formData.append('img', post.img);
      formData.append('markdown', post.markdown);
      formData.append('title', post.title);
      formData.append('summary', post.title);
      
      await axios.post('/api/posts', formData, {
        headers: {
          'Content-Type': 'multipart/form-data',
        },
      });
    } catch (err) {
      console.error(err);
    }
  };
  
  return (
    <form>	
      {/* ... */}
      <div className="mb-3">
        <label htmlFor="img" className="form-label">
          Image URL
        </label>
        <input
          type="file"
          className="form-control"
          id="img"
          placeholder="image"
          onChange={e => handleAddFile(e, 'img')}
          />
      </div>
      <div className="mb-3">
        <label htmlFor="markdown" className="form-label">
          File URL
        </label>
        <input
          type="file"
          className="form-control"
          id="markdown"
          placeholder="file"
          onChange={e => handleAddFile(e, 'markdown')}
          />
      </div>
      <Button onClick={submitForm}>submit</Button>
    </form>
  )
}

버튼의 onClick() 이벤트에 submitForm()함수를 바인딩했다. submitForm() 함수는 FormData 객체를 만들어서 저장해놓은 폼의 데이터를 append() 함수로 추가해주고 있다. 그 후에 axios를 통해서 formdata/multipart-data 형식으로 POST 요청을 보낸다.

Next.js API

프론트엔드에서 전송한 폼 데이터를 Next.js API에서 받아서 처리해야한다. 폼 데이터를 처리할 때 여기서는 formidable을 사용했다.

많이들 사용하는 multerexpress 미들웨어로 사용할 때 편리한데 Next.js에서는 사용할 떄 어려움이 있었다. multer가 필요로 하는 req 객체와 Next.js에서 제공하는 req 객체의 타입이 달라서 이를 해결하기가 까다로웠다. 타입을 any로 넘겨줄 수 있었는데 좋은 방법은 아닌 것 같아서 사용하지 않기로 결정했다.

그래서 formidable을 사용해서 받아오기로 했다. 프로미스를 지원하지 않아서 직접 Promise 객체를 만들어서 사용했다. 이렇게 문제를 다 해결했다고 생각했는데, 파일이 받아지지 않는 문제가 있었다. 이와 관련해서 검색을 했더니 Next.js의 기본 bodyParser를 사용하지 않도록 하면 된다는 것을 알게 되었다.

파일을 받을 때 SQLite의 AutoIncrement id를 받아서 다음 id에 대한 디렉토리를 만들고 해당 위치에 저장하도록 했다. 그리고 저장한 path와 함께 포스트의 제목과 요약을 DB에 저장했다.

아래는 실제 폼 데이터를 받아서 처리하는 코드이다.

// pages/api/posts/index.ts
export const config = {
  api: {
    bodyParser: false,
  },
};

export default async function handler(
  req: NextApiRequest & { files: any[] },
  res: NextApiResponse<Data>
) {
/* ... */
  const id: [{ seq: number }] = await db.all(
    'SELECT * FROM SQLITE_SEQUENCE'
  );

  const basePath = 'public/posts/' + (id[0].seq + 1);
  fs.mkdirSync(basePath);
  const form = formidable({
    uploadDir: basePath,
    filter: function ({ name }) {
      return !!name && (name.includes('img') || name.includes('markdown'));
    },
    filename: function (name, ext, part, form) {
      return `${new Date().getTime()}-${part.originalFilename}`;
    },
  });

  //resolve parsing with Promise
  const [fields, files] = await new Promise((resolve, reject) => {
    form.parse(req, (err, fields, files) => {
      if (err) {
        reject(err);
      }
      resolve([fields, files]);
    });
  });

  const { title, summary } = fields;
  const imgUrl = basePath + '/' + files.img.newFilename;
  const fileUrl = basePath + '/' + files.markdown.newFilename;

  const result = await db.all(
    `INSERT INTO posts (title, imgUrl, summary, fileUrl) VALUES ('${title}', '${imgUrl}', '${summary}', '${fileUrl}')`
  );

  res.status(200).json(result);
  /* ... */
}

정적 페이지 생성

이전 포스트에서도 작성한 부분이지만 DB를 사용하면서 몇 가지가 바뀌었다. 우선, getStaticPathsgetStaticProps가 이제 DB를 참조한다. getStaticPaths에서는 모든 포스트에 대한 정보를 가져와서 각각에 대한 페이지를 만들 수 있도록 한다. 그리고 getStaticProps에서 실제 정적 페이지를 생성하는데, 여기서도 DB를 참조하여 파일 경로를 확인하고 해당 파일을 읽어서 렌더링할 수 있도록 한다.

export async function getStaticProps(context: GetStaticPropsContext) {
  try {
    const db = await open({
      filename: 'db/blog.db',
      driver: sqlite3.Database,
    });

    const file: Post[] = await db.all(
      'SELECT * FROM posts WHERE id=' + context.params?.id
    );
    const post = fs.readFileSync(file[0].fileUrl).toString();

    return {
      props: { post },
    };
  } catch (err) {
    console.error(err);
    return {
      props: {},
      notFound: true,
    };
  }
}

export async function getStaticPaths() {
  try {
    const db = await open({
      filename: 'db/blog.db',
      driver: sqlite3.Database,
    });

    const posts = await db.all('SELECT * FROM posts');
    const paths = posts.map(post => ({
      params: { id: '' + post.id },
    }));

    return { paths, fallback: 'blocking' };
  } catch (err) {
    console.error(err);
  }
}

결과

개발 모드로 실행해서 페이지 렌더링이 느리지만... 성공했다!

다음은 인증을 추가해서 글쓰기 기능을 제한하고, 삭제 및 수정 등의 기능을 추가할 수 있도록 수정할 것이다. 그 후에 Nginx를 통해서 배포를 진행하면 될 것 같다.

profile
주니어 프론트엔드 개발자!

0개의 댓글