주변 가게를 찾는 기능에서는 위치 검색과 인증을 함께 다루게 된다. PostGIS는 거리 계산과 공간 조건을 DB에서 처리하고, Supabase는 그 SQL 함수를 RPC로 노출한다. Next.js에서는 요청의 인증 쿠키를 읽고 갱신하며, 실제 데이터 접근 권한은 API와 DB에서 검사한다.
이 글의 예제는 Next.js 15 App Router, @supabase/ssr 0.7의 쿠키 인터페이스, PostGIS 3.x를 기준으로 한다. 공간 검색과 로그인 처리를 각각 완성한 뒤 연결하는 순서로 정리한다.
geometry(Point, 4326)는 WGS 84 좌표의 점이다. ST_MakePoint에 넣는 순서는 경도, 위도다. 4326 geometry의 거리 단위는 도(degree)이므로, 미터 단위 반경 검색에서는 geography로 변환해 계산한다.
이때 측지선 거리는 도로 이동 거리나 예상 도착 시간이 아니다. “반경 5km”와 “차로 5km”는 다르다. 도로 경로가 필요하면 공간 검색으로 후보를 좁힌 뒤 경로 엔진을 별도로 사용한다.
아래는 새로운 데모 테이블을 만드는 SQL이다. 기존 stores에 그대로 적용하는 마이그레이션은 아니다. PostGIS가 이미 다른 스키마에 설치되어 있다면 그 스키마를 확인하고 검색 경로를 맞춘다.
CREATE EXTENSION IF NOT EXISTS postgis;
SET search_path = public, extensions;
CREATE TABLE public.demo_stores (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
position_lat double precision NOT NULL
CHECK (position_lat BETWEEN -90 AND 90),
position_lng double precision NOT NULL
CHECK (position_lng BETWEEN -180 AND 180),
geom geometry(Point, 4326) GENERATED ALWAYS AS (
ST_SetSRID(ST_MakePoint(position_lng, position_lat), 4326)
) STORED
);
CREATE INDEX demo_stores_geography_gist
ON public.demo_stores USING gist ((geom::geography));
위도·경도가 필수인 테이블로 범위를 한정했다. 생성 열을 사용하므로 좌표가 바뀌면 geom도 계산되고, 애플리케이션이 별도로 공간 열을 갱신할 필요가 없다. 위치가 없는 가게도 저장해야 한다면 두 좌표의 동시 NULL 허용 정책부터 정의한다.
기존 테이블에서 일반 열과 트리거를 유지한다면, 새 행의 트리거만 추가하고 끝내면 안 된다. 기존 좌표의 유효성을 검사하고 기존 행의 geom도 backfill해야 한다. SQL 주석은 -- 또는 /* ... */를 사용한다. 마이그레이션 중 쓰기와 backfill이 충돌하지 않도록 순서와 잠금·배치 크기도 검토한다.
인덱스는 쿼리가 사용하는 geom::geography 표현식에 맞췄다. gist(geom)를 만들었다고 해서 geography로 캐스팅한 거리 조건까지 같은 인덱스를 사용하는 것은 아니다. PostgreSQL 표현식 인덱스
반경은 미터 단위로 0 초과, 50km 이하로 제한하고 결과는 가까운 50개까지 반환한다. 제한값은 이 예제의 서비스 정책이며 PostGIS의 한계가 아니다.
CREATE OR REPLACE FUNCTION public.demo_stores_within_radius(
lat double precision,
lng double precision,
radius_meters double precision
)
RETURNS TABLE (
id bigint,
name text,
distance_meters double precision
)
LANGUAGE plpgsql
STABLE
SECURITY INVOKER
SET search_path = public, extensions
AS $$
DECLARE
center geography;
BEGIN
IF lat IS NULL OR NOT (lat BETWEEN -90 AND 90)
OR lng IS NULL OR NOT (lng BETWEEN -180 AND 180)
OR radius_meters IS NULL
OR NOT (radius_meters > 0 AND radius_meters <= 50000) THEN
RAISE EXCEPTION 'invalid coordinates or radius'
USING ERRCODE = '22023';
END IF;
center := ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography;
RETURN QUERY
SELECT s.id, s.name,
ST_Distance(s.geom::geography, center) AS distance_meters
FROM public.demo_stores AS s
WHERE ST_DWithin(s.geom::geography, center, radius_meters)
ORDER BY distance_meters, s.id
LIMIT 50;
END;
$$;
RETURNS TABLE과 SELECT 열의 순서·타입을 맞췄다. SELECT s.*는 테이블에 열을 추가하는 순간 함수 반환 정의와 어긋날 수 있어 사용하지 않는다. 유효성 검사는 NULL과 범위 밖 값뿐 아니라 PostgreSQL의 NaN·Infinity 입력도 거절한다.
ST_DWithin으로 후보를 걸러낸 뒤 ST_Distance로 거리와 정렬 기준을 계산한다. 인덱스가 있어도 작은 테이블이나 넓은 검색 범위에서는 순차 스캔을 선택할 수 있다. 함수 이름만 보고 인덱스 사용을 단정하지 않고 실행 계획을 확인한다. PostGIS 공간 인덱스 안내
이 예제는 로그인한 사용자가 모든 데모 가게의 공개 위치를 읽는 정책이다. 점주 전용 정보나 사용자별 비공개 위치가 있다면 그대로 사용하지 말고 행 조건과 노출 열을 바꿔야 한다.
ALTER TABLE public.demo_stores ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.demo_stores FROM anon, authenticated;
GRANT SELECT ON public.demo_stores TO authenticated;
CREATE POLICY demo_store_read ON public.demo_stores
FOR SELECT TO authenticated USING (true);
REVOKE ALL ON FUNCTION public.demo_stores_within_radius(
double precision, double precision, double precision
) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.demo_stores_within_radius(
double precision, double precision, double precision
) TO authenticated;
anon과 authenticated는 Supabase가 제공하는 역할이다. 일반 PostgreSQL에서는 별도로 정의되어 있지 않다. 함수는 호출자 권한으로 실행하는 SECURITY INVOKER를 유지한다. SQL Editor의 관리자 계정으로 성공했다는 사실만으로 사용자 RLS가 검증되지는 않는다. 실제 사용자 JWT를 사용한 호출도 확인한다.
// 로그인한 사용자 컨텍스트의 supabase 클라이언트로 호출
const { data, error } = await supabase.rpc('demo_stores_within_radius', {
lat: 37.5665,
lng: 126.9780,
radius_meters: 5000,
});
if (error) throw new Error('주변 가게를 불러오지 못했습니다.');
RPC로 SQL을 캡슐화하는 것 자체가 별도 BFF 계층을 만든다는 뜻은 아니다. 브라우저에서 직접 호출한다면 공개 키, 사용자 JWT, DB 권한과 RLS가 접근 경계를 구성한다. service_role 같은 관리자 키는 브라우저에 넣지 않는다.
getSession()은 세션을 읽는 데 유용하지만, 서버가 요청 쿠키에서 읽은 사용자 객체를 그대로 신뢰하는 인증 검사로 사용하면 안 된다. 아래는 Auth 서버에 확인하는 getUser()를 사용한다. Supabase getSession 주의사항
인증 쿠키를 갱신하면 이후 서버 코드가 읽을 요청 쿠키와 브라우저에 보낼 응답 쿠키를 모두 반영해야 한다. 다음 유틸리티는 갱신 쿠키를 모아 정상 응답·리다이렉트·오류 응답에 적용한다.
// lib/supabase/request.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export function createRequestContext(request: NextRequest) {
const pending = new Map<string, {
name: string; value: string; options: CookieOptions;
}>();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll(values) {
for (const cookie of values) {
request.cookies.set(cookie.name, cookie.value);
pending.set(cookie.name, cookie);
}
},
},
},
);
function finish(response: NextResponse) {
for (const { name, value, options } of pending.values()) {
response.cookies.set(name, value, options);
}
response.headers.set('Cache-Control', 'private, no-store');
return response;
}
return { supabase, finish };
}
// middleware.ts — Next.js 15
import { NextResponse, type NextRequest } from 'next/server';
import { createRequestContext } from './lib/supabase/request';
export async function middleware(request: NextRequest) {
const { supabase, finish } = createRequestContext(request);
const { data: { user }, error } = await supabase.auth.getUser();
const path = request.nextUrl.pathname;
const protectedPath = ['/profile', '/settings'].some(
base => path === base || path.startsWith(base + '/'),
);
if (protectedPath && (error || !user)) {
return finish(NextResponse.redirect(new URL('/login', request.url)));
}
return finish(NextResponse.next({ request }));
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Next.js 16에서는 대응하는 파일 규약이 proxy.ts로 바뀐다. 파일명 변경과 별개로, 페이지 앞단의 검사는 API·Server Action·DB 권한 검사를 대체하지 않는다. 각 작업은 요청자를 확인하고 허용된 데이터만 읽고 변경해야 한다.
외부 입력 next를 그대로 new URL(next, request.url)에 넣으면 외부 사이트로 리다이렉트할 수 있다. 여기서는 최종 이동 경로를 세 개로 제한한다. 경로에 쿼리를 자유롭게 붙이는 동작도 허용하지 않는다.
// lib/auth-target.ts
const allowedTargets = new Set(['/', '/profile', '/reset-password']);
export function safeAuthTarget(value: string | null): string {
return value !== null && allowedTargets.has(value) ? value : '/';
}
// app/auth/callback/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { createRequestContext } from '../../../lib/supabase/request';
import { safeAuthTarget } from '../../../lib/auth-target';
export async function GET(request: NextRequest) {
const { supabase, finish } = createRequestContext(request);
const code = request.nextUrl.searchParams.get('code');
const next = safeAuthTarget(request.nextUrl.searchParams.get('next'));
let target = '/login?error=oauth';
if (code) {
try {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) target = next;
} catch {
// 오류 상세와 인증 코드를 URL 또는 일반 로그에 기록하지 않는다.
}
}
return finish(NextResponse.redirect(new URL(target, request.url)));
}
이 파일 위치에서 lib까지는 ../../../다. PKCE 코드 교환에 필요한 브라우저 쿠키와 Supabase의 허용 Redirect URL 설정도 일치해야 한다. 위 코드의 code 기반 콜백과 이메일 템플릿의 token_hash를 verifyOtp로 검증하는 흐름을 섞어 사용하지 않는다.
/reset-password로 이동했다는 사실만으로 비밀번호 변경 권한이 생기지는 않는다. 유효한 복구 인증 상태에서 새 비밀번호를 검증하고 updateUser 결과를 확인한다. UI의 onAuthStateChange는 로그인 표시를 동기화하는 데 쓰며, 구독 해제를 처리한다. 클라이언트 Context에 사용자가 있다는 사실을 서버 권한 검사의 근거로 사용하지 않는다.
좌표 순서가 뒤바뀐 입력, 위도 91, NULL, NaN, 반경 0·50km 초과는 거절되어야 한다. 검색 경계 안팎의 가게, 같은 거리의 정렬, 좌표 수정 후 검색 결과도 확인한다.
EXPLAIN (ANALYZE, BUFFERS)는 함수 호출 한 줄뿐 아니라 함수 내부의 SELECT에도 적용한다. 데이터 분포와 반경을 실제 사용 조건에 맞추고 통계를 갱신한 뒤 인덱스 조건과 읽은 블록 수를 본다. 인증에서는 세션 교환 실패, 만료 쿠키, 외부 URL 형태의 next, 비로그인 RPC 호출을 따로 검사한다. 공간 검색이 빠르다는 사실과 데이터 접근이 허용된다는 사실은 서로 다른 검증 결과다.