Next.js에서의 IP 지오로케이션: 브라우저와 서버 패턴
Next.js는 동일한 페이지를 서버 또는 브라우저에서 렌더링할 수 있으며, 이 선택에 따라 실제로 방문자의 위치를 확인하는 Watch-IP 엔드포인트가 달라지거나 — 대신 서버의 위치를 확인하게 될 수도 있습니다.
시작하기 전에
- 클라이언트 패턴에는 공개 가능한 방문자용 키를, 서버 패턴에는 시크릿 조회 키를 사용합니다 — 인증 문서를 참고하세요.
- Next.js App Router 프로젝트(예제는 Next.js 16의 Server/Client Component 구분을 전제로 합니다).
- 서버 패턴에는 호스팅 플랫폼이 들어오는 요청에 방문자의 실제 IP를 담아 설정하는 헤더가 필요합니다(예: Cloudflare의 CF-Connecting-IP) — 클라이언트가 직접 설정할 수 있는 헤더는 사용하지 마세요.
1. 각 패턴이 실제로 호출하는 대상 이해하기
GET /v1/geo는 API에 도달한 연결의 주체를 식별합니다 — 방문자의 브라우저가 직접 호출한 경우라면 방문자를, 서버 렌더링 중에 자체 코드가 호출한 경우라면 Next.js 서버를 식별합니다. Server Component나 Route Handler에서 GET /v1/geo를 호출하면 방문자가 아니라 서버의 네트워크 위치가 확인되며, 이는 대개 원하는 결과가 아닙니다. 실제로 방문자를 확인하는 패턴은 두 가지입니다 — 브라우저에서 요청을 실행하거나(Client Component), 제공된 IP에 대해 GET /v1/lookup/{ip}로 서버에서 요청을 실행하는 것입니다.
2. Client Component: 브라우저에서 GET /v1/geo 호출하기
이는 JavaScript 가이드와 React 가이드에서 사용한 것과 동일한 요청입니다 — 공개 가능 키로 fetch하는 Client Component입니다. 브라우저 자체의 Origin 헤더가 키의 오리진 허용 목록 검사를 충족시키므로, 앱의 공개 URL이 해당 목록에 등록되어 있어야 합니다.
TypeScript (Client Component)
"use client";
import { useEffect, useState } from "react";
export function VisitorLocation() {
const [city, setCity] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch("https://api.watch-ip.com/v1/geo", {
headers: { "X-Api-Key": "YOUR_PUBLISHABLE_KEY" },
signal: controller.signal,
})
.then((res) => (res.ok ? res.json() : null))
.then((geo) => setCity(geo?.city ?? null))
.catch(() => {});
return () => controller.abort();
}, []);
return <span>{city ?? "이 지역"}</span>;
}3. Server Component 또는 Route Handler: 방문자의 IP를 명시적으로 조회하기
서버 렌더링 중 방문자의 위치를 확인하려면 GET /v1/geo가 아니라, 시크릿 키와 방문자의 실제 IP(호스팅 플랫폼이 들어오는 요청에 설정하는 헤더에서 읽음)로 GET /v1/lookup/{ip}를 호출하세요. 플랫폼이 엣지에서 값을 제거하고 다시 설정하지 않는 한, 클라이언트가 직접 설정할 수 있는 일반 X-Forwarded-For 값은 절대 신뢰하지 마세요.
TypeScript (Server Component)
import { headers } from "next/headers";
export async function VisitorLocationServer() {
const ip = (await headers()).get("cf-connecting-ip");
if (!ip) return null;
const response = await fetch(`https://api.watch-ip.com/v1/lookup/${ip}`, {
headers: { Authorization: "Bearer YOUR_SECRET_KEY" },
cache: "no-store",
});
if (!response.ok) return null;
const geo = await response.json();
return <span>{geo.city ?? "이 지역"}</span>;
}4. 한 방문자의 결과를 다른 방문자에게 캐시하지 마세요
위의 cache: 'no-store'는 의도적인 설정입니다 — 조회 결과는 요청한 방문자에게만 해당하는 값이기 때문입니다. 이 컴포넌트가 Next.js나 CDN이 캐시할 수 있는 라우트(정적 페이지, ISR 페이지, 공유 fetch 캐시) 안에서 렌더링되면, 캐시된 응답이 이후 모든 사용자에게 한 방문자의 city를 보여주게 됩니다. 개인화된 분기는 항상 동적으로 렌더링하고, 조회 응답을 페이지 수준이나 CDN 수준 캐시에 절대 포함시키지 마세요.
오류 및 예외 상황
IP 헤더가 없거나 비어 있으면(일부 로컬 또는 프록시 환경에서는 헤더를 설정하지 않음) 조회할 방문자 IP가 없다는 뜻이므로, 빈 문자열로 엔드포인트를 호출하지 말고 대체 화면을 렌더링하세요. GET /v1/lookup/{ip}에서 403이 발생하면 대개 시크릿 키가 필요한 라우트에 공개 가능 키를 사용한 경우이며, 400은 헤더에 유효한 IPv4/IPv6 주소가 아닌 값이 담겨 있었다는 뜻입니다. 오류 및 속도 제한 문서를 참고하세요.
다음 단계
Next.js뿐 아니라 클라이언트 전용 React 앱에서도 재사용할 수 있는 컴포넌트가 필요하다면 React 가이드를 참고하세요 — 위의 Client Component 패턴은 동일한 요청을 감싼 Next.js 전용 래퍼입니다.