GraphQL 보안 가이드: 인가·별칭·쿼리 깊이 점검

GraphQL 보안을 REST와 다르게 봐야 하는 이유와 리졸버 단위 인가 누락, 별칭·배치 쿼리를 이용한 rate limit 우회, 중첩 쿼리 부하를 Apollo Server·graphql-yoga 설정 예시와 점검 체크리스트로 정리했다.

보안 점검 의뢰 팀 15분 분량

GraphQL API를 운영할 때 REST와 별도로 막아야 할 것은 크게 세 가지다. 첫째, 인가를 엔드포인트가 아니라 리졸버(필드) 단위로 걸어야 한다. 둘째, HTTP 요청 수 기준 rate limit은 별칭(alias)과 배치 쿼리로 우회되므로 연산 단위로 제한해야 한다. 셋째, 중첩 쿼리 하나로 서버와 DB에 큰 부하를 줄 수 있으므로 깊이·별칭 수·비용 한도를 둬야 한다. introspection 비활성화와 에러 메시지 정리는 그다음 순서다.

이 순서가 중요한 이유는 GraphQL이 /graphql 엔드포인트 하나에 모든 기능을 모으기 때문이다. REST에서는 URL마다 미들웨어, WAF 규칙, rate limit을 따로 걸 수 있었지만, GraphQL에서는 그 경계가 요청 본문 안으로 들어간다. 그래서 REST 시절의 보안 장치를 그대로 두면 "설정은 다 했는데 실제로는 아무것도 막지 않는" 상태가 되기 쉽다.

이 글에서는 실제 점검에서 자주 보이는 빈틈을 취약한 코드와 고친 코드로 나눠 설명하고, Apollo Server와 graphql-yoga 기준 설정 예시와 배포 전 체크리스트를 정리한다.

REST 보안 장치가 GraphQL에서 무력해지는 지점

REST에서 당연하던 장치들이 GraphQL에서는 이렇게 바뀐다.

  • 라우트 단위 인증 미들웨어: /admin/*에만 관리자 검사를 거는 식의 구성이 불가능하다. 모든 요청이 같은 경로로 들어온다.
  • HTTP 요청 수 기반 rate limit: Nginx limit_req 같은 요청 수 제한은 요청 하나에 연산이 여러 개 들어 있어도 1회로 센다. OWASP GraphQL Cheat Sheet도 이 한계를 지적하며 비즈니스 로직 단에서 GraphQL 수준의 제한을 두라고 권고한다.
  • 응답 필드 고정: REST는 응답 DTO가 정해져 있지만 GraphQL은 클라이언트가 필드를 고른다. 연관 객체를 따라가면 의도하지 않은 데이터까지 닿을 수 있다.
  • WAF 패턴 매칭: 요청 본문이 JSON 안의 쿼리 문자열이라 경로·파라미터 기반 규칙이 잘 맞지 않는다.

결국 GraphQL 보안의 무게중심은 게이트웨이가 아니라 스키마와 리졸버 안쪽으로 옮겨진다.

리졸버 단위 인가 누락: 가장 흔하고 가장 위험한 빈틈

점검에서 가장 자주 보는 패턴은 "로그인 여부는 확인하지만 이 객체가 요청자의 것인지는 확인하지 않는" 리졸버다. GraphQL에서는 이것이 그래프를 따라 번진다.

취약한 코드

const resolvers = {
  Query: {
    // 로그인만 확인하고, 주문 소유자는 확인하지 않는다
    order: (_parent, { id }, ctx) => {
      if (!ctx.viewer) throw new GraphQLError('UNAUTHENTICATED');
      return db.order.findById(id);
    },
  },
  Order: {
    buyer: (order) => db.user.findById(order.buyerId),
  },
  User: {
    // 누가 조회하든 그대로 반환
    email: (user) => user.email,
    phone: (user) => user.phone,
    orders: (user) => db.order.findMany({ buyerId: user.id }),
  },
};

order(id)에서 IDOR이 생기는 것도 문제지만, 더 큰 문제는 order → buyer → email, phone, orders로 연결된 경로다. 리뷰 작성자, 댓글 작성자, 판매자처럼 User 타입을 반환하는 필드가 하나라도 공개돼 있으면 그 경로를 통해 다른 사용자의 연락처에 닿을 수 있다. REST라면 응답 DTO에서 빠졌을 필드가 GraphQL에서는 타입 정의에 있다는 이유만으로 노출된다.

고친 코드

// 인가 판단은 리졸버가 아니라 데이터 접근 계층 한 곳에 둔다
const orderService = {
  async getForViewer(viewer, id) {
    const order = await db.order.findById(id);
    if (!order) return null;
    const allowed = order.buyerId === viewer.id || order.sellerId === viewer.id || viewer.isAdmin;
    return allowed ? order : null; // 존재 여부도 드러내지 않는다
  },
};

const canSeePrivate = (viewer, user) => viewer && (viewer.id === user.id || viewer.isAdmin);

const resolvers = {
  Query: {
    order: (_p, { id }, ctx) => orderService.getForViewer(requireViewer(ctx), id),
  },
  User: {
    email: (user, _a, ctx) => (canSeePrivate(ctx.viewer, user) ? user.email : null),
    phone: (user, _a, ctx) => (canSeePrivate(ctx.viewer, user) ? user.phone : null),
    orders: (user, _a, ctx) => {
      if (!canSeePrivate(ctx.viewer, user)) throw new GraphQLError('FORBIDDEN');
      return db.order.findMany({ buyerId: user.id });
    },
  },
};

판단 기준은 이렇다.

  • "이 타입에 도달할 수 있는 모든 경로"를 기준으로 인가한다. Query.user만 막고 Review.author를 열어두면 의미가 없다. 민감 필드 검사는 부모 리졸버가 아니라 그 필드 자체에 둔다.
  • node(id:)·nodes(ids:) 같은 전역 조회 필드를 따로 확인한다. OWASP도 이 root 필드와 edge·node 양쪽 인가를 별도 항목으로 다룬다. 전역 ID로 아무 타입이나 꺼낼 수 있는 경로가 되기 쉽다.
  • 공개용 타입과 본인용 타입을 나누는 것도 방법이다. PublicUser { nickname }와 Me { email phone }로 분리하면 필드 단위 검사 누락의 영향 범위가 줄어든다.
  • mutation 입력 타입도 같이 본다. updateUser(input: UserInput)에 role이 들어 있으면 Mass Assignment 취약점과 같은 문제가 생긴다.

별칭과 배치 쿼리로 rate limit이 우회되는 구조

GraphQL은 같은 필드를 별칭을 붙여 한 연산 안에서 여러 번 호출할 수 있다. 여기에 배열 형태로 연산 여러 개를 한 HTTP 요청에 담는 배치 기능까지 켜져 있으면, "IP당 분당 10회" 같은 제한은 실제로는 수백 번의 시도를 허용하게 된다.

# 가상의 예: HTTP 요청은 1번, 인증번호 검증은 여러 번
mutation {
  a1: verifyOtp(phone: "01000000000", code: "000001") { ok }
  a2: verifyOtp(phone: "01000000000", code: "000002") { ok }
  # ... 수십 개 반복
}

로그인, 인증번호 검증, 쿠폰 코드 확인, 비밀번호 재설정처럼 시도 횟수 자체가 보안 통제인 기능에서 특히 문제가 된다. OWASP는 이를 batching attack으로 분류하고 객체 열거와 비밀번호·OTP 무차별 대입 위험을 경고한다.

고친 방향

// 시도 제한은 HTTP 요청이 아니라 "대상" 기준으로, 리졸버 안에서 센다
verifyOtp: async (_p, { phone, code }, ctx) => {
  await otpLimiter.consume(`otp:${phone}`);     // 번호당 시도 횟수
  await otpLimiter.consume(`otp-ip:${ctx.ip}`); // 보조 지표
  return otpService.verify(phone, code);
},
  • 카운터를 리졸버(비즈니스 로직) 안에 둔다. 별칭 50개면 50번 차감된다. 게이트웨이 rate limit은 보조 수단으로만 쓴다.
  • 배치는 필요할 때만 켠다. Apollo Server 4 이후 allowBatchedHttpRequests 기본값은 false이고, graphql-yoga도 배치가 기본 비활성이며 켜면 요청당 기본 10개까지 허용한다. 예전 설정을 그대로 옮겨오면서 켜둔 경우가 많으니 확인한다.
  • 별칭 수를 제한한다. 아래 graphql-armor 설정으로 연산당 별칭 수 상한을 둘 수 있다.
  • 민감한 mutation은 한 연산에 하나만 허용하는 규칙을 검토한다.

인증번호 쪽 통제는 문자 인증 보안 체크리스트에서 더 자세히 다뤘다.

중첩 쿼리 부하: 깊이·개수·비용 세 가지 한도

user → friends → friends → friends ...처럼 순환 관계가 있는 스키마에서는 쿼리 하나가 수만 건의 DB 조회를 일으킬 수 있다. OWASP는 깊이와 개수(amount) 제한이 기본적으로 없다는 점을 짚고, 둘 다 명시적으로 설정하라고 권고한다.

취약한 코드와 고친 코드

// 취약: 클라이언트가 요청한 만큼 그대로 조회
posts: (_p, { first }) => db.post.findMany({ take: first }),

// 수정: 상한을 서버가 정한다
posts: (_p, { first = 20 }) => db.post.findMany({ take: Math.min(first, 100) }),

깊이·별칭·비용 제한은 직접 구현하기보다 검증된 플러그인을 쓰는 편이 낫다. Escape의 graphql-armor는 Apollo Server와 graphql-yoga(Envelop) 모두를 지원하며, 공식 문서 기준 기본값은 최대 깊이 6, 별칭 15개, 비용 한도 5000이다.

Apollo Server 설정 예시

import { ApolloServer } from '@apollo/server';
import { ApolloArmor } from '@escape.tech/graphql-armor';

const armor = new ApolloArmor({
  maxDepth: { n: 7 },          // 실제 클라이언트 최대 깊이 + 여유 1~2
  maxAliases: { n: 10 },
  costLimit: { maxCost: 5000 },
  blockFieldSuggestion: { enabled: true },
});
const protection = armor.protect();

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.ALLOW_INTROSPECTION === 'true',
  includeStacktraceInErrorResponses: false,
  hideSchemaDetailsFromClientErrors: true,
  allowBatchedHttpRequests: false,
  csrfPrevention: true,
  plugins: [...protection.plugins],
  validationRules: [...protection.validationRules],
});

graphql-yoga 설정 예시

import { createYoga } from 'graphql-yoga';
import { useDisableIntrospection } from '@graphql-yoga/plugin-disable-introspection';
import { EnvelopArmor } from '@escape.tech/graphql-armor';

const protection = new EnvelopArmor({
  maxDepth: { n: 7 },
  maxAliases: { n: 10 },
  costLimit: { maxCost: 5000 },
}).protect();

export const yoga = createYoga({
  schema,
  graphiql: false,
  batching: false,             // 필요하면 { limit: 3 } 처럼 작게
  // maskedErrors는 기본 활성. false로 바꾸지 않는다
  plugins: [useDisableIntrospection(), ...protection.plugins],
});

숫자는 감으로 정하지 말고, 운영 중인 클라이언트 쿼리의 최대 깊이와 비용을 먼저 측정한 뒤 그보다 약간 높게 잡는다. 너무 낮으면 정상 화면이 깨지고, 너무 높으면 의미가 없다. 여기에 리졸버·쿼리 단위 타임아웃을 함께 둔다. OWASP는 인프라 단 타임아웃보다 애플리케이션 단 타임아웃을 권장한다.

introspection과 에러 메시지: 끄되 과신하지 않는다

introspection은 스키마 전체를 알려주는 기능이라 운영 환경에서는 끄는 것이 원칙이다. 다만 두 가지를 알아둬야 한다.

첫째, 기본값에 기대면 안 된다. Apollo Server는 NODE_ENV가 production일 때만 introspection을 기본으로 끄고, 스택 트레이스 포함 옵션도 production이나 test가 아니면 켜져 있다. 컨테이너 환경 변수에 NODE_ENV가 빠져 있으면 둘 다 켜진 채로 배포된다. 위 예시처럼 명시적으로 지정하는 편이 안전하다.

둘째, introspection을 꺼도 스키마는 추측할 수 있다. "Did you mean ...?" 같은 필드 제안 메시지로 필드 이름이 드러나기 때문이다. Apollo는 hideSchemaDetailsFromClientErrors를 운영 환경에서 켜라고 권장하고, Yoga 문서도 graphql-armor의 필드 제안 차단 플러그인을 함께 쓰라고 안내한다. 무엇보다 introspection 비활성화는 보안 경계가 아니다. 스키마가 알려져도 안전하도록 인가를 설계하는 것이 우선이다.

그 밖에 확인할 운영 설정은 다음과 같다.

  • 에러 마스킹: 예상하지 못한 예외는 일반 메시지로 바꾸고, 사용자에게 보여줄 에러만 GraphQLError로 던진다. 상세 내용은 서버 로그에만 남긴다.
  • CSRF: Apollo Server 4 이후 csrfPrevention이 기본 활성이다. 쿠키 인증을 쓴다면 이 옵션을 끄지 않는다.
  • CORS: Apollo의 startStandaloneServer는 Access-Control-Allow-Origin: *를 보내고 변경할 수 없다. 쿠키 인증이나 출처 제한이 필요하면 Express 등 통합 패키지와 cors 설정을 쓴다.
  • Persisted operations: 자사 앱만 API를 쓴다면 미리 등록한 쿼리만 실행하도록 하는 방식이 가장 강한 통제다.
  • 버전: Apollo 공식 마이그레이션 문서 기준 Apollo Server 4는 2026년 1월 26일에 지원이 종료됐다. 아직 4를 쓰고 있다면 5로의 이전 일정을 잡는다.

배포 전 GraphQL 보안 점검 체크리스트

  1. 민감 필드(이메일, 전화번호, 주소, 결제 정보)를 반환하는 모든 경로에 필드 단위 인가가 있는가
  2. node/nodes 같은 전역 조회 필드가 타입별 인가를 거치는가
  3. mutation 입력 타입에 role, status, ownerId처럼 사용자가 바꾸면 안 되는 필드가 없는가
  4. 로그인·인증번호·쿠폰 등 시도 횟수가 중요한 기능의 제한이 리졸버 안에서 대상 기준으로 동작하는가
  5. 배치 요청이 꺼져 있거나 작은 상한이 있는가
  6. 깊이·별칭·비용 한도와 목록 조회 개수 상한이 있는가
  7. 운영 환경에서 introspection, GraphiQL·Sandbox, 필드 제안이 꺼져 있는가(환경 변수와 무관하게 명시적으로)
  8. 스택 트레이스와 내부 에러 메시지가 응답에 나가지 않는가
  9. CSRF 방어와 CORS 출처 제한이 쿠키 인증 여부에 맞게 설정됐는가
  10. 쿼리 실행 시간과 거부된 쿼리(한도 초과)가 로그·모니터링에 남는가

자주 묻는 질문

introspection만 끄면 GraphQL 보안은 충분한가요?

아니다. introspection을 꺼도 필드 제안 메시지, 프런트엔드 번들 안의 쿼리 문자열, 모바일 앱 디컴파일로 스키마를 상당 부분 알아낼 수 있다. 공격 비용을 조금 높일 뿐이므로, 필드 단위 인가와 쿼리 한도를 먼저 갖춰야 한다.

쿼리 깊이 제한은 몇으로 잡아야 하나요?

정답은 없다. 운영 중인 클라이언트 쿼리 중 가장 깊은 것을 측정하고 거기에 1~2 정도 여유를 두는 것이 실무적인 기준이다. 깊이만으로는 넓게 펼친 쿼리를 막지 못하므로 별칭 수와 비용 한도를 함께 쓴다.

API 게이트웨이에 rate limit이 있는데 따로 필요한가요?

필요하다. 게이트웨이는 HTTP 요청 수를 세지만 GraphQL은 요청 하나에 연산을 여러 개 담을 수 있다. 게이트웨이 제한은 전체 트래픽 보호용으로 두고, 로그인·인증번호처럼 시도 횟수가 중요한 기능은 리졸버 안에서 대상 기준으로 따로 센다.

정리

  • GraphQL은 엔드포인트가 하나라서 인가, rate limit, 부하 통제가 모두 스키마와 리졸버 안으로 들어와야 한다.
  • 가장 먼저 볼 것은 민감 필드에 닿는 모든 경로의 필드 단위 인가와 node 같은 전역 조회 필드다.
  • 별칭·배치 우회는 리졸버 안 대상 기준 카운터로, 중첩 쿼리 부하는 깊이·별칭·비용·개수 한도로 막는다.
  • introspection과 에러 노출은 기본값에 맡기지 말고 명시적으로 끄되, 그것을 보안 경계로 여기지 않는다. 직접 점검이 어렵다면 무료 분석 신청으로 현재 구성을 함께 확인해볼 수 있다.

참고 자료

함께 읽으면 좋은 글