본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 4장: GraphQL — 유연한 데이터 쿼리
2026년 2월 10일·아키텍처·

4장: GraphQL — 유연한 데이터 쿼리

GraphQL의 스키마 퍼스트 설계, 타입 시스템, N+1 문제 해결, AI 서비스 데이터 모델링을 Apollo Server 실습과 함께 학습합니다.

12분826자8개 섹션
api-designgraphqlarchitecture
공유
api-design4 / 11
1234567891011
이전3장: gRPC — 고성능 서비스 간 통신다음5장: AI 서비스 API 설계 패턴

학습 목표

  • GraphQL의 스키마 퍼스트 설계 철학과 타입 시스템을 이해합니다
  • 쿼리, 뮤테이션, 구독의 올바른 사용법을 학습합니다
  • N+1 문제를 DataLoader로 해결하는 방법을 익힙니다
  • AI 서비스의 데이터를 GraphQL로 모델링합니다

GraphQL의 핵심 철학

GraphQL은 "클라이언트가 필요한 데이터를 정확히 요청한다"는 철학에 기반합니다. REST에서 서버가 응답 형태를 결정하는 것과 달리, GraphQL에서는 클라이언트가 응답의 구조를 지정합니다.

REST vs GraphQL: 데이터 요청 비교

REST에서 AI 모델의 상세 정보와 최근 사용 이력을 가져오려면 여러 번의 요청이 필요합니다.

rest-multiple-requests.sh
bash
# 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에서는 단일 요청으로 필요한 데이터만 정확히 가져옵니다.

graphql-single-request.graphql
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)로 타입, 쿼리, 뮤테이션을 정의합니다.

AI 서비스 스키마

schema.graphql
graphql
# 스칼라 타입
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
}
Tip

GraphQL 스키마 설계에서 Relay 커넥션 스펙을 따르면 커서 기반 페이지네이션이 자연스럽게 구현됩니다. edges, node, cursor, pageInfo 패턴은 GraphQL 생태계의 사실상 표준이므로 클라이언트 라이브러리의 자동 페이지네이션 기능을 활용할 수 있습니다.


리졸버 구현

리졸버(Resolver)는 스키마의 각 필드를 실제 데이터로 해석하는 함수입니다.

resolvers/model.ts
typescript
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);
    },
  },
};

N+1 문제와 DataLoader

GraphQL에서 가장 흔한 성능 문제는 N+1 문제입니다. 목록 조회 시 각 항목의 연관 데이터를 개별적으로 조회하면 N+1번의 데이터베이스 쿼리가 발생합니다.

문제 상황

n-plus-1-problem.graphql
graphql
# 이 쿼리를 실행하면...
query {
  completions(first: 20) {
    edges {
      node {
        id
        model {          # 각 completion마다 model을 개별 조회
          name            # 1 (목록) + 20 (개별 모델) = 21번 쿼리
          provider
        }
        usage {
          totalTokens
        }
      }
    }
  }
}

DataLoader로 해결

DataLoader는 동일 이벤트 루프 틱 내의 개별 요청을 모아서 단일 배치 요청으로 변환합니다.

dataloaders/model-loader.ts
typescript
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),
    },
  };
}
Warning

DataLoader는 반드시 요청마다(per-request) 새로 생성해야 합니다. 요청 간에 DataLoader를 공유하면 캐시된 데이터가 다른 사용자에게 노출될 수 있으며, 권한 검증을 우회하는 보안 취약점이 됩니다.


Fragment와 Directive

Fragment

반복되는 필드 선택을 재사용하는 방법입니다.

fragment-example.graphql
graphql
# 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
    }
  }
}

Directive

조건부 필드 포함을 제어합니다.

directive-example.graphql
graphql
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
  }
}

Apollo Server 구현

server.ts
typescript
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}`);
Info

프로덕션 환경에서는 반드시 쿼리 복잡도 제한(complexity limit)과 깊이 제한(depth limit)을 설정해야 합니다. 악의적으로 깊게 중첩된 쿼리는 서버 리소스를 고갈시킬 수 있으며, 이는 GraphQL 특유의 보안 취약점입니다.


정리

이 장에서는 GraphQL의 핵심 개념인 스키마 퍼스트 설계, 타입 시스템, 리졸버 패턴을 AI 서비스 데이터 모델링에 적용했습니다. N+1 문제를 DataLoader로 해결하는 방법과 Fragment, Directive를 활용한 효율적 쿼리 작성법을 살펴보았습니다.

GraphQL은 복잡한 데이터 관계를 가진 AI 서비스에서 특히 강력합니다. 모델 정보, 실행 이력, 비용 분석 등 다양한 데이터를 클라이언트가 필요한 만큼만 정확히 가져올 수 있어, 프론트엔드 개발자의 생산성을 크게 향상시킵니다.

다음 장 미리보기

5장에서는 REST, gRPC, GraphQL 위에 구축되는 AI 서비스 특화 API 설계 패턴을 다룹니다. 비동기 작업 처리, 멀티모달 입력, Function Calling, 배치 API, 구조화된 출력 등 AI 서비스만의 고유한 인터페이스 패턴을 체계적으로 살펴봅니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#api-design#graphql#architecture

관련 글

아키텍처

5장: AI 서비스 API 설계 패턴

비동기 작업 패턴, 멀티모달 입력 처리, Function Calling 인터페이스, 배치 API, 구조화된 출력 등 AI 서비스 고유의 API 설계 패턴을 학습합니다.

2026년 2월 12일·17분
아키텍처

3장: gRPC — 고성능 서비스 간 통신

HTTP/2와 Protocol Buffers 기반의 gRPC를 활용한 고성능 마이크로서비스 통신을 학습합니다. 4가지 스트리밍 모드와 AI 추론 서비스 구현을 실습합니다.

2026년 2월 8일·15분
아키텍처

6장: 스트리밍 응답 인터페이스 설계

SSE 기반 토큰 스트리밍 프로토콜, OpenAI 호환 스트리밍 형식, 에러 처리, 클라이언트 취소, 프론트엔드 통합 패턴을 학습합니다.

2026년 2월 14일·15분
이전 글3장: gRPC — 고성능 서비스 간 통신
다음 글5장: AI 서비스 API 설계 패턴

댓글

목차

약 12분 남음
  • 학습 목표
  • GraphQL의 핵심 철학
    • REST vs GraphQL: 데이터 요청 비교
  • 스키마 퍼스트 설계
    • AI 서비스 스키마
  • 리졸버 구현
  • N+1 문제와 DataLoader
    • 문제 상황
    • DataLoader로 해결
  • Fragment와 Directive
    • Fragment
    • Directive
  • Apollo Server 구현
  • 정리
    • 다음 장 미리보기