실시간 채팅 서버 개발 - 03 (방 접속하기)

2026. 1. 19. 10:55·NestJS/개발

이전 프로젝트 정리

이전 포스트에서는 채팅 서버에서 방을 어떻게 생성하고, Redis를 이용해 방의 상태와 생명주기를 관리하는 구조를 정리했다. 방은 단순히 생성되고 사라지는 데이터가 아니라, WAIT → PLAYING → END 로 이어지는 명확한 상태를 가지며, 각 상태에 따라 TTL을 다르게 적용해 안전하게 관리해야 한다고 생각을 가지며 마무리 하였다. 이를 통해 유령방은 자동으로 정리하면서도, 게임이 진행 중인 방이 TTL 만료로 삭제되는 문제는 방지할 수 있었다. 이제 다음으로 해결해야 할 문제는 "방에 어떻게 입장할 것인가"이다. 방 접속은 단순히 인원 수를 증가시키는 작업처럼 보이지만, 실제로는 다음과 같은 요구사항을 동시에 만족해야 한다.

  • 최대 인원 수 초과 방지
  • 동시 접속 상황에서도 정확한 인원 관리
  • 정상적인 요청만 소켓 join 으로 연결
  • Redis 상태와 실제 접속 상태의 일관성 유지

이번 포스트에서는 위의 사항들을 고려해서 HTTP 기반 방 접속 로직을 설계하고, 검증이 완료된 경우에만 WebSocket join 이 이루어지도록 전체 흐름을 정리해보려고 한다.


방 참여 로직 설계 전 고려사항

  • Redis로 방 관리를 한번 체크 해준다.
    먼저 HTTP API에서 Redis를 통해 방 상태를 검증한다.
    이 단계에서는 방 존재 여부, 현재 인원 수, 최대 인원 제한과 같은
    모든 인증 및 검증 로직을 처리한다.

  • 그리고 소켓으로 join 시킨다.
    검증이 완료된 이후에만 WebSocket을 통해 room join 을 수행한다.
    소켓은 상태를 판단하지 않고, 실시간 통신과 연결 관리에만 집중한다.

  • 소켓으로만 관리하지 않는다. 
    방 참여를 소켓으로만 처리하지 않는 이유는 명확하다.
    방 인원 관리, 정리 로직까지 모두 소켓 이벤트에 포함시키면
    소켓 핸들러가 빠르게 비대해지고, 예외 처리와 테스트가 어려워진다.

  • 소켓은 join을 할 경우에 방이 자동으로 생성된다.
    Socket.io 특성상 join 시 방은 자동으로 생성된다.
    따라서 방의 생성과 생명주기는 Redis에서 명시적으로 관리하고,
    소켓 방은 비어 있는 경우 자연스럽게 정리되도록 맡긴다.

닉네임 설정하기 - HTTP

소켓 서버는 아무 요청이나 받아들여서는 안 된다. JWT 토큰은 소켓 서버 접속 시 전달되어, 이 사용자가 서버에서 정상적인 흐름을 거쳐 발급된 사용자인지를 확인하는 역할을 한다. 결론적으로 닉네임을 설정하면서 JWT 토큰을 발행하고 추후에 소켓서버에 참여할때 전송하여 검증까지 완료시킨다.

async createNickname(nickname: string) {
  const result = { nickname , Authorization: await this.jwt.createJwt(nickname) };
  return result;
}


방 참여하기 - HTTP

메인코드

import {
  BadRequestException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { randomUUID } from 'crypto';
import { RedisService } from 'src/redis/redis.service';

@Injectable()
export class RoomService {
  constructor(private readonly redis: RedisService) {}
  
  async joinRoom(roomId: string, jwtToken: string) {
    // JWT 검증 + 방 존재 확인만 수행 (인원 관리는 소켓에서 단독 담당)
    if ((await this.jwt.verifyJwt(jwtToken)) == null) {
      throw new UnauthorizedException('jwt 검증실패');
    }
    const key = `room:${roomId}`;

    const room = await this.redis.client.hgetall(key);

    if (Object.keys(room).length === 0) {
      throw new NotFoundException('방이 존재하지 않습니다.');
    }

    if (room.status === 'PLAYING') {
      throw new BadRequestException('게임이 진행 중인 방입니다.');
    }

    return {
      roomId,
      roomJoin: true,
      players: Number(room.players),
      maxPlayers: Number(room.maxPlayers),
    };
  }

 

코드설명

const room = await this.redis.client.hgetall(key);
const maxPlayers = room.maxPlayers;

if (Object.keys(room).length === 0) {
  throw new NotFoundException('방이 존재하지 않습니다.');
}

 

roomId를 기반으로 Redis Hash 전체를 조회한다. 방이 존재한다면 최소한 하나 이상의 필드를 가지고 있어야 하며, 빈 객체가 반환되었다는 것은 방이 이미 삭제되었거나 존재하지 않는다는 의미다. 이 검증을 통해서 생성되지 않은 방이나, 이미 만료되어 사라진 방에 접속하는것을 초기에 차단해버린다.

if (room.status === 'PLAYING') {
  throw new BadRequestException('게임이 진행 중인 방입니다.');
}

그리고 마지막으로 게임이 진행중인 방인지 확인하여 기본적인 검증으로 기본적인 유효성을 검사한다.


방 참여하기 - Socket

import {
  OnGatewayDisconnect,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { CustomJwtService } from 'src/jwt/custom-jwt.service';
import { RedisService } from 'src/redis/redis.service';
import { ResultCategory } from './result.category';

// 원자적 정원 체크 + 멤버 추가 Lua 스크립트
const LUA_ADD_MEMBER = `
  local count = redis.call('SCARD', KEYS[1])
  if count >= tonumber(ARGV[1]) then return 0 end
  redis.call('SADD', KEYS[1], ARGV[2])
  return 1
`;

@WebSocketGateway({
  namespace: 'game',
  transports: ['websocket'],
  cors: {
    origin: '*',
  },
})
export class GameGateway implements OnGatewayDisconnect {
  constructor(
    private readonly jwtService: CustomJwtService,
    private readonly redis: RedisService,
  ) {}

  @WebSocketServer()
  server: Server;

  @SubscribeMessage('join')
  async handleJoinRoom(client: any, payload: { roomId: string; jwt: string }) {
    const { roomId, jwt } = payload;

    if (!roomId || !jwt) {
      client.emit('join_error', { message: 'roomId 또는 jwt가 필요합니다.' });
      return;
    }

    const trimmedRoomId = roomId.trim();
    const key = `room:${trimmedRoomId}`;
    const membersKey = `room:${trimmedRoomId}:members`;

    // 방 존재 확인
    const room = await this.redis.client.hgetall(key);
    if (Object.keys(room).length === 0) {
      client.emit('join_error', { message: '존재하지 않는 방입니다.' });
      return;
    }

    // 게임 진행 중이면 입장 차단
    if (room.status === 'PLAYING') {
      client.emit('join_error', { message: '게임이 진행 중인 방입니다.' });
      return;
    }

    // JWT 검증
    const tokenResult = await this.jwtService.verifyJwt(jwt);
    if (!tokenResult) {
      client.emit('join_error', { message: '토큰이 유효하지 않습니다.' });
      return;
    }

    const maxPlayers = Number(room.maxPlayers);
    const nickname = tokenResult.nickname;

    // 원자적 정원 체크 + Set 추가 (Lua 스크립트)
    const added = (await this.redis.client.eval(
      LUA_ADD_MEMBER,
      1,
      membersKey,
      maxPlayers.toString(),
      nickname,
    )) as number;

    if (!added) {
      client.emit('join_error', { message: '방이 가득 찼습니다.' });
      return;
    }

    // players 카운트를 Set 크기로 동기화
    const currentCount = await this.redis.client.scard(membersKey);
    await this.redis.client.hset(key, 'players', currentCount);

    // members Set TTL을 방 TTL에 맞춤
    const ttl = await this.redis.client.ttl(key);
    if (ttl > 0) await this.redis.client.expire(membersKey, ttl);

    // client 상태 저장 및 소켓 룸 입장
    client.data.roomId = trimmedRoomId;
    client.data.nickname = nickname;
    client.join(trimmedRoomId);

    // 전체 멤버 목록 조회
    const members = await this.redis.client.smembers(membersKey);

    // 본인에게 (현재 멤버 목록 포함)
    client.emit('join_success', {
      roomId: trimmedRoomId,
      nickname,
      socketId: client.id,
      members,
      maxPlayers,
    });

    // 다른 사람들에게 (업데이트된 멤버 목록 포함)
    client.to(trimmedRoomId).emit('user_joined', {
      socketId: client.id,
      nickname,
      members,
    });

    return { connected: true, roomId: trimmedRoomId };
  }
}

앞에서의 HTTP를 이용한 간단한 검증이 끝나면 이제 방을 socket에 연결을 하여야한다. 이 부분이 조금 길어지긴 했는데 나눠서 생각해보면 그렇게 어려운 부분은 아니다.

  • 소켓으로 join 시킬때 방 아이디와 jwt 토큰을 같이 보낸다. -> 마지막 검증용
  • redis에서 조회해서 방이 없으면 실패
  • redis에서 조회해서 게임중이면 실패
  • jwt 토큰 검사를 했는데 인증이 안되면 실패
  • 방 인원수 초과 및 수 계산 코드 -> 이 부분은 나중에 한꺼번에 정리할 예정

'NestJS > 개발' 카테고리의 다른 글

실시간 채팅 서버 개발 - 02 (방 생성하기)  (0) 2026.01.18
실시간 채팅 서버 개발 - 01 (Redis 및 NestJS 셋팅)  (1) 2026.01.14
Redis/BullMQ 이용하여 연산 작업 따로하기  (0) 2025.12.23
JWT - Passport 사용하기  (0) 2025.12.22
JWT - 역할 기반 관리하기  (1) 2025.12.21
'NestJS/개발' 카테고리의 다른 글
  • 실시간 채팅 서버 개발 - 02 (방 생성하기)
  • 실시간 채팅 서버 개발 - 01 (Redis 및 NestJS 셋팅)
  • Redis/BullMQ 이용하여 연산 작업 따로하기
  • JWT - Passport 사용하기
나는지토
나는지토
  • 나는지토
    안녕은헬로입니다.
    나는지토
  • 전체
    오늘
    어제
    • 분류 전체보기 (29)
      • Backend Design (1)
      • NestJS (19)
        • 개발 (9)
        • 개념과 구조 정리 (10)
      • SpringBoot (0)
      • Python (1)
        • 코테 (1)
      • Java (5)
        • 코테 (1)
      • PostgreSQL (2)
      • Docker (1)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    nestjs/jwt
    서비스
    컨트롤러
    커서기반 조회
    Collections
    채팅
    조회 방식
    nestjs
    Redis
    역할 검사
    PostgreSQL
    Java
    JWT
    토큰 검사
    ArrayList
    코딩테스트
    db 연결 오류
    인증 가드
    자료구조
    BullMQ
  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
나는지토
실시간 채팅 서버 개발 - 03 (방 접속하기)
상단으로

티스토리툴바