Cloudflare Pages Functions로 브라우저 언어에 맞춰 자동 이동하는 방법

정적 다국어 사이트에서 외국 방문자가 모국어 페이지로 바로 가도록, Cloudflare Pages Functions 미들웨어로 Accept-Language와 언어 선택 쿠키를 읽어 번역 페이지로 302 이동시키는 방법을 정리합니다. 검색엔진 봇은 이동시키지 않습니다.

정적 다국어 사이트에서 언어 자동 이동이 필요했던 이유

이 블로그는 한국어 원문을 기본 주소(/<글주소>)에 두고, 영어·일본어 번역본을 /en/<글주소>, /ja/<글주소>에 둡니다. 모든 페이지를 빌드할 때 HTML 파일로 미리 만들어 Cloudflare Pages에 올리는 정적 사이트입니다.

문제는 검색이나 공유 링크로 들어오는 주소가 대부분 한국어 주소라는 점이었습니다. 미국이나 일본 방문자도 한국어 페이지부터 보게 되고, 번역본이 있다는 걸 모르면 그냥 나가 버립니다.

보통은 서버가 요청을 받을 때 브라우저 언어를 보고 알맞은 페이지를 내려주면 됩니다. 하지만 정적 사이트는 이미 만들어진 파일을 그대로 내려줄 뿐이라, 요청마다 무언가를 판단할 서버가 없습니다.

그래서 Cloudflare Pages Functions를 썼습니다. Pages Functions는 정적 파일을 내려주기 전에 요청을 가로채서 짧은 코드를 실행할 수 있게 해 주는 기능입니다. functions/_middleware.ts 파일을 만들면 사이트 전체 요청 앞에서 실행되는 미들웨어(요청과 응답 사이에 끼어드는 코드)가 됩니다1.

어떤 언어로 보낼지 정하는 순서

먼저 규칙을 정했습니다. 방문자를 마음대로 옮기면 오히려 불편할 수 있어서, "확실할 때만, 번역본이 있을 때만" 옮기는 것이 원칙입니다.

  1. 사이트의 언어 선택 메뉴로 직접 고른 언어가 있으면 그 언어를 따릅니다. 한국어를 골랐으면 옮기지 않습니다.
  2. 직접 고른 적이 없으면 브라우저가 보내는 Accept-Language 헤더를 봅니다. 이 헤더에는 브라우저 설정의 선호 언어 목록이 en-US,en;q=0.9처럼 우선순위(q 값)와 함께 들어 있습니다.
  3. 선호 언어 중 사이트가 지원하는 언어(한국어·영어·일본어)가 없으면 영어로 보냅니다.
  4. 옮길 언어의 번역 페이지가 실제로 있을 때만 옮깁니다.

그리고 다음 요청은 아예 옮기지 않습니다.

  • 검색엔진 봇: 봇까지 옮기면 한국어 원문이 검색엔진에 색인되지 않을 수 있습니다.
  • Accept-Language가 없는 요청: 언어를 알 수 없으므로 원래 페이지를 그대로 보여줍니다.
  • 이미 /en, /ja 아래인 주소: 사용자가 번역 페이지를 보고 있는 중입니다.

언어 선택 메뉴는 고른 언어를 lang 쿠키에 1년 동안 저장합니다. src/components/language-select.tsx의 코드입니다.

document.cookie = `${LOCALE_COOKIE}=${target}; path=/; max-age=31536000; samesite=lax`;

functions/_middleware.ts 전체 코드

실제로 사용 중인 functions/_middleware.ts입니다. LOCALES(ko, en, ja), localePath(언어에 맞는 주소를 만드는 함수) 같은 값은 사이트 코드(src/lib/i18n.ts)와 함께 씁니다.

// Cloudflare Pages Function: 한국어 주소로 처음 들어온 방문자를 브라우저 언어(Accept-Language)에 맞는 /en·/ja 페이지로 보낸다.
// - 언어 select로 고른 언어(lang 쿠키)가 있으면 브라우저 언어보다 그 선택을 따른다(한국어를 골랐으면 이동하지 않는다).
// - 검색엔진 봇과 Accept-Language가 없는 요청은 이동시키지 않는다(한국어 원문이 색인되도록).
// - 이동할 번역 페이지가 없으면 한국어 페이지를 그대로 보여준다. 한국어 페이지도 없으면(404) 그 언어의 404 페이지를 보여준다.
// 정적 자산은 public/_routes.json에서 제외해 이 함수가 실행되지 않는다.
import {DEFAULT_LOCALE, LOCALE_COOKIE, LOCALES, Locale, isLocale, localePath} from "../src/lib/i18n";

type PagesContext = {
  request: Request;
  next: () => Promise<Response>;
  env: { ASSETS: { fetch: (input: Request | string | URL) => Promise<Response> } };
};

const BOT_UA = /bot|crawl|spider|slurp|yeti|daum|bingpreview|facebookexternalhit|embedly|preview/i;
// 지원하지 않는 언어만 받는 방문자는 영어로 보낸다.
const FALLBACK_LOCALE: Locale = 'en';

function cookieLocale(request: Request): Locale | null {
  const cookie = request.headers.get('Cookie') ?? '';
  for (const c of cookie.split(';')) {
    const [name, value] = c.trim().split('=');
    if (name === LOCALE_COOKIE && isLocale(value ?? '')) return value as Locale;
  }
  return null;
}

function preferredLocale(acceptLanguage: string | null): Locale | null {
  if (!acceptLanguage) return null;
  const langs = acceptLanguage.split(',')
    .map((part, index) => {
      const [tag, ...params] = part.trim().split(';');
      const q = params.map(p => p.trim()).find(p => p.startsWith('q='));
      return {lang: tag.trim().toLowerCase().split('-')[0], q: q ? parseFloat(q.slice(2)) : 1, index};
    })
    .filter(l => l.lang && l.lang !== '*' && l.q > 0)
    .sort((a, b) => b.q - a.q || a.index - b.index);
  if (langs.length === 0) return null;
  return langs.map(l => l.lang).find(isLocale) ?? FALLBACK_LOCALE;
}

export const onRequest = async ({request, next, env}: PagesContext): Promise<Response> => {
  if (request.method !== 'GET') return next();

  const url = new URL(request.url);
  const path = url.pathname;
  const otherLocales = LOCALES.filter(l => l !== DEFAULT_LOCALE);
  if (otherLocales.some(l => path === `/${l}` || path.startsWith(`/${l}/`))) return next();
  if (/\.[a-z0-9]+$/i.test(path)) return next();
  if (BOT_UA.test(request.headers.get('User-Agent') ?? '')) return next();

  const target = cookieLocale(request) ?? preferredLocale(request.headers.get('Accept-Language'));
  if (!target || target === DEFAULT_LOCALE) return next();

  const targetPath = localePath(target, path);
  const exists = await env.ASSETS.fetch(new URL(targetPath, url.origin));
  if (!exists.ok) {
    const response = await next();
    if (response.status !== 404) return response;
    const notFound = await env.ASSETS.fetch(new URL(localePath(target, '/404'), url.origin));
    return notFound.ok ? new Response(notFound.body, {status: 404, headers: notFound.headers}) : response;
  }

  return new Response(null, {
    status: 302,
    headers: {
      'Location': `${targetPath}${url.search}`,
      'Vary': 'Accept-Language, Cookie',
      'Cache-Control': 'private, no-store',
    },
  });
};

강조한 부분을 차례로 보겠습니다.

Accept-Language를 우선순위대로 읽기

preferredLocale 함수는 Accept-Language 값을 쉼표로 나누고, 각 언어의 q 값을 읽어 높은 순서로 정렬합니다. q가 같으면 적힌 순서를 따릅니다. en-US처럼 지역이 붙은 값은 앞부분(en)만 씁니다.

정렬한 목록에서 사이트가 지원하는 첫 언어를 고르고, 하나도 없으면 영어(FALLBACK_LOCALE)를 고릅니다. 예를 들어 프랑스어만 쓰는 방문자는 영어 페이지로 갑니다.

옮기지 않을 요청 먼저 걸러내기

onRequest 앞부분에서 다음 요청을 바로 next()로 넘깁니다. next()는 "원래 하려던 대로 정적 파일을 내려주라"는 뜻입니다.

  • GET이 아닌 요청
  • 이미 /en, /ja 아래인 주소
  • .png, .xml처럼 확장자가 있는 파일 주소
  • User-Agent(요청한 프로그램 이름)에 bot, crawl, spider 같은 단어가 들어간 요청

번역 페이지가 있는지 확인한 뒤 302로 이동

옮길 언어가 정해지면 env.ASSETS.fetch로 번역 페이지 주소의 정적 파일을 직접 요청해 봅니다. 응답이 정상이면 번역본이 있다는 뜻이므로 302 응답으로 그 주소로 보냅니다.

번역본이 없으면 한국어 페이지를 그대로 보여줍니다. 한국어 페이지마저 없으면(없는 주소) 그 방문자의 언어로 된 404 페이지를 보여줍니다.

302와 캐시 헤더를 쓰는 이유

이동 응답에는 세 가지를 신경 썼습니다.

  • 302(임시 이동): 같은 주소라도 방문자마다 가야 할 곳이 다릅니다. 301·308 같은 영구 이동을 쓰면 브라우저와 검색엔진이 "이 주소는 영원히 저쪽"이라고 기억해 버립니다.
  • Vary: Accept-Language, Cookie: 응답이 이 두 헤더에 따라 달라진다고 알려 줍니다. 그래야 중간 캐시가 영어 방문자용 응답을 한국어 방문자에게 그대로 주는 일이 생기지 않습니다.
  • Cache-Control: private, no-store: 이동 응답 자체를 아예 캐시하지 않게 합니다. 방문자가 나중에 언어 선택을 바꾸면 바로 반영되어야 하기 때문입니다.

_routes.json으로 함수가 실행될 주소 줄이기

미들웨어는 기본적으로 모든 요청에서 실행됩니다. 그런데 이미지·폰트·스크립트나 이미 번역 페이지인 /en, /ja 주소는 언어를 판단할 필요가 없습니다. 이런 요청까지 함수를 실행하면 응답만 느려지고 호출 횟수도 늘어납니다.

그래서 public/_routes.json에서 함수를 실행하지 않을 주소를 뺐습니다. exclude에 들어간 주소는 함수가 실행되지 않고, 함수 호출 요금도 붙지 않습니다2.

{
  "version": 1,
  "include": ["/*"],
  "exclude": [
    "/assets/*",
    "/images/*",
    "/fonts/*",
    "/en",
    "/en/*",
    "/ja",
    "/ja/*",
    "/*.xml",
    "/*.txt",
    "/*.png",
    "/*.ico",
    "/*.svg",
    "/*.webmanifest",
    "/*.json",
    "/*.data"
  ]
}

curl로 언어별 이동 확인하기

배포한 뒤 curl로 운영 사이트에 여러 조건의 요청을 보내 확인했습니다. 이때 curl -I는 쓰면 안 됩니다. -I는 HEAD 요청을 보내는데, 미들웨어가 GET이 아닌 요청은 그냥 넘기기 때문에 항상 200이 나옵니다. 저도 처음에 이것 때문에 미들웨어가 동작하지 않는 줄 알았습니다. 대신 -s -o /dev/null -D -로 GET 요청을 보내고 응답 헤더만 출력합니다.

영어 브라우저는 영어 번역본으로 이동합니다.

$ curl -s -o /dev/null -D - -H 'Accept-Language: en-US,en;q=0.9' https://sncstudio.net/win-acme-validation-port
HTTP/2 302
vary: Accept-Language, Cookie
location: /en/win-acme-validation-port
cache-control: private, no-store

일본어 브라우저는 일본어로, 지원하지 않는 프랑스어 브라우저는 영어로 이동합니다.

$ curl -s -o /dev/null -D - -H 'Accept-Language: ja-JP,ja;q=0.9' https://sncstudio.net/win-acme-validation-port
HTTP/2 302
location: /ja/win-acme-validation-port

$ curl -s -o /dev/null -D - -H 'Accept-Language: fr-FR,fr;q=0.9' https://sncstudio.net/win-acme-validation-port
HTTP/2 302
location: /en/win-acme-validation-port

반대로 다음 경우에는 이동하지 않고 한국어 페이지(200)를 그대로 보여줍니다.

# 한국어가 1순위인 브라우저
$ curl -s -o /dev/null -D - -H 'Accept-Language: ko-KR,ko;q=0.9,en;q=0.8' https://sncstudio.net/win-acme-validation-port
HTTP/2 200

# 언어 선택 메뉴로 한국어를 고른 방문자 (lang 쿠키)
$ curl -s -o /dev/null -D - -H 'Accept-Language: en-US' -H 'Cookie: lang=ko' https://sncstudio.net/win-acme-validation-port
HTTP/2 200

# 검색엔진 봇
$ curl -s -o /dev/null -D - -H 'Accept-Language: en-US' -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' https://sncstudio.net/win-acme-validation-port
HTTP/2 200

없는 주소로 들어온 영어 방문자에게는 영어 404 페이지(<html lang="en">)가 나옵니다.

주의사항

  • hreflang과 함께 쓰세요. 봇은 옮기지 않으므로, 검색엔진이 번역본의 존재를 알려면 각 페이지에 <link rel="alternate" hreflang="en" ...> 같은 언어별 주소 표시가 있어야 합니다. 이 블로그는 모든 페이지에 ko·en·ja·x-default를 넣고 있습니다.
  • 언어를 바꿀 수 있는 메뉴는 꼭 두세요. 자동 이동이 틀릴 수도 있습니다. 한국어를 읽고 싶은 외국 브라우저 사용자도 있으니, 직접 고른 언어(쿠키)가 항상 우선해야 합니다.
  • 주소 형태를 통일해 두세요. 이동할 주소는 끝 슬래시가 없는 형태입니다. 주소 형태를 맞추는 방법은 Cloudflare Pages 끝 슬래시 리다이렉트 없애는 방법에 정리했습니다.
  • 로컬 확인은 bunx wrangler pages dev out으로 functions/까지 함께 실행해서 할 수 있습니다.

참조

Footnotes

  1. https://developers.cloudflare.com/pages/functions/middleware/ ↩

  2. https://developers.cloudflare.com/pages/functions/routing/ ↩

2026 © snc studio·개인정보처리방침