GraphQL API의 프로덕션 운영 전략을 다룹니다. 스키마 변경 관리, Breaking Change 감지, 스키마 레지스트리, 쿼리 제한, 관측 가능성, 에러 처리, 성능 모니터링을 학습합니다.
GraphQL 스키마는 클라이언트와의 계약(Contract) 입니다. 스키마 변경은 신중하게 관리해야 합니다.
| 변경 유형 | Breaking 여부 | 설명 |
|---|---|---|
| 필드 추가 | 안전 | 기존 클라이언트에 영향 없음 |
| 필드 삭제 | Breaking | 해당 필드를 사용하는 클라이언트가 깨짐 |
| 필드 타입 변경 | Breaking | 클라이언트의 타입 기대가 어긋남 |
| Non-null 추가 (반환) | Breaking | null을 반환하던 필드가 에러 발생 |
| Non-null 제거 (반환) | 안전 | 더 관대해짐 |
| Non-null 추가 (인자) | Breaking | 기존 호출에 필수 인자 누락 |
| 열거형 값 추가 | 주의 | 클라이언트가 새 값을 처리 못할 수 있음 |
| 열거형 값 삭제 | Breaking | 해당 값을 사용하는 로직이 깨짐 |
필드를 즉시 삭제하는 대신 Deprecation을 통해 점진적으로 마이그레이션합니다.
type User {
id: ID!
name: String!
# 1단계: 새 필드 추가
displayName: String!
firstName: String!
lastName: String!
# 2단계: 기존 필드 Deprecated 표시
fullName: String! @deprecated(reason: "Use displayName instead. Removal planned for 2026-Q3.")
}Deprecated 필드를 삭제하기 전에 반드시 해당 필드의 실제 사용량을 확인해야 합니다. Apollo Studio나 서버 로그에서 필드 사용 빈도를 추적하고, 사용량이 0에 도달한 후에만 삭제합니다.
스키마 레지스트리(Schema Registry) 는 스키마 버전을 중앙에서 관리하고, 변경의 안전성을 검증하는 서비스입니다.
# 1. 스키마 변경 검증 (PR에서 실행)
rover subgraph check my-graph@production \
--name users \
--schema ./schema.graphql
# 출력 예시:
# Comparing users schema changes against operation history...
#
# FAIL: Found 2 breaking changes and 1 warning
# - FIELD_REMOVED: User.fullName was removed
# Used by 23 operations in the last 90 days
# - REQUIRED_INPUT_FIELD_ADDED: CreateUserInput.phone is required
# Affects all createUser operations
# 2. 스키마 배포 (merge 후 실행)
rover subgraph publish my-graph@production \
--name users \
--schema ./schema.graphql \
--routing-url https://users.internal:4001/graphqlname: Schema Check
on:
pull_request:
paths:
- 'schema.graphql'
- 'src/schema/**'
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rover
run: |
curl -sSL https://rover.apollo.dev/nix/latest | sh
echo "$HOME/.rover/bin" >> $GITHUB_PATH
- name: Check Schema
env:
APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
run: |
rover subgraph check my-graph@production \
--name ${{ matrix.subgraph }} \
--schema ./subgraphs/${{ matrix.subgraph }}/schema.graphql프로덕션 GraphQL API는 악의적이거나 비효율적인 쿼리로부터 보호되어야 합니다.
4장에서 다룬 복잡도 분석을 프로덕션에 적용합니다.
import { ApolloServer } from '@apollo/server';
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule, simpleEstimator } from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// 깊이 제한
depthLimit(12),
// 복잡도 제한
createComplexityRule({
maximumComplexity: 1500,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
onComplete: (complexity: number) => {
if (complexity > 1000) {
console.warn(`High complexity query: ${complexity}`);
}
},
}),
],
});import { rateLimitDirective } from 'graphql-rate-limit-directive';
const { rateLimitDirectiveTypeDefs, rateLimitDirectiveTransformer } =
rateLimitDirective();
const typeDefs = `
${rateLimitDirectiveTypeDefs}
type Query {
posts: [Post!]! @rateLimit(limit: 100, duration: 60)
search(query: String!): [SearchResult!]! @rateLimit(limit: 30, duration: 60)
}
type Mutation {
createPost(input: CreatePostInput!): Post! @rateLimit(limit: 10, duration: 60)
sendMessage(input: SendMessageInput!): Message! @rateLimit(limit: 60, duration: 60)
}
`;프로덕션에서 임의의 쿼리 실행을 완전히 차단합니다.
import { ApolloServer } from '@apollo/server';
const allowedOperations = new Map<string, string>();
// 빌드 타임에 생성된 쿼리 목록 로드
loadPersistedQueries(allowedOperations);
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation(requestContext) {
const hash =
requestContext.request.extensions?.persistedQuery?.sha256Hash;
if (process.env.NODE_ENV === 'production' && !hash) {
throw new GraphQLError('Only persisted queries are allowed', {
extensions: { code: 'PERSISTED_QUERY_REQUIRED' },
});
}
},
};
},
},
],
});프로덕션 GraphQL API의 건강 상태를 파악하려면 세 가지 축이 필요합니다: 메트릭(Metrics), 트레이싱(Tracing), 로깅(Logging).
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginUsageReporting({
sendVariableValues: { none: true }, // 민감 데이터 제외
sendHeaders: { onlyNames: ['x-request-id'] },
}),
],
});Apollo Studio에서 확인할 수 있는 정보는 다음과 같습니다.
벤더 중립적인 관측 가능성을 위해 OpenTelemetry를 사용합니다.
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}),
instrumentations: [
new HttpInstrumentation(),
new GraphQLInstrumentation({
mergeItems: true,
allowValues: false, // 인자 값 수집 안 함
depth: 5,
}),
],
});
sdk.start();import { Counter, Histogram, register } from 'prom-client';
const queryDuration = new Histogram({
name: 'graphql_query_duration_seconds',
help: 'Duration of GraphQL queries',
labelNames: ['operationName', 'status'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
});
const resolverErrors = new Counter({
name: 'graphql_resolver_errors_total',
help: 'Total number of resolver errors',
labelNames: ['typeName', 'fieldName', 'errorCode'],
});
// Apollo Server 플러그인으로 수집
const metricsPlugin = {
async requestDidStart() {
const start = Date.now();
return {
async willSendResponse(requestContext: GraphQLRequestContext<Context>) {
const duration = (Date.now() - start) / 1000;
const hasErrors = (requestContext.errors?.length ?? 0) > 0;
queryDuration
.labels(
requestContext.operationName ?? 'unknown',
hasErrors ? 'error' : 'success',
)
.observe(duration);
},
async didEncounterErrors(requestContext: GraphQLRequestContext<Context>) {
for (const error of requestContext.errors ?? []) {
resolverErrors
.labels(
error.path?.[0]?.toString() ?? 'unknown',
error.path?.[1]?.toString() ?? 'unknown',
(error.extensions?.['code'] as string) ?? 'UNKNOWN',
)
.inc();
}
},
};
},
};import { GraphQLError } from 'graphql';
// 클라이언트 에러: 사용자에게 상세 메시지 전달
class NotFoundError extends GraphQLError {
constructor(resource: string, id: string) {
super(`${resource} with ID ${id} not found`, {
extensions: {
code: 'NOT_FOUND',
resource,
id,
},
});
}
}
class ValidationError extends GraphQLError {
constructor(field: string, message: string) {
super(message, {
extensions: {
code: 'VALIDATION_ERROR',
field,
},
});
}
}
// 서버 에러: 내부 정보 숨기고 일반 메시지 전달
class InternalError extends GraphQLError {
constructor(originalError: Error) {
super('An internal error occurred', {
extensions: {
code: 'INTERNAL_SERVER_ERROR',
// 프로덕션에서는 originalError를 로그에만 기록
},
});
console.error('Internal error:', originalError);
}
}프로덕션에서는 민감한 정보가 클라이언트에 노출되지 않도록 에러를 포맷팅합니다.
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// 프로덕션: 내부 에러 상세 숨기기
if (process.env.NODE_ENV === 'production') {
// 알려진 에러 코드만 전달
const knownCodes = [
'NOT_FOUND',
'VALIDATION_ERROR',
'UNAUTHENTICATED',
'FORBIDDEN',
'BAD_USER_INPUT',
];
const code = formattedError.extensions?.['code'] as string | undefined;
if (code && knownCodes.includes(code)) {
return formattedError;
}
// 알 수 없는 에러는 일반 메시지로 대체
return {
message: 'An unexpected error occurred',
extensions: { code: 'INTERNAL_SERVER_ERROR' },
};
}
// 개발 환경: 전체 에러 정보 반환
return formattedError;
},
});에러 응답에 스택 트레이스, SQL 쿼리, 내부 서비스 URL 등이 포함되면 보안 취약점이 됩니다. 프로덕션에서는 반드시 에러 포맷터를 통해 민감한 정보를 제거해야 합니다.
프로덕션 GraphQL API에서 모니터링해야 할 핵심 지표를 정리합니다.
| 지표 | 임계값 예시 | 대응 |
|---|---|---|
| 쿼리 응답 시간 p95 | 500ms 이하 | DataLoader, 캐싱 확인 |
| 에러율 | 1% 이하 | 에러 로그 분석 |
| 쿼리 복잡도 p99 | 설정 한계의 70% | 클라이언트 쿼리 최적화 |
| 리졸버 지연 시간 Top-5 | 200ms 이하 | 인덱스, 쿼리 최적화 |
| WebSocket 동시 연결 수 | 서버당 10K 이하 | 수평 확장 |
| 스키마 크기 (필드 수) | 모니터링 | 불필요한 필드 정리 |
이번 장에서는 GraphQL API의 프로덕션 운영에 필요한 전략을 종합적으로 다루었습니다. 안전한 스키마 진화를 위한 Deprecation 패턴과 Breaking Change 감지, 스키마 레지스트리를 통한 중앙 관리, 쿼리 복잡도와 요청 제한을 통한 API 보호, OpenTelemetry 기반 관측 가능성, 그리고 프로덕션에 적합한 에러 처리 전략을 학습했습니다.
운영은 기술의 절반입니다. 아무리 잘 설계된 API도 프로덕션에서 적절한 모니터링과 보호 없이는 안정적으로 서비스할 수 없습니다.
11장에서는 시리즈의 마지막으로 실전 프로젝트를 진행합니다. 지금까지 학습한 모든 개념을 종합하여 Apollo Server + Federation + DataLoader 기반의 GraphQL API를 처음부터 구축하고, Docker로 배포하며, 성능 테스트를 수행합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
시리즈의 모든 개념을 종합하여 Apollo Server, Federation, DataLoader 기반의 프로덕션 수준 GraphQL API를 구축합니다. 스키마 설계부터 Docker 배포, 성능 테스트까지 전 과정을 다룹니다.
GraphQL Code Generator를 활용한 TypeScript 타입 생성, React 훅 자동 생성, Fragment Colocation 패턴을 다룹니다. 코드 퍼스트 스키마 빌더와의 비교, CI 통합 전략도 학습합니다.
GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.