GraphQL Interface 자동생성으로 빠르게 프론트엔드 개발하기

Fitpet Developer
36 min readJun 13, 2022

들어가며

React와 Typescript를 사용하는 프론트엔드 환경에서는 API 통신을 받아온 결과값을 interface로 정의하여 각 변수마다 타입을 할당하게 됩니다. 이를 통해 타입 추론이 용이해지고 에러도 방지할 수 있습니다. 그러나 개발 초기에는 API의 변경이 수시로 일어나게 되는데 이는 이미 만들어진 interface의 잦은 변경을 야기하게 됩니다.

본 글에서는 GraphQL, React와 Typescript를 사용하는 프론트엔드 환경에서 자주 변경이 일어나지만 개발에 도움이 되는 interface를 자동으로 생성하여 개발을 빠르고 효과적으로 할 수 있는 방법을 알아보고자 합니다. 또한 GraphQL의 스키마 속성을 분석하여 원하는 형태의 class나 로직을 자유롭게 만들 수 있도록 커스터마이징하는 방법도 알아보겠습니다. 저희 핏펫의 커머스 플랫폼에 적용되어 있으며 저희는 백엔드가 Django로 구현이 되어 있지만 graphql.schema의 json 형태가 동일하므로 다른 언어에도 적용하기는 어렵지 않으리라 생각됩니다.

GraphQL

핏펫의 커머스 플랫폼은 대부분의 API 통신이 GraphQL로 이루어지고 있습니다. GraphQL은 REST를 완전히 대체하여 사용될 수 있는 SQL(Structured Query Language)과 같은 쿼리형 언어입니다. 하지만 SQL이 데이터베이스의 데이터를 효율적으로 가져오는 것이 목적인 반면, GraphQL은 태생적으로 클라이언트가 서버로부터 데이터를 효율적으로 가져오는 것이 목적입니다. 따라서 백엔드에서 생성한 스키마에 맞추어 프론트엔드에서 쿼리를 작성하고 호출합니다.

# SQL Query
select pp.id, pp.name, ppo.name from products_product pp left join products_productoption ppo on pp.id = ppo.product_id
# GraphQL Query
{
id
name
productOptions {
name
}
}

GrpahQL in Django

또한 핏펫의 커머스 플랫폼 백엔드는 현재 Python 기반 Django로 구현이 되어 있는데 Graphene-Django는 GraphQL의 기능을 쉽게 구현하고 사용할 수 있도록 도와줍니다. 설치 및 자세한 셋팅에 대한 방법은 Graphene-Django에 쉽게 잘 설명되어 있으니 넘어가도록 하고 간단한 개념만 소개하겠습니다.

graphene.Schema에서 GraphQL로 통신할 수 있도록 CRUD에 해당하는 Query와 Mutation을 등록합니다. 여기서 Query는 CRUD의 R에 해당되며 Mutation은 CUD에 해당됩니다.

class Query(ProductQueries, ...):
pass
class Mutation(ProductMutations, ...):
pass
schema = graphene.Schema(query=Query, mutation=Mutation)

물론 Query를 통해서도 서버의 데이터를 수정할 수 있으나 REST와 마찬가지로 명시적으로는 Mutation을 통해서 데이터를 전송하는 규칙을 정하는 것이 좋습니다.

class ProductQueries(graphene.ObjectType):
product = graphene.Field(ProductType, id=graphene.ID(required=True), product_promotion=graphene.ID())
products = ProductConnectionField(ProductType)
def resolve_product(self, info, *args, **kwargs):
...
def resolve_products(self, info, *args, **kwargs):
...
class ProductMutations(graphene.ObjectType):
create_product = CreateProductMutation.Field()

위의 Query product, products에 공통적으로 사용되는 ProductType은 아래 단락에서 예시를 보여드리기로 하겠습니다. create_product를 위한 CreateProductMutation은 아래와 같은 형태를 띕니다. ProductInput은 클라이언트가 보내는 payload이고 결과로 product를 return하게 됩니다.

class ProductInput(graphene.InputObjectType):
name = graphene.String(description=get_description(ProductOption, 'name'))
...
class CreateProductMutation(graphene.Mutation):
product = graphene.Field(ProductType)
class Arguments:
input = ProductInput(required=True)
class Meta:
description = '상품 등록'
model = Product

@classmethod
def mutate(cls, root, info, text, id):
...
return CreateProductMutation(product=product)

프론트 모델 Interface 생성을 위한 준비

우리가 정의한 query와 mutation의 모든 정의가 정리되어 있는 스키마를 아주 쉽게 만들 수 있습니다. 순차적으로 가벼운 예시와 함께 한번 따라가보겠습니다.

먼저 Django에서 Product model과 1:N으로 연결된 ProductImage model을 정의해보겠습니다. 많은 필드들이 많겠지만 설명을 목적으로 몇가지 필드만 간추려서 표현합니다.

class Product(models.Model):
display_order = models.IntegerField(default=1, help_text='디스플레이 순서')
created_at = models.DateTimeField(auto_now_add=True, help_text='생성일', null=True, blank=True)
review_group = models.ForeignKey(
'boards.ReviewGroup', related_name='review_group_products',
on_delete=models.SET_NULL, null=True,
blank=True, help_text='리뷰 그룹'
)
...
class ProductImage(models.Model):
product = models.ForeignKey(
'products.Product', related_name='product_product_images',
on_delete=models.SET_NULL, null=True,
blank=True, help_text='상품'
)
...

그리고 위 모델을 graphene python에서 Query로 활용하기 위해 Type으로 정의합니다. ProductTypeproduct 모델을 참조하는데 이렇게 django의 모델을 참조할 경우 대부분의 field는 자동으로 선언되어 중복으로 선언할 필요가 없으며 따로 추가하고 싶은 field는 resolve_() 함수를 사용하여 선언하게 됩니다. 이 부분은 본 블로그의 내용을 벗어나므로 여기까지만 설명하도록 하겠습니다.

class ProductType(DjangoObjectType):
class Meta:
model = Product
filterset_class = ProductFilter // django_filter 연동
interfaces = (relay.Node,)
connection_class = BaseConnection // edge를 사용하고자할 때 connection class 정의
fields = "__all__"
// display_order -> django model을 상속하여 기본값으로 쓸 경우 Type에서 선언하지 않아도 됩니다
// created_at, review_group -> created_at과 동일합니다
// 1:N으로 연결된 ProductImage도 related_name인 product_product_images로 자동으로 연결됩니다

위와 같은 모델 설계 및 구현은 백엔드단에서 이미 진행되어 있을 것입니다. 그리고 위 Type 정의를 통해 프론트엔드에서 활용할 수 있는 풍부한 정보를 자동으로 제공할 수 있게 됩니다. 모델 Interface 생성을 위해 우리가 관심을 가져야 할 정보는 사용하게 될 Type과 Type 안에 들어있는 field들의 이름, 속성입니다. 이 정보들이 프론트가 백엔드의 정의에 따라서 가져야 할 모델 Interface를 비롯해 모델 class 등을 니즈에 맞게 auto generation할 수 도록 도와줍니다.

또한 Type이 Query인지 Mutation의 여부는 크게 상관이 없습니다. 다만 차이점은 Query의 경우 호출한 정보를 정의하는 것, Mutation의 경우 request할 payload와 액션을 실행한 후 받는 return 값이 필드 내에서 정의되는 것입니다.

프론트엔드에서의 활용방법을 찾아보기 전에 백엔드에서 프론트엔드에서 활용할 사전이라 할 수 있는 schema.graphql을 생성하도록 합니다.

python manage.py graphql_schema--type ProductType implements Node {
displayOrder: Int!
createdAt: DateTime
id: ID!
reviewGroup: ReviewGroupType
productProductImages(offset: Int, before: String, after: String, first: Int, last: Int): ProductImageTypeConnection!
...
}

위 결과로 graphql.graphql 파일이 생성되며 아래 명령어를 통해 json 형태로도 추출 할 수 있습니다. 위의 파일은 apollo client를 이용한 frontend GraphQL 관련된 query, mutation의 정합성 체크와 interface 파일 자동생성을 할 수 있으며 아래 json 파일로는 좀 더 입맛에 맞게 자유자재로 원하는 파일을 원하는 위치에 생성할 수 있도록 도와주게 됩니다.

python manage.py graphql_schema --schema fitpet.schema.schema --out {SCHEMA_FILE_NAME}# json 형태 결과값은 아래에 다시 설명합니다.

아래에서 이미 알려진 Apollo client tool을 활용하여 Typescript 용 인터페이스를 auto generation하는 방법부터 알아보겠습니다.

Apollo Client Codegen

아래의 명령어로 apollo client를 설치하고 버전을 확인합니다.

npm install -g apollo
apollo -v

프로젝트 root에 apollo.config.js 파일을 만들고 아래와 같이 설정합니다.

module.exports = {
client: {
includes: [
'./src/containers/gqls/*.ts', // 우리가 사용할 gql 문법들이 위치한 곳을 명시합니다.
'./src/containers/gqls/*/*.ts',
],
excludes: [
'**/__tests__/**/*',
],
service: {
name: 'service-name',
endpoint: '<http://127.0.0.1:8000/graphql>', // graphql type이 정의된 로컬 서버 주소입니다.
localSchemaFile: './schema.graphql', // graphql을 백엔드에서 생성해 직접 받아왔다면 이렇게 파일이 위치한 경로를 적어줍니다. endpoint를 설정하지 않고 localSchemaFile 위치를 설정해도 됩니다.
},
}
};

apollo-tooling 프로젝트에서 위의 설정값을 비롯해 에서 다른 기능들과 자세한 내용을 확인할 수 있습니다.

이전에는 아래 명령어를 통해 백엔드로부터 GraphQL schema를 가져올 수 있었으나 현재는 deprecated되었습니다.

apollo service:download

우리는 백엔드에서 생성한 schema.graphql을 프론트엔드에서 복사합니다. 그리고 apollo client의 기능 중에 아래 명령어를 실행하면 2가지 유의미한 액션을 하게 됩니다.

apollo client:codegen --target=typescript --outputFlat src/containers/gqls/generated
  1. GraphQL을 이미 사용하고 있었다면 설정에서 includes한 위치에서 그 내용을 참조해 유효성 검사를 하게 됩니다. 만약 중복된 query나 mutation을 사용했거나 존재하지 않는 field를 선언한 경우 에러를 내게 됩니다.
  2. 에러가 없이 통과를 하였다면 위에 지정한 위치에 자동으로 typescript로 사용할 수 있는 파일을 선언하여 생성합니다. ProductType의 예시는 아래와 같습니다.
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { ProductProductOptionType } from "./globalTypes";// ====================================================
// GraphQL fragment: product
// ====================================================
export interface product {
__typename: "ProductType";
id: string;
displayOrder: number;
createdAt: any;
reviewGroup: any | null;
productProductImages: any | null;
...
}

위 파일은 항상 지정된 위치에 생성되므로 우리는 Product 모델의 interface와 그 내부에 있는 field를 매번 선언할 필요없이 바로 사용할 수 있게 됩니다.

mutation의 생성예시도 한번 확인해보겠습니다. 아래는 리뷰 작성을 위한 create_review mutation의 interface가 있는 파일입니다.

/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { CreateReviewInput, ReviewMileageStatus } from "./globalTypes";// ====================================================
// GraphQL mutation operation: createReview
// ====================================================
export interface createReview_createReview_review {
__typename: "ReviewType";
id: string;
mileageStatus: ReviewMileageStatus;
answerBody: string | null;
score: number;
}
export interface createReview_createReview {
__typename: "CreateReviewMutation";
review: createReview_createReview_review | null;
}
export interface createReview {
createReview: createReview_createReview | null;
}
export interface createReviewVariables {
input: CreateReviewInput;
}

몇가지 특이한 점이 있네요. 먼저 mutation createReview와 request payload로 사용하는 input createReviewVariables 그리고 성공시에 결과로 받아오는 createReview_createReview_review이 잘 선언었습니다. 그러나 camel과 snake가 섞인 이름이 부담스럽습니다. 무엇보다 내 마음대로 뭔가 좀 바꾸고 싶은데 apollo client의 tooling을 활용하는 한도 내에서는 더 이상 할 수 있는 것이 없습니다. 그러면 우리가 직접 schema.graphql을 커스텀해보는건 어떨까요?

Schema.graphql 정보 가져오기

먼저 python에서 다루기 쉽도록 schema.graphql을 json으로 변형합니다. 백엔드 프로젝트에서 아래의 명령어를 실행해봅니다.

python manage.py graphql_schema --schema fitpet.schema.schema --out {SCHEMA_FILE_NAME}

schema.json가 생성이 될텐데요. 이는 우리가 추가로 커스터마이징하여 사용할 파일을 만들기 위해 정보를 메모리에 적재하기 위해 사용할 예정이므로 메모리에 로드한 후 바로 삭제를 해줍니다. ensure_ascii를 False처리해 한글 설명이 깨지는 일이 없도록 합니다.

res = json.dumps(schema_graphql_file, ensure_ascii=False)

schema.json은 아래와 같은 구조로 표현되어 있습니다. 사용하는 룰등을 설명하는 directives와 이름을 표현하는 기타 등을 빼면 실제 사용할 주요 데이터는 types 안에 모두 들어있습니다.

{
"data": {
"__schema": {
"directives": [ ... ],
"mutationType": { ... },
"queryType": { ... },
"subscriptionType": { ... },
"types": [ {}, {}, ... ]
}
}
}

이 값만 메모리에 담아줍니다.

schema_types = res['data']['__schema']['types']

Type 정보 추출

schema_types에서 추출할 주요 데이터를 계층적으로 표현하면 아래와 같습니다.

schema_types

  • query
    - PageInfo (페이지네이션 Type)
    -
    Type (싱글쿼리 Type)
    - TypeConnection (페이지네이션 가능한 리스트쿼리 Type)
  • mutation
    - Mutation (뮤테이션 Type)
  • enum

모든 주요한 정보는 위 Type 안에서 표현이 가능하며 TypeConnection이 하위 필드에 PageInfoType을 가지고 있는 방식으로 재사용이 이루어집니다.

아래는 ProductType의 fields 예시입니다.

{
"enumValues": null,
"fields": [
{
"args": [],
"deprecationReason": null,
"description": "디스플레이 순서",
"isDeprecated": false,
"name": "displayOrder",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
}
},
{
"args": [],
"deprecationReason": null,
"description": "생성일",
"isDeprecated": false,
"name": "createdAt",
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
{
"args": [],
"deprecationReason": null,
"description": "The ID of the object.",
"isDeprecated": false,
"name": "id",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
{
"args": [],
"deprecationReason": null,
"description": "리뷰 그룹",
"isDeprecated": false,
"name": "reviewGroup",
"type": {
"kind": "OBJECT",
"name": "ReviewGroupType",
"ofType": null
}
},
{
"args": [ ... ],
"deprecationReason": null,
"description": "상품",
"isDeprecated": false,
"name": "productProductImages",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ProductImageTypeConnection",
"ofType": null
}
}
},
{ ... }
],
"inputFields": null,
"interfaces": [
{
"kind": "INTERFACE",
"name": "Node",
"ofType": null
}
],
"kind": "OBJECT",
"name": "ProductType",
"possibleTypes": null
}

Type이라는 정의 아래 query나 mutation 등 다르게 사용되더라도 그 형태가 같으며 몇가지 특징이 있습니다.

  1. type의 내부 정보를 나타내는 주요 정보는 fields 안에 들어 있음
  2. 각 type의 첫번째 fields depth에 담긴 모든 정보만이 해당 type이 가진 field를 설명하며 한 type이 반복적으로 다른 type을 참조할 수 있음 (ex. ProductType > ReviewGroupType … )
  3. field 안에서는 type의 명시적 이름인 fields > name과 내부 속성을 표현하는 fields > type의 일부 정보가 중요
  4. fields > type > kind가 OBJECT인 경우 다른 type을 참조함
  5. fields > type > kind가 NON_NULL인 경우 required를 의미하며 이 경우 fields > type > kind > ofType으로 한번 더 감싸서 해당 field의 속성을 표현함
  6. OBJECT이면서 NON_NULL인 경우 반드시 OBJECT를 먼저 명시함
  7. 우리가 각 field의 속성을 오롯이 다 표현하기 위해 알아야 할 내용은 아래와 같음
    - name : (ex. ReviewGroupType)
    - type_kind: (ex. NON_NULL | OBJECT | LIST | SCALAR)
    - type_name: (ex. Int | DateTime | ID | String | Decimal | {CustomType})
    - required 여부
    - list 여부
    - type_kind가 ‘LIST’이거나 ‘Connection’ suffix를 가지고 있음

위 내용을 이해하고 아래와 같이 type을 변화시켜 봅니다. 아래는 ProductType의 fields 내용을 변환한 결과값이며 mutation도 동일합니다.

query_fields = get_query_fields(schema_types)# --('ProductType',
[{'name': 'displayOrder',
'type_kind': 'SCALAR',
'type_name': 'Int',
'required': True,
'is_list': False},
{'name': 'createdAt',
'type_kind': 'SCALAR',
'type_name': 'DateTime',
'required': False,
'is_list': False},
{'name': 'id',
'type_kind': 'SCALAR',
'type_name': 'ID',
'required': True,
'is_list': False},
{'name': 'reviewGroup',
'type_kind': 'OBJECT',
'type_name': 'ReviewGroupType',
'required': False,
'is_list': False},
{'name': 'productProductImages',
'type_kind': 'OBJECT',
'type_name': 'ProductImageTypeConnection',
'required': True,
'is_list': True},
...
]
),
mutation_fields = get_mutation_fields(schema_types)# --
{
'CreateReviewMutation': [{'name': 'review',
'type_kind': 'OBJECT',
'type_name': 'ReviewType',
'required': False,
'is_list': False}],
...
}

Query / Mutation 정보 추출

우리는 이제 query와 mutation에서 활용할 수 있는 모든 type의 세부 정의를 확보했습니다. 이제 query와 mutation 정보를 추출해보게 될텐데요. 위 type과 만들게 될 query의 차이를 이해를 도울 수 있는 한가지 예시를 들어보겠습니다.

ProductType은 말 그대로 ProductType의 스키마 형태를 나타낸 것이며 우리는 ‘product’라는 쿼리를 통해 하나의 상품을 가져오고 ‘products’라는 쿼리를 통해 여러개의 상품을 가져오면서 두 쿼리가 모두 같은 ProductType을 사용할 수 있습니다.

Query의 정보는 schema_types의 리스트 중 ‘name’이 Query로 있는 object에 모두 있고, Mutation은 마찬가지로 ‘name’이 Mutation이라고 된 object에 모두 있습니다. 해당 내용의 json 정의도 Type과 동일하며 우리는 아래와 같이 Query와 Mutation 정보를 획득할 수 있습니다. 이 정보에는 일반적인 Type 외에도 각 query와 mutation이 추가로 요청받을 수 있는 매개변수가 정의됩니다.

# query 예시
query_schematypes = {
'product': {'type': 'ProductType',
'args': [{'name': 'id',
'type_kind': 'SCALAR',
'type_name': 'ID',
'required': True,
'is_list': False}
]},
'products': {'type': 'ProductTypeConnection',
'args': [{'name': 'pageInfo',
'type_kind': 'INPUT_OBJECT',
'type_name': 'PageInfoInputType',
'required': False,
'is_list': False},
{'name': 'createdAtBefore',
'type_kind': 'SCALAR',
'type_name': 'DateTime',
'required': False,
'is_list': False},
{'name': 'createdAtAfter',
'type_kind': 'SCALAR',
'type_name': 'DateTime',
'required': False,
'is_list': False},
{'name': 'name',
'type_kind': 'SCALAR',
'type_name': 'String',
'required': False,
'is_list': False}
]},
...
}

args’는 query를 사용할 때 사용할 인자값입니다. 위의 ‘product’라는 Query는 ProductType이라는 Type을 사용하며 조회를 할 때 id를 사용하여 조회할 수 있고 id는 반드시 입력되어야 합니다.

mutation_schematypes = {
# mutation 예시
'createReview': {'type': 'CreateReviewMutation',
'args': [{'name': 'input',
'type_kind': 'INPUT_OBJECT',
'type_name': 'CreateReviewInput',
'required': True,
'is_list': False}]},
'createBulkReviewByFitpetAdminExcel': {'type': 'CreateBulkReviewByFitpetAdminExcelMutation',
'args': [{'name': 'input',
'type_kind': 'INPUT_OBJECT',
'type_name': 'BaseImportByExcelInput',
'required': True,
'is_list': False}]},
...
}

createReviewCreateReviewMutation type을 사용하며 CreateReviewInput으로 정의된 매개변수를 받아야만 실행됩니다.

Enum 정보 추출

마지막으로 하나 남은 정보가 있습니다. Enum인데요. 해당 정보는 장고의 CharField > Choice로 지정할 경우 자동으로 enum으로 type이 지정됩니다. 해당 값은 schema_types > ‘kind’가 ‘ENUM’인 모든 type에 해당되며 아래와 같이 결과값을 정리할 수 있습니다.

enums = {
# 사용하는 배너의 종류
'BannerBannerType': [
'BANNER_HOME_BOTTOM',
'BANNER_HOME_MIDDLE',
'BANNER_HOME_MULTI_LINE',
'BANNER_PRODUCT_TOP',
'BANNER_REVIEW',
'BANNER_SLIM',
'BANNER_TOP_ROLL',
'BANNER_TOP_SLIM'
],
}

이로써 커스텀하게 파일을 만들기 위한 데이터 준비는 끝났습니다. 우리는 type 정보가 담긴 query_fields와 mutation_fields, query와 mutation 정보가 담긴 query_schematypes, mutation_schematypes, 그리고 enums 정보를 추출하였습니다.

어떤 정보를 자동으로 만들까

저희는 프론트엔드에서 React를 사용하며 Apollo Client를 활용해 graphql로 백엔드와 통신하였습니다. 관련된 자세한 내용은 이 문서를 통해 확인하실 수 있습니다. 여기서부터는 저희 핏펫에서 만들고 활용했던 자동생성파일의 종류입니다. GraphQL 모든 정보를 활용할 수 있게 된 상황에서 자유도는 높아졌고 저희는 아래의 파일을 자동으로 만들었습니다.

  • defaultModelInterfaces.ts

type의 interface를 저희 입맛에 맞게 재생성하였습니다. 기존 Apollo Client에서 제공하는 codegen을 통해서도 interface 생성이 가능하나 다시 재생성하면서 여러가지 이점이 생기게 되었습니다.

import { Moment } from 'moment'
import { IModelBase } from 'containers/models/modelBase'
export interface IModelBase {
id: string;
_id: string;
__typename: string;
}
export interface IModelAdDefault extends IModelBase {
name: string;
description: string;
adStartedAt: Moment;
adEndedAt: Moment;
adType: 'AD_EPISODE' | 'AD_EVENT';
adImages: IModelAdImageDefault[];
adCollections: IModelAdCollectionDefault[];
brands: IModelBrandDefault[];
...
}
...

그리고 아래의 예는 원래 기존 Apollo Client 자동생성되었던 같은 interface의 예시입니다.

export interface ad {
__typename: "AdType";
name: string;
description: string | null;
id: string;
adStartedAt: any | null;
adEndedAt: any | null;
adType: AdAdType;
}

interface는 object와 날짜가 모두 any형태로 되어 있었으나 Moment와 object명을 정확히 명시할 수 있게 되었고, 공통으로 사용되는 id 값은 상속받아 재활용하게 되었습니다. 또한 adImages나 brands같이 연결지을 수 있는 다른 커스텀 type도 미리 선언이 가능하게 되었습니다.

  • defautModels.ts

type을 프론트엔드에서 class로 생성하여 반복되는 모델 내에서의 로직을 담을 수 있도록 했습니다.

import { DataBase } from 'containers/gqls/data'
import { ModelBase } from 'containers/models'
export class ModelAdDefault extends ModelBase {
name = ''
description = ''
adStartedAt = null
adEndedAt = null
adType = ''
adImages: IModelAdImageDefault[] = [] as IModelAdImageDefault[]
adCollections: IModelAdCollectionDefault[] = [] as IModelAdCollectionDefault[]
brands: IModelBrandDefault[] = [] as IModelBrandDefault[]
constructor(data: IModelAdDefault) {
super(data)
const _data = this.makeNumberIfAny(data)
Object.assign(this, _data)
this.adImages = DataBase.getListModel(this.adImages)
this.adCollections = DataBase.getListModel(this.adCollections)
this.brands = DataBase.getListModel(this.brands)
}
}

위와 같은 로직을 담은 파일이 모든 Type에 대해 자동으로 생성됩니다. API 요청 후 결과값은 위 클래스에서 자동으로 모델화되며, Custom이 필요할 경우 아래와 같이 한번 더 상속받아서 필요한 메서드를 선언해서 사용하였습니다. 이로써 매번 스키마를 보면서 일일이 작업해야 했던 일은 상속으로 간단하게 해결되었고 재사용이 가능한 로직을 보다 쉽게 구성할 수 있게 되었습니다. 아래의 경우 ad type을 사용할 때 adStatusadStatusText값은 항상 값이 셋팅되어 있게 되어 호출만 하면 되었습니다.

export default class ModelAd extends ModelAdDefault {
adStatus = ''
adStatusText = ''
constructor(data: IModelAd) {
super(data)
this.adStatus = this.getAdStatus()
this.adStatusText = AD_STATUS_TEXT[this.adStatus as keyof typeof AD_STATUS_TEXT]
}
getAdStatus = () => { ... }
}
  • mutationVariables.ts

mutation에 variable로 넣게될 파라미터를 사용할 수 있는 object를 자동으로 생성해 쉽게 유효성 검증을 할 수 있도록 했습니다.

import {
CreateReviewInput,
...
} from 'containers/gqls/generated/globalTypes'
const MUTATION_VARIABLES = {
CREATE_REVIEW: (params: CreateReviewInput) => parameterize(params),
...
}
--const createReviewVariable = MUTATION_VARIABLES.CREATE_REVIEW({ ... }) // 유효성 검증

위 코드에서 보듯 CreateReviewInput은 다시 만들지 않고 Apollo client의 codegen으로 생성되었던 것을 활용했으며 네이밍 룰에 따라 interface 이름을 모두 예측할 수 있었습니다.

  • defaultMutations.ts

저희는 활용하게 될 mutation과 query문도 자동으로 만들 수 있음을 알게 되었습니다. 아래와 같이 사용하게 될 Mutation을 자동으로 Apollo Client 문법에 맞게 생성합니다. 하단에 쓰인 DEFAULT_FRAGMENT는 defaultFragment.ts에서 설명하겠습니다.

export const DEFAULT_MUTATION_GQLS = {
CREATE_REVIEW: gql`
mutation createReviewDefault($input: CreateReviewInput!) {
createReview(input: $input) {
review {
...review
user { ...user }
product { ...product }
}
}
}
${DEFAULT_FRAGMENTS.user}
${DEFAULT_FRAGMENTS.product}
${DEFAULT_FRAGMENTS.review}
`,
...
}
  • defaultQueries.ts

마찬가지로 사용하게 될 Query를 자동으로 Apollo Client 문법에 맞게 생성합니다.

export const DEFAULT_QUERY_GQLS = {
PRODUCT: gql`
query productDefault($id: ID!) {
product(id: $id) {
...product
category { ...category }
brand { ...brand }
}
}
${DEFAULT_FRAGMENTS.product}
${DEFAULT_FRAGMENTS.category}
${DEFAULT_FRAGMENTS.brand}
`,
}

query와 mutation을 자동으로 만들게 될 때 유의했던 점은 type의 depth를 두단계 이상 가지 않도록 하는 것입니다. 두단계 이상 반복되는 type이 생길 경우 product > category > product 식으로 loop 현상이 생길 수 있습니다.

Fragment는 GraphQL에서 재사용할 수 있는 조각단위입니다. query나 mutation에서 반복되는 fragment는 한번의 선언으로 재사용할 수 있습니다. 아래와 같이 생성했으며 defaultMutationsdefaultQueries에서 유용하게 사용할 수 있었습니다.

const DEFAULT_FRAGMENTS: {[index: string]: any} = {
get product() {
return (
gql`
fragment product on ProductType {
id
createdAt
price
...
}
`
)
},
...
}

이 외에도 각 프론트엔드 프로젝트에 따라 반복적으로 백엔드의 변화에 맞춰 생성하고 수정하는 파일과 로직들이 있었다면 충분히 자동화한 뒤 활용할 수 있을거라고 생각합니다. 다만 항상 default를 base로 만들고 그것을 상속받는 형태로 진행하는게 좀 더 개별 작업의 자유도가 높아질거라 생각합니다.

프론트엔드 파일 자동생성 스크립트

프론트엔드의 파일을 자동생성하는 과정에서는 백엔드에서의 작업이 필수로 요구됩니다. 따라서 스크립트를 작성해 프론트엔드 프로젝트에서 백엔드의 작업을 완료하고 필요한 파일을 생성하도록 했습니다. 이로써 백엔드의 변경이 일어날 때마다 스크립트를 한번 실행하는 것만으로도 변경된 사항을 지속적으로 업데이트해나갈 수 있게 되었습니다.

#!/bin/bashFRONTEND_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"rm -rf $FRONTEND_PATH/src/containers/gqls/base
rm -rf $FRONTEND_PATH/src/containers/models/base
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"
pyenv shell $PYENV
pyenv activate
export DJANGO_SETTINGS_MODULE=...
cd $BACKEND_PATH
echo $BACKEND_PATH
python make_front_files.py FRONTEND_PATH=$FRONTEND_PATH // 프론트엔드 패스에 파일 만들도록 경로 지정
pyenv shell system

마치며

스크립트를 한번 실행하면서 저희는 API 통신으로 받아오는 데이터의 interface와 그 interface를 참조하는 model class의 원형을 만들수 있었고 재사용하는 fragment를 기반으로 query와 mutation 구문도 자동으로 생성할 수 있었습니다. 또한 mutation에 사용되는 payload의 interface를 활용하는 코드로 자동화하였습니다. 실 프로젝트에서 이를 활용해본 결과 query와 mutation은 자동으로 만들어진 코드를 참조하되 별도로 생성하는 방식을 택하게 되었는데 이는 query, mutation 종류마다 필요로 하는 response data가 type 내 모든 데이터를 필요로 하지 않았기 때문입니다. 그럼에도 백엔드의 변경사항을 파일 하나에서 편하게 확인할 수 있어서 효과적으로 사용가능했습니다.

읽어주셔서 감사합니다!

written by Shaun (Juyup Sung)

email: jy.sung@fitpet.co.kr

--

--