TIL

24/01/21 TIL __ nestjs에서 socket io를 이용해 실시간 채팅기능 구현(mongodb)

GABOJOK 2024. 1. 21. 16:23

  

 

nestjs에서 socket io를 활용해 채팅기능을 구현하려면 먼저 알아야 할 것이 있다.

nestjs의 공식문서에도 나와있는 gateway이다. 

 

https://docs.nestjs.com/websockets/gateways

 

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 Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 
 
먼저 nestjs 로 웹소켓을 구현하기에 앞서, 가장 헷갈렸던 폴더구조에 대해 먼저 말하고자 한다. 

 

🐕  nestjs에서 socket io 사용시 폴더 구조

 
 
일반적으로 nestjs를 사용하게 되면 폴더구조는 정해져 있다.
module.ts
controller.ts
server.ts
entitiy.ts

이외 추가적으로
dto
type
 
 
그래서 처음에 나도 이 구조를 따라갔다.
그렇지만 nestjs 공식문서에 찾아보니 socket 을 사용할 때에는  event.gateway.ts 파일 이 있었다.
처음 보는 파일이라 뭘까 싶었는데,
여러 예시들을 보다보니, controller단 대신 gateway 파일로 사용하는듯 했다.
 
따라서 나도 controller는 사용하지 않고 gateway로 대신했다.
gateway와 service를 연결하여 사용했고,
database는  mongodb를 사용하기 위해 mongoose를 사용했다.
module에 mongodb를 위한 설정을 해주고,
entity파일 대신 schema파일을 만들어 주었다.
 

 

🐶  설정 코드 

설정코드를 잊고 있었는데 에러가 나와 확인해 보니 따로 웹소켓 설정을 하지 않았다. 
 
//main.ts 

async function bootstrap() {
    const app = await NestFactory.create<NestExpressApplication>(AppModule);

    const corsOptions: CorsOptions = {
        origin: '*',
        methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
        credentials: true,
    };
    app.enableCors(corsOptions);

    //소켓 어뎁터로 연결(nest에서 웹소켓을 사용할 수 있도록)
    app.useWebSocketAdapter(new IoAdapter(app));
 
 
여기서 origin에  * 는 모든 도메인 허용한다는 의미이기 때문에 배포할때는 바꿔야만 한다. 
 
 
 
//app.module

@Module({
    imports: [
        MongooseModule.forRootAsync({
            inject: [ConfigService],
            useFactory: (configService: ConfigService) => ({
                uri: configService.get<string>('MONGO_URL'),
                useNewUrlParser: true,
                useUnifiedTopology: true,
            }),
        }),
 
//chat.module.ts

@Module({
    imports: [MongooseModule.forFeature([{ name: Chat.name, schema: ChatSchema }]), UserModule, AuthModule],
    controllers: [],
    providers: [ChatService, ChatGateway],
})
export class ChatModule {}
 
 
 

 

 

mongodb 를 사용하지만 스케마를 지정해 주었다.

//chat.schema.ts

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { number } from 'joi';
import { Date, HydratedDocument, ObjectId } from 'mongoose';
import { ObjectIdColumn } from 'typeorm';

export type ChatDocument = HydratedDocument<Chat>;

@Schema({
    collection: 'chat',
    timestamps: true,
})
export class Chat {
    @ObjectIdColumn()
    _id: ObjectId;

    @Prop()
    userId: number;

    @Prop()
    liveId: string;

    @Prop({ required: true })
    content: string;

}
export const ChatSchema = SchemaFactory.createForClass(Chat);
 
 
데이터베이스에 시간은 timezone설정을 할까 고민했지만, 
오히려 더 복잡한것 같아 프론트에서 로직으로 처리할까 싶어 이렇게 작성했다.
 
 

 

 

🪴 @WebSocketGateway 

 
@WebSocketGateway

이 데코레이터를 사용해서 실시간 양방향 통신이 가능하도록 돕는다.

따라서 최상단에 걸어주면 된다.

 
또한 안에는 이런 설정값을 넣어줄 수 있다.
@WebSocketGateway({
    cors: {
        //origin: ['ws://localhost:3002/api/live'],
        origin: '*',
    },
})​
이 설정은 클라이언트에서 접근이 허용되는 도메인을 지정하는 건데
*로 하면 편리해서 일단 이렇게 했다. 배포 전에 이부분은 도메인 주소로 바꿀 예정이다.
 
 
 
 

🦊  채팅방 입장 기능

 

프론트에서 페이지가 로드되면 채팅방에 입장하도록 구현했다.

시청하고자 하는 라이브 방송을 클릭하면 채팅기능도 같이 켜져야 하기 때문이다.

 

//chat.gateway.ts

@WebSocketGateway({
    cors: {
        //origin: ['ws://localhost:3002/api/live'],
        origin: '*',
    },
}) 

@UseGuards(WsGuard) 
export class ChatGateway {
    @WebSocketServer() server: Server;
    constructor(private readonly chatService: ChatService) {}

    @SubscribeMessage('enter_room')
    async enterLiveRoomChat(client: Socket, liveId: string): Promise<EnterRoomSuccessDto> {
        const chat = await this.chatService.enterLiveRoomChat(liveId, client);
        return {
            statusCode: 200,
            message: '채팅방 입장 성공',
        };
    }

    @SubscribeMessage('new_message')
    async createChat(client: Socket, [value, liveId]: [value: string, liveId: string]) {
        const saveChat = await this.chatService.createChat(client, value, liveId);
        this.server.to(liveId).emit('sending_message', saveChat.content, client.handshake.auth.user.nickname);
    }

    @SubscribeMessage('get_all_chat_by_liveId')
    async getAllChatByLiveId(client: Socket, liveId: string) {
        const socketId = client.id;
        const messages = this.chatService.getAllChatByLiveId(liveId);
    }
}
 
 
 

여기서 처음 소켓 연결부분이 어떻게 해야할까 싶었는데

@WebSocketServer() server: Server;

 

이 코드가 바로

@WebsocketServer()는 websocket서버 인스턴스를 해당 속성에 자동으로 할당하고, 
소켓 연결, 소켓 해제 등의 이벤트를 처리하는 데 사용되는 코드이다.

 

 

 

🐲  난관 

채팅 기능은 되었는데 이제 유저 닉네임을 가져와야 했다.

userInfo를 팀원분이 만들어두셔서 그걸 활용해서 가져오려고 했지만 왜인지 계속 인식되지 않았다.

알고보니 http 프로토콜과  ws 프로토콜은 달라서 http용으로 만든 UserInfo,  AuthGuard 를 사용할 수 없었다.

ws 프로토콜 용으로 따로 만들어야 했던것!!!

 

어떻게 따로 만들어야 하나 싶어 기존 http 프로토콜에서 작성된  userInfo데코레이터를 참고해 만들어 봤지만 작동하지 않았다.

찾아보니,

ctx.switchToHttp().getRequest()`를 사용하는 것은
HTTP 요청이 아니라 WebSocket 컨텍스트에서는 예상대로 작동하지 않습니다.
WebSocket 컨텍스트에서는 HTTP 요청이 없기 때문입니다.

 

따라서 switchToWs로 바꿨다. 

 

그렇지만 여전히 작동하지 않았고, wsGuard를만드는 방법에 대해 좀더 알아보아야 겠다고 생각해서 

열심히 검색했다. 결국 스텍오버플로우 에서  참조할만한 코드를 찾았는데,

 

https://stackoverflow.com/questions/71000142/how-to-use-guards-with-nestjs-gatewayweb-sockets/77398183#77398183

 

How to use guards with NestJs gateway(web sockets)?

In my NestJS API, I'm JWT token, stored in a cookie to authenticate my users. The user will have to call my login controller: @UseGuards(LocalAuthenticationGuard) @Post('login') async logIn(@...

stackoverflow.com

 

 

 

 해당 코드를 참고하면서 작성했다.

실행컨텍스트가 switchToHttp가 아니라 switchToWs 라는 점이 달랐고,(미리 찾아보고 작성할껄 싶었따...ㅎㅎ)

해당 데이터를 넣고 가져오는 부분도 약간 달랐다.

 

 

열심히 작성해서 아래와 같이 작성 했는데, 또 작동하지 않았다.

이 함수에 콘솔들은 다 찍히는데, gateway.ts파일에 다음 내용들로 넘어가질 못했다.

 

//WsGuard.ts
    
@Injectable() 
export class WsGuard implements CanActivate { 
	constructor( 
    	private userService: UserService, 
        private readonly configService: ConfigService, 
    ) {} 
		private readonly secretKey = this.configService.get<string>('JWT_SECRET_KEY'); 
        canActivate(context: ExecutionContext): any { 
        	const token = context.switchToWs().getClient().handshake.headers.cookie.split('=')[1]; 
            console.log('token', token, context.switchToWs().getClient().handshake.headers); 
            try { 
            	const verifyToken = async (token: string): Promise<any> => { 
                	const verify = jwt.verify(token, process.env.JWT_SECRET_KEY); 
                    console.log('verify', verify, typeof verify.sub); 
                    const userId = verify.sub; 
                    if (!verify) { 
                    	throw new UnauthorizedException('인증이 유효하지 않습니다. '); 
                    } else {
                        const findUser = await this.userService.findByKakaoIdGetUserName(+userId); 
                        context.switchToWs().getClient().handshake.auth.user = findUser; 
                        return true; 
					} 
                }; 
				verifyToken(token); 
			} catch (err) {
            	console.log(err); return false; 
            }
        }
    }

 

 

사실 되게 간단한 문제였는데, socket io와 nestjs에서 어떻게 사용해야 할지 한참 헤맸던 터라

그런 종류에 문제라고 나도 모르게 생각해  찾는데 어려웠다.

 

사실 verifyToken(token)이 비동기 적으로 호출되지 않았고,

canActivate도 비동기 처리를 하지 않아서 발생한 문제였다.

 

비동기 처리에 대해 잘 알고있다고 생각했는데,

막상 이 부분때문에 꽤 해매고 나니 다시 찾아보게 되었다.

 

 

🏞️   비동기 함수 사용하는 경우

  1. 외부 서비스 호출: 다른 서버로 HTTP 요청을 보내거나, 데이터베이스와의 통신을 위해 필요하다. 데이터를 가져오는 동안 애플리케이션이 다른 작업을 수행할 수 있도록 한다.
  2. 파일 I/O: 파일을 읽거나 쓸 때 비동기 작업이 필요하며, 파일 시스템 작업은 시간이 오래 걸릴 수 있으므로 블로킹하지 않도록 비동기로 수행하는 것이 좋다.
  3. 타이머 또는 이벤트 리스너: 특정 시간이나 이벤트가 발생할 때까지 기다리는 작업은 비동기로 처리해야 한다. 
  4. 병렬 작업: 여러 작업을 동시에 수행해야 할 때 비동기 작업을 사용한다. 이는 성능을 향상시키고 블로킹을 방지하는 효과가 있다.

 

서비스호출, 통신, 파일읽기, 병렬작업, 이벤트 등의  경우에는 비동기 !! 잊지 말자