04 · Relay Store — 정규형을 spec으로 강제하다
질문: Apollo의 유연한 정규화 캐시가 있는데, Relay는 왜 더 엄격한 모델을 고집하나?
Nodeinterface와Connectionspec은 정확히 무엇을 푸는가? 한 줄 답: Relay는 cache가 그 자체로 일관된 그래프가 되도록 —Nodeinterface, 전역 고유id,Connectionspec, immutable store,updater함수의 5가지를 spec 차원에서 강제한다.
Pyramid Top
03이 Apollo의 유연한 정규화를 다뤘다면, 이번 문서는 Relay의 강제된 정규화를 다룬다. Relay는 Facebook이 자기 모바일 앱을 위해 만든 클라이언트라 — 사용자의 불편한 규약을 받아들이는 대신, 런타임에서 그래프가 항상 일관된 결과를 얻는다. 이 트레이드오프(엄격한 schema 규약 ↔ 견고한 캐시)가 Relay의 정체성이고, 연결성·페이지네이션·캐시 갱신이 다른 클라이언트와 근본적으로 다르게 동작하는 이유다.
사고 흐름
Why — 왜 spec 강제인가
Apollo의 typePolicies는 옵션이다. 안 정해도 동작한다 (단, 사고 위험). Relay는 옵션이 아니다. 서버 스키마가 다음 4개를 만족해야 Relay가 동작한다:
Nodeinterface — id 가진 모든 type이 구현해야 함.- 전역 고유
id— 같은 id면 어떤 typename이든 같은 entity로 취급되므로, typename 무관하게 유일해야 함. node(id: ID!): Noderoot field — id만으로 어떤 entity든 다시 조회 가능해야 함.Connectionspec — 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/42와 posts/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이 어떻게 풀었나 |
|---|---|
| 페이지 단위 cursor | cursor: String! per edge |
| 다음/이전 페이지 여부 | pageInfo.hasNext/hasPreviousPage |
| edge에 부가 메타 (e.g. role) | PostEdge.node 옆에 role: String 추가 가능 |
| forward / backward 양방향 | first/after와 last/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 debugging과 concurrent 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 비교 표
| 차원 | Apollo | Relay |
|---|---|---|
| 정규화 키 | typePolicies.keyFields (옵션) | Node.id (강제) |
| 빌드 타임 검증 | 약함 (codegen 옵션) | 강함 (relay-compiler가 거부) |
| 페이지네이션 | relayStylePagination() 프리셋 | @connection directive로 자동 |
| immutability | 변경 가능 | 불변 store + snapshot |
| API 표면 | cache.modify, useFragment, writeFragment | commitMutation.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 condition과 missing record 문제를 회피.
- edge에 메타데이터를 둘 수 있어 role/score/rank 같은 관계 자체의 속성을 표현 가능.
서버 스키마가 Relay-friendly한 게 곧 모든 클라이언트에게 유리하다는 게 업계 표준의 답.
흥미로운 이야기
Relay는 Facebook이 자기 신경을 위해 디자인했다
2015년 Lee Byron이 GraphQL Summit에서 *“Relay는 Facebook의 모바일 News Feed가 가진 그래프 일관성 문제를 풀려고 만들었다”*고 밝혔다. 같은 사용자가 N개 화면에서 보이는 환경에서, 한 화면의 like가 다른 화면에 반영되지 않는 것은 bug report의 다수를 차지했다고 한다. 그 해법으로 Facebook이 도달한 결론은 — 클라이언트 캐시가 그 자체로 그래프여야 한다. 그래서
Nodeinterface가 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 차원에서 강제한다 —
Nodeinterface, 전역 고유 id,node(id:)재조회,Connectionspec, immutable store,updater함수의 5가지. 이 강제가 서버 스키마 진입장벽을 만들지만, 그 대가로 런타임 캐시 사고가 거의 0이 된다. Connection spec은 Relay 외부에서도 de facto 표준이 되었고, GitHub v4·Shopify가 그것을 채택한 이유다. Apollo와의 본질적 차이는 유연성 ↔ 강제력의 트레이드오프고, 팀 규모와 도메인 복잡도가 그 답을 정한다. 다음 문서(05-persisted-queries)는 클라이언트 캐시가 아니라 네트워크 비용을 줄이는 다음 레이어 — query 자체를 hash로 줄여 보내는 법을 다룬다.