[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
---
|
||||
id: backend-graphql-yoga-pothos
|
||||
title: GraphQL Yoga / Pothos — Modern GraphQL Server
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [backend, graphql, yoga, pothos, vibe-coding]
|
||||
tech_stack: { language: "TS", applicable_to: ["Backend"] }
|
||||
applied_in: []
|
||||
aliases: [GraphQL Yoga, Pothos, code-first GraphQL, Apollo Server alternative, Mercurius]
|
||||
---
|
||||
|
||||
# GraphQL Yoga / Pothos
|
||||
|
||||
> Apollo Server 의 modern alternative. **Yoga (server) + Pothos (schema builder, type-safe)**. Edge runtime + 빠른 + 작은. Federation 지원.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Yoga: Server (request handler).
|
||||
- Pothos: code-first schema builder (TS).
|
||||
- DataLoader: N+1 해결.
|
||||
- Federation: 마이크로서비스 결합.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Yoga + Pothos 시작
|
||||
```bash
|
||||
yarn add graphql graphql-yoga @pothos/core
|
||||
```
|
||||
|
||||
```ts
|
||||
import { createYoga } from 'graphql-yoga';
|
||||
import SchemaBuilder from '@pothos/core';
|
||||
|
||||
const builder = new SchemaBuilder<{
|
||||
Context: { user: User | null; db: DB };
|
||||
}>({});
|
||||
|
||||
builder.objectType('User', {
|
||||
fields: t => ({
|
||||
id: t.exposeID('id'),
|
||||
email: t.exposeString('email'),
|
||||
name: t.exposeString('name', { nullable: true }),
|
||||
posts: t.field({
|
||||
type: ['Post'],
|
||||
resolve: (user, _, ctx) => ctx.db.posts.findByUser(user.id),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
builder.queryType({
|
||||
fields: t => ({
|
||||
me: t.field({
|
||||
type: 'User',
|
||||
nullable: true,
|
||||
resolve: (_, __, ctx) => ctx.user,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const yoga = createYoga({
|
||||
schema: builder.toSchema(),
|
||||
context: async ({ request }) => ({
|
||||
user: await getUser(request),
|
||||
db,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Server (Bun / Node)
|
||||
import { createServer } from 'node:http';
|
||||
const server = createServer(yoga);
|
||||
server.listen(4000);
|
||||
```
|
||||
|
||||
### Type-safe input
|
||||
```ts
|
||||
const CreatePostInput = builder.inputType('CreatePostInput', {
|
||||
fields: t => ({
|
||||
title: t.string({ required: true }),
|
||||
body: t.string({ required: true }),
|
||||
tags: t.stringList(),
|
||||
}),
|
||||
});
|
||||
|
||||
builder.mutationType({
|
||||
fields: t => ({
|
||||
createPost: t.field({
|
||||
type: 'Post',
|
||||
args: {
|
||||
input: t.arg({ type: CreatePostInput, required: true }),
|
||||
},
|
||||
resolve: async (_, { input }, ctx) => {
|
||||
if (!ctx.user) throw new Error('UNAUTHORIZED');
|
||||
return ctx.db.posts.create({ ...input, userId: ctx.user.id });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
→ Schema + resolver type-safe.
|
||||
|
||||
### Pothos Prisma plugin
|
||||
```ts
|
||||
import PrismaPlugin from '@pothos/plugin-prisma';
|
||||
import type PrismaTypes from './generated/pothos-types';
|
||||
|
||||
const builder = new SchemaBuilder<{ PrismaTypes: PrismaTypes }>({
|
||||
plugins: [PrismaPlugin],
|
||||
prisma: { client: prisma },
|
||||
});
|
||||
|
||||
builder.prismaObject('User', {
|
||||
fields: t => ({
|
||||
id: t.exposeID('id'),
|
||||
email: t.exposeString('email'),
|
||||
posts: t.relation('posts'), // N+1 자동 해결
|
||||
}),
|
||||
});
|
||||
|
||||
builder.queryFields(t => ({
|
||||
user: t.prismaField({
|
||||
type: 'User',
|
||||
args: { id: t.arg.id({ required: true }) },
|
||||
resolve: (query, _, { id }) => prisma.user.findUnique({ ...query, where: { id } }),
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
→ Prisma + Pothos = N+1 자동.
|
||||
|
||||
### Drizzle plugin
|
||||
```ts
|
||||
import { drizzlePlugin } from '@pothos/plugin-drizzle';
|
||||
|
||||
const builder = new SchemaBuilder<{ DrizzleSchema: typeof schema }>({
|
||||
plugins: [drizzlePlugin],
|
||||
drizzle: { client: db },
|
||||
});
|
||||
|
||||
builder.drizzleObject('users', {
|
||||
name: 'User',
|
||||
fields: t => ({
|
||||
id: t.exposeID('id'),
|
||||
email: t.exposeString('email'),
|
||||
posts: t.relation('posts'),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### DataLoader (manual)
|
||||
```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 grouped = new Map<string, Post[]>();
|
||||
for (const p of posts) {
|
||||
const arr = grouped.get(p.userId) ?? [];
|
||||
arr.push(p);
|
||||
grouped.set(p.userId, arr);
|
||||
}
|
||||
return userIds.map(id => grouped.get(id) ?? []);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Per-request
|
||||
const yoga = createYoga({
|
||||
context: ({ request }) => ({
|
||||
user: ...,
|
||||
loaders: makeLoaders(db),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
→ Pothos + Prisma 가 자동. 자체 = manual loader.
|
||||
|
||||
### Subscription (real-time)
|
||||
```ts
|
||||
builder.subscriptionType({
|
||||
fields: t => ({
|
||||
postCreated: t.field({
|
||||
type: 'Post',
|
||||
subscribe: (_, __, ctx) => ctx.pubsub.subscribe('POST_CREATED'),
|
||||
resolve: (payload) => payload,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Yoga + WebSocket
|
||||
import { createServer } from 'node:http';
|
||||
import { useServer } from 'graphql-ws/lib/use/ws';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
const wss = new WebSocketServer({ server: httpServer, path: '/graphql' });
|
||||
useServer({ schema, context: () => ({ ... }) }, wss);
|
||||
```
|
||||
|
||||
### Persisted queries
|
||||
```ts
|
||||
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations';
|
||||
|
||||
const yoga = createYoga({
|
||||
plugins: [
|
||||
usePersistedOperations({
|
||||
getPersistedOperation: (key) => operations[key],
|
||||
allowArbitraryOperations: false, // prod
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
→ Client = hash, server = registered query. Bandwidth + security.
|
||||
|
||||
### Cost analysis (DoS 방지)
|
||||
```ts
|
||||
import { useCostAnalysis } from '@envelop/cost-analysis';
|
||||
|
||||
const yoga = createYoga({
|
||||
plugins: [
|
||||
useCostAnalysis({
|
||||
maximumCost: 1000,
|
||||
defaultCost: 1,
|
||||
// 매 field 의 cost 정의
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
→ 큰 nested query (10 → 100 → 1000) 차단.
|
||||
|
||||
### Depth limit
|
||||
```ts
|
||||
import { useDepthLimit } from '@envelop/depth-limit';
|
||||
|
||||
useDepthLimit({ maxDepth: 7 });
|
||||
```
|
||||
|
||||
### Error masking
|
||||
```ts
|
||||
import { useMaskedErrors } from '@envelop/core';
|
||||
|
||||
useMaskedErrors({
|
||||
maskError: (error, message) => {
|
||||
if (error.extensions?.code === 'INTERNAL_ERROR') {
|
||||
return new Error('Internal server error');
|
||||
}
|
||||
return error;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
→ Internal error 사용자에 자세 X.
|
||||
|
||||
### Authentication
|
||||
```ts
|
||||
const yoga = createYoga({
|
||||
context: async ({ request }) => {
|
||||
const token = request.headers.get('authorization')?.replace('Bearer ', '');
|
||||
const user = token ? await verifyJwt(token) : null;
|
||||
return { user };
|
||||
},
|
||||
});
|
||||
|
||||
// Resolver
|
||||
resolve: (_, __, ctx) => {
|
||||
if (!ctx.user) throw new GraphQLError('UNAUTHORIZED', { extensions: { code: 'UNAUTHORIZED' } });
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Authorization (field-level)
|
||||
```ts
|
||||
import AuthPlugin from '@pothos/plugin-scope-auth';
|
||||
|
||||
const builder = new SchemaBuilder<{
|
||||
AuthScopes: { admin: boolean; loggedIn: boolean };
|
||||
}>({
|
||||
plugins: [AuthPlugin],
|
||||
authScopes: ({ user }) => ({
|
||||
admin: user?.role === 'admin',
|
||||
loggedIn: !!user,
|
||||
}),
|
||||
});
|
||||
|
||||
builder.queryFields(t => ({
|
||||
adminStats: t.field({
|
||||
type: 'Stats',
|
||||
authScopes: { admin: true },
|
||||
resolve: () => ...,
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
### Federation (마이크로서비스)
|
||||
```ts
|
||||
import { fastify } from 'fastify';
|
||||
import { useApolloFederation } from '@graphql-yoga/apollo-federation';
|
||||
|
||||
const subgraph = builder.toSchema();
|
||||
useApolloFederation({ subgraph });
|
||||
|
||||
// Gateway
|
||||
import { stitchSchemas } from '@graphql-tools/stitch';
|
||||
const supergraph = stitchSchemas({
|
||||
subschemas: [usersSubgraph, ordersSubgraph],
|
||||
});
|
||||
```
|
||||
|
||||
### Edge runtime (Hono + Yoga)
|
||||
```ts
|
||||
import { Hono } from 'hono';
|
||||
import { createYoga } from 'graphql-yoga';
|
||||
|
||||
const yoga = createYoga({ schema });
|
||||
|
||||
const app = new Hono();
|
||||
app.all('/graphql', (c) => yoga.fetch(c.req.raw, c.env));
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
→ Cloudflare Workers / Vercel Edge.
|
||||
|
||||
### Mercurius (Fastify GraphQL, fast)
|
||||
```ts
|
||||
import Fastify from 'fastify';
|
||||
import mercurius from 'mercurius';
|
||||
|
||||
const app = Fastify();
|
||||
app.register(mercurius, { schema, resolvers });
|
||||
```
|
||||
|
||||
→ Yoga 의 Fastify 대안 — 매우 빠름.
|
||||
|
||||
### Code-first vs Schema-first
|
||||
```
|
||||
Code-first (Pothos):
|
||||
+ Type-safe (TS 가 schema 만듦)
|
||||
+ Refactoring 쉬움
|
||||
- Schema = code (다른 lang client 가 generate 필요)
|
||||
|
||||
Schema-first (SDL .graphql 파일):
|
||||
+ Schema 가 truth
|
||||
+ 다른 lang 가 generate 가능
|
||||
+ Tools (codegen) 친화
|
||||
- Type 가 다른 곳 — drift 가능
|
||||
```
|
||||
|
||||
→ Pothos 추세.
|
||||
|
||||
### vs Apollo Server
|
||||
```
|
||||
Apollo:
|
||||
+ 큰 ecosystem
|
||||
+ Apollo Studio (managed)
|
||||
- 옛 (some legacy)
|
||||
|
||||
Yoga:
|
||||
+ Modern, 빠름
|
||||
+ Edge 호환
|
||||
+ 작은 bundle
|
||||
- 작은 community
|
||||
```
|
||||
|
||||
### Persisted queries (Apollo Persisted Queries)
|
||||
```ts
|
||||
// Build-time:
|
||||
// Client 의 모든 query → hash → registry.
|
||||
|
||||
// Runtime:
|
||||
// Client 가 hash 만 보냄.
|
||||
// Server 가 hash → query 조회.
|
||||
|
||||
// 장점:
|
||||
// - Bandwidth 작음
|
||||
// - Public schema 숨김 (allowlist)
|
||||
// - DoS 방지
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 상황 | 추천 |
|
||||
|---|---|
|
||||
| Modern TS GraphQL | Yoga + Pothos |
|
||||
| Federation | Apollo / Yoga + tools |
|
||||
| Edge runtime | Yoga |
|
||||
| Fastify ecosystem | Mercurius |
|
||||
| Existing Apollo | 점진 migrate |
|
||||
| Schema-first 강 | GraphQL Codegen + Yoga |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **Cost analysis 없음**: 큰 nested query DoS.
|
||||
- **N+1 무관심**: DataLoader 또는 plugin.
|
||||
- **Depth limit 없음**: deep query.
|
||||
- **모든 field public**: auth scope.
|
||||
- **Schema 자주 변경 — version 없음**: client 깨짐.
|
||||
- **Public schema 추가 + persisted X**: introspection leak.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Yoga + Pothos = modern stack.
|
||||
- Pothos + Prisma plugin 가 N+1 자동.
|
||||
- Cost / depth / scope auth 3종 항상.
|
||||
- Edge runtime 호환.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Backend_GraphQL_Server_Patterns]]
|
||||
- [[Web_GraphQL_Client_Patterns]]
|
||||
- [[Backend_Hono_Modern]]
|
||||
Reference in New Issue
Block a user