[NestJS] Subscription과 Guard 사용하기
NestJS를 이용해서 실시간 통신을 구현해야 하는 상황이 주어졌다. 이를 위해서 실시간 통신에 관련한 자료를 찾아보니 WebSocket 관련한 자료가 많이 나왔다. 하지만 현재 사용중인 서버는 Graphql을 사용하므로, Graphql에서 지원하는 것들 위주로 찾아보니 Subscription이라는 기능을 발견했다. 이제 이를 이용해서 실시간 통신을 구현함과 동시에, HTTP Header를 이용해 보안까지 같이 챙겨보자.
Subscription 이란?
Subscription에 관해서 알아보기 전에 Rest API 와 GraphQL의 차이에 대해서 정말 간단히 정리해봐야겠다. Rest API와 GraphQL은 서버에 무언가를 요청하여 응답을 받을 수 있는 하나의 약속이라고 볼 수 있다. 하지만 둘의 사용 방식에 대해서 차이점이 있다. Rest API는 특정한 작업을 하기 위해서는 각 명령과 그에 맞는 EndPoint를 조합해서 명령을 보내고, 이에 맞게 모든 경우에 대해서 서버단에서 처리하는 로직을 작성해야 했다. 이에 반해 GraphQL은 Rest API의 다양한 요청 (POST, GET, PATCH...) 들을 Query와 Mutation을 통합시켰으며, 원하는 데이터를 받고 싶으면 클라이언트에서 따로 요청을 할 수도 있다.
RestAPI는 단순한 CRUD 작업이 아닌 요청이나 요청의 구조가 정해져 있을 때 사용하면 좋고, GraphQL은 주 작업이 CRUD 작업인 동시에 요청하는 데이터가 매우 다양할 때 사용하면 좋다고 생각한다. 무엇을 사용할지는 사용자의 니즈에 따라 다를 것이다.
위에 대한 내용 정리는 GraphQL 문서에 설명이 되어 있다.
https://www.howtographql.com/basics/1-graphql-is-the-better-rest/
여튼 GraphQL은 Query, Mutation 이 두개가 CURD의 모든 것을 담당한다. 하지만 여기서 실시간 구독을 위한 WebSocket 같은 기능은 보이지 않는다. 그래서 GraphQL에는 Subscription이 있다. Subscription을 통해서 웹소켓의 기능을 대신할 수 있다.
로직 작성
기존의 요청은 Graphql.module 안의 context로 받아온다. 하지만 subscription은 installSubscribeHandler를 활성화 한 다음 subscription 옵션을 주어야 한다. 글 작성 기준으로 subscription-transport-ws 버전이 지원되고 graphql-ws 는 deprecated 되었다. 전자의 옵션을 사용하자. 이를 이용하여 입력받은 parameter를 미들웨어로 보내줄 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
import { CacheModule, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { join } from 'path/posix';
import { UserModule } from './resolver/user/user.module';
import { AuthModule } from './resolver/auth/auth.module';
import { CronModule } from './utils/cron/cron.module';
import { ScheduleModule } from '@nestjs/schedule';
import { WeatherModule } from './utils/weather/weather.module';
// Code First로 작성
@Module({
imports: [
GraphQLModule.forRoot({
autoSchemaFile: true,
installSubscriptionHandlers: true,
// subscription
subscriptions: {
'subscriptions-transport-ws': {
onConnect: (connectionParams) => {
return connectionParams;
},
},
},
// context
context: async ({ req, connection }) => {
if (req) {
return req;
} else {
return connection;
}
},
}),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: join(`${__dirname}/env/.${process.env.NODE_ENV}.env`),
}),
UserModule,
AuthModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
|
cs |
위의 코드는 기존의 Query, Mutation도 대응 가능하게 context 항목도 만들어 두었다.
다음에는 UserService에 유저가 존재하는지를 판별하는 메서드를 하나 만들어두자. 이 메서드는 사용자의 id와 name을 받아서 해당 사용자가 존재하는지 판별하는 로직이다. 반환값은 boolean으로 한다. 이 로직은 간단히 작성 가능하니 넘기겠다.
그 다음 여기서는 Jwt 토큰을 이용한 인증 로직을 작성할 것이다. 여기서는 Passport라는 기능을 사용할 것이다. 기존의 nodejs에서도 사용가능하지만, NestJS에서는 이 기능을 확장 모듈로 넣어 버렸다. 이를 이용해서 validate라는 함수 안에 가드에 대한 내용을 넣어 둔다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { SignUpJwt } from './dto/sign-up-jwt';
import { AuthService } from './auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECREAT,
});
}
validate = async (payload: SignUpJwt) => {
if (!payload) throw new UnauthorizedException();
const { id, name, env } = payload;
const user = await this.authService.validateUser(name);
const NODE_ENV = process.env.NODE_ENV;
if (!user || id !== user.id || env !== NODE_ENV)
throw new UnauthorizedException();
return user;
};
}
|
cs |
마지막으로 각 요청에 decorator를 통해서 가드를 넣어 주어야 한다. 가드에서는 요청받은 정보들을 해석해서 서버에 접근을 하게 할 것인지, 아니면 접근을 막을 것인지 판별할수 있게 한다. 여기서 입력값은 GqlExecutionContext.create(context).getContext(); 를 사용하면 받아올 수 있으며, 각 요청에 대해서 log를 찍어 보면 어떠한 값들이 넘어오는지 알 수 있을 것이다. 이곳의 로직과 UserService 안에서 만들어 두었던 해당 사용자의 유효함을 판별하는 로직까지 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Observable } from 'rxjs/internal/Observable';
import { AuthService } from './auth.service';
@Injectable()
export class GqlAuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const gqlContext = GqlExecutionContext.create(context).getContext();
// connection (subscription logic)
if (gqlContext?.Authorization) {
try {
return this.authService.validateJwtToken(
gqlContext.Authorization.toString().replace('Bearer ', ''),
);
} catch (e) {
console.log(JSON.stringify(e));
return false;
}
}
// res (query, mutation logic)
try {
return this.authService.validateJwtToken(
gqlContext.req.headers.authorization.toString().replace('Bearer ', ''),
);
} catch (e) {
console.log(JSON.stringify(e));
return false;
}
}
}
|
cs |
추가사항
subscription 기능을 사용할려고 하면 이를 지원하는 서버가 하나 더 있어야 한다. 이를 pubsub으로 칭하는 것 같다. graphql의 기본 구성품에는 pubsub이 내장되어 있어서 간단한 프로젝트에는 그냥 사용해도 된다. 하지만 실제 서비스를 배포할때는 외부 pubsub을 사용하는 것을 권장한다. 대표적으로 redis, google pubsub 등이 있다. 나는 redis를 사용하기로 했다. 사실 공식 문서에 잘 나와 있지만, 내가 구현한 것을 공유할려고 한다.
https://docs.nestjs.com/graphql/subscriptions
https://github.com/apollographql/graphql-subscriptions#pubsub-implementations
userValue의 type을 타고타고 들어가다보면 대강 어떠한 값들이 있는지 알 수 있다. 나의 경우에는 아래의 설정으로 redis pubsub을 구성했다. (물론 ioredis 와 type의 설치까지 완료) redis의 pubsub은 아쉽게도 자체 보안 기능이 없다. 실제 배포할 때는 private으로 열어두어야 할 것 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
import { Module } from '@nestjs/common';
import { RedisPubSub } from 'graphql-redis-subscriptions';
import { PrismaService } from 'src/utils/prisma-serivce/prisma.service';
import { UserResolver } from './user.resolver';
import { UserService } from './user.service';
import * as Redis from 'ioredis';
const options = {
host: process.env.PUBSUB_HOST,
port: +process.env.PUBSUB_PORT,
connectTimeout: 1000,
retryStrategy: times => {
// reconnect after
return Math.min(times * 50, 2000);
}
};
@Module({
providers: [
UserService,
UserResolver,
PrismaService,
{
provide: 'PUB_SUB',
useValue: new RedisPubSub({
publisher: new Redis.default(options),
subscriber: new Redis.default(options),
}),
},
],
})
export class UserModule {}
|
cs |
결과
잘못된 토큰을 전송했을 때 나오는 결과
정상적인 토큰을 전송했을 경우