드디어 다시 Card 컴포넌트에 들어갈 글 썸네일을 만들어 주는 작업으로 돌아왔다. 다른 페이지들을 대강이나마 꾸며주는 데에 글 하나가 쓰였다. 특히 TOC를 만들어 주는 데에 많은 시간이 걸렸다.
1. 설계
이 글에서 하기로 생각하는 건 하나뿐이다. 글의 썸네일을 만드는 것이다.
글 목록에서 보이는 Card 컴포넌트는 현재 글의 프리뷰 같은 느낌으로 작동하므로 여기에 글의 썸네일을 넣을 수도 있을 것이고, 또한 open graph 이미지에도 넣을 수 있을 것이다. 이는 글의 이해에도 도움을 주고 링크 미리보기도 생성해 주며 글 목록 페이지의 카드 내의 텍스트 한 줄의 너비도 줄여서 사용자의 집중도를 높일 수 있을 것이다.
이때 썸네일을 넣으면 글 목록의 카드 내의 텍스트 한 줄 너비가 줄어든다고 한 이유는 다음과 같은 레이아웃을 생각하고 있기 때문이다.
하지만 이를 위해 해야 하는 일이 많다. 과연 썸네일에는 어떤 이미지가 들어가야 할까?
만약 글 내에 쓰인 이미지가 있다면, 그게 썸네일이 되는 게 마땅하다고 생각한다. 물론 그게 꼭 글의 내용을 간략하게 정리하는 이미지일 거라고 생각하지는 않지만 일단 그렇게 해보자. 나중에 이상하면 바꾸면 되니까.
더 큰 문제는 글에 쓰인 이미지가 없을 때이다. 이때는 무엇이 썸네일이 되어야 할까? 글의 제목 혹은 글의 요약이라고도 할 수 있는 TOC(Table Of Content)의 일부가 되어야 하지 않을까 한다. 이런 썸네일을 동적으로 만들어보자.
일단 이전에 Card컴포넌트에서 이미지를 넣을 수 있도록 타입을 정해 놓긴 했다. 따라서 이미지를 가져올 방법을 생각해 보자. src/pages/posts/[category]/index.tsx에서 Card 컴포넌트에 어떻게든 썸네일 이미지를 넘겨주기만 하면 된다.
2. 구상
먼저 생각을 해보았다. md 파일 내용은 html 형식을 한 문자열로 변환되어 저장되어 있으니 여기서 정규식으로 img 태그의 src를 파싱해서 그걸 썸네일로 만들 수 있겠다. 실제로 이 방법으로 시도하였고 어느 정도 성공을 거두었다.
그런데 mdx 파일은? mdx 파일은 코드로 변환되기 때문에 쓰인 이미지를 찾기가 난감했다. 라이브러리 같은 게 있기는 했지만 그렇게 내키지 않았다.
그런데 우리는 이미 md파일이건 mdx파일이건 내부의 요소 계층 구조를 다룰 수 있는 방법을 알고 있다. 바로 remark 플러그인에서 하는 것이다.
따라서 다음과 같은 방식을 생각했다.
remark 플러그인에서 AST상의 이미지 요소를 찾고, src를 파싱한다.
이미지가 없을 경우 제목과 heading 요소를 이용해서 어떻게든 썸네일을 만든다.
3. 글에 이미지가 있을 경우
일단 플러그인을 만들자. src/plugins에 make-thumbnail.mjs를 만들자.
마크다운으로 만들어진 AST 중 이미지 태그를 순회하면서 이미지 URL들을 모두 파싱한 후 그 첫번째 URL을 thumbnail로 넘겨주는 것이다. 만약 글에 이미지가 없다면 그대로 넘어간다.
이제 이 플러그인을 contentlayer.config.js에서 remark plugin으로 추가해 준다.
이렇게 하면 JSON으로 변환된 파일에 thumbnail 항목이 추가된 것을 확인할 수 있다. 단 이미지가 들어가 있는 글에 한해서 해당 이미지의 경로가 들어가 있다. 이미지가 있는 글의 경우 make-thumbnail.mjs에서 썸네일을 만들어 주면 되겠다.
4. 글에 이미지가 없을 경우
이 경우 이미지를 동적으로 만들어줘야 한다. 이런 용도로 최근에 vercel에서 나온 라이브러리 @vercel/og가 있다. open graph만을 위해 나온 거긴 하지만 이를 한번 써보자.
공식 문서를 참고해서 해보았다. 먼저 src/pages/api/thumbnail.tsx를 만들고 다음과 같이 작성한다.
이러면 블로그주소/api/thumnail에 쿼리스트링으로 title을 전달한 URL로 접속하면 title이 크게 쓰인 사진 같은 게 보이게 된다. 이를 썸네일 소스로 사용하면 되겠다.
하지만 이게 내가 블로그 배포에 쓸 Cloudflare Pages에서도 잘 될까? 해보니 역시나. 엄청난 고난들이 있었다.
5. Cloudflare 배포
나는 블로그를 Cloudflare에서 배포할 예정이다. 하지만 @vercel/og는 vercel에서 만든 라이브러리인데, vercel에서 배포할 때만 돌아가는 게 아닐까? 그래서 Cloudflare에서 배포하고 실험해 보기로 했다.
당연히 NextJS는 Vercel에서 만든 것이므로 그쪽에 가장 최적화가 잘 되어 있고, 따라서 Cloudflare에서 배포할 때는 @vercel/og 말고도 문제가 좀 있었다.
하지만 이걸 써보려고 하루종일 시도했으나 잘 되지 않았다. 사실 vercel에서 @vercel/og를 쓸 때도, 대부분의 사진은 잘 생성되었지만 가끔씩 사진이 생성되지 않는 경우가 있었다. 이유는 잘 모르겠지만 한글 인코딩 과정에서 뭔가 문제가 생기지 않았을까 추측한다. og-image-korean이라는 것도 있는 모양이지만 어차피 vercel에서만 될 것 같았다.
6.1. canvas로 이미지 생성해보기
그래서 어차피 우리가 썸네일 생성에 쓸 것은 그냥 텍스트(제목 등)와 정적 이미지만 들어간 사진이므로 canvas를 써서 직접 생성해 보기로 했다.
원래 쓰던 @vercel/og 플러그인 삭제. 그리고 canvas 설치
바로 엄청난 에러의 향연이 펼쳐졌다. canvas를 못 깔겠단다.
node-pre-gyp가 문제인 것 같다. 그래서 그걸 안 쓰는 canvasAPI를 찾아서 써보았다. 심지어 더 빠르다고 한다. Rust를 써서 컴파일되며 시스템 디펜던시도 없다고 한다.
이를 이용해서 make-thumbnail.mjs를 다음과 같이 수정해보자. 만약 글에 이미지가 없을 경우, 간단하게 제목을 썸네일로 만들어주는 실험을 해보았다. 제목을 뽑아내는 건 여기서 쓰는 file 객체의 형식을 console.log로 열심히 찍어 보면서 직접 만들었다.
파일의 원래 내용이 file.value에 저장되어 있었고 이를 개행 기준으로 split해주면 2번째 요소가 title: 제목어쩌고저쩌고였다.
이렇게 하니까 글에 이미지가 없을 경우 file.data.rawDocumentData.thumbnail에 이미지 자체는 잘 만들어졌다. 그런데 한글이 깨진다. 찾아보니 폰트를 직접 받아서 지정해 줘야 한다. 따라서 구글 폰트에서 제공하는 무료 폰트를 사용하기로 했다.
TMI지만 이렇게 한글이 깨지는 등, 폰트에서 지원하지 않는 글자가 있을 때 글자가 깨져서 나오는 네모난 모양을 tofu라고 부르는데(아마 두부처럼 네모나서 그렇지 않을까) 이러한 tofu가 없다는 의미로 위 폰트에 Noto 라는 이름이 붙었다고 한다.
이러한 글꼴에 비용이 있나요?
아니요, 모든 Google Fonts는 오픈소스이며 무료로 제공됩니다.
- Google Fonts FAQ 중
그래서 NotoSansKR의 otf 파일을 다운받아서 /font에 넣고 다음과 같이 폰트를 직접 지정해 주었다.
이렇게 하니까 썸네일이 잘 나온다. 이제 이를 좀더 다듬어보자.
6.2. 썸네일 이미지 구성
일단 글씨에 들어가는 title:부터 떼버리고, 레이아웃도 어떻게 할지 생각해 보았다. 일단 제목은 어떻게든 들어가야 하고. 부연 설명도 heading 2개 정도를 떼어서 넣어주면 좋겠다. 그리고 내 블로그의 제목도 넣고자 한다.
이런 레이아웃을 어떻게든 만들면 되지 않을까?
이를 기반으로 createThumbnailFromText함수를 구성해 보자. 먼저 400x300 정도로 캔버스를 생성하자. 이후에 이미지 처리를 위한 비동기 로직이 쓰일 것이므로 async 함수로 만들어준다.
필요한 함수들을 하나하나 생성해 보자. 먼저 캔버스를 흰색으로 칠해 주는 initCanvas 함수를 만들자. 그냥 흰색 직사각형으로 캔버스를 칠해주는 함수다. 그 후 스타일 색상을 바꿔주는 것만 잊지 않으면 된다.
이제는 제목을 일정 글자수마다 끊어 주는 함수를 만들고 그렇게 끊긴 제목 부분들을 하나씩 그려주는 함수 drawTitle
그리고 headingTree를 받아서 그중 depth 1짜리(즉 h1에 쓰인) 소제목들을 최대 2개까지 뽑아서 캔버스에 그려 주는 drawHeadings함수도 작성한다. 내가 주로 쓰는 소제목 양식에 맞춰 적당히 포매팅도 했다. 소제목들은 굳이 break-word를 쓰진 않았다.
그리고 내 블로그의 favicon으로도 쓰이고 있는 마녀 모자 사진을 가져와서 캔버스에 그려주는 drawBlogSymbol함수도 만들어 준다. 이때 마녀 모자 사진은 40x40으로 캔버스에 그려준다. 그리고 이미지 로딩이 끝나고 나서 이미지를 사용해야 하므로 await을 사용했다.
그리고 이렇게 만든 함수들을 모두 사용해서 그린 캔버스를 png로 인코딩해서 파일로 만든 후 경로를 리턴하는 함수 createThumbnailFromText를 완성하자.
이렇게 해주면 내 프로젝트 폴더의 /public/thumbnails에 썸네일이 잘 만들어진다. 단 /public/thumbnails폴더까지는 직접 생성해 줘야 한다. 그렇지 않으면 fs.writeFile에서 에러가 나더라.
예를 들어 이런 썸네일이 자동으로 생성되게 된다.
contentlayer에서 무슨 변경 flag 처리라도 하는 건지, 이렇게 하면 알아서 정보가 변경된 글에 대해서만 썸네일이 새로 생성된다.
7. 썸네일 넣어 주기
그럼 이제 양식을 잘 맞춰서 글을 썼다고 할 때, 모든 글의 변환 데이터에 thumbnail 속성이 들어가 있을 것이다. 사실 양식이 좀 안 맞아도 어쨌든 들어가 있긴 할 거라 생각한다. null이나 undefined라도...
이걸 이제 글 목록 페이지의 Card 컴포넌트에 넣고, 글 상세 페이지의 og:image에도 넣어주자. Card컴포넌트에는 이미 이미지를 넣을 수 있는 기능이 있으므로 이를 props로 넘겨주기만 하면 된다. 따라서 src/pages/posts/[category]/index.tsx의 getStaticProps를 다음과 같이 수정한다.
post._raw 의 타입이 엄격하게 정해져 있는 편이었어서 이를 회피하느라 좀 코드가 길어졌는데, post._raw 내에 thumbnail이 있을 때만 그걸 넘겨주는 식으로 했다.
그리고 PostListPage 컴포넌트에서는 다음과 같이 post별 데이터를 그냥 Card에 넘겨주도록 한다.
이제 글 목록 페이지에서 썸네일이 잘 보이는 걸 확인할 수 있다. 이제 src/pages/posts/[category]/[slug]/index.tsx에서 open graph 이미지도 넣어주자. 이건 getStaticProps에서 post를 통째로 넘겨주기 때문에 post._raw.thumbnail을 SEOconfig에 넣어 주기만 하면 된다.
8. 카드 내부 요소 배치
그런데 지금 보니 카드에서 썸네일 사진과 글 인트로가 딱 붙어 있다.
그리고 src/components/Card/styles.module.css를 다음과 같이 수정한다. 높이를 기준으로 사진 크기를 조절할 수 있다면 좋았겠지만 그럴 방법을 모르겠어서, 어차피 다 비슷한 레이아웃으로 쓰일 거라 width, height를 딱 정해 주었다.
이렇게 하니까 사진 크기가 무조건 고정된다. 높이 기준으로 어떻게든 해보려 했지만, 이미지 요소의 조상 요소들 중에 computed height가 없어서 쉽지 않았기에 그냥 고정해 버렸다.