[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
---
|
||||
id: backend-graphql-server-patterns
|
||||
title: GraphQL Server — Schema / Resolver / DataLoader
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [backend, graphql, dataloader, n+1, vibe-coding]
|
||||
tech_stack: { language: "TS / Apollo / Pothos / Yoga", applicable_to: ["Backend"] }
|
||||
applied_in: []
|
||||
aliases: [Apollo, Yoga, Pothos, schema-first, code-first, N+1]
|
||||
---
|
||||
|
||||
# GraphQL Server
|
||||
|
||||
> 클라이언트가 필요한 필드만 요청. **N+1 문제 = DataLoader** 가 답. Schema-first(SDL) vs Code-first(Pothos). REST 와 공존 — 단순 CRUD 는 REST 가 종종 낫다.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Resolver: field 마다 lazy 로 호출.
|
||||
- N+1: list resolver → 각 item resolver → DB 100번 hit.
|
||||
- DataLoader: 같은 tick 안 batch + cache.
|
||||
- Persisted query: 클라가 hash 만 보내 — 페이로드 줄임 + allowlist 보안.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Pothos (code-first, type-safe)
|
||||
```ts
|
||||
import SchemaBuilder from '@pothos/core';
|
||||
|
||||
const builder = new SchemaBuilder<{
|
||||
Context: { db: Db; loaders: Loaders };
|
||||
}>({});
|
||||
|
||||
builder.objectType('User', {
|
||||
fields: t => ({
|
||||
id: t.exposeID('id'),
|
||||
email: t.exposeString('email'),
|
||||
posts: t.field({
|
||||
type: ['Post'],
|
||||
resolve: (user, _, ctx) => ctx.loaders.postsByUser.load(user.id),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
builder.queryType({
|
||||
fields: t => ({
|
||||
me: t.field({
|
||||
type: 'User',
|
||||
resolve: (_, __, ctx) => ctx.db.getUser(ctx.userId),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const schema = builder.toSchema();
|
||||
```
|
||||
|
||||
### DataLoader — N+1 해결
|
||||
```ts
|
||||
import DataLoader from 'dataloader';
|
||||
|
||||
function makeLoaders(db: Db) {
|
||||
return {
|
||||
postsByUser: new DataLoader<string, Post[]>(async (userIds) => {
|
||||
const posts = await db.posts.where('userId', 'in', userIds);
|
||||
const byUser = new Map<string, Post[]>();
|
||||
for (const p of posts) {
|
||||
const arr = byUser.get(p.userId) ?? [];
|
||||
arr.push(p);
|
||||
byUser.set(p.userId, arr);
|
||||
}
|
||||
return userIds.map(id => byUser.get(id) ?? []);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// 매 request 마다 새로 — cache 가 cross-request 누수 안 되게
|
||||
app.use((req, res, next) => { req.loaders = makeLoaders(db); next(); });
|
||||
```
|
||||
|
||||
### Mutations
|
||||
```ts
|
||||
builder.mutationType({
|
||||
fields: t => ({
|
||||
createPost: t.field({
|
||||
type: 'Post',
|
||||
args: {
|
||||
input: t.arg({ type: CreatePostInput, required: true }),
|
||||
},
|
||||
resolve: async (_, { input }, ctx) => {
|
||||
if (!ctx.userId) throw new Error('UNAUTHORIZED');
|
||||
return ctx.db.posts.insert({ ...input, userId: ctx.userId });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Subscription (real-time)
|
||||
```ts
|
||||
builder.subscriptionType({
|
||||
fields: t => ({
|
||||
postCreated: t.field({
|
||||
type: 'Post',
|
||||
subscribe: (_, __, ctx) => ctx.pubsub.subscribe('POST_CREATED'),
|
||||
resolve: (payload) => payload,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Server (Yoga)
|
||||
```ts
|
||||
import { createYoga } from 'graphql-yoga';
|
||||
import { useResponseCache } from '@graphql-yoga/plugin-response-cache';
|
||||
|
||||
const yoga = createYoga({
|
||||
schema,
|
||||
context: ({ request }) => ({
|
||||
db, userId: getUserId(request), loaders: makeLoaders(db),
|
||||
}),
|
||||
plugins: [
|
||||
useResponseCache({ ttl: 1_000 }),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Persisted query (보안 + 성능)
|
||||
```ts
|
||||
// 클라이언트 빌드 시 query → hash 매핑 .json 생성 → 서버에 동기화
|
||||
// 서버는 hash 만 받고 등록된 query 만 실행
|
||||
```
|
||||
|
||||
### Error handling
|
||||
```ts
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
throw new GraphQLError('Not found', { extensions: { code: 'NOT_FOUND' } });
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 상황 | 추천 |
|
||||
|---|---|
|
||||
| 다양한 클라 (web/mobile/admin) | GraphQL |
|
||||
| 단순 CRUD 5개 endpoint | REST 충분 |
|
||||
| 실시간 | Subscription (WS) 또는 SSE |
|
||||
| 파일 업로드 | REST (multipart) — GraphQL 어색 |
|
||||
| 마이크로서비스 통합 | Federation (Apollo / Mesh) |
|
||||
| 강력 type safety | Pothos / GraphQL-Codegen |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **DataLoader 안 씀**: N+1 으로 100ms → 5s.
|
||||
- **DataLoader cross-request 공유**: 권한/cache leak.
|
||||
- **Resolver 깊이 무제한**: query depth 제한 (e.g. 10) + cost analysis.
|
||||
- **Internal error 그대로 노출**: stack trace 노출.
|
||||
- **Auth resolver 안에서 검사**: 쉽게 까먹음. Auth directive 또는 plugin.
|
||||
- **Mutation 이 read 도**: side-effect 명시 분리.
|
||||
- **Schema 자동 노출 prod**: introspection off + persisted query.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Pothos + Yoga + DataLoader 디폴트.
|
||||
- 매 요청 loaders 새로.
|
||||
- Persisted query 권장.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[GraphQL_Client_Patterns]]
|
||||
- [[REST_API_Versioning_Strategies]]
|
||||
- [[API_Auth_Bearer_Token_Patterns]]
|
||||
Reference in New Issue
Block a user