Serverless microservice architecture에서의 inter-communication caching

빙글은 8월에 진행된 서비스 리뉴얼과 함께, 기존의 Ruby on rails 기반의 monolithic 앱에서 구현되어 있던 서비스 로직을 상당부분 Serverless 기반의 microservice architecture로 옮겼다.

그걸 하면서도 정말 많은걸 배웠고, 그 과정에서 필요했던 다양한 도구들 (Lambda용 http api framework라던가, DynamoDB ORM이라던가..) 오픈소스로 만들었다. 그부분도 정말 자랑스럽지만, 정작 그 과정에서는 너무 정신없이 바빠서 블로깅은 엄두도 못냈고, 매일 매일 새로운 기능을 추가하면서 우리가 큰 그림에서 좋은 architecture에서 벗어나고 있는건 아닌지 걱정하기 바빴다;

그래서 약간은 여유가 생긴 요즘에야, 앞으로 하려고 하는것들에 대해 좀더 쓰고 정리해보려고 한다.

Intro

우선 현재 빙글에서 구축한 시스템 구조는 대략 다음과 같다.

이것보다 훨씬 서비스가 많고, 서비스 각각의 구성이 다른것들이 있으며, 서로간의 요청이 훨씬 다양하다는 것만 빼면.

여기서 왼쪽의 큰 부분이 기존의 Rails Application이고, 우리가 서비스 리뉴얼 과정에서 했던 것은 여기서 business logic을 분리하여 AWS Lambda / API Gateway 기반의 service들로 옮기는 것이였다. 이 service들은 모두 Typescript로 작성되었고, Ruby On Rails Application과 각각의 Service들은 HTTP API 로 통신했다.

우선 이부분은 매우 성공적으로 끝났고, 만족스러웠다. 이부분만 별도의 포스팅을 해야될 정도인데, 단언컨데 이렇게 바꾸고 나서 우리 backend team의 생산성이 정말 두배는 올라간 것 같다.

Microservice Inter-communication caching

다만 오늘 집중해서 다루려는 부분은 <서비스간의 통신에서의 cache> 이다. 구체적인 예시를 생각해보자.

  1. Feed service에서는 주어진 card의 interest tag 목록을 필요로 한다.
    예를들어, https://www.vingle.net/posts/2250569 이 card는 
    #남성스트리트패션 #여성패션 #남성패션 #여성스트리트패션 #남성데일리룩 #남성신발 #여성데일리룩 #힙합 #패션디자인 #나이키 
    라는 interest들에 tag되어 있다.
  2. 주어진 card의 interests 목록은 Interest Service에서 관리한다. 아주 직관적인,
    GET /api/cards/:cardId/interests
    PUT /api/cards/:cardId/interests
    이런 API들로
  3. 따라서, 유저가 Feed를 요청했을때의 Service 호출 순서는 대략 이렇다.
  1. 유저가 Ruby On Rails 서버에 HTTP API 로 feed를 요청한다
  2. Ruby On Rails App이 Feed Service에, AWS SDK를 이용해 lambda invoke-function요청을 보낸다. payload를 이용해 /api/feed, 즉 feed를 달라는 요청임을 명시한다.
  3. Feed Service는 주어진 유저의 Feed를 만드려고 시도하고, 이때 특정 card의 interests 목록을 달라고 interest service에 HTTP Call을 통해 요청한다 (GET /api/cards/:cardId/interests)
  4. Interest Service는 요청이 들어온 Card의 interests 목록을 DynamoDB에서 조회하거나, 변경이 없다면 memcached 에서 cache된 결과를 읽어 return한다.

3, 4번은 좀더 개선할수 있지 않나?

Card의 Interest tag목록은 어차피 유저가 Card내용 수정을 통해 수정하지 않는 이상 바뀌지 않기 때문에 지금도 대부분의 경우 Interest Service가 하는 일은 memcached에서 데이터를 읽어 return하는게 끝이다.

그렇다면, Feed Service에서 애초에 Interest Service가 사용하는 memcached를 직접 hit하게 하면 훨씬 빠르지 않나?

이 부분은 Netflix의 EVCache와 서비스 구조에서 많은 영감을 얻었다.

좀더 상세한 설명은 이 영상이 영상을 추천한다.

즉, Service A가 Service B에 C Operation을 요청한다고 했을때,

  1. Service B의 Memcached server에 직접접근하여, C Operation에 대한 cache를 확인
  2. 있으면 쓰고, 아니면 Service B의 HTTP API를 요청
  3. Service B는 HTTP API로 응답을 주기 전에, 결과를 memcached 서버에 기록
  4. Service B에 cache data를 변경하는 요청 (DELETE / PUT 같은) 이 들어오면, Service B에서 cache에 접근하여 변경

말이야 간단해 보이지만, 이 접근은 까다로운 문제를 지닌다.

  1. 모든 Client들은 사용하려는 Service의 HTTP Endpoint뿐만 아니라, Memcached Endpoint도 알아야 한다
  2. 사용하는 Service가 많아지면 endpoint관리가 귀찮아진다. 흠 그럼 그냥 모든 Service들이 하나의 Memcached cluster를 사용하게 하자? -> 전형적인 Single point of failure. 해당 memcached가 죽으면 모든 Service가 다 죽는다.
  3. Service A가 Service B에 Operation C를 Parameter P로 요청한다고 할때, Service A와 Service B 모두 “정확히 같은 Cache Key를 알아야 한다” 
    왜냐면 
    1) Service A가 Memcached Server에 Service B의 Operation C, Parameter P에 대한 Cache를 요청해본다
    2) Service B가 Memcached Server에 Service B의 Operation C, Parameter P에 대한 Cache를 Set한다.
    이 두가지가 모두 가능해야한다.

실제 구현

Vingle의 모든 microservice들은 Swagger API 에 따라 자신이 제공하는 API들을 정리한 XML을 가지고 있다. 그래서 aws-sdk가 aws가 제공하는 다양한 service들의 API 를 모아놓듯이, 우리도 Swagger를 이용해 API Client를 자동으로 generate해서 아래와 같이 사용하고 있다.

그래서, 처음엔 정말

아 그럼 Service A에서 Service B를 호출하면, Service B에서 응답을 줄때 HTTP Cache header를 Cache-Control: max-age=360 이렇게 주고, SDK만 수정해서,
1) 서비스 응답을 받아온다
2) Cache Control header를 본다
3) header가 있다면 memcached에 set한다!
4) 다음에 요청할때는 memcached에 get을 먼저 해본다.
이러면 되겠군!

SDK를 사용하는 모든 Client들이 하나, 혹은 몇개의 memcached 서버를 공유해야한다는게 좀 꺼름직했지만 이렇게 하면 되겠다 싶었는데..

“Service B 가 해당 cache를 당장 날리거나 update 해야 할때는 어떻게 하지?”

라는 문제가 남아있었다. 예를들어 카드의 관심사 목록은 거의 바뀌지 않으니 cache TTL을 5분으로 하든 3주로 하던 1년으로 하던 상관 없지만, 
일단 사용자가 카드를 수정하여 “바뀌면”, 반드시 바로 cache에 반영되어야 했다. 안그러면 사용자가 카드를 수정했는데 안바뀐 것 처럼 보일테니까

또 하나 문제는,

“SDK에서 Service B가 내부적으로 사용하는 CacheKey를 어떻게 알지?”

사실 우리가 SDK를 수동으로 Service가 업데이트 되는것에 맞춰서 업데이트 하고 있었다면, 그냥 cache key를 수동으로 맞추자! 하고 넘어 갔을지도 모르겠다.
근데 일단 우리는 이미 SDK를 자동으로 swagger doc을 읽어서 업데이트 하게 하고 있었고, 나는 이게 “그냥 개발자가 알아서 맞추자!” 라고 하면 너무너무 실수하기 쉬운 환경이라고 생각했다. 하다못해 같은 const string을 사용하자고 하는것도 서로다른 application에 부탁하면 가끔씩 띄어쓰기나 대소문자 실수하는 마당에..;

그래서 몇가지 해결책을 생각했다.

HTTP URL을 cache key로 쓰면?

SDK에서도 해당 operation 의 url이야 당연히 아니까, 그걸 key로 쓰면 되지 않나? 
아이디어는 괜찮아 보였는데, 이건 service 쪽에서 문제가 있었다. 우리는 Cascading routing을 사용하기 때문에, 하나의 Route안에서 자신의 주소를 정확히 알수가 없다; (자신이 어떤 부모 아래에 있냐에 따라 상위 주소가 달라지니까) 
물론 실제로 API가 실행되는 단계에서는 알수 있지만, 그건 “다른 API의 cache를 날려야할때" 가 문제였다.

대략 아래와 같이 작성해야 하는데,

딱봐도 L70이 상당히 애매해보인다.. 여기서 L70이 L58의 Route의 Cache를 날리라는 명령이라는게 너무 비직관적이고, L53이 바뀌거나 해서 애초에 url이 상위에서 바뀌면?

왜 아이디 바꿨는데 업데이트 안돼요!!!@#@!#..

라는 CS가 들어올게 뻔하다..

그래서 최종적으로 선택한건,

cache_key를 “${serviceName}.${operationId}.${Parameter}” 
로 통일하자!

operationId란 Swagger에서 모든 route에 대해 가지고 있기를 요구하는 uniqe한 string이다. Swagger spec에 있으니 SDK에서도 알수 있고, Service에서도 반드시 알아야 한다. 
최초의 card의 interests tag 목록 예제로 보자면,

  1. Feed Service에서는 우선 memcached에 
    “InterestService.GetCardInterests.cardId=12345” 라는 키로 get 요청을 보낸다.
  2. memcached 에서 결과가 안오면, Interest Service에 GetCardInterests(12345) 요청을 보낸다. 
    SDK에서 자동으로 GET /api/cards/:cardId/interests라는 매칭되는 HTTP 요청으로 바꾸어준다
  3. InterestService 에서는 이 요청에 대한 응답을 만들고, 이 응답을 “InterestService.GetCardInterests.cardId=12345” 라는 키로 memcached에 저장한후, Feed Service로 응답을 보낸다.
  4. 만약 InterestServicePutCardInterests 같은 요청이 들어와서 cache를 날려야 하면, 대략
     
    CacheManager.deleteCache(“GetCardInterests”, { cardId: 12345 })
    이렇게 보낸다. CacheManager는 주어진 OperationId와 Parameter에 응답하는 Route가 있는지 확인하고 없으면 exception을 throw한다

결과적으로, 많은 경우 10~20ms정도 걸리는 HTTP 요청이 아니라 Memcached의 ~3ms 정도의 응답을 사용할수 있게 됬다.

끝으로,

Microservice로 넘어오면서, “Service 각각이 어떤 정보를 알고 있나 / 알아야 하나” 가 매우 중요한 질문이 됬다. 사실 우리가 서비스마다 담당하는 팀이 다르고 사람이 다르다면, 오히려 별 문제가 아니였을것이다. 그 경우엔 어차피 서로 내부 정보는 모르는거고, 그럼 뭘 공유해야 하는지만 정리하면 끝나니까. 
그런데 서비스 숫자는 많고 사람은 4명이다 보니.. 서비스간에 통신을 설계할때는 특히

“나는 이 서비스 내부 로직은 모른다고 생각하고, 내가 외부에서 얻을수 있는 정보만 가지고 생각할때.. "

라고 사고의 흐름을 스위칭 하는게 필요하다.

현재 proof of concept는 끝났고, 전체 적용을 준비중이다.

빙글에는 이런 문제를 함께 풀어갈 사람을 언제나 기다립니다.