본문 바로가기

Memo

외부 API 결과에 캐싱 도입

Meet-Up-Spot이라는 프로젝트를 하면서 레디스로 캐싱 메커니즘을 도입한 과정을 기록하려고 한다.

우선 진행하는 프로젝트의 메인 기능이 여러 주소의 중간 지점을 계산해 여러 장소를 추천하는 것이다 보니. 중간 과정에서 몇 번 Google  Maps API 요청을 보낸다.

그래서 왜 캐싱을 하려고 하냐면

그냥 생각해 봤을때는 일단 비용적인 문제도 있기 때문에 캐싱하면 좋을 거 같다고 생각함. 

또한 api 요청을 줄이면 다른 로직들은 복잡하지 않아서 충분히 성능면에서 나아질거라 판단했다.

그리고 자주 사용될 엔드포인트가 장소 추천일 텐데 ,  이게 중간에 좌표를 기준으로 Google  Maps API 요청을 보냄,  사실 좌표라는 게 딱 맞기가 힘듦. 근데 그 주변 좌표들을 요청하면 어차피 거기서 거기엔 장소 리스트를 넘겨줄 거임. 그래서 차라리 그 요청으로 온 결과 장소들을 캐싱해두고. Redis에서 보면 geospatial data들을  radius를 줘서 찾을 수있는 기능이 있다. 그래서 이걸 기반으로 저장된 api 요청 값들을 불러와서 추천하는 로직에 적용하려고 한다. 이렇게 되면 특정 좌표가 아니더라도 적당한 범위 내의 좌표들의 결과들도 가져올 수 있다. 역시 범위를 어떻게 줄겠이냐에 대한 고민은 있다.

그래서 여기서 캐싱되는 데이터는 다음과 같다

일단 geospatial 한 데이터를 저장해야한다. 그래야 redis에서 범위를 줘서 찾을 수 있다.

그리고 geospatial 한 데이터 저장할때 latitud, longitude , 멤버를 저장한다. 그래서 레디스 내부적으로는 저 멤버가 geohash 된 스코어를 갖게 된다. 

예를 들면 다음과 같다

1.
geoadd 127.11116, 37.394776 '판교역'

2.
좌표 해싱한 문자열: api response

저 판교역이 멤버가 되고 내부적으로 score를 갖는다.

아무튼 자세한건 생략하고 나는 저기서 멤버를 내가 직접 geohash로 인코딩한 값을 넣어주려고 한다. 왜냐면 좌표로 lat:lng로 문자열로 넣으면 너무 길어질 수가 있다. 쓸데없이 코스트가 드니까 그걸 해싱하려고 한다. 그니까 기존 레디스에서 하는 방식이랑 같은 거다.

그리고 이걸 키값으로 api요청응답을 value로 redis에 저장할 것이다.

그니까 정리하면 geospatial 하게 저장하고 거기 멤버를 키값으로 api 응답을 value로 저장.

지금은 주변장소 요청에 대한 api응답만 해서 바로 키값 value 이렇게 했는데, 나중에 저 해시 값이 다른 응답에도 쓰이면 수정해서 '... api:geohash"이런 식으로 저장하던가 해시로 바꿔서 저장해야 한다.

아 그리고 geohash 정밀도

처음에는 당연히 가장 정확하게 해야지라고 생각하고 11자리 주려고 했는데, 생각해 보면 너무 길게 주면 일단 메모리를 많이 잡아먹으니 구분을 할정도만 되면 된다. 그리고 또 하나는 비슷한 문제인데, 1km이내의 장소들을 다른 해시로 하게 되는게 물론 당연하긴한데, 내 서비스상 저 위치를 같게 하고 요청 서비스를 보내면 당연히 같은 api응답이 온다. 그니까 주변 장소 검색 api가 같은 응답을 쏟아낼것이다. 그걸 레디스에 다른 해시값으로 담고 있는건 비효율적이라 생각이 들었다. 그렇다고 모든걸 지역을 합쳐버리면 의미가 없어지니, 적당한 값을 생각하다가 몇몇 지역을 직접 해보니 2km이내의 거리는 정밀도 5정도면 다 구분이 가능했다. 그니까 강남이랑 역삼이랑 하면 같아질 정도의 정밀도가 될것이다. 근데 웬만한 다른 지역은 다르게 해시값이 계산될 것이다.  누구한테는 맞지 않다고 여길 수 있겠지만, 중간 값 계산이라는 점을 생각해보면 나는 어차피 역삼, 강남정도는 중간값을 계산할 의미가 없다고 본다. 그냥 두 위치 내에서 주변 장소를 찾으면 비슷하게 준다.  결론적으로는 api 요청 결과와 서비스 주제를 생각해서 해시값을 조정하게 되었다. 그리고 이 부분은 나중에도 수정 가능성이 충분히 열려있고 수정이 편하기 때문에 문제 될 건 없다고 본다.

프로파일링

그래도 캐시 한것과 안한 로직의 명확한 차이를 알기 위해선 직접 프로파일링 하나씩 해보는게 맞는거 같아서 시간을 체크해봤다. 사용한 라이브러리는 profilehooks  를 사용했다. recommend_places_based_on_requested_address 이 부분이 엔드포인트 시작 함수. 밑에 결과 가장 밑줄을 보면 된다.

# 캐시 x
get_auto_completed_place (Meet-Up-Spot/app/services/map_services.py:405): 0.237 seconds
get_complete_addresses (Meet-Up-Spot/app/services/map_services.py:393): 0.238 seconds
## 밑에서 부터 추천 로직

get_cached_address_coordinates (Meet-Up-Spot/app/services/redis_services.py:57): 0.006 seconds
cache_address_coordinates (Meet-Up-Spot/app/services/redis_services.py:41): 0.027 seconds
##오래걸리는 부분 fetch_by_address가 호출하는 함수
get_lat_lng_from_address (Meet-Up-Spot/app/services/map_services.py:320): 0.404 seconds

find_geohashes_in_radius (Meet-Up-Spot/app/services/redis_services.py:88): 0.003 seconds
get_cached_nearby_places_responses (Meet-Up-Spot/app/services/redis_services.py:121): 0.001 seconds
_get_cached_places (Meet-Up-Spot/app/services/recommend_services.py:53): 0.004 seconds
cache_nearby_places_response (Meet-Up-Spot/app/services/redis_services.py:108): 0.004 seconds
add_location_to_redis (Meet-Up-Spot/app/services/redis_services.py:75): 0.001 seconds
_create_new_locations_from_result (Meet-Up-Spot/app/services/map_services.py:224): 0.000 seconds
create_or_get_locations (Meet-Up-Spot/app/services/map_services.py:236): 0.030 seconds
_create_new_places_from_results (Meet-Up-Spot/app/services/map_services.py:269): 0.000 seconds
create_or_get_places (Meet-Up-Spot/app/services/map_services.py:289): 0.031 seconds
process_nearby_places_results (Meet-Up-Spot/app/services/map_services.py:309): 0.061 seconds
get_nearby_places (Meet-Up-Spot/app/services/map_services.py:357): 0.447 seconds
##fetch_by_address가 호출하는 함수
fetch_places_by_coordinates (Meet-Up-Spot/app/services/recommend_services.py:74): 0.451 seconds
##오래 걸리는 부분
fetch_by_address (Meet-Up-Spot/app/services/recommend_services.py:96): 0.855 seconds

calculate_distance_matrix (Meet-Up-Spot/app/services/map_services.py:148): 0.341 seconds
get_distance_matrix_for_places (Meet-Up-Spot/app/services/map_services.py:422): 0.341 seconds
rank_candidates (Meet-Up-Spot/app/services/recommend_services.py:241): 0.494 seconds
recommend_places_by_address (Meet-Up-Spot/app/services/recommend_services.py:215): 1.349 seconds
recommend_places_based_on_requested_address (Meet-Up-Spot/app/api/endpoints/places.py:26): 1.608 seconds
profile_recommendations_by_address (Meet-Up-Spot/profiling.py:40): 1.645 seconds


# 캐싱 o
get_auto_completed_place (Meet-Up-Spot/app/services/map_services.py:405): 0.230 seconds
get_complete_addresses (Meet-Up-Spot/app/services/map_services.py:393): 0.230 seconds
##밑에서 부터 추천로직

get_cached_address_coordinates (Meet-Up-Spot/app/services/redis_services.py:57): 0.003 seconds
get_lat_lng_from_address (Meet-Up-Spot/app/services/map_services.py:320): 0.003 seconds
find_geohashes_in_radius (Meet-Up-Spot/app/services/redis_services.py:88): 0.001 seconds
get_cached_nearby_places_responses (Meet-Up-Spot/app/services/redis_services.py:121): 0.002 seconds
_create_new_locations_from_result (Meet-Up-Spot/app/services/map_services.py:224): 0.000 seconds
create_or_get_locations (Meet-Up-Spot/app/services/map_services.py:236): 0.008 seconds
_create_new_places_from_results (Meet-Up-Spot/app/services/map_services.py:269): 0.000 seconds
create_or_get_places (Meet-Up-Spot/app/services/map_services.py:289): 0.002 seconds
process_nearby_places_results (Meet-Up-Spot/app/services/map_services.py:309): 0.010 seconds
_get_cached_places (Meet-Up-Spot/app/services/recommend_services.py:53): 0.013 seconds
fetch_places_by_coordinates (Meet-Up-Spot/app/services/recommend_services.py:74): 0.013 seconds
fetch_by_address (Meet-Up-Spot/app/services/recommend_services.py:96): 0.016 seconds
calculate_distance_matrix (Meet-Up-Spot/app/services/map_services.py:148): 0.240 seconds
get_distance_matrix_for_places (Meet-Up-Spot/app/services/map_services.py:422): 0.240 seconds
rank_candidates (Meet-Up-Spot/app/services/recommend_services.py:241): 0.263 seconds
recommend_places_by_address (Meet-Up-Spot/app/services/recommend_services.py:215): 0.279 seconds
recommend_places_based_on_requested_address (Meet-Up-Spot/app/api/endpoints/places.py:26): 0.521 seconds
profile_recommendations_by_address (Meet-Up-Spot/profiling.py:40): 0.549 seconds

최종 시간만 비교하면 recommend_places_based_on_requested_address  여길 비교하면된다. 저 함수가 엔드포인트 함수.

캐시 로직 없음: 1.608

캐시 로직 추가: 0.521 

확실히 이렇게 보니까 줄어 든게 보여서 다행이다. 일단 안했을때 보면 가장 오래걸리는게 fetch_by_address 부분으로 0.855가 걸리는데 get_lat_lan_from_address 랑 fetch_places_by_coordinates를 호출한다. 이둘이  0.4 씩 걸리는데 

이 둘 내부에서 외부 api 요청하기 때문에 비교적 시간이 더 걸리는거 같다. 따라서 나는 이 부분에 결과들을 캐싱해줬다. 외부 api 자체 호출 부분들은 평균적으로 0.1 에서 0.2초 정도 걸림.

생각난 김에 Locust를 사용해서 부하테스트 비교

캐싱을 했다고 부하테스트에 큰 의미는 없겠지만 어차피 여기서 기능 추가가 더 될게 아니여서 프로파일링 하는김에 부하테스트도 해봤다. 위에 말한 것처럼 장소 요청 api에만 적용했기 때문에 관련 로직만 테스트했다. 또한 토큰을 헤더에서 가져와야 하기 때문에 access-token도 같이 실행된다.

초당 5명씩 100으로 테스트를 했다. 근데 기존에 db커넥션 풀을 늘리지 않았는데 이번에 하면서 감당을 못하길래 풀사이즈를 15개로 늘리고  max_overflow 도 25개로 해줬다. 

아 그리고 worker 는 일단 1개로 돌렸다. 이것도 적당히 포함시켜서 측정해야하는데 일단은 캐시 차이만 보려고 그냥 추가 하지 않았다.

캐시 도입 안 했을 때

places 평균 응답 시간 8.4   이것도 RPS 평균적으로 보면 2보다 더 적거나 같거나 함

캐시 도입

places 평균 응답 시간 6.8  RPS 는 평균적으로 2가 나오는거 같다 

통계 수치만 보면

차이가 있긴 한데 큰 의미는 없고, 예상은 했지만 캐싱을 해도 그냥 느리다 평균 응답시간 6.8초면 너무 느린 거 같다. 워커가 1개인 상태에서 원래 이렇게 느린게 맞는지 모르겠는데, 이 정도는 생각을 못했다. 초당 요청수 (RPS)만 보면 그냥 성능이 별로다. 그리고 이게 그나마 괜찮은 상태인데 평균적으로 보면 그냥 별로 차이가 없을때도 많다. 그리고 100명을 채우고 얼마 안되서 종료한거라서 사실 더 안좋다고 봐야한다. 근데 워커 1개로 처리하는거 자체가 무리인거 같기도 하다.

워커를 4개 정도 늘리면 다음과 같은 결과 나온다

확실히 줄긴 했는데 4개에 보니까 평균이 RPS가 5~6까지 나온다. 시간도 recommend 엔드포인트만 2.5 초정도 나오니느린편은 맞는거 같다. 이건 계속 둬도 안정적으로 RPS 가 저정도 나오고 평균 시간도 어느순간 부터 안올라간다.  이 부분은 나중에 배포할때 다시 봐야겠다

성능 차트 

이게 차트 자체는 100명에서 그 뒤에 얼마 안 기다려서 크게 의미는 없지만, 몇 번을 테스트해봤는데 그 뒤에 계속된 요청에 둘 다 응답시간이 많이 늦어지는건 똑같았다. 그래서 캐싱 도입한게 크게 효과적이진 않은거 같다. 

평균 응답시간이 너무 느려서 일단은 구체적으로 캐싱의 효과가 있는지 프로파일링을 해보면서 찾아봤다.  추천 로직이 있는 부분도 그렇게 복잡하지 않아서 얼마 안걸리고. 메인 로직에서 그나마 오래걸리던 부분들을 캐싱을 통해 어느 정도 나아진거 같다.

그리고 사실 다음 작업으로 비동기 처리로 수정하는 걸 생각하긴 했는데 고민이 된다. 일단 기존에 문제를 더 찾아봐야겠다.