Skip to content

Commit e4e9ffa

Browse files
committed
fix(seo): OG 이미지 함수 용량 제한 해소
Edge Function에 번들된 한글 폰트가 Vercel 배포 용량 제한을 초과했습니다. OG Route Handler를 Node.js runtime으로 전환하고 폰트를 파일 시스템에서 지연 로드하도록 변경했습니다. 실제 폰트 PNG 테스트와 ADR로 배포 계약을 고정했습니다.
1 parent be42fce commit e4e9ffa

5 files changed

Lines changed: 61 additions & 36 deletions

File tree

app/api/og/route.test.ts

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,21 @@
11
// @vitest-environment node
22

3-
import { readFile } from 'node:fs/promises';
4-
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import { describe, expect, it } from 'vitest';
54
import { NextRequest } from 'next/server';
65

76
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
87

9-
afterEach(() => {
10-
vi.unstubAllGlobals();
11-
vi.resetModules();
12-
});
13-
148
describe('GET /api/og', () => {
15-
it('returns a non-empty PNG using the bundled Korean font', async () => {
16-
vi.stubGlobal(
17-
'fetch',
18-
vi.fn(async (input: string | URL | Request) => {
19-
const url =
20-
input instanceof URL
21-
? input
22-
: new URL(typeof input === 'string' ? input : input.url);
23-
24-
if (url.protocol !== 'file:') {
25-
throw new Error(`Unexpected external font request: ${url.href}`);
26-
}
27-
28-
return new Response(await readFile(url), {
29-
status: 200,
30-
headers: { 'Content-Type': 'font/ttf' },
31-
});
32-
})
33-
);
34-
35-
const { GET } = await import('./route');
9+
it('returns a non-empty PNG from the Node.js runtime', async () => {
10+
const { GET, runtime } = await import('./route');
3611
const response = await GET(
3712
new NextRequest(
3813
'https://ark-log.vercel.app/api/og?title=%ED%95%9C%EA%B8%80%20OG%20%EC%9D%B4%EB%AF%B8%EC%A7%80&tags=Next.js,Ark'
3914
)
4015
);
4116
const bytes = new Uint8Array(await response.arrayBuffer());
4217

18+
expect(runtime).toBe('nodejs');
4319
expect(response.status).toBe(200);
4420
expect(response.headers.get('content-type')).toBe('image/png');
4521
expect(bytes.byteLength).toBeGreaterThan(10_000);

app/api/og/route.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
1+
import { readFile } from 'node:fs/promises';
12
import { ImageResponse } from 'next/og';
23
import type { NextRequest } from 'next/server';
34
import { SITE_NAME } from '@/site/config/site';
45

5-
export const runtime = 'edge';
6+
export const runtime = 'nodejs';
67

78
const fontUrl = new URL('./Pretendard-Bold.ttf', import.meta.url);
89
let fontDataPromise: Promise<ArrayBuffer> | null = null;
910

1011
function loadFontData(): Promise<ArrayBuffer> {
1112
if (!fontDataPromise) {
12-
fontDataPromise = fetch(fontUrl).then((response) => {
13-
if (!response.ok) {
14-
throw new Error(`Failed to load bundled OG font: ${response.status}`);
15-
}
16-
17-
return response.arrayBuffer();
18-
});
13+
fontDataPromise = readFile(fontUrl).then(
14+
(font) =>
15+
font.buffer.slice(
16+
font.byteOffset,
17+
font.byteOffset + font.byteLength
18+
) as ArrayBuffer
19+
);
1920
}
2021

2122
return fontDataPromise;

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Last updated: 2026-07-13
2323
- `docs/adr/0022-default-new-posts-to-private.md`
2424
- `docs/adr/0023-run-the-release-quality-gate-in-ci.md`
2525
- `docs/adr/0024-use-latest-only-home-feed.md`
26+
- `docs/adr/0025-use-node-runtime-for-og-image-route.md`
2627
- `docs/blog-quality-guide.md`
2728
- `docs/database/db-schema.md`
2829
- `docs/database/supabase-view-count.sql`
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# 0025. OG 이미지 route에 Node.js runtime을 사용한다
2+
3+
Date: 2026-07-13
4+
Status: Accepted
5+
6+
## 배경
7+
8+
`/api/og`는 한글 제목을 안정적으로 렌더링하기 위해 2.72MB
9+
`Pretendard-Bold.ttf`를 번들에 포함한다. Edge runtime으로 생성한 Vercel
10+
함수는 1.86MB였고, Preview 배포의 1MB 제한을 초과했다. Next.js
11+
production build는 성공했지만 출력물을 배포하는 단계에서 실패했다.
12+
13+
한글 글리프를 줄인 폰트 subset은 크기를 낮출 수 있지만, 블로그 제목과 태그에
14+
필요한 문자가 빠질 위험과 별도 생성 절차가 생긴다. 외부 URL에서 폰트를 읽으면
15+
함수 크기는 줄지만 OG 이미지 생성이 네트워크 상태에 의존한다.
16+
17+
## 결정
18+
19+
- `/api/og` Route Handler는 `nodejs` runtime을 명시한다.
20+
- 번들된 Pretendard 폰트는 `node:fs/promises``readFile`로 지연 로드한다.
21+
- 폰트 데이터 Promise를 module scope에 캐시해 같은 인스턴스에서 반복해서
22+
파일을 읽지 않는다.
23+
- route 테스트는 실제 번들 폰트로 PNG를 생성하고 Node.js runtime 설정을 함께
24+
검증한다.
25+
- 배포 검증은 Vercel Preview가 `Ready` 상태인지와 실제 OG endpoint가 PNG를
26+
반환하는지 확인한다.
27+
28+
## 결과
29+
30+
- Edge Function 크기 제한과 무관하게 전체 한글 폰트를 유지할 수 있다.
31+
- OG 이미지 생성에 외부 폰트 서버가 필요하지 않다.
32+
- Edge runtime 대비 실행 위치와 cold start 특성은 달라질 수 있다.
33+
- 폰트 파일 크기 자체는 저장소와 Node.js 함수 산출물에 남는다.
34+
35+
## 검토한 대안
36+
37+
- 한글 폰트 subset 생성: Edge runtime을 유지할 수 있지만 글리프 누락 방지와
38+
생성 절차를 지속해서 관리해야 한다.
39+
- 외부 폰트 URL을 runtime에 fetch: Edge bundle은 작아지지만 네트워크 장애가
40+
OG 이미지 생성 실패로 이어진다.
41+
- 커스텀 폰트 제거: 배포는 단순해지지만 한글 렌더링의 일관성과 기존 테스트
42+
의도를 잃는다.
43+
44+
## Related History
45+
46+
- `5c4bcaf`: OG 이미지에 번들 한글 폰트 도입

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ ADR은 AI 협업 가이드와 별개의 문서다. 사람이 결정했든 AI가
4242
| [0022](0022-default-new-posts-to-private.md) | Accepted | 새 글을 명시적으로 검토한 뒤 공개한다 |
4343
| [0023](0023-run-the-release-quality-gate-in-ci.md) | Accepted | 배포 품질 gate를 CI에서 실행한다 |
4444
| [0024](0024-use-latest-only-home-feed.md) | Accepted | 홈 피드를 최신순 단일 경로로 유지한다 |
45+
| [0025](0025-use-node-runtime-for-og-image-route.md) | Accepted | OG 이미지 route에 Node.js runtime을 사용한다 |
4546

4647
## 작성 조건
4748

0 commit comments

Comments
 (0)