🚀 프로젝트/🎸 고스락 티켓

[Gosrock/Nestjs] Socket.io 사용하여 실시간 공연 입장 시스템 구현하기

gengminy 2022. 7. 30. 18:46

학교 컴공 밴드 동아리 고스락 여름방학 프로젝트인

고스락 티켓 예매 페이지 22th 의 일부인 socket 구현에 대한 글입니다

nestjs + socket.io 를 사용하여 구현하였습니다

 

📝 Reference

nestjs + socket.io(EventsGateway) - https://www.youtube.com/watch?v=gkJ1N6PDCEc&t=690s 

chat app with nestjs - https://www.youtube.com/watch?v=7xpLYk4q0Sg&t=722s 

docs nest js (gateway) - https://docs.nestjs.com/websockets/gateways

 

 

💻 socket.io 모듈 설치

npm i @nestjs/websockets @nestjs/platform-socket.io --save

 

💻 gateway 생성

nest g ga socket

 

socket 모듈 아래에 게이트웨이를 생성한다

 

 

📜 socket 모듈 구조

backend
...
└───socket
│   │   socket-admin.gateway.ts
│   │   socket-user.gateway.ts
│   │   socket.guard.ts
│   │   socket-module.ts
│   │   socket-service.ts
└───tickets
│   │   tickets-service.ts
│   │   ...
...

 

💻 Gateway 구현

유저와 어드민 페이지의 분리를 위해

각 게이트웨이 파일을 네임스페이스 별로 나누었다

SocketUserGateway 와 SocketAdminGateway

콘솔에 찍어서 확인해본 결과 게이트웨이를 나누어 init 해줘도

싱글톤으로 동작하는 듯 보인다

즉 하나의 소켓 서버 아래에서 동작하는 것이다

 

 

📝 socket-user.gateway.ts

// @UseGuards(SocketGuard)
@WebSocketGateway({
  cors: {
    origin: '*'
  },
  namespace: '/socket/user' //socket/admin or socket/user
})
export class SocketUserGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  private readonly logger = new Logger(SocketUserGateway.name);
  constructor(
    @Inject(forwardRef(() => TicketsService))
    private ticketsService: TicketsService
  ) {}
  @WebSocketServer() public io: Namespace;

  //interface 구현부

  afterInit(server: Server) {
    this.logger.log('SocketUserGateway Init');
  }

  //티켓 uuid로 존재하는 티켓인지 검사 후 연결
  async handleConnection(@ConnectedSocket() client: Socket) {
    try {
      const ticketUuid =
        process.env.NODE_ENV == 'dev'
          ? client.handshake.headers.authorization
          : client.handshake.auth?.ticketUuid;

      if (!ticketUuid) {
        throw new UnauthorizedException('잘못된 헤더 요청');
      }

      const ticket = await this.ticketsService.findByUuidSocket(ticketUuid);
      if (!ticket) {
        throw new UnauthorizedException('없는 유저입니다.');
      }
      this.logger.log(`${client.id} connected`);

      //room: uuid로 강제 연결
      client.join(ticketUuid);
    } catch (e) {
      this.logger.error(
        `${client.id} 연결 강제 종료, status: ${e.status}, ${e.message}`
      );
      client.disconnect();
    }
  }

  handleDisconnect(@ConnectedSocket() client: Socket) {
    this.logger.log(`${client.id} disconnected`);
  }
}

클래스 정의 위에 @WebSocketGateway 데코레이터를 달아준다

여기에서 네임스페이스 이름과 cors 정책을 변경할 수 있다

 

네임스페이스는 룸의 상위 호환이다

하나의 네임스페이스에 여러 개의 룸을 만들수 있다

쉽게 말해서 네임스페이스는 채널, 룸은 채팅방이라고 생각하면 된다

 

게이트웨이는 interface 상속해서 세 가지 메서드를 구현해야 한다 구현하는 것이 반드시 좋다

afterInit, handleConnection, handleDisconnect

이건 socket.on 사용해서 connection, disconnection 구현했던 거와 같다

 

🎈 afterinit - 초기 생성 시 호출

🎈 handleConnection - 유저가 연결 시도할 때 호출

🎈 handleConnection - 유저가 연결 해제 시도할 때 호출

 

constructor 대신 @WebSocketServer() 로 의존성을 주입 받는다(DI)

DI 오브젝트에 Server와 Namespace 를 적을 수 있는데

나는 외부 네임스페이스를 가져오기 위해 Namespace 형태로 주입시켜주었다

 

왜인지는 모르겠지만 Server로 주입하면 server.of('admin') 형태로

외부 네임스페이스와 연결할 수가 없었다

of 메서드가 정의되지 않았다면서 말이다

 

@SubscribeMessage('') 를 통해 해당 룸에 대한 리스너를 달아줄 수 있다

node 같은데서 했던 sockett.on('message', () => {}) 이런 구문과 같음

매개변수로 @ConnectedSocket 은 현재 연결중인 소켓 정보를 가져올 수 있고

@MessageBody로 메세지 정보를 가져올 수 있음

 

구현해 보니까 이 프로젝트에서는 메세지 구독 까지는 사용하지 않을 거 같아서

없애버렸다

 

처음에 구조 이해가 힘들어서 그림판에 발로 그린 그림이다

결국 구현하고자 하는 것은 실시간 티켓 입장 확인 이벤트 처리를 소켓으로 하고자 하는 거였는데

 

1. 유저가 /tickets/{ticketUuid} 라는 url에 접근
티켓 uuid 가 담긴 QR 코드를 띄움과 동시에 socket 채널에 입장시켜야 한다

그래서 프론트 단에서 socket 요청을 날릴 때 header.authorization 에 담아서 보내도록 말해놨다

 

2. 여기서 티켓의 uuid 를 뽑아와 유효성 검사를 한다

유효성 검사를 마치면 uuid 룸에 강제로 join 시켜버렸다

그러면 해당 유저는 자신의 티켓 uuid 에 해당하는 소켓 메세지를 강제로 구독중인 것이다

 

 

📝 socket-admin.gateway.ts

// @UseGuards(SocketGuard)
// @Roles(Role.Admin)
@WebSocketGateway({
  cors: {
    origin: '*'
  },
  namespace: '/socket/admin' //socket/admin or socket/user
})
export class SocketAdminGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  private readonly logger = new Logger(SocketAdminGateway.name);
  constructor(private authService: AuthService) {}

  @WebSocketServer() public io: Namespace;

  //interface 구현부

  afterInit(server: Server) {
    // remove the namespace
    this.io.server._nsps.delete('/');
    this.logger.log('SocketAdminGateway Init');
  }

  //소켓 헤더에서 엑세스토큰 검사
  async handleConnection(@ConnectedSocket() client: Socket) {
    try {
      const accessToken =
        process.env.NODE_ENV == 'dev'
          ? client.handshake.headers.authorization
          : client.handshake.auth?.token;

      if (!accessToken) {
        throw new UnauthorizedException('잘못된 헤더 요청');
      }
      const payload = this.authService.verifyAccessJWT(accessToken);

      const user = await this.authService.findUserById(payload.id);
      if (!user) {
        throw new UnauthorizedException('없는 유저입니다.');
      }
      if (user.role !== Role.Admin) {
        throw new UnauthorizedException('권한이 없습니다');
      }
      this.logger.log(`${client.id} connected`);
    } catch (e) {
      this.logger.error(
        `${client.id} 연결 강제 종료, status: ${e.status}, ${e.message}`
      );
      client.disconnect();
    }
  }

  handleDisconnect(@ConnectedSocket() client: Socket) {
    this.logger.log(`${client.id} disconnected`);
  }
}

어드민은 엑세스 토큰을 헤더에서 가져와서 권한을 획득한다

 

3. 어드민은 티켓을 QR 리더로 찍었을때

/tickets/{ticketUuid}/enter 라는 url 로 이동하게되고

권한이 어드민일 경우에 이 티켓의 상태를 업데이트 시킨다

 

 

📝 socket.service.ts

@Injectable()
export class SocketService {
  constructor(
    @Inject(forwardRef(() => SocketUserGateway))
    private userGateway: SocketUserGateway,
    @Inject(forwardRef(() => SocketAdminGateway))
    private adminGateway: SocketAdminGateway
  ) {}

  async emitToUser(ticketEntryResponseDto: TicketEntryResponseDto) {
    try {
      const { uuid } = ticketEntryResponseDto;
      this.userGateway.io.emit(uuid, ticketEntryResponseDto);
    } catch (error) {
      console.log(error);
      throw new GatewayTimeoutException('소켓 서버에 연결할 수 없습니다');
    }
  }

  async emitToAdmin(ticketEntryResponseDto: TicketEntryResponseDto) {
    try {
      this.adminGateway.io.emit('enter', ticketEntryResponseDto);
    } catch (error) {
      console.log(error);
      throw new GatewayTimeoutException('소켓 서버에 연결할 수 없습니다');
    }
  }

  //양쪽
  async emitToAll(ticketEntryResponseDto: TicketEntryResponseDto) {
    await this.emitToUser(ticketEntryResponseDto);
    await this.emitToAdmin(ticketEntryResponseDto);
  }
}

4. 해당 url 에 접근하면

유저의 ticketUuid 룸과 어드민의 enter 룸에 메세지를 보낸다

프론트 단에서 admin 은 enter 메세지를 구독중이니까

실시간 입장 확인은 admin 도 가능하다

 

 

📝 tickets.service.ts

/**
   * 어드민이 티켓을 찍었을때 연결할 url에서 검증을 완료한 후 소켓 메세지 전송
   * @param uuid TicketValidationDto -> uuid
   * @param admin 현재 로그인 중인 어드민
   */
  async entryValidation(
    ticketEntryDateValidationDto: TicketEntryDateValidationDto,
    uuid: string,
    admin: User
  ): Promise<TicketEntryResponseDto> {
    this.logger.log('TicketEntryValidation');

    const queryRunner = this.dataSource.createQueryRunner();

    await queryRunner.connect();
    await queryRunner.startTransaction();

    const connectedRepository = getConnectedRepository(
      TicketRepository,
      queryRunner,
      Ticket
    );

    try {
      const { date } = ticketEntryDateValidationDto;
      const ticket = await connectedRepository.findByUuid(uuid);

      // 티켓 날짜 오류(공연 날짜가 일치하지 않음)
      if (ticket.date !== date) {
        const failureResponse = new TicketEntryResponseDto(
          ticket,
          admin.name,
          false,
          '[입장실패] 공연 날짜가 일치하지 않습니다'
        );
        this.socketService.emitToAll(failureResponse);
        throw new BadRequestException('공연 날짜가 일치하지 않습니다');
      }

      // 티켓 상태 오류('입장대기'가 아님)
      if (ticket.status !== TicketStatus.ENTERWAIT) {
        const failureResponse = new TicketEntryResponseDto(
          ticket,
          admin.name,
          false,
          '[입장실패] 이미 입장 완료된 티켓입니다'
        );
        this.socketService.emitToAll(failureResponse);
        throw new BadRequestException('이미 입장 완료된 티켓입니다');
      }

      //성공 시
      ticket.status = TicketStatus.DONE;
      ticket.admin = admin;

      await connectedRepository.saveTicket(ticket);

      await queryRunner.commitTransaction();

      const successResponse = new TicketEntryResponseDto(
        ticket,
        admin.name,
        true,
        `[입장성공] ${ticket.user?.name}님이 입장하셨습니다`
      );
      this.logger.log(`${ticket.user?.name}님이 입장하셨습니다`);
      this.socketService.emitToAll(successResponse);
      return successResponse;
    } catch (e) {
      await queryRunner.rollbackTransaction();
      this.logger.error(`티켓 상태 오류 - ${e.message}`);
      // 내부 예외 그대로 던짐
      throw e;
    } finally {
      await queryRunner.release();
    }
  }

/tickets/{ticektUuid}/enter 접근 시

TicketsController 에서 호출하는 비즈니스 로직

티켓 입장 상태와 티켓 건드린 어드민을 넣어줘야 해서 트랜잭션으로  처리햇다

모든 조건을 통과하면 socketService 의 emit 로직을 호출했다

 

 

 

📝 socket.guard.ts

@Injectable()
export class SocketGuard implements CanActivate {
  private readonly logger = new Logger(SocketGuard.name);
  constructor(private authService: AuthService, private reflector: Reflector) {}

  canActivate(
    context: ExecutionContext
  ): boolean | Promise<boolean> | Observable<boolean> {
    const client: Socket = context.switchToWs().getClient<Socket>();
    return this.validateHeader(client, context);
  }

  public async validateHeader(client: Socket, context: ExecutionContext) {
    //가드에 걸리면 에러 리턴 + 소켓 강제 연결 종료
    try {
      const accessToken =
        process.env.NODE_ENV == 'dev'
          ? client.handshake.headers.authorization
          : client.handshake.auth?.accessToken;

      if (!accessToken) {
        throw new UnauthorizedException('잘못된 헤더 요청');
      }
      if (Array.isArray(accessToken)) {
        throw new UnauthorizedException('잘못된 헤더 요청');
      }
      const payload = this.authService.verifyAccessJWT(accessToken);

      const roles = this.reflector.getAllAndOverride<string[]>('roles', [
        context.getHandler(),
        context.getClass()
      ]);

      const user = await this.authService.findUserById(payload.id);
      if (!user) {
        throw new UnauthorizedException('없는 유저입니다.');
      }
      const newObj: any = client;
      newObj.user = user;
      context.switchToWs().getData().user = user;

      // 롤기반 체크
      if (!roles) {
        return true;
      }
      if (!roles.length) {
        return true;
      } else {
        if (roles.includes(user.role) === true) {
          return true;
        } else if (user.role === Role.Admin) {
          return true;
        } else {
          throw new UnauthorizedException('권한이 없습니다.');
        }
      }
    } catch (e) {
      this.logger.error(
        `${client.id} 연결 강제 종료, status: ${e.status}, ${e.message}`
      );
      client.disconnect();
      throw new WsException(e.message);
    }
  }
}

사용하지는 않지만 구현한 게 아까워서 올리는 소켓 가드

 

소켓 구현하면서 권한 관리에 많이 애먹었는데

그 중의 일부인 SocketGuard 이다

내가 하고 싶은 건 결국 소켓 요청을 보내는 것부터

권한이 없으면 막고 싶은건데 그게 힘들었다

 

Nestjs 공식 레퍼런스 페이지에서는

소켓에서도 Guard를 사용할 수 있다 해서 SocketGuard를 구현했다

문제는 이건 소켓의 이벤트, 예를 들면 @SubscribeMessage 같은 거만 막을 수 있다

소켓 커넥션 자체를 막을 수 있는게 아니였다

 

결국 구현해야 하는 건

handleConnection 에서 접근 자체를 막는 것으로 바뀌었다

 

 

✨ Postman 으로 소켓 연결 테스트

테스트는 postman 에 소켓 연결 테스트가 있길래 그것으로 했다

유저 테스트는 headers -> authorization 에 ticketUuid 를 끼우고

Listeners 에 해당 ticketUuid 를 끼우면 구독이 된다 (수동으로 해주어야됨)

 

 

어드민은 헤더에 엑세스 토큰을 끼우고 db에서 권한을 admin 으로 바꿔준 다음

listener에 enter 등록해주면 된다

이러고 tickets/{ticketUuid}/enter 에 접근해서 돌리면 테스트가 된다

 

권한이 없으면 이런식으로 튕궈버린다

 

소켓 둘다 연결시킨 다음에 swagger로 요청 테스트

 

입장 성공시 오는 메세지

어드민이랑 유저랑 동일함

 

 

상당히 구현하는데 머리 아팠지만

막상 만들고 나니까 소켓 공부도 많이 되었고 재밌었다