-
graphst를 사용한 API서버(graphst-api)와 리펙토링graphst프로젝트 2023. 8. 10. 19:43
이번 포스팅에선 graphst를 사용해 만든 API에서 graphst를 어떤 식으로 활용해야 할지 살펴보고
graphst를 사용해 리펙토링까지 해보는 시간까지 가져보겠습니다.
자세한 코드는 github를 참고해주세요
import { GraphstServer } from 'graphst'; import { DataSource } from 'typeorm'; import { UserResolver } from './user/resolver/user.resolver'; import { JwtMiddleware } from './jwt/jwt.middleware'; const dataSource = new DataSource({ ... }); dataSource .initialize() .then(() => { console.log('Data Source has been initialized! 🛠️'); }) .catch((err) => { console.error('Error during Data Source initialization', err); }) .finally(() => { const server = new GraphstServer({ providers: [ { key: DataSource, instance: dataSource, }, ], resolvers: [ UserResolver, ... ], middlewares: [JwtMiddleware], }); server.start(4000, () => { console.log('Server start port: 4000'); console.log('Happy Hacking! 🚀🚀🚀'); }); });
위 코드만으로 아주 간단하게 graphst서버를 실행할 수 있습니다.
리졸버만 연결해 주면 됩니다. 리졸버가 불려지면 키로 가지고 있는 object class의 데코레이터도 호출되고 연쇄적으로 field부터 다른 데코레이터까지 자동으로 호출되어 데이터가 수집될 겁니다.
단 typeorm 같은 외부 class을 inject 해서 사용하고 싶다면 providers에 키와 함께 인스턴스를 넣어주어야 합니다.
DateScource에 @Injectable데코레이터를 붙일 순 없으니까요.
@Inject의 키를 class로 받기 때문에 해당 인스턴스를 찾아 전체 코드에서 공유할 수 있습니다.
저번 포스팅에서 middlewares에 대해 설명한 적이 있긴 하지만 글로벌 미들웨어에 대해서 설명하지는 않았습니다.
미들웨어는 모든 resolver에 공통적으로 적용될 global middleware, resolver class의 하위 메서드에 모두 적용될 middleware, 각각의 메서드에 적용될 middleware 3가지가 있습니다 우선순위는 global, resolver, method순으로 미들웨어가 해석됩니다.
위 코드에 추가된 글로벌 미들웨어는 jwt를 분석해 payload를 context에 추가해 주는 미들웨어를 넣어주었습니다.
@Entity() @ObjectType() export class User extends BaseEntity { @Field(() => GraphQLNonNull(GraphQLID)) @PrimaryGeneratedColumn() id!: number; @Field(() => GraphQLNonNull(GraphQLString)) @Column({ type: String }) name!: string; ... }
위 같은 방식으로 object를 만들 겁니다. nest랑 똑같죠??
Entitiy
@Entity() @Injectable() export class Like extends BaseEntity { @PrimaryGeneratedColumn() id!: number; @Column({ type: String, name: 'target_id' }) targetId!: string; @Column({ type: String, name: 'target_type' }) targetType!: LikeTargetType; @Column({ type: String, name: 'user_id' }) userId!: string; @Column({ type: 'timestamp', name: 'created_at', readonly: true, default: () => 'CURRENT_TIMESTAMP', }) createAt!: number; }
위 class가 이번 포스팅에서 중점적으로 살펴볼 object입니다.
graphst코드는 하나도 없는데 이는 맵핑테이블로 사용해 client에 schema로 노출시키지 않을 것이기 때문입니다.
like테이블은 어떤 유저가 유저, 댓글, 포스팅 등등 target으로 지정된 테이블의 관계를 정의합니다. 이런 식으로 테이블을 짜면
각각의 resolver에서 공통되게 사용할 like service를 만드는 데에 도움이 됩니다.
like관련 resolver는 두 가지를 만들 겁니다.
하나는 토큰이 없는 요청을 허용할 resolver, 또 하나는 토큰을 검사해 통과하지 못하면 에러를 리턴 시킬 resolver그룹입니다.
두 번째 resolver엔 mutation 같은 민감한 요청들이 들어갈 예정입니다.
LikeResolver
// JWT검사를 하지 않는 resovler @Resolver(() => Like) export class LikeResolver { // LikeService를 주입받아 사용할겁니다. @Inject(() => LikeService) likeService!: LikeService; @FieldResolver({ parent: () => User, returnType: () => GraphQLNonNull(GraphQLInt), }) async countFollower(parent: User): Promise<number> { return this.likeService.countLikeByTargetLoader.load({ targetType: LikeTargetType.User, targetId: `${parent.id}`, }); } @FieldResolver({ parent: () => Post, returnType: () => GraphQLNonNull(GraphQLInt), name: 'countLike', }) async countLikeByPost(parent: Post): Promise<number> { return this.likeService.countLikeByTargetLoader.load({ targetType: LikeTargetType.Post, targetId: `${parent.id}`, }); } }
일반적인 LikeResolver에선 fieldResolver 같은 민감하지 않은 resolver를 추가합니다.
gql로 요청을 하면 아래처럼 요청할 수 있게 되죠
user {
countFollower
}
post {
countLike
}
두 FieldResolver에선 dataLoader를 사용하고 있는데 인자를 한 곳에 모아 처리해 RDBMS query를 한 번만 보내게 해주는 고마운 친구로 graphql을 쓴다면 필수라고 생각합니다.
LikeService가 Contaior에서 단 하나의 인스턴스만 생성되었기 때문에 다른 class에서 해당 서비스를 사용해도 하나의 dataLoader에 모일 수 있게 됩니다. 또한 맵핑 테이블로 설계했기 때문에 포스팅의 좋아요, 유저의 팔로워를 얻는 데에 단 하나의 query만 쏴도 됩니다.
포스팅 하나당 포스팅을 작성한 유저도 같이 불러온다고 가정해 봅시다.
만약 서비스를 공유하지 않았다면 포스팅 10개를 불러올 땐 총 10번의 query요청을 해야 합니다.
거기에 맵핑테이블로 만들지 않았다면 user, post에 대한 요청을 따로 보내야 하니 20번까지 늘어나겠죠
LikeVerifiedResolver
// 인증된 요청만 통과시켜주는 middleware를 추가한 resolver @Resolver({ key: () => Like, middlewares: [AuthGuardMiddleware], }) export class LikeVerifiedResolver { @Inject(() => LikeService) likeService!: LikeService; @Mutation({ args: { targetId: () => GraphQLNonNull(GraphQLString), like: () => GraphQLNonNull(GraphQLBoolean), }, returnType: () => GraphQLBoolean, name: 'toggleLikeCommentLike', }) @Mutation({ args: { targetId: () => GraphQLNonNull(GraphQLString), like: () => GraphQLNonNull(GraphQLBoolean), }, returnType: () => GraphQLBoolean, name: 'toggleLikeCommentUnlike', }) @Mutation({ args: { targetId: () => GraphQLNonNull(GraphQLString), like: () => GraphQLNonNull(GraphQLBoolean), }, returnType: () => GraphQLBoolean, name: 'toggleLikePost', }) @Mutation({ args: { targetId: () => GraphQLNonNull(GraphQLString), like: () => GraphQLNonNull(GraphQLBoolean), }, returnType: () => GraphQLBoolean, name: 'toggleLikeUser', }) async toggleLike( _: null, args: { targetId: string; like: boolean }, ctx: verifiedAuthContext, info: { fieldName: string } ) { const targetType = info.fieldName === 'toggleLikeUser' ? LikeTargetType.User : info.fieldName === 'toggleLikePost' ? LikeTargetType.Post : info.fieldName === 'toggleLikeCommentLike' ? LikeTargetType.CommentLike : info.fieldName === 'toggleLikeCommentUnlike' ? LikeTargetType.CommentUnlike : null; if (!targetType) { throw new Error('Invalid target type by toggleLike'); } if (args.like) { await this.likeService.createLikeLink( targetType, args.targetId, `${ctx.auth.id}` ); } else { await this.likeService.deleteLikeLink( targetType, args.targetId, `${ctx.auth.id}` ); } return true; } }
AuthGuardMiddleware로 인해 context의 payload가 없는 경우는 해당 class에 있는 resolver엔 접근할 수 없을 테니 mutation들은 거리낌 없이 context.auth를 사용할 수 있습니다.
반대로 일반 LikeResolver에선 context.auth가 없을 수도 있다는 점을 염두하고 코드를 짜야합니다.
@Mutation데코레이터를 여러 번 사용해서 단 하나의 method에 연결해 코드 중복을 최대한으로 줄여 mutation을 만들어봤습니다.
위 코드로 인해 schema는 아래처럼 잘 생성됩니다.
type Mutation { toggleLikeUser(targetId: String!, like: Boolean!): Boolean toggleLikePost(targetId: String!, like: Boolean!): Boolean toggleLikeCommentUnlike(targetId: String!, like: Boolean!): Boolean toggleLikeCommentLike(targetId: String!, like: Boolean!): Boolean }
리펙토링을 해보자
위 코드에 mutation그룹엔 아주 큰 문제가 두 개나 있습니다.
- mutation이 추가될 때마다 tagetType을 도출하기 위한 코드 또한 같이 길어집니다.
- mutation이름이 변경되면 targetType을 도출하는 코드 또한 수정해야 합니다.
const targetType = info.fieldName === 'toggleLikeUser' ? LikeTargetType.User : info.fieldName === 'toggleLikePost' ? LikeTargetType.Post : info.fieldName === 'toggleLikeCommentLike' ? LikeTargetType.CommentLike : info.fieldName === 'toggleLikeCommentUnlike' ? LikeTargetType.CommentUnlike : null;
결국 위 코드가 문제가 됩니다. "toggleLikeUser"라는 mutaion이름이 바뀌는데 위 코드를 수정하지 않으면 끔찍한 사이드 이펙트가 되겠죠 또한 새로운 mutaion이 추가되었을 때에도 수정해줘야 하는 것은 똑같습니다.
게다가 mutation하나의 잘못으로 다른 mutation도 오염된 메서드를 공유해야 한다니...
if (!targetType) {
throw new Error('Invalid target type by toggleLike');
}이 부분도 맘에 들지 않는군요... 정말이지 bad smell이 풀풀 나는 끔찍한 코드입니다.
하지만 graphst의 미들웨어를 사용하면 좀 더 깔끔하고 에러를 만들지 않을 코드를 작성할 수 있습니다.
import { Injectable, MiddlewareInterface } from 'graphst'; export function createLikeTypeMiddleware(type: LikeTargetType) { @Injectable() class LikeMiddleware implements MiddlewareInterface { handle( props: GraphstApiProps, next: (props?: GraphstApiProps | undefined) => void ): void | Promise<void> { return next({ ...props, context: { ...props.context, likeTargetType: type, }, }); } } return LikeMiddleware; }
인자로 받은 타입을 context에 추가해 주는 아주 간단한 미들웨어 생성함수를 만들어봤습니다.
해당 함수에서 생성되는 class는 각각 독립적으로 Containor에 저장됩니다. 해당 class를 리턴했기 때문에 나중에 그 class로 알맞은 미들웨어를 찾아 사용할 수 있습니다.
이제 각각의 요청마다 원하는 타입의 타깃을 메서드에게 알려줄 수 있게 되었습니다.
위에 targetType을 구하는 더러운 코드도 치울 수 있죠
최종적으로 완성된 코드는 아래와 같습니다.
@Resolver({ key: () => Like, middlewares: [AuthGuardMiddleware], }) export class LikeVerifiedResolver { @Inject(() => LikeService) likeService!: LikeService; @Mutation({ args: { targetId: () => GraphQLNonNull(GraphQLString), like: () => GraphQLNonNull(GraphQLBoolean), }, middlewares: [createLikeTypeMiddleware(LikeTargetType.CommentLike)], returnType: () => GraphQLBoolean, name: 'toggleLikeCommentLike', }) @Mutation({ args: { targetId: () => GraphQLNonNull(GraphQLString), like: () => GraphQLNonNull(GraphQLBoolean), }, middlewares: [createLikeTypeMiddleware(LikeTargetType.CommentUnlike)], returnType: () => GraphQLBoolean, name: 'toggleLikeCommentUnlike', }) @Mutation({ args: { targetId: () => GraphQLNonNull(GraphQLString), like: () => GraphQLNonNull(GraphQLBoolean), }, middlewares: [createLikeTypeMiddleware(LikeTargetType.Post)], returnType: () => GraphQLBoolean, name: 'toggleLikePost', }) @Mutation({ args: { targetId: () => GraphQLNonNull(GraphQLString), like: () => GraphQLNonNull(GraphQLBoolean), }, middlewares: [createLikeTypeMiddleware(LikeTargetType.User)], returnType: () => GraphQLBoolean, name: 'toggleLikeUser', }) async toggleLike( // 이 위치는 부모요소가 올 자리입니다. mutation에선 없죠 _: null, args: { targetId: string; like: boolean }, ctx: VerifiedLikeContext ) { if (args.like) { await this.likeService.createLikeLink( ctx.likeTargetType, args.targetId, `${ctx.auth.id}` ); } else { await this.likeService.deleteLikeLink( ctx.likeTargetType, args.targetId, `${ctx.auth.id}` ); } return true; } }
이로써 만약 미들웨어를 까먹었다고 해도 mutaion하나의 책임이지 다른 mutaion과 메서드에 에러를 전파하지 않는 코드가 되었습니다.
또한 새로운 count관련 mutaion을 추가한다면 @Mutation만 추가하고 메서드는 건들지 않아도 됩니다.
코드 수명이 좀 더 늘어난 것 같아서 만족스럽군요
마치며...
이번 포스팅에선 간단히 graphst를 사용해 graphql server를 만드는 방법도 살펴보고 middleware를 사용해 리펙토링도 진행해 보았습니다.
graphst는 딱히 Docs도 없고 github의 readme에 작성된 사용법이 다입니다. 그럴 일은 없겠지만 graphst사용법을 궁금해하는 사람이 있다면 해당 포스팅을 참고할 수 있으면 좋겠네요.
다음 포스팅엔 만든 graphst서버를 Aws와 Github Actions을 사용해 배포 + 자동화까지 해보겠습니다.
늘 부족한 글을 끝까지 읽어주셔서 감사합니다.
'graphst프로젝트' 카테고리의 다른 글
Parameter Decorator추가 (4) 2023.08.14 API서버 배포하기(Docker + Aws + Github) (0) 2023.08.11 schema, resolver 자동생성 (2) ...또 Circular (0) 2023.08.09 schema, resolver 자동생성 (1) (0) 2023.08.09 graphst decorator 살펴보기 (0) 2023.08.08