- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに:なぜGraphQLなのか?
2015年にFacebookがオープンソースとして公開(こうかい)したGraphQLは、今やAPI設計(せっけい)の標準的(ひょうじゅんてき)な選択肢(せんたくし)となりました。GitHub、Shopify、Netflix、Airbnbなど主要(しゅよう)テック企業(きぎょう)が採用(さいよう)し、RESTの限界(げんかい)を超(こ)える新(あたら)しいパラダイムを示(しめ)しています。
この記事(きじ)では、GraphQLの基礎(きそ)からFederationを活用(かつよう)したマイクロサービス統合(とうごう)、パフォーマンス最適化(さいてきか)まで、実務(じつむ)ですぐに活用(かつよう)できる内容(ないよう)を扱(あつか)います。
1. REST vs GraphQL:根本的(こんぽんてき)な違(ちが)い
RESTの限界(げんかい)
REST APIは長年(ながねん)WebAPIの標準(ひょうじゅん)でしたが、いくつかの根本的(こんぽんてき)な問題(もんだい)があります。
Over-fetching:不要(ふよう)なデータまでレスポンスに含(ふく)まれます。ユーザー名(めい)だけ必要(ひつよう)なのに、プロフィール全体(ぜんたい)が返(かえ)ってきます。
Under-fetching:一(ひと)つの画面(がめん)を描画(びょうが)するために複数(ふくすう)のエンドポイントを呼(よ)び出(だ)す必要(ひつよう)があります。ユーザー情報(じょうほう) + 投稿(とうこう) + コメント = 3回(かい)のリクエスト。
エンドポイント管理(かんり):クライアントの要件(ようけん)が変(か)わるたびに新(あたら)しいエンドポイントを作成(さくせい)するか、既存(きそん)のものを修正(しゅうせい)する必要(ひつよう)があります。
GraphQLの解決策(かいけつさく)
# 1回のリクエストで必要なデータだけを正確に取得
query GetUserWithPosts {
user(id: "123") {
name
email
posts(first: 5) {
title
commentCount
}
}
}
| 区分 | REST | GraphQL |
|---|---|---|
| エンドポイント | リソースごとに別URL | 単一エンドポイント |
| データ量 | サーバーが決定 | クライアントが決定 |
| バージョン管理 | v1, v2 URL分岐 | スキーマ進化(deprecation) |
| 型システム | Swagger/OpenAPIで補強 | 内蔵型システム(SDL) |
| リアルタイム | 別途WebSocket実装 | Subscription内蔵 |
| キャッシュ | HTTPキャッシュ活用が容易 | 別途戦略が必要 |
| 学習曲線 | 低い | 中〜高い |
核心的(かくしんてき)な違(ちが)い:宣言的(せんげんてき)データリクエスト
RESTは**どこから(WHERE)データを取得(しゅとく)するかを指定(してい)し、GraphQLは何(なに)を(WHAT)**取得(しゅとく)するかを宣言(せんげん)します。
2. スキーマ設計(せっけい) (Schema Definition Language)
基本(きほん)型(かた)システム
GraphQLのスキーマはAPIの契約書(けいやくしょ)です。SDLを使(つか)って型(かた)を定義(ていぎ)します。
# スカラー型: String, Int, Float, Boolean, ID
# ! はNon-nullを意味します
type User {
id: ID!
name: String!
email: String!
age: Int
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
tags: [String!]
publishedAt: DateTime
}
type Comment {
id: ID!
body: String!
author: User!
post: Post!
createdAt: DateTime!
}
Query, Mutation, Subscription
GraphQLには3つのオペレーションタイプがあります。
type Query {
# データ取得
user(id: ID!): User
users(filter: UserFilter, pagination: PaginationInput): UserConnection!
post(id: ID!): Post
searchPosts(query: String!): [Post!]!
}
type Mutation {
# データ変更
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createPost(input: CreatePostInput!): Post!
likePost(postId: ID!): Post!
}
type Subscription {
# リアルタイム更新
postCreated: Post!
commentAdded(postId: ID!): Comment!
userStatusChanged(userId: ID!): User!
}
Input型(かた)とEnum
input CreateUserInput {
name: String!
email: String!
age: Int
role: UserRole = USER
}
input UserFilter {
role: UserRole
nameContains: String
createdAfter: DateTime
}
input PaginationInput {
first: Int = 20
after: String
}
enum UserRole {
ADMIN
EDITOR
USER
GUEST
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
Relayスタイルコネクション(カーソルベースページネーション)
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
3. Resolverパターン
基本(きほん)Resolver構造(こうぞう)
Resolverはスキーマの各(かく)フィールドを実際(じっさい)のデータと接続(せつぞく)します。
// Resolverの4つの引数: parent, args, context, info
const resolvers = {
Query: {
user: async (parent, args, context, info) => {
const { id } = args;
const { dataSources, currentUser } = context;
return dataSources.userAPI.getUser(id);
},
users: async (_, { filter, pagination }, { dataSources }) => {
return dataSources.userAPI.getUsers(filter, pagination);
},
searchPosts: async (_, { query }, { dataSources }) => {
return dataSources.postAPI.search(query);
},
},
Mutation: {
createUser: async (_, { input }, { dataSources, currentUser }) => {
if (!currentUser?.isAdmin) {
throw new ForbiddenError('Admin access required');
}
return dataSources.userAPI.createUser(input);
},
likePost: async (_, { postId }, { dataSources, currentUser }) => {
if (!currentUser) {
throw new AuthenticationError('Login required');
}
return dataSources.postAPI.likePost(postId, currentUser.id);
},
},
// 型Resolver - リレーションフィールドの解決
User: {
posts: async (user, _, { dataSources }) => {
return dataSources.postAPI.getPostsByAuthor(user.id);
},
},
Post: {
author: async (post, _, { dataSources }) => {
return dataSources.userAPI.getUser(post.authorId);
},
comments: async (post, _, { dataSources }) => {
return dataSources.commentAPI.getByPost(post.id);
},
},
};
Context設定(せってい)
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
app.use(
'/graphql',
expressMiddleware(server, {
context: async ({ req }) => {
const token = req.headers.authorization || '';
const currentUser = await getUserFromToken(token);
return {
currentUser,
dataSources: {
userAPI: new UserDataSource(),
postAPI: new PostDataSource(),
commentAPI: new CommentDataSource(),
},
};
},
})
);
4. N+1問題(もんだい)とDataLoader
N+1問題(もんだい)とは?
投稿(とうこう)リストを取得(しゅとく)した後(あと)、各(かく)投稿(とうこう)の著者(ちょしゃ)を個別(こべつ)クエリで取得(しゅとく)するとN+1問題(もんだい)が発生(はっせい)します。
Query: posts(first: 10) → 1回のクエリ(投稿10件)
Post.author (post 1) → 2回目のクエリ
Post.author (post 2) → 3回目のクエリ
...
Post.author (post 10) → 11回目のクエリ
合計(ごうけい)11回(かい)のDBクエリが実行(じっこう)されます。
DataLoaderで解決(かいけつ)
DataLoaderは同(おな)じイベントループティック内(ない)のリクエストをまとめてバッチ処理(しょり)します。
import DataLoader from 'dataloader';
// バッチ関数: ID配列を受け取り、同じ順序で結果を返す
const userLoader = new DataLoader(async (userIds: string[]) => {
const users = await db.users.findMany({
where: { id: { in: userIds } },
});
// リクエストされたID順に結果をマッピング
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) || null);
});
// Resolverで使用
const resolvers = {
Post: {
author: (post, _, { loaders }) => {
return loaders.userLoader.load(post.authorId);
},
},
};
結果(けっか):11回(かい)のクエリが2回(かい)に削減(さくげん)されます。
Query: posts(first: 10) → 1回のクエリ
DataLoader batch: users([1,2,3,...,10]) → 2回のクエリ(バッチ)
DataLoaderコンテキストパターン
// リクエストごとに新しいDataLoaderインスタンスを生成
function createLoaders() {
return {
userLoader: new DataLoader((ids) => batchGetUsers(ids)),
postLoader: new DataLoader((ids) => batchGetPosts(ids)),
commentLoader: new DataLoader((ids) => batchGetComments(ids)),
// 1:Nリレーション用loader
postsByAuthorLoader: new DataLoader(async (authorIds) => {
const posts = await db.posts.findMany({
where: { authorId: { in: authorIds } },
});
const grouped = groupBy(posts, 'authorId');
return authorIds.map((id) => grouped[id] || []);
}),
};
}
// contextでリクエストごとに新規生成
context: async ({ req }) => ({
currentUser: await getUser(req),
loaders: createLoaders(),
}),
5. Apollo ServerとClient
Apollo Serverセットアップ
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { makeExecutableSchema } from '@graphql-tools/schema';
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
const server = new ApolloServer({
schema,
plugins: [
// 本番環境でイントロスペクション無効化
ApolloServerPluginLandingPageDisabled(),
// 使用量レポーティング
ApolloServerPluginUsageReporting(),
],
formatError: (formattedError, error) => {
// エラーフォーマット - 内部情報を隠す
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return { message: 'Internal server error' };
}
return formattedError;
},
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => ({
currentUser: await authenticateUser(req),
loaders: createLoaders(),
}),
});
console.log(`Server ready at ${url}`);
Apollo Client (React)
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
useQuery,
useMutation,
gql,
} from '@apollo/client';
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
// Relayスタイルカーソルページネーションマージ
keyArgs: ['filter'],
merge(existing, incoming, { args }) {
if (!args?.after) return incoming;
return {
...incoming,
edges: [...(existing?.edges || []), ...incoming.edges],
};
},
},
},
},
},
}),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});
// Reactコンポーネントでの使用
const GET_POSTS = gql`
query GetPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
edges {
node {
id
title
author {
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function PostList() {
const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 10 },
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error occurred</p>;
return (
<div>
{data.posts.edges.map(({ node }) => (
<PostCard key={node.id} post={node} />
))}
{data.posts.pageInfo.hasNextPage && (
<button
onClick={() =>
fetchMore({
variables: { after: data.posts.pageInfo.endCursor },
})
}
>
Load More
</button>
)}
</div>
);
}
6. Apollo Federation v2
Federationとは?
Apollo Federationは複数(ふくすう)のGraphQLサービス(Subgraph)を一(ひと)つの統合(とうごう)API(Supergraph)に合成(ごうせい)します。各(かく)チームが独立(どくりつ)してサービスを開発(かいはつ)しながら、クライアントには一(ひと)つのGraphQLエンドポイントを提供(ていきょう)します。
Subgraph設計(せっけい)
# --- Users Subgraph ---
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
role: UserRole!
}
type Query {
user(id: ID!): User
me: User
}
# --- Posts Subgraph ---
type Post @key(fields: "id") {
id: ID!
title: String!
content: String!
author: User!
publishedAt: DateTime
}
# 他のsubgraphのUser型を拡張
type User @key(fields: "id") {
id: ID!
posts: [Post!]! # Postsサービスが追加するフィールド
postCount: Int!
}
type Query {
post(id: ID!): Post
feed(first: Int = 20, after: String): PostConnection!
}
# --- Reviews Subgraph ---
type Review @key(fields: "id") {
id: ID!
rating: Int!
comment: String
author: User!
post: Post!
}
type Post @key(fields: "id") {
id: ID!
reviews: [Review!]! # Reviewsサービスが追加
averageRating: Float
}
Router設定(せってい)(Apollo Router)
# router.yaml
supergraph:
listen: 0.0.0.0:4000
subgraphs:
users:
routing_url: http://users-service:4001/graphql
posts:
routing_url: http://posts-service:4002/graphql
reviews:
routing_url: http://reviews-service:4003/graphql
headers:
all:
request:
- propagate:
named: authorization
SubgraphのReference Resolver
// Posts Subgraph
const resolvers = {
User: {
// FederationがUserをresolveする際に呼び出される
__resolveReference: async (user, { loaders }) => {
// user.idのみが渡される
return loaders.postsByUserLoader.load(user.id);
},
posts: async (user, _, { dataSources }) => {
return dataSources.postAPI.getByAuthor(user.id);
},
postCount: async (user, _, { dataSources }) => {
return dataSources.postAPI.countByAuthor(user.id);
},
},
};
7. Subscription(リアルタイム機能(きのう))
サーバーセットアップ
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const httpServer = createServer(app);
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
useServer(
{
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authorization;
const user = await authenticateUser(token);
return { currentUser: user, pubsub };
},
},
wsServer
);
// Resolver
const resolvers = {
Subscription: {
commentAdded: {
subscribe: (_, { postId }) => {
return pubsub.asyncIterator(`COMMENT_ADDED_${postId}`);
},
},
postCreated: {
subscribe: () => pubsub.asyncIterator('POST_CREATED'),
},
},
Mutation: {
createComment: async (_, { input }, { pubsub, currentUser }) => {
const comment = await db.comments.create({
data: { ...input, authorId: currentUser.id },
});
// 購読者に通知
pubsub.publish(`COMMENT_ADDED_${input.postId}`, {
commentAdded: comment,
});
return comment;
},
},
};
クライアントSubscription
import { useSubscription, gql } from '@apollo/client';
const COMMENT_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
body
author {
name
}
createdAt
}
}
`;
function CommentFeed({ postId }) {
const { data, loading } = useSubscription(COMMENT_SUBSCRIPTION, {
variables: { postId },
});
// 新しいコメントが来るたびにdataが自動更新される
if (data?.commentAdded) {
return <NewComment comment={data.commentAdded} />;
}
return null;
}
8. 認証(にんしょう)(Authentication)と認可(にんか)(Authorization)
Directiveベースの認可(にんか)
# カスタムdirective定義
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT
enum Role {
ADMIN
EDITOR
USER
GUEST
}
type Query {
publicPosts: [Post!]!
me: User! @auth
adminDashboard: Dashboard! @auth(requires: ADMIN)
userManagement: [User!]! @auth(requires: ADMIN)
}
type Mutation {
createPost(input: CreatePostInput!): Post! @auth
deletePost(id: ID!): Boolean! @auth(requires: ADMIN)
updateProfile(input: UpdateProfileInput!): User! @auth
}
Directive実装(じっそう)
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const requiredRole = authDirective.requires || 'USER';
const originalResolve = fieldConfig.resolve || defaultFieldResolver;
fieldConfig.resolve = async (source, args, context, info) => {
const { currentUser } = context;
if (!currentUser) {
throw new AuthenticationError('Not authenticated');
}
if (!hasRole(currentUser, requiredRole)) {
throw new ForbiddenError('Insufficient permissions');
}
return originalResolve(source, args, context, info);
};
}
return fieldConfig;
},
});
}
9. パフォーマンス最適化(さいてきか)
Query Complexity制限(せいげん)
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
schema,
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
formatErrorMessage: (cost) =>
`Query too complex: cost ${cost} exceeds maximum 1000`,
}),
],
});
Depth Limiting
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(7)],
});
Persisted Queries (APQ)
// サーバー
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
const server = new ApolloServer({
schema,
plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 60 })],
persistedQueries: {
cache: new KeyValueCache(), // Redisなど
},
});
// クライアント
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const link = createPersistedQueryLink({ sha256 });
const client = new ApolloClient({
link: link.concat(httpLink),
cache: new InMemoryCache(),
});
キャッシュ制御(せいぎょ)
type Query {
posts: [Post!]! @cacheControl(maxAge: 300)
user(id: ID!): User @cacheControl(maxAge: 60)
me: User @cacheControl(maxAge: 0, scope: PRIVATE)
}
type Post @cacheControl(maxAge: 600) {
id: ID!
title: String!
author: User! @cacheControl(inheritMaxAge: true)
viewCount: Int! @cacheControl(maxAge: 30)
}
10. キャッシュ戦略(せんりゃく)
サーバーサイドキャッシュ
import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';
import KeyvRedis from '@keyv/redis';
const server = new ApolloServer({
schema,
cache: new KeyvAdapter(new Keyv({ store: new KeyvRedis('redis://localhost:6379') })),
});
// DataSourceレベルのキャッシュ
class CachedUserAPI {
private cache: RedisClient;
private ttl = 300; // 5分
async getUser(id: string): Promise<User> {
const cacheKey = `user:${id}`;
const cached = await this.cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await db.users.findUnique({ where: { id } });
await this.cache.set(cacheKey, JSON.stringify(user), 'EX', this.ttl);
return user;
}
}
クライアントサイドキャッシュ(Apollo Cache)
const cache = new InMemoryCache({
typePolicies: {
User: {
keyFields: ['id'],
fields: {
fullName: {
read(_, { readField }) {
const first = readField('firstName');
const last = readField('lastName');
return `${first} ${last}`;
},
},
},
},
Post: {
keyFields: ['id'],
},
Query: {
fields: {
post(_, { args, toReference }) {
return toReference({ __typename: 'Post', id: args?.id });
},
},
},
},
});
11. テスト
Resolver単体(たんたい)テスト
import { describe, it, expect, vi } from 'vitest';
describe('User Resolvers', () => {
it('should return user by id', async () => {
const mockUser = { id: '1', name: 'Test User', email: 'test@example.com' };
const context = {
dataSources: {
userAPI: { getUser: vi.fn().mockResolvedValue(mockUser) },
},
};
const result = await resolvers.Query.user(null, { id: '1' }, context, null);
expect(result).toEqual(mockUser);
expect(context.dataSources.userAPI.getUser).toHaveBeenCalledWith('1');
});
it('should throw if not admin for createUser', async () => {
const context = {
currentUser: { role: 'USER' },
dataSources: { userAPI: { createUser: vi.fn() } },
};
await expect(
resolvers.Mutation.createUser(null, { input: {} }, context, null)
).rejects.toThrow('Admin access required');
});
});
統合(とうごう)テスト
import { ApolloServer } from '@apollo/server';
describe('GraphQL Integration', () => {
let server: ApolloServer;
beforeAll(async () => {
server = new ApolloServer({ typeDefs, resolvers });
});
it('should execute GetUser query', async () => {
const response = await server.executeOperation({
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`,
variables: { id: '1' },
});
expect(response.body.kind).toBe('single');
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.user).toBeDefined();
});
});
12. GraphQLを使(つか)うべきでないとき
すべての状況(じょうきょう)にGraphQLが適切(てきせつ)なわけではありません。
RESTが適(てき)している場合(ばあい):
- シンプルなCRUD API(リソース構造(こうぞう)が明確(めいかく)な場合(ばあい))
- ファイルアップロード/ダウンロードが主機能(しゅきのう)のサービス
- HTTPキャッシュが重要(じゅうよう)な公開(こうかい)API
- チーム規模(きぼ)が小(ちい)さく学習(がくしゅう)コストを負担(ふたん)できない場合(ばあい)
- サーバー間(かん)内部(ないぶ)通信(つうしん)(gRPCがより適切(てきせつ))
GraphQLが輝(かがや)く場合(ばあい):
- 多様(たよう)なクライアント(Web、モバイル、TV)が同(おな)じAPIを使用(しよう)
- フロントエンドチームが高速(こうそく)にイテレーションする必要(ひつよう)がある場合(ばあい)
- データ関係(かんけい)が複雑(ふくざつ)なドメイン(SNS、ECサイト)
- マイクロサービスを統合(とうごう)APIとして提供(ていきょう)する必要(ひつよう)がある場合(ばあい)
- リアルタイム機能(きのう)が必要(ひつよう)なアプリケーション
13. 実務(じつむ)面接(めんせつ)質問(しつもん)15選(せん)
基礎(きそ)(1-5)
Q1. GraphQLとRESTの核心的(かくしんてき)な違(ちが)いを説明(せつめい)してください。
GraphQLはクライアントが必要(ひつよう)なデータの構造(こうぞう)を直接(ちょくせつ)指定(してい)するクエリ言語(げんご)です。RESTがサーバー定義(ていぎ)のエンドポイントから固定(こてい)レスポンスを返(かえ)すのに対(たい)し、GraphQLは単一(たんいつ)エンドポイントからクライアントが希望(きぼう)するフィールドのみを選択的(せんたくてき)にリクエストします。
Q2. Over-fetchingとUnder-fetchingを説明(せつめい)してください。
Over-fetchingはAPIがクライアントに必要(ひつよう)以上(いじょう)のデータを返(かえ)すことで、Under-fetchingは必要(ひつよう)なデータを得(え)るために複数回(ふくすうかい)のAPI呼(よ)び出(だ)しが必要(ひつよう)なことです。
Q3. SDL(Schema Definition Language)の主要(しゅよう)な型(かた)を説明(せつめい)してください。
Object型(かた)(カスタムフィールド定義(ていぎ))、Scalar型(かた)(String, Int, Float, Boolean, ID)、Enum(列挙型(れっきょがた))、Input(入力用(にゅうりょくよう)オブジェクト)、Interface(共通(きょうつう)フィールド定義(ていぎ))、Union(複数型(ふくすうがた)のいずれか)があります。
Q4. Query、Mutation、Subscriptionの違(ちが)いを説明(せつめい)してください。
Queryはデータ取得(しゅとく)(GETに類似(るいじ))、Mutationはデータ変更(へんこう)(POST/PUT/DELETEに類似(るいじ))、SubscriptionはWebSocketを通(つう)じたリアルタイムイベントストリームです。
Q5. Non-null(!)とリスト型(かた)の組(く)み合(あ)わせを説明(せつめい)してください。
[String]はnull許容(きょよう)文字列(もじれつ)のnull許容(きょよう)配列(はいれつ)、[String!]はnull不可(ふか)文字列(もじれつ)のnull許容(きょよう)配列(はいれつ)、[String!]!はnull不可(ふか)文字列(もじれつ)のnull不可(ふか)配列(はいれつ)です。
中級(ちゅうきゅう)(6-10)
Q6. N+1問題(もんだい)とは何(なに)で、どう解決(かいけつ)しますか?
親(おや)リストを取得(しゅとく)した後(あと)、各(かく)項目(こうもく)のリレーションデータを個別(こべつ)クエリで取得(しゅとく)する問題(もんだい)です。DataLoaderで同(おな)じティック内(ない)のリクエストをまとめて1回(かい)のバッチクエリで実行(じっこう)します。
Q7. Apollo Federationの核心(かくしん)コンセプトを説明(せつめい)してください。
複数(ふくすう)のSubgraphをRouterが一(ひと)つのSupergraphに統合(とうごう)します。@keyでエンティティを識別(しきべつ)し、__resolveReferenceで他(た)のサービスのエンティティをresolveします。
Q8. GraphQLで認証(にんしょう)と認可(にんか)をどう実装(じっそう)しますか?
認証(にんしょう)はcontextでトークンを検証(けんしょう)してcurrentUserを設定(せってい)します。認可(にんか)はカスタムdirective(@auth)やresolver内(ない)でロールベースの検査(けんさ)を行(おこな)います。
Q9. Persisted Queriesの利点(りてん)は何(なに)ですか?
クエリ文字列(もじれつ)の代(か)わりにハッシュ値(ち)を送信(そうしん)してネットワークペイロードを削減(さくげん)し、サーバーで許可(きょか)されたクエリのみ実行(じっこう)してセキュリティを強化(きょうか)します。
Q10. CursorベースとOffsetベースのPaginationの違(ちが)いは?
Offsetはページ番号(ばんごう)ベースで、挿入(そうにゅう)/削除(さくじょ)時(じ)にデータの重複(じゅうふく)/欠落(けつらく)が発生(はっせい)します。Cursorは特定(とくてい)位置(いち)(opaque cursor)ベースで、リアルタイムデータでも安定(あんてい)しています。
上級(じょうきゅう)(11-15)
Q11. Query ComplexityとDepth Limitを説明(せつめい)してください。
Query Complexityは各(かく)フィールドにコストを付与(ふよ)し、総(そう)コストが上限(じょうげん)を超(こ)えると拒否(きょひ)します。Depth Limitはネスト深度(しんど)を制限(せいげん)して悪意(あくい)のある再帰(さいき)クエリを防止(ぼうし)します。
Q12. FederationにおけるEntityとReference Resolverの動作(どうさ)を説明(せつめい)してください。
Entityは@keyで識別(しきべつ)される型(かた)です。Routerが他(た)のsubgraphのデータが必要(ひつよう)な場合(ばあい)、__resolveReferenceを呼(よ)び出(だ)してkeyフィールドだけで全(ぜん)エンティティをresolveします。
Q13. GraphQLのキャッシュがRESTより難(むずか)しい理由(りゆう)は?
RESTはURLベースのHTTPキャッシュを直接(ちょくせつ)活用(かつよう)できますが、GraphQLは単一(たんいつ)エンドポイントにPOSTリクエストを使用(しよう)するためHTTPキャッシュが使(つか)えません。APQ、CDNキャッシュキー設定(せってい)、正規化(せいきか)されたクライアントキャッシュなど別途(べっと)の戦略(せんりゃく)が必要(ひつよう)です。
Q14. Subscriptionのプロダクションスケーリングはどうしますか?
インメモリPubSubの代(か)わりにRedis PubSubやKafkaを使用(しよう)します。複数(ふくすう)のサーバーインスタンス間(かん)でメッセージを共有(きょうゆう)し、WebSocket接続(せつぞく)のロードバランシングにはsticky sessionや専用(せんよう)WebSocketゲートウェイを構成(こうせい)します。
Q15. スキーマ進化(しんか)(Evolution)戦略(せんりゃく)を説明(せつめい)してください。
@deprecated directiveでフィールドの廃止(はいし)を予告(よこく)し、新(あたら)しいフィールドを追加(ついか)します。フィールド削除前(さくじょまえ)にクライアント使用量(しようりょう)をモニタリングし、スキーマレジストリで変更(へんこう)の互換性(ごかんせい)を検証(けんしょう)します。RESTのURLバージョン管理(かんり)より柔軟(じゅうなん)です。
14. 実践(じっせん)クイズ5問(もん)
Q1. GraphQL Queryで「必ずnullでない文字列の配列」を表す型はどれですか?
正解(せいかい):[String!]!
[String!]! — 配列(はいれつ)自体(じたい)もnullではなく(!)、配列(はいれつ)内(ない)の各(かく)要素(ようそ)もnullではありません(String!)。[String]は配列(はいれつ)もnull可能(かのう)、要素(ようそ)もnull可能(かのう)です。
Q2. DataLoaderの核心的な動作原理を説明してください。
正解(せいかい):同(おな)じイベントループティック内(ない)の.load()呼(よ)び出(だ)しをまとめてバッチ関数(かんすう)を1回(かい)実行(じっこう)します。
DataLoaderはリクエストをキューに蓄(たくわ)え、現在(げんざい)のティックが終(お)わるとすべてのIDを一(ひと)つのバッチ関数(かんすう)に渡(わた)します。これによりN+1クエリを1+1クエリに削減(さくげん)します。
Q3. Apollo Federationにおける@key directiveの役割は?
正解(せいかい):エンティティ(Entity)を一意(いちい)に識別(しきべつ)するフィールドを指定(してい)します。
@key(fields: "id")はその型(かた)がFederationのエンティティであることを宣言(せんげん)し、Routerが他(た)のsubgraphからこのエンティティを参照(さんしょう)する際(さい)にidフィールドを使(つか)ってresolveできるようにします。
Q4. GraphQL Subscriptionが内部的に使用するプロトコルは?
正解(せいかい):WebSocket
SubscriptionはHTTPリクエスト-レスポンスではなく、WebSocketを通(つう)じた双方向(そうほうこう)接続(せつぞく)を使用(しよう)します。graphql-wsプロトコルでサーバーからクライアントにリアルタイムイベントをpushします。
Q5. Persisted Queriesのセキュリティ上の利点を説明してください。
正解(せいかい):サーバーに登録(とうろく)されたクエリのみ実行(じっこう)し、任意(にんい)のクエリ(悪意(あくい)のあるクエリ)の実行(じっこう)を防止(ぼうし)します。
APQ(Automatic Persisted Queries)はクエリのSHA256ハッシュを送信(そうしん)します。サーバーはハッシュとマッピングされたクエリのみ実行(じっこう)するため、攻撃者(こうげきしゃ)が任意(にんい)の複雑(ふくざつ)なクエリを送(おく)ってサーバーリソースを消費(しょうひ)することを防(ふせ)ぎます。
参考(さんこう)資料(しりょう)(References)
- GraphQL公式ドキュメント
- Apollo GraphQLドキュメント
- Apollo Federationガイド
- GraphQL Spec (2021)
- DataLoader GitHub
- graphql-wsプロトコル
- How to GraphQL - チュートリアル
- Relay公式ドキュメント
- GraphQL Best Practices
- Production-Ready GraphQL (Marc-Andre Giroux)
- The Guild - GraphQL Tools
- GraphQL Security (OWASP)
- Apollo Client Reactガイド
- Netflix: GraphQL at Scale
- Shopify: GraphQL Design Tutorial
- GitHub GraphQL API