How and Why Netflix Built a Real-Time Distributed Graph: Part 3 — Querying the graph with gRPC execution API

Author: Nilesh Mishra, Ajit Koti (Netflix) | Source: netflixtechblog.com | Published: 2026-08-08


한 줄 요약

80억 노드·1,500억 엣지 위에서 sub-100ms 응답을 내기 위해 breadth-first 순회 + async-first 실행 + 선택적 캐싱 + opt-in enrichment를 택했고, 특히 async 구성이 레이턴시뿐 아니라 인프라 비용의 경제학 자체를 바꿔놓았다.

핵심 주장/내용

  • “그래프를 질의한다”는 one-size-fits-all이 아니다. 쿼리는 두 축으로 다양하다 — 각 hop에서 얼마나 넓게 fan out하는가와 hop을 얼마나 깊게 연쇄하는가
    • shallow & wide (“이 계정이 쓴 기기는?“) — 단일 hop이지만 활발한 계정은 fan-out이 거대하다. 수백 개 엣지를 가져와 각 엣지 속성에 시간 필터를 적용하고 집계하는 것을 sub-100ms에 해야 한다. I/O 처리량을 압박
    • deep & narrow (“Account X의 모든 프로필에 걸친 Stranger Things 시청 이력”) — 핵심 난제는 순차 의존성이다. Hop 1이 어떤 프로필이 존재하는지 식별하기 전에는 그 프로필의 시청 이력을 가져올 수 없다. 각 hop이 10ms 네트워크 시간이면 단 1바이트도 처리하기 전에 20ms 오버헤드다. 실행 효율을 압박
    • 3~4 hop까지 연쇄될 수 있고 순차 실행의 레이턴시 페널티는 깊이에 따라 커진다
  • 설계 선택 4가지:
    • 왜 depth-first가 아니라 breadth-first인가 — 가장 직관적인 순회는 depth-first지만 모든 hop이 네트워크 호출인 분산 시스템에서는 고레이턴시로 이어진다. Account X에 프로필 5개가 있고 각각 수백 타이틀을 봤다면 depth-first는 한 프로필의 모든 타이틀을 추적한 뒤 다음으로 가면서 프로필 간 조회를 배치할 기회를 놓친다. breadth-first는 각 노드를 통과하는 한 경로가 아니라 모든 노드에 걸쳐 한 레벨씩 작업한다 — 순차 연쇄 대신 세 번의 병렬 호출 라운드. 트레이드오프는 메모리다(각 레벨을 한 번에 메모리에 들고 있으므로 깊이가 아니라 레벨의 fan-out 폭에 비례해 비용이 커진다). hop마다 edge-type별 limit으로 경계를 지어 high fan-out 레벨도 관리 가능한 frontier로 유지
    • 왜 thread-per-request가 아니라 async-first인가 — 레이턴시가 I/O에 지배된다(스토리지 읽기, enrichment 서비스 호출, 캐시 대기). thread-per-request는 in-flight 쿼리마다 스레드를 고정하는데 대부분의 시간 그 스레드는 네트워크 응답을 기다리며 유휴다. 동시 쿼리 수천 개면 수천 스레드가 필요하고 대부분이 아무것도 안 한다. 대신 실행 파이프라인 전체를 비동기 구성으로 — 작은 전용 스레드 풀 세트(총 16~24 스레드)가 수천 동시 요청을 처리한다. 어떤 스레드도 I/O에 블록되지 않기 때문이다. 나머지 모든 것이 이 위에 얹히는 기초 결정
    • 왜 전부가 아니라 선택적 캐싱인가 — 그래프의 모든 데이터가 같은 속도로 변하지 않는다. 계정 플랜 유형·콘텐츠 메타데이터 같은 일부 속성은 상대적으로 안정적(시간·일 단위 변경)이고, 누가 무엇을 언제 봤는지 같은 엣지는 끊임없이 변한다. 많은 쿼리가 건드리는 안정적 데이터에 대해 데이터 변동성에 맞춘 TTL로 분산 캐시(EVCache)를 쓴다. 캐싱 전략을 제대로 잡는 데 반복이 필요했다 — 공격적으로 캐싱하고 히트율 추적·stale 인시던트 모니터링·프로덕션에서 노드 유형별 실제 변화 속도에 따라 TTL 조정. 결과는 노드 조회 70~80% 히트율이고, 자주 접근되면서 느리게 변하는 노드로 캐시를 좁히고 TTL이 끝나기 전에 만료될 데이터는 건너뛴 덕분
    • 왜 자동이 아니라 opt-in enrichment인가 — 클라이언트가 무엇이 필요한지 안다. 계정 관계를 확인하는 쿼리는 타이틀 아트워크에 관심 없고, 시청 타임라인을 만드는 개인화 서비스는 관심 있다. 기본으로 외부 서비스에서 메타데이터를 가져와 모든 쿼리에 벌을 주는 대신 요청별로 정확히 어떤 외부 데이터를 원하는지 클라이언트가 명시하게 한다. 그리고 fail-open — 서비스가 느리거나 불가용하면 그것 없이 그래프 데이터를 반환
    • 왜 strong이 아니라 eventual consistency인가 — 대부분의 쿼리가 “이 멤버가 최근에 무엇을 했나”를 묻지 “지난 1밀리초에 무슨 일이 있었나”를 묻지 않는다. eventual을 기본으로 하면 가장 가까운 replica에서 읽고 조율 오버헤드를 피한다. RDG는 in-the-moment 경험을 구동하지만 담고 있는 데이터의 source of truth로 설정되어 있지는 않다
  • 3계층 아키텍처: Graph Query Service(진입점 — gRPC 요청 수용, 순회 명세 검증, 실행 엔진에 인계) → Storage Abstraction Layer(노드 조회·엣지 검색의 깔끔한 인터페이스, 큰 인접 리스트 스트리밍, 노드 캐싱) → Enrichment Layer(요청 시 외부 서비스에서 메타데이터 배치 조회, 그래프 데이터 조립과 병렬 실행, 소스 불가용 시 graceful degradation)
  • 쿼리 여정 6단계:
    1. 요청 읽기 — 스토리지를 건드리거나 엣지를 걷기 전에 엔진이 호출자가 실제로 원하는 것을 이해해야 한다. 애플리케이션 레벨 기본값부터 edge-type별 오버라이드까지 필터·limit 계층을 병합해 구체적 실행 계획으로. 핵심 통찰은 단순하다 — 요청을 미리 해석하는 것이 다운스트림 스토리지 레이어에서의 과다 fetch를 막는다
    2. 스토리지 읽기 — 인접 리스트와 스트리밍: 모든 엣지를 하나의 거대 테이블에 저장하면 source = Account X를 스캔해야 하는데 수십억 엣지에서 매 요청마다 하는 건 인덱싱이 있어도 느리다. 엣지를 인접 리스트로 조직하면 “Account X의 모든 프로필 가져오기”가 전역 검색이 아니라 저장된 인접 정보로의 직접 조회가 된다
      • 큰 fan-out에 대해서는 인접 리스트를 blob이 아니라 스트림으로 취급한다. 스토리지가 100개 배치로 스트리밍하고, 배치가 도착할 때마다 필터(“최근 30일”)를 적용하고 계속할지 결정. 쿼리 limit을 만족할 만큼 모았으면 읽기를 멈춘다. 500개 엣지(수개월 시청 이력) 중 몇 배치만에 필요한 것을 찾는다 — 필요 이상의 데이터를 절대 materialize하지 않고 필터링이 소스에서 일어난다
    3. 레벨별 순회 — Level 1(Account → Profiles)이 다음 레벨의 frontier가 되고, Level 2(Profiles → Content)에서 Profile_Alex의 전체 이력을 소진한 뒤 Profile_Kids로 가는 대신 하나의 논리적 단계로 취급해 각 프로필의 엣지를 병렬로 가져오고 스트리밍되는 대로 필터. 2-hop 쿼리 = 수백 번의 순차 조회가 아니라 두 라운드의 병렬 조회
    4. 병렬 실행 — 단일 큐가 아니라 주방: 애피타이저·메인·디저트마다 자기 용량을 가진 전문 스테이션이 있는 전문 주방처럼 구조화. 한 스테이션이 몰려도 다른 곳은 계속 흐른다. 실무적으로는 작업 유형별 전용 스레드 풀(노드 조회, 인접 리스트 읽기, enrichment). adaptive concurrency limiting으로 전역 용량도 관리 — 건강하면 한도를 점진적으로 올리고(100 → 101 → 102) 타임아웃·에러가 급증하면 더 큰 폭으로 물러난다(100 → 70). 동시성을 스위치가 아니라 다이얼로 취급
    5. 똑똑한 필터링 — 보수적 기본값(100일 lookback, hop당 300 엣지)에서 시작해 요청이 전역·hop별·특정 edge type별로 오버라이드할 수 있고 더 좁은 규칙이 이긴다. 시간 필터 후에도 엣지가 많으면 LATEST(타임스탬프로 정렬해 최신 것을 limit까지 — “이 프로필이 최근에 뭘 봤나”에 이상적)와 ANY(정렬 없이 먼저 만나는 엣지를 취해 더 빠름 — “이 프로필이 Stranger Things를 본 적이 있나”처럼 타이밍이 무관할 때) 두 선택 모드
    6. 캐싱 — 계정·프로필·콘텐츠 노드는 거의 변하지 않는다. 다만 모든 것을 캐싱할 수는 없다 — RDG가 보존 윈도우 후 오래된 활동을 pruning하므로 곧 삭제될 노드를 캐싱하는 것은 낭비다. smart TTL 정책: 노드의 마지막 활동 타임스탬프, 그래프 보존 기간(예: 100일), 캐시 TTL(예: 30일)을 고려해 마지막 활동이 99일 전이면 하루 뒤 그래프에서 만료되므로 30일 TTL이 말이 안 되어 건너뛴다

주요 수치 / 사실

  • 서빙 레이어가 80억 노드, 1,500억 엣지 앞에 서 있고 초당 수만 쿼리 처리
  • single-hop P50 1530ms, P99 100ms 미만. 3-hop 순회도 P99 100150ms
  • 수천 동시 요청이 16~24 스레드를 통해 흐름
  • 인기 엔티티(계정·프로필·콘텐츠) 캐시 히트율 70~80%, 일반적 쿼리 경로에서 스토리지 호출 약 3~4배 감소
  • 가장 큰 놀라움은 단일 최적화가 아니라 async 구성이 시스템의 경제학을 바꾼 정도였다. 레이턴시에 도움될 것은 예상했지만 인프라 비용을 깎을 줄은 몰랐다 — 인스턴스당 수백 스레드가 필요했을 서빙 레이어가 16~24로 편안히 돈다. 트레이드오프는 디버깅 용이성 — async 스택 트레이스는 읽기 어렵고 예외가 future 체인에서 사라질 수 있다. 검증·스토리지·enrichment·end-to-end 각 단계별 메트릭으로 보완해 느릴 때 어느 단계를 탓할지 정확히 안다
  • 필터링 계층은 좌절에서 태어났다 — 초기에는 새 유스케이스마다 코드 변경이었다(한 팀은 7일 lookback, 다른 팀은 90일, 세 번째는 깊이마다 다른 limit). 팀별 맞춤 로직 대신 계층화된 오버라이드 시스템을 만들었고, 기능 요청의 한 부류 전체를 없애 팀이 우리 코드를 건드리지 않고 자기 쿼리를 튜닝한다
  • 분산 시스템 원칙 4가지: 기능이 아니라 frontier로 생각하라(호출자가 어떤 frontier를 탐색할지 기술하게 하고 시스템이 효율적으로 걷는 법을 결정) / 늦게가 아니라 일찍 필터링하라(가져왔지만 필요 없는 모든 바이트가 낭비된 I/O. 필터와 limit을 스토리지 레이어에 최대한 가깝게 밀어라) / 기본값이 아니라 의도적으로 병렬화하라(무제한 동시성은 의존 시스템을 압도하기 전까지만 빨라 보인다) / 캐싱을 사후 고려가 아니라 1급 설계 선택으로 취급하라(무엇을 기억할 가치가 있는지, 얼마나 오래, 무엇이 메모리에서 사라지게 둘지 결정. TTL을 데이터 변동성에 맞추고 곧 만료될 것을 캐싱하지 마라)

관련 위키


Source: 원문 보기