• 본 시리즈에서는 How to GraphQL의 Tutorial 문서들을 차례대로 번역합니다.
  • 이 글은 GraphQL Advanced - Security을 번역한 글입니다.
  • 오역 또는 의역이 있을 수 있습니다. 양해 부탁드리며, 수정할 필요한 부분은 댓글로 요청해주세요.

보안

GraphQL은 클라이언트에 막대한 힘을 가져다줍니다. 하지만 큰 힘에는 큰 책임이 따르는 법이죠 🕷.

클라이언트에 복잡한 쿼리를 만들 수 있게 되었으므로, 서버 또한 이를 적절하게 다룰 준비를 갖추어야 합니다. 악의적인 클라이언트라면 악의적인 의도의 쿼리를 전송할 수도 있을 것이고, 아니면 단순히 합법적인 클라이언트가 매우 큰 크기의 쿼리를 전송할 수도 있을 것입니다. 어느 경우이든, 클라이언트는 잠재적으로 GraphQL 서버를 느리게 만들 수 있다는 것입니다.

이러한 위험성에 대처하기 위한 몇 가지 전략들이 존재합니다. 이번 장에서는 쉬운 것부터 시작하여 복잡한 것 순으로, 각각의 전략들에 대하여 및 각각의 장단점에 대하여 알아보겠습니다.

시간 제한

첫번째이자 가장 간단한 전략은 대형 쿼리에 대하여 시간 제한을 두어서 방어하는 것입니다. 이 전략은 들어오는 쿼리에 대하여 서버가 특별히 조치를 하지 않아도 된다는 점에서 가장 간단합니다. 서버가 알아야 할 것은 한 쿼리에 허용되는 최대 시간 뿐입니다.

예를 들어, 5초의 시간 제한을 가진 서버는 실행에 5초 이상 소요되는 쿼리를 만나게 되면 실행을 멈추게 됩니다.

시간 제한의 장점

  • 구현이 쉽다.
  • 대부분의 전략에서는 최후의 보루로서 시간 제한을 채택한다.

시간 제한의 단점

  • 시간 제한에 의하여 실행이 멈추더라도 서버에 대한 피해를 돌이킬 수 없다.
  • 때로는 구현이 어렵다. 일정 시간이 흐른 뒤 통신을 끊는 것은 이상한 동작을 유발할 수 있다.

최대 쿼리 깊이

앞에서 다루었듯이 GraphQL을 다루는 클라이언트는 복잡한 쿼리를 만들어낼 수 있습니다. GraphQL 스키마는 순환 그래프의 형태를 가집니다. 즉, 클라이언트는 아래와 같은 쿼리를 만들 수도 있을 것입니다.

query IAmEvil {
  author(id: "abc") {
    posts {
      author {
        posts {
          author {
            posts {
              author {
                # 클라이언트가 원한다면 더 깊어질 수 있습니다!
              }
            }
          }
        }
      }
    }
  }
}

위와 같은 악의적인 쿼리 깊이를 미리 방지하려면 어떻게 해야 할까요? 작성한 스키마에 대하여 잘 파악한다면, 합법적인 쿼리가 가질 수 있는 깊이가 어느 정도인지도 알 수 있습니다. 이는 실제로 구현이 가능하며 최대 쿼리 깊이라고도 부릅니다.

쿼리 문서의 추상 문법 트리(Abstract Syntax Tree; ASB)를 분석하면, GraphQL 서버는 들어온 쿼리의 깊이를 기반으로 실행을 허용하거나 거절할 수 있습니다.

최대 쿼리 깊이가 3으로 설정된 서버와 아래의 쿼리 문서를 예로 들어보겠습니다. 빨간 상자 안의 것들은 깊이를 초과하는 것으로, 이 쿼리는 유효하지 않습니다.

[그림]

최대 쿼리 깊이를 설정하는 데에 graphql-ruby를 사용하면, 아래와 같은 결과가 반환됩니다.

{
  "errors": [
    {
      "message": "Query has depth of 6, which exceeds max depth of 3"
    }
  ]
}

최대 쿼리 깊이의 장점

  • 문서의 AST는 정적으로 분석되므로, 쿼리는 실행이 일절 이루어지지 않고 따라서 GraphQL 서버에 아무런 부담이 발생하지 않습니다.

최대 쿼리 깊이의 단점

  • 깊이라는 값 그 자체로는 악의적인 쿼리를 가려내기 충분하지 않은 정보입니다. 예를 들어, 최상위에서 지나치게 큰 크기의 노드에 대한 요청을 보낸 쿼리는 아주 값비싼 요청이지만 쿼리 깊이 분석기로는 차단되지 않을 것입니다.

쿼리 복잡도

때로는 쿼리 깊이만으로는 GraphQL 쿼리의 크기 또는 비용을 가늠하기 충분하지 않습니다. 대부분의 경우, 스키마 내의 특정 필드가 다른 필드에 비하여 처리하기 복잡합니다.

쿼리 복잡도를 사용하면 필드가 얼마나 복잡한지 정의할 수 있고, 이를 통하여 최대 복잡도를 기반으로 쿼리를 제한할 수 있습니다. 각 필드의 개수를 세는 것으로 복잡한 정도를 정의하는 것입니다. 기본값으로는 각 필드에 대하여 1의 복잡도를 부여합니다. 아래의 쿼리를 한번 보도록 하겠습니다.

query {
  author(id: "abc") { # 복잡도: 1
    posts {           # 복잡도: 1
      title           # 복잡도: 1
    }
  }
}

간단한 덧셈을 통하여 이 쿼리의 총 복잡도는 3이라는 것을 알 수 있습니다. 만약 스키마에서 최대 복잡도를 2로 설정했다면, 이 쿼리는 동작하지 않을 것입니다.

만약 posts 필드가 author 필드보다 훨씬 더 복잡하다면 어떡할까요? 서로 다른 필드에 각기 다른 복잡도 값을 설정할 수도 있습니다. 심지어 인자에 따라 다른 복잡도를 설정해줄 수도 있습니다! 아래를 보면 비슷하지만, posts의 인자가 무엇인지에 따라 복잡도가 달라지는 쿼리를 볼 수 있습니다.

query {
  author(id: "abc") {    # 복잡도: 1
    posts(first: 5) {    # 복잡도: 5
      title              # 복잡도: 1
    }
  }
}

쿼리 복잡도의 장점

  • 쿼리 깊이에 비하여 훨씬 많은 경우를 다룰 수 있다.
  • 복잡도를 정적으로 분석함에 따라 쿼리가 실행되기 전에 미리 실행을 거절할 수 있다.

쿼리 복잡도의 단점

  • 완벽하게 구현하는 것이 어렵다.
  • 개발 시점에 복잡도를 측정한다면, 이를 어떻게 최신으로 유지할 것인가? 처음에 비용을 어떻게 산출해낼 것인가?
  • 뮤테이션은 복잡도를 측정하는 것이 어렵다. 측정하기 어려운 부작용이 있다면 이를 어떻게 다룰 것인가? 이러한 예시로는 백그라운드 작업을 유발하는 경우가 있다.

쓰로틀링

지금까지 알아본 해결 방안들은 악의적인 쿼리로부터 서버를 보호하는 데에는 아주 유용합니다. 위의 방법들로는 크기가 큰 쿼리는 막을 수 있겠지만, 중간 사이즈의 쿼리를 여러 개로 나누어 보내는 것은 막을 수 없을 겁니다.

대부분의 API에서는 간단한 쓰로틀링(Throttling)를 사용하여 너무 자주 이루어지는 요청을 막습니다. GraphQL은 다소 특별한데, 요청 횟수에 따른 쓰로틀링은 의미가 없기 때문입니다. 한두 번 정도의 쿼리일지라도 그 크기가 너무 크다면 문제가 됩니다.

사실 요청 크기를 얼마나 제한해야 할지에 대해서는 미리 정하기가 어렵습니다. 왜냐하면 이것은 클라이언트에 의하여 결정되기 때문입니다. 이런 상황에서 클라이언트를 제어하려면 어떻게 접근해야 할까요?

서버 시간에 기반한 쓰로틀링

쿼리의 비용을 산정하는 좋은 방법 중 하나는 해당 쿼리를 완수하기 위한 서버 시간을 보는 것입니다. 이러한 직관(Heuristic)을 사용하여 쿼리를 쓰로틀링할 수 있습니다. 시스템에 대하여 잘 파악하고 있다면, 일정 기간 동안에 얼마 정도의 서버 시간이 필요한지 정할 수 있을 것입니다.

또한 시간이 지남에 따라 서버 시간을 클라이언트에 얼마나 더할지 정할 수 있습니다. 이는 전통적인 Leaky Bucket 알고리즘입니다. 다른 쓰로틀링 알고리즘도 존재하지만, 이번 장의 주제를 벗어납니다. 다음 예시에서는 Leaky Bucket 알고리즘을 사용하겠습니다.

허용된 최대 서버 시간(버킷의 크기)이 1000ms이고, 클라이언트는 초당 100ms의 서버 시간을 얻을 수 있다(Leak Rate)고 합시다.

mutation {
  createPost(input: { title: "GraphQL Security" }) {
    post {
      title
    }
  }
}

그러면 위의 뮤테이션은 완료까지 평균적으로 200ms 소요됩니다. 실제로는 다소 차이가 있을 수 있지만, 예시에서는 설명의 간편함을 위하여 항상 200ms가 걸린다고 해봅시다.

즉, 위의 동작을 1초동안 5번 이상 요청하는 클라이언트는 해당 클라이언트에 추가적인 서버 시간이 더해지기 전까지는 차단된다는 것입니다. 2초가 지난 뒤에야(1초마다 100ms가 더해집니다) 클라이언트는 createPost를 한번 호출할 수 있게 될 것입니다.

보시다시피 시간에 기반한 쓰로틀링은 GraphQL 쿼리를 쓰로틀링할 수 있는 효과적인 방법입니다. 복잡한 쿼리는 더 많은 서버 시간을 필요로 하고, 이러한 쿼리는 덜 사용하여야 할 것입니다. 반면 작고 단순한 쿼리는 처리하는 데에 시간이 덜 소요되므로 더 자주 사용할 수 있을 것입니다.

외부로 공개되는 GraphQL API를 사용한다면 이러한 쓰로틀링 제약 사항을 함께 공개하는 것이 좋습니다. 이 경우, 서버 시간을 클라이언트에게 알려주는 것은 쉽지 않으며, 클라이언트는 API를 한번 사용해보지 않으면 쿼리의 실제 소요 시간이 얼마나 되는지를 정확히 알 수 없을 것입니다.

위에서 최대 복잡도에 대하여 이야기한 것을 기억하시나요? 최대 복잡도를 기반으로 쓰로틀링한다면 어떨까요?

쿼리 복잡도에 기반한 쓰로틀링

쿼리 복잡도에 기반한 쓰로틀링은 클라이언트와의 의사소통이 용이하며, 클라이언트가 스키마의 제한량을 따르기 편하도록 해줍니다.

쿼리 복잡도를 다룰 때에 사용했던 동일한 예제를 보도록 하겠습니다.

query {
  author(id: "abc") {    # 복잡도: 1
    posts {              # 복잡도: 1
      title              # 복잡도: 1
    }
  }
}

이 쿼리는 복잡도에 따라 3의 비용이 소요됩니다. 시간 기반의 쓰로틀링과 마찬가지로, 클라이언트가 한번에 필요한 최대 비용(Bucket 크기)을 계산해낼 수 있습니다.

9의 최대 비용을 사용한다면 클라이언트는 위의 쿼리를 3번 사용할 수 있으며, 그 이후에는 Leak Rate에 의하여 더 이상 쿼리를 사용할 수 없게 됩니다.

시간 기반의 쓰로틀링과 같은 원칙이지만, 이제 이러한 제한량을 클라이언트 측과 공유하는 것이 훨씬 편리해졌습니다. 클라이언트는 서버 시간을 측정할 필요 없이 알아서 쿼리 비용을 계산할 수 있게 되었습니다.

GitHub의 공개 API는 이러한 접근을 사용하여 클라이언트에 대한 쓰로틀링을 수행합니다. 사용자들에게 제한량을 어떻게 알려주는지 다음 글을 읽어보시기 바랍니다.

마무리

GraphQL은 클라이언트에게 아주 강력한 기능을 제공하므로 유용합니다. 하지만 이러한 강력한 기능은 GraphQL 서버에 값비싼 쿼리를 남용할 가능성을 만들기도 합니다.

이러한 쿼리로부터 GraphQL 서버를 지킬 다양한 방법들이 존재하지만, 그 자체로 완벽한 방법이란 존재하지 않습니다. 어떤 방법들이 존재하는지, 그리고 각각의 한계가 무엇인지를 확실히 아는 것이 중요합니다. 그래야 최선의 선택을 할 수 있기 때문입니다!

Quiz

악의적 또는 거대한 쿼리로부터 방어하는 전략으로 옳지 않은 것은?

  • 쿼리 복잡도 계산
  • 최대 쿼리 깊이
  • 서버 추가 증설
  • 시간 제한