프로젝트-탐구/아이템 가상 bulk 개봉

아이템 bulk 개봉과 API 서버의 자체 로드밸런싱

ohksj77 2025. 4. 4. 01:30
로드밸런서가 아닌 API 서버에서 로드밸런싱을 수행할 수 밖에 없었던 작업 내용을 공유합니다.

 

요구사항

  • 아이템(선수팩, 랜덤박스 등)의 실제 개봉 확률을 의도한 확률과 비교하여 검증하고자 한다.
  • 어드민에서 100만 건의 아이템을 한 번에 개봉하여 로그로 결과를 확인하고자 한다.

 

* 아이템 개봉이란 아이템을 사용해 정해진 확률을 기반으로 랜덤한 결과(선수, 재화 등)를 계정에 획득하는 것을 의미합니다.

 

개발 범위 설정

  • 실제 확률을 검증하기 위해 어드민에서 실 서비스에서 사용되는 개봉 메서드를 호출한다.
    • 따로 로직을 만들어 기능을 제공하면 실제 로직을 타지 않아 확률을 검증한다는 기능의 의미와 괴리가 있습니다.

서버 구조 파악

API Gateway는 Consul에서 알맞은 API 정보를 찾아서 MQ 기반의 RPC 통신으로 Admin Server에 요청합니다.

 

개발 과정 1: 테스트, chunk size, 그리고 두가지 문제점...

테스트 1. 메서드 1회 호출에 100만 건을 한 번에 요청하기

  • 맨 처음에는 다음과 같은 의문이 들었습니다: 아이템 개봉 메서드를 최소한으로 수정하여 100만 건을 개봉할 순 없을까?
    • 이에 따라 로컬 환경에서 개봉 메서드의 한 번에 최대 개봉 가능한 개수인 10개 제한만 간단히 분기처리로 우회하여 100만 건을 한 번에 개봉해보았습니다.
아쉽게도 100만 건을 한 번에 개봉하다 부하가 심해지며 중간에 OOM과 함께 로컬 환경의 서버가 죽어버렸습니다...
  • 메서드 내부 로직에서 100만 번 반복하는 부분이 여러번 있었을 것이며 반복 내에서 MongoDB 와 상호작용하고 MQ 에 메시지를 보내는 등 데이터가 많이 오가고 있기 때문에 어쩌면 당연한 결과였습니다.

 

 

테스트 2. 메서드 1회 호출에 chunk 단위 만큼 요청하여 메서드를 반복 호출하기

  • chunk 를 테스트로 산정하여 메서드 호출 시 chunk 단위만큼 요청하면서 메서드 호출을 반복하고자 했습니다.
  • 부하가 문제 없는 정도의 chunk size 중에 성능이 우수한 매직 넘버를 찾고자 테스트를 진행했습니다.

 

  • 테스트 전체 시간을 고려해 10만 건만 개봉하며 다음과 같은 단계로 chunk size를 찾아갔습니다.
  1. chunk size 20000 부터는 부하가 심했기 때문에 20000 미만의 size 가 적합했습니다.
  2. [10000, 5000, 1000, 500, 100, 50]을 chunk size로 설정하여 시간 여건 상 3번씩만 실행하며 평균 속도를 비교하여 다음과 같은 적정값을 찾았습니다.
chunk size : 100

 

 

  • 테스트하면서 알 수 있었던 두가지 문제점이 있었습니다.

첫 번째 문제: 트랜잭션 예외 케이스

  • 사내에 구현된 트랜잭션은 트랜잭션 도중 동일 MongoDB Document 동시 접근 시 에러를 발생시킵니다.
  • 아이템 개봉 메서드를 병렬로 호출할 경우 동일한 Document로 동시 접근이 일어나기 때문에 에러가 필연적으로 발생합니다.
  • 따라서 chunk size 만큼 메서드를 호출하는 작업을 모두 순차 처리해야 했습니다.

 

두 번째 문제: 부하의 심각성

  • 하나의 서버에서 100만 건을 chunk 단위로 분할하여 모두 순차 처리한 결과, 부하가 매우 심했습니다.
  • 처리 전, 처리 중 사용률 증분
    • Memory: 360MB (8%) -> 2450MB (60%)
    • CPU: 0% -> 100%

 

  • 어드민 서버 하나를 완전히 장악할 수준의 하드웨어 자원 사용률이었습니다.
하나의 서버에서 처리할 수 없는 작업이므로 부하 분산이 필요했습니다.

 

 

개발 과정 2: 로드밸런서가 아닌 API 서버가 직접 로드밸런싱?!

어드민 클라이언트에서의 chunk 단위 반복 요청

  • 하나의 서버가 모든 chunk를 처리할 수 없으므로 여러 서버가 처리에 참여해야 합니다.
    • 이 기능 하나 때문에 수많은 어드민 서버를 모두 스케일업할 순 없습니다.

 

  • 클라이언트가 chunk 단위만큼 요청을 보내며 반복 요청을 하면 좋은 구조인지는 헷갈리지만 문제가 해결될 것으로 예상했습니다.
  • 이를 테스트하기 위해 Docker로 직접 실제 어드민 서버와 동일하게 CPU, Memory를 할당한 3대의 서버를 빠르게 띄워봤습니다.

 

  • 수정하여 테스트한 결과, 예상과는 다르게 특정 서버에 처리가 집중되는 현상이 자주 발생했으며 부하 문제가 해결되지 않았습니다.
    • 확인 결과 앞단의 API Gateway가 수행하는 RabbitMQ RPC 통신의 queue에 전역으로 prefetch size가 설정되어 있다는 사실을 알 수 있었으며, 이 prefetch size가 원인임을 유추할 수 있었습니다.
다수의 메시지를 Consumer가 한 번에 가져오기 때문에 균등한 분배가 불가하다.

 

 

  • 그럼, 지금 구조로는 해결이 불가능한지 다음과 같이 고민했습니다.
    • 관리자 페이지에서 요청 시 즉시 수행되어야 하기에 요청 시 bulk 처리용 서버를 새로 띄워서 사용하고 종료하는 방법은 어렵다.
    • 하나의 기능만을 위한 서버를 상시로 실행해두는 것은 예외적인 서버를 두는 것이므로 관리가 어려울 것이다.
    • API Gateway 혹은 prefetch size 를 수정하기에는 오랫동안 전역으로 사용되는 기능과 설정값을 수정하는 것이고 정말 많은 고민이 들어가있을 터인데 어드민 기능 하나 때문에 수정하기에는 부담이 크고 좋은 선택이 아니다.
  • 따라서 현재 구조로 해결이 불가능한 문제라고 판단을 내렸으며, 새로운 시도가 필요한 시점이라 생각했습니다.

 

API 서버에서 반복 요청하며 로드밸런싱을 수행하도록 구현

  • 이렇게 제약이 많은 상황에서 제가 선택할 수 있는 최선의 방법을 고민하며 다음과 같이 생각했습니다.
API 서버가 반복 요청하면서 부하에 맞게 처리 여부를 선택할 순 없을까?

 

 

  • API Gateway가 호출한 API 서버에서 직접 반복 요청하며 로드밸런싱 수행을 시도하기로 결정했습니다.
    • 커넥션을 유지할 이유는 없어서 우선 클라이언트에 응답한 이후 이러한 처리를 수행하기로 했습니다.
  • 이를 위해 사전 설계가 필요했습니다.

 

설계 1: 통신 기술 선택

  • 순차 처리가 필요했으며 하나의 서버만 요청을 받도록 하기 위해 이미 사용 중인 통신 방법인 RPC를 선택했습니다.
    • 나머지 선택지로는 Event Queue, Redis Pub/Sub 이 있었습니다.
    • 서버의 API들은 RPC 기반이었으며 HTTP는 사용하는 곳이 없었기에 배제했습니다.

 

설계 2: 처리 여부 선택 알고리즘

  • RPC 요청 큐의 Consumer가 폴링으로 가져오는 구조였기에 요청 보내는 시점에 처리 서버 결정이 불가했습니다.
  • 따라서 요청 받은 서버가 처리 여부를 선택하는 구조가 필요했으며, 처리 여부를 선택하는 알고리즘 또한 필요했습니다.
  • 부하 분산이 목적이었기에 가장 최근에 처리한 N개의 서버는 처리하지 않도록 제한하는 알고리즘을 떠올렸습니다.
    • 최근에 처리한 서버는 부하가 높을 것이므로 최근에 처리하지 않은 서버들이 처리 가능하도록 하기 위한 아이디어입니다.

 

  • N이라는 매직넘버를 찾아야 했으며 우선 구현 후 테스트하기로 했습니다.

 

설계 3: 전체 흐름 설계

  • 생각해둔 구조대로 다음과 같은 흐름을 설계 및 구현했습니다.
    • API Gateway를 지나 API 서버가 요청을 받은 시점 부터의 흐름입니다.
    • 2개의 RPC기반 API를 구현해 전체 흐름을 구성했으며, API Gateway가 API 1을 어드민 서버에 요청하면 해당 요청을 받은 서버가 API 2 (아래 흐름에서의 RPC request)를 동기적으로 순차 반복 호출하며 진행됩니다.
    • [Lock, 요청 ID, 저장소의 count] 등의 로직을 제외하고 확인하시면 좋을 것 같습니다. 다른 고민에 의해 구현한 부분입니다.

  • 용어 정리
    • leftCount: 개봉해야 하는 잔여 아이템 개수
    • RPC request: API 서버가 수행하는 API 2 RPC 요청
    • chunkSize: 테스트로 산정한 chunk 사이즈, 100
    • response.usedCount: 한 번의 RPC 요청마다 개봉한 아이템 개수이자 API 2 RPC 응답 데이터

 

  • 처리 가능 여부 판단 로직 추가 설명
    • 각 서버 본인의 식별자를 각자 어드민 서버 메모리 상에 가지고 있으며 API 2 request와 response 마다 최대 N 크기의 리스트를 전달하며 진행됩니다.
    • API 2 요청을 받은 서버는 리스트에 서버 본인의 식별자가 존재하는 경우 처리 가능하지 않다고 판단하고 처리하지 않고 응답합니다.
    • 처리 가능한 경우 서버 본인의 식별자를 리스트에 넣으며 리스트 크기가 N을 넘어서면 가장 오래된 식별자 하나를 제거합니다.
    • API 2 RPC request를 반복하는 API 1 는 서버 식별자 리스트를 (프로그래밍 언어의)변수로 관리하며 RPC를 통해 주고 받습니다.

 

API 1 상세 흐름

* 트랜잭션 에러로 인해 동기적으로 동일 시점에 하나의 서버만 API 2 요청을 받아 처리합니다. (병렬 x)

API 2 상세 흐름

 

* 실제로는 개봉해야할 아이템 개수가 chunk_size 보다 작다면 그 만큼만 개봉합니다.

 

구현 결과

  • 로컬에서 간단하게 테스트를 진행해 결과를 확인했습니다.
  • 이전에 구성해둔 Docker 환경 기반으로 서버 3대를 사용해 N이 2인 경우를 테스트했으며, 부하 분산 수행하지 않았을 때와 다음과 같이 달랐습니다. (처리 중인 상태의 평균 값 기준 수치입니다.)
    • 부하 분산을 수행하지 않았을 때
      • Memory: 2450MB (60%)
      • CPU: 100%
    • 부하 분산을 수행했을 때
      • Memory: 800MB (20%)
      • CPU: 45%
  • 매우 유의미한 결과를 얻었습니다. 100만 건을 처리하는데 문제가 없었습니다.
    • 추가로, 9분 내로 100만 건이 처리됨 또한 확인할 수 있었습니다.
    • 더 많은 수의 서버가 클러스터링된 실제 어드민 서버에서 테스트하면 더욱 유의미한 결과가 나올 수 있습니다.

 

이러한 고민과 문제해결로 요구사항을 만족시켰으며 단순 기능 개발 이상으로 다음과 같은 의미가 있었습니다:

 

기술 관점 의미

  • 제약이 여러모로 많은 환경에서 새로운 기술 도입이나 인프라 수정 없이 문제를 해결했다는 점에서 의미가 깊습니다.
  • 사내에 없던 새로운 부하 분산 구조를 직접 설계하고 개발하는 경험이 되었습니다.

 

성과 관점 의미

  • 어드민에 아이템 개봉 기능이 없던 불편함을 개선하였으며 개발자 및 테스터의 업무 생산성에 기여했습니다.
  • 게임 클라이언트에 직접 접속하여 개봉했던 반복 작업과 한 번에 최대 10건 개봉 가능했던 불편함을 개선했습니다.
  • 아이템 개봉 확률을 검증하는데 기여했습니다.

 

기회가 된다면 개선하고 싶은 점

  • API 2에서의 처리 여부 결정 조건의 고도화가 필요하다고 느껴집니다.
  • 개봉 실패에 대한 에러 핸들링을 더욱 고도화하면 좋을 것이라 생각이 듭니다.