GraphQL은 Schema를 통해 각 필드의 데이터 타입을 정의하고 해당 필드를 쿼리를 통해 주고 받는다.
Schema First는 개발자가 직접 Schema를 생성하고 해당 Schema를 이용하고, Code First는 resolve와 class의 코드를 기반으로 자동으로 schema가 생성된다.
현재 GraphQL 프로젝트를 진행하는 방법은 Schema First와 Code First 두가지가 존재한다.
Schema란 자료의 구조나 자료의 관계를 정의한 것으로 Schema를 먼저 만드냐, 나중에 만드냐에 따라 생성 방법이 나뉘어 진다.
GraphQL은 2015년에 처음 공개되었고, 2016년 Schema First 구현방식이 촉진 되었다. 하지만 Schema First의 단점때문에 여러 프레임워크가 만들어 졌고, 2018년 쯤부터 Code First를 사용하는 프레임워크가 늘어나기 시작했다.
현재는 Code First방식이 개발에 있어 주를 이루고 있다.
Schema First를 하기 위해선 Schema First에 맞춰 app.module을 수정해 줘야한다.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
typePaths: ['./**/*.graphql'],
definitions: {
path: join(process.cwd(), 'src/graphql.ts'),
outputAs: 'class',
},
}),
],
})
export class AppModule {}
GraphQL의 드라이버를 아폴로 서버로 하고, 각 API에서 만든 *.graphql
파일을 읽어 src/graphql.ts 파일을 생성해 준다. outputAs
옵션은 생성될 코드를 class 혹은 interface로 선택할 수 있는데, interface는 런타임 환경에서 작동하지 않으니 class를 사용하겠다.
// src/user/user.graphql
type Mutation {
createUser(createUserInput: CreateUserInput!): User!
}
type User {
id: ID!
name: String!
email: String!
created_at: DateTime!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
위는 유저 회원가입 API에 대한 Schema이다. User
타입을 지정해 주었고, 회원가입시 필요한 CreateUserInput
타입을 지정해 준뒤, Mutation 으로 유저 생성에 필요한 정보를 입력 받고, 생성된 유저의 정보를 반환한다.
// src/user/user.resolver.ts
import { Args, Mutation, Resolver } from '@nestjs/graphql'
@Resolver()
export class UserResolver {
constructor(private readonly userService: UserService) {}
@Mutation('createUser')
createUser(@Args('createUserInput') args: CreateUpdateUserDto
): Promise<UserEntity> {
return this.userService.createUser(args);
}
}
그 뒤 Resolver를 생성하고 Schema에서 선언한 Mutation을 매칭 시켜준다. GraphQL 쿼리에서 Mutation createUser
을 보낼경우 해당 리졸버가 실행된다. 그 뒤 비즈니스 로직은 REST API와 동일하며, GraphQL과 REST API의 코드 구현은 Reslover까지 차이가 있다.
Resolver까지 작성한뒤 서버를 구동하면 src폴더에 graphql.ts 파일이 생성되면, Schema First 구현방식은 끝난다.
// src/graphql.ts
export class CreateUserInput {
name: string;
email: string;
password: string;
}
export abstract class IMutation {
abstract createPost(createPostInput: CreatePostInput): Post | Promise<Post>;
abstract createUser(createUserInput: CreateUserInput): User | Promise<User>;
abstract updateUser(updateUserInput: UpdateUserInput): User | Promise<User>;
}
export class User {
id: string;
name: string;
email: string;
created_at: DateTime;
}
graphql.ts에는 정의한 모든 Schema가 합쳐져서 생성된다. 생성 방식을 interface로 지정했을 경우 class가 아닌 interface로 생성된다.
생성한 Schema와 resolver의 네이밍이 일치하지 않을 경우, 네이밍이 일치하지 않는다는 에러를 맞이하게 될것이다. 에러를 마주했을 때 네이밍을 다시 확인 해보자
Code First 또한 Schema First와 app.module을 다르게 설정해 줘야한다.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
}),
],
})
export class AppModule {}
자동으로 생성될 SchemaFile의 경로와 이름을 정해주고 sortSchema는 생성된 Schema안의 내용을 정렬해 주는 기능이다.
// src/recipes/models/recipe.model.ts
import { Directive, Field, ID, ObjectType } from '@nestjs/graphql';
@ObjectType({ description: 'recipe' })
export class Recipe {
@Field(type => ID)
id: string;
@Directive('@upper')
title: string;
@Field({ nullable: true })
description?: string;
@Field()
creationDate: Date;
@Field(type => [String])
ingredients: string[];
}
Schema First가 Schema를 생성한 것처럼 Code First는 코드를 통해 타입을 지정해 준다.
각 컬럼의 키 값과 타입을 TypeORM의 Entity를 정의하듯이 클라이언트에 반환할 타입을 지정해 준다.
// src/recipes/dto/new-recipe-input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, Length, MaxLength } from 'class-validator';
@InputType()
export class NewRecipeInput {
@Field()
@MaxLength(30)
title: string;
@Field({ nullable: true })
@IsOptional()
@Length(30, 255)
description?: string;
@Field(type => [String])
ingredients: string[];
}
반환할 타입을 지정해 줬으면, 생성에 필요한 데이터를 받기 위한 타입도 필요하다. @InputType
데코레이터를 통해 입력값에 대한 정보를 할당 하고 class-validator 라이브러리를 사용해 해당 값을 검증 해주자
// src/recipes/recipes.resolver.ts
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { NewRecipeInput } from './dto/new-recipe.input';
import { Recipe } from './models/recipe.model';
import { RecipesService } from './recipes.service';
@Resolver(of => Recipe)
export class RecipesResolver {
constructor(private readonly recipesService: RecipesService) {}
@Mutation(returns => Recipe)
async addRecipe( @Args('newRecipeData') newRecipeData: NewRecipeInput,
): Promise<Recipe> {
const recipe = await this.recipesService.create(newRecipeData);
return recipe;
}
}
Resolver에서 해당 Resolver의 타입을 지정해 주고, @Mutation
데코레이터에 반환 타입을 지정해 준다.
그 외 데이터를 받아와 비즈니스 로직을 진행하는 것은 Schema First와 동일하게 진행 된다.
Code First의 경우 함수의 네이밍을 매칭할 필요가 없어서 Schema First에 비해 신경 쓸것이 적다.
// ./schema.gql
type Recipe {
id: ID!
description: String
creationDate: Date!
ingredients: [String!]!
title: String!
}
type Mutation {
addRecipe(newRecipeData: NewRecipeInput!): Recipe!
}
input NewRecipeInput {
title: String!
description: String
ingredients: [String!]!
}
Resolver까지 생성하고 서버를 실행시키면 app.module.ts에 정의해둔 위치에 Schema가 자동으로 생성 된다.
오늘은 GraphQL에서 사용되고 있는 두 가지 구현방식을 알아 보았다.
Schema First는 코드의 문맥을 쉽게 파악할 수 있어 초기 개발에는 좋을거 같지만, Resolver와 Schema를 계속 동기화 시켜줘야 한다는것에 큰 장애물이 있다.
Schema를 정의할 때는 자동완성이 지원하지 않아 코드를 짜면서 계속 Schema와 Resolver를 같이 띄워놓고 작업을 했는데, 프로젝트가 커질수록 휴먼 에러가 나게 되고 이는 개발에 큰 걸림돌이 될것이다.
하지만 Code First는 Code를 기반으로 Schema가 만들어 지기 때문에 일치성을 따지지 않아도 되서 좋은것 같다.
이전에 Schema First로 간단한 CRUD를 만들어 봤는데 다음에는 Code First로도 한번 진행해볼 예정이다.