Next.js i18n 미들웨어에서 OG 이미지가 안 보이는 이유: redirect vs rewrite 완벽 가이드
다국어 사이트에서 루트 경로의 OG 이미지가 안 보이는 이유는 next-intl 미들웨어의 307 redirect 때문입니다. 크롤러 UA 감지 대신 모든 요청을 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-intl은 localePrefix: '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를 보냄
}
이 접근법에는 근본적인 문제가 있습니다:
- UA 목록은 항상 불완전합니다 — 새로운 플랫폼, 새로운 크롤러가 계속 등장합니다.
- OG 검증 도구는 크롤러 UA를 사용하지 않습니다 — opengraph.xyz, metatags.io 등에서 테스트하면 여전히 실패합니다.
- 고양이-쥐 게임입니다 — 하나를 추가하면 다른 하나가 빠집니다.
실제로 검증해보면:
# 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);
}
핵심 변경점은 세 가지입니다:
CRAWLER_UA정규식 제거 — 크롤러 감지에 의존하지 않음- 모든
/요청을 rewrite — UA와 무관하게 페이지 내용 반환 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-intl의 defineRouting에서 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. 참고 자료
- Next.js 공식 문서 — Internationalization — Next.js i18n 라우팅 가이드
- next-intl 공식 문서 — Proxy / middleware — next-intl 미들웨어 설정 및 localePrefix 옵션
- NextResponse API Reference —
redirect(),rewrite(),next()메서드 상세 - The Open Graph Protocol — OG 메타 태그 명세
- Facebook 공유 디버거 — OG 태그 검증 및 캐시 갱신 도구