개요
오늘은 지난번에 소개 드린 "올봄" 프로젝트의 1. 성능 개선을 진행해보려고 합니다.
성능을 개선하는 방법에는 데이터베이스 쿼리 튜닝, 비즈니스 로직 개선 등 다양한 방법이 있습니다.
그 중에서도 이번 성능 개선편에서는 Redis를 활용한 캐시 도입을 통해 성능 최적화를 이루는 방법에 대해 소개해보려 합니다.
Redis에 대한 자세한 설명은 아래 링크에 첨부해두었습니다.
https://esssun.tistory.com/142
이번에 개선할 프로젝트인 AI 기반 장년층 라이프 케어 서비스 "올봄"에서는 챗봇, 지도, ToDo, 게임, 일자리 총 5가지 기능이 있습니다.
이 중에서도 저는 지도 기능에서 로직 개선 및 성능 개선을 이루었습니다.
캐시 도입 이유
1. 지도에 들어가는 데이터(병원, 복지시설, 케어센터, 복지관, 복지주택)는 업데이트 주기성이 낮은 데이터이다.
- 위의 타입의 데이터들을 미리 API 호출하거나 pdf/csv 파일에서 필요한 정보만을 정제한 후 S3에 json 형태로 적재시켜놓았다.
2. 지도의 데이터들은 (쓰기가 거의 일어나지 않는) 읽기 위주의 데이터들이다. 따라서, 캐싱에 적합하다.
- 현재는 관리자가 주기적으로 (한두달에 한 번씩) 데이터를 업데이트하여 S3에 적재해놓는 방식이다.
Spring batch를 이용하여 특정 시기에 데이터 업데이트가 자동으로 이루어지도록 로직 구현을 하려 했으나, 데이터들을 정제하여 S3에 적재해놓는 작업부터가 현재로서는 자동화가 불가능하므로 이는 생략하였다. 그 이유는 지도에 들어가는 데이터들을 여러 군데(API, .csv, .pdf)에서 받아오기 때문이다. (추후 고민하여, 로직 개선할 에정)
Redis Geospatial 도입 이유
해당 자료구조를 이용하면 여러 지리 데이터를 직접 구현없이 빠른 성능으로 제공 가능하기 때문이다.
- 효율적인 위치 데이터 저장 및 조회: Redis의 Geospatial 자료구조는 좌표(위도 및 경도)를 효율적으로 저장할 수 있어 특정 위치를 기준으로 근처에 있는 지점들을 빠르게 조회할 수 있습니다.
- 빠른 성능: Redis는 인메모리 데이터 저장소로, 빠른 읽기 및 쓰기 성능을 제공합니다. 위치 데이터를 조회할 때도 매우 짧은 응답 시간을 유지할 수 있습니다.
- 간편한 사용: Redis는 GEOADD, GEORADIUS, GEODIST와 같은 명령어들을 통해 간편하게 위치 데이터를 추가하고 조회할 수 있는 기능을 제공하여 개발자 입장에서 구현이 간편합니다.
- 정확한 거리 계산: Redis의 Geospatial 기능은 하버사인 공식을 사용해 두 지점 사이의 실제 거리를 계산할 수 있어, 위치 기반 서비스에서 중요한 거리 정보를 정확하게 제공할 수 있습니다.
Redis Geospatial & Geohash
Geospatial 란?
Redis Geo는 지구가 완전한 구라고 가정한다. 따라서 최대의 경우 0.5% 정도 오차가 발생할 수 있습니다.
또한, 위경도를 기준으로 거리를 게산하기 위해 Haversine formula를 사용합니다.
Geospatial이란 지도상의 object들의 위치인 지리 데이터를 의미합니다.
우리가 자주 사용하는 배달의 민족 등의 서비스에서는 이러한 geospatial 데이터를 활용해 서비스를 제공합니다.
Geospatail을 이용해서 아래와 같은 명령을 할 수 있습니다.
- 경도/위도 입력 : GEOADD
- 경도/위도 조회 : GEOPOS
- 거리 조회 : GEODIST
- 주변 지점 조회 : GEOSEARCH
- 해시값 조회 : GEOHASH
- 범위 조회 : ZRANGE
- 삭제 : ZREM
- 개수 조회 : ZCARD
Redis를 사용하면 대규모 geospatail 객체 데이터의 저장 및 조회를 매우 낮은 지연성으로 구현할 수 있습니다.
Geospatial Index
Redis.io에서는 Redis Geospatial을 이렇게 설명하고 있습니다.
Redis Geospatial 인덱스를 사용하면 좌표를 저장하고 검색할 수 있습니다. 이 자료 구조는 주어진 반경이나 경계 상자 내에서 가까운 지점을 찾는데 유용합니다.
Redis Geospatial은 Redis의 sorted set 자료구조를 사용하여 위치 정보를 저장한다. 이때 내부적으로는 위경도, Geohash로 인코딩한 값을 저장하게 됩니다.
sorted set 자료구조는 score을 활용하여 내부적으로 정렬하는 특징을 가지고 있다. Geospatial에서는 GeoHash 값을 Redis에서 별도로 변환하여 score로 저장합니다.
Redis GeoHash를 직접 사용하여 위치를 저장하고 가까운 사용자를 탐색하는 로직을 작성할 수도 있지만, Redis Geospatial을 사용하면 제공되는 명령어를 사용하여 훨씬 간단하게 구현할 수 있습니다.
물론 인메모리인 Redis를 사용하게 되므로 RDB를 사용했을때보다 속도적인 측면에서 성능적인 이점도 얻을 수 있습니다.
Geohash란?
Redis는 geospatial object의 데이터인 longitude와 latitude의 쌍을 저장할 때 실제로는 Geohash 값을 저장합니다.
Geohash는 52bit 정수로부터 인코딩된 11자리 문자열입니다.
위의 그림에서 Union Coffee라는 카페가 있다고 가정할 때, 이 카페의 경도와 위도를 -123/+12라고 할 경우 이는 Union Coffee의 지리정보인 Geospatial data라고 할 수 있습니다.
이러한 데이터를 Redis는 Geohash를 사용해 "c2672gnx8p0"로 hash 했습니다.
이제 이 hashed string은 Geopoints라는 key의 sorted set에 담겨지게 됩니다.
sorted set은 데이터를 저장할 때 order의 기준을 정하기 위해 score값을 요구하기 때문에, Redis가 Geohash 값을 확인해 score을 입력해줍니다.
결과적으로 Geopoints라는 sorted set에는 1558859838165330의 score을 가진 Union Coffee라는 멤버가 담기게 되는 것입니다.
이 때 이 멤버의 실제 value는 hashed string인 c2672gnx8p0입니다.
이제 사용자는 hashed string인 c2672gnx8p0을 활용해 역으로 Union Coffee의 경도와 위도를 확인할 수 있습니다.
Redis Cli
GEOADD
Redis Cli에서는 GEOADD 명령어를 사용하여 지정한 키에 위치 데이터를 저장할 수 있습니다.
다음은 "HOSPITAL"이라는 geokey의 sorted set에 id가 13300인 데이터의 geospatial 정보(경도, 위도, id)를 저장하는 예시입니다.
아래 예시에서 id는 식별자 역할을 합니다. 이 식별자는 Sorted Set에 저장되며, 고유해야 합니다. 즉, 같은 키 내에서 시설명(식별자)이 중복되면 기존 데이터를 덮어씁니다.
시간 복잡도 : O(logN)이며, N은 Sorted Set에 저장된 멤버의 개수
GEOADD HOSPITAL 126.823162 35.191378 "13300"
GEOSEARCH
Redis Cli에 GEOSEARCH 명령어를 사용하여 특정 좌표를 기준으로 반경 내의 좌표 데이터를 검색할 수 있습니다.
# 반경 100km 내에 있는 시설 검색
GEOSEARCH HOSPITAL FROMLONLAT 13.361389 38.115556 BYRADIUS 100 KM
추가 옵션
- FROMLONLAT : 검색할 기준 좌표를 지정합니다.
- BYRADIUS : 반경을 설정합니다. 100km와 같이 거리를 설정합니다.
- ASC/DESC : 결과를 오름차순(ASC) 또는 내림차순(DESC)으로 정렬할 수 있습니다.
- WITHCOORD : 검색된 항목에 좌표를 포함합니다.
- WITHDIST : 검색된 항목에 기준 좌표로부터의 거리를 포함합니다.
# Sicily 좌표를 기준으로 반경 100km 내의 시설 검색 (좌표와 거리 포함)
GEOSEARCH HOSPITAL FROMLONLAT 13.361389 38.115556 BYRADIUS 100 KM WITHCOORD WITHDIST
Redis GeoSpatial 도입
RedisConfig.java
@Bean
public RedisTemplate<String, String> redisStringTemplate() {
final RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // JSON 직렬화를 위한 Serializer
redisTemplate.setEnableTransactionSupport(true);
return redisTemplate;
}
FacilityService.java
1. Redis에 데이터 추가하는 함수
public void saveFacilityToRedis(Facility facility) {
GeoOperations<String, String> geoOperations = redisStringTemplate.opsForGeo();
Point point = new Point(facility.getLongitude(), facility.getLatitude());
String geoKey = facility.getType().toString();
geoOperations.add(geoKey, point, facility.getId().toString());
}
2. Redis에서 데이터 조회하는 함수
public List<RedisGeoCommands.GeoLocation<String>> findNearestFacilities(double latitude, double longitude, double radius, int maxResults, String geoKey) {
// 검색 옵션 정의: 좌표와 거리 포함, 오름차순 정렬, 최대 결과 개수 제한
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeCoordinates() // 좌표 포함
.includeDistance() // 거리 포함
.sortAscending() // 오름차순 정렬 (가까운 거리부터)
.limit(maxResults); // 최대 결과 수 제한
// 중심 좌표와 반경 정의 (Point는 (longitude, latitude) 순서로 입력됨)
Circle searchArea = new Circle(new Point(longitude, latitude), new Distance(radius, RedisGeoCommands.DistanceUnit.KILOMETERS));
// Redis에서 가장 가까운 시설 검색
GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults = redisStringTemplate.opsForGeo()
.radius(geoKey, searchArea, args);
// GeoResult에서 GeoLocation 객체만 추출하여 리스트로 반환
return geoResults.getContent().stream()
.map(GeoResult::getContent) // GeoResult에서 GeoLocation 추출
.collect(Collectors.toList());
}
[함수 개요]
- 사용자의 현재 위치(위경도), 찾을 반경, 최대 반환 갯수, geokey을 인자로 받습니다.
- 이때, geokey는 Redis에 저장된 지리적 좌표 데이터를 식별하는 키로, 시설의 타입을 나타냅니다.
- 다음 5가지 값 중 하나입니다.
- HOSPITAL, PHARMACY, WELFAREHOUSE, WELFARECENTER, CARECENTER
- 요청된 시설 타입과 일치하고, 사용자의 현재 위치와 가까운 몇 km 반경 내의 시설 리스트를 사용자와 가까운순으로 정렬하여 반환합니다.
- 반환타입 : 가까운 시설들의 위치 정보를 담은 리스트를 반환합니다. 각 항목은 GeoLocation<String> 타입으로, 좌표와 시설의 이름을 포함합니다.
[함수 설명]
1) 검색 옵션 정의
GeoRadiusCommandArgs는 Radius의 GEO 명령어에 전달될 검색 옵션을 정의하는 객체입니다.
- includeCoordinates() : 검색되 각 항목의 좌표(위도, 경도)를 반환합니다.
- includeDistance() : 검색된 각 항목과 기준점 간의 거리를 반환합니다.
- sortAscending() : 가까운 순으로 결과를 정렬합니다. 즉, 중심 좌표에 가장 가까운 결과부터 반환합니다.
- limit(maxResults) : 반환할 최대 결과 수를 제한합니다.
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeCoordinates() // 좌표 포함
.includeDistance() // 거리 포함
.sortAscending() // 오름차순 정렬 (가까운 거리부터)
.limit(maxResults); // 최대 결과 수 제한
2) 중심 좌표와 검색 반경 정의
검색 영역을 설정합니다. (Circle)
- Circle 객체는 검색 영역을 정의합니다. 이 객체는 중심 좌표와 반경을 기준으로 시설을 검색할 수 있는 공간을 설정합니다.
- Point : 중심 좌표를 설정합니다. Point의 생성자는 (경도, 위도) 순서로 좌표를 받습니다. 즉, 사용자의 현재 위경도를 중심 좌표로 설정합니다.
- Distance : 반경을 설정합니다. 검색 반경은 인자로 받은 radius입니다. 거리 단위는 RedisGeoCommands.DistanceUnit.KILOMETERS를 사용하여 킬로미터 단위로 설정합니다.
Circle searchArea = new Circle(new Point(longitude, latitude), new Distance(radius, RedisGeoCommands.DistanceUnit.KILOMETERS));
3) Redis에서 시설 검색
- redisStringTemplate.opsForGeo(): Redis에서 GEO 관련 명령어를 실행할 수 있는 객체를 가져옵니다.
- radius(geoKey, searchArea, args) : geoKey로 식별되는 Redis에 저장된 데이터를 기준으로, searchArea로 설정된 영역 내에서 가장 가까운 시설을 검색합니다.
- geoKey : Redis에 저장된 좌표 데이터를 식별하는 키입니다.
- searchArea : 앞서 정의한 중심 좌표와 반경으로 설정된 검색 영역입니다.
- args : 검색 옵션을 지정한 GeoRadiusCommandArgs 객체입니다.
- 결과는 GeoResults<geolocation> 타입으로 반환됩니다. 이 객체는 검색된 시설들에 대한 결과 리스트를 포함하고 있습니다. </geolocation
GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults = redisStringTemplate.opsForGeo()
.radius(geoKey, searchArea, args);
4) 반환 타입 설정 - GeoLocation<String> 객체만 추출
- geoResults.getContent() : GeoResults에서 실제 검색된 결과 목록을 가져옵니다. 이 목록은 List<GeoResult<GeoLocation<String>>> 타입입니다.
- 스트림 API를 사용하여 GeoResult 객체를 처리합니다.
- map(GeoResult::getContent) : GeoResult 객체에서 실제 좌표 데이터인 GeoLocation<String> 객체를 추출합니다.
- collect(Collectors.toList()) : 추출된 GeoLocation<String> 객체들을 리스트로 수집하여 반환합니다.
return geoResults.getContent().stream()
.map(GeoResult::getContent) // GeoResult에서 GeoLocation 추출
.collect(Collectors.toList());
정리
- 중심 좌표와 검색 반경을 설정합니다.
- Redis에서 중심 좌표를 기준으로 radius 반경 내에서 가장 가까운 시설들을 검색합니다.
- 검색된 결과에서 좌표와 시설 정보를 추출하여 리스트로 변환합니다.
FacilityController.java
@GetMapping("/redis/{type}")
public ResponseEntity<List<RedisGeoCommands.GeoLocation<String>>> getFacilitiesByTypeFromRedis(
@Auth Member member,
@PathVariable final String type,
@RequestParam final Double latitude,
@RequestParam final Double longitude,
@RequestParam final Double radius,
@RequestParam final int limit
) {
List<RedisGeoCommands.GeoLocation<String>> nearestFacilities = facilityService.findNearestFacilities(latitude, longitude, radius, limit, type.toUpperCase());
return new ResponseEntity<>(nearestFacilities, HttpStatus.OK);
}
Redis 캐시 도입으로 성능 향상 (전후 비교)
PostgreSQL에서 제공하는 PostGis를 활용하였을 때와, Redis에서 제공하는 Geospatail를 활용하였을때의 성능 비교를 각각 진행해보았습니다.
강남구청역을 기준으로 50km 반경 내의 시설 데이터 10000개를 반환하는 것을 기준으로 두 API 모두 테스트를 진행하였습니다.
(강남구청역은 주변에 다양한 시설이 있어 테스트하기에 적합하다고 생각)
두 API의 성능은 각각 30번씩 호출한 평균 응답 시간을 기준으로 비교하였습니다.
PostgreSQL PostGIS
21655ms // 30 = 721.83ms
-> 평균 722ms가 걸렸다.
Redis Geospatial
1020ms // 30 = 34ms
-> 평균 34ms가 걸렸다.
따라서, Redis 캐시를 도입하여 성능을 21배 향상시켰다.
개선된 아키텍처
Redis는 Spring Boot 애플리케이션과 PostgreSQL 데이터베이스 사이에서 캐싱 계층으로 작동하는 것을 알 수 있다.
캐시를 사용함으로써 지도 데이터의 빠른 접근을 가능하게 하고 데이터베이스의 부하를 줄이는데 기여하였다.
결론
- 지도에 들어가는 데이터(병원, 복지시설, 케어센터, 복지관, 복지주택)는 업데이트 주기성이 낮은 데이터로, 읽기 위주의 작업이 일어나므로 캐싱에 적합하다.
- 위치 데이터를 효율적으로 저장하고 빠르게 조회하기 위해 Redis의 Geospatial 자료구조를 사용하였다. Redis의 Geospatial 자료구조는 메모리 기반으로 빠른 성능을 제공한다. 또, 간단한 명령어(GEOADD, GEORADIUS, GEODIST)로 쉽게 구현 가능하고, 하버사인 공식을 사용해 정확한 거리 계산도 지원한다. 따라서, 이와 같은 자료구조를 활용하였다.
- PostgreSQL의 PostGIS를 활용한 것과 비교했을 때, Redis의 Geospatial 자료구조를 사용하여 위치 데이터를 조회하였을 시 성능이 21배 향상되었다.
-> 읽기 작업 위주의 데이터라면 캐싱을 적용해보자. 위치 데이터를 빠르게 조회해야할 시 Redis의 Geospatial 자료구조를 활용해보면 좋은 성능을 기대할 수 있다.
Redis GeoSpatial를 활용하는 방법 (심화)
더 나아가 대규모 시스템 설계 시 Redis Geospatial을 어떻게 잘 활용할 수 있을까?
참고문헌
https://blog.imqa.io/design_a_geo-spatial_index_3/
https://jupiny.com/2020/03/29/redis-sorted-set/
https://wonyong-jang.github.io/bigdata/2021/05/12/BigData-Redis-Geospatial.html
https://velog.io/@haron/%EB%8C%80%EC%9A%A9%EB%9F%89-%EC%84%A4%EA%B3%84
'Project > Performance Improvement' 카테고리의 다른 글
[프로젝트] 1-1. 로직 개선 - PostGIS 도입 (0) | 2024.08.18 |
---|