OG 이미지 구현의 두 전환점: 단순한 답이 이긴 두 번의 리팩토링
OG 이미지를 구현하면서 같은 영역을 두 번 리팩토링했습니다. 두 번 모두 "영리해 보이는" 첫 설계를 버리고 "덜 특별한" 두 번째 설계를 택했고, 두 번 모두 그게 맞았습니다. 공통 패턴과 의사결정 기준을 정리합니다.
1. 문제 상황
다국어 포트폴리오 사이트에 OG 이미지를 구현하면서, 같은 기능을 두 번 갈아엎었습니다. 최종 코드는 깔끔하지만 여정이 구불구불했고, 돌아보면 그 구불구불함이 거의 같은 형태로 두 번 반복됐습니다.
| # | 버전 1 (첫 설계) | 버전 2 (최종) | 커밋 |
|---|---|---|---|
| 전환 A | opengraph-image.tsx (Satori 동적 생성) |
public/og-image.png (정적 PNG) |
ab8d5a9 → b29bea3 |
| 전환 B | CRAWLER_UA 정규식 (UA 기반 rewrite) |
모든 / 요청을 rewrite (universal) |
f950c92 → 1a58718 |
두 전환의 커밋 메시지가 무엇을 바꿨는지 직접 증언합니다.
b29bea3 fix: replace dynamic OG image with static PNG (Satori font issue)1a58718 fix: rewrite all root requests for consistent OG metadata— 본문: "크롤러 UA 매칭(CRAWLER_UA) 방식 제거 / 모든 / 요청을 locale 페이지로 rewrite하여 OG 태그 항상 포함"
두 전환의 공통점은 선명합니다. 첫 설계는 "더 많은 가능성을 연" 영리한 해법이고, 두 번째 설계는 "가능성의 일부를 포기한" 단순한 해법입니다. 포기한 가능성은 — 두 경우 모두 — 실제 요구사항에 존재하지 않는 것이었습니다. 그래서 두 번째가 이겼습니다.
이 글은 두 전환을 나란히 놓고, 같은 패턴이 두 영역에서 반복되는 이유를 분석합니다. 개별 기술 디테일(Satori 폰트 주입, redirect vs rewrite)은 이 시리즈의 다른 두 글에서 따로 다루므로, 여기서는 의사결정의 형태에 집중합니다.
2. 원인 분석
전환 A: Satori 동적 생성 → 정적 PNG
첫 설계: src/app/opengraph-image.tsx에서 JSX로 OG 이미지를 렌더링하는 Satori 기반 동적 생성. 101줄짜리 JSX 컴포넌트로, 그라디언트 배경, 아이콘, 제목, 부제, 메트릭 숫자까지 프로그래밍적으로 배치합니다.
// 첫 설계의 일부
export default function Image() {
return new ImageResponse(
(
<div style={{ fontFamily: 'system-ui, sans-serif', /* ... */ }}>
<div style={{ fontSize: '64px' }}>
귀찮음을 코드로
<br />
해결하는 개발자
</div>
{/* ... 아이콘, 메트릭, 부제 ... */}
</div>
),
{ ...size }
);
}
이 설계가 "연" 가능성
- 페이지마다 다른 OG 이미지를 자동 생성할 수 있음
- JSX를 수정해 디자인을 코드로 버전 관리할 수 있음
- 동적으로 제목/설명을 주입해 SNS별 맞춤 이미지를 만들 수 있음
이 설계의 운영 비용
- Satori는 OS 폰트를 읽지 않음 → 한글 폰트를
fetch로 주입해야 함 - Edge runtime에서 폰트 파일 크기(Noto Sans KR 가변 폰트 ~11MB)는 응답 레이턴시를 늘림
- 로컬
next dev와 배포된 Edge runtime의 동작 차이로 디버깅이 어려움 - 커밋 메시지가 남긴 기록:
"Satori font issue"
포트폴리오가 실제로 필요로 한 것
- 단 하나의 OG 이미지 (메인 페이지 공유용)
- 디자인은 이미 Figma에서 완성됨
- 페이지별 동적 생성 수요는 없음 (블로그도 아니고, 제품 페이지도 아님)
즉 첫 설계가 "연" 가능성 세 가지 중 어느 것도 실제 요구사항이 아니었습니다. 대신 운영 비용만 실체로 남았습니다. 이 불균형이 전환의 이유입니다. 커밋 b29bea3은 101줄의 opengraph-image.tsx를 전부 삭제하고 public/og-image.png 한 파일로 대체했습니다.
전환 B: 크롤러 UA 감지 → 모든 요청 rewrite
OG 이미지가 정적 PNG로 자리잡은 뒤, 미들웨어 쪽에서 새 문제가 드러났습니다. next-intl 기본 설정이 루트 경로(/)에 307 redirect를 응답하는 바람에, OG 크롤러가 메타 태그 없는 응답을 받는 상황이었습니다.
첫 설계 (f950c92): User-Agent 정규식으로 크롤러를 감지해서, 그 경우에만 rewrite로 전환.
// 첫 설계
const CRAWLER_UA = /kakaotalk|facebookexternalhit|twitterbot|slackbot|linkedinbot|discordbot|telegrambot|whatsapp|googlebot|bingbot|yandexbot/i;
export default function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const ua = request.headers.get('user-agent') || '';
if (pathname === '/' && CRAWLER_UA.test(ua)) { // ← 특별 케이스
const lang = request.headers.get('accept-language')?.includes('ko') ? 'ko' : 'en';
return NextResponse.rewrite(new URL(`/${lang}`, request.url));
}
return intlMiddleware(request);
}
이 설계가 "연" 가능성
- 일반 사용자는 기존대로
/ko,/en으로 redirect되어 URL에 locale이 명시됨 - 크롤러만 선별적으로 다른 경로를 탐
- 두 층위의 행동을 분리해 유지할 수 있음
이 설계의 숨은 비용
CRAWLER_UA정규식 리스트는 폐쇄 집합이 아님 — 플랫폼이 UA를 바꾸거나 새 SNS가 등장할 때마다 추가해야 함- OG 검증 도구(opengraph.xyz, metatags.io 등)는 독자적 UA를 쓰는 경우가 많아 UA 리스트로 잡히지 않음 — 이 도구에서 테스트하면 여전히 307 redirect를 받음
- 리스트가 하나 빠질 때마다 미묘한 "가끔 안 되는 버그"가 생김 — 디버깅 비용이 매번 발생
포트폴리오가 실제로 필요로 한 것
- 루트 경로에서 OG 태그가 포함된 HTML을 반환할 것
- 사용자 경험상
/와/ko의 URL 구분이 필수인가? — 검토 결과 아님. 내용이 동일하고 언어는 쿠키/Accept-Language로 판단
두 번째 설계 (1a58718): UA 감지를 제거하고, 모든 / 요청을 rewrite로 통일. NEXT_LOCALE 쿠키 우선, accept-language 폴백.
// 두 번째 설계
export default function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/') {
const cookie = request.cookies.get('NEXT_LOCALE')?.value;
const validLocale = cookie === 'ko' || cookie === 'en';
const lang = validLocale
? cookie
: request.headers.get('accept-language')?.includes('ko') ? 'ko' : 'en';
return NextResponse.rewrite(new URL(`/${lang}`, request.url));
}
return intlMiddleware(request);
}
첫 설계가 "연" 가능성(두 층위의 분리)은 실제 요구사항이 아니었고, 숨은 비용(리스트 불완전성, 검증 도구 미지원, 버그 재발)은 계속 발생하는 형태였습니다. 대체한 두 번째 설계는 한 축의 표현력을 잃었지만(URL에서 locale이 사라짐), 그 표현력은 실제로 쓸 곳이 없었습니다.
공통 패턴
두 전환을 나란히 놓으면 구조가 같습니다.
| 측면 | 전환 A | 전환 B |
|---|---|---|
| 첫 설계가 "연" 가능성 | 페이지별 동적 OG | 크롤러와 일반 사용자의 행동 분리 |
| 해당 가능성이 실제 요구됐나 | ❌ 단일 OG면 충분 | ❌ URL에서 locale 명시가 필수는 아님 |
| 첫 설계의 숨은 비용 | 폰트 로딩, Edge 레이턴시, 디버깅 | UA 리스트 유지, 검증 도구 미커버 |
| 두 번째 설계의 철학 | 정적으로 미리 만들어 둔다 | 모두에게 같은 답을 준다 |
| 두 번째 설계가 잃은 것 | 페이지별 커스텀 가능성 | URL의 명시적 locale |
| 잃은 것이 실제 아쉬웠나 | ❌ | ❌ |
핵심은 첫 설계가 제공한 유연성이 실제 요구사항에 존재하지 않았다는 점입니다. 존재하지 않는 요구사항을 위해 비용을 지불한 셈이고, 그 비용을 회수하는 방법이 리팩토링이었습니다.
3. 해결 방법
두 전환점의 최종 코드
전환 A 최종: 정적 PNG 참조
// src/app/[locale]/layout.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata' });
return {
title: t('title'),
description: t('description'),
openGraph: {
title: t('title'),
description: t('description'),
siteName: 'example.com',
locale: locale === 'ko' ? 'ko_KR' : 'en_US',
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: t('title'),
description: t('description'),
images: ['/og-image.png'],
},
};
}
opengraph-image.tsx 파일은 삭제됐고, 그 자리를 public/og-image.png(1200×630 PNG)가 대체합니다. 총 코드 변경량은 -101줄 + 이미지 파일 1개. 유지보수 비용은 "디자인 변경 시 PNG 재export" 하나뿐입니다.
전환 B 최종: 보편 rewrite
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from '@/i18n/routing';
import { NextRequest, NextResponse } from 'next/server';
const intlMiddleware = createMiddleware(routing);
export default function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/') {
const cookie = request.cookies.get('NEXT_LOCALE')?.value;
const validLocale = cookie === 'ko' || cookie === 'en';
const lang = validLocale
? cookie
: request.headers.get('accept-language')?.includes('ko') ? 'ko' : 'en';
return NextResponse.rewrite(new URL(`/${lang}`, request.url));
}
return intlMiddleware(request);
}
export const config = {
matcher: ['/', '/(ko|en)/:path*']
};
CRAWLER_UA 정규식이 사라졌고, UA 헤더 검사도 제거됐습니다. 모든 / 요청은 동일 경로를 탑니다.
결과 비교
| 지표 | 첫 설계 기준 | 두 번째 설계 기준 |
|---|---|---|
| 전환 A 총 코드 라인 | +101 (Satori JSX) | −101 + 정적 PNG 1개 |
| 전환 A 런타임 비용 | Edge 함수 실행 + 폰트 fetch | CDN에서 정적 파일 서빙 |
| 전환 A 유지보수 포인트 | 폰트 URL, Satori 동작, Edge 환경 | PNG 디자인 변경 시 재export |
| 전환 B 총 코드 라인 | +19 (UA 정규식 + 분기) | +13 (쿠키/헤더 분기) |
| 전환 B 유지보수 포인트 | 새 크롤러 추가 시 정규식 수정 | 없음 |
| 전환 B 신뢰성 | UA 리스트에 없으면 실패 | 모든 경우 성공 |
4. 핵심 개념 정리
"첫 설계를 버릴 가치가 있나"를 판단하는 기준
두 전환을 통해 정리된 의사결정 기준은 아래 세 가지입니다.
기준 1: 첫 설계가 연 유연성이 실제 쓰이는가
| 유연성 | 실제 사용 시점 | 판정 |
|---|---|---|
| 페이지별 동적 OG | 블로그 글 페이지, 상품 페이지 등에서 쓰임 | 포트폴리오는 ❌ |
| 크롤러/사용자 행동 분리 | URL 구조가 비즈니스 중요 지표인 경우 | 포트폴리오는 ❌ |
실제 사용 시점을 찾지 못하면, 그 유연성은 부채입니다. "나중에 필요할 수도 있으니까"는 유지보수 비용을 현재에 부과하고 이득은 미래로 미룹니다.
기준 2: 특별 케이스의 집합이 폐쇄적인가
특별 케이스로 관리하는 로직(예: UA 감지)이 안정적이려면 케이스의 집합이 정의 가능하고 유한하며 잘 변하지 않아야 합니다.
| 특별 케이스 예시 | 집합 성격 | 판정 |
|---|---|---|
관리자 권한 체크 (role === 'admin') |
폐쇄, 유한, 안정 | ✅ 특별 케이스 OK |
| 신용카드 3DS 필요 여부 | 폐쇄 (카드사 규약) | ✅ 특별 케이스 OK |
| 크롤러 UA 감지 | 개방, 무한, 계속 바뀜 | ❌ 보편 답 필요 |
| 사용자 브라우저 감지 | 개방, 무한, 계속 바뀜 | ❌ 보편 답 필요 |
기준 3: 모두에게 같은 답을 주는 경로가 존재하는가
존재한다면, 특별 케이스 없이도 문제를 해결할 수 있는지 먼저 점검합니다. 전환 B에서는 "모든 / 요청을 rewrite"가 바로 그 경로였습니다. 전환 A에서는 "모든 페이지에 같은 정적 PNG를 제공"이 그 경로였습니다.
"정적으로 시작하는" 기본값
설계 선택지가 생겼을 때의 기본값(default)을 바꾸는 것도 교훈입니다.
- 기존 기본값: "동적 생성이 가능하니까 일단 동적으로"
- 교정된 기본값: "정적으로 충분한지 먼저 확인. 충분하지 않다면 그때 동적으로"
두 번째 기본값은 필요 이상의 가능성을 열지 않는 원칙입니다. 가능성은 공짜가 아니라 유지보수 비용으로 청구됩니다.
5. 베스트 프랙티스
첫 설계 전에 자문해볼 질문 5개
- [ ] 내가 선택하려는 구현이 여는 유연성이 구체적으로 무엇인가? (단어로 적어보자)
- [ ] 그 유연성이 실제 요구사항에 존재하는가? 가상 시나리오가 아니라, 지금 존재하는 페이지/사용자/기능 중 어디서 쓰이는가?
- [ ] 이 구현이 특별 케이스 리스트에 의존한다면, 그 리스트는 폐쇄적인가? 누가 그 리스트를 유지하는가?
- [ ] 모두에게 같은 답을 주는 경로는 없는가? 있다면 왜 그걸 쓰지 않는가?
- [ ] 정적 버전으로 먼저 시작할 수 있는가? 정적이 불충분하다는 증거가 있는가?
리팩토링 판단 신호
첫 설계를 유지해도 좋은 신호
- 여는 유연성이 현재 여러 페이지/기능에서 실제로 쓰임
- 특별 케이스 집합이 폐쇄적이고 거의 안 변함
- 유지보수 포인트가 명확하고 적음
첫 설계를 버려야 할 신호
- "언젠가 필요할 수도 있으니까"라는 언어로 정당화됨
- 관련 도구/환경의 엣지 케이스로 디버깅이 반복됨 (폰트, UA, 브라우저 호환성 등)
- 같은 커밋 영역에서 여러 번 버그 수정 커밋이 쌓임 — 근본 설계를 의심할 때
커밋 이력을 리팩토링 신호로 읽기
이 프로젝트의 OG 관련 커밋은 아래와 같은 클러스터 형태로 나타났습니다.
ab8d5a9 feat: add dynamic OG image for link sharing
7b3796f fix: add OG image to metadata for link sharing
b29bea3 fix: replace dynamic OG image with static PNG (Satori font issue) ← 전환 A
7bcb770 fix: improve OG image with better contrast and layout
f950c92 fix: rewrite crawlers to locale page for OG image support
1a58718 fix: rewrite all root requests for consistent OG metadata ← 전환 B
같은 파일에 fix: 커밋이 3개 이상 쌓이면 설계를 의심할 타이밍입니다. 이 프로젝트에서는 opengraph-image.tsx에 두 개의 fix:(7b3796f, b29bea3) 후 삭제, middleware.ts에 두 개의 fix:(f950c92, 1a58718) 후 재구성이 일어났습니다. 세 번째 fix:가 나오기 전에 한 번 멈춰서 "이 설계를 교체할 만큼 큰 문제인가"를 질문하는 습관이 도움이 됩니다.
6. FAQ
Q: 처음부터 단순한 답(정적 PNG / 보편 rewrite)을 고를 수는 없었나요?
A: 이론상 가능합니다. 다만 "정적으로 충분한가"를 단호히 판단하려면 요구사항을 바닥까지 검토해야 하는데, 다른 작업과 병행되는 시점에는 그 검토가 짧아지기 쉽습니다. 두 번째 설계를 구현하는 과정 자체가 "첫 설계의 유연성이 실제로 필요한가"를 검증하는 절차라고 볼 수 있습니다. 이 글이 제안하는 실천은 **"첫 설계 구현 전에 단순한 버전을 벤치마크로 먼저 만들어보자"**입니다.
Q: 특별 케이스 로직이 전부 나쁜가요?
A: 아닙니다. 특별 케이스 집합이 폐쇄적이고 안정적일 때는 특별 케이스가 오히려 적합합니다. 예를 들어 "관리자만 접근 가능"이나 "PRO 플랜만 사용 가능" 같은 비즈니스 규칙은 집합이 잘 정의돼 있어 특별 케이스가 자연스럽습니다. 반면 "모든 크롤러", "모든 구형 브라우저"처럼 외부에서 생성되고 계속 변하는 집합은 특별 케이스로 관리하기 부적합합니다. 판단 기준은 집합의 폐쇄성과 변동성입니다.
Q: Satori 동적 생성이 쓸모 있는 경우는 언제인가요?
A: 페이지마다 다른 OG 이미지가 실제로 필요한 경우입니다. 예: 블로그 글 제목을 이미지로 렌더링, 상품 상세 페이지의 가격/재고 표시, 사용자 프로필 카드. 이 경우엔 CJK 폰트 주입과 Edge runtime 제약을 감수할 가치가 있습니다. 판단 기준은 "이 유연성이 실제로 쓰이는가?" 하나뿐입니다.
Q: 두 전환 사이에 공통 원인이 있나요?
A: 네. 두 문제 모두 **"브라우저 UX"가 아니라 "크롤러/빌드 환경"**이라는 평소 다루는 런타임과 다른 영역에서 발생했습니다. 낯선 런타임일수록 첫 설계가 기존 직관에 기대 오버엔지니어링될 가능성이 커집니다. 이런 영역에서는 정적/단순한 옵션을 특히 먼저 고려할 가치가 있습니다.
Q: 리팩토링 타이밍을 어떻게 알아차리나요?
A: 가장 구체적 신호는 같은 파일/함수에 fix: 커밋이 3개 이상 쌓이는 것입니다. 이 시점이면 개별 버그를 고치는 대신 설계를 의심할 때입니다. 또 다른 신호는 **"특별 케이스를 추가해야 고쳐지는 버그"**가 반복되는 경우로, 리스트 관리의 근본적 불가능성을 드러냅니다. 이 둘을 동시에 관찰하면 거의 확실한 리팩토링 타이밍입니다.
7. 참고 자료
- Satori 공식 저장소 — Vercel의 JSX → SVG 렌더러. 오버엔지니어링의 기술적 배경 확인용
- Next.js:
opengraph-image.tsx파일 컨벤션 — 동적 OG 이미지 공식 가이드 - next-intl 미들웨어 문서 —
localePrefix옵션 및 기본 동작 - NextResponse API Reference —
redirectvsrewrite - The Open Graph Protocol — OG 메타 태그 명세
8. 다음 단계
이 글은 의사결정 패턴에 집중했습니다. 두 전환의 기술적 디테일은 시리즈의 나머지 두 글에서 별도로 다룹니다.
- Satori에서 한글이 깨지는 정확한 이유와
fonts배열 주입법 → 1편 - next-intl 미들웨어의 redirect/rewrite 차이와
NEXT_LOCALE쿠키 우선순위 → 2편
시리즈 목차:
- Next.js opengraph-image에서 한글이 깨지는 이유: Satori 폰트 로딩 완벽 가이드
- Next.js i18n 미들웨어에서 OG 이미지가 안 보이는 이유: redirect vs rewrite 완벽 가이드
- OG 이미지 구현의 두 전환점: 단순한 답이 이긴 두 번의 리팩토링 ← 현재 글