The posts on this blog are written in Korean. There is no English version. Open the Korean page
opengraph-image 재사용에서 걸린 3건: 해시 경로와 옛 캐시
블로그 목록 카드에 커버가 없어 자동 생성을 붙였다. 기존 OG 라우트를 그대로 부르려 했더니 404 였다. 파일 규약 라우트 주소에는 빌드 해시가 붙는다. 확인하러 갔더니 공유 카드에는 이미 그림이 있었고, 고친 그림은 캐시가 막고 있었다.
Published 9 views
TL;DR
- 목록 카드에 커버가 없어 만들려고 보니 공유 카드에는 이미 붙고 있었다. og:image 가 200, 1200x630
- 파일 규약 라우트의 실제 주소는
opengraph-image-1fmbi2?74878dd7aef165df다. 해시 없는 경로는 404 라 화면에서 못 가리킨다 - 그리는 코드를 고쳤는데 화면이 안 바뀌었다.
.next/cache/images가 옛 PNG 를 200 으로 준다 - 라우트를 따로 두고 그리는 함수만 공유했다. 목록 카드 요청 1회, 캐시 하루
블로그 목록에 카드가 열아홉 장 깔리는데 커버 이미지가 있는 글이 한 편뿐이었다. 나머지는 자리표시자 이모지가 그려졌다. 제목으로 커버를 자동 생성해서 그 자리를 채우기로 했다.
시작하기 전에 지금 상태부터 확인했다. 그 확인에서 예상과 다른 것이 먼저 나왔다.
이미 붙어 있던 공유 카드
공유 카드에도 그림이 없다고 알고 있었는데 아니었다.
curl -s https://portfolio.nimuni.com/ko/blog/<slug> | grep -oE '<meta property="og:image[^>]*>'
<meta property="og:image" content="https://portfolio.nimuni.com/ko/blog/<slug>/opengraph-image-1fmbi2?74878dd7aef165df"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
받아 보니 1200x630 PNG 가 정상으로 나온다. 한글 제목도 네모 상자 없이 그려진다. next/og 는 폰트를 따로 넘기지 않으면 기본 폰트를 쓴다. 폰트를 명시하지 않았다면 한글이 나오는지부터 받아서 확인하는 편이 낫다.
붙는 경로는 app/(localized)/[locale]/(blog)/blog/[slug]/opengraph-image.tsx 다. 커버 이미지가 없는 글에 이 라우트가 자동으로 걸린다. 메타데이터를 만드는 쪽에 이런 주석이 있었다.
// 커버 이미지가 없으면 images 키 자체를 넣지 않는다.
// `images: undefined` 로 넣으면 Next.js 가 "직접 지정했다" 로 보고
// opengraph-image.tsx 가 만드는 이미지를 붙이지 않는다.
const imageOverride = post.coverImage ? { images: [/* ... */] } : {};
키를 넣지 않는 것과 undefined 를 넣는 것이 다르게 동작한다. 여기까지는 이미 해결돼 있었다. 그래서 실제로 비어 있는 자리는 목록 카드 하나였다.
여기서 가져갈 것은 하나다. 없다고 알고 있는 것부터 한 번 확인한다. 위 curl 한 줄이면 된다.
주소에 붙는 빌드 해시
목록 카드가 같은 라우트를 부르면 될 것 같았다. 안 된다.
curl -s -o /dev/null -w "%{http_code}\n" https://portfolio.nimuni.com/ko/blog/<slug>/opengraph-image
# 404
메타데이터에 실린 주소에는 -1fmbi2 라는 조각과 쿼리 문자열이 붙어 있다. 빌드마다 만들어지는 값이고 그 값을 아는 방법은 메타데이터를 읽는 것뿐이다. 화면 컴포넌트에서 경로를 문자열로 만들어 가리킬 수가 없다.
flowchart LR
A[opengraph-image.tsx] --> B[metadata 가 만드는 주소]
B --> C["해시 붙은 주소: 200"]
A --> D["손으로 적은 주소: 404"]
E[목록 카드] -.가리킬 방법 없음.-> B
그래서 목록용 경로를 따로 팠다. app/api/blog/cover/[slug]/route.ts 는 우리가 이름을 정하므로 해시가 붙지 않는다. 그리는 코드는 lib/og/blogCoverImage.tsx 한 곳에 두고 두 라우트가 그것을 부른다. 양쪽에 그리는 코드를 두면 같은 글이 자리마다 다른 그림이 되는데 양쪽 다 그림은 나오므로 화면만 봐서는 드러나지 않는다.
같은 그림을 그대로 쓰지는 않았다. 공유 카드는 링크 하나만 놓이는 자리라 제목이 그림에만 있다. 목록 카드는 바로 아래에 제목과 분류가 글자로 또 있다. 그대로 쓰면 한 카드에 같은 문장이 두 번 들어간다. 목록용은 글자를 빼고 slug 해시로 바탕색만 정한다.
200 으로 오는 옛 그림
바탕색 계산을 고치고 화면을 다시 열었는데 색이 그대로였다. 라우트를 직접 부르면 새 그림이 온다. 카드에는 옛 그림이 나온다.
next/image 를 쓰면 브라우저가 받는 것은 원본이 아니라 /_next/image 가 다시 인코딩한 파일이다. 그 결과가 .next/cache/images 에 남는다. 응답 바이트 수를 재면 갈라진 것이 보인다.
U=$(node -e "console.log(encodeURIComponent('/api/blog/cover/<slug>'))")
curl -s -o /dev/null -w "%{size_download}\n" "http://localhost:3000/_next/image?url=$U&w=640&q=75"
# 20082 (코드를 고친 뒤에도 같은 값)
rm -rf .next/cache/images
curl -s -o /dev/null -w "%{size_download}\n" "http://localhost:3000/_next/image?url=$U&w=640&q=75"
# 9206
캐시 키에 원본 주소와 크기, 품질은 들어가고 원본 내용은 들어가지 않는다. 주소가 그대로면 내용이 바뀌어도 옛 파일이 나간다. 상태 코드는 200 이고 형식도 맞아서 실패로 보이지 않는다.
프로덕션에서는 응답의 Cache-Control 로 만료를 정하므로 덜하다. 개발 중에 문제가 된다. 그림을 고치는 중이라면 이 디렉토리를 지우고 다시 잰다.
처리
lib/og/blogCoverImage.tsx: 그리는 함수 하나. 공유용과 목록용 두 가지로 그린다app/api/blog/cover/[slug]/route.ts: 목록용 경로. 발행글이 아니면 404lib/constants/seo.ts: 카드가 부르는 경로 문자열. 그리는 모듈은next/og와 prisma 를 끌어오므로 화면이 그것을 가져오면 브라우저 번들에 서버 코드가 실린다scripts/ci/blog-cover-guard.test.mjs: 두 라우트가 같은 함수를 쓰는지, 카드가 가리키는 경로에 라우트가 있는지 정적으로 검사
커버 값을 DB 에 굽지 않았다. 제목을 고치면 구운 그림이 낡는다. 요청 시점에 그리고 응답에 s-maxage=86400, stale-while-revalidate=604800 을 붙였다.
목록 카드의 대체 텍스트는 비웠다. 그림에 글자가 없고 바로 아래에 제목이 있어서 읽어 주면 같은 문장이 두 번 나온다.
검증
| 확인한 것 | 값 |
|---|---|
| 발행글 커버 | 200, 1200x630 PNG |
| 초안 slug | 404 |
| 없는 slug | 404 |
| 응답 헤더 | public, max-age=0, s-maxage=86400, stale-while-revalidate=604800 |
| 공유 카드 | 200, 109795 바이트로 변경 전과 같음 |
npx tsc --noEmit | 오류 0 |
npm test | 194건 통과 |
초안 slug 를 따로 잰 이유가 있다. 그림에 제목이 박히므로 발행 상태를 안 거르면 주소만 아는 사람이 초안 제목을 읽는다.
배포한 뒤 프로덕션에서 다시 쟀다. 목록 첫 화면이 부르는 커버 열한 개가 전부 200 이다. 없는 slug 는 404 다. 목록 HTML 에 자리표시자 이모지가 0개다.
개선
목록 카드 열아홉 장 중 커버가 그려지는 것이 1장에서 19장이 됐다. 어드민에서 올린 진짜 커버가 있는 한 편은 그것을 그대로 쓴다. 카드당 요청은 1회다. 프로덕션에서 처음 그릴 때가 글마다 1.0초에서 2.2초 사이이고 그 뒤로는 캐시에서 나간다. 640px 로 다시 인코딩한 파일은 9KB 에서 23KB 사이다.
남은 것
바탕색이 도는 범위를 청록에서 보라까지로 묶었다. 색상환을 전부 쓰면 올리브나 갈색이 나와 사이트에서 튄다. 겹쳐도 잃는 것이 없다고 보고 넘어갔는데, 발행글 스무 편으로 재 보니 열일곱 쌍이 6도 이내였고 두 편은 완전히 같은 색이었다. 대역이 82도뿐이라 편수가 늘면 겹친다.
대역은 그대로 두고 방향 여덟 가지와 채도 두 단계를 더했다. 밝기는 안 흔들었다. 흔들면 카드마다 무게가 달라져 목록이 들쭉날쭉해 보인다. 글이 더 늘면 또 겹치는데, 그때도 대역이 아니라 단계 수를 늘린다.
| 흔든 것 | 구분 안 되는 쌍 |
|---|---|
| 색만 | 17 / 190 |
| 색과 방향 | 2 / 190 |
| 색과 방향과 채도 | 0 / 190 |
글 상세 페이지에는 자동 커버를 붙이지 않는다. 커버가 있는 글을 열어 보니 히어로가 첫 화면의 절반을 먹고 제목이 아래로 밀렸다. 어드민이 올린 커버는 그림 자체가 정보라 그 자리값을 하지만 생성 커버에는 새 정보가 없다.
다음부터
만들 기능을 시작하기 전에 없다고 알고 있는 것이 정말 없는지 요청 한 번으로 확인한다. 생성 이미지를 고쳤는데 화면이 안 바뀌면 원본 경로를 직접 부른 응답과 최적화 캐시를 거친 응답의 크기를 재고, 캐시를 지운 뒤 다시 잰다. 프레임워크가 주소를 정하는 라우트를 화면에서 가리켜야 하면 이름을 직접 정한 경로를 따로 두고 그리는 함수만 공유한다.