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

들어가며

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

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에 쉽게 잘 설명되어 있으니 넘어가도록 하고 간단한 개념만 소개하겠습니다.

class Query(ProductQueries, ...):
pass
class Mutation(ProductMutations, ...):
pass
schema = graphene.Schema(query=Query, mutation=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()
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의 모든 정의가 정리되어 있는 스키마를 아주 쉽게 만들 수 있습니다. 순차적으로 가벼운 예시와 함께 한번 따라가보겠습니다.

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='상품'
)
...
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로 자동으로 연결됩니다
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!
...
}
python manage.py graphql_schema --schema fitpet.schema.schema --out {SCHEMA_FILE_NAME}# json 형태 결과값은 아래에 다시 설명합니다.

Apollo Client Codegen

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

npm install -g apollo
apollo -v
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 service:download
apollo client:codegen --target=typescript --outputFlat src/containers/gqls/generated
  1. 에러가 없이 통과를 하였다면 위에 지정한 위치에 자동으로 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;
...
}
/* 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;
}

Schema.graphql 정보 가져오기

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

python manage.py graphql_schema --schema fitpet.schema.schema --out {SCHEMA_FILE_NAME}
res = json.dumps(schema_graphql_file, ensure_ascii=False)
{
"data": {
"__schema": {
"directives": [ ... ],
"mutationType": { ... },
"queryType": { ... },
"subscriptionType": { ... },
"types": [ {}, {}, ... ]
}
}
}
schema_types = res['data']['__schema']['types']

Type 정보 추출

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

  • mutation
    - Mutation (뮤테이션 Type)
  • enum
{
"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
}
  1. 각 type의 첫번째 fields depth에 담긴 모든 정보만이 해당 type이 가진 field를 설명하며 한 type이 반복적으로 다른 type을 참조할 수 있음 (ex. ProductType > ReviewGroupType … )
  2. field 안에서는 type의 명시적 이름인 fields > name과 내부 속성을 표현하는 fields > type의 일부 정보가 중요
  3. fields > type > kind가 OBJECT인 경우 다른 type을 참조함
  4. fields > type > kind가 NON_NULL인 경우 required를 의미하며 이 경우 fields > type > kind > ofType으로 한번 더 감싸서 해당 field의 속성을 표현함
  5. OBJECT이면서 NON_NULL인 경우 반드시 OBJECT를 먼저 명시함
  6. 우리가 각 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를 가지고 있음
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의 차이를 이해를 도울 수 있는 한가지 예시를 들어보겠습니다.

# 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}
]},
...
}
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}]},
...
}

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'
],
}

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

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

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[];
...
}
...
export interface ad {
__typename: "AdType";
name: string;
description: string | null;
id: string;
adStartedAt: any | null;
adEndedAt: any | null;
adType: AdAdType;
}
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)
}
}
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 = () => { ... }
}
import {
CreateReviewInput,
...
} from 'containers/gqls/generated/globalTypes'
const MUTATION_VARIABLES = {
CREATE_REVIEW: (params: CreateReviewInput) => parameterize(params),
...
}
--const createReviewVariable = MUTATION_VARIABLES.CREATE_REVIEW({ ... }) // 유효성 검증
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}
`,
...
}
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}
`,
}
const DEFAULT_FRAGMENTS: {[index: string]: any} = {
get product() {
return (
gql`
fragment product on ProductType {
id
createdAt
price
...
}
`
)
},
...
}

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

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

#!/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 내 모든 데이터를 필요로 하지 않았기 때문입니다. 그럼에도 백엔드의 변경사항을 파일 하나에서 편하게 확인할 수 있어서 효과적으로 사용가능했습니다.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store