본문 바로가기
Socket.io

실시간 게임 서버 성능 최적화 - 한 게임방에서 200명이 플레이?!

by NewCodes 2025. 1. 8.
반응형

안녕하세요! NewCodes입니다!

 

실시간으로 퀴즈 맞추기!

 

실시간 퀴즈 게임 플랫폼 프로젝트에서

성능 최적화한 경험을 공유하고자 합니다!

 

부하테스트를 통해 메트릭을 수집했고, 

병목의 원인을 분석했습니다. 

 

그리고 최적화한 방법까지

자세하게 남겨보고자 합니다!

 


📍 한 게임방에 200명을 지원하고자 한 이유 

한 게임방에 200명을 지원하고자 한 이유는..! 팀의 목표였기 때문입니다!

한 게임방 내 200명의 플레이어가 원활히 플레이할 수 있게 구현

강연 현장

 

그런데 왜 200명일까요? 저희의 서비스가 사용되길 기대하는 상황은 위와 같았습니다.

 

대규모의 인원이 한 장소에서 즐겁게 아이스브레이킹, 레크리에이션 등의 용도로 사용되길 원했습니다. 그러면 지루할 수도 있는 강연이나 회의장에서도 즐거움을 더해줄 수 있으니까요! 그리고 네이버 부스트캠프 인원 전체를 수용하자는 의미에서 200명이라는 구체적인 수치를 잡았습니다. 

 

부하테스트를 통해 한 게임방 내 200명의 트래픽을 주었습니다. 이를 통해 원하는 성능이 나오는지 체크하고, 병목을 분석하여 개선한 경험을 이야기해보려 합니다. 

 


🚀 부하테스트 실행

먼저 부하테스트를 통해 한 게임방 내 200명의 트래픽을 받아보고자 했습니다. 이를 통해 원활히 게임이 되는지 확인하고자 했고, 잘 안 된다면 병목 지점을 분석하여 최적화하고자 했습니다. 

 

시나리오

부하테스트의 시나리오는 다음과 같습니다.

실제와 비슷하게 부하를 주려고 노력했습니다. 

  1. 한 게임방에 200명의 vuser가 접속
  2. 각각의 vuesr는 1초에 2번 꼴로 이벤트(위치 변경 혹은 채팅 메시지 요청)를 전송
    1. 이벤트에는 위치 변경 혹은 채팅 메시지가 있음
    2. 위치 변경(9):채팅 메시지(1)의 비중으로 요청
  3. 총 60초 동안 지속 

 

부하테스트 용도의 서버 준비

부하테스트를 위해서 Ncloud에서 세 개의 서버를 준비했습니다. 

  • 첫 번째 서버
    • 용도: 100명의 트래픽을 주는 서버
    • 사양: Ncloud Standard-g3 Server (vCPU 2개, 메모리 8GB)
  • 두 번째 서버
    • 용도: 100명 트래픽을 주는 서버
    • 사양: Ncloud Standard-g3 Server (vCPU 2개, 메모리 8GB)
  • 세 번째 서버
    • 용도: 1명의 트래픽을 주면서 E2E 레이턴시를 측정하는 서버
    • 사양: Ncloud Standard-g3 Server (vCPU 2개, 메모리 8GB)

 

왜 세 개의 서버를 준비했는지는 '부하테스트 용도의 서버 준비'를 참고해주세요!

 

또, 부하테스트 관련해서 전반적으로 궁금하신 분은 아래 글을 참고해주세요!

 

Artillery를 통한 Socket.io 게임 서버 부하테스트 경험기

 

Artillery를 통한 Socket.io 게임 서버 부하테스트 경험기(feat. TIP)

안녕하세요! NewCodes입니다!  이번에는 Node.js + Socket.io 게임 서버를부하테스트했던 경험에 대해서 적어보려 합니다! Socket.io 부하테스트 관련 레퍼런스가 많이 없어시행착오를 많이 겪었습니다.

newcodes.tistory.com

 

 

첫 번째 부하테스트 결과

부하테스트를 실행한 결과 주요 이벤트에서 아래와 같은 응답시간이 측정되었습니다. 

여기서의 응답시간은 네트워크를 제외한 서버 자체의 처리 시간을 측정한 값을 나타냅니다. 

첫 번째 부하테스트 결과

 

하늘색이 '캐릭터 위치 변경', 주황색이 '채팅 메시지 전달' 응답시간입니다! 지표를 보면 5초, 8초가 나오네요. 

 

성능 측정 결과, 참담한 결과가 나왔습니다 ㅎㅎ.. 이런 게임을 하고 싶은 사용자는 아마 없을 겁니다. 개선이 시급했습니다!

 

참고로 p95는 백분위수로 95%의 요청은 해당 시간 내에 처리되었다는 걸 의미합니다. 쉽게 말하면 100명을 성적순으로 줄을 세웠을 때 95등의 성적을 뜻합니다. 

 


🙌 소켓 서버를 멀티 프로세스로!

Why Multi Process?

위 측정 결과를 보시면 알겠지만, 게임 진행 자체가 힘든 수준입니다. 게임에서 캐릭터를 이동시켰는데 5초 뒤에 이동한다면 누가 그 게임을 할까요? 그래서 너무 긴 응답 시간에 문제의식을 삼고 개선하고자 했습니다. 

 

우선 코드 레벨에서 비효율적인 부분이 있는지 체크했습니다. 캐릭터 위치 변경, 채팅 업데이트를 담당하는 모듈, 함수를 쭉 살펴봤습니다. 하지만, 눈에 띄게 비효율적으로 짜인 코드를 보진 못했고 정말 필요한 로직들만이 담겼었습니다. 

 

프로젝트 기간이 얼마 남지 않은 상황이라 우선 해볼 수 있는 것부터 빠르게 시도하고자 했습니다. 

 

그래서 낸 솔루션은요!

 

기존에는 단일 프로세스로 서버가 돌아갔는데요.

 

배포된 서버의 CPU가 2개인 점을 이용하여 단일 프로세스에서 멀티 프로세스로 전환하고자 했습니다. 동시에 많은 요청이 들어와도 로드밸런싱을 통해 두 개의 프로세스가 나눠서 일하도록 하는 걸 유도했어요. 

 

멀티 프로세스로 바꾼 후 다시 부하테스트하여 추가로 병목을 찾아보기로 했습니다. 

 

멀티 프로세스로 바꾸는 주요 과정 3가지

1) 게임 서버를 멀티 프로세스로 실행

  • pm2를 통해 3000번, 3001번 포트로 프로세스 2개 실행
  • 클라우드 서버 vCPU 2개에 맞춰 프로세스 2개로 결정

 

2) NginX로 로드 밸런싱

upstream backend-socket { 
    ip_hash;
    server localhost:3000;
    server localhost:3001;
}
  • NginX에서 3333번 포트로 오는 건 소켓 연결로 3000번, 3001번으로 로드밸런싱
  • ip_hash로 하여 소켓 연결이 지속적으로 이루어질 수 있게 함
  • 네이버 클라우드 콘솔에서 ACG Inbound에 3333번 포트 열기

 

3) 멀티 프로세스일 때, 스케일아웃 가능한 게임 플레이 보장 (세션 불일치 해결)

 : 한 게임방 내에 4명의 클라이언트가 있다고 가정하겠습니다. 이때 프로세스가 여러 개라면 아래와 같이 연결될 수 있습니다. (서버가 여러 개라고 생각해도 좋습니다.)

각 서버에 골고루 연결된 사용자들

 

여기서 한 클라이언트가 캐릭터 위치 변경을 한다고 생각해봅시다. 변경된 위치는 게임방 내 모든 클라이언트가 볼 수 있어야 합니다. 그러면 어떻게 한 게임방 내에 변경된 위치를 브로드캐스팅할 수 있을까요? 

 

바로 Redis의 Pub/Sub을 이용해서 해결할 수 있습니다!

클라이언트 위치 브로드캐스팅 과정

 

한 클라이언트가 캐릭터 위치 변경을 하게 되면, 이는 Redis를 통해 게임방 내 모든 클라이언트에게 전달이 됩니다. 이게 가능한 이유는 각 서버가 Redis에서 클라이언트 위치 key에 대한 변경 이벤트를 구독했기 때문입니다. 

 

참고로 실시간 채팅도 위와 같은 방식으로 구현했습니다. 

 

 

여기서 한 가지 문제가 더 있습니다!

 

한 게임에서는 여러 개의 퀴즈가 존재합니다. 한 퀴즈의 제한시간이 끝나면, 정답을 발표하고 다음 퀴즈를 시작해야 합니다. 이때 서버가 여러 대라면 퀴즈의 제한시간 타이머는 어떻게 카운트해야 할까요? 

 

이 또한 위와 비슷한 방식으로 아래와 같이 해결할 수 있습니다. 

퀴즈 진행 과정

 

퀴즈의 제한시간만큼 TTL을 걸어두어 다음 퀴즈를 진행할 타이밍을 잡을 수 있게 됩니다! 참고로 첫 번째 퀴즈가 종료되었다는 알림을 받으면 두 번째 퀴즈 TTL을 걸어두는 방식으로 동작합니다. 

 

이후 아키텍처

이후에는 아래와 같이 NginX를 통해 멀티 프로세스로 운영할 수 있는 구조로 바뀌었습니다. 

이후 아키텍처

 

NginX가 3333번을 통해 소켓 요청을 받아주고,

이를 두 개의 프로세스 3000, 3001번으로 ip_hash를 기반으로 로드 밸런싱해주는 구조입니다!

 


🚀 멀티프로세스로 바꾼 이후, 부하테스트 실행

이후 같은 환경에서 다시 부하테스트를 실행했어요! 결과를 보시죠!

 

캐릭터 위치 변경 응답시간 - 이전과 이후 결과 비교

두 번째 부하테스트 결과

 

채팅의 응답시간 - 이전과 이후 결과 비교

두 번째 부하테스트 결과

 

해석

위치 변경에 대한 응답 시간평균, p95, max 지표에서 대부분 절반 이상 줄어들었습니다! 채팅에 대한 서버 처리 시간은 4 ~ 6배 정도 줄어들었습니다. 

 

하지만, 여전히 실시간으로 게임을 플레이하기 힘든 수치입니다..!! 그래서 추가로 병목 원인을 찾아보고자 했습니다. 

 


🧐 도대체 무엇이 병목일까?

도대체 무엇이 병목일지 고민했습니다!

최대한 가능성을 열어두고 탐색한 뒤, 가장 의심이 되는 부분을 골랐어요!

 

1) Redis에 대한 많은 조회

첫 번째 후보는 Redis입니다! Redis에 많은 조회가 발생해서 느려지는 거라 추측해볼 수 있습니다. 

 

서버 내 코드에서는 채팅을 브로드캐스팅하기 전에 탈락한 사람이 보낸 채팅인지를 조회합니다. 게임에서 탈락한 플레이어라면 생존한 플레이어에게 채팅이 가지 않도록 하여 게임 플레이에 방해가 되지 않도록 하기 위함입니다. 

 

유령이 탈락한 플레이어

 

탈락한 사람이 보낸 채팅인지 확인하기 위해 redis에서 해당 플레이어의 isAlive를 조회합니다. 캐릭터의 변경된 위치를 전송하는 것도 마찬가지입니다. 부하테스트 스크립트상 1초에 캐릭터 위치 변경, 채팅 전송 총합 400개의 요청이 오기에 1초에 400번 Redis에서 특정 플레이어의 isAlive를 조회해야 했어요. 또한, Redis는 Private Subnet에 별도의 서버에서 실행되고 있었기에 추가적인 네트워크 비용이 발생했을 거라 추측했습니다. 

 

그러나 Redis가 1초에 400번 조회로 부하가 오기는 힘들 것 같다고 판단했습니다. 일단 느낌적으로 Redis가 1초에 400번 정도를 못 버틴다면 이렇게 인기 있는 빠른 인메모리 DB가 될 수는 없었겠죠. 그리고 알려지기를 Redis에서는 대게 초당 100,000 QPS는 된다고 해요. 

 

결론은 Redis 성능 상 1초에 400번 조회는 무리가 없을 거라 판단하여 다음 원인을 분석해보겠습니다. 

 

+) 해당 isAlive를 조회하는 로직은 개선 자체가 필요한 건 맞습니다. isAlive는 자주 바뀌는 게 아니기에 WAS 내에서 간단히 세션으로 관리해도 되는 데이터입니다. 

 

2) NginX 과부하

그다음은 NginX인데요!

 

NginX에서는 1초에 400번 요청을 받아야 하고, 1초에 80000번 응답을 해줘야 했습니다. 80000번 응답을 해줘야 하는 이유는 한 게임방에 200명의 플레이어가 있고 1초당 400개의 요청이 오면, 이를 브로드캐스팅하기 위해서 200명에게 400개의 메시지를 보내야 하기에 200 * 400 = 80000개의 응답이 필요합니다. 

 

400개의 요청을 괜찮지만, 80000개의 응답은 살짝 부담이 될 수 있을 것 같습니다. 병목의 원인일 확률이 Redis보다는 높아보입니다. 

 

그러나 NginX에서 응답을 주는 과정에서 정확히 무엇 때문에 부하가 생길지 가늠이 안 됐습니다. 일단 보류하고 80000개의 네트워크 응답에 집중해보기로 했습니다. 

 

3) 제한된 네트워크 대역폭

네트워크 대역폭이 1초에 80000개 응답을 견디지 못했을 것이라 추측해볼 수 있습니다. 그러나 이는 아니었습니다. 

 

그 이유는 서버를 Ncloud로 운영하고 있었고, Ncloud 서버의 기본 네트워크 대역폭1Gbps입니다. 1초에 80000개 메시지를 보내기 위해 필요한 대역폭은 122.24Mbps 정도입니다. 1개의 메시지를 대략 191bytes 정도로 잡고 계산했습니다. 

 

`1Gbps = 1000Mbps > 122.24Mbps`이기에 대역폭 자체의 문제는 아닐 것으로 판단했습니다. 

 

4) Network I/O로 인한 과부하

드디어 마지막입니다! 마지막에 둔 이유가 있겠죠? ㅎㅎ

 

바로 Network I/O 작업 때문입니다. I/O 자체가 비용이 비싼 작업이며 Network I/O 역시 마찬가지입니다. 1초에 80000번 Network Ouput을 하는 것이니 여기서 병목이 발생했을 걸로 추정했습니다. 

 

Network Output을 위해서 OS에서 시스템 콜을 통해 유저모드와 커널모드 사이를 전환하면서 생긴 오버헤드가 있을 걸로 추정했습니다. 또,  컨텍스트 스위칭이 다소 일어났을 것입니다. 

 

수많은 Network I/O가 유력한 원인일 것으로 파악했습니다. 그래서 Network I/O로 인한 부하가 Node.js에도 영향을 주었을 것입니다. 

 

과감히 개선하기로 결정했습니다!

 


🎯 수많은 Network Output을 어떻게 개선할 수 있을까?

1) 데이터 배치 처리

  • 배치 처리(Batch Processing): 데이터에 대한 반복적인 처리를 하는 대신, 데이터를 모아서 일정 주기로 한 번에 처리
  • 이벤트를 바로 브로드캐스팅하지 않고 큐에 쌓아두기
  • 정해진 주기로 큐에 있는 걸 한 번에 전송함으로써 Network Output 부담 줄이기
  • 기존에는 1초에 80000번 Network Output을 해야 했지만,
    0.1초에 1번씩 쌓인 배치를 처리한다면 배치 처리를 통해 1초에 10번 Network Output하게 됨

 

2) 데이터 압축

  • 데이터 크기를 줄여 네트워크 부담을 줄이기
  • Network I/O 횟수 자체는 변동이 없기에 지금 문제에 적절한 해결 방안은 아님

 

3) 수평적 확장 또는 수직적 확장

  • 서버를 더 늘리거나 사양을 높이는 건 본질적인 해결 방안이 아님 (우선 pass)

 

배치 처리로 선택

지금 당장 시도해볼 법한 건 배치 처리라고 판단했습니다. Network Output 횟수 자체를 줄이는 게 병목의 원인을 제거하는 데 핵심이기 때문이죠. 그리고 배치처리는 횟수를 줄여줄 수 있는 아주 좋은 솔루션입니다.

 


✈️ 데이터 배치 처리 도입하기

구현 계획

  1. 메모리 변수에 큐를 두기
  2. 채팅, 위치 변경 요청이 들어오면 바로 브로드캐스팅하지 않고, 큐에 데이터를 일단 쌓아두기
  3. 0.1초에 한 번씩 큐 배치 처리하기

 

구현 코드

위치 변경과 채팅에 대해서 배치 처리를 해주었습니다. 아래는 배치 처리를 직접 구현한 코드입니다! 배치 프로세서위치 변경 함수에 대해 살펴보시죠!

 

참고로 위치 변경의 배치처리 주기는 0.1초로 설정했습니다. 채팅은 더 빠른 실시간 동기화를 위해 0.05초로 설정했습니다.

 

1) batch.processor.ts (배치 처리를 지원하는 모듈)

// 배치처리 모듈
export class BatchProcessor implements OnApplicationShutdown, OnModuleDestroy {
  private batchMap: Map<BatchProcessorType, Map<string, any[]>> = new Map();
  private isProcessing = false;
  private intervalId: NodeJS.Timeout;

  private server: Namespace;
  private eventName: string;

  constructor(
    @InjectRedis() private readonly redis: Redis
  ) {}

  // 어떤 서버, 이벤트에서 사용하는 배치처리인지 초기화
  initialize(server: Namespace, eventName: string) {
    this.server = server;
    this.eventName = eventName;
    for (const type of Object.values(BatchProcessorType)) {
      this.batchMap.set(type, new Map());
    }
  }

  // 특정 게임방에 보내야 하는 데이터를 배치 큐에 삽입
  pushData(type: BatchProcessorType, gameId: string, data: any): void {
    const batchMap = this.batchMap.get(type);
    if (!batchMap.has(gameId)) {
      batchMap.set(gameId, []);
    }
    batchMap.get(gameId).push(data);
  }
  
  // 주기적인 배치 처리 시스템 시작 
  startProcessing(interval: number = 100): void {
    this.intervalId = setInterval(() => this.processBatch(), interval);
  }
  
  // 주기적으로 배치 처리하며 각 게임방에 브로드캐스팅
  private async processBatch(): Promise<void> {
    // isProcessing을 통해 lock을 걸어 한 데이터에 대한 여러 번의 배치 처리 방지
    if (this.isProcessing) {
      return;
    }

    this.isProcessing = true;

    // BatchProcessorType에 따라 처리 (ex. 채팅을 모두에게 보낼지, 특정인에게 보낼지)
    for (const type of Object.values(BatchProcessorType)) {
      const batchMap = this.batchMap.get(type);
      const processingTasks = Array.from(batchMap.entries()).map(async ([gameId, queue]) => {
        if (queue.length > 0) {
          const batch = queue.splice(0, queue.length);
          const handler = this.batchProcessHandlers[type];
          await handler(gameId, batch);
        }
      });

      // isProcessing 변수 관리를 위해 await 
      // 시스템 상으로 블록이 되는 것은 아니며 배치 처리 이후 실행해야 할 주요 함수는 없기에 성능 영향x
      await Promise.all([...processingTasks]);
    }

    this.isProcessing = false;
  }

  // 배치 처리 핸들러 
  private batchProcessHandlers: Record<
    BatchProcessorType,
    (gameId: string, batch: any[]) => Promise<void>
  > = {
    [BatchProcessorType.DEFAULT]: // (생략) 디폴트 브로드캐스팅 해주는 함수,
    [BatchProcessorType.ONLY_DEAD]: // (생략) 게임에서 죽은 사람끼리 브로드캐스팅하는 함수
  };
}

 

 

2) player.subscriber.ts (캐릭터의 변경된 위치를 브로드캐스팅하는 모듈)

@Injectable()
export class PlayerSubscriber extends RedisSubscriber {
  constructor(
    @InjectRedis() redis: Redis,
    private positionProcessor: BatchProcessor
  ) {
    super(redis);
  }

  // 배치처리 객체 초기화 및 시작
  async subscribe(server: Namespace): Promise<void> {
    this.positionProcessor.initialize(server, SocketEvents.UPDATE_POSITION);
    this.positionProcessor.startProcessing(POSITION_BATCH_TIME);
    
    ...
  }

  // 배치 처리 큐에 데이터 쌓아두기  
  private async handlePlayerPosition(playerId: string, playerData: any) {
    const { gameId, positionX, positionY } = playerData;
    const playerPosition = [parseFloat(positionX), parseFloat(positionY)];
    const updateData = { playerId, playerPosition };

    const isAlivePlayer = await this.redis.hget(REDIS_KEY.PLAYER(playerId), 'isAlive');
		const processorType = isAlivePlayer === SurvivalStatus.ALIVE 
		 ? BatchProcessorType.DEFAULT 
		 : BatchProcessorType.ONLY_DEAD;
		 
		this.positionProcessor.pushData(processorType, gameId, updateData);
  }

 

보시다시피 라이브러리를 별도로 사용하지 않고 직접 구현했는데요! 그 이유는 배치 처리를 직접 구현하여 학습하기 위함이었습니다. 팀원 모두 배치 처리 관련 경험이 처음이었고, 이를 직접 구현하면서 배치 처리에 대해 알아가보고자 했습니다. 그래야 향후 라이브러리를 도입하더라도 더 잘 쓸 수 있고, 장애에도 더 빠르게 대응할 수 있을 거라 생각했기 때문입니다. 

 


🚀 부하테스트 실행하여 확인해보자!

개요

  • 이전 부하테스트 환경과 동일

 

위치 변경의 응답시간 - 이전과 이후 결과 비교

세 번째 부하테스트 결과

 

채팅의 응답시간 - 이전과 이후 결과 비교

세 번째 부하테스트 결과

 

결과 해석

채팅, 위치 변경의 응답시간이 비약적으로 단축되었습니다!! E2E 측정 시간을 보더라도 최대 0.12초 안에 변경된 위치 응답을 받는 모습을 보였습니다. 즉, 배치 처리가 성공적으로 이루어졌다는 걸 알 수 있어요!

 

기존에는 Network Output이 1초에 80000번이었고, 이제는 1초에 30번 이하입니다. 역시 수많은 Network I/O로 인한 오버헤드가 병목의 원인이었을 걸로 추정되네요. 

 


📝 추가 분석

'멀티 프로세스 + 배치 처리' 모니터링 결과

Ncloud의 cloud insight 활용

 

  • Memory: 약 500MB 상승
  • Network Output: 100Mbps
  • CPU: 50% 상승 (용량이 큰 데이터의 직렬화, 패킷 등 네트워크 스택에서 부하가 생겼을 걸로 보임)

 

향후 개선 사항

  1. 한 게임방 200명이 있을 때 몇 개의 게임방까지 버틸 수 있는지 체크 후, 병목 재분석
  2. 배치 처리 양이 많아질 때를 감안하여 메시지 압축 고려
  3. 급격히 트래픽 많아지는 걸 대비해 오토 스케일링 조건을 세우고 클라우드에서 설정
  4. 모니터링 도구를 도입하여 더 많은 데이터를 바탕으로 병목 분석
  5. 커스텀 메트릭 측정 코드가 배치 처리 로직과 섞여있는 것 분리

 


⭐️ 결론

요약

  1. 한 게임방 200명 원활한 플레이 지원하고자 부하테스트 진행
  2. '멀티 프로세스 + 배치처리'를 도입해 대부분 0.1초 안에 응답을 받을 수 있게 되며 비약적인 성능 향상
  3. 한 게임방 200명 목표를 달성했으며, 더 많은 트래픽을 감당하기 위해서는 추가 개선이 필요

 

레퍼런스

 

🙋🏻‍♂️ 궁금한 점이 있다면 무엇이든 댓글 편하게 남겨주세요~~! 🙋🏻‍♂️ 

 

🙇🏻 읽어주셔서 감사합니다 🙇🏻

 

반응형