[번역] 6년 만에 GraphQL을 사용하지 않게 된 이유

sejin kim·2024년 7월 30일
1

번역

목록 보기
8/8

이 글은 소프트웨어 엔지니어 Matt Bessey님이 작성하여 자신의 블로그에 게시한 다음의 글을 한국어로 옮긴 것입니다 : Why, after 6 years, I’m over GraphQL


GraphQL은 제가 2018년에 처음 프로덕션에 적용하기 시작했던 이래로 많은 사람들의 마음을 사로잡은 놀라운 기술입니다. 지금 이 (그다지 활발하진 않은) 블로그를 조금만 돌아보아도, 제가 이전에는 이 기술을 강력하게 옹호해왔었다는 사실을 아실 수 있습니다. 타입화되지 않은 뒤섞인 JSON REST API들 위에서 수많은 React SPA들을 구축한 이후로, GraphQL은 제게 굉장히 새롭고 신선하게 다가왔습니다. 저는 그야말로 진정한 GraphQL 열성 지지자였습니다.

하지만 기능 외적인 요구사항들이 더 중요한 환경에 배포할 기회가 생기면서, 제 관점은 달라지게 되었습니다. 이 글에서는 오늘날 대부분의 사람들에게 GraphQL을 추천하지 않는 이유와, 더 나은 대안이라고 생각하는 것들에 대해 설명드리고자 합니다.

이후 이어지는 내용에서는 graphql-ruby 라이브러리를 사용한 Ruby 코드를 예제로 사용할 것입니다만, 문제들 중 상당수가 언어나 GraphQL 라이브러리의 선택과는 무관하게 전반에 걸쳐 보편적으로 적용되는 것들이라고 생각됩니다.

더 나은 솔루션이나 완화 방안을 알고 계시다면 댓글을 남겨주시길 부탁드리겠습니다. 그럼, 시작해 보겠습니다.






공격 표면 (Attack Surface)

GraphQL이 처음 등장했을 때부터, 신뢰할 수 없는 클라이언트에게 쿼리 언어를 노출한다는 것이 애플리케이션의 공격 표면을 확장시킬 것이라는 사실은 이미 명확했습니다. 하지만 그럼에도 불구하고 고려해야 할 공격의 종류는 상상했던 것보다 훨씬 더 광범위했고, 이를 완화하는 것은 적잖은 부담으로 작용했습니다. 지난 몇 해 동안 제가 경험하고 대응해야 했던 최악의 사례들을 꼽아보자면 아래와 같습니다.



인가 (Authorisation)

역주) 원문에서 Authorization의 영국식 철자인 Authorisation으로 표기되고 있으므로 이를 따릅니다.


이는 이미 GraphQL의 가장 널리 알려진 위험이라고 생각되므로 여기서는 너무 자세히 설명하지는 않겠습니다. 요약하자면, 모든 클라이언트에 완전한 자체 문서화 쿼리 API를 노출하는 경우, 모든 필드가 해당 필드를 가져오는 컨텍스트에 맞게 현재 사용자에 대해서 적절하게 권한이 부여되어 있는지를 반드시 확인해야 한다는 것입니다. 처음에는 객체(objects)를 인가하는 것으로 충분해 보이지만, 이것만으로는 금방 부실해지게 됩니다. 예를 들어, 트위터 X 🙄 API를 가정해 보겠습니다:


query {
  user(id: 321) {
    handle # ✅ 사용자의 공개 정보를 볼 수 있도록 허용됨
    email # 🛑 사용자 정보를 볼 수 있다고 해서 그 PII(개인 식별 정보)까지 볼 수 있어서는 안 됨
  }
  user(id: 123) {
    blockedUsers {
      # 🛑 때로는 공개된 정보라고 해도 볼 수 없어야 할 때가 있는데,
      # 컨텍스트가 중요하기 때문!
      handle
    }
  }
}

취약한 접근 제어(Broken Access Control) 취약점이 OWASP Top 10에서 1위에 등극하는 데에 GraphQL이 얼마나 책임이 있는지 궁금해지는 부분입니다. 이를 완화하는 한 가지 방법은 GraphQL 라이브러리의 인가(권한 부여) 프레임워크와 통합하여 기본적으로 API를 안전하게 만드는 것입니다. 반환된 모든 객체 및/또는 필드마다 인가 시스템이 호출되어, 현재 사용자가 접근 권한을 가지고 있는지를 검사하는 식입니다.

이를 일반적으로 각 엔드포인트마다 권한을 부여하는 REST 환경과 비교해 본다면, REST의 경우가 훨씬 더 작은 작업이라고 할 수 있을 것입니다.



호출 제한 (Rate Limiting)

GraphQL에서는 모든 요청이 서버에 동일한 수준의 부담을 준다고 가정할 수 없습니다. 쿼리의 크기에 제한이 없기 때문입니다. 완전히 비어 있는 스키마라고 해도, 스키마 확인(인트로스펙션, introspection)을 위해 노출되는 타입은 순환적(cyclical)이기 때문에 MB 단위의 JSON을 반환하는 유효한 쿼리를 작성하는 것이 가능합니다:


query {
  __schema{
    types{
      __typename
      interfaces {
        possibleTypes {
          interfaces {
            possibleTypes {
              name
            }
          }
        }
      }
    }
  }
}

저는 방금 어떤 매우 인기 있는 웹 사이트의 GraphQL API 탐색기에 대해 이 공격을 테스트해 보았고 10초 후 500 응답을 받았습니다. 이 128 byte의 쿼리(공백이 제거된)를 실행하는 것으로 누군가의 CPU 시간을 10초나 차지하였는데, 로그인조차도 필요하지 않았습니다.

이러한 공격에 대한 일반적인 대응 방법1)은 다음과 같습니다.

  1. 스키마의 모든 단일 필드를 처리하는 복잡도를 측정하고, 최대 복잡도를 초과하는 쿼리는 중단시킵니다.
  2. 실행 쿼리의 실제 복잡도를 측정하고, 일정한 간격으로 재설정되는 크레딧 버킷에서 제거합니다.

이러한 계산은 정확하게 수행되어야 하는 꽤나 까다로운 작업입니다. 특히 실행 전에는 길이를 알 수 없는 리스트 필드들을 반환할 때는 더욱 문제가 복잡해집니다. 이러한 필드의 복잡도를 추산할 수는 있지만, 만약 잘못 가정한다면 유효한 쿼리를 제한한다거나 유효하지 않은 쿼리를 제한하지 못하게 될 수 있습니다.

설상가상으로 상황을 더 악화시키는 것은, 스키마를 구성하는 그래프에 순환(cycles)이 포함되는 경우가 흔하다는 점입니다. 각기 여러 개의 태그를 가진 기사가 있는 블로그를 운영한다고 가정해 보겠습니다. 각 태그를 통해서는 관련 기사를 볼 수 있습니다.


type Article {
  title: String
  tags: [Tag]
}
type Tag {
  name: String
  relatedTags: [Tag]
}

Tag.relatedTags의 복잡도를 추산할 때, 하나의 기사는 5개 이상의 태그를 가지지 않을 것임을 가정하여 필드의 복잡도를 5(또는 5 * 자식의 복잡도)로 설정할 수 있습니다. 그러나 이때 문제는 Article.relatedTags가 자기 자신의 자식이 될 수도 있으므로, 추정치의 부정확성이 기하급수적으로 증가할 수 있다는 것입니다. 공식은 N^5*1 입니다. 따라서 다음과 같은 쿼리가 주어집니다:


query {
  tag(name: "security") {
    relatedTags {
      relatedTags {
        relatedTags {
          relatedTags {
            relatedTags { name }
          }
        }
      }
    }
  }
}

5^5 = 3,125의 복잡도를 예상하실 것입니다. 그러나 공격자가 10개의 태그를 가진 기사를 찾을 수 있다면, "실제" 복잡도가 10^5 = 100_000인 쿼리를 실행하게 될 것입니다. 이는 당초 예상보다도 20배나 더 높은 수치입니다.

여기서 부분적인 완화책은 깊게 중첩되는 쿼리를 방지하는 것입니다. 그러나 위 예시에서 나타나고 있듯, 비정상적으로 깊은 쿼리는 아니기 때문에 이는 실질적인 방어책이 되지는 못합니다. GraphQL Ruby의 기본 최대 깊이 제한은 13인데, 위의 쿼리는 7에 불과합니다.

이를 일반적으로 응답 시간이 비슷한 REST 엔드포인트에서의 호출 제한과 비교해 보겠습니다. 이런 경우 사용자가 모든 엔드포인트에서 분당 200건의 요청을 초과하지 못하도록 하는 버킷형 호출 제한을 적용하는 것으로 충분합니다. 만약 더 느린 엔드포인트가 있는 경우에는(CSV 리포트나 PDF 생성기 등), 더 공격적인 제한을 설정할 수도 있을 것입니다. HTTP 미들웨어를 사용한다면 이는 꽤 간단합니다:


Rack::Attack.throttle('API v1', limit: 200, period: 60) do |req|
  if req.path =~ '/api/v1/'
    req.env['rack.session']['session_id']
  end
end


쿼리 구문 분석 (Query parsing)

쿼리는 실행되기에 앞서 구문 분석이 이루어집니다. 잘못된 쿼리 문자열을 생성하는 것으로 서버의 OOM을 유발시키는 것이 가능하다는 침투 테스트(pen-test) 리포트를 받은 적이 있었습니다. 예를 들면:


query {
  __typename @a @b @c @d @e ... # 1천 개 이상을 가정해 보세요
}

이는 구문상으로는 유효한 쿼리이지만, 스키마로서는 유효하지 않습니다. 사양을 준수하는 서버라면 이를 구문 분석하여 수천 개의 에러가 포함된 응답을 작성하기 시작할 것이며, 이 과정에서 쿼리 문자열 자체보다 2,000배 더 많은 메모리를 소비하게 될 것입니다. 이러한 메모리 증폭 때문에 페이로드의 크기를 제한하는 것만으로는 충분하지 않습니다. 유효한 쿼리가 매우 작은 악성 쿼리보다도 크기가 더 클 수 있기 때문입니다.

서버가 구문 분석을 중단하기 전까지 누적할 수 있는 최대 에러 수를 설정한다면 이 문제를 완화할 수 있을 것입니다. 그러나 그렇지 않다면 자체적인 솔루션을 마련해야 합니다. 이 정도의 심각도를 가진 공격에 상응하는 것은 REST에는 존재하지 않습니다.






성능 (Performance)

GraphQL의 성능에 관해서는 HTTP 캐싱과의 비호환성 문제가 빈번하게 언급되곤 합니다. 하지만 개인적으로 이는 특별히 문제가 되지 않았습니다. SaaS 애플리케이션의 경우 데이터는 일반적으로 사용자에 매우 특화되어 있고, 오래된 데이터를 제공하는 것도 용인되지 않기 때문에, 응답 캐시가 누락된다 하더라도 문제가 없었습니다(또는 캐시 무효화 버그가 발생하더라도...).

제가 실제로 겪었던 주요한 성능 문제들은 오히려 다음과 같은 것들이었습니다.



데이터 페칭 및 N+1 문제

이 문제는 최근 꽤 널리 이해되고 있는 것 같습니다. 간략히 설명하자면, 필드 리졸버가 DB나 HTTP API와 같은 외부 데이터 소스에 접근할 때, 그 필드가 N개의 아이템이 포함된 리스트에 중첩되어 있을 경우 해당 호출을 N번 수행하게 되는 문제입니다.

이 문제는 GraphQL에만 국한된 것은 아니며, GraphQL의 엄격한 처리 알고리즘 덕분에 대부분의 라이브러리에서는 공통적인 솔루션으로 Dataloader 패턴이라는 것을 공유할 수 있었습니다. 그러나 GraphQL 특유의 문제는, 쿼리 언어이기 때문에 클라이언트가 쿼리를 수정하면 백엔드의 변경이 없어도 이 문제가 발생할 수 있다는 것입니다. 때문에 결과적으로는 클라이언트가 리스트 컨텍스트에서 필드를 가져오게 될 경우를 대비해 모든 곳마다 방어적으로 Dataloader 추상화를 도입해야 한다는 것을 알게 되었습니다. 이는 수많은 보일러플레이트를 작성하고 관리해야 한다는 것이기도 합니다.

한편, REST에서는 일반적으로 중첩된 N+1 쿼리를 컨트롤러로 끌어올릴 수 있는데, 이는 훨씬 이해하기 쉬운 패턴이라고 생각합니다:


class BlogsController < ApplicationController
  def index
    @latest_blogs = Blog.limit(25).includes(:author, :tags)
    render json: BlogSerializer.render(@latest_blogs)
  end

  def show
    # 여기서는 N=1이므로 프리페칭이 필요하지 않음
    @blog = Blog.find(params[:id])
    render json: BlogSerializer.render(@blog)
  end
end


인가 및 N+1 문제

그런데 N+1 문제가 아직 더 있습니다! 앞서 라이브러리의 인가(권한 부여) 프레임워크와 통합하라는 조언을 따랐다면, 이제 완전히 새로운 범주의 N+1 문제를 처리해야 합니다. 위 X API 예제를 이어서 진행하겠습니다:


class UserType < GraphQL::BaseObject
  field :handle, String
  field :birthday, authorize_with: :view_pii
end

class UserPolicy < ApplicationPolicy
  def view_pii?
    # 이런, 사용자의 친구 정보를 가져오기 위해 DB에 접근했습니다
    user.friends_with?(record)
  end
end
query {
  me {
    friends { # N명의 사용자 반환
      handle
      birthday # UserPolicy#view_pii? N번 실행
    }
  }
}

이 문제는 이전 예제보다 더 다루기 까다롭습니다. 인가 코드가 항상 GraphQL 컨텍스트에서 실행되는 것은 아니기 때문입니다. 예를 들면 백그라운드 작업이라거나 HTML 엔드포인트에서 실행될 수도 있을 것입니다. 이는 그저 무턱대고 Dataloader를 사용할 수는 없다는 것을 의미하는데, Dataloader는 GraphQL 내에서 실행될 것으로 예상되기 때문입니다(적어도 Ruby 구현에서는 그렇습니다).

경험상 이는 실제로 성능 문제의 가장 큰 원인이었습니다. 쿼리가 다른 무엇보다도 데이터 인가에 많은 시간을 소비하는 상황을 자주 마주하곤 했습니다. 다시 말하지만, 이는 REST 환경에서는 존재하지 않는 문제입니다.

정책 호출(policy calls) 간에 데이터를 메모하기 위해 요청 레벨에서 전역 속성을 사용하는 것과 같은 끔찍한 방법을 사용하여 이 문제에 대응했지만, 결코 유쾌하지는 않았습니다.






결합 (Coupling)

경험에 따르면, 성숙한 GraphQL 코드베이스에서는 비즈니스 로직이 전송 계층(transport layer)으로 강제 이동하게 됩니다. 이는 여러 메커니즘을 통해 발생하는데, 이들 중 일부는 이미 언급한 바 있습니다:


  • 데이터 인가 문제를 해결하면 GraphQL 타입 전반에 걸쳐 인가 규칙이 흩어지게 됩니다.
  • 뮤테이션(mutation) / 인자(argument)의 인가 문제를 해결하면 인가 규칙이 GraphQL 인자 전체에 흩어지게 됩니다.
  • 리졸버의 N+1 데이터 페칭 문제를 해결하면 해당 로직은 GraphQL의 특정 Dataloader로 옮겨지게 됩니다.
  • (매력적인) 릴레이 커넥션 패턴을 활용하면 데이터 페칭 로직이 GraphQL의 특정 사용자 정의 객체로 이동하게 됩니다.

이 모든 것의 결과는 애플리케이션을 유의미하게 테스트하는 것, 다시 말해 GraphQL 쿼리를 실행하여 통합 레이어에서 광범위하게 테스트해야 한다는 것입니다. 저는 이것이 매우 고통스러운 경험이 될 수 있다는 것을 알았습니다. 발생하는 에러들은 프레임워크에 의해 캡처되어, JSON GraphQL 에러 응답에서 스택 트레이스를 읽어내는 고된 작업으로 이어지게 됩니다. 많은 인가 및 Dataloader 관련 작업들이 프레임워크 내부에서 이루어지기 때문에, 적절한 중단점이 애플리케이션 코드에는 존재하지 않아 디버깅이 훨씬 더 어려운 경우가 많습니다.

그리고, 쿼리 언어이기 때문에 앞서 언급한 모든 인자와 필드 레벨의 동작들이 정상적으로 기능하는지 검증하기 위해 훨씬 더 많은 테스트를 작성해야 하는 것은 물론입니다.






복잡성 (Complexity)

종합적으로 보았을 때, 지금까지 살펴보았던 보안 및 성능 문제에 대한 다양한 완화 조치들은 코드베이스에 상당한 복잡성을 더합니다. REST에는 이러한 문제가 없다는 것은 아니지만(물론 상대적으로 그 수가 더 적습니다만), 일반적으로 백엔드 개발자가 구현하고 이해하기에는 REST 솔루션이 훨씬 더 간단합니다.






그리고 더...

이것이 제가 GraphQL에 대해 회의적인 이유들입니다. 몇 가지 불만이 더 있지만, 글이 너무 길어질 수 있으니 이쯤에서 정리하겠습니다:


  • GraphQL은 급격한 변경(breaking changes)을 권장하지 않으며 이를 처리할 수 있는 도구도 제공하지 않습니다. 이는 클라이언트를 제어하는 입장에서는 불필요한 복잡성이 더해지며, 우회 방안을 찾아야 하게끔 만듭니다.
  • HTTP 응답 코드에 대한 의존도는 작업 과정 전반에서 나타나므로, 200이 '모든 것이 정상'이라는 의미부터 '모든 것이 다운되었다'는 의미까지 모든 것을 아우른다는 점이 꽤나 성가실 수 있습니다.
  • HTTP 2+ 시대에서는 모든 데이터를 하나의 쿼리로 가져오는 것이 응답 시간에 그다지 도움이 되지 않는 경우가 많으며, 오히려 서버가 병렬화되지 않은 경우 분리된 서버 각각에 별도의 요청을 보내는 것보다 응답 시간이 더 나빠질 수 있습니다.





대안들

좋습니다. 불평은 여기까지 하겠습니다. 대신 무엇을 추천할까요? 솔직히 말하면, 저는 아직 과대광고 주기(hype cycle)의 초기 단계에 있습니다만, 지금의 제 견해로는 다음의 조건을 만족한다면 다른 접근 방식을 고려해볼 만하다고 생각합니다:


  1. 모든 클라이언트를 제어할 수 있음
  2. 클라이언트 수가 3 이하
  3. 클라이언트가 정적 타입 언어로 작성되었음
  4. 서버와 클라이언트에서 1개를 초과하는 언어를 사용하고 있음2)

이 경우 OpenAPI 3.0+을 준수하는 JSON REST API를 사용하는 것이 더 나을 것입니다. 제 경험에 비추어 보았을 때, 프론트엔드 개발자들이 GraphQL을 선호하는 가장 주요한 이유가 자체 문서화되는 타입 안전성 때문이라면, 이러한 접근 방식이 잘 맞을 것입니다. GraphQL이 등장한 이후로 이 분야의 도구들도 많이 개선되었고, 프레임워크에 특화된 데이터 페칭 라이브러리에 이르기까지 타입화된 클라이언트 코드를 생성하는 데 활용할 수 있는 선택지들이 다수 존재합니다. 지금까지의 제 경험은 "페이스북이 필요로 했던 복잡성 없이 GraphQL을 사용한 작업들 중 가장 좋았던 부분"에 가장 근접한다고 할 수 있겠습니다.

GraphQL과 마찬가지로 구현에 대한 몇 가지 접근 방식이 있습니다.

구현 우선 도구는 타입화된 / 타입 힌트되는 서버에서 OpenAPI 사양을 생성합니다. Python의 FastAPI와 TypeScript의 tsoa가 이러한 접근 방식의 좋은 예입니다3). 이는 제가 가장 많은 경험을 가지고 있는 부분이기도 하며, 효과적으로 기능한다고 생각합니다.

사양 우선은 GraphQL에서의 "스키마 우선"과 동일합니다. 사양 우선 도구는 수작업으로 작성된 사양에서 코드를 생성합니다. 저는 솔직히 OpenAPI YAML 파일을 보고 "이걸 내가 직접 작성하고 싶다"라고 생각한 적은 없었습니다만, 최근 출시된 TypeSpec은 상황을 완전히 바꿔놓았습니다. 매우 우아한 스키마 우선의 워크플로우가 가능해졌습니다:


  1. 간결하고 사람이 읽을 수 있는 TypeSpec 스키마를 작성합니다.
  2. 이를 바탕으로 OpenAPI YAML 사양을 생성합니다.
  3. 선택한 프론트엔드 언어(예: TypeScript)에서 정적 타입의 API 클라이언트를 생성합니다.
  4. 백엔드 언어 및 서버 프레임워크에 대해 정적 타입의 서버 핸들러를 생성합니다(예: TypeScript + Express, Python + FastAPI, Go + Echo).
  5. 타입 안전성이 보장된 상태에서 컴파일을 수행하는 해당 핸들러의 구현을 작성합니다.

이러한 접근 방식은 아직 성숙하지는 않았지만, 많은 가능성을 가지고 있다고 생각합니다.

제게는 강력하면서도 더 단순한 선택지들이 등장한 것으로 보이며, 앞으로 이들의 단점이 무엇인지 배우게 될 날이 기대됩니다😄.

읽어주셔서 감사합니다! 이 글에 대한 더 많은 논의는 Hacker NewsReddit을 참조하세요.




1) 지속된 쿼리(Persisted queries)는 이 문제와 여러 공격에 대한 완화책이 될 수 있지만, 실제로 클라이언트에게 GraphQL API를 노출하려는 경우에는 유효한 선택지가 되지 못합니다.
2) 이외에는 tRPC와 같은 언어 특화 솔루션이 더 적합할 수 있습니다.
3) Ruby에서는 타입 힌트가 널리 사용되지 않기 때문에 이에 상응하는 접근 방식이 없습니다. 대신 요청 사양에서 OpenAPI 사양을 생성할 수 있는 rswag가 있습니다. Sorbet / RBS 타입 엔드포인트에서 OpenAPI 사양을 구축할 수 있다면 좋을 것 같습니다!

profile
퇴고를 좋아하는 주니어 웹 개발자입니다.

0개의 댓글