프로젝트에 Supabase와 PostGIS, Supabase Auth와 Middleware 적용하기

이동휘·2025년 6월 22일
0

매일매일 블로그

목록 보기
32/49

"내 주변 맛집 찾기", "가장 가까운 편의점 안내", "반경 5km 내 배달 가능한 가게 목록"…

오늘날 많은 서비스의 핵심에는 위치 기반 기능이 자리 잡고 있습니다. 하지만 단순히 데이터베이스에 위도(latitude), 경도(longitude) 컬럼을 만들어 모든 데이터를 가져온 뒤, 클라이언트에서 일일이 거리를 계산하는 방식으로 구현한다면 어떻게 될까요? 데이터가 수백, 수천 개만 넘어가도 끔찍한 성능 저하와 마주하게 될 겁니다.

모던 웹 애플리케이션에서 인증은 단순히 로그인/로그아웃 기능을 넘어, 페이지 접근 제어, 실시간 UI 업데이트, 안전한 세션 관리까지 포괄하는 복잡한 시스템입니다. 사용자가 로그인 상태 변화를 거의 인지하지 못할 정도로 부드럽고, 보안적으로는 철저한 인증 시스템을 구축하는 것은 뛰어난 사용자 경험의 핵심입니다.

이 글에서는 PostgreSQL의 강력한 공간 데이터 확장 기능인 PostGIS와, 편리한 백엔드 개발 환경을 제공하는 Supabase의 RPC 기능을 활용하여, 어떻게 효율적이고 확장 가능한 위치 기반 검색 시스템을 구축했는지 그 과정을 A to Z로 상세하게 공유하고 Next.js 미들웨어, React Context, 그리고 Supabase Auth의 onAuthStateChange 리스너를 유기적으로 조합하여, 어떻게 사용자가 불편함을 느끼지 않는 '끊김 없는(Seamless) 인증 경험'을 구축했는지 그 비결을 상세히 공개합니다.


1. 위치 데이터 저장: float 대신 GEOMETRY 타입을 선택한 이유

가장 먼저 "왜 단순히 위도, 경도 컬럼을 따로 만드는 것보다 GEOMETRY 타입을 쓰는 것이 더 나은가?"라는 질문에 답해야 합니다.

1단계: PostGIS 확장 활성화 및 테이블 준비

모든 위치 기반 기능의 시작은 데이터베이스에서 PostGIS를 활성화하는 것입니다.

-- 1. 데이터베이스에서 PostGIS 확장 기능을 활성화합니다.
CREATE EXTENSION IF NOT EXISTS postgis;

-- 2. 가게 정보를 저장할 테이블에 geom 컬럼을 추가합니다.
ALTER TABLE stores ADD COLUMN IF NOT EXISTS geom GEOMETRY(Point, 4326);
  • GEOMETRY(Point, 4326) 타입의 의미:
    • GEOMETRY: PostGIS가 제공하는 핵심 데이터 타입으로, 점, 선, 다각형(폴리곤) 등 다양한 공간 데이터를 저장할 수 있습니다.
    • Point: 우리는 가게의 '위치'라는 단일 점(Point) 데이터를 저장할 것입니다.
    • 4326 (SRID): 공간 참조 시스템 식별자(Spatial Reference ID)로, 좌표계를 정의합니다. 4326은 전 세계적으로 가장 널리 사용되는 GPS 좌표계인 WGS 84를 의미합니다.

GEOMETRY 타입일까요?

  • 성능 (공간 인덱스): GEOMETRY 타입은 공간 인덱스(Spatial Index)와 함께 사용될 때 진정한 힘을 발휘합니다. 일반적인 위도, 경도 숫자 컬럼은 B-Tree 인덱스를 사용하지만, 이는 2차원 공간 검색에 최적화되어 있지 않습니다. PostGIS의 GIST(Generalized Search Tree) 인덱스는 R-Tree 알고리즘을 기반으로 작동하여, 특정 영역 내에 포함된 데이터를 매우 빠르게 필터링할 수 있습니다.
  • 다양한 공간 함수 활용: GEOMETRY 타입을 사용하면 ST_DWithin(특정 거리 내 데이터 검색), ST_Distance(두 지점 간 거리 계산), ST_Contains(한 도형이 다른 도형을 포함하는지 확인) 등 PostGIS가 제공하는 수십 가지의 강력한 공간 함수를 데이터베이스 레벨에서 직접 활용할 수 있습니다. 이는 "강남구에 있는 모든 가게 찾기"와 같은 복잡한 공간 쿼리도 효율적으로 처리할 수 있게 해줍니다.

2. 데이터 무결성과 자동화: 트리거(Trigger)의 숨은 공로

가게의 위도(position_lat)나 경도(position_lng) 값이 변경될 때마다, geom 컬럼도 함께 업데이트되어야 합니다. 만약 개발자가 이 업데이트를 잊어버린다면 어떻게 될까요? 데이터 정합성이 깨지고, 해당 가게는 위치 검색 결과에서 누락되는 심각한 문제가 발생할 수 있습니다.

데이터베이스 트리거는 이러한 휴먼 에러를 원천적으로 방지하고 데이터 무결성을 보장하는 가장 확실한 안전장치입니다.

1단계: geom 값을 자동으로 생성하는 함수 정의

-- 위도, 경도 값이 변경될 때마다 geom 컬럼을 자동으로 업데이트하는 함수
CREATE OR REPLACE FUNCTION update_store_geom()
RETURNS TRIGGER AS $$
BEGIN
    -- NEW: 새로 추가되거나 수정될 행(row)을 의미하는 특별한 변수
    -- ST_MakePoint(경도, 위도) 함수로 Point 객체를 생성하고,
    // ST_SetSRID 함수로 좌표계(4326)를 설정합니다.
    NEW.geom = ST_SetSRID(ST_MakePoint(NEW.position_lng, NEW.position_lat), 4326);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

2단계: 함수를 실행할 트리거 생성

-- stores 테이블에 INSERT 또는 UPDATE가 발생하기 전에 위 함수를 실행하는 트리거
CREATE TRIGGER update_store_geom_trigger
BEFORE INSERT OR UPDATE ON stores
FOR EACH ROW EXECUTE FUNCTION update_store_geom();

이제 stores 테이블에 데이터가 추가되거나 위도/경도 값이 수정될 때마다, 데이터베이스가 알아서 geom 컬럼을 최신 값으로 자동 업데이트해 줍니다. 애플리케이션 코드는 geom 컬럼의 존재를 신경 쓸 필요 없이 위도, 경도 값만 관리하면 됩니다. 이는 관심사의 분리(Separation of Concerns) 원칙에도 부합하는 훌륭한 설계입니다.


3. 검색 성능 최적화: 공간 인덱스(GIST)의 힘

수천, 수만 개의 가게 데이터 중에서 특정 위치 주변의 가게를 빠르게 찾으려면, 모든 데이터의 좌표를 일일이 비교하는 비효율적인 방식에서 벗어나야 합니다. PostGIS는 공간 인덱스를 통해 이 문제를 해결합니다.

-- geom 컬럼에 대해 GIST(Generalized Search Tree) 타입의 공간 인덱스를 생성합니다.
CREATE INDEX IF NOT EXISTS idx_stores_geom ON stores USING GIST(geom);

GIST 인덱스는 2차원 공간 데이터를 효율적으로 검색할 수 있도록 도와주어, 특정 반경 내의 데이터를 찾거나 가까운 순서로 정렬하는 쿼리의 속도를 극적으로 향상시킵니다.


4. Supabase RPC: 복잡한 SQL을 간편한 API처럼 호출하기

"반경 5km 내의 모든 가게 찾기"와 같은 복잡한 공간 쿼리를 프론트엔드에서 매번 직접 작성하는 것은 비효율적이고 보안에도 취약합니다. 우리는 Supabase의 RPC(Remote Procedure Call) 기능을 활용하여, 복잡한 SQL 로직을 데이터베이스에 함수로 미리 정의해두고, 이를 마치 간단한 API처럼 호출하는 방식을 사용했습니다.

1단계: 데이터베이스에 SQL 함수 생성

-- 특정 위경도와 반경(미터)을 입력받아, 해당 반경 내의 가게 목록과 거리를 반환하는 함수
CREATE OR REPLACE FUNCTION stores_within_radius(lat float, lng float, radius_meters float)
RETURNS TABLE (
    -- stores 테이블의 모든 컬럼과 추가로 distance 컬럼을 반환
    id bigint,
    name text,
    -- ... 기타 stores 테이블 컬럼들
    geom geometry,
    distance double precision
) AS $$
BEGIN
    RETURN QUERY
    SELECT
        s.*,
        -- ST_Distance 함수로 실제 거리를 계산합니다.
        ST_Distance(
            s.geom::geography,
            ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography
        ) as distance
    FROM
        stores s
    WHERE
        -- ST_DWithin 함수로 GIST 인덱스를 활용하여 검색 범위를 먼저 좁힙니다.
        ST_DWithin(
            s.geom::geography,
            ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography,
            radius_meters
        )
    ORDER BY
        distance; -- 계산된 거리 순으로 정렬
END;
$$ LANGUAGE plpgsql;
  • ST_DWithin (The Filter): 이 함수의 역할은 '필터링'입니다. GIST 인덱스를 사용하여 검색 범위를 극적으로 좁혀줍니다. "일단 반경 5km 안에 들어올 가능성이 있는 후보들만 전부 가져와!" 라는 명령과 같습니다.
  • ST_Distance (The Calculator): ST_DWithin으로 걸러진 소수의 후보들에 대해서만 정확한 거리를 계산합니다. 만약 모든 데이터에 대해 ST_Distance를 실행하고 WHERE distance < 5000 조건을 건다면, 인덱스를 제대로 활용하지 못해 매우 느려질 것입니다.
  • ::geography 타입 캐스팅의 중요성: 단순히 geometry 타입으로 거리를 계산하면, 위도에 따라 1도의 실제 거리가 달라지는 왜곡 현상 때문에 부정확한 결과가 나옵니다. ::geography로 타입을 변환하면 지구의 곡률을 고려한 미터(meter) 단위의 정확한 측지선 거리를 계산할 수 있어, 위치 기반 서비스에 필수적입니다.

2단계: Supabase 클라이언트로 RPC 함수 호출

이렇게 생성된 SQL 함수는 Supabase 클라이언트를 통해 마치 API처럼 간편하게 호출할 수 있습니다.

// 클라이언트 또는 서버에서 RPC 함수 호출 예시
const { data, error } = await supabase.rpc('stores_within_radius', {
  lat: 37.5665,
  lng: 126.9780,
  radius_meters: 5000,
});

if (error) {
  console.error('Error fetching stores:', error);
} else {
  console.log('Nearby stores:', data);
}

이 방식은 BFF(Backend for Frontend) 패턴의 훌륭한 구현 사례입니다. 프론트엔드는 복잡한 내부 SQL 구현을 알 필요 없이, stores_within_radius라는 명확한 'API'와 필요한 파라미터(lat, lng, radius_meters)만 알면 됩니다. 내부 로직이 변경되더라도 프론트엔드 코드를 수정할 필요가 없어 유지보수성이 크게 향상됩니다.

🤔 꼬리 질문: 만약 "서울시에 있는 모든 스타벅스 찾기"와 같은 폴리곤(Polygon) 기반 검색 기능이 필요하다면, PostGIS의 어떤 함수와 인덱싱 전략을 활용할 수 있을까요? (ST_Contains, ST_Intersects 등)


5. 서버와 클라이언트, 두 종류의 Supabase 클라이언트 이해하기 (핵심 개념)

본격적인 설명에 앞서, Supabase를 Next.js App Router 환경에서 사용할 때 왜 두 종류의 클라이언트 생성 파일(lib/supabase/server.tslib/supabase/client.ts)이 필요한지 이해하는 것이 중요합니다.

  • lib/supabase/server.ts (createServerClient):
    • 사용 환경: 서버 환경에서 사용됩니다. (예: 미들웨어, Route Handlers, 서버 컴포넌트)
    • 동작 방식: 사용자의 브라우저에서 전송된 쿠키(Cookie)를 직접 읽어와 Supabase 서버에 전달함으로써, 서버 측에서도 사용자의 인증 상태를 안전하게 파악할 수 있게 해줍니다. 경로 보호를 위한 미들웨어의 핵심적인 역할을 합니다.
  • lib/supabase/client.ts (createBrowserClient):
    • 사용 환경: 클라이언트 환경에서 사용됩니다. (React 컴포넌트 내, 특히 'use client' 지시어가 있는 컴포넌트)
    • 동작 방식: onAuthStateChange와 같은 실시간 인증 상태 변경 이벤트를 구독하거나, 클라이언트에서 직접 로그인/로그아웃/회원가입 등의 인증 관련 함수를 호출할 때 필요합니다.

이 두 클라이언트를 상황에 맞게 올바르게 사용하는 것이 안전하고 효율적인 인증 시스템 구축의 첫걸음입니다.


6. 서버 사이드 경로 보호: '깜빡임' 없는 완벽한 리디렉션 (middleware.ts)

사용자가 로그인해야만 접근할 수 있는 페이지(예: /profile)를 어떻게 보호할 수 있을까요?

  • 클라이언트 사이드 리디렉션의 문제점: 만약 useEffect 훅을 사용해 클라이언트 컴포넌트가 렌더링된 후에 로그인 여부를 확인하고 리디렉션한다면, 사용자는 보호된 페이지의 내용이 아주 잠깐 보였다가 로그인 페이지로 넘어가는 '화면 깜빡임(flicker)' 현상을 경험하게 됩니다. 이는 매우 나쁜 사용자 경험입니다.

  • 미들웨어의 해결책: Next.js 미들웨어는 페이지 자체가 렌더링되기 전, 서버에서 요청을 가로채기 때문에 이런 깜빡임 현상이 전혀 없습니다.

// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  // ... (Supabase 클라이언트 설정) ...

  const supabase = createServerClient( ... ); // 서버 클라이언트 생성
  const { data: { session } } = await supabase.auth.getSession();
  const { pathname } = request.nextUrl;

  const authRequiredPaths = ['/profile', '/settings'];
  const publicOnlyPaths = ['/login', '/signup'];

  // 인증이 필요한 경로인데 세션이 없는 경우 -> 로그인 페이지로 리디렉션
  if (!session && authRequiredPaths.some(path => pathname.startsWith(path))) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // 로그인한 유저가 로그인/회원가입 페이지에 접근하려는 경우 -> 메인 페이지로 리디렉션
  if (session && publicOnlyPaths.some(path => pathname.startsWith(path))) {
    return NextResponse.redirect(new URL('/', request.url));
  }

  return NextResponse.next();
}

사용자가 /profile에 접근하면, 서버는 페이지를 렌더링하기 전에 세션이 없는 것을 확인하고 즉시 로그인 페이지로의 리디렉션 응답을 보내줍니다. 따라서 사용자는 보호된 페이지의 내용을 전혀 볼 수 없으며, 완벽하고 안전한 경로 보호가 가능해집니다.


7. 실시간 UI 동기화: onAuthStateChange 리스너의 마법 (AuthContext.tsx)

사용자의 로그인 상태는 헤더의 프로필 아이콘, 메뉴 등 앱의 여러 곳에서 사용됩니다. 이를 효율적으로 관리하고 실시간으로 UI에 반영하기 위해 React Context와 Supabase의 onAuthStateChange 리스너를 함께 사용합니다.

'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { createBrowserClient } from '@supabase/ssr';
// ... (기타 import)

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const supabase = createBrowserClient( ... ); // 브라우저 클라이언트 생성
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    // onAuthStateChange 리스너를 구독합니다.
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (event, session) => {
        console.log(`Auth event: ${event}`);
        setUser(session?.user ?? null);

        // 이벤트 타입에 따라 추가적인 UI 피드백 제공
        if (event === 'SIGNED_IN') {
          // ... 로그인 성공 토스트 메시지 등
        } else if (event === 'SIGNED_OUT') {
          // ... 로그아웃 성공 토스트 메시지 및 페이지 이동 등
        }
      }
    );

    // 컴포넌트 언마운트 시 리스너 구독 해제
    return () => {
      subscription.unsubscribe();
    };
  }, [supabase]);

  return <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>;
}

export const useAuth = () => useContext(AuthContext);

onAuthStateChange 리스너는 사용자의 인증 상태가 변경될 때마다(로그인, 로그아웃, 토큰 갱신, 비밀번호 변경 등) 자동으로 콜백 함수를 실행합니다. 이를 통해 사용자가 다른 브라우저 탭에서 로그아웃하더라도, 현재 탭의 UI가 즉시 실시간으로 user 상태 변화를 감지하고 동기화됩니다. 이는 끊김 없는 사용자 경험을 만드는 데 핵심적인 역할을 합니다.


8. 소셜 & 비밀번호 리셋 플로우 완벽 해부: 안전한 콜백 처리

OAuth(소셜 로그인)나 비밀번호 재설정 플로우처럼 외부(이메일, 소셜 로그인 제공자)에서 우리 서비스로 돌아오는 콜백(Callback) 과정은 보안상 매우 중요하며, 반드시 서버 사이드에서 안전하게 처리되어야 합니다.

1단계: 재설정 요청 & 소셜 로그인 시작

  • 비밀번호 재설정: 사용자가 이메일을 입력하고 supabase.auth.resetPasswordForEmail 함수를 호출합니다. 이때 redirectTo 옵션으로 .../auth/callback?next=/reset-password 와 같이 특별한 콜백 경로를 지정하는 것이 핵심입니다.
  • 소셜 로그인: supabase.auth.signInWithOAuth 호출 시에도 redirectTo 옵션을 통해 콜백 경로를 지정합니다.

2단계: 서버 사이드 콜백 처리 (app/auth/callback/route.ts)

이 콜백 라우트는 소셜 로그인 제공자나 이메일 링크를 통해 사용자가 리디렉션되는 엔드포인트입니다.

// app/auth/callback/route.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');
  const next = searchParams.get('next') ?? '/';

  if (code) {
    const supabase = createServerClient( ... ); // 서버 클라이언트
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (error) {
      // 에러 처리
    }
  }
  // 사용자를 최종 목적지로 리디렉션
  return NextResponse.redirect(new URL(next, request.url));
}
  • 소셜 로그인 제공자나 Supabase Auth는 인증 성공 후, 우리 서버의 /auth/callback 엔드포인트로 일회성 code를 보내줍니다.
  • 이 Route Handler는 서버에서 실행되므로, 이 code를 받아 Supabase의 exchangeCodeForSession(code) 함수를 안전하게 호출하여 실제 사용자 세션(토큰)으로 교환합니다. 이 과정은 클라이언트에 민감한 정보를 노출하지 않아 안전합니다.
  • 세션 교환이 성공하면, next 파라미터에 지정된 최종 목적지(예: / 또는 /reset-password)로 사용자를 안전하게 리디렉션합니다.

3단계: 새 비밀번호 설정 (reset-password/page.tsx)

  • 콜백 라우트로부터 /reset-password 페이지로 리디렉션된 사용자는 새 비밀번호를 입력합니다.
  • 비밀번호 업데이트는 supabase.auth.updateUser({ password: newPassword })를 통해 이루어집니다. 이 함수는 사용자가 로그인(유효한 세션을 가진) 상태일 때만 작동합니다. 2단계에서 exchangeCodeForSession을 통해 생성된 세션 덕분에 이 업데이트가 가능해집니다.

이처럼 각 단계의 역할과 코드, 그리고 그 이유를 명확히 이해하고 연결함으로써, 복잡하지만 안전하고 사용자 친화적인 인증 플로우를 완성할 수 있습니다.

🤔 꼬리 질문: 만약 소셜 로그인으로 처음 가입하는 사용자의 경우, profiles 테이블에 해당 사용자의 정보를 자동으로 생성해 주어야 합니다. 이 로직은 위 플로우의 어느 단계에, 어떤 방식으로 구현하는 것이 가장 효율적이고 안전할까요? (힌트: 데이터베이스 트리거 또는 콜백 라우트 핸들러 내 로직 추가)


결론: 기술의 유기적인 조합으로 만드는 최상의 사용자 경험

이 글에서는 Supabase와 PostGIS, 그리고 Next.js의 다양한 기능들을 어떻게 유기적으로 조합하여, 성능이 뛰어난 위치 기반 서비스안전하고 끊김 없는 인증 시스템을 구축했는지 살펴보았습니다.

  • 위치 기반 서비스: GEOMETRY 타입과 GIST 공간 인덱스로 성능의 기반을 다지고, 트리거로 데이터 무결성을 자동화했으며, RPC 함수로 복잡한 로직을 캡슐화하여 효율적인 아키텍처를 완성했습니다.
  • 인증 시스템: 미들웨어로 완벽한 서버 사이드 경로 보호를 구현하고, onAuthStateChange 리스너로 실시간 UI 동기화를 달성했으며, 서버 사이드 콜백 처리를 통해 안전한 인증 플로우를 구축했습니다.

핵심은 단순히 특정 기능을 나열하는 것이 아니라, 각 기술이 '왜' 필요하고 서로 어떻게 상호작용하여 최종적으로 더 나은 사용자 경험과 안정적인 시스템을 만들어내는지를 이해하는 것입니다. 이 글이 여러분의 프로젝트에 실질적인 영감과 깊이 있는 지식을 제공했기를 바랍니다.

0개의 댓글