GraphQL의 스키마 퍼스트 설계, 타입 시스템, N+1 문제 해결, AI 서비스 데이터 모델링을 Apollo Server 실습과 함께 학습합니다.
GraphQL은 "클라이언트가 필요한 데이터를 정확히 요청한다"는 철학에 기반합니다. REST에서 서버가 응답 형태를 결정하는 것과 달리, GraphQL에서는 클라이언트가 응답의 구조를 지정합니다.
REST에서 AI 모델의 상세 정보와 최근 사용 이력을 가져오려면 여러 번의 요청이 필요합니다.
# 1. 모델 정보 조회
GET /api/v1/models/claude-4
# 응답: 모델 전체 정보 (불필요한 필드 포함)
# 2. 모델의 가격 정보
GET /api/v1/models/claude-4/pricing
# 응답: 가격 전체 정보
# 3. 최근 사용 이력
GET /api/v1/models/claude-4/usage?limit=5
# 응답: 사용 이력 목록
# 총 3번의 HTTP 요청, 불필요한 데이터 포함GraphQL에서는 단일 요청으로 필요한 데이터만 정확히 가져옵니다.
query GetModelDetails {
model(id: "claude-4") {
name
provider
contextWindow
pricing {
inputPerMillion
outputPerMillion
currency
}
recentUsage(limit: 5) {
date
totalTokens
cost
}
}
}GraphQL의 스키마는 API의 계약서입니다. SDL(Schema Definition Language)로 타입, 쿼리, 뮤테이션을 정의합니다.
# 스칼라 타입
scalar DateTime
scalar JSON
# 열거형
enum ModelProvider {
OPENAI
ANTHROPIC
GOOGLE
META
}
enum ModelCategory {
CHAT
EMBEDDING
IMAGE
AUDIO
CODE
}
enum FinishReason {
STOP
LENGTH
TOOL_CALLS
CONTENT_FILTER
}
enum RunStatus {
PENDING
RUNNING
COMPLETED
FAILED
CANCELLED
}
# 타입 정의
type Model {
id: ID!
name: String!
provider: ModelProvider!
category: ModelCategory!
contextWindow: Int!
maxOutputTokens: Int!
pricing: ModelPricing!
capabilities: [String!]!
deprecated: Boolean!
deprecationDate: DateTime
createdAt: DateTime!
}
type ModelPricing {
inputPerMillion: Float!
outputPerMillion: Float!
currency: String!
}
type Completion {
id: ID!
model: Model!
choices: [Choice!]!
usage: TokenUsage!
finishReason: FinishReason!
latencyMs: Int!
createdAt: DateTime!
}
type Choice {
index: Int!
message: Message!
finishReason: FinishReason!
}
type Message {
role: String!
content: String!
toolCalls: [ToolCall!]
}
type ToolCall {
id: String!
name: String!
arguments: JSON!
}
type TokenUsage {
promptTokens: Int!
completionTokens: Int!
totalTokens: Int!
estimatedCost: Float!
}
type UsageSummary {
date: DateTime!
model: String!
totalRequests: Int!
totalTokens: Int!
totalCost: Float!
averageLatencyMs: Float!
}
type Run {
id: ID!
status: RunStatus!
model: Model!
input: JSON!
output: JSON
usage: TokenUsage
error: RunError
createdAt: DateTime!
completedAt: DateTime
}
type RunError {
type: String!
message: String!
code: String
}
# 페이지네이션 (Relay 커넥션 스펙)
type ModelConnection {
edges: [ModelEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ModelEdge {
node: Model!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type CompletionConnection {
edges: [CompletionEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type CompletionEdge {
node: Completion!
cursor: String!
}
# 입력 타입
input CompletionInput {
model: String!
messages: [MessageInput!]!
temperature: Float = 1.0
maxTokens: Int
tools: [ToolInput!]
}
input MessageInput {
role: String!
content: String!
}
input ToolInput {
name: String!
description: String!
parameters: JSON!
}
input UsageFilterInput {
startDate: DateTime!
endDate: DateTime!
models: [String!]
providers: [ModelProvider!]
}
# 쿼리
type Query {
# 모델 조회
model(id: ID!): Model
models(
first: Int
after: String
provider: ModelProvider
category: ModelCategory
): ModelConnection!
# 실행 이력
completion(id: ID!): Completion
completions(
first: Int
after: String
model: String
): CompletionConnection!
# 비용 분석
usageSummary(filter: UsageFilterInput!): [UsageSummary!]!
# 실행 상태
run(id: ID!): Run
}
# 뮤테이션
type Mutation {
# 텍스트 완성 요청
createCompletion(input: CompletionInput!): Completion!
# 배치 작업
createBatchRun(inputs: [CompletionInput!]!): [Run!]!
cancelRun(id: ID!): Run!
}
# 구독 (실시간 업데이트)
type Subscription {
# 실행 상태 변경 감지
runStatusChanged(runId: ID!): Run!
# 실시간 스트리밍 완성
completionStream(input: CompletionInput!): StreamChunk!
}
type StreamChunk {
delta: String!
finishReason: FinishReason
usage: TokenUsage
}GraphQL 스키마 설계에서 Relay 커넥션 스펙을 따르면 커서 기반 페이지네이션이 자연스럽게 구현됩니다. edges, node, cursor, pageInfo 패턴은 GraphQL 생태계의 사실상 표준이므로 클라이언트 라이브러리의 자동 페이지네이션 기능을 활용할 수 있습니다.
리졸버(Resolver)는 스키마의 각 필드를 실제 데이터로 해석하는 함수입니다.
import { GraphQLResolveInfo } from "graphql";
import { DataSources } from "../datasources";
interface Context {
dataSources: DataSources;
userId: string;
}
export const modelResolvers = {
Query: {
model: async (
_: unknown,
args: { id: string },
context: Context
) => {
return context.dataSources.modelAPI.getModel(args.id);
},
models: async (
_: unknown,
args: {
first?: number;
after?: string;
provider?: string;
category?: string;
},
context: Context
) => {
const limit = args.first ?? 20;
const models = await context.dataSources.modelAPI.listModels({
limit: limit + 1, // hasNextPage 판별용
cursor: args.after,
provider: args.provider,
category: args.category,
});
const hasNextPage = models.length > limit;
const edges = models.slice(0, limit).map((model) => ({
node: model,
cursor: encodeCursor(model.id),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!args.after,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
totalCount: await context.dataSources.modelAPI.countModels({
provider: args.provider,
category: args.category,
}),
};
},
usageSummary: async (
_: unknown,
args: { filter: UsageFilterInput },
context: Context
) => {
return context.dataSources.usageAPI.getSummary(
context.userId,
args.filter
);
},
},
Mutation: {
createCompletion: async (
_: unknown,
args: { input: CompletionInput },
context: Context
) => {
// 비용 한도 확인
const withinBudget = await context.dataSources.usageAPI
.checkBudget(context.userId, args.input.model);
if (!withinBudget) {
throw new GraphQLError("월간 비용 한도를 초과했습니다", {
extensions: { code: "BUDGET_EXCEEDED" },
});
}
return context.dataSources.inferenceAPI.complete(args.input);
},
},
// 필드 리졸버: Model 타입의 pricing 필드
Model: {
pricing: async (model: Model, _: unknown, context: Context) => {
return context.dataSources.pricingAPI.getModelPricing(model.id);
},
},
};GraphQL에서 가장 흔한 성능 문제는 N+1 문제입니다. 목록 조회 시 각 항목의 연관 데이터를 개별적으로 조회하면 N+1번의 데이터베이스 쿼리가 발생합니다.
# 이 쿼리를 실행하면...
query {
completions(first: 20) {
edges {
node {
id
model { # 각 completion마다 model을 개별 조회
name # 1 (목록) + 20 (개별 모델) = 21번 쿼리
provider
}
usage {
totalTokens
}
}
}
}
}DataLoader는 동일 이벤트 루프 틱 내의 개별 요청을 모아서 단일 배치 요청으로 변환합니다.
import DataLoader from "dataloader";
// DataLoader 생성
function createModelLoader(modelAPI: ModelAPI) {
return new DataLoader<string, Model>(
async (modelIds: readonly string[]) => {
// 20개의 개별 요청이 1번의 배치 쿼리로
const models = await modelAPI.getModelsByIds([...modelIds]);
// DataLoader는 요청 순서대로 결과를 반환해야 함
const modelMap = new Map(models.map((m) => [m.id, m]));
return modelIds.map(
(id) => modelMap.get(id) ?? new Error(`Model ${id} not found`)
);
},
{
maxBatchSize: 100,
cache: true, // 동일 요청 내 캐싱
}
);
}
// 리졸버에서 DataLoader 사용
const resolvers = {
Completion: {
model: async (completion: Completion, _: unknown, context: Context) => {
// 개별 조회 대신 DataLoader를 통해 배치 처리
return context.loaders.modelLoader.load(completion.modelId);
},
},
};
// 컨텍스트 팩토리 (요청마다 새 DataLoader 생성)
function createContext(req: Request): Context {
return {
userId: req.userId,
dataSources: createDataSources(),
loaders: {
modelLoader: createModelLoader(modelAPI),
pricingLoader: createPricingLoader(pricingAPI),
},
};
}DataLoader는 반드시 요청마다(per-request) 새로 생성해야 합니다. 요청 간에 DataLoader를 공유하면 캐시된 데이터가 다른 사용자에게 노출될 수 있으며, 권한 검증을 우회하는 보안 취약점이 됩니다.
반복되는 필드 선택을 재사용하는 방법입니다.
# Fragment 정의
fragment ModelBasicInfo on Model {
id
name
provider
category
contextWindow
}
fragment UsageInfo on TokenUsage {
promptTokens
completionTokens
totalTokens
estimatedCost
}
# Fragment 사용
query CompareModels {
modelA: model(id: "claude-4") {
...ModelBasicInfo
pricing {
inputPerMillion
outputPerMillion
}
}
modelB: model(id: "gpt-4o") {
...ModelBasicInfo
pricing {
inputPerMillion
outputPerMillion
}
}
}조건부 필드 포함을 제어합니다.
query GetCompletion(
$id: ID!
$includeUsage: Boolean!
$includeCost: Boolean = false
) {
completion(id: $id) {
id
choices {
message {
content
}
}
usage @include(if: $includeUsage) {
promptTokens
completionTokens
totalTokens
estimatedCost @include(if: $includeCost)
}
latencyMs
}
}import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { readFileSync } from "fs";
const typeDefs = readFileSync("./schema.graphql", "utf-8");
const server = new ApolloServer({
typeDefs,
resolvers: [modelResolvers, completionResolvers, usageResolvers],
plugins: [
// 쿼리 복잡도 제한
createComplexityLimitPlugin({ maximumComplexity: 1000 }),
// 깊이 제한
createDepthLimitPlugin({ maxDepth: 10 }),
// 응답 캐싱
responseCachePlugin(),
],
introspection: process.env.NODE_ENV !== "production",
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
const userId = await authenticate(req.headers.authorization);
return createContext(userId);
},
});
console.log(`GraphQL 서버 실행: ${url}`);프로덕션 환경에서는 반드시 쿼리 복잡도 제한(complexity limit)과 깊이 제한(depth limit)을 설정해야 합니다. 악의적으로 깊게 중첩된 쿼리는 서버 리소스를 고갈시킬 수 있으며, 이는 GraphQL 특유의 보안 취약점입니다.
이 장에서는 GraphQL의 핵심 개념인 스키마 퍼스트 설계, 타입 시스템, 리졸버 패턴을 AI 서비스 데이터 모델링에 적용했습니다. N+1 문제를 DataLoader로 해결하는 방법과 Fragment, Directive를 활용한 효율적 쿼리 작성법을 살펴보았습니다.
GraphQL은 복잡한 데이터 관계를 가진 AI 서비스에서 특히 강력합니다. 모델 정보, 실행 이력, 비용 분석 등 다양한 데이터를 클라이언트가 필요한 만큼만 정확히 가져올 수 있어, 프론트엔드 개발자의 생산성을 크게 향상시킵니다.
5장에서는 REST, gRPC, GraphQL 위에 구축되는 AI 서비스 특화 API 설계 패턴을 다룹니다. 비동기 작업 처리, 멀티모달 입력, Function Calling, 배치 API, 구조화된 출력 등 AI 서비스만의 고유한 인터페이스 패턴을 체계적으로 살펴봅니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
비동기 작업 패턴, 멀티모달 입력 처리, Function Calling 인터페이스, 배치 API, 구조화된 출력 등 AI 서비스 고유의 API 설계 패턴을 학습합니다.
HTTP/2와 Protocol Buffers 기반의 gRPC를 활용한 고성능 마이크로서비스 통신을 학습합니다. 4가지 스트리밍 모드와 AI 추론 서비스 구현을 실습합니다.
SSE 기반 토큰 스트리밍 프로토콜, OpenAI 호환 스트리밍 형식, 에러 처리, 클라이언트 취소, 프론트엔드 통합 패턴을 학습합니다.