본문으로 바로가기

graphql yoga에서는 pubsub(graphql-subscriptions) 을 이용하여 subscription을 사용해보았습니다.

darrengwon.tistory.com/931?category=900016

 

여기서는 Nest에서 Subscription을 사용하는 방법을 알아보려고 합니다.

역시나 개발 시에는 graphql-subscriptions

실제 배포시에는 graphql-redis-subscriptions (저는 Redis가 좋습니다...)

 

docs.nestjs.com/graphql/subscriptions

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac

docs.nestjs.com

 

Enable subscriptions

 

To enable subscriptions, set the installSubscriptionHandlers property to true.

GraphQLModule.forRoot({ installSubscriptionHandlers: true, })

 

 

Context function param: connection

 

Next에는 없지만 중요하니 apollo 관련 문서를 읽어봅시다.

www.apollographql.com/docs/apollo-server/data/subscriptions/#context-with-subscriptions

 

The function to create a context for subscriptions includes connection, while the function for Queries and Mutations contains the arguments for the integration, in express's case req and res. This means that the context creation function needs to check the input. This is especially important, since the auth tokens are handled differently depending on the transport:

 

그러니까, context function의 인자로 subscription은 connection, query와 mutation은 req, res를 가지고 있습니다. 

이를 구분하는 것은 중요한데, auth token이 어떤 프로토콜을 사용하는지에 따라 다르게 다뤄져야 하기 때문입니다.

connection이 있으면 ws 프로토콜, req가 있으면 http 프로토콜.

매 요청마다 들어오는 req와는 달리 connection은 ws 연결되는 시점 1번만 출력된다.

const server = new ApolloServer({
  schema,
  context: async ({ req, connection }) => {
    if (connection) {
      // check connection for metadata
      return connection.context;
    } else {
      // check from req
      const token = req.headers.authorization || "";

      return { token };
    }
  },
});

connection contains various metadata, found here.

문서의 connection에는 아래와 같은 meta 데이터가 있습니다. 적절히 사용하시면 됩니다.

[
  'query',
  'variables',
  'operationName',
  'context',
  'formatResponse',
  'formatError',
  'callback',
  'schema'
]

 

 

 

Code first 방식에서의 Subscription 기본

 

schema first 방식은 여기서는 다루지 않겠습니다.

 

graphql-subscriptions의 pubsub을 이용하여 아래와 같이 Subscription resolver를 작성할 수 있습니다.

Nest에서도 경고하고 있지만, production에서는 적합하지 않습니다.

PubSub is a class that exposes a simple publish and subscribe API. Read more about it here. Note that the Apollo docs warn that the default implementation is not suitable for production (read more here). Production apps should use a PubSub implementation backed by an external store (read more here).
예를 들면 Redis, Memcache와 같은. 외부 strore를 사용하라는 것이죠. Redis는 제 블로그에서도 정리해두었으니 참고!
const pubSub = new PubSub();

@Resolver(of => Author)
export class AuthorResolver {
  // ...
  @Subscription(returns => Comment)
  commentAdded() {
    return pubSub.asyncIterator('commentAdded');
  }
}

 

* pub/sub model에 대해서는 아래 Redis와 곁들여 쓴 글을 보시면 쉽게 이해하실 수 있습니다.

darrengwon.tistory.com/1076

 

Redis : pub/sub model

graphql 쓸 때 pub/sub을 사용해본 적이 있을 것이다. pub/sub 모델은 특정한 주제(Topic)에 대하여 구독(Subscribe)한 모두에게 메시지를 발생(Publish)하는 통신 방법이다. 구독 시대를 살고 있어서 자연스

darrengwon.tistory.com

 

이제 기본적인 pub/sub 모델을 Subscription을 이용하여 만들어봅시다.

 

1. 특정 Subscription에서 들을 수 있는 topic을 지정합니다. pubsub.asyncIterator('topic')

2. subscription을 해줬으니 publish를 어디선가 해줘야겠죠. pubsub.publish('topic', {subName: any}) 꼴로 보내줍니다.

3. 이제, subscription을 시작하고, publish를 해주면 publish의 payload 부분을 받아볼 수 있습니다.

 

* 현재 어떤 유저라도 Subscription을 할 수 있습니다. 이를 방지하기 위해선 별도의 조치를 취해야 합니다.

import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

@Resolver(() => Order)
export class OrderResolver {

  ... 중략

  @Mutation(() => Boolean)
  anythingReady() {
    pubsub.publish('anything', { anythingSubscription: 'publishing by anythingReady' });
    return true;
  }

  @Subscription(() => String)
  anythingSubscription() {
    return pubsub.asyncIterator('anything');
  }
}

 

 

 

global pubsub

 

pubsub을 특정 resolver에서만 두는 것은 좋지 않습니다. 모든 resolver에서 사용할 수 있도록 최상위 경로에 두는 것이 좋습니다.

 

따라서 Global 모듈을 하나 만든 후, provider로 제공해준 다음에 appModule에 추가해주었습니다.

export에 주의합시다.

@Global()
@Module({
  providers: [{ provide: PUB_SUB, useValue: new PubSub() }],
  exports: [PUB_SUB],
})
export class CommonModule {}

 

@Inject해서 사용하면 됩니다.

@Resolver(() => Order)
export class OrderResolver {
  constructor(private readonly orderService: OrderService, @Inject(PUB_SUB) private readonly pubSub: PubSub) {}

  @Mutation(() => Boolean)
  anythingReady() {
    this.pubSub.publish('anything', { anythingSubscription: 'publishing by anythingReady' });
    return true;
  }

  @Subscription(() => String)
  @Role(['Any'])
  anythingSubscription(@AuthUser() user: User) {
    console.log(user);
    return this.pubSub.asyncIterator('anything');
  }
}

 

 

Authentication Over WebSocket

graphql-yoga에서는 withFilter를 사용하여 특정 유저만 Subscription을 할 수 있도록 한 바 있습니다.

darrengwon.tistory.com/932?category=900016

 

Authentication Over WebSocket 을 위한 subscription filter 적용기

subscription을 이용해 소켓 통신을 하는 것은 좋습니다. 그러나 특정 유저만 사용하게끔 authentication을 덧붙여야할 때가 있습니다. 1. Yoga Server 설정 바꾸기 우선 이를 위해서는 기존 Yoga의 subscriptions

darrengwon.tistory.com

 

이번에는 Next에서 Subscription을 특정 유저만 사용할 수 있도록 보호해봅시다.

 

일반적으로, http에 jwt 토큰을 주고, decode하는 작업에서는 아래와 같은 절차를 걸쳐왔습니다.

[http 메서드만 사용할 경우 토큰을 이용해 authentication하기 ]

(1) http 메서드에서 header에 토큰을 보낸다. 
(2) jwtMiddleware를 appmodule에 달아 req에 user를 달아준다 
(3) GraphQLModule의 req에서 user를 추출하여 context에 담아준다. 
(4) 이 grapqhl의 context에서 return하는 값은 @Context() context를 통해 user를 resolver에서 사용할 수 있다. 
혹은, customDecorator에서 context를 통해 추출하여 한 번 거른 다음 resolver에서 사용할 수도 있다. 
이 context는 guard에서 canActivate 함수의 인자인 ExecutionContext에서도 사용할 수도 있다. 

 

그러나 ws(Subscription)을 사용한다면 jwtMiddleware를 사용할 수 없습니다

AppModule에서 미들웨어를 지워주고 대신 Guard를 활용해보자.

 

1. 우선, 미들웨어 없이 곧바로 넘어온 request(http) 혹은 connection(ws)를 받은 GraphQLModule에서 context를 통해 헤더에 붙어온 토큰을 받아 다른 곳에서 context로 활용할 수 있도록 return해줍니다.

context: ({ req, connection }) => {
  const TOKEN_KEY = 'x-jwt';
  // http
  if (req) {
    return { token: req.headers[TOKEN_KEY] }; // 헤더에 있는 토큰만 쓸거니 헤더 중 토큰만 솎아서 보내자
  }
  // ws. connection은 ws 연결시 딱 한 번만 반환됨
  if (connection) {
    return { token: connection.context[TOKEN_KEY] }; // connection.context 에 토큰이 들어 있다
  }
},

 

2. 해당 context에서 찾은 token을 이용하여 유저를 찾고, 이를 context에 담아서 다른 곳에서도 사용할 수 있도록 조치합니다.

@Injectable()
export class AuthGaurd implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly jwtService: JwtService,
    private readonly userService: UsersService,
  ) {}

  async canActivate(context: ExecutionContext) {
    // gql-context
    const gqlContext = GqlExecutionContext.create(context).getContext(); // apollo-server context를 받기 위한 과정
    const token = gqlContext.token;

    if (token) {
      const decoded = this.jwtService.verify(token);

      if (typeof decoded === 'object' || decoded.hasOwnProperty('id')) {
        // 유저 식별 성공 하면 req 객체에 담아 context를 통해 resolver 전역에서 사용 가능하게끔
        const { user } = await this.userService.findById(decoded['id']);

        if (!user) {
          return false; // 찾는 user 없으면 guard가 막기
        }

		// 다른 곳에서도 식별된 user를 사용할 수 있도록 context에 달아주기
        gqlContext['user'] = user;
        
        return true
      } else {
        return false;
      }
    } else {
      return false;
    }
  }
}

 

이제 해당 context를 이용하여 적절하게 원하는 대로 사용하면 됩니다.

 

@Context() context를 통해 resolver에서 쓰거나
customDecorator에서 context를 통해 user에 어떤 조작을 가하거나...

customDecorator의 경우 아래와 같이 사용할 수 있습니다.

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const AuthUser = createParamDecorator((data: unknown, context: ExecutionContext) => {
  const gqlContext = GqlExecutionContext.create(context).getContext(); // apollo-server context를 받기 위한 과정
  const user = gqlContext['user']; // Guard에서 gqlContext에 user를 넣어 줬음.

  return user;
});

 

 

 

Subscription option : (1) filtering

docs.nestjs.com/graphql/subscriptions#filtering-subscriptions

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac

docs.nestjs.com

 

Subsription한 topic 중에서도 특정 조건에 만족할 때만 반응을 보이고 싶다면, filter를 이용하면 됩니다.

 

Subscription의 정의 부분을 확인해보면, 두번째 인자로 Option이 있음을 확인할 수 있고, 

활용도가 높아 보이는 filter와 resolve를 확인할 수 있습니다. 여기서는 filter를 알아보겠습니다.

export interface SubscriptionOptions extends BaseTypeOptions {
    name?: string;
    description?: string;
    deprecationReason?: string;
    filter?: (payload: any, variables: any, context: any) => boolean | Promise<boolean>;
    resolve?: (payload: any, args: any, context: any, info: any) => any | Promise<any>;
}

export declare function Subscription(typeFunc: ReturnTypeFunc, options?: SubscriptionOptions): MethodDecorator;

 

간단히 filter의 인자인 payload, variables, context를 출력하여 각 특성에 알아보겠습니다.

It takes two arguments: payload containing the event payload (as sent by the event publisher), and variables taking any arguments passed in during the subscription request.
It returns a boolean determining whether this event should be published to client listeners.
@Subscription(() => String, {
  filter: (payload, variables, context) => {
    console.log(payload, variables, context);
    return true;
  },
})
@Role(['Any'])
anythingSubscription(@Args('anyId') anyId: number) {
  console.log(anyId);
  return this.pubSub.asyncIterator('anything');
}

 

출력 결과는 아래와 같습니다.

// publish한 곳에서 담아 보낸 payload
payload => { anythingSubscription: 'publishing by anythingReady 99' }

// 해당 Subscription에 직접 입력해준 인자
variables => { anyId: 1 }

// context. Guard에서 context에 담은 내용들이 보인다.
{
  token: 'some token',     
  req: undefined,
  user: User {
    id: 4,
    email: 'client@gmail.com',
    password: '$2b$10$yxGdva/3eQAt2FHNSBucRecz/gDlbf04S/0VP29GwszF.dkX5t2jG',
    role: 'Client',
    verified: false
  }
}

 

publish, variables, context를 목적에 맞게 사용하여 특정 조건에서만 filter가 통과하게 만들 수 있습니다.

@Subscription(() => String, {
  filter: ({ readyId }, { anyId }) => {
    return readyId === anyId;
  },
})
@Role(['Any'])
anythingSubscription(@Args('anyId') anyId: number) {
  console.log(anyId);
  return this.pubSub.asyncIterator('anything');
}

 

 

Subscription option : (2) resolve

docs.nestjs.com/graphql/subscriptions#mutating-subscription-payloads

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac

docs.nestjs.com

To mutate the published event payload, set the resolve property to a function. The function receives the event payload (as sent by the event publisher) and returns the appropriate value.

쉽게 말해서 Subscription이 출력하는 값을 변경하는 겁니다.

resolve?: (payload: any, args: any, context: any, info: any) => any | Promise<any>;

 

아래와 같이 resolve를 지정하면

@Subscription(() => String, {
  resolve: ({ readyId }) => `you recieved ${readyId}`,
})
@Role(['Any'])
anythingSubscription(@Args('anyId') anyId: number) {
  console.log(anyId);
  return this.pubSub.asyncIterator('anything');
}

아래와 같이 나옵니다.

 

 


darren, dev blog
블로그 이미지 DarrenKwonDev 님의 블로그
VISITOR 오늘 / 전체