티스토리 뷰
로드밸런서가 아닌 API 서버에서 로드밸런싱을 수행할 수 밖에 없었던 작업 내용을 공유합니다.
요구사항
- 아이템(선수팩, 랜덤박스 등)의 실제 개봉 확률을 의도한 확률과 비교하여 검증하고자 한다.
- 어드민에서 100만 건의 아이템을 한 번에 개봉하여 로그로 결과를 확인하고자 한다.
* 아이템 개봉이란 아이템을 사용해 정해진 확률을 기반으로 랜덤한 결과(선수, 재화 등)를 계정에 획득하는 것을 의미합니다.
개발 범위 설정
- 실제 확률을 검증하기 위해 이미 존재하는 개봉 메서드를 활용하며 어드민에서만 사용 가능한 기능을 제공한다.
- 따로 로직을 만들어 기능을 제공하면 실제 로직을 타지 않아 확률을 검증한다는 기능의 의미와 괴리가 있습니다.
개발 과정 1: 테스트, chunk size, 그리고 두가지 문제점...
테스트 1. 메서드 1회 호출에 100만 건을 한 번에 요청하기
- 맨 처음에는 다음과 같은 의문이 들었습니다: 아이템 개봉 메서드를 최소한으로 수정하여 100만 건을 개봉할 순 없을까?
- 이에 따라 로컬 환경에서 개봉 메서드의 한 번에 최대 개봉 가능한 개수인 10개 제한만 간단히 분기처리로 우회하여 100만 건을 한 번에 개봉해보았습니다.
아쉽게도 100만 건을 한 번에 개봉하다 부하가 심해지며 중간에 OOM과 함께 로컬 환경의 서버가 죽어버렸습니다...
- 메서드 내부 로직에서 100만 번 반복하는 부분이 여러번 있었을 것이며 반복 내에서 MongoDB 와 상호작용하고 MQ 에 메시지를 보내는 등 데이터가 많이 오가고 있기 때문에 어쩌면 당연한 결과였습니다.
테스트 2. 메서드 1회 호출에 chunk 단위 만큼 요청하여 메서드를 반복 호출하기
- chunk 를 테스트로 산정하여 메서드 호출 시 chunk 단위만큼 요청하면서 메서드 호출을 반복하고자 했습니다.
- 부하가 문제 없는 정도의 chunk size 중에 성능이 우수한 매직 넘버를 찾고자 테스트를 진행했습니다.
- 테스트 전체 시간을 고려해 10만 건만 개봉하며 다음과 같은 단계로 chunk size를 찾아갔습니다.
- chunk size 20000 부터는 부하가 심했기 때문에 20000 미만의 size 가 적합했습니다.
- [10000, 5000, 1000, 500, 100, 50]을 chunk size로 설정하여 시간 여건 상 3번씩만 실행하며 평균 속도를 비교하여 다음과 같은 적정값을 찾았습니다.
chunk size : 100
- 테스트하면서 알 수 있었던 두가지 문제점이 있었습니다.
첫 번째 문제: 트랜잭션 예외 케이스
- 사내에 구현된 트랜잭션은 트랜잭션 도중 동일 Docs 동시 접근 시 에러를 발생시킵니다.
- 아이템 개봉 메서드를 병렬로 호출할 경우 동일한 Docs 로 동시 접근이 일어나기 때문에 에러가 필연적으로 발생합니다.
- 따라서 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 서버가 요청을 받은 시점 부터의 흐름입니다.
- [Lock, 요청 ID, 저장소의 count] 등의 로직을 제외하고 확인하시면 좋을 것 같습니다. 다른 고민에 의해 구현한 부분입니다.
- 용어 정리
- leftCount: 개봉해야 하는 잔여 아이템 개수
- RPC request: API 서버가 수행하는 RPC 요청
- chunkSize: 테스트로 산정한 chunk 사이즈, 100
- response.usedCount: 한 번의 RPC 요청마다 개봉한 아이템 개수이자 RPC 응답 데이터
- 처리 가능 여부 판단 로직 추가 설명
- 각 서버 본인의 식별자를 각자 어드민 서버 메모리 상에 가지고 있으며 request와 response 마다 최대 N 크기의 리스트를 전달하며 진행됩니다.
- 리스트에 서버 본인의 식별자가 존재하는 경우 처리 가능하지 않다고 판단하고 처리하지 않고 응답합니다.
- 처리 가능한 경우 서버 본인의 식별자를 리스트에 넣으며 리스트 크기가 N을 넘어서면 가장 오래된 식별자 하나를 제거합니다.
- RPC request를 반복하는 서버는 리스트를 (프로그래밍 언어의)변수로 관리하며 RPC를 통해 주고 받습니다.
구현 결과
- 로컬에서 간단하게 테스트를 진행해 결과를 확인했습니다.
- 이전에 구성해둔 Docker 환경 기반으로 서버 3대를 사용해 N이 2인 경우를 테스트했으며, 부하 분산 수행하지 않았을 때와 다음과 같이 달랐습니다. (처리 중인 상태의 평균 값 기준 수치입니다.)
- 부하 분산을 수행하지 않았을 때
- Memory: 2450MB (60%)
- CPU: 100%
- 부하 분산을 수행했을 때
- Memory: 800MB (20%)
- CPU: 45%
- 부하 분산을 수행하지 않았을 때
- 매우 유의미한 결과를 얻었습니다. 100만 건을 처리하는데 문제가 없었습니다.
- 추가로, 9분 내로 100만 건이 처리됨 또한 확인할 수 있었습니다.
- 더 많은 수의 서버가 클러스터링된 실제 어드민 서버에서 테스트하면 더욱 유의미한 결과가 나올 수 있습니다.
이러한 고민과 문제해결로 요구사항을 만족시켰으며 단순 기능 개발 이상으로 다음과 같은 의미가 있었습니다:
기술 관점 의미
- 제약이 여러모로 많은 환경에서 새로운 기술 도입이나 인프라 수정 없이 문제를 해결했다는 점에서 의미가 깊습니다.
- 사내에 없던 새로운 부하 분산 구조를 직접 설계하고 개발하는 경험이 되었습니다.
성과 관점 의미
- 어드민에 아이템 개봉 기능이 없던 불편함을 개선하였으며 개발자 및 테스터의 업무 생산성에 기여했습니다.
- 게임 클라이언트에 직접 접속하여 개봉했던 반복 작업과 한 번에 최대 10건 개봉 가능했던 불편함을 개선했습니다.
- 아이템 개봉 확률을 검증하는데 기여했습니다.