Redis Geo: Spring Boot로 가장 가까운 위치 찾기
1. 개요
“근처 상점 찾기” 기능은 대부분의 음식 배달, 택시 호출, 커피 체인 지도 앱에서 볼 수 있습니다. 직접 haversine 공식을 작성할 수 있습니다만, 이 방식으로 전체 위치 테이블을 스캔하면 데이터가 크면 느립니다.
Redis에는 이 문제를 해결하기 위한 Geo 명령어 세트가 있으며, 지연 시간이 단 몇 밀리초입니다. 이 글은 Redis에 좌표를 저장하고, GEOSEARCH를 이용해 가장 가까운 위치를 찾으며, 거리를 계산하고, Spring Boot 3.x에서 이런 작업들을 테스트하는 방법을 안내합니다.
2. Redis Geo는 어떻게 작동하는가
Redis에는 Geo 전용 데이터 타입이 없습니다. 각 좌표는 52비트 geohash로 인코딩되어 sorted set의 score로 저장됩니다. 가까운 점들은 보통 유사한 geohash를 가지므로, 덕분에 Redis는 전체 데이터셋이 아닌 몇 가지 점수 범위만 스캔하면 됩니다.
redis-cli로 빠르게 시도해보세요:
GEOADD coffee:hanoi 105.8547 21.0334 cafe-giang
GEOADD coffee:hanoi 105.8541 21.0251 cong-trang-tien
GEOADD coffee:hanoi 105.8128 21.0318 highlands-lotte
GEOSEARCH coffee:hanoi FROMLONLAT 105.8522 21.0287 BYRADIUS 2 km ASC WITHDIST
GEOADD 명령어는 경도 먼저, 위도 나중을 받습니다. GEOSEARCH는 Hoan Kiem 호 주변 2km 반경 내의 member를 찾아서, 가까운 것부터 먼 것까지 정렬 (ASC)하고 거리를 함께 반환 (WITHDIST)합니다:
1) 1) "cong-trang-tien"
2) "0.4465"
2) 1) "cafe-giang"
2) "0.5845"
highlands-lotte는 약 4km 떨어져 있어 나타나지 않습니다. 당신의 머신에서 결과의 마지막 소수 자리가 다를 수 있는데, Redis가 geohash와 구형 지구 모델을 사용하기 때문입니다.
본질적으로 sorted set이므로, ZRANGE, ZCARD, ZREM을 여전히 사용할 수 있습니다.
주의:
GEORADIUS와GEORADIUSBYMEMBER는 Redis 6.2부터 deprecated로 표시됩니다. 새 코드에서는GEOSEARCH를 사용하세요.
3. Spring Data Redis 설치
pom.xml에 Redis starter 추가:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.properties에서 연결 구성:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Spring Boot는 자동으로 StringRedisTemplate bean을 생성합니다. member가 보통 문자열 형식의 ID이므로 Geo에 적합한 선택입니다. opsForGeo()를 통해 Geo 명령어 세트에 액세스하며, GeoOperations<String, String>을 반환합니다.
4. 위치 저장
Geo 작업을 모두 포함하는 service 생성:
@Service
public class StoreLocatorService {
static final String KEY = "coffee:hanoi";
private final GeoOperations<String, String> geoOps;
public StoreLocatorService(StringRedisTemplate redisTemplate) {
this.geoOps = redisTemplate.opsForGeo();
}
public void addStore(String storeId, double longitude, double latitude) {
geoOps.add(KEY, new Point(longitude, latitude), storeId);
}
public void removeStore(String storeId) {
geoOps.remove(KEY, storeId);
}
}
설명:
Point는org.springframework.data.geo패키지에 속하며, 생성자는(x, y), 즉 **(경도, 위도)**를 받습니다.geoOps.add(...)는GEOADD와 동등합니다.storeId가 이미 존재하면 이전 좌표가 덮어씌워져서, 중복 레코드 걱정이 없습니다.geoOps.remove(...)는 실제로ZREM을 호출하는데, Geo가 sorted set이기 때문입니다.
한 번에 여러 점을 로드해야 할 때는 Map<String, Point>를 전달하여 모든 것을 한 번의 명령어로 보낼 수 있습니다:
public void addStores(Map<String, Point> stores) {
geoOps.add(KEY, stores);
}
이 방식은 네트워크 왕복을 절약합니다.
5. 가장 가까운 위치 찾기
5.1. 사용자 좌표로 찾기
가장 일반적인 경우는 애플리케이션이 사용자의 GPS 위치를 전송하는 것입니다. 주어진 반경 내에서 N개의 가장 가까운 매장을 반환해야 합니다. 먼저 결과를 위한 record를 선언합니다:
public record NearbyStore(String storeId, double distanceKm, double longitude, double latitude) {
}
그 다음 StoreLocatorService에 검색 메서드를 추가:
public List<NearbyStore> findNearest(double longitude, double latitude,
double radiusKm, int limit) {
var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.includeCoordinates()
.sortAscending()
.limit(limit);
GeoResults<RedisGeoCommands.GeoLocation<String>> results = geoOps.search(
KEY,
GeoReference.fromCoordinate(longitude, latitude),
new Distance(radiusKm, Metrics.KILOMETERS),
args);
if (results == null) {
return List.of();
}
return results.getContent().stream()
.map(r -> new NearbyStore(
r.getContent().getName(),
r.getDistance().getValue(),
r.getContent().getPoint().getX(),
r.getContent().getPoint().getY()))
.toList();
}
각 부분이 하는 일:
GeoReference.fromCoordinate(...)는FROMLONLAT에 해당하며, 거리를 측정할 기준점입니다.new Distance(radiusKm, Metrics.KILOMETERS)는BYRADIUS 2 km에 해당합니다. 여기서 선택한 단위도 반환되는 거리의 단위입니다.includeDistance()와includeCoordinates()는WITHDIST와WITHCOORD를 추가합니다.includeCoordinates()가 없으면getPoint()는null을 반환합니다.sortAscending()은 가까운 것부터 먼 것까지 정렬합니다.limit(limit)은COUNT를 추가합니다.
Hoan Kiem 호의 위치로 호출해봅시다:
storeLocatorService.findNearest(105.8522, 21.0287, 2, 5)
.forEach(s -> System.out.printf("%s: %.2f km%n", s.storeId(), s.distanceKm()));
cong-trang-tien: 0.45 km
cafe-giang: 0.58 km
5.2. 기존 위치 주변 찾기
때로 “이 매장 근처의 다른 매장들”을 추천하고 싶습니다. 좌표가 필요 없으며, member 자체를 중심으로 사용하면 됩니다:
public List<String> findStoresNear(String storeId, double radiusKm) {
var results = geoOps.search(
KEY,
GeoReference.fromMember(storeId),
new Distance(radiusKm, Metrics.KILOMETERS),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().sortAscending());
if (results == null) {
return List.of();
}
return results.getContent().stream()
.map(r -> r.getContent().getName())
.filter(name -> !name.equals(storeId))
.toList();
}
GeoReference.fromMember는 FROMMEMBER에 해당합니다. 결과는 항상 기준 member 자체를 거리 0으로 포함하므로, filter 줄처럼 필터링해야 합니다.
5.3. 직사각형 범위 내 찾기
사용자가 지도를 드래그할 때, 보통 표시되는 범위 내의 모든 점이 필요하며 원 모양이 아닙니다. 이때 BoundingBox를 사용합니다 (BYBOX에 해당):
public List<String> findInBox(double centerLon, double centerLat,
double widthKm, double heightKm) {
var box = new BoundingBox(
new Distance(widthKm, Metrics.KILOMETERS),
new Distance(heightKm, Metrics.KILOMETERS));
var results = geoOps.search(KEY,
GeoReference.fromCoordinate(centerLon, centerLat),
box,
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs());
return results == null ? List.of()
: results.getContent().stream().map(r -> r.getContent().getName()).toList();
}
BoundingBox는 너비와 높이로 범위를 정의하며, 중심이 참조 점입니다.
6. 거리 계산 및 좌표 조회
검색 외에도 GeoOperations는 두 가지 유용한 작업을 더 지원합니다:
public Distance distanceBetween(String storeA, String storeB) {
return geoOps.distance(KEY, storeA, storeB, Metrics.KILOMETERS);
}
public Point locationOf(String storeId) {
List<Point> points = geoOps.position(KEY, storeId);
return (points == null || points.isEmpty()) ? null : points.get(0);
}
distance는GEODIST에 해당합니다. 둘 중 하나의 member가 존재하지 않으면 메서드는null을 반환합니다.position은GEOPOS에 해당합니다. 반환 목록은 존재하지 않는 member의null요소를 가집니다.
다시 읽은 좌표는 저장한 값과 약간 다를 수 있습니다. 예를 들어, 105.8547 대신 105.85470169782638일 수 있습니다. 이는 52비트 geohash의 오차이며, 약 1미터 미만입니다. 원래 값과 equals로 비교하지 마세요.
7. Testcontainers로 테스트 작성
개발 머신에 의존하지 않고 실제 Redis에서 테스트를 실행하려면 Testcontainers를 사용합니다. dependency 추가:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
핵심 동작을 위한 테스트 작성:
@SpringBootTest
@Testcontainers
class StoreLocatorServiceTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Autowired
StoreLocatorService service;
@Autowired
StringRedisTemplate redisTemplate;
@BeforeEach
void setUp() {
redisTemplate.delete(StoreLocatorService.KEY);
service.addStore("cafe-giang", 105.8547, 21.0334);
service.addStore("cong-trang-tien", 105.8541, 21.0251);
service.addStore("highlands-lotte", 105.8128, 21.0318);
}
@Test
void shouldReturnStoresWithinRadiusSortedByDistance() {
List<NearbyStore> result = service.findNearest(105.8522, 21.0287, 2, 10);
assertThat(result)
.extracting(NearbyStore::storeId)
.containsExactly("cong-trang-tien", "cafe-giang");
assertThat(result.get(0).distanceKm()).isCloseTo(0.45, within(0.02));
}
@Test
void shouldRespectLimit() {
assertThat(service.findNearest(105.8522, 21.0287, 10, 1))
.extracting(NearbyStore::storeId)
.containsExactly("cong-trang-tien");
}
@Test
void shouldNotFindRemovedStore() {
service.removeStore("cafe-giang");
assertThat(service.findNearest(105.8522, 21.0287, 2, 10))
.extracting(NearbyStore::storeId)
.doesNotContain("cafe-giang");
}
}
@ServiceConnection(name = "redis")은 Spring Boot에게 container에서 호스트와 포트를 자동으로 구성하라고 알립니다. @DynamicPropertySource가 필요 없습니다. 거리 테스트는 isCloseTo(..., within(...))를 사용하는데, 섹션 6에서 언급했듯이 결과에 작은 오차가 있기 때문입니다.
8. 일반적인 오류 및 사용하지 말아야 할 때
경도와 위도 바꾸기. 가장 흔한 실수입니다. Google Maps는 “21.0287, 105.8522” (위도, 경도)로 표시하지만, Redis와 Point는 (경도, 위도)가 필요합니다. 바꾸면 Redis는 invalid longitude,latitude pair 오류를 보고할 수 있거나, 더 나쁜 경우 잘못된 위치에 점을 성공적으로 저장합니다.
주의: Redis는 ±85.05112878도 범위의 위도만 허용하며, 이는 Web Mercator 투영의 제한에 따릅니다. 두 극 근처의 점은 저장할 수 없습니다.
member에 상세 데이터 저장하기. member는 store:1024처럼 짧은 ID여야 합니다. member에 JSON 이름, 주소, 영업 시간을 넣지 마세요. Redis Hash 또는 데이터베이스에 상세 정보를 저장하세요. GEOSEARCH에서 ID 목록을 받은 후 한 번의 배치 호출로 상세 정보를 가져옵니다.
키가 너무 큽니다. 수천만 개의 점을 포함하는 sorted set은 big key입니다. 이 키를 삭제하거나 마이그레이션하면 Redis가 차단됩니다. Redis Cluster에서는 한 키의 모든 점이 같은 shard에 위치하므로, 도시나 지역별로 키를 분할해야 합니다. 예: coffee:hanoi, coffee:hcm.
member별 TTL이 없습니다. 지속적으로 업데이트되는 드라이버 위치에서 “죽은” member가 계속 남아있습니다. 별도의 정리 작업이 필요하며, 예를 들어 마지막 업데이트 타임스탬프를 저장하는 보조 sorted set을 두고 주기적으로 만료된 member를 삭제할 수 있습니다.
limit 없이 너무 큰 반경. 제한 없이 50km를 찾으면, 점이 많은 도시에서 수만 개 요소를 반환할 수 있습니다. 항상 합리적인 limit을 설정해야 합니다.
Redis Geo를 사용하지 말아야 할 때:
-
다각형(구역 경계, 배송 지역), 기하학적 교차점 또는 경로 찾기로 쿼리해야 하는 경우. PostGIS 또는 Elasticsearch geo를 사용하세요.
-
“가장 가까운, 영업 중, 주차장 있는” 같이 여러 조건을 조합하여 필터링해야 하는 경우. Redis Geo는 거리만으로 필터링하므로, 나머지 필터링은 애플리케이션 계층에서 수행해야 하거나 Redis Query Engine을 사용해야 합니다.
-
Redis가 유일한 저장소이고 지속성이 없는 경우. Geo는 속도 향상 인덱스여야 하며, 원본 데이터 소스는 데이터베이스에 있어야 합니다.
9. 결론
Redis Geo는 “가장 가까운 위치 찾기” 문제를 sorted set에서 몇 가지 간단한 명령어로 변환합니다. GEOADD로 좌표를 저장하고, GEOSEARCH로 반경 또는 직사각형 범위로 찾고, GEODIST로 거리를 계산하는 방법을 배웠습니다. 모든 것을 Spring Boot service로 패키징하고 Testcontainers로 테스트했습니다.
실제 적용할 때는 세 가지를 기억해야 합니다: 항상 경도를 먼저 전달하고, member를 짧은 ID로 유지하며, 데이터가 크면 지역별로 키를 분할하세요. 거리보다 더 복잡한 공간 쿼리가 필요하면 Redis를 지리학 데이터 전문 시스템과 결합해야 합니다.