--- 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(async (userIds) => { const posts = await db.posts.where('userId', 'in', userIds); const byUser = new Map(); 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]]