TypeGraphQL = Server Side의 Typescript + GraphQL

augusty·2019년 10월 1일
19
post-thumbnail
  • 해당 포스트는 TypeGraphQL 을 소개하는 포스트입니다.
  • 선행 지식으로 server 개발에 대한 어느 정도의 지식, Typescript, SDL을 포함한 GraphQL에 대한 이해를 요구합니다.

GraphQL 서버에서의 타입 정의의 피로함

GraphQL을 Typescript와 함께 사용할 때 피로함과 실수를 만들어내는 부분이 바로 각각의 타입정의입니다.
하지만 생각해봅시다. 둘은 꽤나 닮은 점이 많고, 어찌보면 반복되는 작업이라고도 생각됩니다. 예를 들어볼까요?

다음은 User라는 간단한 ObjectType을 GraphQL을 사용하기 위한 SDL(Schema Definition Language)로 정의한 것입니다.

type User {
  id: ID!
  email: String!
  firstName: String
  lastName: String
  username: String
  age: Int
}

👉 username은 User테이블에 저장된 firstName과 lastName이 합쳐진 결과로 나타나게 된다고 가정할거예요

이를 타입스크립트에서 제대로 사용하기 위해선 interface(혹은 type)가 정의될 필요가 있겠죠?
작성해봅시다.

interface User {
  id: string;
  email: string;
  firstName?: string;
  lastName?: string;
  username?: string;
  age?: number;
}

실제 프로젝트에서 타입 정의는 여기서 끝나지 않고 Query, Mutaion Field 등에 대한 Resolver를 작성할 때 필요한 각각의 전달인자에 대한 타입을 타입스크립트로 정의하고, SDL의 Input Type이나 Resolver를 통해 반환되어야하는 Object Type들에 대한 정의도 SDL/Typescript로 각각 정의해야 합니다.

이런 과정속에서 우리는 실수로 SDL에서 키를 하나 빼먹는다던지, Typescript에서 키를 하나빼먹을지라도 실행하기 전까지 확실히 알기가 어렵습니다. Typescript를 써서 얻는 이점이 GraphQL의 SDL때문에 반감된다고 할수도 있을까요?

여기서 다시금 위의 코드를 곱씹어 보면 SDL과 Typescript는 당연하게도 같은 키를 공유합니다. 다만 타입이 다를 뿐이죠. 그럼 이렇게 생각을 해봅시다.
key: (GraphQL Scalar는 String!, Typescript type은 string)라는 느낌으로 정의한다면 어떨까요?
코드관리가 훨씬 편해지지 않을까요?


TypeGraphQL - 세팅

TypeGraphQL은 바로 이런 부분에서 우리를 도와줄 수 있는 Framework입니다.
작성하는 코드의 양을 줄여주고, 개발자가 관리하기에 용이하게 만들어주며, validation, authorization 등을 쉽게 도와줍니다.
하지만 프레임워크이기 때문에 많은 부분 TypeGraphQL에 의존하게 되는 단점이 있겠네요.

기존에 GraphQL을 다양한 방법으로 사용해보신 분이라면 GraphQL용 ORM인 Prisma에 대해 알거나 들어보신 적이 있으실텐데요. 가볍게 생각하면 Prisma보단 자유롭고, 그냥 SDL을 사용하는 것보단 의존도가 높은 프레임워크 정도로 이해하시면 좋을 것 같습니다.

사용법이 어렵지 않아, GraphQL과 Typescript를 사용해보셨다면 금방 이해하실 수 있을거예요.

우선 GraphQL을 사용하는 서버쪽 프로젝트에 다음과 같이 TypeGraphQL을 설치해주세요

$ yarn add type-graphql reflect-metadata

reflect-metadata는 데코레이터문법을 지원(엄밀히는 Polyfill)하기 위해 필요한 라이브러리인데요. 현재 데코레이터 문법은 정식 ECMAScript에 포함되지 않기 때문입니다. 아마 TypeORM과 같이 데코레이터 문법을 기존에 사용하고 계셨다면 아마 설치되어 있을겁니다.
자세한 내용이 알고싶으시다면 여기(한글) 혹은 여기(영문)를 참조해주세요.

설치하셨다면 서버프로젝트가 가장 먼저진입하게 되는 엔트리 파일 상단에 위와 같이 입력해주세요.

  • index.ts (실행시 최초 진입 파일)
import 'reflect-metadata';
/* ... 생략 ... */

그리고 TypeGraphQL은 몇 가지 문법지원이 필요합니다. 그렇기 때문에 tsconfig.json파일의 옵션을 다음과 같이 수정(혹은 추가)해줍시다

{
  // Decorator를 위한 metadata를 컴파일시에 내보내줍니다.
  "emitDecoratorMetadata": true,
  // ES7의 Decorator문법을 허용해줍니다.
  "experimentalDecorators": true
}

이제 TypeGraphQL을 사용할 준비가 완료되었습니다.


TypeGraphQL - Object Type

먼저 위에서 정의했던 User라는 Object Type을 다시 TypeGraphQL로 정의해볼까요?

  • User.ts
import { ObjectType, Field, Int, ID } from 'type-graphql';

@ObjectType()
export class User {
  @Field(() => ID)
  id: string;

  @Field() // () => String은 생략가능
  email: string;

  @Field({ nullable: true })
  firstName?: string;

  @Field({ nullable: true })
  lastName?: string;

  @Field({ nullable: true })
  username?: string;

  @Field(() => Int, { nullable: true })
  age?: number;
}

위와 같이 클래스 형태로 Type을 정의하고 데코레이터를 통해 클래스의 각각의 Property에 Field데코레이터로 type에 속한 필드와 리턴값을 정의함을 확인할 수 있습니다.

  • 기본 Field는 String으로 선언되기 때문에 () => String은 생략가능 합니다.
  • 기존 SDL과 다른점은 default로 설정되는 nullable에 대한 기본 처리가 typescript처럼 false라는 것인데요. 이를 통해 TypeScript와 타입을 보다 편하게 일치시킬 수 있습니다.
    • 👉 nullable옵션은 true/false 외에 "items", "itemsAndList" 라는 값도 추가적으로 가질 수 있는데요. 필드가 리스트로 들어오는 경우에 대한 nullable 처리를 할 때 사용합니다. 이건 Resolver에서 설명하겠습니다.

TypeORM과의 혼용
TypeORM을 사용해보신 분이라면 Entity정의와 굉장히 닮아있음을 확인할 수 있는데요.
다음과 같이 Entity에 Object Type을 함께 선언해서 사용할 수 있습니다.
(TypeORM에 대해 모르시거나 ORM을 사용하지 않으신다면 이 부분은 넘어가셔도 좋습니다)

  • entity/User.ts
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from 'typeorm';
import { ObjectType, Field, Int, ID } from 'type-graphql';

@ObjectType()
@Entity()
export class User extends BaseEntity {
  @Field(() => ID)
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Field()
  @Column('text', { unique: true })
  email: string;

  @Column('text')
  password: string;

  @Field({ nullable: true })
  @Column('text', { nullable: true })
  firstName?: string;

  @Field({ nullable: true })
  @Column('text', { nullable: true })
  lastName?: string;

  @Field({ nullable: true })
  username?: string;

  @Field(() => Int, { nullable: true })
  @Column('int', { nullable: true })
  age?: number;
}

만약 User에 새로운 Field가 추가되더라도 하나의 코드에서 관리하기 때문에 기존보다 관리가 더욱 용이하겠죠?

기존의 방식이라면 username은 Field Resolver로 작성해야 합니다.
username은 firstName과 lastName을 합성해서 사용할 예정이기 때문이죠.

하지만 TypeGraphQL에선 Field Resolver를 기존 ObjectType을 정의한 곳에 Field로 혹은 Resolver에서 Field Resolver로도 선언할 수 있게 해줍니다.

  • User.ts
import { ObjectType, Field, Root, Int, ID } from 'type-graphql';

@ObjectType()
export class User {
  /* ..생략.. */
  // @Field({ nullable: true })
  // username?: string;

  @Field(() => String, { nullable: true })
  username(@Root() parent: User): string | null {
    return parent.firstName && parent.lastName
      ? `${parent.firstName} ${parent.lastName}`
      : null;
  }

  /* ..생략.. */
}

TypeGraphQL에선 이런식으로 작성해도 무방합니다.

위 코드에서 @Root()는 처음 보실텐데요. 기존 Resolver는 Parent, args, context, info를 인자로 받아 사용하는 것을 아실텐데요.
Root@는 바로 이어 나올 값이 Parent라는 것을 데코레이터로 알려주는 역할을 하게 됩니다.

username은 User의 Field Resolver이기 때문에 자연스럽게 parent로 User가 오게 되고, 위와 같이 작성할 수 있게 되는 것이죠.

그렇다면 Resolver는 어떻게 작성할까요?


TypeGraphQL - Resolver(쿼리)

query Hello {
  hello
}

위와 같은 쿼리에 "hi"를 리턴하는 Query Resolver를 우선 간단하게 TypeGraphQL로 작성해봅시다.

  • UserResolver.ts
import { Resolver, Query } from 'type-graphql';

@Resolver()
export class UserResolver {
  @Query(() => String)
  async hello() {
    return 'hi';
  }
}

Object Type을 정의하던 방식과 크게 다르지 않죠?

그럼 이제 UserResolver에 모든 users에 모든 User들을 반환하는 QueryResolver를 작성해보겠습니다.

  • UserResolver.ts
import { Resolver, Query } from 'type-graphql';
import { User } from './entity/User.ts';

@Resolver()
export class UserResolver {
  /* ..생략.. */
  @Query(() => [User])
  async users() {
    // 함수의 내부는 TypeORM을 예시로 작성되었지만 다르게 작성하셔도 무방합니다.
    return await User.find();
  }
}

@Query데코레이터를 통해 리턴 값을 넣어준다는 개념이 @Field와 유사하죠?
다만 처음 보시는 것은 [User]일텐데요. GraphQL에서 스칼라 타입을 [User]로 정의하는 것과 같다고 보시면 됩니다.

  • list에서의 nullable 옵션은 true, false외에도 "items", "itemsAndList"를 넣을 수 있습니다.
    • { nullable: false } : [User!]! (default 값)
    • { nullable: true } : [User!]
    • { nullable: "items" } : [User]!
    • { nullable: "itemsAndList" } : [User]
  • 중첩된 List는 어떨까요? (선언은 다음과 같이 합니다. [[User]])
    • { nullable: false } : [[User!]!]!
    • { nullable: true } : [[User!]!]
    • { nullable: "items" } : [[User]]!
    • { nullable: "itemsAndList" } : [[User]]

TypeGraphQL - Resolver(필드)

Query Resolver에 대해 알아봤으니 아까 Field로 정의했던 username을 이번엔 Field Resolver로 작성해봅시다.

  • UserResolver.ts
import { Resolver, Query, FieldResolver } from 'type-graphql';
import { User } from './entity/User.ts';

@Resolver(() => User) //_of => User 또는 User 모두 사용가능합니다.(같은 의미)
export class UserResolver {
  /* ..생략.. */
  @FieldResolver(() => String, { nullable: true })
  async username(@Root() parent: User): Promise<string | null> {
    return parent.firstName && parent.lastName
      ? `${parent.firstName} ${parent.lastName}`
      : null;
  }
}

앞서 Field로 선언했던 username과 완전히 내용이 같죠? 다만 다른점은 @Field@FieldResolver로 바뀌었다는 점, @Resolver에서 User를 명시하고 있다는 점입니다.

User를 명시하는 것은 이 Resolver가 무엇을 위한 것인지 알려주는 것이고, 이를 통해 Parent를 User로 사용할 수 있죠.

  • 작성하면서 따라하고 계시다면 username은 쓰지 않을테니 @ResolverUserusername 메소드는 지워주세요.

TypeGraphQL - Resolver(뮤테이션, args)

User를 등록하는 register라는 Mutaion Resolver를 작성해봅시다.
register는 args로 email, password, firstName, lastName, age를 받아와서 등록이 성공하면 true, 실패하면 false를 리턴하는 resolver입니다.

import {
  Resolver,
  Query,
  FieldResolver,
  Mutation,
  Arg,
  Int,
} from 'type-graphql';
import { User } from './entity/User.ts';
import { hashPassword } from './util.ts'; // 패스워드 해시용 유틸 (예시입니다.)

@Resolver()
export class UserResolver {
  /* ..생략.. */
  @Mutation(() => Boolean)
  async register(
    @Arg('email') email: string,
    @Arg('password') password: string,
    @Arg('firstName', { nullable: true }) firstName?: string,
    @Arg('lastName', { nullable: true }) lastName?: string,
    @Arg('age', () => Int, { nullable: true }) age?: number,
  ): Promise<boolean> {
    // 내부코드는 예시이니 마음대로 작성하셔도 됩니다.
    const hashedPassword = await hashPassword(password, 12);
    try {
      await User.insert({
        email,
        password: hashedPassword,
        firstName,
        lastName,
        age,
      });
    } catch (err) {
      console.warn(err);
      return false;
    }
    return true;
  }
}

위처럼 Args역시 @Arg 데코레이터를 통해 이름, 타입, 옵션을 정의해줘서 사용하게 되며, 크게 다른 것은 Parent, Args, Context, Info 순으로 인자를 선언할 필요가 없다는 것이죠.

앞서 username에서 @Root를 통해 다음 인자가 Parent임을 알려준 것처럼, 순서 상관없이 데코레이터와 타입만 제대로 명시해주면 순서에 무관하게 편하게 사용할 수 있습니다.

하지만 위에서도 arg가 굉장히 많아보이죠? 이렇게 5개도 많아보이는데 더 많다면 가독성이 그만큼 낮아지겠죠?

TypeGraphQL은 이를 위해 @Args/@ArgsType쌍과 @Arg/@InputType쌍을 제공합니다.
위의 @Arg코드들은 다음과 같이 대치할 수 있습니다.

  • UserResolver.ts
@Resolver()
export class UserResolver {
  @Mutation(() => Boolean)
  async register(@Args() args: RegisterArgs) {
    const { email, password, firstName, lastName, age } = args;
    //생략
  }
}
  • RegisterArgs.ts
@ArgsType()
export class RegisterArgs {
  @Field()
  email: string;

  @Field()
  password: string;

  @Field({ nullable: true })
  firstName?: string;

  @Field({ nullable: true })
  lastName?: string;

  @Field(() => Int, { nullable: true, defaultValue: 20 })
  age?: number;
}

이경우 SDL이라면 아래와 같이 정의한 것과 같습니다.

type Mutation {
  register(
    email: String!
    password: String!
    firstName: String!
    lastName: String!
    age: String! = 20
  ): Boolean
}

그럼 이번엔 input type을 정의하는 방식으로 해볼까요?

  • UserResolver.ts
@Resolver()
export class UserResolver {
  @Mutation(() => Boolean)
  async register(@Arg('data') data: RegisterInput) {
    const { email, password, firstName, lastName, age } = data;
    //생략
  }
}
  • RegisterInput.ts
@InputType()
export class RegisterInput implements Partial<User> {
  @Field()
  email: string;

  @Field()
  password: string;

  @Field({ nullable: true })
  firstName?: string;

  @Field({ nullable: true })
  lastName?: string;

  @Field(() => Int, { nullable: true, defaultValue: 20 })
  age?: number;
}

우선 SDL이라면 어떻게 정의되는지 확인해봅시다.

input RegisterInput {
  email: String!
  password: String!
  firstName: String
  lastName: String
  age: Int = 20
}
type Mutation {
  register(data: RegisterInput!): Boolean
}

Args와는 다르게 input type이 새로 선언되었죠?

또한 ArgsType선언 방식과 크게 다르지 않지만 이번엔 Partial<User>를 구현해 사용했습니다. 왜일까요?

여기서 우리는 email이 string일 것을 알고 있습니다. 왜냐하면 User의 email과 같은 타입일 것이기 때문이죠. 하지만 코드는 이에 대해 알 수 없습니다.

그렇기 때문에 코드 차원에서 실수를 방지하기 위해 TypeScript의 도움을 받아 implements를 통해 email과 같은 User에 속한 값들을 확정적으로 string으로 선언할 수 밖에 없게 만든겁니다.

(Partial사용으로 인해 undefined는 막을 수 없겠지만 그걸 쓸리는 없겠죠. 만약 제대로 사용한다면 Omit을 사용하겠지만 그런 수고로움에 비해 얻는게 적기 때문에 Partial로도 충분할 것 같습니다.)


TypeGraphQL - Resolver(response)

리턴타입은 특별한 타입이 아닌 Object Type입니다. 따라서 User와 마찬가지로 Object Type으로 선언해서 @Mutation(() => RegisterResponse)등으로 사용하면 되겠죠?


TypeGraphQL - 스키마

이제 Type정의, Resolver정의 까지 끝냈으니 실제로 GraphQL모듈에 넣어야 작동할텐데요.
GraphQL 모듈에 따라 방법이 상이하기 때문에 TypeGraphQL은 두가지 방법을 제공합니다.

TypeGraphQL은 두가지 방법을 제공합니다

  1. 첫번째, 스키마를 빌드하는 방식
    (Apollo를 사용하시는 분들이 익숙하실 방식입니다.)
  • server.ts
import { buildSchema } from 'type-graphql';
import { FirstResolver, SecondResolver } from '../app/src/resolvers';

const schema = await buildSchema({
  resolvers: [FirstResolver, SampleResolver],
});

/* ======= 또는 ======= */

import { buildSchema } from 'type-graphql';

const schema = await buildSchema({
  resolvers: [
    __dirname + '/modules/**/*.resolver.ts',
    __dirname + '/resolvers/**/*.ts',
  ],
});

처럼 경로나 import를 통해 스키마를 만들어서 다음과 같이 적용하면되죠.

  • server.ts
const apolloServer = new ApolloServer({ schema });
  1. 두번째, typeDefs와 Resolver map을 만들어 schema를 만드는 방식
import { buildTypeDefsAndResolvers } from 'type-graphql';
import { makeExecutableSchema } from 'graphql-tools';

const { typeDefs, resolvers } = await buildTypeDefsAndResolvers({
  resolvers: [FirstResolver, SecondResolver],
});

const schema = makeExecutableSchema({ typeDefs, resolvers });

TypeGraphQL - 추가 기능

validation

추가적으로 class-validator라이브러리를 이용하면 클래스 property들에 대한 검증이 가능합니다.

$ yarn add class-validator

class-validator는 몇가지 데코레이터들을 제공해주는데
예를 들어 @IsEmail를 앞의 RegisterInput의 email에 선언해두면
자동으로 email형식이 아닌 값이 들어왔을 때 에러가 발생하고 "Argument Validation Error"를 가진 GraphQLFormattedError를 자동으로 반환하게 됩니다.

기존엔 Resolver내부에 체크 로직을 두어 값들을 검증한 것에 비해 굉장히 간단하게 validation할 수 있게 된거죠.

authorization

만약 유저에 따른 role이 정해져 있다면 TypeGraphQL에서 사용하는 AuthChecker를 사용해보시길 권장드립니다. 간단하거든요. (참조)

middleware

하지만 role을 사용하지 않는다면 @UseMiddleware()를 사용하시면 됩니다. 권한체크를 사용할 Resolver에 해당 데코레이터를 넣고 인자로 미들웨어 함수를 넣어주면 됩니다.

미들웨어 함수는 MiddlewareFn타입을 가지는데 인자로 actionnext를 받는 구조입니다.
action은 다음과 같습니다

  • action
{
  root, args, context, info;
}

resolver의 인자 parent, args, context, info라는게 감이 오시죠?
이값들을 통해 검증을 실시하고
만약 권한이 없다면 Authentication Error를 throw,
권한이 있다면 next();라고 선언해 resolver를 실행하게 하는 식으로 구현하면됩니다.

예시로 isAuth라는 검증미들웨어를 만들어봅시다.

export const isAuth: MiddlewareFn<CustomContext> = (
  { context },
  next,
) => {
  const authorization = context.req.headers['authorization'];
  if (!authorization) {
    throw new AuthenticationError('Unauthorized');
  }
  try {
    const payload = verify(authorization); //verify는 만든 헤더 검증용 유틸함수
    context.payload = payload;
  } catch (err) {
    console.log(err);
    throw new AuthenticationError('Unauthorized');
  }
  return next();
};

기본 contextany타입이기 때문에 현재 사용하고 있는 context를 제너릭스로 받아 정의합니다.
위 코드는 'authorization'헤더로 넘어온 값을 검증해서 context의 값으로 넘겨 사용하는 방식입니다. 이 방법으로 정상적인 헤더와 함께 요청하지 않은 유저는 자동으로 AuthenticationError를 받게 되겠죠?

공식 문서를 참조하시면 괜찮은 middleware활용 예시가 많습니다


마무리

그 밖에도 Service로직을 위한 DI(Dependency Injection), 커스텀 데코레이터 등 다양하고 강력한 기능들을 제공하고 있습니다.

TypeGraphQL은 아래와 같은 단점도 분명히 가지고 있습니다.

  1. 아직 1.0대 버전이 아니고 사용하는 유저층이나 커뮤니티가 잘 형성되어 있지는 않다.
  2. 프레임워크이기 때문에 관련 의존도가 높아진다.

하지만 간결함, 기존 Typescript에서 크게 벗어나지 않는 통일성 등은 분명 한번쯤 사용해보고 싶은 매력적인 장점으로 다가올겁니다.

Reference

오탈자 및 잘못된 내용에 대해선 댓글로 알려주시면 반영하도록 하겠습니다.
감사합니다.

1개의 댓글

comment-user-thumbnail
2019년 12월 13일

감사합니다!!!!

답글 달기