게임 플랫폼에서 성능 테스트는 서버가 얼마나 많은 요청과 동시 접속을 처리할 수 있는지 검증하는 과정이다. 기능 테스트가 시스템의 동작 정확성을 확인한다면, 성능 테스트는 부하가 증가하는 상황에서도 응답 시간과 안정성을 유지할 수 있는지 확인한다.
특히 실시간 멀티플레이 게임 플랫폼은 로그인, 매칭, 게임 결과 저장, 포인트 정산, 보상 지급, WebSocket 통신 등 다양한 작업이 동시에 발생한다. 단순히 요청을 많이 보내는 것만으로는 충분하지 않다. 처리량, 지연 시간, 오류율, 리소스 사용량을 함께 분석해 시스템의 한계를 파악해야 한다.
이 글에서는 k6와 Artillery를 이용한 부하 테스트부터 동시 접속자, RPS, p95·p99 지연 시간, 병목 지점 분석까지 정리한다.
성능 테스트의 목적
성능 테스트의 핵심은 서버가 처리할 수 있는 최대 부하와 정상적인 서비스 품질을 유지할 수 있는 부하 범위를 파악하는 것이다.
주요 검증 대상은 다음과 같다.
- 처리 용량: 일정 시간 동안 서버가 처리할 수 있는 요청의 양
- 응답 성능: 요청부터 응답까지 걸리는 시간
- 안정성: 부하가 증가해도 오류나 타임아웃이 급증하지 않는지 여부
- 확장성: 서버 인스턴스나 리소스를 추가했을 때 처리 능력이 얼마나 개선되는지
- 자원 효율성: CPU, 메모리, 네트워크, 데이터베이스 커넥션 등을 얼마나 사용하는지
- 복구 능력: 과부하가 발생한 뒤 정상 상태로 회복할 수 있는지 여부
성능 테스트는 단순히 최대 요청 수를 기록하는 작업이 아니다. 서비스가 요구하는 품질 기준을 충족하면서 어느 정도의 부하까지 안정적으로 처리할 수 있는지 확인하는 작업이다.
부하 테스트의 종류
성능 테스트는 목적에 따라 여러 유형으로 구분할 수 있다.
부하 테스트(Load Testing)
예상되는 실제 서비스 부하를 재현한다.
예를 들어 게임 플랫폼에서 평소 동시 접속자가 수천 명이고 특정 시간대에 접속자가 급증한다면, 이러한 사용 패턴을 재현해 응답 시간과 오류율을 확인한다.
스트레스 테스트(Stress Testing)
시스템이 감당할 수 있는 범위를 넘어 부하를 점진적으로 증가시킨다.
목적은 정상적인 최대 처리량을 확인하는 것뿐 아니라 한계에 도달했을 때 어떤 문제가 먼저 발생하는지 파악하는 것이다. CPU 포화, 데이터베이스 커넥션 고갈, 타임아웃 증가 등이 대표적인 결과다.
스파이크 테스트(Spike Testing)
짧은 시간 안에 부하가 급격하게 증가하는 상황을 검증한다.
게임 업데이트 직후의 로그인 폭주, 이벤트 시작 시점의 동시 접속, 보상 지급 시간대의 요청 집중 등을 재현할 때 유용하다.
지속 부하 테스트(Soak Testing)
상대적으로 일정한 부하를 장시간 유지한다.
짧은 테스트에서는 발견하기 어려운 메모리 누수, 커넥션 반환 누락, 이벤트 리스너 누적, 큐 적체와 같은 문제를 찾는 데 적합하다.
k6를 이용한 성능 테스트
k6는 JavaScript로 테스트 시나리오를 작성할 수 있는 부하 테스트 도구다. HTTP API뿐 아니라 WebSocket과 같은 통신 시나리오도 구성할 수 있으며, 가상 사용자와 요청 속도를 조절하고 응답 시간 및 오류율을 측정할 수 있다.
Node.js 기반 게임 플랫폼에서는 로그인, 매칭 요청, 경기 결과 제출, 포인트 조회 등 HTTP API의 성능을 검증하는 데 활용할 수 있다.
k6 테스트 스크립트
다음은 게임 서버의 상태 확인 API를 대상으로 부하 테스트를 수행하는 예시다.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 },
{ duration: '1m', target: 50 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<300'],
},
};
export default function () {
const response = http.get(
'http://localhost:3000/health'
);
check(response, {
'status is 200': (res) => res.status === 200,
});
sleep(1);
}
이 스크립트는 가상 사용자 수를 점진적으로 늘렸다가 줄인다. 응답 실패율은 낮게 유지하고, 요청 지연 시간의 p95가 설정한 기준을 넘지 않는지 검사한다.
실행 명령은 다음과 같다.
k6 run load-test.js
실제 프로젝트에서는 /health와 같은 단순한 엔드포인트뿐 아니라 인증이 필요한 API, 데이터베이스 조회, 매칭 요청, 정산 작업 등 대표적인 업무 흐름을 테스트해야 한다.
또한 sleep()은 가상 사용자가 요청 사이에 대기하도록 만드는 설정이다. 이를 제거하면 요청 발생 패턴이 달라지므로, 실제 사용자 행동을 재현하는 테스트와 최대 처리량을 측정하는 테스트를 구분해야 한다.
k6의 주요 활용 기능
- Virtual Users: 동시 사용자 시나리오를 구성한다.
- Stages: 시간에 따라 부하를 증가하거나 감소시킨다.
- Thresholds: 응답 시간과 오류율에 대한 합격 기준을 설정한다.
- Checks: HTTP 상태 코드나 응답 데이터의 정확성을 검사한다.
- Metrics: 요청 수, 처리량, 지연 시간, 실패율 등을 수집한다.
주의할 점은 가상 사용자 수와 실제 동시 접속자 수가 항상 같지는 않다는 것이다. 테스트 시나리오가 실제 연결을 유지하는지, 요청 후 대기하는지에 따라 서버에 가해지는 부하가 달라진다.
Artillery를 이용한 성능 테스트
Artillery는 YAML 기반 설정과 JavaScript를 활용해 부하 테스트를 작성할 수 있는 도구다. HTTP API와 WebSocket을 포함한 다양한 통신 시나리오를 구성할 수 있어 실시간 게임 서버 테스트에도 적합하다.
특히 사용자가 연결을 수립하고, 인증을 수행하고, 메시지를 전송하고, 연결을 종료하는 흐름을 시나리오로 표현하기 편리하다.
Artillery HTTP 테스트 예시
config:
target: "http://localhost:3000"
phases:
- duration: 30
arrivalRate: 5
rampTo: 20
name: "부하 증가"
scenarios:
- name: "상태 확인"
flow:
- get:
url: "/health"
위 설정은 테스트 시작 후 일정 시간 동안 새로운 가상 사용자를 생성하며, 초당 사용자 생성 속도를 점진적으로 증가시킨다.
실행 명령은 다음과 같다.
npx artillery run load-test.yml
여기서 arrivalRate는 초당 새로 시작하는 가상 사용자 수를 의미한다. 이는 초당 HTTP 요청 수인 RPS와 다르다. 한 사용자가 여러 요청을 수행하면 요청 수는 사용자 생성 속도보다 커질 수 있다.
k6와 Artillery 비교
| 비교 항목 | k6 | Artillery |
|---|---|---|
| 테스트 작성 | JavaScript 중심 | YAML 및 JavaScript |
| 부하 제어 | 가상 사용자, 도착률 등 | 도착률 기반 시나리오 등 |
| HTTP API | 지원 | 지원 |
| WebSocket | 지원 | 지원 |
| 검증 방식 | Checks, Thresholds | 시나리오 검증 및 플러그인·프로세서 활용 |
| 적합한 용도 | 세밀한 부하 제어와 지표 기반 검증 | 선언적인 시나리오 작성과 프로토콜 중심 테스트 |
두 도구 중 하나가 항상 우월한 것은 아니다. 테스트 대상 프로토콜, 부하 생성 방식, 팀의 코드 작성 방식, CI 환경, 결과 수집 시스템을 기준으로 선택하는 것이 합리적이다.
동시 접속자(Concurrent Users)
동시 접속자는 특정 시점에 서비스에 접속해 있거나 작업을 수행하는 사용자 수를 의미한다. 게임 서버에서는 이 개념을 구체적으로 정의해야 한다.
예를 들어 다음은 서로 다른 지표다.
- 로그인 세션이 유효한 사용자 수
- 서버와 WebSocket 연결을 유지하는 사용자 수
- 실제로 게임 플레이 메시지를 주고받는 사용자 수
- 특정 API를 동시에 호출하는 요청 수
온라인 사용자 수가 많더라도 대부분의 사용자가 유휴 상태라면 서버 부하는 낮을 수 있다. 반대로 접속자 수가 적어도 모든 사용자가 빈번하게 상태를 갱신하거나 무거운 연산을 요청하면 부하는 커질 수 있다.
동시 접속자와 요청량의 관계
HTTP API 중심 서비스에서는 다음과 같은 근사식을 활용할 수 있다.
[
\text{동시 처리 요청 수}
\approx
\text{RPS} \times \text{평균 응답 시간(초)}
]
이는 Little’s Law를 이용한 근사다. 정상 상태에서 요청이 시스템에 머무르는 평균 시간과 요청 유입률을 바탕으로 평균적인 처리 중 요청 수를 추정한다.
예를 들어 초당 요청이 일정하게 유입되고 평균 응답 시간이 증가하면, 처리 중인 요청 수가 늘어날 수 있다. 서버가 요청을 충분히 빨리 처리하지 못하면 대기열이 커지고 지연 시간이 더욱 증가할 수 있다.
단, 이 식의 결과는 처리 중인 요청의 평균 개수이지, 실제 접속 사용자 수가 아니다. 연결을 계속 유지하는 WebSocket 사용자는 별도로 측정해야 한다.
RPS와 처리량(Throughput)
RPS(Requests Per Second)는 초당 처리된 요청 수를 나타낸다. 일반적으로 서버가 HTTP 요청을 얼마나 빠르게 처리하는지 파악할 때 사용한다.
[
\text{RPS} =
\frac{\text{측정 구간의 처리 요청 수}}
{\text{측정 구간의 시간(초)}}
]
처리량(Throughput)은 시스템이 일정 시간 동안 처리한 작업의 양을 의미한다. 요청 수뿐 아니라 메시지 수, 트랜잭션 수, 이벤트 처리 건수, 전송 데이터량 등으로 표현할 수 있다.
따라서 RPS는 처리량을 표현하는 대표적인 지표지만 모든 처리량을 대변하지는 않는다.
게임 플랫폼에서는 다음과 같이 구분할 수 있다.
- HTTP API: 초당 요청 수
- WebSocket 서버: 초당 수신·송신 메시지 수
- 매칭 서버: 초당 완료된 매칭 수
- 정산 시스템: 초당 처리한 경기 결과 수
- 보상 시스템: 초당 지급 완료한 보상 수
최대 RPS가 항상 좋은 지표는 아니다
서버가 높은 RPS를 기록하더라도 응답 실패가 많거나 지연 시간이 지나치게 길다면 정상적인 성능이라고 보기 어렵다.
예를 들어 처리량이 증가하는 동안 오류율이 급격하게 상승하거나 p99 지연 시간이 크게 늘어난다면 시스템이 한계에 가까워졌을 가능성이 있다.
따라서 RPS는 반드시 오류율과 지연 시간을 함께 분석해야 한다. 성능 목표는 단순한 최대 요청 수가 아니라 정해진 품질 기준을 만족하는 처리량이어야 한다.
p95와 p99 지연 시간
평균 응답 시간은 전체적인 경향을 파악하는 데 유용하지만 일부 요청이 매우 느려지는 문제를 숨길 수 있다.
이를 보완하기 위해 백분위 지연 시간(percentile latency)을 사용한다.
- p50: 전체 요청의 절반이 이 시간 이내에 완료된다.
- p95: 전체 요청의 대부분인 95%가 이 시간 이내에 완료된다.
- p99: 전체 요청의 99%가 이 시간 이내에 완료된다.
예를 들어 p95가 200ms라면 측정된 요청의 약 95%가 200ms 이내에 완료됐다는 의미다. 나머지 약 5%는 그보다 오래 걸렸다.
p99는 특히 느린 요청의 영향을 파악하는 데 유용하다. 데이터베이스 잠금 대기, GC, 외부 서비스 지연, 특정 쿼리의 실행 시간 증가와 같은 문제가 평균값보다 p99에서 더 뚜렷하게 나타날 수 있다.
지연 시간 해석 시 주의점
p95와 p99는 요청이 얼마나 느려지는지 보여 주지만, 원인 자체를 알려 주지는 않는다. 서버 로그, 트레이싱, 데이터베이스 지표와 함께 분석해야 한다.
또한 요청이 실패하거나 타임아웃으로 종료된 경우를 지연 시간 지표만으로 평가하면 안 된다. 오류율과 타임아웃 비율을 별도로 추적해야 한다.
성능 목표는 서비스 특성에 따라 설정해야 한다. 빠른 매칭 응답과 비동기 통계 집계는 허용할 수 있는 지연 시간이 다르다.
오류율과 리소스 사용량
성능 테스트에서 오류율은 부하 증가에 따라 서비스가 정상적으로 처리하지 못한 요청의 비율을 나타낸다.
오류에는 다음과 같은 유형이 포함될 수 있다.
- HTTP 오류 응답
- 연결 실패
- 요청 타임아웃
- WebSocket 연결 종료 및 메시지 전송 실패
- 비즈니스 규칙 위반으로 인한 처리 거부
- 내부 예외로 인한 작업 실패
단, 모든 오류 응답을 서버 장애로 분류해서는 안 된다. 잘못된 입력에 대한 정상적인 검증 실패와 서버 내부 오류는 구분해야 한다.
주요 리소스 지표
CPU 사용률
CPU 사용률이 지속적으로 높다면 직렬화·역직렬화, 암호화, 게임 로직, 데이터 변환, 비효율적인 반복 연산 등이 원인일 수 있다. Node.js에서는 이벤트 루프 지연도 함께 확인해야 한다.
메모리 사용량
메모리가 지속적으로 증가한다면 메모리 누수, 캐시의 무제한 증가, 요청 객체의 불필요한 보관 등을 의심할 수 있다. 가비지 컬렉션 시간과 힙 사용량도 함께 확인한다.
네트워크 사용량
WebSocket 메시지가 빈번하게 발생하는 서비스에서는 네트워크 송수신량과 연결 수가 중요한 지표다. 메시지 크기, 브로드캐스트 방식, 불필요한 상태 동기화가 네트워크 병목을 유발할 수 있다.
데이터베이스 커넥션
커넥션 풀이 고갈되면 요청이 데이터베이스 연결을 얻기 위해 대기한다. 쿼리 실행 시간뿐 아니라 커넥션 획득 대기 시간과 사용 중인 커넥션 수를 확인해야 한다.
이벤트 루프 지연
Node.js는 이벤트 루프가 장시간 차단되면 다른 요청과 콜백의 처리가 지연될 수 있다. CPU를 많이 사용하는 동기 연산이나 대량의 JSON 처리, 비효율적인 반복문이 원인이 될 수 있다.
작업 큐 적체
비동기 작업 큐에 작업이 계속 쌓인다면 작업 생성 속도가 처리 속도를 초과하고 있을 가능성이 있다. 큐 길이, 가장 오래 대기한 작업의 시간, 실패 및 재시도 횟수를 함께 관찰해야 한다.
병목 지점 분석
병목은 시스템의 특정 구성 요소가 전체 처리 성능을 제한하는 현상이다. 애플리케이션 서버의 CPU만 확인해서는 병목을 정확하게 찾을 수 없다.
요청이 유입되는 순간부터 응답이 반환될 때까지의 처리 경로를 구분해 분석해야 한다.
애플리케이션 병목
CPU 사용률이 높고 이벤트 루프 지연이 증가한다면 애플리케이션 내부 연산이 원인일 수 있다.
대응 방법은 다음과 같다.
- 불필요한 연산과 객체 생성을 줄인다.
- 반복적으로 수행되는 계산을 캐싱한다.
- CPU 집약적인 작업을 Worker Threads나 별도 작업 프로세스로 분리한다.
- 요청당 처리 비용과 직렬화 비용을 측정한다.
데이터베이스 병목
요청 지연 시간이 증가하면서 데이터베이스 쿼리 실행 시간이나 커넥션 대기가 함께 증가한다면 데이터베이스가 병목일 가능성이 있다.
대응 방법은 다음과 같다.
- 실행 계획을 확인하고 적절한 인덱스를 설계한다.
- N+1 쿼리와 불필요한 조회를 제거한다.
- 트랜잭션 범위를 줄이고 잠금 대기를 분석한다.
- 커넥션 풀 크기와 데이터베이스의 실제 처리 능력을 함께 조정한다.
- 읽기 부하가 문제라면 캐시나 읽기 복제본을 검토한다.
커넥션 풀 크기를 무조건 늘리는 것은 해결책이 아니다. 데이터베이스가 이미 포화 상태라면 더 많은 동시 쿼리가 경합을 악화시킬 수 있다.
네트워크와 WebSocket 병목
동시 연결 수가 증가할 때 메모리 사용량이나 네트워크 송수신량이 급증한다면 연결 관리와 메시지 전송 방식을 살펴봐야 한다.
대응 방법은 다음과 같다.
- 불필요한 메시지 전송을 줄인다.
- 메시지 크기와 전송 빈도를 최적화한다.
- 모든 사용자에게 브로드캐스트하는 대신 필요한 대상에게만 전달한다.
- 연결별 메모리 사용량을 측정한다.
- 서버 간 메시지 전달 구조와 로드 밸런싱 방식을 검토한다.
외부 서비스 병목
외부 API 호출이 요청 처리 경로에 포함돼 있다면 외부 서비스의 지연이 전체 응답 시간에 영향을 줄 수 있다.
필요하다면 타임아웃, 제한된 재시도, 서킷 브레이커, 비동기 작업 큐 등을 적용한다. 다만 재시도를 무제한으로 수행하면 장애가 발생한 서비스에 부하를 더 집중시키는 문제가 생긴다.
병목 분석의 기본 원칙
성능 문제가 발견되면 먼저 지표를 통해 병목이 발생한 구간을 좁히고, 해당 구간의 원인을 확인한 다음 변경 사항을 적용한다. 이후 동일한 조건으로 다시 테스트해 실제 개선 여부를 검증한다.
근거 없이 캐시를 추가하거나 서버 인스턴스를 늘리는 것은 원인 분석을 대체하지 못한다.
실전 성능 테스트 시나리오
게임 플랫폼에서는 실제 사용자 행동과 업무 흐름을 반영한 테스트가 필요하다.
로그인 및 인증
여러 사용자가 동시에 로그인하고 토큰을 발급받는 상황을 재현한다. 인증 처리 시간, 데이터베이스 조회, 비밀번호 검증 비용, 인증 서비스의 리소스 사용량을 확인한다.
매칭 요청
사용자가 동시에 매칭 대기열에 진입하고 매칭이 완료되는 상황을 검증한다. 요청 처리량뿐 아니라 대기열 길이, 매칭 완료 시간, 중복 매칭 여부도 확인한다.
경기 결과 저장
다수의 경기가 동시에 종료되고 결과가 저장되는 상황을 테스트한다. 데이터베이스 쓰기 처리량, 잠금 대기, 트랜잭션 실패율, 재시도에 따른 부하를 분석한다.
포인트 정산과 보상 지급
여러 작업이 동시에 정산을 수행하는 상황을 재현한다. 성능 지표와 함께 중복 지급, 잔액 불일치, 트랜잭션 실패 후 복구 여부도 검증해야 한다.
WebSocket 동시 접속
다수의 클라이언트가 연결을 유지하고 주기적으로 메시지를 주고받는 상황을 구성한다. 연결 수, 메시지 처리량, 연결 종료율, 메시지 지연 시간, 서버 메모리와 네트워크 사용량을 확인한다.
성능 테스트는 정합성 테스트를 대체하지 않는다. 높은 처리량을 기록하더라도 보상이 중복 지급되거나 경기 결과가 유실된다면 정상적인 서비스라고 볼 수 없다.
성능 테스트 결과 분석
테스트 결과는 단일 숫자가 아니라 여러 지표의 관계로 해석해야 한다.
| 관측 결과 | 의심할 수 있는 원인 | 추가로 확인할 지표 |
|---|---|---|
| RPS 정체, CPU 사용률 높음 | 연산 병목 | CPU 프로파일, 이벤트 루프 지연 |
| RPS 정체, CPU 사용률 낮음 | I/O 대기, 커넥션 대기 | DB 대기 시간, 네트워크, 외부 API 지연 |
| p99 급증, 평균은 안정적 | 일부 요청의 긴 대기 | 느린 쿼리, GC, 잠금, 트레이싱 |
| 오류율 급증 | 과부하, 타임아웃, 의존성 장애 | 오류 유형, 연결 실패, 타임아웃 |
| 메모리 지속 증가 | 누수, 캐시 증가, 큐 적체 | 힙 스냅샷, GC, 큐 길이 |
| 연결 수 증가와 함께 메시지 지연 증가 | 네트워크 또는 메시지 처리 병목 | 메시지 크기, 전송량, 이벤트 루프 지연 |
위 항목은 원인을 확정하는 규칙이 아니라 조사 방향을 정하기 위한 가설이다. 하나의 지표만으로 병목을 단정하지 말고 애플리케이션, 데이터베이스, 운영체제 및 네트워크 지표를 함께 비교해야 한다.
성능 테스트 자동화와 CI 연동
성능 테스트를 개발 과정에 통합하면 코드 변경으로 발생하는 성능 저하를 조기에 발견할 수 있다.
예를 들어 주요 API에 대해 다음 기준을 자동으로 검사할 수 있다.
- 오류율이 허용 범위 이내인지 확인한다.
- p95 지연 시간이 목표를 충족하는지 확인한다.
- 핵심 업무의 처리량이 기준보다 크게 떨어지지 않았는지 비교한다.
- 메모리 사용량이나 큐 적체가 비정상적으로 증가하지 않았는지 확인한다.
k6의 Thresholds와 같은 기능을 이용하면 기준을 만족하지 못한 테스트를 실패 처리할 수 있다. CI에서는 짧은 스모크 성능 테스트를 자주 실행하고, 대규모 부하 테스트는 별도의 환경이나 정해진 일정에 수행하는 방식이 유용하다.
테스트 환경과 운영 환경의 하드웨어, 네트워크, 데이터베이스 구성, 데이터 크기가 다르면 결과를 그대로 비교하기 어렵다. 성능 회귀를 정확히 추적하려면 테스트 조건을 일관되게 유지하고 결과를 지속적으로 저장해야 한다.
또한 부하 생성기 자체가 CPU나 네트워크 한계에 도달하면 측정값이 서버의 실제 처리 능력을 반영하지 못할 수 있다. 테스트 도구가 충분한 부하를 생성할 수 있는지도 확인해야 한다.