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 태그 검증 및 캐시 갱신 도구