지난 번에는 마크다운을 보여주는 페이지를 빌드하고 보여줄 수 있도록 개발을 진행했다. 그런데 마크다운에 대한 메타데이터를 저장하는 DB가 없어서 파일 시스템을 직접 검사하고 마크다운 파일이 있을 경우 렌더링하도록 했다. 오늘은 이 부분을 개선하여 글을 작성할 것이다.
지금까지 프로젝트를 해오면서 다양한 DB를 사용해봤지만 SQLite는 처음 사용하게 되었다. SQLite는 경량화된 데이터베이스로, DB를 하나의 파일에서 관리한다는 특징이 있다. 블로그 특성상 나만 글을 작성할 것이고 데이터 저장량이 많지 않을 것 같아서 쉽고 빠르게 사용할 수 있는 SQLite를 선택하게 되었다.
SQLite를 Node에서 사용하기 위해서 기본적으로는 sqlite3
패키지를 설치해야 한다. 그런데 해당 패키지에는 문제가 있다. Promise
를 지원하지 않고 콜백을 통해서 비동기 처리를 해야한다는 점이다. 그래서 sqlite
라는 패키지를 추가로 설치하였다. 이 패키지는 es6 Promise
를 사용할 수 있도록 sqlite3
패키지를 래핑한 함수를 제공한다. 이 덕분에 쉽게 개발을 할 수 있었다.
(안 그랬으면 콜백 지옥을 맛보거나 프로미스를 직접 모든 호출마다 생성했을 것이다...)
아직 마크다운 에디터까지 추가하지는 않을 것이기 때문에, 마크다운 파일과 대표 이미지를 form으로 업로드해서 DB에 메타데이터를 저장하고 서버의 public
디렉토리 안에 파일을 저장할 것이다.
우선은 프론트엔드에서 업로드 폼을 만들어서 전송해야 한다. 아래 코드를 보자. (중간중간 생략한 부분이 있다.)
// 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에서 받아서 처리해야한다. 폼 데이터를 처리할 때 여기서는 formidable
을 사용했다.
많이들 사용하는 multer
는 express
미들웨어로 사용할 때 편리한데 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를 사용하면서 몇 가지가 바뀌었다. 우선, getStaticPaths
와 getStaticProps
가 이제 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를 통해서 배포를 진행하면 될 것 같다.