notion api 이미지 문제 해결하기
이 글은 next
12버전을 기준으로 작성되었습니다.
문제 상황
얼마전 부터 노션 API를 이용해 블로그를 리팩토링하고 있습니다. 그러던 중, 노션의 이미지와 관련된 문제가 발생했습니다. 때때로 이미지들이 잘 보이지 않았고 또 가끔은 이미지들이 제대로 출력되었습니다.
이러한 이유를 찾아보니, 노션의 이미지들은 보안상의 이유로 각각의 이미지 url
에 expiry_time
이 있었습니다. 그런데 저는 ssg
의 이점을 이용해 블로그를 구성하고 있었고 사전렌더링이 된 시점에 src
에 들어간 url
의 주소가 더이상 유효하지 않았기 때문에 발생한 문제였습니다.
해결방안 생각하기
이러한 문제를 어떻게 해결할 수 있을지 해결방안을 모색해보니 두가지 방법이 떠올랐습니다.
-
getServerSideProps
를 이용하기getServerSideProps
는, 사전렌더링을 하지 않고 필요한 시점에 렌더링해 해당 페이지를 구현해줍니다.따라서 이를 이용한다면 해당 페이지의 현재시점에 만료되지 않은 이미지url
을 계속해서 불러올 수 있을 것입니다. -
s3
와 같은 클라우드 이용하기클라우드 서비스에 이미지를 업로드 하고, 해당 이미지를
notion
에서 이용하도록 수정합니다. 그렇다면notion
에서 이미지의 만료일자와 무관하게, 저의 클라우드에서 해당 이미지를 불러올 수 있으므로 문제없이 해당 부분을 해결할 수 있을 것입니다.(개별적으로 손수 넣어주는 방법도 존재합니다. 하지만 그렇게 진행하는 것은 힘드니까요 😅)
저는 위의 두가지 방법중에서 두번째 방법을 선택해 진행했습니다. 두번째 방법을 선택한 이유는 다음과 같았습니다.
- 대부분의 기능이 보여주기만 하는 블로그라는 특성상,
ssg
의 이점을 포기하기가 어려웠습니다. - 구현이 어려워 보였으나,
nextJs
의api
를 이용하면 충분히 할 수 있다고 판단했습니다. - 또
notion api
에서도page
의 모든block
을 가져올 수 있고 해당block
을update
할 수 있는api
를 제공하고 있었습니다.
생각보다 많이 어렵고 복잡했습니다..!!
시간이 부족하다면,getServerSideProps
를 이용하는 방법을 추천드립니다. 😂
해야할 일 정리하기
- 페이지의
blocks
들을 모두 불러와야 합니다. blocks
중image
이면서notion
에서 이미지를 제공하는block
들만 필터링해야합니다.- 필터링된
block
들의image url
을 통해 해당 이미지를 다운로드 받고base64
로 인코딩합니다. base64
로 인코딩된 문자열을, 클라우드에 업로드 합니다.- 클라우드에 업로드된 이미지
url
로blocks
을 대체해야 합니다.
이미지 blocks 불러오기
notion API
를 사용해 이미지의 blocks
들만 모아올 수 있습니다.
export const getPage = async (slug: string) => { const response = await notion.databases.query({ database_id: process.env.DATABASE_ID, filter: { property: "slug", formula: { string: { equals: slug, }, }, }, }); return response.results[0]; }; // getPage 함수를 통해, database ID에서 해당 slug를 찾습니다. // 저는 slug를 각 게시글의 프로퍼티로 등록해 사용해 가능했습니다. 이를 통해 // 페이지의 `id`를 찾습니다. export const getAllBlocksBySlug = async (slug: string) => { const response = await getPage(slug); const id = response.id; const blocks = await notion.blocks.children.list({ block_id: id, }); return blocks.results; }; // blocks들을 불러올 수 있는 notion API 쿼리를 사용합니다. // 위의 함수에서 찾아낸 id를 통해, 페이지에 있는 모든 blocks들을 찾을 수 있습니다.
위의 notion API
쿼리를 통해 각 페이지의 모든 blocks
들을 찾아올 수 있습니다. 이 blocks
들 type
에 image
가 존재하고, 또 image
에서 external
에서 불러온 이미지를 사용하는지 표기되어 있으므로 이 blocks
들을 이용해 노션에서 사용하고 있는 이미지들만들 필터링할 수 있습니다.
notion API
의 이미지는 다음과 같은 구조로 이루어져 있습니다.
- 노션에서 제공하는 이미지
type
이 file
이고, 만료일자가 있는 것이 notion
에서 제공하고 있는 이미지입니다.
- 외부에서 사용하는 이미지
type
이 external
이고, url
또한 external
에 존재합니다.
위 이미지를, 필요한 방식으로 필터링할 수 있습니다. 저는, 다음과 같이 필터를 진행했습니다.
// api/reviseImage export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { const blocks = await getAllBlocksBySlug(req.body); const candidates = blocks .filter((block) => "type" in block && block.type === "image") .filter((block) => "image" in block && block.image?.type === "file") .map((block) => { return { id: block.id, url: block.image.file.url, imgId: block.id.replace(/-/g, ""), // 이 부분은, 이미지를 다른 클라우드에 적용할 때 id가 필요했기 때문에 추가했습니다. }; }); ... // 이러한 방식으로 필터링 하면 이제 노션의 이미지만들 사용하는 `blocks` 들만 추출할 수 있습니다.
base64 encode
이제 우리는 모든 image
들의 block
을 모았고, 이 block
들은 노션에서 사용하고 있는 image url
을 가지고 있습니다. 이 image url
을 base64
로 encode 해야 합니다. encode
된 코드라인을 통해 다른 클라우드에 이미지로 변환해 다시 올릴 수 있기 때문입니다.
import https from "https"; export const downloadImageToBase64 = (url: string) => { return new Promise((resolve, reject) => { const req = https.request(url, (response) => { const chunks: any[] = []; response.on("data", function (chunk) { chunks.push(chunk); }); response.on("end", function () { const result = Buffer.concat(chunks); resolve(result.toString("base64")); }); }); req.on("error", reject); req.end(); }); }; // 이 부분은 stack overflow를 보고 진행했습니다.
사실 node
관련을 사용해본 경험이 거의 없어, encode
등으로 검색하고 레퍼런스를 통해 위와같은 함수를 작성할 수 있었습니다.
그런데, gif
와 같은 경우에는 위의 함수로 encode
를 할 수 없습니다. (저는 gif
를 많이 사용해왔기 때문입니다.) 그래서 gif
를 encode
할 수 있는 함수를 하나 더 작성했습니다.
const gifFrames = require("gif-frames"); const downloadGifToBase64 = async (url: string) => { const response = await gifFrames({ url: url, frames: "0", }); const image = await response[0].getImage(); const base64 = await image.read().toString("base64"); return base64; };
여기서 주의할 점은 진행하는 방식으로 gif -> base64 -> gif
로 변환되지 않습니다. gif
더라도 한장의 jpeg
파일로 변환됩니다.
클라우드에 업로드하기
저는, cloudinary
를 이용했습니다. 이미지의 사용량이 넉넉했고 접근성 측면에서 좋았다고 생각합니다. 물론, 다른 클라우드를 사용하시는 방법도 물론 존재합니다.
import { v2 as cloudinary } from "cloudinary"; const NAME = process.env.CLOUDINARY_NAME; const KEY = process.env.CLOUDINARY_KEY; const SECRET = process.env.CLOUDINARY_SECRET; const FOLDER_NAME = process.env.CLOUDINARY_UPLOAD_FOLDER; cloudinary.config({ cloud_name: NAME, api_key: KEY, api_secret: SECRET, }); export const uploadImage = async (encoded: string, options: any) => { let url; try { const response = await cloudinary.uploader.upload(encoded, options); url = response.url; } catch (error) { console.error(error); url = ""; } finally { return url; } }; // 위와 같은 함수를 만들어 진행했습니다. // 이 함수는 이미지가 등록된 클라우드의 `url` 을 최종적으로 반환합니다.
위의 함수를 적용할 때에는 다음과 같이 사용했습니다.
const { id, url: passedUrl, imgId } = candidate; // image block중 하나입니다. const lastIndex = passedUrl.indexOf("?"); const isGif = passedUrl.slice(lastIndex - 4, lastIndex).includes("gif"); // notion 이미지 url의 경우, `?` 쿼리 등장지점이 존재했고 이를 통해 `gif`인지 확인했습니다. const prefix = `data:image/jpeg;base64,`; const base64 = isGif ? await downloadGifToBase64(candidate.url) : await downloadImageToBase64(candidate.url); // 위에 만든 함수를 통해 const url = await uploadImage(prefix + base64, { public_id: imgId, folder: `blog/${id}`, });
이런 방식으로 이미지를 업로드할 수 있었습니다.
위의 과정을 통해 클라우드 이와 같이 이미지가 잘 업로드 외었음을 확인할 수 있습니다.
notion 이미지 교체하기
위의 블락들과, 클라우드 url
을 사용해 진행합니다. notion API
에서 제공하고 있는, blocks
을 수정할 수 있는 함수를 이용합니다.
export const updateBlockImage = async (id: string, url?: string) => { if (!url) return; await notion.blocks.update({ block_id: id, // ---------------- 수정되는 이미지 객체 image: { external: { url, }, // ----------------- }, }); }; // block의 id에, image 객체로 수정합니다.
이러한 방식을 통해 노션 이미지의 url
을 수정할 수 있었습니다.
최종 코드
최종 코드는 다음과 같습니다.
// api/reviseImage import type { NextApiRequest, NextApiResponse } from "next"; import { getAllBlocksBySlug, getPage, updateBlockImage, updateCoverImage, } from "lib/notion"; import { uploadImage } from "lib/cloudinary"; import { downloadImageToBase64, downloadGifToBase64 } from "lib/utils"; type Block = { type: string; image: { type: string; file: { url: string; }; }; id: string; }; const IMAGE_PASSWORD = process.env.IMAGE_PASSWORD; const UPLOAD_IMAGE_PREFIX = `data:image/jpeg;base64,`; const getIsGif = (url: string) => { const beforeQuery = url.indexOf("?"); return url.slice(beforeQuery - 4, beforeQuery).includes("gif"); }; export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { const inputPassword = req.headers["x-images-passcode"]; const { slug } = req.body; if (!inputPassword || inputPassword !== IMAGE_PASSWORD) { return res.status(400).json({ message: "유효한 비밀번호가 아닙니다.", }); } const page = await getPage(slug); if (!page) return res.status(400).json({ error: "유효한 경로가 아닙니다." }); // ----- cover 이미지 수정 const cover = page?.cover.type === "file" ? { id: page.id, coverUrl: page.cover.file.url, imgId: page.id.replace(/-/g, ""), } : null; const blocks = (await getAllBlocksBySlug(slug)) as Block[]; if (cover) { const { id, coverUrl, imgId } = cover; const isGif = getIsGif(coverUrl); const base64 = isGif ? await downloadGifToBase64(coverUrl) : await downloadImageToBase64(coverUrl); const url = await uploadImage(UPLOAD_IMAGE_PREFIX + base64, { public_id: imgId, folder: `blog/${id}`, }); await updateCoverImage(id, url); } // ----- blocks 이미지들 수정 const candidates = blocks .filter((block) => "type" in block && block.type === "image") .filter((block) => "image" in block && block.image.type === "file") .map((block) => { return { id: block.id, url: block.image.file.url, imgId: block.id.replace(/-/g, ""), }; }); if (!candidates.length) return res.status(200).json({ message: "수정할 이미지가 없습니다.", }); for (const candidate of candidates) { const { id, url: candidateUrl, imgId } = candidate; const isGif = getIsGif(candidateUrl); const base64 = isGif ? await downloadGifToBase64(candidateUrl) : await downloadImageToBase64(candidateUrl); const url = await uploadImage(UPLOAD_IMAGE_PREFIX + base64, { public_id: imgId, folder: `blog/${id}`, }); await updateBlockImage(id, url); } return res.status(200).json({ message: `${slug}의 이미지를 수정했습니다.`, }); }
API 사용하기
그런데, 위의 api
를 어디서 사용할 수 있을까요? getStaticProps
에서는 fetch
요청을 하지 말라는 내용이 공식문서에 적혀있기도 했고, getStaticProps
에서 위의 변환과정을 거치는 것은 비효율적이라 생각했습니다. 사실 이미지의 경로는 한번만 변환하면 되기 때문입니다.
다만, 이 부분을 접근하는 방법은 까다로웠습니다. slug
를 기반으로 해당 부분을 작성했기 때문입니다. 결과적으로는 다음과 같이 진행했습니다.
오터로그 이미지 수정 페이지를 따로 작성해 비밀번호와 slug
를 입력해 제출하면 해당 slug
의 이미지를 클라우드에 올리고 notion
페이지의 이미지 경로를 수정합니다.
이 부분을 진행하고 보니, on-demanded revalidate
로 전환하는 것이 좋겠다는 생각이 들었습니다. 어차피, 매번 페이지에 들어가 이미지를 새롭게 설정해주어야 했기 때문입니다. 해당 내용은 추후에 게시할 예정입니다!