스키마 튜토리얼
스키마 튜토리얼레슨 28: 대용량 데이터 일괄 업데이트

레슨 28: 대용량 데이터 일괄 업데이트

때로는 단 한 번의 작업으로 수천 개의 리소스를 업데이트해야 하는 경우가 있습니다. 이는 WordPress 관련 커뮤니티 그룹에 게시된 다음 댓글에 잘 나타나 있습니다:

제가 작업하는 많은 클라이언트들의 경우 대용량 데이터(제품 1개에 10,000개 이상의 상품 변형이나 13,000개 이상의 미디어 파일)를 다루게 됩니다. 그런데 클라이언트들은 2,000개의 미디어 파일에 동일한 태그를 붙이는 것처럼 많은 항목을 한꺼번에 일괄 편집할 수 있기를 원합니다.

이 튜토리얼 레슨에서는 이 과제를 해결하는 방법들을 살펴보겠습니다.

Nested Mutations

이 GraphQL 쿼리가 작동하려면, 엔드포인트에 적용된 스키마 설정에서 Nested Mutations가 활성화되어 있어야 합니다.

Nested Mutations 덕분에 단 하나의 GraphQL 쿼리로 DB에서 수천 개의 리소스를 조회하고 업데이트할 수 있습니다:

mutation ReplaceOldWithNewDomainInPosts {
  posts(pagination: { limit: 3000 }) {
    id
    rawContent
    adaptedRawContent: _strReplace(
      search: "https://my-old-domain.com"
      replaceWith: "https://my-new-domain.com"
      in: $__rawContent
    )
    update(input: {
      contentAs: { html: $__adaptedRawContent }
    }) {
      status
      errors {
        __typename
        ...on ErrorPayload {
          message
        }
      }
    }
  }
}

단, 시스템의 내구성에 따라 이 단일 GraphQL 실행이 DB에 과도한 부하를 주어 크래시를 유발할 수도 있습니다.

GraphQL 쿼리 실행을 페이지네이션으로 분할하기

수천 개의 리소스를 한 번에 업데이트하면 시스템이 크래시되는 경우, 해결책은 간단합니다: 수천 개의 리소스에 대해 GraphQL을 한 번만 실행하는 대신, 수십 개씩 수백 번에 나누어 실행하면 됩니다.

다음 bash 스크립트는 먼저 commentCount를 통해 댓글 총 개수를 확인하고, 환경 변수 $ENTRIES_TO_PROCESS를 고려하여 세그먼트를 계산한 후, 각 세그먼트의 페이지네이션 파라미터를 산출하여 GraphQL 쿼리를 호출합니다(해당 세그먼트의 댓글을 단순히 조회하는 방식):

# Get the number of comments in the site
GRAPHQL_RESPONSE=$(curl
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"query": "{\n  commentCount\n}"}' \
  https://mysite.com/graphql/)
 
# Extract the number of comments into a variable
COMMENT_COUNT=$(echo $GRAPHQL_RESPONSE \
  | grep -E -o '"commentCount\":([0-9]+)' \
  | cut -d':' -f2-)
 
echo "Number of comments: $COMMENT_COUNT"
 
# How many entries will be processed on each query
ENTRIES_TO_PROCESS=10
 
# Calculate how many requests must be triggered
PAGINATION_COUNT=$(($(($COMMENT_COUNT / $ENTRIES_TO_PROCESS)) + $(($(($COMMENT_COUNT % $ENTRIES_TO_PROCESS)) ? 1 : 0))))
 
echo "Number of requests to process (at $ENTRIES_TO_PROCESS entries per request): $PAGINATION_COUNT"
 
# Execute the requests, at one per second
for PAGINATION_NUMBER in $(seq 0 $(($PAGINATION_COUNT - 1))); do sleep 1 && echo "\n\nPagination number: $PAGINATION_NUMBER\n" && curl -X POST -H "Content-Type: application/json" -d "{\"query\": \"{ comments(pagination: { limit: $ENTRIES_TO_PROCESS, offset: $(($PAGINATION_NUMBER * $ENTRIES_TO_PROCESS)) }) { id date content } }\"}" https://mysite.com/graphql/ ; done

GraphQL 쿼리를 재귀적으로 실행하기

위의 해결책은 bash 스크립트를 사용하기 때문에 CLI(또는 관리 패널이나 도구)를 통해 실행해야 하므로 활용 범위가 제한됩니다.

동일한 로직을 GraphQL 쿼리 자체에 내장함으로써 WordPress 내에서 직접 실행할 수 있게 됩니다(GraphQL Persisted Query로 저장하는 것도 가능합니다).

아래 GraphQL 쿼리는 스스로를 재귀적으로 실행합니다. 처음 호출될 때:

  • 업데이트할 리소스의 총 수를 세그먼트로 분할합니다(제공된 $limit 변수를 사용하여 계산)
  • 각 세그먼트에 대해 새 HTTP 요청으로 스스로를 실행하여(해당 $offset을 변수로 전달), 특정 시점에는 전체 리소스 중 일부만 업데이트합니다

이 GraphQL 쿼리는 HTTP 요청이 현재 URL과 동일한 URL(해당 세그먼트의 $offset 변수를 추가)을 가리킴으로써 재귀적으로 동작합니다. 해당 URL(및 바디, 메서드, 헤더)은 현재 HTTP 요청에서 가져옵니다(HTTP Request via Schema 확장 기능 사용).

_sendHTTPRequests에 전달되는 $async 인수는 false로 설정되어 있어 HTTP 요청이 순서대로 하나씩 실행됩니다. 또한 선택적 변수 $delay를 사용하여 각 요청을 전송하기 전에 몇 밀리초 지연할지 지정할 수 있습니다.

모든 리소스가 업데이트되면 GraphQL 쿼리의 실행이 끝에 도달하여 종료됩니다:

# When first invoked, we do not pass variable `$offset`
# Then `$offset` is `null`, and dynamic variable `$executeQuery` will be `true`
query ExportExecute(
  $offset: Int
) {
  executeQuery: _notNull(value: $offset)
    @export(as: "executeQuery")
    @remove # Comment this directive to visualize output during development
}
 
# Only calculate the segments on the first invocation of the GraphQL query
query CalculateVars($limit: Int! = 10)
  @depends(on: "ExportExecute")
  @skip(if: $executeQuery)
{
  # Calculate the number of HTTP requests to be sent
  commentCount
  fractionalNumberExecutions: _floatDivide(number: $__commentCount, by: $limit)
    @remove # Comment this directive to visualize output during development
  numberExecutions: _floatCeil(number: $__fractionalNumberExecutions)
  
  # Generate a list of the offset
  arrayOffsets: _arrayPad(array: [], length: $__numberExecutions, value: null)
    @underEachArrayItem(
      passIndexOnwardsAs: "position"
    )
      @applyField(
        name: "_intMultiply"
        arguments: {
          multiply: $position
          with: $limit
        }
        setResultInResponse: true
      )
    @export(as: "offsets")
 
  # Vars needed to generate a list of the HTTP Request inputs,
  # with many of them retrieved from the current HTTP request data
  url: _httpRequestFullURL
    @export(as: "url")
    @remove # Comment this directive to visualize output during development
  method: _httpRequestMethod
    @export(as: "method")
    @remove # Comment this directive to visualize output during development
  headers: _httpRequestHeaders
    @remove # Comment this directive to visualize output during development
  headersInputList: _objectConvertToNameValueEntryList(
    object: $__headers
  )
    @export(as: "headersInputList")
    @remove # Comment this directive to visualize output during development
  body: _httpRequestBody
    @remove # Comment this directive to visualize output during development
  bodyJSONObject: _strDecodeJSONObject(string: $__body)
    @export(as: "bodyJSONObject")
    @remove # Comment this directive to visualize output during development
  bodyHasVariables: _propertyIsSetInJSONObject(
    object: $__bodyJSONObject,
    by: { key: "variables" }
  )
    @export(as: "bodyHasVariables")
    @remove # Comment this directive to visualize output during development
}
 
query GenerateVars
  @depends(on: ["ExportExecute", "CalculateVars"])
  @skip(if: $executeQuery)
{
  bodyJSON: _echo(value: $bodyJSONObject)
    @unless(condition: $bodyHasVariables)
      @objectAddEntry(
        key: "variables"
        value: {}
      )
    @export(as: "bodyJSON")
    @remove # Comment this directive to visualize output during development
}
 
# Generate all the HTTPRequestInput objects to send each of the HTTP requests
query GenerateRequestInputs(
  $timeout: Float,
  $delay: Int
)
  @depends(on: ["ExportExecute", "GenerateVars"])
  @skip(if: $executeQuery)
{
  # Generate a list of the HTTP Request inputs (without the offset)
  requestInputs: _echo(value: $offsets)
    @underEachArrayItem(
      passValueOnwardsAs: "requestOffset"
      affectDirectivesUnderPos: [1, 2]
    )
      @applyField(
        name: "_objectAddEntry",
        arguments: {
          object: $bodyJSON
          underPath: "variables"
          key: "offset"
          value: $requestOffset
        },
        passOnwardsAs: "itemJSON"
      )
      @applyField(
        name: "_echo",
        arguments: {
          value: {
            url: $url
            method: $method
            options: {
              headers: $headersInputList
              json: $itemJSON
              timeout: $timeout
              delay: $delay
            }
          }
        },
        setResultInResponse: true
      )
    @export(as: "requestInputs")
    @remove # Comment this directive to visualize output during development
}
 
# Execute all the generated URLs, either asynchronously or not
query ExecuteURLs
  @depends(on: ["ExportExecute", "GenerateRequestInputs"])
  @skip(if: $executeQuery)
{
  _sendHTTPRequests(
    async: false
    inputs: $requestInputs
  ) {
    statusCode
    contentType
    body
      @remove
    bodyJSON: _strDecodeJSONObject(string: $__body)
  }
}
 
# This is the actual execution of the query.
# In this case, it simply prints the time when it was executed,
# the provided query variables, and the comment IDs for that segment
query ExecuteQuery(
  $offset: Int
  $limit: Int! = 10
)
  @depends(on: "ExportExecute")
  @include(if: $executeQuery)
{
  executionTime: _httpRequestRequestTime
  queryVariables: _sprintf(string: "[$limit: %s, $offset: %s]", values: [$limit, $offset])
  comments(
    pagination: { limit: $limit, offset: $offset }
    sort: { order: ASC, by: ID }
  ) {
    id
  }
}
 
query ExecuteAll
  @depends(on: ["ExecuteURLs", "ExecuteQuery"])
{
  id
    @remove
}

응답은 다음과 같습니다:

{
  "data": {
    "commentCount": 23,
    "numberExecutions": 3,
    "arrayOffsets": [
      0,
      10,
      20
    ],
    "_sendHTTPRequests": [
      {
        "statusCode": 200,
        "contentType": "application/json",
        "bodyJSON": {
          "data": {
            "executionTime": 1689814467,
            "queryVariables": "[$limit: 10, $offset: 0]",
            "comments": [
              {
                "id": 2
              },
              {
                "id": 3
              },
              {
                "id": 4
              },
              {
                "id": 5
              },
              {
                "id": 6
              },
              {
                "id": 7
              },
              {
                "id": 8
              },
              {
                "id": 9
              },
              {
                "id": 10
              },
              {
                "id": 11
              }
            ]
          }
        }
      },
      {
        "statusCode": 200,
        "contentType": "application/json",
        "bodyJSON": {
          "data": {
            "executionTime": 1689814468,
            "queryVariables": "[$limit: 10, $offset: 10]",
            "comments": [
              {
                "id": 12
              },
              {
                "id": 13
              },
              {
                "id": 16
              },
              {
                "id": 17
              },
              {
                "id": 18
              },
              {
                "id": 19
              },
              {
                "id": 20
              },
              {
                "id": 21
              },
              {
                "id": 22
              },
              {
                "id": 23
              }
            ]
          }
        }
      },
      {
        "statusCode": 200,
        "contentType": "application/json",
        "bodyJSON": {
          "data": {
            "executionTime": 1689814470,
            "queryVariables": "[$limit: 10, $offset: 20]",
            "comments": [
              {
                "id": 24
              },
              {
                "id": 25
              },
              {
                "id": 26
              }
            ]
          }
        }
      }
    ]
  }
}