◆ ESSAY
next.config.ts 설정의 역할/api/og/art의 생성 원리
thumbnail 필드와의 관계이 문서는 Blog 앱이 포스트에 썸네일을 연결하는 과정을 설명한다.
포스트를 읽고 썸네일을 연결·생성하는 코드를 살펴본다.
src/utils/Post.ts: 포스트를 읽고 썸네일 URL을 결정한다.src/utils/postPaths.ts: Markdown 파일 경로를 포스트 slug로 변환한다.src/components/PostCard.tsx: 홈의 포스트 카드에서 썸네일을 출력한다.src/components/PostRow.tsx: 목록의 포스트 행에서 썸네일을 출력한다.src/app/api/og/art/route.tsx: slug를 seed로 사용해 1200×630 아트 이미지를 생성한다.src/utils/ogSharpUnblock.ts: next/og 렌더링 전에 Sharp의 SVG 로더를 다시 허용한다.next.config.ts: 생성 썸네일 URL을 next/image가 사용할 수 있도록 허용한다.현재 코드는 로컬 PNG를 먼저 찾는다.
public/thumbnails/{slug}.png 파일을 찾는다./api/og/art?... 생성 URL을 사용한다./api/og/art Route Handler는 next/og의 ImageResponse를 사용해 실제 이미지를 반환한다. 따라서 로컬 PNG가 없는 포스트도 생성 썸네일을 표시할 수 있다. 생성기는 포스트 제목을 조회하지 않고 slug만 받으므로 포스트 카드용 fallback은 글자가 없는 추상 아트로 생성된다.
Markdown 파일
│
▼
getAllPosts(locale)
│ 파일 경로를 slug로 변환
▼
public/thumbnails/{slug}.png 존재 여부 확인
│
├─ 존재함 ───────► /thumbnails/{slug}.png
│
└─ 존재하지 않음 ─► /api/og/art?v=4&slug={encoded-slug}
│
▼
/api/og/art가 이미지 반환
│
▼
PostCard / PostRow가 <Image>로 출력
생성 URL을 사용하는 경우에도 getAllPosts()가 곧바로 이미지 바이트를 생성하지는 않는다. 이 함수는 나중에 요청할 URL 문자열만 포스트 데이터에 넣는다. 브라우저가 URL을 요청하면 /api/og/art 서버 라우트가 그때 JSX 기반 디자인을 렌더링해 이미지 응답을 만든다.
Post.ts의 getAllPosts()는 posts 경로 아래의 Markdown과 MDX 파일을 찾는다.
const POST_ROOT = path.join(process.cwd(), 'posts')
const files = sync(`${POST_ROOT}/**/*.md*`).toReversed()
Blog 앱의 실행 위치가 apps/blog이므로 process.cwd()가 apps/blog를 가리킬 때 포스트 루트는 다음과 같다.
apps/blog/posts
포스트 파일을 하나 예로 들면 경로는 이렇다.
apps/blog/posts/2026/09/blog-01-linear-referencing.md
pathToSlug()는 포스트 루트 뒤의 경로에서 확장자를 제거한다.
2026/09/blog-01-linear-referencing
영문 번역 파일도 같은 slug를 사용한다.
posts/2026/09/blog-01-linear-referencing.en.md
→ 2026/09/blog-01-linear-referencing
따라서 한국어 파일과 영문 파일의 slug가 같으면 생성 썸네일 seed도 같다. 현재 buildArtThumbnail()은 locale을 seed에 포함하지 않으므로 두 언어가 같은 아트 썸네일을 공유하도록 설계되어 있다.
Post.ts는 썸네일 디렉터리를 다음처럼 계산한다.
const THUMB_DIR = `${process.cwd()}/public/thumbnails`
이후 slug 전체를 파일 경로 뒤에 붙여 PNG 파일이 있는지 확인한다.
const thumbnail = fs.existsSync(`${THUMB_DIR}/${slug}.png`)
? `/thumbnails/${slug}.png`
: buildArtThumbnail(slug)
slug가 다음과 같다면:
2026/09/blog-01-linear-referencing
앱은 다음 파일을 찾는다.
apps/blog/public/thumbnails/2026/09/blog-01-linear-referencing.png
파일이 존재하면 브라우저에 전달하는 URL은 다음과 같다.
/thumbnails/2026/09/blog-01-linear-referencing.png
Next.js에서 public 디렉터리는 웹 루트(/)로 노출된다. 파일 시스템의 public/thumbnails는 URL의 /thumbnails에 대응한다.
다음 파일은 현재 규칙에 맞지 않는다.
public/thumbnails/blog-01-linear-referencing.png
public/thumbnails/2026-09-blog-01-linear-referencing.png
public/thumbnails/2026/09/blog-01-linear-referencing.jpg
현재 코드는 slug 전체 경로를 사용하고 .png만 확인한다. 위 파일만 있으면 로컬 썸네일을 찾지 못하고 생성 URL로 넘어간다.
로컬 PNG가 없으면 buildArtThumbnail()이 실행된다.
const ART_VERSION = 4
export function buildArtThumbnail(seed: string): string {
return `/api/og/art?v=${ART_VERSION}&slug=${encodeURIComponent(seed)}`
}
예를 들어 seed가 다음과 같으면:
2026/09/blog-01-linear-referencing
반환 URL은 다음 형태가 된다.
/api/og/art?v=4&slug=2026%2F09%2Fblog-01-linear-referencing
slug를 URL 인코딩하는 이유slug에는 /가 들어간다. 이 값을 query string에 그대로 넣으면 서버가 경로 구분자로 오해하거나 값이 여러 부분으로 나뉠 수 있다. encodeURIComponent()는 /를 %2F로 바꾸어 seed 전체를 하나의 query parameter 값으로 보낸다.
API에서는 query string을 다시 읽어 원래 seed를 얻는다.
const url = new URL(request.url)
const seed = url.searchParams.get('slug')
v=4의 역할v는 아트 디자인이 바뀌었을 때 캐시를 무효화하기 위한 버전 값이다.
예를 들어 디자인을 변경한 뒤에도 URL이 계속 같으면 브라우저, CDN, Next 이미지 최적화 캐시가 이전 이미지를 계속 제공할 수 있다. 이때 ART_VERSION을 4에서 5로 올리면 URL이 바뀐다.
/api/og/art?v=4&slug=...
/api/og/art?v=5&slug=...
Route Handler는 v 값을 디자인 계산에 사용하지 않는다. 대신 v가 URL에 포함되므로 버전을 올리면 브라우저와 CDN이 다른 리소스로 인식한다. Route Handler는 ImageResponse에 다음 캐시 헤더를 설정한다.
Cache-Control: public, max-age=31536000, immutable
아트 디자인을 바꾼 뒤 ART_VERSION을 올리면 새 URL이 새 캐시 항목을 만든다. 버전을 올리지 않으면 기존 URL에 연결된 캐시가 남을 수 있다.
getAllPosts()가 만든 thumbnail 값은 frontMatter에 들어간다.
frontMatter: {
...fm,
tags,
date: new Date(date).toISOString().substring(0, 19),
thumbnail,
}
홈의 PostCard는 이 값을 next/image에 전달한다.
{
thumbnail && (
<Image
src={thumbnail}
alt=""
fill
sizes="(min-width: 1024px) 33vw, 100vw"
priority={priority}
/>
)
}
목록의 PostRow도 같은 방식으로 사용한다.
{thumbnail ? (
<Image
src={thumbnail}
alt=""
fill
sizes="(min-width: 768px) 120px, 84px"
/>
) : (
// thumbnail이 없을 때의 SVG placeholder
)}
현재 getAllPosts()는 로컬 파일이 없을 때도 buildArtThumbnail()이 반환한 URL을 넣는다. 일반적인 포스트에서는 thumbnail이 빈 값이 아니다. /api/og/art가 정상적으로 이미지를 반환한다면 SVG placeholder 분기까지 내려가지 않는다.
next.config.ts 설정의 역할현재 설정은 images.localPatterns로 이미지 경로를 허용한다.
images: {
localPatterns: [
{ pathname: "/api/og/art" },
{ pathname: "/**", search: "" },
],
},
이 설정은 next/image가 사용할 수 있는 로컬 이미지 경로를 허용한다. /api/og/art를 목록에 넣었기 때문에 생성 API를 Image의 src로 사용할 수 있다.
하지만 localPatterns는 API 라우트를 만들지 않는다. 다음 두 작업은 서로 다르다.
| 작업 | 담당 코드 | 의미 |
|---|---|---|
| 이미지 URL 허용 | next.config.ts의 images.localPatterns | next/image가 해당 URL을 사용하도록 검사 조건을 통과시킨다. |
| 이미지 생성 | src/app/api/og/art/route.tsx | HTTP 요청을 받고 실제 이미지 응답을 만든다. |
현재 저장소에는 두 설정과 Route Handler가 모두 있다. localPatterns는 요청 허용을 담당하고 Route Handler는 허용된 요청에 실제 이미지 본문을 반환한다.
포스트 2026/09/blog-01-linear-referencing를 예로 들면 다음과 같다.
파일:
apps/blog/public/thumbnails/2026/09/blog-01-linear-referencing.png
thumbnail 값:
/thumbnails/2026/09/blog-01-linear-referencing.png
결과:
정적 PNG를 표시한다.
thumbnail 값:
/api/og/art?v=4&slug=2026%2F09%2Fblog-01-linear-referencing
결과:
`/api/og/art`가 slug를 seed로 사용해 추상 아트를 생성하고 1200×630 이미지로 반환한다.
포스트 데이터 자체와 썸네일 요청은 별개의 흐름이다. 포스트 페이지가 정상적으로 열려도 이미지 API의 렌더링이나 외부 폰트 요청이 실패하면 썸네일만 실패할 수 있다. 브라우저 개발자 도구의 Network 탭에서 /api/og/art 요청을 확인하면 페이지 라우트 404와 이미지 생성 실패를 구분할 수 있다.
/api/og/art의 생성 원리생성 API는 route.tsx에 구현되어 있다.
apps/blog/src/app/api/og/art/route.tsx
요청 URL에는 네 query parameter가 들어올 수 있다. Route Handler가 실제로 읽는 값은 slug, title, tag이고 v는 URL 버전에만 사용한다.
| 파라미터 | 현재 fallback에서 전달하는가 | 역할 |
|---|---|---|
slug | 전달한다 | 아트의 seed다. 값이 같으면 같은 디자인을 재현한다. |
v | 전달한다 | URL 버전과 캐시 분리를 위한 값이다. 현재 디자인 계산에는 사용하지 않는다. |
title | 전달하지 않는다 | 직접 호출할 때 제목을 이미지 위에 표시할 수 있다. |
tag | 전달하지 않는다 | title이 있을 때 제목 위에 태그를 표시할 수 있다. |
slug가 없으면 Route Handler는 기본값 yceffort를 사용한다. 실제 포스트 카드 URL에는 항상 slug가 들어가므로 일반적인 경로에서는 기본값을 사용하지 않는다.
생성기는 slug를 hashCode()로 정수로 바꾼다.
const hash = hashCode(slug)
const rand = mulberry32(hash)
const [c1, c2] = DUOS[hash % DUOS.length]
그 다음 mulberry32() 의사 난수 생성기를 사용한다. 이 방식은 Math.random()과 다르다. 같은 정수 seed로 시작하면 같은 난수 순서를 재현한다.
이 특성 덕분에 다음 요청은 같은 디자인을 얻는다.
/api/og/art?v=4&slug=2026%2F09%2Fblog-01-linear-referencing
/api/og/art?v=5&slug=2026%2F09%2Fblog-01-linear-referencing
두 URL의 v는 다르지만 slug가 같으므로 디자인은 같다. v는 캐시 URL만 바꾼다.
DUOS에는 두 색으로 구성된 색상 조합이 들어 있다. hash % DUOS.length로 한 조합을 고르므로 slug마다 기본 색이 달라진다.
PAPERS에는 밝은 배경과 어두운 배경이 가중치와 함께 정의되어 있다. pickPaper()는 가중치에 따라 다음 정보를 선택한다.
그 뒤 생성기는 의사 난수를 사용해 다음 요소의 위치와 크기를 정한다.
현재 LAYOUTS에는 12개의 배경 레이아웃이 등록되어 있다.
const LAYOUTS = [
bandsLayout,
ringsLayout,
fieldLayout,
columnsLayout,
codePanelLayout,
contoursLayout,
gridChartLayout,
glyphLayout,
halftoneLayout,
stripesLayout,
bauhausLayout,
stepsLayout,
]
선택된 레이아웃은 다음과 같은 SVG와 CSS 도형을 조합한다.
bandsLayout: 넓은 색상 밴드와 연결 선ringsLayout: 원 또는 회전 사각형 링fieldLayout: 작은 도형이 흩어진 필드columnsLayout: 세로 기둥과 연결 선codePanelLayout: 코드 에디터처럼 보이는 패널contoursLayout: 등고선 형태의 곡선gridChartLayout: 격자와 꺾은선 그래프glyphLayout: 큰 원 또는 마름모 윤곽halftoneLayout: 색상과 크기가 달라지는 점 격자stripesLayout: 기울어진 줄무늬bauhausLayout: 원과 기하학 도형stepsLayout: 상승 또는 하강하는 계단형 막대각 레이아웃은 같은 rand()를 공유한다. 따라서 slug가 바뀌면 레이아웃뿐 아니라 도형 개수, 위치, 크기, 회전, 투명도도 함께 달라진다.
현재 buildArtThumbnail()은 slug만 URL에 넣는다. 그래서 포스트 카드에서 생성되는 기본 썸네일에는 제목이 없다.
그러나 API를 다음처럼 직접 호출하면 제목과 태그를 포함할 수 있다.
/api/og/art?slug=demo&title=기본%20썸네일&tag=design
title이 있을 때 Route Handler는 다음 작업을 한다.
parseTitleEmphasis()로 제목의 강조 부분을 나눈다.tag가 있으면 제목 위에 ◆ #TAG 형태로 표시한다.현재 포스트 fallback 요청에는 title이 없으므로 외부 폰트를 가져오지 않는다. 이 점이 카드용 추상 썸네일과 OG 메타데이터용 이미지의 차이다.
Route Handler는 JSX 안에서 <svg>, <div>, <circle>, <rect>, <path> 등을 조합한 뒤 ImageResponse에 전달한다.
return new ImageResponse(<div>{/* generated artwork */}</div>, {
width: 1200,
height: 630,
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
},
});
ImageResponse가 이 JSX를 이미지 응답으로 변환한다. 응답 크기는 항상 1200×630으로, Open Graph 이미지와 같은 비율이다. ImageResponse가 이미지 응답의 Content-Type을 설정하므로 Route Handler에서 PNG 바이트를 직접 조립하지 않는다.
Route Handler는 렌더링 전에 unblockSvgLoader()를 호출한다. Next 이미지 최적화가 Sharp를 먼저 사용한 프로세스에서는 SVG 로더가 차단될 수 있다. 이 함수는 같은 프로세스에서 next/og가 SVG를 래스터라이즈할 수 있도록 SVG 로더를 다시 허용한다.
이 계약에 따라 GET 요청에 이미지 응답을 반환한다.
GET /api/og/art?v=4&slug=2026%2F09%2Fblog-01-linear-referencing
200 OK
Content-Type: image/png
<generated image bytes>
Route Handler가 정상적으로 응답하면 ImageResponse가 이미지 Content-Type과 본문을 반환한다. 렌더링 중 예외가 발생하면 Next.js는 오류 응답을 반환하고 next/image는 이를 이미지로 표시할 수 없다. 특히 배포 환경에서는 외부 폰트 요청, Sharp의 native 모듈, next/og의 런타임 지원 여부를 함께 확인해야 한다.
thumbnail 필드와의 관계FrontMatter 타입에는 thumbnail?: string이 정의되어 있다. 그러나 현재 getAllPosts()는 Markdown Front Matter의 thumbnail 값을 그대로 사용하지 않는다.
frontMatter: {
...fm,
thumbnail,
}
객체에서 뒤에 작성한 thumbnail이 앞에서 펼친 fm.thumbnail을 덮어쓴다. 현재 우선순위는 아래와 같다.
public/thumbnails/{slug}.png
↓ 없으면
/api/og/art?... fallback
Front Matter에 다음을 적어도:
thumbnail: https://example.com/cover.png
현재 구현에서는 이 URL을 사용하지 않는다. 외부 URL이나 Front Matter의 썸네일을 지원하려면 getAllPosts()의 우선순위를 별도로 정의해야 한다.
예를 들어 의도한 우선순위가 다음과 같다면:
Front Matter thumbnail
↓ 없으면
public/thumbnails/{slug}.png
↓ 없으면
/api/og/art?... fallback
코드도 이 순서에 맞게 조건을 작성해야 한다.
개발 서버를 실행한 뒤 포스트에 연결된 썸네일 URL을 직접 확인한다.
pnpm --filter blog dev
로컬 파일이 있는 포스트는 다음 주소를 확인한다.
http://localhost:3000/thumbnails/2026/09/blog-01-linear-referencing.png
생성 fallback은 다음 주소를 확인한다.
http://localhost:3000/api/og/art?v=4&slug=2026%2F09%2Fblog-01-linear-referencing
Route Handler를 포함한 현재 구현은 두 번째 주소에서 생성 이미지를 반환해야 한다. 다음 조건을 확인한다.
200인가?Content-Type이 image/png, image/jpeg, image/svg+xml 중 실제 본문과 일치하는가?ART_VERSION을 바꿨을 때 같은 디자인의 새 URL이 별도 캐시 항목으로 처리되는가?오류가 발생하면 먼저 다음 위치를 확인한다.
/api/og/art 요청 자체가 404인가? Route Handler 경로와 배포 산출물을 확인한다.next/og, Sharp, 외부 폰트 요청 오류를 확인한다.next/image의 원본 URL, images.localPatterns, 이미지 CSS 크기를 확인한다.로컬 PNG 존재
→ /thumbnails/... 사용
로컬 PNG 없음
→ /api/og/art?... URL 사용
→ slug 기반 deterministic 아트 생성
→ ImageResponse로 1200×630 이미지 반환
getAllPosts()는 로컬 PNG가 있으면 그 파일을 사용하고 없으면 slug를 담은 /api/og/art URL을 반환한다. API는 slug에서 재현 가능한 seed를 만들어 1200×630 이미지를 렌더링한다. ART_VERSION을 바꾸면 디자인은 유지하면서 새 캐시 URL을 만들 수 있다.