스키마 튜토리얼
스키마 튜토리얼레슨 22: 서비스 연결 시 오류 처리하기

레슨 22: 서비스 연결 시 오류 처리하기

외부 API에서 데이터를 가져올 때 다양한 유형의 오류가 발생할 수 있습니다.

예를 들어, 다음 쿼리를 살펴보겠습니다:

{
  externalData: _sendJSONObjectItemHTTPRequest(
    input: {
      url: "https://newapi.getpop.org/wp-json/wp/v2/posts/8888/"
    }
  )
    
  postTitle: _objectProperty(
    object: $__externalData,
    by: { path: "title.rendered"}
  )
}

인터넷 연결이 끊기면 필드 _sendJSONObjectItemHTTPRequest가 오류를 발생시킵니다:

{
  "errors": [
    {
      "message": "cURL error 6: Could not resolve host: newapi.getpop.org (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://newapi.getpop.org/wp-json/wp/v2/posts/8888/",
      "locations": [
        {
          "line": 2,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    },
    {
      "message": "Argument 'object' in field '_objectProperty' of type 'QueryRoot' cannot be null",
      "locations": [
        {
          "line": 10,
          "column": 13
        }
      ],
      "extensions": {
        "path": [
          "$__externalData",
          "(object: $__externalData)",
          "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
        "id": "root",
        "code": "gql@5.4.2.1[b]",
        "specifiedBy": "https://spec.graphql.org/draft/#sec-Required-Arguments"
      }
    }
  ],
  "data": {
    "externalData": null,
    "postTitle": null
  }
}

연결에 성공했더라도 요청한 리소스가 존재하지 않으면 404가 반환됩니다:

{
  "errors": [
    {
      "message": "Client error: `GET https://newapi.getpop.org/wp-json/wp/v2/posts/8888/` resulted in a `404 Not Found` response:\n{\"code\":\"rest_post_invalid_id\",\"message\":\"Invalid post ID.\",\"data\":{\"status\":404}}\n",
      "locations": [
        {
          "line": 2,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    },
    {
      "message": "Argument 'object' in field '_objectProperty' of type 'QueryRoot' cannot be null",
      "locations": [
        {
          "line": 10,
          "column": 13
        }
      ],
      "extensions": {
        "path": [
          "$__externalData",
          "(object: $__externalData)",
          "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
        "id": "root",
        "code": "gql@5.4.2.1[b]",
        "specifiedBy": "https://spec.graphql.org/draft/#sec-Required-Arguments"
      }
    }
  ],
  "data": {
    "externalData": null,
    "postTitle": null
  }
}

두 경우 모두 응답에 추가적인 오류가 발생했습니다:

{
  "message": "Argument 'object' in field '_objectProperty' of type 'QueryRoot' cannot be null" 
}

이 오류는 첫 번째 오류 이후 동적 변수 $__externalData의 값이 null이 되어 두 번째 오류를 발생시키기 때문입니다. 이는 이상적이지 않습니다. 오류가 발생했음을 인식하고 나머지 GraphQL 쿼리 실행을 건너뛰는 것이 더 바람직합니다.

이 튜토리얼 레슨에서는 이를 달성하는 방법을 살펴보겠습니다.

REST API 연결 시 오류 처리하기

이 GraphQL 쿼리는 로직을 두 개의 오퍼레이션으로 나눕니다:

  • 첫 번째 오퍼레이션은 동적 변수 $requestProducedErrors를 내보내며, 필드 _sendJSONObjectItemHTTPRequest의 값이 null인지(즉, 어떤 오류가 발생했는지) 여부를 나타냅니다
  • 두 번째 오퍼레이션은 $requestProducedErrorstrue일 때 @skip됩니다

이렇게 하면 실행할 로직을 포함하는 두 번째 오퍼레이션은 첫 번째 오퍼레이션에서 데이터 가져오기 중 오류가 발생했을 때 건너뛰어집니다:

query ConnectToRESTEndpoint($postId: ID!) {
  endpoint: _sprintf(
    string: "https://newapi.getpop.org/wp-json/wp/v2/posts/%s/?_fields=id,type,title,date"
    values: [$postId]
  ) @remove
  
  externalData: _sendJSONObjectItemHTTPRequest(
    input: {
      url: $__endpoint
    }
  ) @export(as: "externalData")
 
  requestProducedErrors: _isNull(value: $__externalData)
    @export(as: "requestProducedErrors")
    @remove
}
 
query ExecuteOperation
  @depends(on: "ConnectToRESTEndpoint")
  @skip(if: $requestProducedErrors)
{
  # Do something...
  postTitle: _objectProperty(
    object: $externalData,
    by: { path: "title.rendered"}
  )
}

$postId: 1을 전달하면 쿼리가 성공하고 응답은 다음과 같습니다:

{
  "data": {
    "externalData": {
      "id": 1,
      "date": "2019-08-02T07:53:57",
      "type": "post",
      "title": {
        "rendered": "Hello world!"
      }
    },
    "postTitle": "Hello world!"
  }
}

존재하지 않는 리소스에 대해 $postId: 8888을 전달하면 다음 응답을 받습니다(응답에 postTitle이 없고 두 번째 오류 메시지도 없음을 확인하세요):

{
  "errors": [
    {
      "message": "Client error: `GET https://newapi.getpop.org/wp-json/wp/v2/posts/8888/?_fields=id,type,title,date` resulted in a `404 Not Found` response:\n{\"code\":\"rest_post_invalid_id\",\"message\":\"Invalid post ID.\",\"data\":{\"status\":404}}\n",
      "locations": [
        {
          "line": 6,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendJSONObjectItemHTTPRequest(input: {url: $__endpoint}) @export(as: \"externalData\")",
          "query ConnectToRESTEndpoint($postId: ID!) { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendJSONObjectItemHTTPRequest(input: {url: $__endpoint}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    }
  ],
  "data": {
    "externalData": null
  }
}

인터넷 연결이 끊어진 경우 다음 응답을 받습니다:

{
  "errors": [
    {
      "message": "cURL error 6: Could not resolve host: newapi.getpop.org (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://newapi.getpop.org/wp-json/wp/v2/posts/8888/?_fields=id,type,title,date",
      "locations": [
        {
          "line": 17,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendHTTPRequest(input: {url: $__endpoint, method: GET}) { ... }",
          "query ConnectToAPI($postId: ID!) @depends(on: \"ExportDefaultDynamicVariables\") { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendHTTPRequest(input: {url: $__endpoint, method: GET}) { ... }",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    }
  ],
  "data": {
    "externalData": null
  }
}

REST API 응답에서 오류 메시지 표시하기

이전 쿼리는 필드 _sendJSONObjectItemHTTPRequest를 사용하며, 상태 코드가 200(또는 다른 성공 코드)이길 기대합니다.

그러나 REST API가 존재하지 않는 리소스에 대해 404를 반환하고 JSON 응답에 설명적인 오류 메시지를 제공하는 경우가 있습니다.

_sendJSONObjectItemHTTPRequest_sendHTTPRequest로 교체하면 웹 서버에서 이 피드백을 캡처하고 GraphQL 응답의 errors 항목에 표시할 수 있습니다.

예를 들어, WP REST API에서 존재하지 않는 리소스의 데이터를 가져오면 응답에 data.status 항목과 관련 데이터가 반환됩니다.

이 GraphQL 쿼리는 이 데이터를 캡처하고, Response Error Trigger 익스텐션이 제공하는 필드 _fail을 사용하여 응답의 오류 코드와 메시지를 포함하는 오류 항목을 명시적으로 추가합니다:

query ExportDefaultDynamicVariables
  @configureWarningsOnExportingDuplicateVariable(enabled: false)
{
  defaultEndpointHasErrors: _echo(value: true)
    @export(as: "endpointHasErrors")
    @remove
}
 
query ConnectToAPI($postId: ID!)
  @depends(on: "ExportDefaultDynamicVariables")
{
  endpoint: _sprintf(
    string: "https://newapi.getpop.org/wp-json/wp/v2/posts/%s/?_fields=id,type,title,date"
    values: [$postId]
  ) @remove
  
  externalData: _sendHTTPRequest(
    input: {
      url: $__endpoint,
      method: GET
    }
  ) {    
    contentType
    statusCode
    body @remove
    bodyJSONObject: _strDecodeJSONObject(string: $__body)
      @export(as: "externalData")
  }
 
  isNullExternalData: _isNull(value: $__externalData)
    @export(as: "isNullExternalData")
    @remove
}
 
query ValidateAPIResponse
  @depends(on: "ConnectToAPI")
  @skip(if: $isNullExternalData)
{
  endpointHasErrors: _propertyIsSetInJSONObject(
    object: $externalData
    by: {
      path: "data.status"
    }
  )
    @export(as: "endpointHasErrors")
    @remove
}
 
query FailIfExternalAPIHasErrors($postId: ID!)
  @depends(on: "ValidateAPIResponse")
  @include(if: $endpointHasErrors)
  @skip(if: $isNullExternalData)
{
  code: _objectProperty(
    object: $externalData,
    by: {
      key: "code"
    }
  ) @remove
  message: _objectProperty(
    object: $externalData,
    by: {
      key: "message"
    }
  ) @remove
  errorMessage: _sprintf(
    string: "[%s] %s",
    values: [$__code, $__message]
  ) @remove
  data: _objectProperty(
    object: $externalData,
    by: {
      key: "data"
    }
  ) @remove
  _fail(
    message: $__errorMessage
    data: {
      postId: $postId,
      endpointData: $__data
    }
  ) @remove
}
 
query ExecuteSomeOperation
  @depends(on: "FailIfExternalAPIHasErrors")
  @skip(if: $endpointHasErrors)
{
  # Do something...
  postTitle: _objectProperty(
    object: $externalData,
    by: { path: "title.rendered"}
  )
}

Response Error Trigger 익스텐션은 errors 아래에 사용자 정의 항목을 추가하는 두 가지 방법을 제공합니다:

  • 필드 _fail 사용
  • 디렉티브 @fail 사용

필드 _fail은 항상 오류를 추가하지만, 디렉티브 @fail은 인수 condition 아래의 조건이 충족될 때만 추가합니다. 기본값은 IS_NULL로, 적용된 필드의 값이 null일 때 트리거됩니다:

query GetPost($id: ID!) {
  post(by:{id: $id})
    @fail(
      message: "There is no post with the provided ID"
      data: {
        id: $id
      }
    )
  {
    id
    title
  }
}

변수 $postId: 1로 쿼리를 실행하면 요청이 성공하고 다음 결과를 얻습니다:

{
  "data": {
    "externalData": {
      "contentType": "application/json; charset=UTF-8",
      "statusCode": 200,
      "bodyJSONObject": {
        "id": 1,
        "date": "2019-08-02T07:53:57",
        "type": "post",
        "title": {
          "rendered": "Hello world!"
        }
      }
    },
    "postTitle": "Hello world!"
  }
}

변수 $postId: 8888로 쿼리를 실행하면 리소스가 없으며 다음 결과를 얻습니다:

{
  "errors": [
    {
      "message": "[rest_post_invalid_id] Invalid post ID.",
      "locations": [
        {
          "line": 76,
          "column": 3
        }
      ],
      "extensions": {
        "path": [
          "_fail(message: $__errorMessage, data: {postId: $postId, endpointData: $__data}) @remove",
          "query FailIfExternalAPIHasErrors($postId: ID!) @depends(on: \"ValidateAPIResponse\") @include(if: $endpointHasErrors) @skip(if: $isNullExternalData) { ... }"
        ],
        "type": "QueryRoot",
        "field": "_fail(message: $__errorMessage, data: {postId: $postId, endpointData: $__data}) @remove",
        "id": "root",
        "failureData": {
          "postId": 8888,
          "endpointData": {
            "status": 404
          }
        },
        "code": "PoPSchema/FailFieldAndDirective@e1"
      }
    }
  ],
  "data": {
    "externalData": {
      "contentType": "application/json; charset=UTF-8",
      "statusCode": 404,
      "bodyJSONObject": {
        "code": "rest_post_invalid_id",
        "message": "Invalid post ID.",
        "data": {
          "status": 404
        }
      }
    }
  }
}

GraphQL API 연결 시 오류 처리하기

GraphQL API에서 존재하지 않는 리소스를 쿼리하면 응답의 상태 코드는 200이 되고 해당 리소스의 값은 null이 됩니다(404를 반환하는 REST와는 다릅니다).

아래 GraphQL은 다음을 확인하여 _sendGraphQLHTTPRequest 실행 시 오류가 발생하지 않았음을 검증합니다:

  • 응답이 null이 아닌 경우(예: 인터넷 연결이 끊기지 않은 경우)
  • 응답에 errors 항목이 포함되지 않은 경우
  • 응답의 data.post 항목 아래에 null이 아닌 값이 포함된 경우(즉, 쿼리한 리소스가 존재하는 경우)
query InitializeDynamicVariables
  @configureWarningsOnExportingDuplicateVariable(enabled: false)
{
  defaultResponseHasErrors: _echo(value: false)
    @export(as: "responseHasErrors")
    @remove
  defaultPostIsMissing: _echo(value: false)
    @export(as: "postIsMissing")
    @remove
}
 
query ConnectToGraphQLAPI($postId: ID!)
  @depends(on: "InitializeDynamicVariables")
{
  externalData: _sendGraphQLHTTPRequest(
    input: {
      endpoint: "https://newapi.getpop.org/api/graphql/",
      query: """
        query GetPostData($postId: ID!) {
          post(by: { id : $postId }) {
            date
            title
          }
        }
      """,
      variables: [
        {
          name: "postId",
          value: $postId
        }
      ]
    }
  ) @export(as: "externalData")
 
  requestProducedErrors: _isNull(value: $__externalData)
    @export(as: "requestProducedErrors")
    @remove
}
 
query ValidateResponse
  @depends(on: "ConnectToGraphQLAPI")
  @skip(if: $requestProducedErrors)
{
  responseHasErrors: _propertyIsSetInJSONObject(
    object: $externalData
    by: {
      key: "errors"
    }
  )
    @export(as: "responseHasErrors")
    @remove
 
  postExists: _propertyIsSetInJSONObject(
    object: $externalData
    by: {
      path: "data.post"
    }
  )
    @remove
    
  postIsMissing: _not(value: $__postExists)
    @export(as: "postIsMissing")
    @remove
}
 
query FailIfResponseHasErrors
  @depends(on: "ValidateResponse")
  @skip(if: $requestProducedErrors)
  @skip(if: $postIsMissing)
  @include(if: $responseHasErrors)
{
  errors: _objectProperty(
    object: $externalData,
    by: {
      key: "errors"
    }
  ) @remove
 
  _fail(
    message: "Executing the GraphQL query produced error(s)"
    data: {
      errors: $__errors
    }
  ) @remove
}
 
query ExecuteOperation
  @depends(on: "FailIfResponseHasErrors")
  @skip(if: $requestProducedErrors)
  @skip(if: $responseHasErrors)
  @skip(if: $postIsMissing)
{
  # Do something...
  postTitle: _objectProperty(
    object: $externalData,
    by: { path: "data.post.title" }
  )
}

$postId: 1을 전달하면 쿼리가 성공하고 응답은 다음과 같습니다:

{
  "data": {
    "externalData": {
      "data": {
        "post": {
          "date": "2019-08-02T07:53:57+00:00",
          "title": "Hello world!"
        }
      }
    },
    "postTitle": "Hello world!"
  }
}

존재하지 않는 리소스에 대해 $postId: 8888을 전달하면 다음 응답을 받습니다(응답에 postTitle이 없고 오류 메시지도 없음을 확인하세요):

{
  "data": {
    "externalData": {
      "data": {
        "post": null
      }
    }
  }
}

인터넷 연결이 끊어진 경우 다음 응답을 받습니다:

{
  "errors": [
    {
      "message": "cURL error 6: Could not resolve host: newapi.getpop.org (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://newapi.getpop.org/api/graphql/",
      "locations": [
        {
          "line": 15,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendGraphQLHTTPRequest(input: {endpoint: \"https://newapi.getpop.org/api/graphql/\", query: \"\n        query GetPostData($postId: ID!) {\n          post(by: { id : $postId }) {\n            date\n            title\n          }\n        }\n      \", variables: [{name: \"postId\", value: $postId}]}) @export(as: \"externalData\")",
          "query ConnectToGraphQLAPI($postId: ID!) @depends(on: \"InitializeDynamicVariables\") { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendGraphQLHTTPRequest(input: {endpoint: \"https://newapi.getpop.org/api/graphql/\", query: \"\n        query GetPostData($postId: ID!) {\n          post(by: { id : $postId }) {\n            date\n            title\n          }\n        }\n      \", variables: [{name: \"postId\", value: $postId}]}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    }
  ],
  "data": {
    "externalData": null
  }
}

요청한 리소스가 존재하지 않는 경우 오류 생성하기

위의 GraphQL 쿼리에서 쿼리한 포스트가 존재하지 않으면 null만 반환되고 errors 아래에 오류 항목이 없습니다.

해당 상황에서 강제로 오류를 추가하려면 필드 _fail을 사용하여 오류를 트리거하는 다음 오퍼레이션을 추가할 수 있습니다:

query FailIfPostNotExists($postId: ID!)
  @skip(if: $requestProducedErrors)
  @include(if: $postIsMissing)
  @depends(on: "ValidateResponse")
{
  errorMessage: _sprintf(
    string: "There is no post with ID '%s'",
    values: [$postId]
  ) @remove
  _fail(
    message: $__errorMessage
    data: {
      id: $postId
    }
  ) @remove
}
 
query ExecuteOperation
  @depends(on: [
    "FailIfResponseHasErrors",
    "FailIfPostNotExists"
  ])
  # ...
{
  # ...
}

이제 존재하지 않는 리소스에 대해 $postId: 8888을 전달하면 다음 응답을 받습니다:

{
  "errors": [
    {
      "message": "There is no post with ID '8888'",
      "locations": [
        {
          "line": 96,
          "column": 3
        }
      ],
      "extensions": {
        "path": [
          "_fail(message: $__errorMessage, data: {id: $postId}) @remove",
          "query FailIfPostNotExists($postId: ID!) @skip(if: $requestProducedErrors) @include(if: $postIsMissing) @depends(on: \"ValidateResponse\") { ... }"
        ],
        "type": "QueryRoot",
        "field": "_fail(message: $__errorMessage, data: {id: $postId}) @remove",
        "id": "root",
        "failureData": {
          "id": 8888
        },
        "code": "PoPSchema/FailFieldAndDirective@e1"
      }
    }
  ],
  "data": {
    "externalData": {
      "data": {
        "post": null
      }
    }
  }
}