매칭 서버의 대규모 트래픽 시험 검증하기 (feat. Kafka 성능 튜닝)
매칭 시스템은 가장 성능이 중요한 비즈니스인 만큼 검증은 필수적이다.
매칭 비즈니스 흐름도
기본 골자는 다음과 같다.
registerMatchQueue : 회원 서비스에게 요청을 보내서 현재 게임중인 사용자인지 검증하고, 매칭 큐에 등록한다.
getRank : 현재 매칭중인지 확인하는 API
calcelMatchQueue : 매칭 큐에 등록된 사용자인지 확인하고 취소한다.
매칭 큐에 등록될때 ZSet 자료구조를 통해 사용자의 Elo 점수를 score로 정렬해서 비슷한 실력대의 사용자들끼리 매칭시켜준다.
마이크로 서비스로 분해하면서 회원 정보의 Nickname에 접근하기 까다로워졌기에, 맨 처음 매칭 큐에 등록할때 사용자 정보를 받아서 HSet 타입으로 캐싱해둬서 가져오도록 했다.
매칭하는데 결과가 줄줄 새요 (random()로 인한 Redis의 Key 중복)
사실 처음에 1만명 정도 매칭 큐에 때려박아서 테스트를 진행한 적이 있었다.
Docker 전체 RAM, CPU는 큰 변화 없었다. (RAM 4.82GB, CPU 0.98% 유지)
그런데도 결과는 722/1000 consumed. (62 sec)
10명씩 매칭하니 1000개의 매칭 결과를 발행해야하는데 고작 722개만 나온 것이다.
72%의 사용자만 매칭 성공시킨다는건 말이 안된다. 너무 유실되는 양이 많다.
매칭 큐를 열어보니 남은 데이터가 하나도 없어서, Kafka 메시지 발행간에 유실된 내용이라고 판단했었다.
좀더 가능성을 열어뒀더라면 이틀이나 끙끙 앓지 않았을텐데 그래도 나름 논리적으로 움직이려고 한거다.
추론의 방향이 점점 산으로 가니까 Kafka가 브로커 하나라서 퍼포먼스를 내지 못한다고 생각해서 Cluster로도 구축하고..
열심히 토픽 설계나 튜닝도 해보고...
결과적으로 공부는 많이 됐지만 원인은 결국 부하를 억지로 주기 위해 작성한 API가 문제였다.
플레이어의 id로 사용하는 난수값이 중복되면서 일부 사용자가 Redis에 등록되지 못했던거다.
AtomicLong memberIdGenerator = new AtomicLong(1);
return Flux.range(0, requests)
.flatMap(request -> {
Long memberId = memberIdGenerator.getAndIncrement();
Long elo = 1200 + (long) (Math.random() * 500);
String nickName = "test" + memberId;
return matchQueueService.requestIntegrationTest(memberId, nickName, elo);
})
.then(matchQueueService.getRequestCount());
AtomicLong 타입을 이용해서 원자적으로 increment시키는 memberId를 부여해서 해결했다.
requestIntegrationTest 메서드는 사용자 검증이나 인가 없이 바로 매칭 큐에 진입시켜준다.
마지막으로 getRequestCount()를 통해서 requests만큼 등록되었는지 매칭 큐의 사이즈를 반환한다.
1만 명이 제대로 매칭 큐에 등록됨을 확인했다. 이제 가보자고~~~~
매칭간의 동시성 이슈 해결 (Redis executeInSession, Lock 처리)
싱글쓰레드의 외부 라이브러리인 Redis의 특징을 살려서 여러 매칭 노드에서도 동시성 문제가 생기지 않도록 Lock을 구현했다.
public Mono<Boolean> acquireLock(String lockKey, String lockValue) {
return reactiveRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10));
}
private Mono<Boolean> releaseLock(String lockKey, String lockValue) {
return reactiveRedisTemplate.opsForValue().get(lockKey)
.flatMap(currentValue -> {
if (lockValue.equals(currentValue))
return reactiveRedisTemplate.opsForValue()
.delete(lockKey)
.map(deleted -> deleted != null && deleted);
return Mono.just(false);
});
}
lockKey와 lockValue 형태로 10초 짜리 Lock을 간단하게 구현했다.
그리고 기존의 RedisTemplate에 있던 트랜잭션 관리가 ReactiveRedisTemplate에서 없는 줄 알았는데, 사실 있었다.
젠장 이걸 이제야 알다니
reactiveRedisTemplate.executeInSession << 어쩐지 트랜잭션으로 쳐도 안나오더라
private Mono<Void> processQueue(String queue) {
return reactiveRedisTemplate.executeInSession(session ->
Flux.defer(() -> processQueueInRange(queue, 100)).then())
.then();
}
processQueueInRange 에서는 100회까지 큐잉을 진행한다.
reactiveRedisTemplate.opsForZSet()
.popMin(MATCH_WAIT_KEY.formatted(queue), MAX_ALLOW_USER_COUNT)
요 귀염둥이자식ㅎ...
Sorted Set 타입의 매칭 큐에서 popMin을 통해서 원하는 인원만큼 빼낼 수 있다.
if (memberValues.size() < MAX_ALLOW_USER_COUNT) {
return handleMatchError(queue, memberValues)
.doOnTerminate(() -> stopProcessing.set(true)); // 실제 Mono 성공시 호출
}
return handleMatchFound(queue, memberValues)
.onErrorResume(e -> handleMatchError(queue, memberValues));
})).then();
MAX_ALLOW_USER_COUNT는 여기서는 10명을 나타내는 상수이다.
이보다 적은 인원이 매칭 큐에 존재하는 경우: 다시 매칭 큐에 등록(handleMatchError 메서드)
handleMatchFound 메서드를 통해서 매칭 결과를 게임 서버(IOCP)로 전달하는 역할을 한다.
이때 오류가 발생하면 해당 사용자들은 다시 매칭 큐에 등록한다.
Kafka Cluster 구축(KRaft, Not Zookeeper)
로컬 환경에서 테스트하는거라, Kafka나 Redis를 Cluster 환경으로 구축하지 않고 단순하게 StandAlone으로 진행했었다.
벌써 비명지르는데 내 컴퓨터가 받아줄 수 있을까.
비용 때문에 쿠버도 minikube로 로컬에서 돌리는 나한테 클라우드는 고사하고 MSK, Kinesis는 택도 없다
하던대로 로컬에서 Docker-compose로 진행하겠다....
각 브로커마다 brokerId, nodeId를 설정해줘서 Kafka 노드를 3개 띄울거다.
클러스터 환경 구축하는데 삽질이 많았다......
삽질이 많았던 항목만 설명하겠다.
Controller의 Quorum Voter 설정 : 각 노드의 Id와 Controller Listener 포트를 명시해준다.
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka-0:9093,1@kafka-1:9093,2@kafka-2:9093
해당 노드가 Controller, Broker 역할을 모두 수행
KAFKA_CFG_PROCESS_ROLES=controller,broker
Broker가 수신할 리스너, 클라이언트가 연결할 주소 설정
- KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
- KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://kafka-2:9092,EXTERNAL://127.0.0.1:10002
참고로 kafka-ui에서는 내부에서 노드간 통신에 쓰이는 9092포트를 환경 변수로 주입해야한다.
중요한건 브로커 개수에 맞는 Partition Replica, In-sync Replica 같은 토픽의 수많은 변수들을 조율해야하는거다.
무턱대고 파티션 수나 브로커 수만 늘린다고 되는게 아니라, 각 파티션별로 골고루 작업을 분산하도록 해야해서 까다롭다.
애플리케이션에서는 bootstrap.servers에 아무 브로커의 주소나 지정해도 된다.
kafka.clusters.bootstrapservers=kafka-0:9092,kafka-1:9092,kafka-2:9092
어차피 클러스터의 메타데이터를 받아오면 자동으로 연결되지만, 그래도 가용성을 위해 모든 브로커의 주소를 명시해뒀다.
Kafka 성능 튜닝
테스트 전에 간단한 튜닝을 거쳐서 Kafka 최소 한번 전송 전략을 구현했다.
비즈니스상 유실은 용서할 수 없고, 속도보다도 안정성이 중요하다고 판단했기 때문이다.
ProducerConfig.ACKS_CONFIG : 모든 replication들이 전송을 완료해야 수신한걸로 친다. (all)
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG : 자동으로 Commit하지 않고, 메시지를 처리하고 나서야 소비된걸로 친다. (false)
최소 한번 전략을 그대로 유지하면서 3번까지 재시도(100ms 간격)으로 설정했다.
압축이나 배치 사이즈 조절도 향신료처럼 슉슉 곁들였다.
Producer
1. acks=all
2. compression.type=snappy
3. batch.size=32768
4. retries=3
5. retry.backoff.ms=100
Consumer
1. group.id=requestConsumerGroup
2. auto.offset.reset=latest
3. enable.auto.commit=false
매칭 서버에 대한 대규모 트래픽 주입 실험 - (1)
게임 테스트한다고 두명씩 짝지어놨었는데
원래 계획대로 5 vs5(10명)이 매칭되도록 설정해서 다시 10000명 꼴아박아보겠다.
Flux.range를 이용해서 반복해서 매칭 큐에 무작위 사용자를 등록하는 API를 구축해서 돌렸다.
메시지 유실 없이 생성한 사용자 1만 명을 확실히 매칭시켜주고 있다. 1000 consumed.
그럼 전송된 매칭 결과는 게임 서버에 수신되었는지 확인해자.
게임 서버는 받은 매칭 결과를 토대로 빈 게임 공간을 찾고 방을 생성한다.
1000개의 매칭 결과가 모두 잘 전달되었다.
게임 서버 내에서 Channel을 2개로 설정했고 각 채널당 게임 공간(room)은 100개로 제한했다.
그리고 0채널 0번 방은 아직 인가받지 못한 임시 접속자들을 수용하는 공간으로 사용하고 있어서 제외해야한다.
따라서 생성된 방은 1채널 99번 방까지 생성되는 것이 정상적이다.
최대 200개의 게임 공간만 생성할 수 있기 때문에 'There are no empty rooms' 로그는 정상적인 로그이다.
매칭 서버에 대한 대규모 트래픽 주입 실험 - (2)
10만명 주입으로 한계를 테스트해보고 싶었다
먼저 최대 10만명을 수용할 수 있는 서버에 Channel 100개, 각 채널당 게임 공간(Room) 1000개를 설정했다.
각 게임 공간에는 5명씩 이뤄진 2개의 팀이 경쟁하는 서비스이니 계왕권 10만배로 다시 도전해보겠다.
10만명을 10명씩 짝지은 매칭 결과는 당연히 10000개가 나와야한다. 유실된 데이터가 있는지도 확인해보자
모든 매칭 결과를 유실없이 소비해서 게임 공간 10000개 생성
매칭 서버의 메시지 발행시간 : 약 5분 (2025. 1. 10. 13시 33분 3초 - 2025. 1. 10. 13시 37분 44초)
게임 서버의 메시지 소비와 방 생성 작업 처리시간 : 약 7분
발행과 동시에 소비하기 시작하니까 대충 10만명을 온전히 처리하는데 7분쯤 걸린다고 생각하면 될거 같다.
몇초에 어느 정도의 양을 처리하는게 가장 효율적인지 계산할려면 일단 실사용자(여기서는 동시접속자 수)가 중요하다고 생각한다.
게임이라는 도메인에서 동시 접속자가 몇 명이고, 가장 매칭 시도가 많은 시간대가 언제인지 데이터가 쌓여야할 것 같다.
혹시라도 Kafka 메시지 유실되면 비즈니스는 어떻게 관리하지?
처음에 말도 안되는 결과에 가장 먼저 Kafka 메시지의 유실로 의심했고, 돌이켜보면 부끄러울 따름이다.
하지만 예상치 못한 장애가 생길 수 있는 상황임을 알고도 해결하지 않을 수 있겠는가? 시작하자
기존 클라이언트의 매칭 흐름은 다음과 같다.
1. 매칭 큐에서 사라지는 getRank() 메서드 호출해서 매칭 정보를 반환
2. 클라이언트는 로비에서 게임 공간으로 진입
3. 게임 서버(IOCP)에 접속한다.
매칭 처리를 전달하는 과정에서 메시지가 유실되는거라, 게임 서비스 진행을 위해서라면 꼭 관리해줘야한다.
백엔드에서는 게임중으로 기록하지만, 정작 게임 서버에서는 매칭 결과를 전달받지 못해서 치명적이다.
흔치 않은 상황이기에 사용자가 서비스의 오류를 불편함으로 인식하지 않도록 재시도를 하거나, 매칭 진행을 취소하는 쪽으로 가닥을 잡자
이거 생각보다 까다롭다.
인가받은 사용자인지, 매칭된 사용자인지 게임 서버 안에서 판단하고 클라이언트를 다시 로비로 쫓아내는 로직은 이미 구현해놨었다.
백엔드 단에서는 게임중인줄 아는데 어떻게 알려주죠??????????????????????