"내 주변 1km 이내의 식당을 찾아줘" — 이 기능을 데이터베이스 없이 Redis만으로 구현할 수 있을까요?

개념 정의

Redis Geospatial은 위도(latitude) 와 경도(longitude) 좌표를 저장하고, 반경 검색이나 거리 계산을 수행하는 기능입니다. 내부적으로는 Sorted Set 에 Geohash 인코딩 값을 score로 저장하는 방식으로 구현되어 있습니다.

왜 필요한가

위치 기반 검색은 많은 서비스에서 핵심 기능입니다.

  • ** 배달 앱 **: 내 주변 음식점 검색
  • ** 택시 호출 **: 가까운 기사 매칭
  • ** 소셜 서비스 **: 근처 사용자 탐색
  • ** 부동산 **: 특정 지역 내 매물 검색

RDBMS의 공간 인덱스보다 간단하고 빠른 위치 검색이 필요할 때 Redis Geospatial이 유용합니다.

기본 명령어

GEOADD — 위치 추가

BASH
# GEOADD key longitude latitude member
GEOADD restaurants 126.977 37.5665 "서울시청"
GEOADD restaurants 127.0276 37.4979 "강남역"
GEOADD restaurants 126.9784 37.5696 "광화문"

# 여러 위치 한 번에 추가
GEOADD restaurants \
  127.0596 37.5085 "삼성역" \
  126.9368 37.5553 "홍대입구" \
  127.0146 37.5052 "역삼역"

# 옵션 (6.2+)
GEOADD restaurants NX 127.0 37.5 "new_place"  # 없을 때만 추가
GEOADD restaurants XX 127.0 37.5 "서울시청"    # 있을 때만 업데이트

GEOPOS — 좌표 조회

BASH
GEOPOS restaurants "서울시청" "강남역"
# 1) 1) "126.97700"
#    2) "37.56650"
# 2) 1) "127.02760"
#    2) "37.49790"

# 존재하지 않는 멤버
GEOPOS restaurants "없는곳"
# 1) (nil)

GEODIST — 두 지점 간 거리

BASH
# 기본 단위: 미터
GEODIST restaurants "서울시청" "강남역"
# "8043.1234"  (약 8km)

# 단위 지정: m(미터), km(킬로미터), mi(마일), ft(피트)
GEODIST restaurants "서울시청" "강남역" km
# "8.0431"

GEODIST restaurants "서울시청" "광화문" m
# "358.9876"  (약 359m)

GEOHASH — Geohash 문자열 조회

BASH
GEOHASH restaurants "서울시청" "강남역"
# 1) "wydm6de0460"  (11자 Geohash)
# 2) "wydm78xf0z0"

GEOSEARCH — 범위 검색 (6.2+)

GEOSEARCH는 이전의 GEORADIUS/GEORADIUSBYMEMBER를 대체하는 통합 명령어입니다.

원형 검색 (BYRADIUS)

BASH
# 서울시청 기준 5km 반경 내 검색
GEOSEARCH restaurants FROMMEMBER "서울시청" BYRADIUS 5 km ASC

# 좌표 기준 검색
GEOSEARCH restaurants FROMLONLAT 126.977 37.5665 BYRADIUS 5 km ASC

# 옵션
GEOSEARCH restaurants FROMMEMBER "서울시청" BYRADIUS 10 km \
  ASC \                  # 가까운 순 정렬 (DESC: 먼 순)
  COUNT 5 \              # 최대 5개
  WITHCOORD \            # 좌표 포함
  WITHDIST               # 거리 포함

# 결과 예시:
# 1) "광화문"
#    1) "0.3590"         (거리 km)
#    2) 1) "126.97840"   (경도)
#       2) "37.56960"    (위도)
# 2) "홍대입구"
#    1) "2.8430"
#    2) ...

사각형 검색 (BYBOX)

BASH
# 서울시청 중심 가로 10km × 세로 5km 사각형 검색
GEOSEARCH restaurants FROMMEMBER "서울시청" BYBOX 10 5 km ASC \
  WITHCOORD WITHDIST COUNT 10

GEOSEARCHSTORE — 결과를 새 키에 저장

BASH
# 검색 결과를 새로운 Sorted Set에 저장
GEOSEARCHSTORE nearby restaurants \
  FROMLONLAT 126.977 37.5665 BYRADIUS 3 km ASC COUNT 10

# 결과 확인 (일반 Sorted Set 명령어 사용)
ZRANGE nearby 0 -1 WITHSCORES

# STOREDIST: score에 거리를 저장
GEOSEARCHSTORE nearby restaurants \
  FROMLONLAT 126.977 37.5665 BYRADIUS 3 km ASC STOREDIST

Geohash 인코딩의 원리

공간 분할

Geohash는 지구 표면을 ** 재귀적으로 2등분 **하여 인코딩합니다.

PLAINTEXT
1단계: 경도를 2등분
  서쪽(-180~0): 0, 동쪽(0~180): 1
  → 서울(127°E) → 1

2단계: 위도를 2등분
  남쪽(-90~0): 0, 북쪽(0~90): 1
  → 서울(37.5°N) → 1

3단계: 경도를 다시 2등분
  (0~90): 0, (90~180): 1
  → 서울(127°) → 1

... 52비트까지 반복
PLAINTEXT
결과 비트열: 경도 비트와 위도 비트를 교대로 배치
경도: 1 1 0 1 ...
위도: 1 0 1 1 ...
Geohash: 1 1 1 0 0 1 1 1 ...
          ^ ^   ^   ^
          경 위 경  위

핵심 특성: 공간적 지역성

PLAINTEXT
Geohash prefix가 같을수록 가까운 지점
wydm6 → 서울 시청 부근 (~5km × 5km 영역)
wydm  → 서울 전체 (~20km × 20km 영역)
wyd   → 서울/경기 (~80km × 80km 영역)

Geohash는 공간을 재귀적으로 분할하여 인코딩하기 ** 때문에 , 가까운 지점은 비슷한 해시값(같은 prefix)을 갖게 됩니다. ** 따라서 Sorted Set의 score 범위 쿼리만으로 근접 검색이 가능합니다.

Redis에서의 Geohash

PLAINTEXT
Redis는 52비트 Geohash를 Sorted Set의 score(double)로 저장
→ ZRANGEBYSCORE로 특정 Geohash 범위를 빠르게 검색
→ O(log N + M) — N: 전체 멤버 수, M: 결과 수

Sorted Set과의 호환

Geospatial은 Sorted Set이므로 일반 명령어를 그대로 사용할 수 있습니다.

BASH
# 멤버 수 확인
ZCARD restaurants

# 멤버 삭제
ZREM restaurants "삼성역"

# 멤버 존재 확인
ZSCORE restaurants "서울시청"
# Geohash 값(score)이 반환됨

# SCAN
ZSCAN restaurants 0 MATCH "*역"

실전 패턴

패턴 1: 주변 가게 검색 API

PYTHON
def add_store(store_id, lng, lat, name):
    """가게 위치 등록"""
    r.geoadd('stores', lng, lat, f"{store_id}:{name}")

def search_nearby(lng, lat, radius_km=3, count=20):
    """주변 가게 검색"""
    results = r.geosearch(
        'stores',
        longitude=lng,
        latitude=lat,
        radius=radius_km,
        unit='km',
        sort='ASC',
        count=count,
        withcoord=True,
        withdist=True
    )
    stores = []
    for item in results:
        member = item[0]  # "store_id:name"
        dist = item[1]    # 거리 (km)
        coord = item[2]   # (lng, lat)
        store_id, name = member.split(':', 1)
        stores.append({
            'id': store_id,
            'name': name,
            'distance_km': float(dist),
            'lng': float(coord[0]),
            'lat': float(coord[1])
        })
    return stores

패턴 2: 실시간 위치 추적 (택시, 배달)

PYTHON
def update_driver_location(driver_id, lng, lat):
    """기사 위치 업데이트"""
    r.geoadd('drivers:active', lng, lat, driver_id)
    r.expire('drivers:active', 300)  # 5분 TTL (전체 키)

def find_nearest_drivers(lng, lat, count=5):
    """가장 가까운 기사 찾기"""
    results = r.geosearch(
        'drivers:active',
        longitude=lng, latitude=lat,
        radius=10, unit='km',
        sort='ASC', count=count, withdist=True
    )
    return [(driver_id, float(dist)) for driver_id, dist in results]

기사와 고객 간 거리를 계산하려면, 임시 키에 두 지점을 추가한 뒤 GEODIST로 구면 거리를 구합니다.

PYTHON
def get_driver_distance(driver_id, customer_lng, customer_lat):
    """기사와 고객 간 거리"""
    r.geoadd('temp:dist', customer_lng, customer_lat, 'customer')
    r.geoadd('temp:dist', *r.geopos('drivers:active', driver_id)[0], driver_id)
    dist = r.geodist('temp:dist', driver_id, 'customer', 'km')
    r.delete('temp:dist')
    return float(dist) if dist else None

패턴 3: 지오펜싱 (Geofencing)

PYTHON
def setup_geofence(fence_id, lng, lat, radius_km):
    """지오펜스 등록"""
    r.hset(f"geofence:{fence_id}", mapping={
        'lng': lng, 'lat': lat, 'radius': radius_km
    })

def check_geofence(fence_id, user_lng, user_lat):
    """사용자가 지오펜스 안에 있는지 확인"""
    fence = r.hgetall(f"geofence:{fence_id}")
    if not fence:
        return False

    # 임시 키에 두 지점을 추가하여 거리 계산
    temp_key = f"temp:fence:{fence_id}"
    r.geoadd(temp_key,
             float(fence['lng']), float(fence['lat']), 'center',
             user_lng, user_lat, 'user')
    dist = r.geodist(temp_key, 'center', 'user', 'km')
    r.delete(temp_key)

    return float(dist) <= float(fence['radius'])

성능과 제약

시간 복잡도

명령어시간 복잡도비고
GEOADDO(log N)Sorted Set 삽입
GEODISTO(1)score에서 좌표 복원 후 계산
GEOSEARCHO(N+log(N))N은 결과 수
GEOPOSO(1)score에서 좌표 복원

제약사항

PLAINTEXT
1. 좌표 범위: 경도 -180~180, 위도 -85.05~85.05
   → 남/북극 근처 지원 불가

2. 거리 계산: Haversine 공식 (지구를 구로 가정)
   → 최대 ~0.5% 오차 (지구는 완벽한 구가 아님)

3. 52비트 Geohash 정밀도
   → 약 0.6mm 수준의 좌표 정밀도
   → 실용적으로는 충분

4. 고도(altitude) 미지원
   → 2D 좌표만 저장 가능

대용량 처리 시 주의

BASH
# 멤버가 수백만 개일 때 넓은 반경 검색은 비용이 큼
# COUNT로 결과 수를 제한하는 것이 중요
GEOSEARCH stores FROMLONLAT 127 37.5 BYRADIUS 100 km COUNT 50 ASC

# ANY 옵션 (6.2+): COUNT에 도달하면 즉시 반환
# 정렬하지 않으므로 가장 가까운 순서가 아닐 수 있음
GEOSEARCH stores FROMLONLAT 127 37.5 BYRADIUS 100 km COUNT 50 ANY

함정/Pitfall

1. Geohash 경계(edge)에서 가까운 점이 다른 bucket에 속할 수 있다

Geohash는 공간을 격자로 분할하기 때문에, 격자 경계 바로 양쪽에 있는 두 점은 prefix가 완전히 다를 수 있습니다. Redis의 GEOSEARCH는 이 문제를 인접 격자까지 검색하여 보완하지만, 직접 Geohash prefix로 검색할 때는 주의가 필요합니다.

2. 개별 멤버에 TTL을 설정할 수 없다

Geospatial은 Sorted Set이므로, 개별 멤버(위치)에 TTL을 설정할 수 없습니다. 오래된 위치 데이터를 정리하려면 별도의 타임스탬프 관리와 주기적 삭제 로직이 필요합니다.

3. 넓은 반경 검색은 비용이 크다

수백만 멤버가 있는 키에서 반경 100km 검색을 하면 많은 결과가 반환되어 서버를 블로킹할 수 있습니다. 반드시 COUNT 옵션으로 결과 수를 제한해야 합니다.

정리

항목핵심 내용
내부 구조Sorted Set + 52비트 Geohash를 score로 저장
범위 검색GEOSEARCH로 원형/사각형 검색, O(log N + M)
거리 계산Haversine 공식, 약 0.5% 오차
호환성일반 Sorted Set 명령어(ZCARD, ZREM 등) 사용 가능
적합 사례주변 가게, 실시간 위치 추적, 지오펜싱
한계다각형 검색, 경로 탐색 필요 시 PostGIS 고려
댓글 로딩 중...