Next.js i18n 미들웨어에서 OG 이미지가 안 보이는 이유: redirect vs rewrite 완벽 가이드

다국어 사이트에서 루트 경로의 OG 이미지가 안 보이는 이유는 next-intl 미들웨어의 307 redirect 때문입니다. 크롤러 UA 감지 대신 모든 요청을 rewrite하여 해결하는 방법을 다룹니다.

Next.js i18n 미들웨어에서 OG 이미지가 안 보이는 이유: redirect vs rewrite 완벽 가이드

1. 문제 상황

다국어 지원(i18n) 포트폴리오 사이트를 배포한 뒤, 카카오톡이나 슬랙에 링크를 공유하면 이상한 현상이 발생했습니다.

  • example.com/en → OG 이미지 정상 출력 ✅
  • example.com/ko → OG 이미지 정상 출력 ✅
  • example.com → OG 이미지 안 나옴

사용자들이 공유하는 URL은 대부분 루트 경로(example.com)입니다. 정작 가장 많이 공유되는 URL에서 미리보기가 깨진 셈이죠.

증상 확인

curl로 각 경로의 응답을 확인해보면 문제가 명확해집니다.

# 루트 경로 응답 확인
curl -s -o /dev/null -w "%{http_code} %{redirect_url}" https://example.com/
# 결과: 307 https://example.com/ko
# 루트 경로의 실제 응답 본문
curl -s -D - https://example.com/ | head -15
HTTP/2 307
location: /ko
set-cookie: NEXT_LOCALE=ko; Path=/; SameSite=lax

Redirecting...

응답 본문이 Redirecting... 한 줄입니다. og:title, og:image, og:description — 아무것도 없습니다.

반면 locale 경로는 정상입니다:

# locale 경로는 OG 태그가 정상 출력
curl -s https://example.com/ko | grep -i 'og:image'
# <meta property="og:image" content="https://example.com/og-image.png"/>

2. 원인 분석

next-intl 미들웨어의 기본 동작

문제의 핵심은 next-intl 미들웨어의 기본 라우팅 전략에 있습니다. next-intllocalePrefix: 'always'가 기본값이어서, locale 접두사가 없는 경로(/)에 접근하면 307 redirect를 보냅니다.

사용자 요청: example.com/
                ↓
next-intl 미들웨어: "locale 접두사가 없네? → 307 redirect → /ko"
                ↓
브라우저: /ko로 이동 → 페이지 정상 렌더링

일반 브라우저는 307 redirect를 자동으로 따라가므로 사용자에게는 문제가 없어 보입니다. 하지만 OG 크롤러는 다릅니다.

OG 크롤러가 redirect를 처리하는 방식

소셜 미디어 플랫폼이 링크 미리보기를 생성할 때, 각 플랫폼의 크롤러가 URL을 방문하여 HTML에서 OG 메타 태그를 추출합니다. 문제는 이 크롤러들의 redirect 처리 방식이 제각각이라는 점입니다.

크롤러 redirect 추적 비고
Facebook (facebookexternalhit) ⚠️ 대부분 따라가지만 일관적이지 않음
Twitter (Twitterbot) ⚠️ 307은 따라가나 캐시 이슈 있음
KakaoTalk ⚠️ 버전에 따라 다름
Slack (Slackbot) ⚠️ 일반적으로 따라감
OG 검증 도구 (opengraph.xyz 등) redirect 응답 자체를 분석
일반 curl/wget -L 플래그 없으면 따라가지 않음

핵심은 307 redirect 응답 자체에는 OG 메타 태그가 없다는 것입니다. 응답 본문이 Redirecting...이니까요. 크롤러가 redirect를 따라가든 안 따라가든, 이 구조 자체가 불안정합니다.

첫 번째 시도: 크롤러 UA 감지 (불완전한 해결)

이 문제를 인식하고 처음 시도한 접근법은 크롤러 User-Agent를 감지하여 rewrite하는 것이었습니다.

// ❌ 불완전한 해결: 크롤러만 선별적으로 rewrite
import createMiddleware from 'next-intl/middleware';
import { routing } from '@/i18n/routing';
import { NextRequest, NextResponse } from 'next/server';

const intlMiddleware = createMiddleware(routing);

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') || '';

  // 크롤러만 rewrite, 나머지는 redirect
  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);  // ← 이게 307 redirect를 보냄
}

이 접근법에는 근본적인 문제가 있습니다:

  1. UA 목록은 항상 불완전합니다 — 새로운 플랫폼, 새로운 크롤러가 계속 등장합니다.
  2. OG 검증 도구는 크롤러 UA를 사용하지 않습니다 — opengraph.xyz, metatags.io 등에서 테스트하면 여전히 실패합니다.
  3. 고양이-쥐 게임입니다 — 하나를 추가하면 다른 하나가 빠집니다.

실제로 검증해보면:

# Facebook 크롤러 UA로 요청 → rewrite → OG 태그 있음 ✅
curl -s -A "facebookexternalhit/1.1" https://example.com/ | grep 'og:image'
# <meta property="og:image" content="https://example.com/og-image.png"/>

# 일반 UA로 요청 → redirect → OG 태그 없음 ❌
curl -s https://example.com/ | grep 'og:image'
# (출력 없음)

목록에 있는 크롤러만 작동하고, 나머지는 여전히 307 redirect를 받습니다.

redirect vs rewrite: 핵심 차이

여기서 NextResponse.redirect()NextResponse.rewrite()의 차이를 정확히 이해해야 합니다.

# redirect (307)
요청: example.com/
응답: HTTP 307 → Location: /ko
본문: "Redirecting..."              ← OG 태그 없음
브라우저: URL이 /ko로 변경됨

# rewrite
요청: example.com/
응답: HTTP 200 → /ko 페이지 내용을 그대로 반환
본문: <html>...<meta property="og:image">...</html>  ← OG 태그 있음!
브라우저: URL은 / 그대로, 내용은 /ko
구분 redirect rewrite
HTTP 상태 코드 307 (Temporary Redirect) 200 (OK)
URL 변경 변경됨 (//ko) 변경 안 됨 (/ 유지)
응답 본문 Redirecting... 실제 페이지 HTML
OG 메타 태그 ❌ 없음 ✅ 포함
크롤러 호환성 불안정 완벽

3. 해결 방법

최종 해결: 모든 루트 요청을 rewrite

크롤러 UA 감지를 완전히 제거하고, 모든 루트 요청을 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 === '/') {
    // 1. NEXT_LOCALE 쿠키 확인 (사용자가 이전에 선택한 언어)
    const cookie = request.cookies.get('NEXT_LOCALE')?.value;
    const validLocale = cookie === 'ko' || cookie === 'en';

    // 2. 쿠키 없으면 Accept-Language 헤더로 폴백
    const lang = validLocale
      ? cookie
      : request.headers.get('accept-language')?.includes('ko') ? 'ko' : 'en';

    // 3. redirect가 아닌 rewrite! ← 핵심 변경점
    return NextResponse.rewrite(new URL(`/${lang}`, request.url));
  }

  // 다른 경로는 next-intl 미들웨어가 처리
  return intlMiddleware(request);
}

export const config = {
  matcher: ['/', '/(ko|en)/:path*']
};

Before/After 비교

Before (크롤러 UA 감지):

// ❌ 크롤러만 선별적으로 처리
const CRAWLER_UA = /kakaotalk|facebookexternalhit|twitterbot|.../i;

export default function middleware(request: NextRequest) {
  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);  // ← 307 redirect
}

After (모든 요청 rewrite):

// ✅ 모든 루트 요청을 rewrite
export default function middleware(request: NextRequest) {
  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));  // ← 항상 rewrite
  }

  return intlMiddleware(request);
}

핵심 변경점은 세 가지입니다:

  1. CRAWLER_UA 정규식 제거 — 크롤러 감지에 의존하지 않음
  2. 모든 / 요청을 rewrite — UA와 무관하게 페이지 내용 반환
  3. NEXT_LOCALE 쿠키 존중 — 사용자가 이전에 선택한 언어 유지

언어 감지 우선순위

rewrite 시 어떤 locale 페이지를 보여줄지 결정하는 우선순위입니다:

1. NEXT_LOCALE 쿠키 (사용자가 이전에 /en 또는 /ko를 방문하면 설정됨)
   ↓ 없으면
2. Accept-Language 헤더 (브라우저 언어 설정)
   ↓ 'ko' 포함 여부로 판단
3. 기본값: 'en'
// 쿠키 확인
const cookie = request.cookies.get('NEXT_LOCALE')?.value;
const validLocale = cookie === 'ko' || cookie === 'en';  // ← 유효한 locale인지 검증

// 쿠키가 없거나 유효하지 않으면 Accept-Language로 폴백
const lang = validLocale
  ? cookie
  : request.headers.get('accept-language')?.includes('ko') ? 'ko' : 'en';

NEXT_LOCALE 쿠키는 next-intl 미들웨어가 자동으로 설정합니다. 사용자가 /en을 방문하면 NEXT_LOCALE=en 쿠키가 설정되고, 이후 루트 경로 방문 시 영어 페이지가 표시됩니다.

4. 핵심 개념 정리

OG 메타 태그가 작동하는 조건

OG 크롤러가 메타 태그를 정상적으로 읽으려면 다음 조건이 모두 충족되어야 합니다:

조건 설명 확인 방법
HTTP 200 응답 redirect가 아닌 직접 응답 curl -s -o /dev/null -w "%{http_code}" URL
HTML 본문에 OG 태그 존재 <meta property="og:image" .../> curl -s URL | grep og:image
OG 이미지 URL 접근 가능 이미지 자체가 200을 반환 curl -sI 이미지URL | head -5
이미지 크기 적합 권장: 1200x630px 이미지 파일 확인
Content-Type 정확 image/png, image/jpeg curl -sI 이미지URL | grep content-type

Next.js 미들웨어 응답 메서드 비교

// 1. redirect: 브라우저에게 "다른 URL로 가라"고 알림
NextResponse.redirect(new URL('/ko', request.url))
// → HTTP 307, Location: /ko, 본문: "Redirecting..."

// 2. rewrite: 내부적으로 다른 경로를 렌더링하되 URL은 유지
NextResponse.rewrite(new URL('/ko', request.url))
// → HTTP 200, URL은 / 그대로, 본문: /ko 페이지 전체 HTML

// 3. next: 미들웨어를 통과시키고 원래 경로로 계속 진행
NextResponse.next()
// → 원래 요청 경로로 라우팅 계속

next-intl localePrefix 옵션

next-intldefineRouting에서 localePrefix 설정에 따라 루트 경로 동작이 달라집니다:

// routing.ts
export const routing = defineRouting({
  locales: ['ko', 'en'],
  defaultLocale: 'ko',
  // localePrefix: 'always'  ← 기본값
});
localePrefix / 경로 동작 비고
'always' (기본) → 307 redirect → /ko 모든 경로에 locale 접두사 필요
'as-needed' /에서 기본 locale 직접 렌더링 /ko/로 redirect됨
'never' locale 접두사 없이 동작 URL에 locale 없음

localePrefix: 'as-needed'를 사용하면 미들웨어 커스터마이징 없이도 OG 문제가 해결될 수 있습니다. 하지만 URL 구조가 바뀌므로 (예: /ko/로 redirect됨), 기존 구조를 유지하면서 해결하려면 미들웨어 rewrite 방식이 더 적합합니다.

5. 베스트 프랙티스

i18n + OG 이미지 체크리스트

  • [ ] 루트 경로(/)에서 curl로 HTTP 200 응답이 오는지 확인하세요.
  • [ ] 루트 경로 응답 본문에 og:image 메타 태그가 포함되어 있는지 확인하세요.
  • [ ] OG 이미지 URL이 절대 경로(https://example.com/og-image.png)로 해석되는지 확인하세요.
  • [ ] OG 이미지 파일 자체가 200 응답을 반환하는지 확인하세요.
  • [ ] 크롤러 UA 감지에 의존하지 말고, 모든 요청에 대해 OG 태그가 포함된 HTML을 반환하세요.
  • [ ] NEXT_LOCALE 쿠키를 존중하여 사용자 언어 선호를 유지하세요.
  • [ ] 배포 후 반드시 OG 검증 도구(opengraph.xyz, Facebook 공유 디버거)로 테스트하세요.

OG 이미지 디버깅 3단계

# Step 1: HTTP 응답 코드 확인
curl -s -o /dev/null -w "%{http_code} %{redirect_url}" https://example.com/
# 기대값: "200 " (redirect URL 없음)

# Step 2: OG 메타 태그 확인
curl -s https://example.com/ | grep -i 'og:image'
# 기대값: <meta property="og:image" content="https://example.com/og-image.png"/>

# Step 3: OG 이미지 파일 접근 확인
curl -sI https://example.com/og-image.png | head -5
# 기대값: HTTP/2 200, content-type: image/png

미들웨어에서 redirect 대신 rewrite를 써야 하는 경우

// ✅ rewrite가 적합한 경우
// - SEO/OG 크롤러가 접근하는 경로
// - URL을 깨끗하게 유지하고 싶은 경우
// - 내부 라우팅 로직을 숨기고 싶은 경우
return NextResponse.rewrite(new URL(`/${lang}`, request.url));

// ✅ redirect가 적합한 경우
// - 사용자가 URL 변경을 인지해야 하는 경우
// - 영구적으로 이전된 페이지 (301)
// - 인증 실패 시 로그인 페이지로 이동
return NextResponse.redirect(new URL('/login', request.url));

OG 이미지: 동적 생성 vs 정적 파일

이 프로젝트에서는 처음에 Satori를 사용한 동적 OG 이미지 생성을 시도했지만, 한글 폰트 로딩 문제로 정적 PNG 파일로 전환했습니다.

// 접근법 1: Satori로 동적 생성 (한글 폰트 이슈 주의)
// src/app/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export default async function Image() {
  // 한글 폰트를 fetch로 로드해야 함
  const fontData = await fetch(
    new URL('../../public/fonts/NotoSansKR-Bold.ttf', import.meta.url)
  ).then(res => res.arrayBuffer());  // ← Vercel 빌드 시 실패 가능

  return new ImageResponse(/* ... */, {
    fonts: [{ name: 'NotoSansKR', data: fontData }]
  });
}
// 접근법 2: 정적 PNG 파일 (권장 — 안정적)
// public/og-image.png (1200x630px)
// layout.tsx의 metadata에서 참조
export async function generateMetadata(): Promise<Metadata> {
  return {
    openGraph: {
      images: [{ url: '/og-image.png', width: 1200, height: 630 }],  // ← 단순 명확
    },
  };
}
구분 Satori 동적 생성 정적 PNG
한글/CJK 지원 ⚠️ 폰트 로딩 필요 ✅ 이미 렌더링됨
페이지별 커스텀 ❌ (전체 동일)
빌드 안정성 ⚠️ 환경 의존적 ✅ 항상 동일
성능 요청마다 생성 즉시 반환 (CDN 캐시)

포트폴리오처럼 단일 OG 이미지면 충분한 경우, 정적 PNG가 가장 안정적입니다. 블로그처럼 페이지마다 다른 이미지가 필요하면 Satori를 사용하되, CJK 폰트 처리에 주의하세요.

6. FAQ

Q: localePrefix: 'as-needed'로 설정하면 미들웨어 수정 없이도 해결되나요?

A: 네, 부분적으로 해결됩니다. localePrefix: 'as-needed'를 사용하면 루트 경로(/)에서 기본 locale의 페이지가 직접 렌더링되므로 OG 태그가 포함됩니다. 다만 URL 구조가 바뀌어서 /ko/로 redirect되는 등 기존 링크에 영향을 줄 수 있습니다. 기존 URL 구조를 유지하면서 해결하려면 미들웨어 rewrite 방식이 더 안전합니다.

Q: 크롤러 UA 감지를 완전히 제거해도 괜찮나요?

A: 이 케이스에서는 그렇습니다. 모든 루트 요청을 rewrite하면 크롤러든 일반 사용자든 동일하게 OG 태그가 포함된 HTML을 받습니다. 오히려 더 안정적입니다. 단, 크롤러에게만 다른 콘텐츠를 보여줘야 하는 특수한 경우(클로킹 주의)가 아니라면 UA 감지 제거가 맞습니다.

Q: rewrite하면 사용자 경험이 바뀌나요?

A: 네, 하나 바뀝니다. 기존에는 example.com/에 접속하면 URL이 example.com/ko로 바뀌었는데, rewrite 후에는 URL이 example.com/으로 유지됩니다. 콘텐츠는 동일하며, 오히려 더 깔끔한 URL을 보여주게 됩니다. 언어 전환 링크(/en, /ko)는 정상 작동합니다.

Q: NEXT_LOCALE 쿠키는 누가 설정하나요?

A: next-intl 미들웨어가 자동으로 설정합니다. 사용자가 /en이나 /ko를 방문하면 Set-Cookie: NEXT_LOCALE=en 또는 NEXT_LOCALE=ko가 응답에 포함됩니다. 루트 경로(/)는 intlMiddleware를 거치지 않으므로 쿠키가 설정되지 않지만, 이전에 locale 경로를 방문한 적이 있다면 쿠키가 남아 있어 다음 방문 시 해당 언어로 표시됩니다.

Q: Facebook 공유 디버거에서 캐시된 OG 정보를 갱신하려면 어떻게 하나요?

A: Facebook 공유 디버거(developers.facebook.com/tools/debug)에서 URL을 입력하고 "다시 스크랩" 버튼을 클릭하면 됩니다. 카카오톡은 developers.kakao.com/tool/clear/og에서 캐시를 초기화할 수 있습니다. 배포 직후에는 이전 캐시가 남아 있을 수 있으므로 반드시 캐시 초기화 후 테스트하세요.

7. 참고 자료