PostGIS로 지도 관련 검색 끝내기
모토맵은 지도 앱이라 모든 화면이 위치 질문에서 시작해요.
"내 주변 3km 안에 바이커 카페가 어디 있지?" "이 코스를 달리다 들를 만한 휴게소는?" 같은 질문이죠.
이 글은 이런 위치 검색을 데이터베이스에 맡긴 과정을 정리한 글이에요. 지도 기술을 처음 접해도 따라올 수 있도록 좌표를 저장하는 법부터 앱이 검색 결과를 받는 과정까지 차례로 살펴볼게요.
먼저 글에 반복해서 나오는 이름들의 관계부터 정리해 볼게요.
- 데이터베이스(DB): 장소나 코스 같은 데이터를 보관하고 필요한 데이터를 찾아주는 창고예요.
- PostgreSQL: 그 창고를 운영하는 데이터베이스 프로그램이에요.
- PostGIS: PostgreSQL이 지도 좌표와 거리를 이해하도록 설치하는 확장 프로그램이에요.
- Supabase: PostgreSQL을 쉽게 운영하고 앱에서 호출할 API까지 제공하는 서비스예요.
- SQL: 데이터베이스에 "이 조건에 맞는 데이터를 찾아줘"라고 요청하는 언어예요.
각각 별개의 경쟁 기술이 아니에요. 모토맵은 Supabase가 제공하는 PostgreSQL에 PostGIS를 설치하고 SQL로 작성한 위치 검색을 앱에서 호출하는 구조예요.
앱에서 계산하면 안 되나요?
처음엔 이런 생각이 들 수 있어요. "장소를 전부 받아와서 앱에서 거리를 계산하면 되지 않나?"
장소가 100개일 땐 그래도 돼요. 문제는 데이터가 늘어날 때예요.
장소가 수천 개가 되면 매번 전체 목록을 내려받아야 하고 그중 대부분은 화면에 보이지 않는 곳이에요.
사용자는 강원도를 달리고 있는데 부산 장소까지 받는 거죠.
거리 계산 자체도 간단하지 않아요.
지구는 평평하지 않아서 좌표 두 개의 차이를 피타고라스로 구하면 오차가 나요. 위도에 따라 경도 1도의 실제 거리가 달라지거든요.
그래서 장소 데이터가 보관된 DB에서 필요한 것만 골라 앱에 보내는 편이 좋아요.
PostgreSQL의 공간 확장인 PostGIS는 '이 점에서 3km 안에 있는 장소'를 빠르게 찾아낼 수 있어요.
모토맵의 DB를 PostgreSQL로 정한 가장 큰 이유 중 하나도 PostGIS를 사용하기 위함이었어요.
PostGIS가 뭔가요?
PostGIS는 PostgreSQL에 지도 기능을 추가하는 확장 프로그램(extension) 이에요. 스마트폰에 지도 앱을 설치하듯 PostgreSQL에 PostGIS를 설치하면 DB가 위치와 거리를 이해하게 돼요.
일반 PostgreSQL은 37.5665라는 숫자를 봐도 이것이 서울의 위도인지, 상품 가격인지 알지 못해요. PostGIS에 위치로 저장하면 DB가 "이 값은 지구 위의 한 지점"이라고 이해하고 거리를 계산할 수 있어요.
지도에 그릴 수 있는 모양은 크게 세 가지예요.
Point: 카페나 주차장처럼 하나의 위치를 표현해요.LineString: 라이딩 코스나 도로처럼 이어진 경로를 표현해요.Polygon: 행정구역이나 서비스 가능 지역처럼 면적을 가진 영역을 표현해요.
위도와 경도를 숫자 컬럼 두 개에 나눠 저장할 수도 있지만 DB 입장에서는 여전히 숫자 두 개일 뿐이에요.
위치를 Point로 저장하면 "두 장소가 얼마나 떨어져 있나?", "이 장소가 서울 안에 있나?", "이 코스 주변에 카페가 있나?" 같은 질문을 SQL 한 번으로 물어볼 수 있어요. 모든 장소를 하나씩 확인하지 않도록 빠른 검색용 색인도 만들 수 있고요.
PostGIS에서는 장소의 위치 컬럼을 아래처럼 만들 수 있어요.
location geometry(Point, 4326)처음 보면 괄호 안의 4326이 낯설지만, 한 줄 전체를 세 조각으로 나누면 어렵지 않아요.
geometry: 위치를 평면 지도 방식으로 계산해요.Point: 저장할 도형은 하나의 점이에요.4326: 위도와 경도를 어떤 규칙으로 읽을지 알려주는 좌표계 번호예요.
즉 "location 컬럼에 GPS 좌표 규칙을 따르는 점 하나를 평면 지도 방식으로 저장한다"는 뜻이에요. 모토맵은 실제 거리를 미터로 계산해야 해서 geometry 대신 geography를 사용하지만 선언을 읽는 방법은 같아요.
geometry와 geography
PostGIS에는 같은 위치를 담는 두 가지 대표적인 타입이 있어요.
geometry는 지도를 평평한 모눈종이처럼 보고 계산해요.geography는 지구가 둥글다는 점을 반영해 지구본 위에서 계산해요.
둘 다 Point, LineString, Polygon을 저장할 수 있지만 거리를 재는 기준이 달라요.
geometry | geography | |
|---|---|---|
| 공간을 보는 방식 | 평평한 지도 | 둥근 지구 |
| 거리 단위 | 사용하는 좌표계에 따라 달라짐 | 미터 |
| 장점 | 사용할 수 있는 함수가 많고 계산이 빠름 | GPS 좌표로 실제 거리를 구하기 쉬움 |
| 잘 맞는 경우 | 한 지역을 알맞은 평면 좌표계로 바꿔 정밀하게 분석 | 전국 장소의 반경 검색처럼 넓은 범위의 실제 거리 계산 |
앞에서 본 4326의 정식 이름은 EPSG:4326이에요. GPS가 사용하는 WGS 84 위도·경도 좌표계를 가리키는 공통 번호예요. 위도와 경도의 단위는 미터가 아니라 도(degree) 이므로 geometry(Point, 4326)의 두 점을 ST_Distance로 재면 결과도 도 단위예요. 이 값을 곧바로 m나 km로 읽으면 안 돼요.
게다가 경도 1도의 실제 길이는 장소에 따라 달라져요. 적도 근처에서는 길지만 극지방으로 갈수록 짧아지죠. 평면 지도에서 같은 한 칸이어도 지구 위 실제 길이는 같지 않은 것처럼요.
반면, 같은 GPS 좌표를 geography로 다루면 ST_Distance와 ST_DWithin이 지구의 곡면을 고려하고 미터를 사용해요. "현재 위치에서 3,000m 이내" 같은 기능을 만들 때 이해하기 쉬운 이유예요.
그렇다고 geography가 무조건 상위 기능인 건 아니에요. 지구본 위 계산은 더 복잡하고 사용할 수 있는 함수도 적어요. 한 도시처럼 범위가 좁다면 그 지역을 평평하게 펼친 전용 좌표계로 바꾼 geometry가 더 빠르고 정밀할 수 있어요. 지구의 곡면을 평면 지도에 옮기는 규칙을 투영 좌표계라고 해요. PostGIS 공식 문서도 데이터 범위와 필요한 계산을 기준으로 둘 중 하나를 선택하라고 안내해요.
모토맵은 전국의 장소를 다루고 "반경 몇 m"가 핵심이라 장소를 geography로 저장했어요. 한 줄로 정리하면 실제 거리 검색은 geography, 평면 도형 분석은 geometry 를 사용한 거예요.
일부 함수는 geometry만 받을 수 있어서 필요한 순간에 ::geometry를 붙여 그 타입으로 잠시 변환해요. 좌표 숫자 자체를 옮기는 작업은 아니고 같은 장소를 모눈종이 방식으로 보겠다고 계산 관점만 바꾸는 작업에 가까워요.
이 지점에서 자주 헷갈리는 연산도 구분해야 해요.
ST_SetSRID: 좌표가 따르는 규칙을 이름표로 붙여요. "이 숫자는 GPS 좌표예요"라고 알려줄 뿐 숫자는 바꾸지 않아요.ST_Transform: 좌표를 다른 지도 규칙에 맞는 숫자로 실제 변환해요. 섭씨 온도를 화씨로 환산하는 것처럼 결과 숫자가 달라져요.::geometry,::geography: 같은 좌표를 평면과 지구본 중 어떤 방식으로 계산할지 바꿔요.
Supabase에서 시작하기
Supabase에서는 Dashboard의 Database → Extensions에서 postgis를 검색해 활성화할 수 있어요. 그다음 장소 테이블에 위치 컬럼을 만들고 GiST 공간 인덱스를 추가하면 기본 준비가 끝나요.
CREATE INDEX places_location_idx
ON places USING GIST (location);인덱스는 책 뒤의 찾아보기와 비슷해요. 원하는 단어가 어느 페이지에 있는지 색인으로 먼저 좁히면 책 전체를 한 장씩 읽지 않아도 되죠. DB 인덱스도 같은 역할을 해요.
다만 이름이나 가격을 찾는 일반 인덱스로는 "이 좌표와 가까운 장소"를 찾기 어려워요. 지도는 가로와 세로를 함께 비교해야 하기 때문이에요. PostGIS의 GiST 공간 인덱스는 각 장소나 도형을 감싸는 작은 사각형을 기준으로 위치를 분류해 둬요. 이 사각형을 bounding box라고 해요.
ST_DWithin은 두 단계로 검색해요.
- GiST 인덱스로 검색 반경과 가까울 가능성이 있는 후보만 빠르게 골라요.
- 그 후보들만 실제 거리를 계산해 반경 안에 있는지 확인해요.
서울 주변을 검색할 때 부산과 제주에 있는 장소는 첫 단계에서 제외되는 거죠. 모든 장소의 거리를 처음부터 재는 것보다 계산량이 크게 줄어요. PostGIS 공간 인덱스 문서에 이 과정이 그림과 함께 설명되어 있어요.
그래서 ST_DWithin으로 반경 안의 장소를 먼저 거르고 ST_Distance는 남은 장소의 실제 거리 표시와 가까운 순 정렬에 사용해요. 전자는 검색 담당, 후자는 거리 측정 담당인 거예요.
DB가 실제로 인덱스를 사용했는지는 EXPLAIN (ANALYZE, BUFFERS)라는 SQL 분석 명령으로 확인할 수 있어요. 결과에 Index Scan이 보이면 인덱스를 이용한 것이고 Seq Scan은 테이블을 처음부터 훑었다는 뜻이에요. 장소가 몇 개뿐이라면 DB가 전체를 보는 편이 더 빠르다고 판단해 일부러 Seq Scan을 선택할 수도 있어요.
모토맵의 공개 검색은 승인됐고 삭제되지 않은 장소만 보여줘요. 데이터가 충분히 커진다면 이 공개 장소만 따로 색인하는 partial index(부분 인덱스) 도 고려할 수 있어요.
CREATE INDEX places_public_location_idx
ON places USING GIST (location)
WHERE approved = true AND deleted_at IS NULL;부분 인덱스도 장단이 있어요. 검색은 빨라질 수 있지만 기존 전체 인덱스와 둘 다 유지하면 장소를 추가하거나 수정할 때 두 색인을 함께 갱신해야 하고 저장 공간도 더 써요. 실제 검색이 느린지, 새 인덱스를 쓰는지는 EXPLAIN 결과를 확인한 뒤 결정해야 해요.
이제 ST_DWithin, ST_Distance, ST_Intersects 같은 공간 함수를 SQL에 사용할 수 있어요. WHERE는 결과에 포함할 조건을 정하고 ORDER BY는 결과의 순서를 정하는 부분이에요.
모토맵에서는 장소를 Point로 저장하고, 코스의 좌표 배열은 필요할 때 LineString으로 바꿔서 검색해요. 아래에서 두 경우를 차례로 살펴볼게요. 더 자세한 설정 방법은 Supabase의 PostGIS 가이드에서도 확인할 수 있어요.
반경 검색
장소 테이블의 위치는 앞에서 살펴본 geography 타입으로 저장해요.
location geography(Point, 4326)Point는 하나의 좌표를, 4326은 GPS에서 사용하는 WGS 84 좌표 규칙을 뜻해요.
geography를 받는 거리 함수는 미터를 단위로 사용하므로 ST_DWithin에 3000을 넘기면 그대로 3km 반경을 의미해요.
WITH origin AS (
SELECT ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography AS point
)
SELECT
p.id, p.name, p.category,
ST_Y(p.location::geometry) AS latitude,
ST_X(p.location::geometry) AS longitude
FROM places p, origin o
WHERE p.approved = true
AND p.deleted_at IS NULL
AND ST_DWithin(p.location, o.point, radius_meters)
ORDER BY ST_Distance(p.location, o.point);- ST_DWithin: "두 위치가 정해진 거리 안에 있는가?"에
true또는false로 답해요. 여기서는 3km 밖의 장소를 탈락시키는 필터예요. - ST_Distance: 두 위치 사이의 실제 거리를 숫자로 돌려줘요. 여기서는 남은 장소를 가까운 순으로 정렬해요.
여기서 주의해야 할 점이 좌표 순서예요.
보통 '위도, 경도' 순서로 말하는 게 익숙한데 ST_MakePoint는 경도(lng)가 먼저예요. 수학의 (x, y) 순서를 따르기 때문이에요.
이걸 바꿔 넣으면 에러 없이 조용히 엉뚱한 바다 한가운데를 검색해요.
ST_MakePoint는 경도와 위도 숫자를 하나의 점으로 묶지만 그 숫자가 어떤 좌표 규칙을 따르는지는 몰라요. ST_SetSRID(..., 4326)으로 "GPS 좌표"라는 이름표를 붙이고 geography로 바꿔 지구본 방식으로 거리를 재게 했어요.
맨 위의 origin은 사용자의 현재 위치를 뜻해요. 이 점을 한 번 만들어 두고 반경 판정과 거리순 정렬에서 똑같이 사용했어요.
ST_X와 ST_Y는 geometry 값을 받기 때문에 좌표를 꺼내는 두 표현에서만 p.location::geometry로 변환했어요. 숫자 좌표를 읽는 용도라 재투영은 필요하지 않아요.
Supabase RPC
앱에서 위의 긴 SQL을 매번 직접 조립하지는 않아요. DB에 nearby_places라는 이름으로 저장해 두고 앱에서는 현재 위치와 반경만 전달해 실행해요. 이렇게 DB에 저장한 SQL 묶음을 database function이라고 해요.
RPC는 Remote Procedure Call로 "다른 곳에 있는 함수를 원격으로 호출"해요. 앱과 DB는 서로 다른 컴퓨터에서 실행되지만 앱이 DB의 nearby_places 함수를 마치 자기 코드의 함수처럼 호출하는 방식이죠.
RPC가 별도의 검색 기술인 것은 아니에요. 앞에서 만든 PostGIS SQL을 앱이 호출할 수 있게 공개되어 있는 경로예요. Edge Function 서버를 한 번 더 거치지 않고 검색이 데이터와 인덱스가 있는 DB 안에서 끝나요.
React Native 앱
→ supabase.rpc('nearby_places', 매개변수)
→ PostgREST Data API
→ public.nearby_places(...)
→ places 테이블 + GiST 인덱스Supabase의 일반 조회 문법만으로는 ST_DWithin 같은 공간 함수를 자유롭게 조합하기 어려워요. 그래서 앞의 SQL을 database function으로 만들고 "무엇을 입력받고 무엇을 돌려줄지" 정했어요. 실제 함수에서 필요한 컬럼만 남기면 이런 형태예요.
CREATE OR REPLACE FUNCTION public.nearby_places(
lat double precision,
lng double precision,
radius_meters integer DEFAULT 5000,
category_filter text DEFAULT NULL
)
RETURNS TABLE (
id uuid,
name text,
distance_meters double precision
)
LANGUAGE sql
STABLE
SECURITY INVOKER
AS $$
WITH origin AS (
SELECT ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography AS point
)
SELECT
p.id,
p.name,
ST_Distance(p.location, o.point) AS distance_meters
FROM public.places p, origin o
WHERE p.approved = true
AND p.deleted_at IS NULL
AND ST_DWithin(p.location, o.point, radius_meters)
AND (category_filter IS NULL OR p.category = category_filter)
ORDER BY distance_meters;
$$;- 입력부의
lat,lng,radius_meters는 요청할 항목이에요.DEFAULT는 앱이 값을 생략했을 때 사용할 기본값이에요. RETURNS TABLE은 결과표에id,name,distance_meters열을 담겠다는 약속이에요. 앱은 이 모양을 기준으로 결과를 읽어요.LANGUAGE sql은 이 함수의 본문을 SQL로 작성했다는 뜻이에요.STABLE은 데이터를 고치지 않으며 하나의 SQL이 실행되는 동안 같은 입력에는 같은 결과를 돌려주는 함수라고 PostgreSQL에 알려줘요.SECURITY INVOKER는 함수를 만든 관리자가 아니라 호출한 사용자의 권한으로 실행한다는 뜻이에요.
함수를 만들었다고 아무에게나 실행을 허용해도 된다는 뜻은 아니에요. DB 함수의 문에는 EXECUTE라는 실행 권한이 있고, 테이블의 각 행에는 RLS(Row Level Security) 라는 열람 규칙이 있어요.
EXECUTE: 이 함수의 호출 버튼을 누를 수 있는가?- RLS: 함수를 통해 테이블을 읽을 때 어떤 행까지 볼 수 있는가?
두 장치는 역할이 달라요. 모토맵의 주변 장소는 로그인하지 않은 사용자도 볼 수 있으므로 비로그인 역할인 anon과 로그인 역할인 authenticated에만 실행을 허용했어요. PUBLIC은 "공개 데이터"라는 뜻이 아니라 PostgreSQL의 모든 역할을 묶은 그룹 이름이에요. 먼저 그 그룹의 기본 실행 권한을 회수한 뒤 필요한 두 역할에만 다시 부여해요.
REVOKE EXECUTE ON FUNCTION public.nearby_places(
double precision, double precision, integer, text
) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.nearby_places(
double precision, double precision, integer, text
) TO anon, authenticated;공개 RPC라면 실행 권한만으로 충분하지 않아요. 위도는 -90°에서 90° 사이, 경도는 -180°에서 180° 사이인지 확인하고 radius_meters에도 서비스가 허용할 최댓값을 둬야 해요. 누군가 반경에 터무니없이 큰 값을 넣으면 사실상 전국의 장소를 계속 검색해 DB에 부담을 줄 수 있기 때문이에요.
앱 화면에서 입력을 검사해도 공격자는 앱을 거치지 않고 API를 직접 부를 수 있어요. 따라서 비용이 큰 공간 검색의 입력값은 DB 함수나 신뢰할 수 있는 서버에서도 다시 확인해야 해요.
SECURITY INVOKER 함수는 인증된 정보를 사용하므로 그 사람에게 적용되는 권한과 RLS를 그대로 따라요. 반대로 SECURITY DEFINER는 함수를 만든 관리자의 정보를 빌려 실행해요. 잘못 사용하면 원래 볼 수 없는 데이터까지 읽을 수 있으므로 단순히 권한 오류를 없애려고 붙이면 안 돼요. 관리자 권한이 꼭 필요한 좁은 작업에서만 사용하고 접근 가능한 DB 영역과 실행 권한을 함께 제한해야 해요. Supabase database function 가이드에서도 SECURITY INVOKER를 기본 선택으로 권장해요.
DB 함수를 만든 뒤 앱에서는 매개변수 이름에 맞춰 호출해요. 함수가 돌려준 결과표는 그대로 data에 들어와요.
const { data, error } = await supabase.rpc('nearby_places', {
lat: latitude,
lng: longitude,
radius_meters: 5000,
category_filter: category ?? null,
});이 함수는 앱과 DB 사이의 약속이에요. 앱은 정해진 입력을 보내고 정해진 모양의 결과를 기대하죠.
함수 내부 SQL만 바꿀 때는 CREATE OR REPLACE FUNCTION으로 기존 함수를 덮어쓸 수 있어요. 하지만 결과표에 열을 추가하는 것처럼 반환 모양 자체가 바뀌면 PostgreSQL은 기존 함수를 지우고 다시 만들라고 요구해요. 같은 이름의 함수가 입력 타입에 따라 여러 개 존재할 수도 있어서 삭제할 때는 이름뿐 아니라 입력 타입도 함께 적어요. 이 전체 조합을 함수의 signature(서명) 라고 해요.
DROP FUNCTION public.nearby_places(
double precision, double precision, integer, text
);
-- hours를 포함한 RETURNS TABLE로 다시 CREATE하고 EXECUTE 권한도 다시 부여함수를 DROP하고 다시 만들면 그 문에 걸어둔 실행 권한도 함께 사라져요. 그래서 하나의 migration(DB 변경 내역 파일) 안에서 함수 생성과 권한 부여를 함께 끝내야 해요. Supabase가 생성한 TypeScript 타입을 사용한다면 앱이 새 결과 모양을 알 수 있도록 database types도 다시 생성해야 하고요. RPC를 수정할 때 SQL 본문만 볼 것이 아니라 입력, 출력, 권한을 하나의 묶음으로 확인해야 하는 이유예요. CREATE FUNCTION 문서에도 기존 함수의 반환 타입은 CREATE OR REPLACE로 바꿀 수 없다고 명시되어 있어요.
앱 입장에서는 복잡한 위치 검색이 함수 호출 하나로 단순해지고, 공간 계산은 데이터와 인덱스가 있는 DB 안에서 끝나요. 결제 API처럼 외부 서비스와 통신하거나 오래 걸리는 작업은 별도 서버 코드인 Edge Function이 어울려요. 반면, 여러 장소를 읽고 공간 인덱스로 필터하는 건 database function에 두면 데이터를 밖으로 옮기지 않아도 돼요.
다만, 읽기 로직을 함수에 모았다면 테이블의 컬럼 구조, 즉 schema(스키마) 가 바뀔 때 함수의 검색 조건과 결과 모양도 함께 갱신해야 해요. 뒤에서 실제로 놓친 사례를 이야기할게요.
코스 경로 주변 장소
모토맵에는 라이딩 코스가 있어요.
코스 상세나 경로 미리보기에서 '이 길을 달리다 들를 만한 곳'을 보여주고 싶었는데, 이건 반경 검색으로는 부족했어요. 코스는 점이 아니라 선이니까요.
다행히 PostGIS는 선 주변 검색도 ST_DWithin으로 해결할 수 있어요. "사용자 위치와 장소"처럼 점 두 개를 비교할 수도 있고 "코스 선과 장소"도 비교할 수 있기 때문이에요.
모토맵의 코스 경로는 [[경도, 위도], ...]처럼 좌표가 차례대로 담긴 JSON 배열이에요. DB가 이를 지도 위의 선으로 이해하도록 먼저 점들을 만들고 이어줘야 해요.
WITH course_line AS (
SELECT ST_SetSRID(
ST_MakeLine(ARRAY(
SELECT ST_MakePoint((pt->>0)::float8, (pt->>1)::float8)
FROM jsonb_array_elements(c.route_geometry) AS pt
)), 4326
) AS line
FROM courses c
WHERE c.id = course_id
)
SELECT
p.name,
ST_LineLocatePoint(cl.line, p.location::geometry) AS route_fraction
FROM places p, course_line cl
WHERE p.approved = true
AND p.deleted_at IS NULL
AND ST_DWithin(p.location, cl.line::geography, radius_m)
ORDER BY route_fraction;SQL이 낯설다면 아래 세 단계만 이해해도 충분해요.
- 배열 속 좌표를
ST_MakePoint로 점으로 만들고ST_MakeLine으로 코스 선을 만들어요.ST_SetSRID(..., 4326)으로 이 선이 GPS 좌표라는 이름표도 붙여요. - 코스에서 실제 거리로 3km 안에 있는 장소를 찾을 때는 선을
geography로 바꿔ST_DWithin에 넘겨요. - 장소가 코스의 어느 지점과 가까운지는
ST_LineLocatePoint로 구해요. 출발점을0, 도착점을1로 봤을 때 중간 지점을0.5처럼 돌려줘요. 이 값으로 정렬하면 출발 직후 휴게소가 먼저, 도착 직전 카페가 마지막에 나와요.
같은 코스 선을 실제 거리 검색에서는 지구본 방식인 geography로, 선 위의 순서를 구할 때는 모눈종이 방식인 geometry로 보는 거죠.
다만 0.5가 반드시 전체 주행 거리의 정확히 절반이나 주행 시간의 절반을 뜻하지는 않아요. 선 위에서 앞뒤 순서를 정하기 위한 값에 가까워요. 장거리 코스에서 정확한 거리 비율까지 필요하다면 그 지역에 맞는 평면 좌표계로 실제 변환한 뒤 계산해야 해요.
거리순 정렬이었다면 "코스 어딘가에 가까운 곳" 목록에 그쳤을 텐데 진행률 정렬 덕분에 "달리는 순서대로 들를 곳"이 될 수 있는 거죠.
시행착오: 삭제한 장소가 계속 보였어요
장소를 실제로 지우는 대신 "삭제된 시각"만 기록하는 soft delete 를 도입하면서 기존 조회 함수의 조건을 함께 갱신하지 않은 적이 있어요. 실수로 삭제했을 때 복구할 수 있도록 데이터는 남겨두고 화면에서만 숨기는 방식이에요.
장소 제보를 반려하거나 정리할 때 행을 바로 지우지 않고 deleted_at에 삭제 시각만 기록했어요.
그때는 지도와 검색 RPC가 approved = true인 장소만 반환하니 삭제 표시만 해도 안전할 거라고 판단했어요.
이 가정은 반려(승인 전) 시나리오에서만 작동했어요.
이미 승인된 장소를 나중에 삭제하면 approved = true인 채로 deleted_at만 찍힌 상태가 돼요.
RPC의 WHERE에는 deleted_at 조건이 없었으니 그 장소들은 지도에도 검색에도 AI 추천에도 계속 노출됐어요.
데이터를 정리하며 삭제한 3곳이 며칠 동안 그대로 보이고 있었고 AI 챗 응답을 검증하다가 발견했어요.
문제는 RPC를 쓴 데 있지 않았어요. deleted_at이라는 새 상태를 추가했는데 그 상태를 해석하는 읽기 쿼리를 함께 바꾸지 않은 게 원인이었어요.
일반 쿼리였어도 같은 조건을 빠뜨렸다면 똑같은 문제가 생겼을 거예요.
쿼리 수정은 간단했어요. 모든 조회 RPC의 WHERE에 deleted_at IS NULL, 즉 "삭제 시각이 기록되지 않은 장소만"이라는 조건을 추가하면 돼요.
읽기 로직이 RPC에 모여 있어서 누락된 조건이 여러 기능에 공통으로 영향을 줬고 반대로 수정 지점도 명확했어요.
새 상태 컬럼을 추가할 때는 쓰기 경로뿐 아니라 그 상태를 해석하는 모든 읽기 경로의 조건도 함께 바꿔야 해요. 모토맵에서는 그 읽기 경로가 RPC였기 때문에 모든 조회 RPC를 점검했어요.
마무리
모토맵에서 PostGIS를 쓰며 배운 것들이에요.
ST_MakePoint(경도, 위도): 경도가 먼저예요. 순서가 바뀌어도 에러가 나지 않아 더 주의해야 해요.geometry는 평평한 지도,geography는 둥근 지구를 기준으로 계산한다고 생각하면 쉬워요. GPS 좌표로 실제 거리를 재는 모토맵은geography를 중심으로 사용해요.- cast는 같은 좌표를 계산하는 관점을 바꾸고
ST_Transform은 좌표 숫자를 다른 좌표계에 맞게 실제로 변환해요. - GiST 공간 인덱스는 책의 찾아보기처럼 가까울 가능성이 있는 후보를 먼저 좁혀요.
ST_DWithin은 반경 검색을,ST_Distance는 거리 표시와 정렬을 맡아요. - 점이든 선이든
ST_DWithin이 다 해줘요. 경로 주변 검색은 선을 만들기만 하면 반경 검색과 같은 문법이에요. - 선 위의 순서가 필요하면
ST_LineLocatePoint를 써요. 다만 0에서 1 사이의 값은 정확한 거리나 시간 비율과는 구분해야 해요. - Supabase RPC는 앱의 요청을 PostgreSQL 함수에 전달하는 통로예요. 함수 호출 권한인
EXECUTE와 데이터 열람 규칙인 RLS를 함께 설계해야 해요. - 새 상태 컬럼을 추가하면 그 상태를 해석하는 모든 쿼리와 함수의 조건을 함께 점검해요.
지도 기능을 만들 계획이 있다면 위치 계산을 앱에서 해결하려 하지 말고 PostGIS를 활용해보세요.
Supabase라면 확장 켜고 RPC 만드는 걸로 간단하게 시작할 수 있어요. 이 위치 검색이 실제 앱에서 어떻게 쓰이는지는 모토맵 소개 글에서 살펴볼 수 있어요.

