지리공간 쿼리는 한 지점의 근처, 반경 안, 특정 영역 내부처럼 위치를 기준으로 레코드를 필터링하고 정렬하는 데이터베이스 조회입니다. 거의 모든 제품에서 두 번째 달쯤 도착하는 기능 요청, 즉 주변의 X 찾기를 구동하는 것이 바로 이 쿼리입니다. 카페, 운전자, 매장, 다른 사용자 등이 그 X입니다. 그리고 이는 일반적인 B-트리 인덱싱으로는 처리할 수 없는 쿼리 유형의 가장 명확한 사례입니다. 가까움은 2차원이고, 한 차원으로 정렬된 구조는 이를 볼 수 없습니다.
핵심 요약
| 질문 | 답변 |
|---|---|
| 필드 타입 | GeoPoint: 데이터베이스가 이해하는 위도/경도 쌍 |
| 쿼리 | near (거리순 정렬), 반경 내, 박스/폴리곤 내 |
| 인덱스 | 2dsphere 방식: 지구를 인덱싱 가능한 셀로 대응 |
| 인덱스가 없으면 | 모든 행까지의 거리 계산, 즉 삼각함수를 곁들인 전체 스캔 |
| 대표적인 기능 | 일반 필터와 조합한 “주변의 X 찾기” |
주변 검색 쿼리의 생성과 효과
// JavaScript / Node.js — Back4app JS SDK
// "Find cafes within 2 km, nearest first" — the canonical geo query
const here = new Parse.GeoPoint({ latitude: 40.7484, longitude: -73.9857 });
const query = new Parse.Query('Cafe');
query.withinKilometers('location', here, 2); // radius filter, nearest-first
query.equalTo('openNow', true); // geo + ordinary filters compose
query.limit(20);
const cafes = await query.find();
cafes.forEach((c) => {
const km = here.kilometersTo(c.get('location'));
console.log(`${c.get('name')} — ${km.toFixed(2)} km away`);
}); // Flutter / Dart — Back4app Flutter SDK
// "Find cafes within 2 km, nearest first" — the canonical geo query
final here = ParseGeoPoint(latitude: 40.7484, longitude: -73.9857);
final query = QueryBuilder<ParseObject>(ParseObject('Cafe'))
..whereWithinKilometers('location', here, 2) // radius filter, nearest-first
..whereEqualTo('openNow', true) // geo + ordinary filters compose
..setLimit(20);
final response = await query.query();
for (final result in response.results ?? []) {
final cafe = result as ParseObject;
final loc = cafe.get<ParseGeoPoint>('location');
print('${cafe.get<String>('name')} at '
'${loc?.latitude}, ${loc?.longitude}');
} // iOS / Swift — Back4app Swift SDK
// "Find cafes within 2 km, nearest first" — the canonical geo query
let here = try ParseGeoPoint(latitude: 40.7484, longitude: -73.9857)
let query = Cafe.query(
withinKilometers(key: "location", geoPoint: here, distance: 2),
"openNow" == true // geo + ordinary filters compose
).limit(20)
query.find { result in
if case .success(let cafes) = result {
for cafe in cafes {
print("\(cafe.name ?? "?") — \(String(describing: cafe.location))")
}
}
} // Android / Kotlin — Back4app Android SDK
// "Find cafes within 2 km, nearest first" — the canonical geo query
val here = ParseGeoPoint(40.7484, -73.9857)
val query = ParseQuery.getQuery<ParseObject>("Cafe")
query.whereWithinKilometers("location", here, 2.0) // radius, nearest-first
query.whereEqualTo("openNow", true) // geo + filters compose
query.limit = 20
query.findInBackground { cafes, e ->
if (e == null) cafes.forEach { cafe ->
val loc = cafe.getParseGeoPoint("location")
val km = loc?.distanceInKilometersTo(here)
println("${cafe.getString("name")} — ${"%.2f".format(km)} km away")
}
} 같은 원리를 PostGIS를 통해 SQL로 표현하면 다음과 같으며, 이를 실용적으로 만드는 인덱스도 함께 보입니다:
-- geography 열에 대한 공간 인덱스:
CREATE INDEX cafe_location_gix ON cafe USING GIST (location);
-- "2 km 이내의 카페, 가까운 순":
SELECT name, ST_Distance(location, ST_MakePoint(-73.9857, 40.7484)::geography) AS meters
FROM cafe
WHERE ST_DWithin(location, ST_MakePoint(-73.9857, 40.7484)::geography, 2000)
ORDER BY location <-> ST_MakePoint(-73.9857, 40.7484)::geography
LIMIT 20;
-- 인덱스 있음: 2 km 원을 덮는 셀만 건드림
-- 인덱스 없음: 테이블의 모든 행에 대해 거리를 계산함
인덱스가 지구를 보는 방식
2dsphere 방식의 인덱스는 곡면을 계층적 셀에 대응시키고, 그 셀의 정렬 순서가 인접성을 보존하도록 해서 가까움을 인덱싱 가능하게 만듭니다. 지구상에서 가까운 지점은 서로 가까운 인덱스 항목에 놓입니다. 그러면 반경 쿼리는 여느 인덱스 조회처럼 읽힙니다. 원을 덮는 소수의 셀을 식별하고, 그 후보를 스캔하고, 이 짧은 목록에서 정확한 거리를 검증합니다. 평면 수학이 아닌 구면 기하학 덕분에 실제 규모에서도, 날짜 변경선을 가로질러도, 극지방으로 가도 결과가 정확하게 유지됩니다.
조합 규칙은 일반적인 쿼리 메커니즘에서 나옵니다. 지리 조건은 동등, 범위, Pointer 필터(위 탭의 openNow == true)와 자유롭게 결합되며, 자주 실행되는 쿼리에서 선택도가 높은 비지리 필터에는 별도의 인덱스를 두는 것이 좋습니다.
페이지네이션은 특별히 언급할 필요가 있습니다. 거리순 정렬 결과는 오프셋 습관을 깨뜨리기 때문입니다. near 쿼리의 첫 페이지를 건너뛰면 엔진은 건너뛴 모든 항목의 순위를 다시 매겨야 하고, 요청 사이에 움직인 지점은 사용자가 보는 동안 순서를 뒤섞을 수 있습니다. 더 견고한 패턴은 “더 보기”에서 반경을 점진적으로 넓히거나, 고정된 반경 안에서 안정적인 키로 페이지를 나누는 것입니다. 가까운 순 정렬은 살아 있는 데이터에 대한 순위입니다. 페이지 경계는 장부의 행이 아니라 참고용으로 다뤄야 합니다.
near vs. within vs. 평면 쿼리
어떤 지리 쿼리를 사용해야 할까?
| 쿼리 형태 | 답하는 질문 | 정렬 | 일반적인 용도 |
|---|---|---|---|
| near (근접) | 여기서 가장 가까운 것은? | 가까운 순 | ”주변 카페” 목록, 운전자 매칭 |
| 반경 내 (within radius) | 여기서 r km 안에 있는 것은? | 함축된 정렬 없음 | 배달 가능 여부, 알림 |
| 박스 내 (within box) | 이 직사각형 안에 있는 것은? | 없음 | 지도 뷰포트 렌더링 |
| 폴리곤 내 (within polygon) | 이 도형 안에 있는 것은? | 없음 | 동네, 구역, 지오펜스 |
| 평면 (2d) | 같은 질문, 투영된 평면 위에서 | 경우에 따라 다름 | 게임 맵, 평면도. 지구가 아닌 것 |
near와 within의 구분은 현학이 아니라 제품 로직입니다. near는 순위이고(limit와 최대 반경으로 제한해야 합니다. 그러지 않으면 밀집한 도시에서 수천 개의 행이 반환됩니다), within은 술어입니다(뷰포트나 펜스 도형과 짝지어야 합니다). 평면 행은 정직한 각주입니다. 구 위에 있지 않은 좌표에는 구면 인덱스가 맞지 않는 도구입니다.
일반적인 사용 사례
- “주변의 X 찾기”. 카페, 헬스장, ATM, 충전소. 반경으로 제한하고 가까운 순으로 정렬한 대표적인 근접 목록입니다.
- 매칭과 배차. 승객과 운전자, 작업과 배달원을 연결합니다. 자주 갱신되는 GeoPoint에 대한 near 쿼리입니다.
- 지도 뷰포트. 사용자가 지도를 이동할 때 핀을 공급하는 박스 내 쿼리입니다. 저렴하고, 정렬이 없으며, 뷰포트 크기만큼만 조회합니다.
- 지오펜싱. 배달 구역, 서비스 지역, 위치에 따라 실행되는 로직을 위한 폴리곤 내 검사입니다.
- 지역 기반 소셜 기능. 주변 사람, 이번 주말 이벤트 같은 기능으로, 개인정보 필터 및 Pointer 기반 관계와 조합됩니다.
지리 쿼리를 쓸까, 지역을 미리 계산할까? 결정 매트릭스
| 지리 인덱스로 실시간 쿼리해야 하는 경우… | 지역 라벨을 미리 계산해야 하는 경우… |
|---|---|
| 반경과 도형이 요청마다 다를 때 | 경계가 고정되어 있을 때(매장, 구역, 행정구) |
| 지점이 자주 움직일 때(운전자, 사용자) | 소속이 거의 바뀌지 않을 때 |
| 가까운 순 정렬이 중요할 때 | 지역 일치로만 필터링할 때 |
| 도형이 진정으로 공간적일 때 | 단순한 region 열로 모든 질문에 답할 수 있을 때 |
| 공간 인덱스를 사용할 수 있을 때 | 측정이 아니라 그룹화를 할 때 |
탈출구가 중요합니다. 모든 쿼리가 “A 구역에 속하는가?”로 환원된다면, 인덱스가 걸린 평범한 문자열 열이 공간 기법보다 낫습니다. 더 저렴하고, 더 단순하며, 일반적인 B-트리로 처리됩니다. 공간 인덱싱은 기하가 동적일 때, 즉 임의의 중심, 움직이는 지점, 실제 거리가 필요할 때 정확히 그 값어치를 합니다.
한계와 트레이드오프
- 지리 인덱스는 특수 용도입니다. 공간 술어만 처리합니다. 다른 필터에는 여전히 별도의 인덱스가 필요하고, 인덱스 유지에는 쓰기 비용이 듭니다. 같은 세금이 공간 요율로 붙습니다.
- 제한 없는 near 쿼리는 자기 발등을 찍습니다. 반경 제한이나 limit 없이 밀집한 도시에서 가까운 순 정렬을 하면 테이블의 절반에 순위를 매깁니다. 검색 범위는 항상 제한해야 합니다.
- 움직이는 지점은 쓰기를 끊임없이 일으킵니다. 실시간 운전자 위치는 고빈도 GeoPoint 갱신을 뜻하며, 갱신마다 인덱스를 유지해야 합니다. 제품이 허용하는 범위에서 위치 갱신을 배칭하거나 스로틀링하는 것이 좋습니다.
- 기본 모델은 행당 지점 하나입니다. 분점이 다섯 곳인 매장은 레코드 다섯 개입니다. 경로나 서비스 범위처럼 점보다 풍부한 도형은 본격적인 공간 데이터베이스로 향하게 만듭니다.
- 정밀도에는 바닥이 있습니다. 구와 타원체의 오차는 무시할 만하지만, GPS 잡음은 조건이 좋을 때도 몇 미터에 이릅니다. 구역이나 임곗값 같은 제품 로직은 이를 감내하도록 설계해야 합니다.
Back4app에서의 지리공간 쿼리
Back4app은 관리형 데이터베이스, 자동 생성되는 REST 및 GraphQL API, 인증, 파일 스토리지, Cloud Code 서버리스 함수를 결합한 오픈소스 Backend as a Service(BaaS) 플랫폼입니다. GeoPoint는 MongoDB 기반의 관리형 저장소에서 기본 제공되는 열 타입이며, 근접, 반경, 박스 연산자인 withinKilometers, near, withinGeoBox는 위의 코드 탭에서 쓰인 그대로 자동 생성 API와 모든 SDK에 포함되어 있습니다. 지리 인덱스는 대시보드에서 나머지 인덱스 전략과 함께 관리되므로, 흔히 인프라 프로젝트가 되는 “주변의 X 찾기”가 스키마 결정 하나와 세 줄의 쿼리로 바뀝니다.
자주 묻는 질문
지리공간 쿼리란 무엇입니까?
값이 아니라 공간을 기준으로 필터링하는 데이터베이스 조회입니다. 한 지점 근처, 반경 안, 박스나 폴리곤 내부에 있는 레코드를 반환하며, 보통 가까운 순으로 정렬합니다. 데이터베이스는 좌표를 일급 타입, 즉 위도와 경도로 이루어진 GeoPoint로 저장하고, 공간 인덱스는 모든 행까지의 거리를 계산하지 않고도 이 질문에 답합니다.
GeoPoint 필드란 무엇입니까?
위도/경도 쌍을 담는 열 타입으로, 위도는 -90에서 90, 경도는 -180에서 180 사이입니다. 데이터베이스가 이 필드를 단순한 실수 두 개가 아니라 지리 정보로 인식하기 때문에 공간 인덱스를 걸 수 있고, 거리 연산자를 노출할 수 있습니다. 여기서 몇 킬로미터 또는 몇 마일 이내, 이 바운딩 박스 안, 이 폴리곤에 포함, 근접 순 정렬 같은 연산입니다.
2dsphere 방식의 지리 인덱스는 어떻게 작동합니까?
지구의 곡면을 정렬되고 인덱싱 가능한 셀로 대응시킵니다. 굵은 셀을 더 작은 셀로 나누는 방식이어서, 지구상의 가까움이 인덱스 안에서의 인접성이 됩니다. near 쿼리는 모든 행이 아니라 검색 영역을 덮는 소수의 셀만 살펴보며, 구면 기하학 덕분에 날짜 변경선(antimeridian)을 가로지르거나 극지방 근처에서도 거리가 정확하게 유지됩니다.
지리 인덱스가 없으면 주변 검색이 왜 느립니까?
인덱스가 없으면 엔진은 기준 지점에서 모든 행까지의 거리를 계산한 뒤 전체를 정렬해야 하기 때문입니다. 삼각함수를 곁들인 전체 스캔이 요청마다 반복되는 셈입니다. 지리 인덱스는 이 작업을 뒤집습니다. 검색 반경을 덮는 셀로 곧장 이동해 가능성 있는 후보만 건드립니다. 테이블이 커질수록 격차는 벌어지며, 다른 누락된 인덱스와 똑같지만 행당 비용이 더 큽니다.
near 쿼리와 within 쿼리의 차이는 무엇입니까?
near 쿼리는 "가장 가까운 것은 무엇인가?"에 답합니다. 한 지점으로부터의 거리로 정렬하며, 보통 limit나 최대 반경으로 결과를 제한합니다. within 쿼리는 "안에 있는 것은 무엇인가?"에 답합니다. 반경, 박스, 폴리곤 안에 포함되는지로 필터링하며 순서를 함축하지 않습니다. 가까운 카페 목록은 near 쿼리이고, "이 지도 뷰포트 안의 매장"과 지오펜스 검사는 within 쿼리입니다.
지리 필터를 일반 쿼리 필터와 결합할 수 있습니까?
그렇습니다. 실제 기능은 거의 항상 결합합니다. 2 km 이내이면서 현재 영업 중이고 별점이 4점을 넘는 곳을 찾는 식입니다. 지리 조건은 하나의 쿼리 안에서 동등, 범위, Pointer 필터와 함께 조합됩니다. 인덱싱과의 상호작용에 유의해야 합니다. 공간 인덱스가 먼저 위치로 범위를 좁히며, 선택도가 높은 일반 필터에는 별도의 인덱스를 두어야 결합된 쿼리가 저렴하게 유지될 수 있습니다.
먼 거리에서 반경 쿼리는 얼마나 정확합니까?
구면 계산은 지구를 구로 모델링하는데, 이는 실제 타원체와 최대 약 0.3% 정도 차이가 납니다. 도시 규모의 거리에서는 몇 미터 수준이며 제품 로직에는 대개 무관합니다. 그보다 먼저 문제가 되는 것은 저장된 지점 자체의 정밀도입니다. 휴대폰 GPS에서 얻은 좌표는 실외에서 몇 미터, 실내에서는 그 이상의 오차를 지닙니다. "주변의 X 찾기"에서는 두 효과 모두 잡음에 불과합니다.