04 · Relay Store — 정규형을 spec으로 강제하다

질문: Apollo의 유연한 정규화 캐시가 있는데, Relay는 왜 더 엄격한 모델을 고집하나? Node interface와 Connection spec은 정확히 무엇을 푸는가? 한 줄 답: Relay는 cache가 그 자체로 일관된 그래프가 되도록 — Node interface, 전역 고유 id, Connection spec, immutable store, updater 함수의 5가지를 spec 차원에서 강제한다.


Pyramid Top

03이 Apollo의 유연한 정규화를 다뤘다면, 이번 문서는 Relay의 강제된 정규화를 다룬다. Relay는 Facebook이 자기 모바일 앱을 위해 만든 클라이언트라 — 사용자의 불편한 규약을 받아들이는 대신, 런타임에서 그래프가 항상 일관된 결과를 얻는다. 이 트레이드오프(엄격한 schema 규약 ↔ 견고한 캐시)가 Relay의 정체성이고, 연결성·페이지네이션·캐시 갱신이 다른 클라이언트와 근본적으로 다르게 동작하는 이유다.


사고 흐름


Why — 왜 spec 강제인가

Apollo의 typePolicies옵션이다. 안 정해도 동작한다 (단, 사고 위험). Relay는 옵션이 아니다. 서버 스키마가 다음 4개를 만족해야 Relay가 동작한다:

  1. Node interface — id 가진 모든 type이 구현해야 함.
  2. 전역 고유 id — 같은 id면 어떤 typename이든 같은 entity로 취급되므로, typename 무관하게 유일해야 함.
  3. node(id: ID!): Node root field — id만으로 어떤 entity든 다시 조회 가능해야 함.
  4. Connection spec — list 필드는 edges/cursors/pageInfo 구조여야 함.

이 조건을 서버가 제공하지 못하면 Relay는 컴파일러가 거부한다. 이게 런타임 사고 대신 빌드 타임 사고로 옮긴 트레이드오프다.

이게 Relay의 진입장벽: 서버 스키마를 Relay 친화적으로 다시 짜야 한다. 그 대신 클라이언트 측 캐시 사고가 거의 0이 된다.


How — 5개 강제의 동작

Node interface — 모든 entity의 공통 형태

interface Node {
  id: ID!
}
 
type User implements Node {
  id: ID!          # ← typename:realId 형태로 *base64 인코딩* 되는 게 관례
  name: String!
}
 
type Query {
  node(id: ID!): Node    # ← 모든 entity 재조회 가능
  user(id: ID!): User
}

Node를 구현하는 순간:

  • 어떤 type이든 id 하나로 cache에서 찾을 수 있다.
  • 어떤 화면이든 node(id: ...)이미 캐시된 데이터를 다시 받을 수 있다.

② 전역 고유 id

REST users/42posts/42다른 자원이지만 id 42를 공유한다. Relay는 그것을 거부한다.

User:42  →  id = "VXNlcjo0Mg=="    (base64 of "User:42")
Post:42  →  id = "UG9zdDo0Mg=="    (base64 of "Post:42")

id 자체에 typename이 포함되어 있어, 한 평면 namespace에서 id 충돌이 불가능하다. 이게 Relay store가 전역 id → record dictionary 하나로 동작할 수 있는 이유다.

③ Connection spec — 페이지네이션의 표준 형태

type PostConnection {
  edges: [PostEdge]
  pageInfo: PageInfo!
}
type PostEdge {
  node: Post
  cursor: String!
}
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
 
type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection
}

이 구조는 4가지를 동시에 푼다:

요구spec이 어떻게 풀었나
페이지 단위 cursorcursor: String! per edge
다음/이전 페이지 여부pageInfo.hasNext/hasPreviousPage
edge에 부가 메타 (e.g. role)PostEdge.node 옆에 role: String 추가 가능
forward / backward 양방향first/afterlast/before

Relay 컴파일러가 @connection(key: "Feed_posts")를 보고 — cursor 기반 무한 스크롤을 자동으로 짠다. merge·read손으로 짤 필요가 없다.

query Feed {
  posts(first: 10, after: $cursor) @connection(key: "Feed_posts") {
    edges { node { id title } cursor }
    pageInfo { hasNextPage endCursor }
  }
}

④ Immutable store — RecordSource + Snapshot

Apollo의 cache는 직접 변경 가능하다. Relay는 불변이다.

  • RecordSource{ [id]: Record } flat map.
  • Snapshot그 시점의 store 사본. 화면 컴포넌트가 snapshot을 구독한다.
  • 변경은 새 snapshot을 만들고 subscribers를 notify한다 (Redux와 같은 패턴).

이 immutability가 time travel debuggingconcurrent rendering을 가능하게 한다. React 18의 useTransition이 Relay와 특히 잘 맞는 이유.

⑤ Updater function — mutation 후 직접 갱신

commitMutation(environment, {
  mutation,
  variables,
  optimisticUpdater(store) {
    const post = store.get(postId);
    post.setValue(post.getValue("likeCount") + 1, "likeCount");
  },
  updater(store) {
    // 서버 응답 도착 후
    const conn = ConnectionHandler.getConnection(viewer, "Feed_posts");
    const newEdge = ConnectionHandler.createEdge(store, conn, newPost, "PostEdge");
    ConnectionHandler.insertEdgeAfter(conn, newEdge);
  },
});

Apollo의 cache.modify에 해당하지만 — Relay store 객체를 직접 다룬다. ConnectionHandler 같은 connection-aware 헬퍼가 spec을 따른다.


What — Apollo vs Relay 비교 표

차원ApolloRelay
정규화 키typePolicies.keyFields (옵션)Node.id (강제)
빌드 타임 검증약함 (codegen 옵션)강함 (relay-compiler가 거부)
페이지네이션relayStylePagination() 프리셋@connection directive로 자동
immutability변경 가능불변 store + snapshot
API 표면cache.modify, useFragment, writeFragmentcommitMutation.updater, useFragment, store.get
러닝 커브낮음높음
서버 요구없음Node + Connection spec
런타임 사고율중간 (policy 빠뜨리면 사고)매우 낮음 (compiler가 막음)
클라이언트 번들큼 (~33KB)작음 (~16KB)
사용 회사대부분 (Apollo의 시장 점유율 1위)Meta·Coinbase·Robinhood·1Password

What-if — Relay 규약을 위반하면

위반무엇이 깨지나
Node interface 미구현relay-compiler가 빌드 실패
id가 typename 안 포함같은 id의 다른 type이 서로 덮어쓰기
node(id:) 미제공refetch·optimistic rollback 불가
Connection spec 미준수@connection 사용 불가 → 페이지네이션 수동 짜야 함
pageInfo 누락무한 스크롤 종료 조건 모름
edge에 cursor 없음”다음부터” 시작점 불가

이 위반들은 런타임에 일어나지 않는다컴파일 타임에 잡힌다. Relay의 핵심 가치 제안이 그것.


Connection spec이 de facto 표준이 된 이유

GraphQL spec 자체는 Connection강제하지 않는다. 그런데 거의 모든 큰 공개 GraphQL API가 이걸 쓴다 — GitHub v4, Shopify Storefront, Contentful, Hasura. 왜?

  • Relay-friendly하면 Apollo·urql 사용자에게도 해가 없다.
  • 페이지네이션을 cursor 기반으로 강제 → offset 기반이 가지는 race conditionmissing record 문제를 회피.
  • edge에 메타데이터를 둘 수 있어 role/score/rank 같은 관계 자체의 속성을 표현 가능.

서버 스키마가 Relay-friendly한 게 곧 모든 클라이언트에게 유리하다는 게 업계 표준의 답.


흥미로운 이야기

Relay는 Facebook이 자기 신경을 위해 디자인했다

2015년 Lee Byron이 GraphQL Summit에서 *“Relay는 Facebook의 모바일 News Feed가 가진 그래프 일관성 문제를 풀려고 만들었다”*고 밝혔다. 같은 사용자가 N개 화면에서 보이는 환경에서, 한 화면의 like가 다른 화면에 반영되지 않는 것bug report의 다수를 차지했다고 한다. 그 해법으로 Facebook이 도달한 결론은 — 클라이언트 캐시가 그 자체로 그래프여야 한다. 그래서 Node interface가 spec 차원에서 강제되었다. 같은 해 Lee Byron의 발언: “Relay is opinionated, and that’s the point.” 옵션이 많은 도구는 옵션을 잘못 쓴 사고가 많다 — Apollo의 typePolicy 사고들이 그 증거다. Relay는 그 사고들을 컴파일러로 막는 길을 골랐다.


Insight — 불편한 규약이 곧 안전

둘 다 정규화 캐시다. 차이는 강제 시점 — Apollo는 런타임에 적당히, Relay는 컴파일 타임에 단단히. 어느 쪽이 옳다가 아니라, 팀의 규모와 사고 비용이 정답을 정한다.

  • 팀 < 5명이고 프로토타이핑 → Apollo.
  • 팀 > 20명이고 공유 도메인 → Relay (또는 Relay-friendly한 Apollo).

한 단락 요약

Relay는 그래프 일관성spec 차원에서 강제한다 — Node interface, 전역 고유 id, node(id:) 재조회, Connection spec, immutable store, updater 함수의 5가지. 이 강제가 서버 스키마 진입장벽을 만들지만, 그 대가로 런타임 캐시 사고가 거의 0이 된다. Connection spec은 Relay 외부에서도 de facto 표준이 되었고, GitHub v4·Shopify가 그것을 채택한 이유다. Apollo와의 본질적 차이는 유연성 ↔ 강제력의 트레이드오프고, 팀 규모와 도메인 복잡도가 그 답을 정한다. 다음 문서(05-persisted-queries)는 클라이언트 캐시가 아니라 네트워크 비용을 줄이는 다음 레이어 — query 자체를 hash로 줄여 보내는 법을 다룬다.