본문으로 건너뛰기
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. 10장: 프로덕션 운영과 모니터링
2026년 2월 24일·웹 개발·

10장: 프로덕션 운영과 모니터링

GraphQL API의 프로덕션 운영 전략을 다룹니다. 스키마 변경 관리, Breaking Change 감지, 스키마 레지스트리, 쿼리 제한, 관측 가능성, 에러 처리, 성능 모니터링을 학습합니다.

13분729자8개 섹션
graphqlapi-designfrontend
공유
graphql-architecture10 / 11
1234567891011
이전9장: 코드 생성과 타입 안전성다음11장: 실전 프로젝트 -- GraphQL API 구축

학습 목표

  • 안전한 스키마 진화 전략과 Breaking Change 감지 방법을 이해한다
  • 스키마 레지스트리의 역할과 워크플로를 파악한다
  • 쿼리 복잡도, 깊이, 요청 제한을 통해 API를 보호한다
  • 분산 트레이싱과 메트릭을 통한 관측 가능성을 구현한다
  • 프로덕션에 적합한 에러 처리 전략을 적용한다

스키마 변경 관리

GraphQL 스키마는 클라이언트와의 계약(Contract) 입니다. 스키마 변경은 신중하게 관리해야 합니다.

Breaking Change의 유형

변경 유형Breaking 여부설명
필드 추가안전기존 클라이언트에 영향 없음
필드 삭제Breaking해당 필드를 사용하는 클라이언트가 깨짐
필드 타입 변경Breaking클라이언트의 타입 기대가 어긋남
Non-null 추가 (반환)Breakingnull을 반환하던 필드가 에러 발생
Non-null 제거 (반환)안전더 관대해짐
Non-null 추가 (인자)Breaking기존 호출에 필수 인자 누락
열거형 값 추가주의클라이언트가 새 값을 처리 못할 수 있음
열거형 값 삭제Breaking해당 값을 사용하는 로직이 깨짐

Deprecation 패턴

필드를 즉시 삭제하는 대신 Deprecation을 통해 점진적으로 마이그레이션합니다.

deprecation.graphql
graphql
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.")
}
Warning

Deprecated 필드를 삭제하기 전에 반드시 해당 필드의 실제 사용량을 확인해야 합니다. Apollo Studio나 서버 로그에서 필드 사용 빈도를 추적하고, 사용량이 0에 도달한 후에만 삭제합니다.


스키마 레지스트리

스키마 레지스트리(Schema Registry) 는 스키마 버전을 중앙에서 관리하고, 변경의 안전성을 검증하는 서비스입니다.

Apollo Studio를 활용한 워크플로

schema-registry.sh
bash
# 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/graphql

CI 통합

.github/workflows/schema-check.yml
yaml
name: 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장에서 다룬 복잡도 분석을 프로덕션에 적용합니다.

production-limits.ts
typescript
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}`);
        }
      },
    }),
  ],
});

요청 제한(Rate Limiting)

rate-limiting.ts
typescript
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)
  }
`;

Persisted Queries 전용 모드

프로덕션에서 임의의 쿼리 실행을 완전히 차단합니다.

persisted-only.ts
typescript
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' },
              });
            }
          },
        };
      },
    },
  ],
});

관측 가능성(Observability)

프로덕션 GraphQL API의 건강 상태를 파악하려면 세 가지 축이 필요합니다: 메트릭(Metrics), 트레이싱(Tracing), 로깅(Logging).

Apollo Studio 트레이싱

apollo-studio.ts
typescript
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에서 확인할 수 있는 정보는 다음과 같습니다.

  • 필드별 사용 빈도와 지연 시간
  • 느린 쿼리 Top-N
  • 에러율 추이
  • 스키마 변경 이력
  • 클라이언트 버전별 사용 패턴

OpenTelemetry 통합

벤더 중립적인 관측 가능성을 위해 OpenTelemetry를 사용합니다.

otel-setup.ts
typescript
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();

커스텀 메트릭

custom-metrics.ts
typescript
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();
        }
      },
    };
  },
};

에러 처리 전략

에러 분류

error-classification.ts
typescript
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);
  }
}

에러 포맷팅

프로덕션에서는 민감한 정보가 클라이언트에 노출되지 않도록 에러를 포맷팅합니다.

error-formatting.ts
typescript
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;
  },
});
Info

에러 응답에 스택 트레이스, SQL 쿼리, 내부 서비스 URL 등이 포함되면 보안 취약점이 됩니다. 프로덕션에서는 반드시 에러 포맷터를 통해 민감한 정보를 제거해야 합니다.


성능 모니터링 체크리스트

프로덕션 GraphQL API에서 모니터링해야 할 핵심 지표를 정리합니다.

지표임계값 예시대응
쿼리 응답 시간 p95500ms 이하DataLoader, 캐싱 확인
에러율1% 이하에러 로그 분석
쿼리 복잡도 p99설정 한계의 70%클라이언트 쿼리 최적화
리졸버 지연 시간 Top-5200ms 이하인덱스, 쿼리 최적화
WebSocket 동시 연결 수서버당 10K 이하수평 확장
스키마 크기 (필드 수)모니터링불필요한 필드 정리

정리

이번 장에서는 GraphQL API의 프로덕션 운영에 필요한 전략을 종합적으로 다루었습니다. 안전한 스키마 진화를 위한 Deprecation 패턴과 Breaking Change 감지, 스키마 레지스트리를 통한 중앙 관리, 쿼리 복잡도와 요청 제한을 통한 API 보호, OpenTelemetry 기반 관측 가능성, 그리고 프로덕션에 적합한 에러 처리 전략을 학습했습니다.

운영은 기술의 절반입니다. 아무리 잘 설계된 API도 프로덕션에서 적절한 모니터링과 보호 없이는 안정적으로 서비스할 수 없습니다.

다음 장 미리보기

11장에서는 시리즈의 마지막으로 실전 프로젝트를 진행합니다. 지금까지 학습한 모든 개념을 종합하여 Apollo Server + Federation + DataLoader 기반의 GraphQL API를 처음부터 구축하고, Docker로 배포하며, 성능 테스트를 수행합니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#graphql#api-design#frontend

관련 글

웹 개발

11장: 실전 프로젝트 -- GraphQL API 구축

시리즈의 모든 개념을 종합하여 Apollo Server, Federation, DataLoader 기반의 프로덕션 수준 GraphQL API를 구축합니다. 스키마 설계부터 Docker 배포, 성능 테스트까지 전 과정을 다룹니다.

2026년 2월 26일·17분
웹 개발

9장: 코드 생성과 타입 안전성

GraphQL Code Generator를 활용한 TypeScript 타입 생성, React 훅 자동 생성, Fragment Colocation 패턴을 다룹니다. 코드 퍼스트 스키마 빌더와의 비교, CI 통합 전략도 학습합니다.

2026년 2월 22일·13분
웹 개발

8장: 캐싱 전략

GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.

2026년 2월 20일·13분
이전 글9장: 코드 생성과 타입 안전성
다음 글11장: 실전 프로젝트 -- GraphQL API 구축

댓글

목차

약 13분 남음
  • 학습 목표
  • 스키마 변경 관리
    • Breaking Change의 유형
    • Deprecation 패턴
  • 스키마 레지스트리
    • Apollo Studio를 활용한 워크플로
    • CI 통합
  • 요청 보호 전략
    • 쿼리 복잡도 제한
    • 요청 제한(Rate Limiting)
    • Persisted Queries 전용 모드
  • 관측 가능성(Observability)
    • Apollo Studio 트레이싱
    • OpenTelemetry 통합
    • 커스텀 메트릭
  • 에러 처리 전략
    • 에러 분류
    • 에러 포맷팅
  • 성능 모니터링 체크리스트
  • 정리
    • 다음 장 미리보기