나는 평상시에 주로 RESTful API를 이용하여 개발을 진행한다. 그냥 그저 익숙하니깐
그 이상 이유가 없다. 사람은 게으른 동물이다. 그냥 원래 알던 거 쓰자. 라는 생각을 갖는 사람이 대다수이다. 그럼 우리는 그 귀찮음
을 이겨낼정도로 RESTful를 포기할 정도로 GraphQL을 사용해야할까? 현재 공부하는 입장으로 둘 다 장단점이 존재한다. 비교하기전에 GraphQL가 뭔지 알아보자
GraphQL이란? 먼저 여기서 QL에 집중해야한다. Query Language 쿼리 언어! 이게 뭐지? 쿼리 언어라고 함은, 질의 언어이다. 좀 더 직관적으로 설명하자면 GraphQL는 클라이언트에서 요청한 데이터만을 정확하게 반환할 수 있는 언어이다. 지금 상태에서는 client든 server든 이점이 되는 지 감이 안 잡힐 수 있다. 하나씩 차근차근 봐보겠다.
overfetching이란 client가 필요한 데이터보다 더 많은 데이터를 서버로부터 받아오는 현상을 말한다. 예를 들자면, 우리는 name과 id
만 필요한 경우가 있을 것이다. 하지만 백엔드 개발자가 name / id / title / content
를 return하는 api를 사용해야한다고 가정해보자 그럼 우리는 title과 content을 사용할 필요가 없다. over된 data가 있는 경우를 overfetching이라고 칭한다. 한 두번의 overfetching은 문제가 발생하지 않겠지만, 이게 수억번 수천억번이 발생한다고 가정해보자 그만큼 비용을 쓸 것이고 이는 손해로 넘어갈 것이다. restAPI 같은 경우 overfetching이 일어날 수 있다.
하지만, graphQL
같은 경우 client에서 정의한 데이터만 갖고올 수 있다. overfetching이 절대 일어나지 않는다는 얘기다 이것만 해도 큰 장점일 것이다.
underfetching이란 client가 필요한 데이터를 한 번의 요청으로 모두 가져오지 못하고, 부족한 데이터를 채우기 위해 추가 요청을 해야 하는 상황을 말한다. 예를 들자면 우리는 name / id / title / content
가 필요한 상황이라고 가정해보자 근데 한 api에선 name & id
를 return 해주고, 다른 api에선 title & content
를 return 해준다고 가정해보자. 그럼 우리는 한 component를 만들기 위해서 두번의 api 호출이 발생할 것이다. 이는 당연하게도 비효율적일 것이다. 한 번 api 호출할 걸 두번 호출한다는 얘기니깐...
하지만 graphQL
은 underfetching을 걱정을 할 필요가 없다. graphQL
은 원하는 데이터만을 한 번의 요청으로 불러올 수 있기 때문이다.
일반적으로 restAPI 같은 경우 api 명세서를 swagger
사용하고 api test 용으로는 postman
을 사용할 것이다. 하지만 playground같은 경우 api 명세서와 api test를 동시에 진행할 수 있다.
아니라고는 못하겠다. 사실 상당히 좋긴하다 원하는 데이터만 갖고 올 수 있고 underfetching overfetching
할 일이 없기 때문이다. 하지만 완전히 대체할 수는 없다. 일단 먼저 러닝커브가 생각보다 높다. client 측면에서는 useQuery(원하는 데이터 형태) 만 들고 오면 되지만 server 측면에서는 resolve / mutation etc 처음 보는 것들이 나온다..(백엔드 하나만 하는 것도 힘든데,, 뭔 resolve mutation... ㅋㅋㅋ)
또한 단순히 crud하는 경우에는... restAPI가 훨씬 더 편할 수도 있다. 왜냐..? 백엔드 개발자들한테는 crud가 익숙한 편일테니깐
시작 전에 잡설을 하나 해보겠다. 코드 뿐만 아니라 어떤 일을 하면 끝까지 간 새끼가 이기는 거다 난 끝까지 가니깐 항상 승리를 쟁취하는 것이다. 다들 힘들더라도 끝까지 포기하지않고 해보세요 최근에 한 영상을 봤다. 최근에 칸예가 한국에서 라이브를 부르면서 화제가 된 적이 있다. 그러면서 자연스럽게 내 유튜브 알고리즘은 칸예로 가득찼는데, 칸예가 거기서 한 말이 있다. 인생에서 죽음 외에 정해진 건 없으니깐 기회가 올 때 기회를 잡아라. 진짜 이 말에 격히 공감한다. 인생에서 정해진 건 죽음밖에 없고 나머지는 자기 하기 나름이다. 열심히 해보자 우리
서론이 살짝 길었다. 사실 백엔드 코드를 제대로 처음해본 류지승이 문서 찾고 강의보고 공식문서 보고 구글링하고 이걸 바탕으로 만들었다는 사실에 울컥했다. 먼저 시작 전에 나는 현재 백엔드를 Nest로 구현중이고, DB는 MongoDB, ORM은 TypeORM을 사용하고 있으며 restAPI를 이용하여 기본적인 CRUD를 구축하였다. 그러면서 이번에 같이 GraphQL을 같이쓰면 좋지 않을까 라는 생각을 해서 사용해보기로 했다. 일단 같이 써도 코드가 많이 복잡해지지 않은게, 현재 restAPI를 어떤 식으로 처리했냐면 한 모듈에 controller(method 요청 관련 로직) / service(실제 원하는 데이터 처리하는 로직) / repository(데이터베이스 접근 로직) 이런 식으로 접근했다. 근데 여기서 GraphQL을 사용하려면 controller 대신 resolver라는 파일을 만들어서 Query와 mutation을 이용하여 CRUD를 처리한다. 그러면서 스키마 부분은 typeORM에서 사용했던 entity를 사용하면 된다. ㅋㅋㅋㅋ 얼마나 좋냐 진짜 그대로 service & repository 로직을 재사용할 수 있고, graphQL과 restAPI를 둘 다 사용할 수 있으니깐,
먼저 resolver란 NestJS에서 controller와 하는 역할이 같다.
import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql';
import { BoardService } from './board.service';
import { Board } from './entities/board.entity';
import { CreateBoardDto } from './dto/create-board.dto';
@Resolver(() => Board)
export class BoardResolver {
constructor(private readonly boardService: BoardService) {}
@Query(() => [Board])
boards() {
return this.boardService.findAll();
}
@Query(() => Board)
board(@Args('boardId', { type: () => Int }) boardId: number) {
return this.boardService.findOne(boardId);
}
@Mutation(() => Board)
createBoard(@Args('data') data: CreateBoardDto) {
return this.boardService.create(data);
}
@Mutation(() => Board)
updateBoard(
@Args('boardId', { type: () => Int }) boardId: number,
@Args('data') data: CreateBoardDto,
) {
return this.boardService.updateOne(boardId, data);
}
@Mutation(() => Board)
updateBoards(
@Args('boardId', { type: () => Int }) boardId: number,
@Args('data') data: CreateBoardDto,
) {
return this.boardService.updateAll(boardId, data);
}
@Mutation(() => Boolean)
deleteBoard(@Args('boardId', { type: () => Int }) boardId: number) {
return this.boardService.remove(boardId);
}
@Mutation(() => Boolean)
async clearBoard() {
await this.boardService.clear();
return true;
}
}
controller에서는 HTTP Method를 처리한다면, resolver에서는 Query와 Mutation을 처리한다. 생각해보니 Query와 Mutation에 대해서 설명을 하지 않았네.
Query & Mutation
- Query : REST에서 GET method를 처리하는 역할이다. GET과 동일하다고 생각하면 된다. 단순히 Read 작업을 할 때 Query를 사용한다.
- Mutation : REST에서 GET method가 아닌 나머지 method을 처리하는 역할이다. CREATE / UPDATE / DELETE를 처리할 때 Mutation을 사용한다.
nestJS가 이 파일이 resolver를 알 수 있게 위에 @Resolver()
데코레이터를 사용한다. 나머지는 Controller와 유사하다. 살짝 신기한 점은 TypeScript에서는 number타입만 존재한다. 즉 int / float / double 한 타입으로 처리한다는 얘기다. 근데 GraphQL은 각자 타입이 존재한다. 그래서 우리는 number 타입으로 적는게 아닌 { type: () => Int }
을 명시해줘야한다. 명시하지 않으면 default로 float 타입이 할당된다.
그리고 신기한점이 있다. GraphQL은 리턴값이 무조건!!!!!! 있어야한다. 사실 나는 전체 board
을 삭제하는 clearBoard()
API를 만들었는데 처음에는 사실 TypeORM의 clear()
때문에 return 값을 void으로 처리했다. 하지만.. 무조건 return 값이 존재해야하므로,,, 나는 async / await
을 통해 true 라는 값을 넘겨줬다. service 로직이든, repository 로직이든 어디서든 에러가 발생하면 난 에러처리를 해놨고, 만약 제대로 값이 들어왔다면 void로 넘어올 거기 때문에 동기적으로 처리하면 안전하다!!
@Args()
데코레이터 같은 경우는 인자로 넘겨받을 때 값이다. restAPI 같은 경우 엔드포인트를 이용하여 데이터 통신이 이루어진다. 하지만 GraphQL 같은 경우 baseurl이 한 개로써 통신을 한다. 그럼 어떤 식으로 데이터를 요청하고 데이터를 불러오는가? method 같은 이미 mutation & query
를 이용하여 데이터를 주고 받는다고 했다. restAPI 같은 경우 request body에 데이터를 넣어서 통신을 하지만, GraphQL 같은 경우 argument에 값을 넣어 데이터를 주고 받는다.
client에서 mutation 호출 예시
const CREATE_PRODUCT = gql` mutation createBoard($writer: String!, $title: String!, $contents: String!) { createBoard(writer: $writer, title: $title, contents: $contents) { _id number message } } `;
이런식으로 request body 대신 argument
에 값을 넣는다. 위 같은 경우 writer / title / content 를 넘겨준 예시이다.
GraphQL은 response든 request든 타입을 반드시 명시해줘야한다. resolver 부분 보면 데코레이터 내부에 response type을 넣었고, requeset type 같은 경우 @args()
에 넣어 타입을 명시해주었다.
여기서 Hit인 부분이 있다. 나는 처음에 prisma를 이용하여 데이터베이스에 데이터를 갖고오려고 했지만, 노원두 팀장님께서 typeORM
을 사용하는 것을 추천해줬다. 그 이유는 typescript를 사용할 수 있기 때문이다. 다른 ORM 같은경우 TS를 지원하지 않을 수도 있고, 지원한다고 해도 TypeScript-first가 아닐 것이다. 백엔드에서의 타입은 엄청나게 중요하다. 타입 하나로 resource를 낭비할 수도 있기 때문이다.
왜 갑자기 typeORM
에 대해서 설명했냐면 우리는 타입 안정성을 보장하기 위해 entity
를 정의해야한다. 이 entity를 graphQL에서도 재사용할 수 있다. 당연히 request data의 type 또한 dto로 사용할 수 있다. 얼마나 편하냐 스키마 파일을 만들지도 않고 재사용할 수 있음이... 너무 행복했다.
// board.entity.ts
import {
Column,
CreateDateColumn,
Entity,
ObjectId,
ObjectIdColumn,
UpdateDateColumn,
} from 'typeorm';
import { ObjectType, Field, Int, ID } from '@nestjs/graphql';
import { CreateBoardDto } from '../dto/create-board.dto';
@Entity()
@ObjectType()
export class Board extends CreateBoardDto {
@ObjectIdColumn()
@Field(() => ID)
_id: ObjectId;
@Column()
@Field(() => Int)
boardId: number;
@CreateDateColumn()
@Field(() => Date)
createdAt: Date;
@UpdateDateColumn()
@Field(() => Date)
updatedAt: Date;
}
// create-board.dto
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
Length,
} from 'class-validator';
import { Column } from 'typeorm';
@InputType()
@ObjectType()
export class CreateBoardDto {
@IsString()
@IsNotEmpty()
@Length(3, 4)
@Column()
@Field()
author: string;
@IsString()
@IsNotEmpty()
@Column()
@Field()
title: string;
@IsString()
@IsNotEmpty()
@Column()
@Field()
content: string;
@IsArray()
@IsOptional()
@IsString({ each: true })
@ArrayMaxSize(3)
@Column('array')
@Field(() => [String], { nullable: true })
imageUrl?: string[];
@IsString()
@IsOptional()
@Column()
@Field({ nullable: true })
youtubeUrl?: string;
}
GraphQL같은 경우 @field()
를 이용하여 데이터의 타입을 정의한다. field 내에 인자로 타입을 지정해주는데, default가 string이다. 아무리 봐도 string이 가장 많이 쓰이기 때문이다. 위애서 말했다시피 number 타입을 지정하지 않고 Int 타입을 지정한다. 사실 잘 하고 있는 지 모르겠는데, 아마 잘 하고 있을 것이다... 답은 항상 공식문서 신기하게도 ID라는 타입이 존재한다. 고유 식별자를 나타내는 문자열이다. 대부분 기본 키 또는 식별자로 사용된다.
처음에는 restapi도 response를 통일 시켜줬으니깐, 당연하게 graphQL 또한 reponse 응답 데이터 일정하게 통일 시켜줘야한다고 생각했다. 하지만, 그럴필요가 없었다.. 사실 graphQL같은 경우 client 딴에서 원하는 데이터만 받아오기 때문에 그냥 reponse 전체를 보내도 상관없다.. 이걸 2시간동안 고민했었다. 어떻게 하면 http 와 graphql을 분리하고 응답 메세지를 보낼지.. 하... ㅋㅋㅋㅋㅋㅋ
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import {
IDeleteResponse,
IResponseInterceptor,
} from '../types/interceptor.interface';
import { GqlContextType } from '@nestjs/graphql';
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, IResponseInterceptor<T> | IDeleteResponse>
{
constructor(private readonly reflector: Reflector) {}
intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Observable<IResponseInterceptor<T> | IDeleteResponse> {
const message = this.reflector.get<string>(
'response-message',
context.getHandler(),
);
const statusCode = context.switchToHttp().getResponse().statusCode;
return next.handle().pipe(
map((data) => {
if (context.getType<GqlContextType>() === 'graphql') {
return { data };
} else if (context.getType() === 'http') {
if (Array.isArray(data)) {
const deleteIdData = data.map((item) =>
this.removeId(item),
);
return { message, statusCode, data: deleteIdData };
} else if (typeof data === 'object' && data !== null) {
const deleteIdData = this.removeId(data);
return { message, statusCode, data: deleteIdData };
} else {
return { message, statusCode };
}
}
}),
);
}
private removeId(item: any): any {
if (item && item._id) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, ...rest } = item;
return rest;
}
return item;
}
}
다음은 interceptor 코드다. 이제는 사용할 일이 없겠지만 공부한 걸 정리해보겠다. 현재 if (context.getType<GqlContextType>() === 'graphql')
여기 부분이 graphQL 통신을 가로채는 코드이다. 원래는 로직을 어떻게 reponse보낼 지 작성했었지만,, 그럴필요가 없다..ㅋㅋㅋㅋㅋㅋㅋ 그래서 깔끔하게 {data}라고 넘겨줬다. 당연히 interceptor는 global로 처리 안하고, controller에 처리해줬다.
우리가 결론적으로 getType을 이용하여 현재 통신 타입을 들고 와야하는데, getType은 ArgumentHost에 존재하고 ExecutionContext에 상속시켜준다. 그래서 우리는 interceptor에 있는 context를 이용하여 getType을 들고 올 수 있다. 하지만 문제가 하나 존재한다. graphQL이든 restAPI든 둘 다 결론적으로 http 통신을 따른다는 것이다. 그래서 nestJS 공식문서에서는 다음과 같이 해결법을 제시하였다.
playground에 정상적으로 들어오는 것을 확인할 수 있다.