본문으로 건너뛰기

Next.js Middleware rewrite로 인증 서비스 OG 크롤링 정상화

·11 min read

인증이 필요한 서비스는 보통 세션이 없는 사용자를 로그인 페이지로 보낸다. 그런데 카카오톡, 슬랙, 페이스북 같은 서비스가 링크 미리보기를 만들 때도 세션 없이 우리 페이지에 접근한다. 이때 서버가 크롤러를 로그인 페이지로 리다이렉트하면, 미리보기에는 우리 서비스가 아니라 로그인 페이지의 OG 정보가 잡힌다.

링크 미리보기에 로그인 페이지가 잡혔다

사용자가 카카오톡에 아래 링크를 공유했다고 하자.

https://service.example.com

카카오톡은 링크 미리보기를 만들기 위해 서버에 직접 요청한다.

GET https://service.example.com
User-Agent: KakaoTalk
Cookie: 없음

하지만 우리 서비스는 인증이 필요하다. 세션 쿠키가 없으면 Next.js Middleware가 로그인 페이지로 보낸다.

결과적으로 크롤러는 service.example.com의 OG가 아니라 SSO 로그인 페이지를 읽게 됐다.

OG 크롤러는 로그인하지 않는다

OG는 Open Graph 메타데이터다. 링크를 공유했을 때 제목, 설명, 이미지를 보여주기 위해 HTML의 <head>에 들어가는 메타 태그다.

<meta property="og:title" content="Acme AI Portal">
<meta property="og:description" content="사내 AI 업무 포털">
<meta property="og:image" content="https://service.example.com/images/new_og.png">
<meta property="og:url" content="https://service.example.com">

OG 크롤러는 브라우저처럼 로그인하지 않는다. 대부분의 크롤러는 세션 쿠키 없이 HTML을 요청하고, 그 HTML 안의 메타 태그만 읽는다.

Redirect와 Rewrite의 차이

이 문제를 이해하려면 redirectrewrite를 구분해야 했다.

Redirect

redirect는 서버가 클라이언트에게 다른 URL로 다시 요청하라고 알려주는 방식이다.

redirect의 특징은 이렇다.

  • 클라이언트가 새 URL로 다시 요청한다.
  • 브라우저 주소창이 바뀐다.
  • 네트워크 탭에 요청이 2개 이상 보인다.
  • OG 크롤러도 redirect를 따라가면 로그인 페이지를 읽게 된다.

Rewrite

rewrite는 서버 내부에서만 처리 경로를 바꾸는 방식이다.

rewrite의 특징은 이렇다.

  • 클라이언트는 다른 URL로 이동하지 않는다.
  • 주소창은 원래 URL 그대로다.
  • 서버 내부에서만 route가 바뀐다.
  • 크롤러는 https://service.example.com에 대한 응답으로 OG HTML을 받는다.

인프라 관점의 요청 흐름

Next.js 앱이 Vercel 같은 플랫폼에 배포되어 있다면 요청은 대략 아래 흐름을 탄다.

redirect는 Middleware 단계에서 응답을 끝낼 수 있다. rewrite는 Middleware가 요청의 내부 목적지만 바꾸고 다음 route로 넘긴다.

인증은 지키되 OG만 열어주기

목표는 명확했다. 실제 앱은 계속 인증으로 보호하되 공개 가능한 OG 메타데이터만 크롤러에게 제공하는 것이다.

크롤러 요청은 어떻게 감지할까

보통 User-Agent 헤더로 감지한다.

const OG_CRAWLER_USER_AGENT_PATTERNS = [
  /facebookexternalhit/i,
  /facebot/i,
  /twitterbot/i,
  /slackbot/i,
  /discordbot/i,
  /linkedinbot/i,
  /telegrambot/i,
  /whatsapp/i,
  /kakaotalk/i,
  /kakaotalk-scrap/i,
]

그리고 요청 조건을 함께 봤다.

function isOpenGraphCrawler(request: NextRequest): boolean {
  if (request.method !== 'GET' && request.method !== 'HEAD') {
    return false
  }
 
  const accept = request.headers.get('accept')
  const acceptsHtml =
    !accept || accept.includes('text/html') || accept.includes('*/*')
  if (!acceptsHtml) {
    return false
  }
 
  const userAgent = request.headers.get('user-agent') ?? ''
  return OG_CRAWLER_USER_AGENT_PATTERNS.some((pattern) =>
    pattern.test(userAgent),
  )
}

조건은 세 가지다.

  1. GET 또는 HEAD 요청이어야 한다.
  2. HTML을 받을 수 있는 요청이어야 한다.
  3. User-Agent가 알려진 링크 미리보기 크롤러와 매칭되어야 한다.

User-Agent는 위조할 수 있다. 그래서 이 방식으로 인증을 우회시키면 안 된다. 여기서는 공개 가능한 OG HTML만 반환하기 때문에 괜찮다.

Next.js Middleware 구현

미들웨어에서는 크롤러 요청을 먼저 처리했다. 세션 검사보다 먼저 해야 한다. 그래야 세션이 없는 크롤러가 SSO로 redirect되지 않는다.

import { NextRequest, NextResponse } from 'next/server'
 
const SESSION_COOKIE = 'SESSION'
const OG_PREVIEW_PATH = '/og-preview'
const OG_PREVIEW_REWRITE_HEADER = 'x-daisy-og-preview-rewrite'
 
export async function middleware(request: NextRequest) {
  const next = () => NextResponse.next()
 
  if (request.nextUrl.pathname === OG_PREVIEW_PATH) {
    return next()
  }
 
  if (isOpenGraphCrawler(request)) {
    const url = request.nextUrl.clone()
    url.pathname = OG_PREVIEW_PATH
    url.searchParams.set(
      'url',
      `${request.nextUrl.pathname}${request.nextUrl.search}`,
    )
 
    const headers = new Headers(request.headers)
    headers.set(OG_PREVIEW_REWRITE_HEADER, '1')
 
    return NextResponse.rewrite(url, {
      request: {
        headers,
      },
    })
  }
 
  const session = request.cookies.get(SESSION_COOKIE)?.value
  if (session) {
    return next()
  }
 
  return NextResponse.redirect('https://sso.example.com/login')
}

여기서 중요한 부분은 이 코드다.

return NextResponse.rewrite(url, {
  request: {
    headers,
  },
})

원래 URL은 /이지만 내부적으로 /og-preview route가 처리하게 된다. 그리고 x-daisy-og-preview-rewrite 헤더를 추가한다.

왜 내부 헤더를 붙일까

사용자가 직접 아래 URL로 접근할 수도 있다.

https://service.example.com/og-preview

이 페이지가 민감한 정보를 반환하지는 않지만, 내부용 route라면 직접 접근을 막는 편이 더 깔끔하다.

그래서 Middleware가 rewrite할 때만 내부 헤더를 붙인다. route handler는 이 헤더가 없으면 404를 반환한다.

OG 전용 Route Handler

/og-preview route는 실제 앱 UI를 렌더링하지 않는다. 크롤러가 필요한 최소 HTML만 반환한다.

import { NextRequest } from 'next/server'
 
const OG_PREVIEW_REWRITE_HEADER = 'x-daisy-og-preview-rewrite'
const OG_TITLE = 'Acme AI Portal'
const OG_DESCRIPTION = '사내 AI 업무 포털'
const OG_IMAGE_PATH = '/images/new_og.png'
 
export function GET(request: NextRequest) {
  if (!isMiddlewareRewrite(request)) {
    return new Response(null, { status: 404 })
  }
 
  return new Response(renderOgHtml(request), {
    headers: {
      'cache-control': 'public, max-age=300, s-maxage=3600',
      'content-type': 'text/html; charset=utf-8',
    },
  })
}
 
function isMiddlewareRewrite(request: NextRequest): boolean {
  return request.headers.get(OG_PREVIEW_REWRITE_HEADER) === '1'
}

HTML은 단순하게 구성했다.

function renderOgHtml(request: NextRequest): string {
  const origin = 'https://service.example.com'
  const imageUrl = new URL(OG_IMAGE_PATH, origin).toString()
 
  return `<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>${OG_TITLE}</title>
  <meta name="description" content="${OG_DESCRIPTION}">
  <meta property="og:type" content="website">
  <meta property="og:locale" content="ko">
  <meta property="og:site_name" content="${OG_TITLE}">
  <meta property="og:title" content="${OG_TITLE}">
  <meta property="og:description" content="${OG_DESCRIPTION}">
  <meta property="og:image" content="${imageUrl}">
  <meta property="og:image:width" content="600">
  <meta property="og:image:height" content="315">
  <meta name="twitter:card" content="summary_large_image">
</head>
<body></body>
</html>`
}

실제 코드에서는 escapeHtml() 같은 처리를 넣어 HTML attribute injection을 방지하는 게 좋다.

검증 방법

일반 요청은 로그인으로 redirect되어야 한다.

curl -I https://service.example.com

예상 결과다.

HTTP/2 307
location: https://sso.example.com/login

크롤러 요청은 200으로 OG HTML을 받아야 한다.

curl -I \
  -A facebookexternalhit/1.1 \
  -H 'Accept: text/html' \
  https://service.example.com

예상 결과다.

HTTP/2 200
content-type: text/html; charset=utf-8

본문 확인은 이렇게 했다.

curl -sS \
  -A facebookexternalhit/1.1 \
  -H 'Accept: text/html' \
  https://service.example.com

직접 /og-preview 접근은 404가 나와야 한다.

curl -I https://service.example.com/og-preview

예상 결과다.

HTTP/2 404

실무 체크리스트

  • 크롤러 분기는 세션 검사보다 먼저 둔다.
  • 크롤러 요청에는 redirect가 아니라 rewrite를 사용한다.
  • /og-preview는 공개 가능한 메타데이터만 반환한다.
  • 직접 접근을 숨기고 싶다면 내부 rewrite 헤더를 확인한다.
  • og:image는 절대 URL이어야 한다.
  • og:image 파일 경로는 인증 미들웨어에서 제외되어야 한다.
  • 카카오톡, 슬랙, 페이스북은 캐시가 강하므로 배포 후 캐시 갱신 도구로 재확인한다.

인증이 필요한 서비스에서 OG가 깨지는 이유는 대부분 크롤러도 일반 미인증 사용자처럼 로그인 페이지로 redirect되기 때문이다. 이 문제는 인증 정책을 풀지 않고도 해결할 수 있었다. 일반 사용자는 로그인 페이지로 redirect하고, OG 크롤러는 내부 OG route로 rewrite했다. redirect는 클라이언트에게 새 URL로 다시 요청하게 만들고, rewrite는 서버 내부에서만 처리 경로를 바꾼다. 링크 미리보기 크롤러가 원래 URL의 OG를 읽어야 하는 상황에서는 rewrite가 더 적합했다.