NestJS와 Apollo GraphQL은 개발자가 강력하고 확장 가능한 애플리케이션을 구축할 수 있도록 지원하는 강력한 기술입니다.
NestJS는 TypeScript를 활용해 웹 애플리케이션 구축을 체계적이고 효율적으로 제공하는 서버 측 프레임워크입니다.
모듈식 아키텍처, 종속성 주입, 다양한 내장 도구 및 라이브러리를 제공하므로 복잡한 엔터프라이즈급 애플리케이션을 구축하는 데 편의를 제공합니다.
Apollo GraphQL은 클라이언트와 서버 간의 통신을 단순화하는 데이터 그래프 레이어입니다. GraphQL 사양을 구현하여 클라이언트가 필요한 데이터를 정확하게 요청할 수 있도록 하여 이를 통해 과도하게 가져오는거나 부족하게 가져오는 문제를 최소화할 수 있습니다. Apollo는 모든 기능을 갖춘 GraphQL 서버, 간편한 클라이언트 측 통합을 위한 Apollo Client, 마이크로 서비스의 분산 그래프 구축을 위한 Apollo Federation을 포함한 다양한 도구를 제공합니다.
NestJS와 Apollo GraphQL을 결합하면 최신 웹 개발을 위한 강력한 풀 스택 솔루션을 제공합니다. NestJS의 구조화된 접근 방식은 Apollo GraphQL의 효율적인 데이터 처리를 보완하여 유지 관리 가능성, 적응성 및 성능이 뛰어난 애플리케이션을 제공합니다. 소규모 애플리케이션을 구축하든 대규모 프로젝트를 구축하든 관계없이 NestJS와 Apollo GraphQL은 개발 요구 사항을 충족하는 성공적인 조합을 이룹니다.
NestJS Server 설정
우선 nest cli를 설치합니다.
npm i -g @nestjs/cli
그 다음 nest cli를 이용해 새 프로젝트를 생성합니다.
nest new backend
여러 패키지 관리자를 선택할 수 있는데 이번에는 npm을 선택합니다.
Nest는 기본적으로 Express 프레임워크 방식을 사용합니다. 하지만 성능 향상을 위해 fastify로 전환합니다.
npm i @nestjs/platform-fastify @nestjs/config
그리고 main.ts 파일을 아래와 같이 업데이트 합니다.
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify'
import { Logger } from '@nestjs/common'
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter()
)
// Don't forget to enable CORS
app.enableCors({
credentials: true,
origin: '*',
})
await app.listen(process.env.PORT || 3001, (err: Error, appUri: string) => {
if (err) {
console.log(err)
return
}
const logger = new Logger()
logger.log(`Server started at ${appUri}`)
logger.log(`GraphQL URL ${appUri + '/graphql'}`)
})
}
bootstrap()
Fastify 설정을 완료했으니 Apollo GraphQL 설정을 진행합니다.
우선 필수 패키지를 설치합니다.
npm i -s @nestjs/graphql @nestjs/apollo @apollo/server @as-integrations/fastify graphql
기본적으로 Apollo GraphQL에서는 하나 이상의 쿼리 핸들러를 작성해야 서버에 에러가 나지 않으니 일단 root.query.ts 파일 1개를 만들어 줍니다.
import { Injectable } from '@nestjs/common'
import { Field, ObjectType, Query } from '@nestjs/graphql'
@ObjectType()
export class DefaultResponse {
@Field(() => String)
public message: string
}
@Injectable()
export class RootQuery {
@Query(() => DefaultResponse)
public async health(): Promise<DefaultResponse> {
return {
message: 'Ok',
}
}
}
app.module.ts 파일을 업데이트 해줍니다.(기본으로 제공하던 playground가 deprecated 되면서 아래와 같이 plugin으로 playground를 사용하는 것을 권장하고 있습니다.)
import { Module } from '@nestjs/common'
import { AppController } from './app.controller'
import { AppService } from './app.service'
import { Enhancer, GraphQLModule } from '@nestjs/graphql'
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { join } from 'path'
import { RootQuery } from './root.query'
import {
ApolloServerPluginLandingPageLocalDefault,
ApolloServerPluginLandingPageProductionDefault,
} from '@apollo/server/plugin/landingPage/default';
@Module({
imports: [
// The config module helps you get your environment variables from .env file
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env'],
}),
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: (config: ConfigService) => {
return {
debug: config.get('GRAPHQL_DEBUG') === 'true',
playground: config.get('GRAPHQL_PLAYGROUND') === 'true',
autoSchemaFile: join(process.cwd(), 'src/generated/schema.gql'),
sortSchema: true,
fieldResolverEnhancers: ['interceptors'] as Enhancer[],
autoTransformHttpErrors: true,
context: (context) => context,
plugins: [
config.get('appEnv', { infer: true }) === 'production'
? ApolloServerPluginLandingPageProductionDefault({ footer: false })
: ApolloServerPluginLandingPageLocalDefault({ footer: false }),
],
}
},
inject: [ConfigService],
}),
],
controllers: [AppController],
providers: [AppService, RootQuery],
})
export class AppModule {}
fastify를 사용하면서 graphql을 이용할 시 helmet 설정(@fastify/helmet도 설치해야 함)이 필요하므로 아래와 같이 main.ts 파일을 업데이트 해줍니다.
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
import { ErrorsInterceptor } from './interceptors/errors.interceptor';
import helmet from '@fastify/helmet';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
app.register(helmet as any, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'", 'unpkg.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net', 'fonts.googleapis.com', 'unpkg.com'],
fontSrc: ["'self'", 'fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:', 'cdn.jsdelivr.net'],
scriptSrc: ["'self'", "https: 'unsafe-inline'", 'cdn.jsdelivr.net', "'unsafe-eval'"],
},
},
});
app.enableCors({ credentials: true, origin: '*' });
app.setGlobalPrefix('');
app.useGlobalInterceptors(new ErrorsInterceptor());
await app.listen(process.env.PORT, '0.0.0.0');
}
bootstrap();
그런 뒤 환경 변수 파일 .env 파일을 아래와 같이 생성합니다.
PORT=3001
GRAPHQL_DEBUG=true
# plugin으로 playground를 사용하기 때문에 이중으로 실행 시 오류가 나니 false로 기본 playground를 닫아야 함.
GRAPHQL_PLAYGROUND=false
이제 앱을 실행해 줍니다.
아래와 같이 서버가 실행되고 query, mutation 및 스키마도 확인하고 실제 요청도 해볼 수 있는 이전보다 업그레이든 playground를 확인하실 수 있습니다. 그런데 갑자기 얼마 전까지 잘 되다가 아무런 에러 메시지도 없이 실제 요청은 되지만 이 playground 화면이 안 보이기 시작해서 다른 방법을 찾았습니다.
우측 링크에 접속하셔서 https://studio.apollographql.com/
아래와 같이 상단에 주소 있는 부분 옆에 톱니바퀴 버튼을 누르면 graphql 서버 endpoint를 적는데 거기에 주소를 적으면 알아서 스키마를 불러와서 평소 playground 사용하듯 할 수 있습니다.
아무튼 nestjs fastify apollo-graphql 서버 설정은 마무리 됐습니다.
이제 채팅 앱을 정의하면 됩니다.
├── backend
│ ├── src
│ │ ├── chat
│ | | ├── chat.handler.ts
│ | | ├── chat.model.ts
│ | | ├── chat.module.ts
│ │ ├── app.module.ts
│ │ ├── main.ts
│ ├── package.json
│ ├── tsconfig.json
│ ├── .env
우선 chat/chat.model.ts 를 정의합니다.
import { Field, GraphQLISODateTime, ID, ObjectType } from '@nestjs/graphql'
@ObjectType()
export class Message {
@Field(() => ID)
public id!: string
@Field(() => ID)
public conversationId!: string
@Field(() => ID)
public senderId!: string
@Field(() => String)
public content!: string
@Field(() => GraphQLISODateTime)
public createdAt!: Date
public constructor(partial: Omit<Message, 'createdAt'>) {
Object.assign(this, partial)
this.createdAt = new Date()
}
}
@ObjectType()
export class Conversation {
@Field(() => ID)
public id!: string
@Field(() => String)
public name!: string
@Field(() => GraphQLISODateTime)
public createdAt!: Date
public constructor(partial: Omit<Conversation, 'createdAt'>) {
Object.assign(this, partial)
this.createdAt = new Date()
}
}
다음으로 chat/chat.handler.ts 를 정의합니다.
import {
Args,
ID,
Mutation,
Query,
Resolver,
ResolveField,
Parent,
} from '@nestjs/graphql'
import { Conversation, Message } from './chat.model.js'
import {
BadRequestException,
Injectable,
OnApplicationBootstrap,
} from '@nestjs/common'
const inMemoryConversations: Record<string, Conversation> = {}
const inMemoryMessages: Record<string, Message[]> = {}
@Injectable()
export class ChatHandler implements OnApplicationBootstrap {
public onApplicationBootstrap() {
const userNames = [
'Emily',
'Jacob',
'Sophia',
'Liam',
'Olivia',
'David',
'Zhang',
]
userNames.forEach((item) => {
const id = getNewConversationId()
inMemoryConversations[id] = {
id,
name: item,
createdAt: new Date(),
}
inMemoryMessages[id] = []
})
}
@Query(() => [Conversation])
public async conversationsList(): Promise<Conversation[]> {
console.log(`calling conversationsList`)
return Object.values(inMemoryConversations)
}
@Query(() => Conversation)
public async conversationById(@Args('id', { type: () => ID }) id: string) {
console.log(`calling conversationById`, { id })
return inMemoryConversations[id]
}
@Mutation(() => Conversation)
public async conversationCreate(
@Args('senderId', { type: () => ID }) senderId: string,
@Args('name') name: string,
@Args('message') message: string
) {
console.log(`calling conversationCreate`, { name, message, senderId })
const conversationId = getNewConversationId()
const newConversation = new Conversation({
name,
id: conversationId,
})
inMemoryMessages[newConversation.id] = [
new Message({
id: getNewMessageId(),
senderId,
conversationId: newConversation.id,
content: message,
}),
]
inMemoryConversations[newConversation.id] = newConversation
return newConversation
}
@Mutation(() => Message)
public async messageCreate(
@Args('conversationId', { type: () => ID }) conversationId: string,
@Args('senderId', { type: () => ID }) senderId: string,
@Args('content') content: string
) {
console.log(`calling messageCreate`, { conversationId, content, senderId })
if (!inMemoryConversations[conversationId]) {
throw new BadRequestException()
}
const newMessage = new Message({
id: getNewMessageId(),
conversationId,
senderId,
content,
})
inMemoryMessages[conversationId].push(newMessage)
// void this._pubSubService.publish(EVENTS.NEW_MESSAGE_SENT, newMessage)
return newMessage
}
}
@Resolver(() => Conversation)
export class ConversationResolver {
@ResolveField(() => [Message])
public async messages(@Parent() conversation: Conversation) {
return inMemoryMessages[conversation.id] || []
}
@ResolveField(() => Message, { nullable: true })
public async lastMessage(@Parent() conversation: Conversation) {
const items = inMemoryMessages[conversation.id]
if (!items) {
return null
}
return items[items.length - 1]
}
}
function getNewConversationId() {
return `conversation_${Object.values(inMemoryConversations).length + 1}`
}
function getNewMessageId() {
return `message_${Object.values(inMemoryMessages).length + 1}`
}
이제 chat/chat.module.ts 를 정의합니다.
import { Module } from '@nestjs/common'
import { ChatHandler, ConversationResolver } from './chat.handler.js'
@Module({
imports: [],
providers: [ChatHandler, ConversationResolver],
})
export class ChatModule {}
그리고 AppModule 에서 Chat Module을 import 해서 엮어줍니다.
@Module({
imports: [
...,
ChatModule,
],
...
})
export class AppModule {}
이제 GraphQL 엔드포인트가 성공적으로 등록되었습니다.
Subscription이란 무엇일까요?
공식 Apollo Server 문서 에 따르면 서버 측 이벤트를 기반으로 클라이언트를 업데이트할 수 있습니다. 일반적으로 서버에서 실시간으로 푸시되는 구독 업데이트는 HTTP 대신 WebSocket 프로토콜을 사용합니다 . 이제 GraphQL 해석기가 작동하므로 변형과 쿼리를 사용하여 채팅을 보내고 볼 수 있습니다. 그러나 새로운 채팅 도착에 대해 즉시 알림을 받으려면 구독 작업이 필요합니다.
이를 달성하려면 구독이 HTTP와 다른 프로토콜을 사용하므로 구독을 처리하도록 서버를 설정해야 합니다. 다행히 Apollo Server는 구독 전용의 별도 엔드포인트를 제공하여 이 설정을 단순화합니다.
GraphQL 구독 설정
우선 필수 패키지를 설치합니다.
npm i graphql-ws graphql-subscriptions
그런 다음 pubsub 서비스 파일을 만듭니다. chat/pubsub.service.ts
import { Injectable } from '@nestjs/common'
import { PubSubEngine } from 'graphql-subscriptions'
@Injectable()
export abstract class PubsubService extends PubSubEngine {}
pubsub 핸들러를 다음에 등록합니다. ChatModule
...
import { PubSub } from 'graphql-subscriptions'
import { PubsubService } from './pubsub.service'
export const PUB_SUB: symbol = Symbol('PUB_SUB')
@Module({
imports: [],
providers: [
...,
{
provide: PUB_SUB,
useFactory: () => {
return new PubSub()
},
inject: [],
},
{
provide: PubsubService,
useFactory: (pubsub) => pubsub,
inject: [PUB_SUB],
},
],
})
export class ChatModule {}
GraphQLModule 내부 에 등록할 때 구독을 활성화하는 것을 잊지 마세요. AppModule
@Module({
imports: [
...,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: (config: ConfigService) => {
return {
...,
// Enable GraphQL subscriptions
subscriptions: {
'graphql-ws': true,
},
}
},
inject: [ConfigService],
}),
ChatModule,
],
...,
})
export class AppModule {}
새 메시지가 전송되면 구독 핸들러를 삽입하고 등록 PubsubService합니다. ChatHandler
import { PubsubService } from './pubsub.service'
@Injectable()
export class ChatHandler implements OnApplicationBootstrap {
public constructor(private readonly _pubSubService: PubsubService) {}
...
@Mutation(() => Message)
public async messageCreate(
@Args('conversationId', { type: () => ID }) conversationId: string,
@Args('senderId', { type: () => ID }) senderId: string,
@Args('content') content: string
) {
...
void this._pubSubService.publish('NEW_MESSAGE_SENT', newMessage)
return newMessage
}
@Subscription(() => Message, {
filter: (newMessage: Message, variables: { conversationId: string }) => {
// Only handle the subscription when conversation exists
return !!inMemoryConversations[variables.conversationId]
},
resolve: (payload: Message) => payload,
})
public onNewMessageSent(
@Args('conversationId', { type: () => ID }) conversationId: string
) {
return this._pubSubService.asyncIterator('NEW_MESSAGE_SENT')
}
}
Playground 를 열면 구독이 성공적으로 등록된 것을 확인할 수 있습니다.
이제 서버 준비는 완료 됐습니다.
다음 편에서 NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(클라이언트편) 으로 마무리하겠습니다.
🏆 2024년 4월 가장 인기 있는 필수 솔루션 10개 (0) | 2024.05.24 |
---|---|
NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(클라이언트편) (0) | 2024.05.21 |
벡터 검색을 통한 검색 혁명 Alloy DB (0) | 2024.05.17 |
MSSQL(SQL Server) 2022 설치 및 구성 방법 (0) | 2024.05.04 |
2024 Mac Book 앱! NeoVim & Ollama & ... (0) | 2024.05.03 |
댓글 영역