Theme:

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

개념 정의

Redis Geospatial은 **위도(latitude)**와 경도(longitude) 좌표를 저장하고, 반경 검색이나 거리 계산을 수행하는 기능입니다. 내부적으로는 Sorted SetGeohash 인코딩 값을 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 영역)

이 특성 덕분에 Sorted Set의 범위 쿼리로 근접 검색이 가능

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]

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

정리

Redis Geospatial은 위치 기반 검색을 간단하게 구현할 수 있는 도구입니다.

  • 내부적으로 Sorted Set + Geohash를 사용하여 일반 Sorted Set 명령어와 호환됩니다
  • Geohash의 공간적 지역성 덕분에 범위 검색이 O(log N + M)으로 효율적입니다
  • GEOSEARCH로 원형/사각형 범위 검색, 거리순 정렬, 좌표/거리 포함 등 다양한 옵션을 제공합니다
  • 거리 계산은 Haversine 공식을 사용하며 약 0.5% 오차가 있습니다
  • 주변 가게 검색, 실시간 위치 추적, 지오펜싱 등에 적합합니다
  • 복잡한 공간 쿼리(다각형 검색, 경로 탐색 등)가 필요하면 PostGIS 같은 전용 공간 DB를 고려해야 합니다
댓글 로딩 중...