패션 브랜드나 커머스는 국가별로 도메인을 나눠 운영하는 경우가 많다. 한국은 .co.kr, 일본은 .co.jp, 나머지는 .com 같은 식이다.
한국 사용자가 일본 스토어로 들어왔을 때, 혹은 미국 사용자가 한국 스토어로 들어왔을 때 어떻게 해야 할까.
예전에는 IP를 보고 자기 나라 스토어로 그냥 튕겨버리는 302 리다이렉트를 많이 썼다. 구현은 제일 쉽지만 SEO와 UX 양쪽에서 대가가 크다.
강제 리다이렉트가 만드는 문제
검색 색인이 통째로 빠진다. Googlebot은 대부분 미국 IP 대역에서 요청한다. 한국이나 일본 스토어에 IP 기반 302가 걸려 있으면 크롤러는 각 국가 스토어의 상품 페이지에 닿지도 못하고 글로벌 스토어로 튕긴다.
사용자 의도를 무시한다. 특정 국가 한정 상품이나 룩북을 보려고 일부러 그 도메인을 찾아 들어온 경우가 있다.
CDN 캐시와 엉킨다. Edge에서 IP 기반 302 응답이 잘못 캐싱되면 멀쩡한 사용자까지 다른 국가로 보내진다.
이동은 사용자가
구글의 다국어 사이트 가이드라인도 자동 리다이렉트보다 콘텐츠는 그대로 두고 다른 버전을 안내하라는 쪽을 권한다. 크롤러는 페이지를 정상적으로 색인하고, 사용자는 원하면 이동한다.
여기에 hreflang을 같이 넣어주면 검색엔진이 국가별 버전의 관계를 이해한다. 안내 배너는 사용자용, hreflang은 크롤러용이라고 보면 된다.
// app/layout.tsx
export const metadata: Metadata = {
alternates: {
canonical: 'https://jp.example-store.com/',
languages: {
'ko-KR': 'https://kr.example-store.com/',
'ja-JP': 'https://jp.example-store.com/',
'x-default': 'https://www.example-store.com/'
}
}
};
국가 판별 결과에 따른 배너 노출 규칙
CloudFront-Viewer-Country 값과 현재 스토어를 조합하면 배너를 띄울지가 결정된다. 어떤 조합에서도 페이지 이동은 일어나지 않는다.
배너 노출
접속 국가 전용 스토어가 있다. 이동 선택지를 제시한다.
https://kr.example-store.com
Step 1. CloudFront가 국가 코드를 넣어준다
외부 Geo-IP API를 매 요청마다 호출하면 지연도 비용도 생긴다. CloudFront를 쓰고 있다면 자체 Geo-IP DB로 CloudFront-Viewer-Country 헤더를 붙여준다. KR, JP, US 같은 두 글자 코드다.
하나, 헤더는 자동으로 오리진까지 가지 않는다. 오리진 요청 정책(Origin request policy)이나 캐시 정책의 헤더 허용 목록에 CloudFront-Viewer-Country를 넣어야 오리진이 그 값을 받는다. 이걸 빼먹으면 서버에서는 계속 null만 보게 된다.
둘, 캐시 키를 정리해야 한다. 국가 정보를 내려주는 엔드포인트가 국가와 무관하게 캐싱되면, 맨 처음 요청한 사람의 국가가 전 세계에 그대로 재사용된다. 캐시 정책의 캐시 키에 헤더를 포함해 국가별로 캐시를 나누거나, 아예 캐시를 끄면 된다. 우리는 이 엔드포인트만 캐시를 끄고 갔다.
HTML 페이지 자체는 국가에 따라 달라지지 않게 두는 게 중요하다. 배너는 클라이언트에서 붙이므로 페이지 캐시가 국가별로 쪼개지지 않고, 크롤러가 받는 HTML도 사용자와 동일하다.
Step 2. Route Handler
CloudFront가 넘겨준 헤더를 읽어 그대로 돌려주는 엔드포인트다. 로컬이나 QA에서 확인할 수 있게 쿼리 오버라이드도 열어뒀다.
// app/api/geo/route.ts
import { NextResponse } from 'next/server';
// 헤더를 읽으므로 정적 최적화 대상에서 빼둔다
export const dynamic = 'force-dynamic';
const jsonNoStore = (country: string | null) => {
return NextResponse.json(
{ country },
{ headers: { 'Cache-Control': 'no-store, private' } }
);
};
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// QA와 로컬 확인용 (?country=KR)
const override = searchParams.get('country');
if (process.env.NODE_ENV !== 'production' && override) {
return jsonNoStore(override.toUpperCase());
}
// 오리진 요청 정책 허용 목록에 넣어야 도달한다
const country = request.headers.get('cloudfront-viewer-country');
return jsonNoStore(country ? country.toUpperCase() : null);
}
응답에 Cache-Control: no-store, private을 붙인 건 CloudFront뿐 아니라 브라우저나 중간 프록시가 다른 사람의 국가를 재사용하는 걸 막기 위해서다.
Step 3. 클라이언트에서 한 번만 조회
방문자의 국가는 세션 동안 바뀔 일이 없다. staleTime을 무한으로 두고 한 번만 가져온다.
// api/geo/types.ts
export type GeoResponse = {
country: string | null;
};
// api/geo/controller.ts
import type { GeoResponse } from './types';
const overrideParam = (): string => {
if (typeof window === 'undefined') {
return '';
}
const country = new URLSearchParams(window.location.search).get('country');
return country ? `?country=${encodeURIComponent(country)}` : '';
};
export const getGeoCountry = async (): Promise<GeoResponse> => {
const res = await fetch(`/api/geo${overrideParam()}`, { cache: 'no-store' });
if (!res.ok) {
throw new Error(`geo lookup failed: ${res.status}`);
}
return res.json();
};
// api/geo/queries.ts
import { queryOptions } from '@tanstack/react-query';
import { getGeoCountry } from './controller';
export function geoCountryQuery() {
return queryOptions({
queryKey: ['geo', 'country'] as const,
queryFn: getGeoCountry,
staleTime: Infinity,
gcTime: Infinity,
retry: 1
});
}
조회에 실패하면 data가 없으니 배너도 뜨지 않는다. 국가를 모르면 아무것도 안 하는 게 맞는 기본값이다.
Step 4. Floating 요소와 겹치는 문제
우측 하단에 고정돼 있던 Top 버튼이나 상담 버튼이 배너에 가려서 눌리지 않는다.
그래서 배너 높이를 ResizeObserver로 실제 측정해서 CSS 변수로 흘려보냈다.
미리보기의 문구 길이 바꾸기를 눌러 배너를 두 줄로 만들어 보고, CSS에서 Floating 버튼의 bottom을 calc(12px + var(--geo-notice-offset))으로 바꿔가며 확인할 수 있다.
// components/geo/GeoStoreNotice.tsx
'use client';
import { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { geoCountryQuery } from '~/api/geo/queries';
import { useSessionDismissed } from '~/hooks/useSessionDismissed';
import { storeInfo } from '~/utils/storeInfo';
const OFFSET_VARIABLE = '--geo-notice-offset';
const TARGET_STORES: Record<string, { name: string; url: string }> = {
KR: { name: '한국 공식 스토어', url: 'https://kr.example-store.com' },
JP: { name: '日本公式オンラインストア', url: 'https://jp.example-store.com' }
};
const GLOBAL_STORE = { name: 'Global Store', url: 'https://www.example-store.com' };
export default function GeoStoreNotice() {
const { data } = useQuery(geoCountryQuery());
const { isDismissed, dismiss } = useSessionDismissed('geo_store_notice_dismissed');
const noticeRef = useRef<HTMLElement>(null);
const detectedCountry = data?.country ?? null;
const isForeign = detectedCountry !== null && detectedCountry !== storeInfo.countryCode;
// 전용 스토어가 없는 국가는 글로벌 스토어로 폴백한다
const targetStore = detectedCountry ? (TARGET_STORES[detectedCountry] ?? GLOBAL_STORE) : null;
const isVisible = isForeign && targetStore !== null && !isDismissed;
useEffect(() => {
const root = document.documentElement;
const element = noticeRef.current;
if (!isVisible || element === null) {
root.style.setProperty(OFFSET_VARIABLE, '0px');
return;
}
let frame = 0;
const observer = new ResizeObserver((entries) => {
// rAF로 한 번 미뤄 ResizeObserver loop 경고를 피한다
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const entry = entries[0];
if (entry === undefined) {
return;
}
const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
root.style.setProperty(OFFSET_VARIABLE, `${Math.round(height)}px`);
});
});
observer.observe(element);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
root.style.setProperty(OFFSET_VARIABLE, '0px');
};
}, [isVisible]);
if (!isVisible || targetStore === null) {
return null;
}
return (
<aside
ref={noticeRef}
role="region"
aria-label="국가별 스토어 안내"
className="fixed inset-x-0 bottom-0 z-40 flex items-center justify-between gap-3 bg-black px-4 py-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] text-white">
<p className="text-sm font-medium">{targetStore.name}로 이동하여 쇼핑하시겠습니까?</p>
<div className="flex items-center gap-3">
<a href={targetStore.url} className="bg-white px-3 py-1.5 text-xs font-bold text-black">
이동하기
</a>
<button type="button" onClick={dismiss} aria-label="안내 닫기" className="text-sm text-gray-400">
닫기
</button>
</div>
</aside>
);
}
닫기 상태는 useState가 아니라 세션에 저장한다. 이 훅은 앞선 글에서 만든 useSessionDismissed를 그대로 썼다.
배너 자체를 position: fixed로 띄우고, 본문 흐름을 밀지 않으므로 배너가 나타나거나 사라져도 레이아웃 이동이 생기지 않는다.
Floating 버튼 쪽은 CSS만 바꾸면 된다.
/* global.css */
:root {
--geo-notice-offset: 0px;
}
.floating-action-button {
position: fixed;
right: 24px;
/* 기본 여백 + 배너 높이 + iOS 하단 안전 영역 */
bottom: calc(24px + var(--geo-notice-offset) + env(safe-area-inset-bottom, 0px));
z-index: 50;
transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@media (prefers-reduced-motion: reduce) {
.floating-action-button {
transition: none;
}
}
남은 주의사항
국가 판별은 틀릴 수 있다. VPN이나 회사 프록시를 타면 엉뚱한 국가가 잡힌다. 그래서 판별 결과는 안내까지만 쓰고 이동은 항상 사용자가 결정하게 뒀다.
헤더가 없을 때의 기본값은 "아무것도 안 함"이다. 로컬 개발, 허용 목록 누락, CloudFront를 거치지 않는 경로에서는 값이 null이 된다.
크롤러도 배너를 본다. 다만 배너는 클라이언트에서 붙는 부가 요소일 뿐이고 본문 HTML은 그대로라 색인에는 영향이 없다.
정리
| 항목 | 302 강제 리다이렉트 | CloudFront 헤더 + 안내 배너 |
|---|---|---|
| 검색 색인 | 크롤러가 원본 페이지에 닿지 못함 | 페이지 그대로 색인 |
| 사용자 경험 | 의도와 무관하게 이동 | 탐색은 유지, 이동은 선택 |
| 국가 판별 비용 | 외부 API 호출 시 지연 발생 | CDN이 붙여준 헤더를 그대로 사용 |
| 잘못 판별했을 때 | 빠져나올 방법이 없음 | 배너를 닫으면 끝 |
| 캐시 | 국가별 응답이 섞일 위험 | 페이지 캐시는 국가와 무관하게 유지 |
- 국가별 도메인을 운영한다면 강제 이동보다 안내가 낫다고 생각했다. 색인과 이탈률 양쪽에서 손해가 적다.
- CloudFront를 쓴다면 국가 코드는 이미 헤더에 있다. 다만 허용 목록과 캐시 키를 확인해야 한다.
- 하단 고정 배너는 기존 Floating UI와 부딪힌다. 높이를 상수로 박지 말고
ResizeObserver로 재서 CSS 변수로 넘기면 문구가 바뀌어도 알아서 따라온다.