ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • GraphQL에 대하여 ( Resolver & Dataloader / Aliases & Fragment)
    Graphql 2023. 6. 3. 12:11

    2023.05.07 - [Graphql] - GraphQL에 대하여 (Interface & Union Type)

     

    GraphQL에 대하여 (Interface & Union Type)

    2023.04.23 - [Graphql] - GraphQL (Schema & Type) 에 이어서 interface & union type에 대해 설명하고자 한다. Interface 다른 많은 타입 시스템처럼 GraphQL은 interface를 지원한다. 인터페이스는 Type이 무엇 인가를 구현

    zzihyeon.tistory.com

    에 이어서 포스팅 하고자 한다.

    Resolver

    GraphQL에서 Resolver는 쿼리의 각 필드에 대한 데이터를 제공하는 함수이다.
    Resolver는 GraphQL 서버에서 데이터를 가져오는 데 사용되며, 클라이언트가 요청한 필드의 값을 반환한다.
    Resolver는 GraphQL Schema의 field와 연결된다.
    각 field에는 해당 필드의 값을 검색하고 반환하는 Resolver 함수가 있어야 한다. (기본 함수는 받은 값을 그대로 return하는 함수이다.)
    Resolver 함수는 데이터베이스, 외부 API 호출, 메모리 캐시, 또는 다른 소스로부터 데이터를 가져올 수 있다.
    GraphQL 스키마는 타입과 필드의 구조를 정의하는데, 이를 기반으로 Resolver는 어떤 데이터를 반환해야 하는지 결정한다.
    Resolver는 필드의 값을 동적으로 계산하거나 적절한 데이터 소스로부터 데이터를 검색하여 반환할 수 있다.

    글로만 작성하면 이해가 어려울 수 있으니 간단히 예를 들어 보겠다. 지난 글에서 사용한 SellerUser Schema를 사용하도록 하겠다.
    우선 아래와 같이 allusers를 호출한다고 가정해보자.

    query{
      allusers{
        name
       ... on SellerUser {
          stuff{
            id
            name
            total
          }
          bizInfo{
            biz_num
            biz_img
          }
        }
      }
    
    }

    field로는 name, stuff, stuff.id, stuff.name, stuff.total, bizInfo, bizInfo.biz_num, bizInfo.biz_img 총 8개가 있고 8번의 resolver가 호출된다. 따로 함수를 작성하지 않으면 그냥 field의 값을 그대로 return한다.

    그럼 이 resolver는 왜 사용해야 할까?

    type SellerUser implements UserInterface {
      uid: Int!
      email: String!
      name: String!
      phone: String!
      gender: gender!
      birth_date: Time!
      created_at: Time!
      updated_at: Time!
      bizInfo: [BizInfo!]!
      stuff: [Stuff!]!
    }

    만약 RDB를 사용했다고 가정하면 여기서 UserInterface에서 가져오는 field들은 하나의 table에 저장되어 있을 가능성이 높다. 즉, 아래의 field들은 data join 없이도 하나의 table에서 가져올 수 있다.

      uid: Int!
      email: String!
      name: String!
      phone: String!
      gender: gender!
      birth_date: Time!
      created_at: Time!
      updated_at: Time!

    하지만 bizInfo와 stuff는 다른 table에 저장되어 있을 가능성이 높다. 이를 가져오기 위해서는 table을 join해서 가져와야 한다.
    SellerUser를 조회할 때마다 bizInfo, stuff 정보를 join해서 가져오면 맨 처음에 말했던 graphQL의 장점인 overfetching을 하지 않는다를 지킬 수 없게 된다.
    이 때, resolver를 통해 사용자가 field를 요청할 때만 해당 data를 조회해 오도록 할 수 있다.

    만약 그렇게 변경한다면 위의 sellerUser에서는 field마다 resolver가 아래와 같이 동작할 것이다.

    func (r *sellerUserResolver) uid(ctx context.Context, obj *model.SellerUser) (string, error) {
        return obj.Uid, error;
    }
    
    /*
    ... bizInfo, stuff를 제외하고는 생략되어 있으며 resolver에서 그냥 자신의 값을 return한다.
    */
    
    func (r *sellerUserResolver) BizInfo(ctx context.Context, obj *model.SellerUser) ([]*model.BizInfo, error) {
        // bizInfo table 조회
    }
    
    func (r *sellerUserResolver) Stuff(ctx context.Context, obj *model.SellerUser) ([]*model.Stuff, error) {
        // Stuff table 조회
    }

    이렇게 변경하면 overfetching은 막을 수 있지만 user조회 시 DB를 총 3번 조회해야 하는 문제가 생길 수 있다.
    따라서, 전략적으로 잘 활용해야 한다.

    DataLoader

    Resolver를 사용할 때 N+1이라는 문제가 발생하게 된다.
    N+1 문제란 요청은 한번만 했지만 Resolver에 의해 N번의 트랜젝션이 발생할 수 있는 것이다.

    예를 들면, user, bizInfo, stuff 이 3개의 table이 있다고 가정해보자.
    이 상황에서 alluser query를 호출하면 User정보를 위해 DB 조회 1회, user가 N명 있다고 가정하면 user 별 bizInfo조회를 위한 DB 조회 N회, user별 Stuff조회를 N회 해야 한다.
    아래와 같이 1회의 query요청으로 1+N+N회의 조회를 해야 한다.

    SellerUser {
      data: [
          {
              uid: 1,
              name: "a",
              bizInfo: [...], //resolver 실행 1회
              stuff: [...], //resolver 실행 1회
          },
          {
              uid: 2,
              name: "b",
              bizInfo: [...], //resolver 실행 1회
              stuff: [...], //resolver 실행 1회
          },
          ...
          {
              uid: N,
              name: "c",
              bizInfo: [...], //resolver 실행 1회
              stuff: [...], //resolver 실행 1회
          }
      ]
    }

    이는 N이 커지면 커질수록 DB에 과부화를 준다. 이 때문에 이를 해결하기 위해 DataLoader가 개발되었다.
    위의 예시에서 dataLoader는 user의 uid를 key 값으로 사용하고 이 uid를 배열로 모아서 각각 1번의 트랜잭션 Or 연산으로 bizInfo와 stuff를 찾을 것이다

    DataLoader의 동작 방식은 아주 간단하고 아래와 같다:
    dataloader github

    1. Batch func을 선언한다.
    2. middleware에서 context에 해당 batch func을 삽입한다.
    3. 필요한 resolver에서 dataLoader의 값을 Load해온다.

    이 때, 주의할 점은 dataLoader에 요청한 키 값의 길이와 데이터를 조회한 결과 값의 길이는 반드시 같아야 한다.
    즉, 위에 예시를 보면 조회 시 keys는 uid의 배열 [1,2,...,n]으로 길이가 n인 배열일 것이다.
    실제로 db에 조회를 하면 SELECT * FROM bizInfos WHERE user_id = ANY([1,2,...n])과 같이 조회를 할 것이다.
    이 때 bizInfo 값이 존재하지 않아 null 이더라도 [bizInfo1, null, ...., bizInfoN] 과 같이 null로 배열을 채워 길이를 맞춰야 한다.

    Aliases

    schema의 경우는 이전 포스팅에서 사용하던 정보를 그대로 사용하겠다. 만약, 우리가 아래와 같은 화면이 필요하다고 가정해보자.

    image.png

    세개의 component 모두 user에 대한 data를 필요로 한다. 이 때 동일한 query를 사용하며 argument만 다르게 사용할 가능성이 높다. 그렇다면 이를 어떻게 처리해야 할까?
    총 3가지의 user에 대한 정보를 얻기 위해서 아래와 같이 사용한다고 가정해 보자.

    query {
        allUser(input: { sortBy: PURCHASE_COUNT, limit: 10}) {
            name
        }
        allUser(input: { sortBy: TOTAL_PRICE, limit: 10}) {
            name
        }
        allUser(input: { sortBy: INVITE_FRIEND, limit: 10}) {
            name
        }
    }

    이렇게 사용하면 서버는 data에 allUser라는 같은 field에 3개의 data를 return할 수 없기 때문에 에러가 발생할 것이다.

    그럼 이는 어떻게 처리할 수 있을까?
    이를 위해서 graphql은 aliases를 지원한다. aliases를 사용하면 위의 query를 아래와 같이 변경할 수 있다.

    query {
        BestPurchaseUser: allUser(input: { sortBy: PURCHASE_COUNT, limit: 10}) {
            name
        }
        BestPriceUser: allUser(input: { sortBy: TOTAL_PRICE, limit: 10}) {
            name
        }
        BestInviteFriendUser: allUser(input: { sortBy: INVITE_FRIEND, limit: 10}) {
            name
        }
    }

    이에 대해서 서버는 아래와 같이 return 해 준다.

    {
        data: {
            BestPurchaseUser:[
            {
                name: "zzihyeon"
            },
            ...
            ]
            BestPriceUser: [
            {
                name: "zzihyeon2"
            },
            ...
            ]
            BestInviteFriendUser: [
            {
                name: "zzihyeon3"
            },
            ...
            ]
        }
    }

    이제 우리는 하나의 쿼리를 이용하여 여러개의 요청을 한번에 할 수 있게 되었다.

    Fragments

    graphQL을 사용하다보면 component에 따라 field들을 다르게 사용할 경우가 많다. 하지만 GraphQL에서는 query / mutation을 요청할 때 field를 지정하기 때문에 이는 중복 작업이고 불편할 수 있다. 바로 위에서 사용한 예를 들어보자.
    아래와 같은 요청을 필요로 한다고 가정하자.

    query {
        BestPurchaseUser: allUser(input: { sortBy: PURCHASE_COUNT, limit: 10}) {
            name
            email
            gender
            purchases{
                id
                amount
            }
        }
        BestPriceUser: allUser(input: { sortBy: TOTAL_PRICE, limit: 10}) {
            name
            email
            gender
            purchases{
                id
                amount
            }
        }
        BestInviteFriendUser: allUser(input: { sortBy: INVITE_FRIEND, limit: 10}) {
            name
            email
            gender
            purchases{
                id
                amount
            }
            friend{
                name
            }
        }
    }

    쓸데없이 너무 길어보이고 중복이 많다. 이를 해결하기 위해 fragment를 사용한다.

    query {
        BestPurchaseUser: allUser(input: { sortBy: PURCHASE_COUNT, limit: 10}) {
            ...userFields
        }
        BestPriceUser: allUser(input: { sortBy: TOTAL_PRICE, limit: 10}) {
            ...userFields
        }
        BestInviteFriendUser: allUser(input: { sortBy: INVITE_FRIEND, limit: 10}) {
            ...userFields
            friend{
                name
            }
        }
    }
    
    fragment userFields on User {
        name
        email
        gender
        purchases{
            id
            amount
        }}

    위 처럼 fragment를 사용해서 query field조각으로 관리하면 한결 편안함을 느낄 수 있다.

    그럼 fragment는 이렇게 중복 방지를 위해서만 사용되는 것일까?
    fragment라는 개념은 복잡한 애플리케이션 데이터 요구 사항을 더 작은 덩어리로 분할하는 데 자주 사용되고 서로 다른 fragment을 가진 많은 UI 구성 요소를 하나의 초기 데이터 가져오기로 결합해야 할 때 더욱 유용하다.
    예를 들어 아래와 같은 화면이 있다고 가정해 보자.

    image.png


    구성하기에 따라서 위의 user 셋은 userField라는 동일한 fragment를 사용할 것이고 아래의 둘은 stuffField라는 동일한 fragment를 사용할 것이고 맨 오른쪽의 광고 상품은 광고 상품을 위한 특정 정보가 있을 것이기 때문에 stuffAdField라는 fragment를 사용할 것이다. 이를 query로 요청한다고 가정해 보자.

    query {
       BestPurchaseUser: allUser(input: { sortBy: PURCHASE_COUNT, limit: 10}) {
           ...userFields
       }
       BestPriceUser: allUser(input: { sortBy: TOTAL_PRICE, limit: 10}) {
           ...userFields
       }
       BestInviteFriendUser: allUser(input: { sortBy: INVITE_FRIEND, limit: 10}) {
           ...userFields
           friend{
               name
           }
       }
       BestSellerStuff: allStuff(input: { sortBy: Sell, limit: 10}) {
           ...stuffFields
       }
       BestPriceAmountStuff: allStuff(input: { sortBy: PriceAmount, limit: 10}) {
           ...stuffFields
       }
       AdStuff: allStuff(input: {isAd: true, sortBy: adPriority, limit: 10}) {
           ...adStuffFields
       }
    }
    
    fragment userFields on User {
       name
       email
       gender
       purchases{
           id
           amount
       }
    }
    fragment stuffFields on Stuff {
       name
       kind
       price
    }
    fragment adStuffFields on Stuff {
       name
       kind
       price
       ad_type
       ad_time
    }

    여기서 각각의 fragment들은 component에 대한 의존성을 갖기 때문에 설계 및 관리를 더욱 편하게 할 수 있다.

    'Graphql' 카테고리의 다른 글

    GraphQL에 대하여 (Interface & Union Type)  (0) 2023.05.07
    GraphQL (Schema & Type)  (0) 2023.04.23
    GraphQL이란  (0) 2023.04.16
    DataLoader  (0) 2022.10.09

    댓글

Designed by Tistory.