OG 이미지 구현의 두 전환점: 단순한 답이 이긴 두 번의 리팩토링

OG 이미지를 구현하면서 같은 영역을 두 번 리팩토링했습니다. 두 번 모두 "영리해 보이는" 첫 설계를 버리고 "덜 특별한" 두 번째 설계를 택했고, 두 번 모두 그게 맞았습니다. 공통 패턴과 의사결정 기준을 정리합니다.

OG 이미지 구현의 두 전환점: 단순한 답이 이긴 두 번의 리팩토링

1. 문제 상황

다국어 포트폴리오 사이트에 OG 이미지를 구현하면서, 같은 기능을 두 번 갈아엎었습니다. 최종 코드는 깔끔하지만 여정이 구불구불했고, 돌아보면 그 구불구불함이 거의 같은 형태로 두 번 반복됐습니다.

# 버전 1 (첫 설계) 버전 2 (최종) 커밋
전환 A opengraph-image.tsx (Satori 동적 생성) public/og-image.png (정적 PNG) ab8d5a9b29bea3
전환 B CRAWLER_UA 정규식 (UA 기반 rewrite) 모든 / 요청을 rewrite (universal) f950c921a58718

두 전환의 커밋 메시지가 무엇을 바꿨는지 직접 증언합니다.

  • 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. 참고 자료


8. 다음 단계

이 글은 의사결정 패턴에 집중했습니다. 두 전환의 기술적 디테일은 시리즈의 나머지 두 글에서 별도로 다룹니다.

  • Satori에서 한글이 깨지는 정확한 이유와 fonts 배열 주입법 → 1편
  • next-intl 미들웨어의 redirect/rewrite 차이와 NEXT_LOCALE 쿠키 우선순위 → 2편

시리즈 목차:

  1. Next.js opengraph-image에서 한글이 깨지는 이유: Satori 폰트 로딩 완벽 가이드
  2. Next.js i18n 미들웨어에서 OG 이미지가 안 보이는 이유: redirect vs rewrite 완벽 가이드
  3. OG 이미지 구현의 두 전환점: 단순한 답이 이긴 두 번의 리팩토링 ← 현재 글