청구 추출: GraphQL 내부 API 최적화 이야기
게시 됨: 2022-03-11Toptal 엔지니어링 팀의 주요 우선 순위 중 하나는 서비스 기반 아키텍처로의 마이그레이션입니다. 이 이니셔티브의 중요한 요소는 Billing Extraction 이었습니다. 이 프로젝트에서는 청구 기능을 Toptal 플랫폼에서 분리하여 별도의 서비스로 배포했습니다.
지난 몇 달 동안 기능의 첫 번째 부분을 추출했습니다. 결제를 다른 서비스와 통합하기 위해 비동기 API(Kafka 기반)와 동기 API(HTTP 기반)를 모두 사용했습니다.
이 문서는 동기식 API를 최적화하고 안정화하기 위한 우리의 노력에 대한 기록입니다.
점진적 접근
이것이 우리 이니셔티브의 첫 번째 단계였습니다. 전체 청구 추출을 향한 여정에서 우리는 프로덕션에 작고 안전한 변경 사항을 제공하는 점진적인 방식으로 작업하기 위해 노력합니다. (이 프로젝트의 또 다른 측면인 Rails 앱에서 엔진을 점진적으로 추출하는 훌륭한 강연 슬라이드를 참조하세요.)
출발점은 모놀리식 Ruby on Rails 애플리케이션 인 Toptal 플랫폼 이었습니다. 우리는 데이터 수준에서 청구와 Toptal 플랫폼 사이의 이음매를 식별하는 것으로 시작했습니다. 첫 번째 접근 방식은 활성 레코드(AR) 관계를 일반 메서드 호출로 대체하는 것이었습니다. 다음으로 메서드에서 반환된 데이터를 가져오는 청구 서비스에 대한 REST 호출을 구현해야 했습니다.
플랫폼과 동일한 데이터베이스에 액세스하는 소규모 청구 서비스를 배포했습니다. HTTP API를 사용하거나 데이터베이스에 대한 직접 호출을 사용하여 청구를 쿼리할 수 있었습니다. 이 접근 방식을 통해 안전한 대체를 구현할 수 있었습니다. 어떤 이유로든 HTTP 요청이 실패한 경우(잘못된 구현, 성능 문제, 배포 문제) 직접 호출을 사용하고 호출자에게 올바른 결과를 반환했습니다.
전환을 안전하고 원활하게 하기 위해 기능 플래그를 사용하여 HTTP와 직접 호출 간에 전환했습니다. 불행히도 REST로 구현된 첫 번째 시도는 허용할 수 없을 정도로 느린 것으로 판명되었습니다. 단순히 AR 관계를 원격 요청으로 교체하면 HTTP가 활성화되었을 때 충돌이 발생했습니다. 비교적 적은 비율의 호출에 대해서만 활성화했지만 문제는 지속되었습니다.
우리는 근본적으로 다른 접근 방식이 필요하다는 것을 알고 있었습니다.
결제 내부 API(B2B라고도 함)
클라이언트 측에서 더 많은 유연성을 확보하기 위해 REST를 GraphQL(GQL)로 교체하기로 결정했습니다. 우리는 이번 전환 과정에서 결과를 예측할 수 있도록 데이터 기반 의사 결정을 내리고 싶었습니다.
이를 위해 우리는 Toptal 플랫폼(모놀리스)에서 청구에 대한 모든 요청을 계측하고 응답 시간, 매개변수, 오류 및 스택 추적(플랫폼의 어느 부분이 청구를 사용하는지 이해하기 위해)과 같은 세부 정보를 기록했습니다. 이를 통해 우리는 핫스팟(많은 요청을 보내거나 느린 응답을 유발하는 코드의 위치)을 감지할 수 있었습니다. 그런 다음 stacktrace 및 parameters 를 사용하여 로컬에서 문제를 재현하고 많은 수정 사항에 대한 짧은 피드백 루프를 가질 수 있습니다.
프로덕션에서 불쾌한 놀라움을 피하기 위해 다른 수준의 기능 플래그를 추가했습니다. REST에서 GraphQL로 이동하는 API의 메서드당 하나의 플래그가 있습니다. 우리는 HTTP를 점진적으로 활성화하고 "뭔가 나쁜 것"이 로그에 나타나는지 관찰했습니다.
대부분의 경우 "불량"은 긴(수초) 응답 시간, 429 Too Many Requests 또는 502 Bad Gateway 입니다. 우리는 이러한 문제를 해결하기 위해 데이터 사전 로드 및 캐싱, 서버에서 가져온 데이터 제한, 지터 추가 및 속도 제한과 같은 몇 가지 패턴을 사용했습니다.
사전 로드 및 캐싱
우리가 알아차린 첫 번째 문제는 SQL의 N+1 문제와 유사한 단일 클래스/뷰에서 보낸 요청의 홍수였습니다.
Active Record 사전 로드는 서비스 경계를 넘어 작동하지 않았으며 결과적으로 다시 로드할 때마다 청구에 ~1,000개 요청을 보내는 단일 페이지가 있었습니다. 한 페이지에서 천 개의 요청! 일부 백그라운드 작업의 상황은 그다지 좋지 않았습니다. 우리는 수천 개의 요청보다 수십 개의 요청을 하는 것을 선호했습니다.
백그라운드 작업 중 하나는 작업 데이터를 가져오는 것(이 모델을 Product 이라고 함)과 청구 데이터를 기반으로 제품이 비활성으로 표시되어야 하는지 확인하는 것이었습니다(이 예에서는 모델을 BillingRecord 라고 부를 것입니다). 일괄적으로 제품을 가져왔지만 필요할 때마다 과금 데이터를 요청했습니다. 모든 제품에는 청구 기록이 필요하므로 모든 단일 제품을 처리하면 청구 서비스에 청구 기록을 가져오라는 요청이 발생했습니다. 즉, 제품당 하나의 요청이 있었고 단일 작업 실행에서 약 1,000개의 요청이 전송되었습니다.
이 문제를 해결하기 위해 청구 기록의 일괄 사전 로드를 추가했습니다. 데이터베이스에서 가져온 모든 제품 배치에 대해 청구 기록을 한 번 요청한 다음 해당 제품에 할당했습니다.
# fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end end100개의 배치와 배치당 청구 서비스에 대한 단일 요청으로 작업당 ~1,000개 요청에서 ~10개로 줄었습니다.
클라이언트 측 조인
일괄 요청 및 캐싱 청구 레코드는 제품 컬렉션이 있고 청구 레코드가 필요할 때 잘 작동했습니다. 그러나 반대의 경우는 어떻습니까? 청구 기록을 가져온 다음 플랫폼 데이터베이스에서 가져온 각 제품을 사용하려고 하면 어떻게 될까요?
예상대로 이번에는 플랫폼 측에서 또 다른 N+1 문제가 발생했습니다. N개의 청구 기록을 수집하기 위해 제품을 사용할 때 N개의 데이터베이스 쿼리를 수행했습니다.
솔루션은 필요한 모든 제품을 한 번에 가져와 ID별로 인덱싱된 해시로 저장한 다음 해당 청구 기록에 할당하는 것이었습니다. 단순화된 구현은 다음과 같습니다.
def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end end해시 조인과 비슷하다고 생각하면 혼자가 아닙니다.
서버 측 필터링 및 언더페칭
우리는 플랫폼 측에서 최악의 요청 급증과 N+1 문제를 해결했습니다. 그러나 우리는 여전히 느린 응답을 받았습니다. 플랫폼에 너무 많은 데이터를 로드하고 플랫폼에서 필터링(클라이언트 측 필터링)하여 이러한 문제가 발생했음을 확인했습니다. 데이터를 메모리에 로드하고 직렬화하고 네트워크를 통해 전송하고 대부분을 삭제하기 위해 역직렬화하는 것은 엄청난 낭비였습니다. 일반적이고 재사용 가능한 끝점이 있기 때문에 구현하는 동안 편리했습니다. 작동 중에는 사용할 수 없는 것으로 판명되었습니다. 우리는 더 구체적인 것이 필요했습니다.
GraphQL에 필터링 인수를 추가하여 문제를 해결했습니다. 우리의 접근 방식은 필터링을 앱 수준에서 DB 쿼리로 이동하는 것으로 구성된 잘 알려진 최적화와 유사했습니다( find_all 대 Rails의 where ). 데이터베이스 세계에서 이 접근 방식은 명백하고 SELECT 쿼리에서 WHERE 로 사용할 수 있습니다. 이 경우 쿼리 처리를 직접 구현해야 했습니다(Billing에서).
우리는 필터를 배포하고 성능 향상을 보기를 기다렸습니다. 대신 플랫폼에서 502 오류가 발생했습니다(사용자도 오류를 확인했습니다). 안좋다. 전혀 좋지 않아!
왜 그런 일이 일어났습니까? 그 변경은 서비스를 중단하는 것이 아니라 개선된 응답 시간을 가져야 합니다. 우리는 실수로 미묘한 버그를 도입했습니다. 클라이언트 측에서 두 버전의 API(GQL 및 REST)를 유지했습니다. 우리는 기능 플래그로 점진적으로 전환했습니다. 우리가 배포한 첫 번째 불행한 버전은 레거시 REST 분기에 회귀를 도입했습니다. 테스트를 GQL 분기에 집중했기 때문에 REST의 성능 문제를 놓쳤습니다. 교훈: 검색 매개변수가 누락된 경우 데이터베이스에 있는 모든 것이 아니라 빈 컬렉션을 반환합니다.
Billing에 대한 NewRelic 데이터를 살펴보십시오. 트래픽이 정체되는 동안 서버 측 필터링으로 변경 사항을 배포했습니다(플랫폼 문제가 발생한 후 청구 트래픽을 껐습니다). 배포 후 응답이 더 빠르고 예측 가능하다는 것을 알 수 있습니다.

GQL 스키마에 필터를 추가하는 것은 그리 어렵지 않았습니다. GraphQL이 실제로 빛을 발한 상황은 너무 많은 객체가 아닌 너무 많은 필드를 가져온 경우였습니다. REST를 사용하여 필요할 수 있는 모든 데이터를 보내고 있었습니다. 일반 엔드포인트를 만들면 플랫폼에서 사용되는 모든 데이터와 연결로 압축해야 했습니다.
GQL을 사용하여 필드를 선택할 수 있었습니다. 여러 데이터베이스 테이블을 로드해야 하는 20개 이상의 필드를 가져오는 대신 필요한 3~5개의 필드만 선택했습니다. 이를 통해 일부 쿼리는 배포 중에 실행되는 탄력적 검색 재인덱싱 작업에서 사용되었기 때문에 플랫폼 배포 중에 청구 사용량의 갑작스러운 급증을 제거할 수 있었습니다. 긍정적인 부작용으로 배포를 더 빠르고 안정적으로 만들었습니다.
가장 빠른 요청은 당신이하지 않는 것입니다
가져온 개체의 수와 모든 개체에 패킹된 데이터의 양을 제한했습니다. 우리는 무엇을 더 할 수 있습니까? 데이터를 전혀 가져오지 않을 수 있습니까?
우리는 개선의 여지가 있는 또 다른 영역을 발견했습니다. 플랫폼에서 마지막 청구 레코드의 생성 날짜를 자주 사용하고 있었고 매번 청구를 호출하여 가져오기를 호출했습니다. 필요할 때마다 동기적으로 가져오는 대신 청구서에서 보낸 이벤트를 기반으로 캐시할 수 있다고 결정했습니다.
우리는 미리 계획하고 작업(그 중 4~5개)을 준비했으며 이러한 요청으로 인해 상당한 부하가 발생했기 때문에 가능한 한 빨리 작업을 완료하기 시작했습니다. 2주의 일이 우리보다 앞서 있었다.
다행히 시작한 지 얼마 되지 않아 문제를 다시 살펴보고 이미 플랫폼에 있지만 다른 형식의 데이터를 사용할 수 있다는 것을 깨달았습니다. Kafka의 데이터를 캐시하기 위해 새 테이블을 추가하는 대신 청구 및 플랫폼의 데이터를 비교하는 데 며칠을 보냈습니다. 또한 플랫폼 데이터를 사용할 수 있는지 여부에 대해 도메인 전문가에게 문의했습니다.
마지막으로 원격 호출을 DB 쿼리로 대체했습니다. 이는 성능과 작업 부하 측면 모두에서 엄청난 승리였습니다. 또한 개발 시간을 일주일 이상 절약했습니다.
부하 분산
우리는 이러한 최적화를 하나씩 구현하고 배포했지만 여전히 청구가 429 Too Many Requests 로 응답하는 경우가 있었습니다. Nginx에 대한 요청 제한을 늘릴 수 있었지만 통신이 예상대로 작동하지 않는다는 힌트였기 때문에 문제를 더 잘 이해하고 싶었습니다. 기억하시겠지만, 우리는 최종 사용자가 볼 수 없었기 때문에 프로덕션에서 이러한 오류를 허용할 수 있었습니다(직접 호출로의 대체 때문에).
플랫폼이 연체된 작업표와 관련하여 인재 네트워크 구성원에게 미리 알림을 예약할 때 매주 일요일에 오류가 발생했습니다. 미리 알림을 보내기 위해 작업은 수천 개의 레코드가 포함된 관련 제품에 대한 청구 데이터를 가져옵니다. 최적화를 위해 가장 먼저 한 일은 청구 데이터를 일괄 처리하고 미리 로드하고 필수 필드만 가져오는 것이었습니다. 둘 다 잘 알려진 트릭이므로 여기서는 자세히 설명하지 않겠습니다.
우리는 배치하고 다음 일요일을 기다렸습니다. 우리는 문제를 해결했다고 확신했습니다. 그러나 일요일에 오류가 다시 나타났습니다.
과금 서비스는 일정을 잡는 동안뿐만 아니라 네트워크 회원에게 미리 알림을 보낼 때도 호출되었습니다. 미리 알림은 별도의 백그라운드 작업(Sidekiq 사용)으로 전송되므로 미리 로드할 필요가 없습니다. 처음에는 모든 제품에 알림이 필요하지 않고 알림이 모두 한 번에 전송되기 때문에 문제가 되지 않을 것이라고 생각했습니다. 미리 알림은 네트워크 구성원의 시간대에서 오후 5시에 예약됩니다. 하지만 중요한 세부 사항을 놓쳤습니다. 우리 회원은 시간대에 균일하게 분포되어 있지 않습니다.
우리는 약 25%가 한 시간대에 살고 있는 수천 명의 네트워크 회원에게 미리 알림을 예약하고 있었습니다. 약 15%는 두 번째로 인구가 많은 시간대에 살고 있습니다. 그 시간대의 시계가 오후 5시를 가리키고 있었기 때문에 우리는 한 번에 수백 개의 알림을 보내야 했습니다. 이는 청구 서비스에 대한 수백 건의 요청 버스트를 의미했으며, 이는 서비스가 처리할 수 있는 것보다 많았습니다.
미리 알림이 독립적인 작업으로 예약되어 있기 때문에 청구 데이터를 미리 로드할 수 없습니다. 이미 해당 수를 최적화했기 때문에 청구에서 더 적은 수의 필드를 가져올 수 없습니다. 네트워크 구성원을 인구가 적은 시간대로 이동하는 것도 문제가 되지 않았습니다. 그래서 우리는 무엇을 했습니까? 알림을 조금 옮겼습니다.
모든 미리 알림이 정확히 동시에 전송되는 상황을 피하기 위해 미리 알림이 예약된 시간에 지터를 추가했습니다. 오후 5시에 예약하는 대신 오후 5시 59분에서 6시 1분 사이의 2분 범위 내에서 예약했습니다.
우리는 서비스를 배포하고 다음 일요일을 기다렸습니다. 마침내 문제를 해결했다고 확신했습니다. 불행히도 일요일에 오류가 다시 나타났습니다.
우리는 어리둥절했습니다. 우리의 계산에 따르면 요청은 2분 동안 분산되어야 합니다. 즉, 초당 최대 2개의 요청이 있어야 합니다. 서비스가 감당할 수 없는 일이 아니었습니다. 청구 요청의 로그와 타이밍을 분석한 결과 지터 구현이 작동하지 않아 요청이 여전히 조밀한 그룹으로 표시되고 있다는 것을 깨달았습니다.
그 행동의 원인은 무엇입니까? Sidekiq이 스케줄링을 구현하는 방식이었습니다. 10-15초마다 redis를 폴링하므로 1초 해상도를 제공할 수 없습니다. 요청을 균일하게 분산하기 위해 Sidekiq Enterprise에서 제공하는 클래스인 Sidekiq::Limiter 를 사용했습니다. 우리는 움직이는 1초 창에 대해 8개의 요청을 허용하는 창 제한기를 사용했습니다. 청구 시 Nginx 제한이 초당 10개이므로 이 값을 선택했습니다. 거친 요청 분산을 제공하기 때문에 지터 코드를 유지했습니다. 이 코드는 Sidekiq 작업을 2분 동안 배포했습니다. 그런 다음 Sidekiq Limiter를 사용하여 정의된 임계값을 위반하지 않고 각 작업 그룹을 처리했습니다.
다시 한번, 우리는 그것을 배포하고 일요일을 기다렸습니다. 우리는 마침내 문제를 해결했다고 확신했고 그렇게 했습니다. 오류가 사라졌습니다.
API 최적화: Nihil Novi Sub Sole
우리가 사용한 솔루션에 놀라지 않으셨으리라 믿습니다. 일괄 처리, 서버 측 필터링, 필수 필드만 보내기 및 속도 제한은 새로운 기술이 아닙니다. 숙련된 소프트웨어 엔지니어는 의심할 여지 없이 다양한 상황에서 이를 사용했습니다.
N+1을 피하기 위해 미리 로드하시겠습니까? 모든 ORM에 있습니다. 해시 조인? 심지어 MySQL에도 지금이 있습니다. 언더페칭? SELECT * 대 SELECT field 는 알려진 트릭입니다. 부하 분산? 새로운 개념도 아닙니다.
그렇다면 나는 왜 이 글을 썼을까? 왜 처음부터 제대로 하지 않았습니까? 늘 그렇듯이 컨텍스트가 핵심입니다. 이러한 기술 중 많은 부분은 구현한 후 또는 코드를 쳐다볼 때가 아니라 해결해야 하는 프로덕션 문제를 발견했을 때만 친숙해 보였습니다.
이에 대한 몇 가지 가능한 설명이 있었습니다. 대부분의 경우 과도한 엔지니어링을 피하기 위해 작동할 수 있는 가장 간단한 작업 을 수행하려고 했습니다. 우리는 지루한 REST 솔루션으로 시작하여 GQL로 옮겼습니다. 기능 플래그 뒤에 변경 사항을 배포하고 트래픽의 일부로 모든 것이 어떻게 작동하는지 모니터링하고 실제 데이터를 기반으로 개선 사항을 적용했습니다.
우리가 발견한 것 중 하나는 성능 저하를 리팩토링할 때 간과하기 쉽다는 것이었습니다(추출은 중요한 리팩토링으로 처리될 수 있음). 엄격한 경계를 추가한다는 것은 코드를 최적화하기 위해 추가된 연결을 끊는 것을 의미했습니다. 그러나 성능을 측정할 때까지는 분명하지 않았습니다. 마지막으로 개발 환경에서 프로덕션 트래픽을 재현할 수 없는 경우가 있었습니다.
우리는 과금 서비스의 보편적인 HTTP API의 작은 표면을 가지려고 노력했습니다. 결과적으로 우리는 다양한 사용 사례에 필요한 데이터를 전달하는 범용 엔드포인트/쿼리를 얻었습니다. 이는 많은 사용 사례에서 대부분의 데이터가 쓸모가 없다는 것을 의미했습니다. DRY와 YAGNI 사이에는 약간의 절충점이 있습니다. DRY를 사용하면 청구 기록을 반환하는 엔드포인트/쿼리가 하나뿐인 반면 YAGNI를 사용하면 성능에만 해를 끼치는 엔드포인트에 사용되지 않은 데이터가 남게 됩니다.
우리는 또한 청구 팀과 지터에 대해 논의할 때 또 다른 절충안을 발견했습니다. 클라이언트(플랫폼) 관점에서 모든 요청은 플랫폼이 필요할 때 응답을 받아야 합니다. 성능 문제와 서버 과부하는 과금 서비스의 추상화 뒤에 숨겨져 있어야 합니다. 과금 서비스의 관점에서 클라이언트가 부하를 견디기 위해 서버 성능 특성을 인식하도록 하는 방법을 찾아야 합니다.
다시 말하지만, 여기에는 새롭거나 획기적인 것은 없습니다. 다양한 컨텍스트에서 알려진 패턴을 식별하고 변경으로 인해 발생하는 상충 관계를 이해하는 것입니다. 우리는 그것을 어려운 방법으로 배웠고 우리가 실수를 반복하지 않기를 바랍니다. 우리의 실수를 반복하는 대신, 당신은 틀림없이 스스로 실수를 하고 그로부터 배울 것입니다.
우리의 노력에 참여한 동료와 팀원들에게 특별한 감사를 전합니다.
- 마카르 에르모킨
- 가브리엘 렌지
- 사무엘 베가 카바예로
- 루카 구이디
