k6와 WebSocket을 활용한 실시간 대전 게임 로비 스트레스 테스트 스크립트 작성 가이드
실시간 대전 게임의 동시 접속 급증에 대비해 Grafana k6와 WebSocket 프로토콜로 로비 입장, 매칭 큐 등록, 하트비트 유지 시나리오를 검증하는 스트레스 테스트 스크립트 작성법을 정리합니다.

핵심 요약
실시간 대전 게임의 로비 서버는 HTTP 요청과 달리 지속적인 TCP 소켓 연결과 양방향 하트비트(Ping/Pong), 비동기 매칭 알림을 처리할 수 있습니다. Grafana k6의 WebSocket 모듈과 Ramping VUs 시나리오로 부하를 만들 수 있지만, 한 테스트 실행에서 만들 수 있는 VU 수와 계측 정확도는 테스트 생성기 하드웨어·스크립트·네트워크에 따라 달라집니다. 목표 부하는 먼저 생성기 자체에서 검증하고 필요하면 생성기를 분산합니다.
실시간 대전 게임 로비에서 WebSocket 부하 테스트가 필수적인 이유는?
모바일 및 PC 실시간 대전 게임(FPS, MOBA, 격투 등)의 로비는 구현에 따라 HTTP 요청과 지속 연결을 함께 사용할 수 있습니다. WebSocket 세션을 택한 경우 매칭 대기 중 방 목록 갱신, 친구 상태, 애플리케이션 하트비트, 매칭 알림 등을 교환하도록 설계할 수 있습니다.
단순 REST API 호출 기반의 부하 테스트 도구로는 연결이 유지되는 상태(Stateful)의 소켓 자원 고갈과 비동기 이벤트 지연을 재현할 수 없습니다.
| 비교 항목 | HTTP 기반 REST API 테스트 | WebSocket 게임 로비 스트레스 테스트 |
|---|---|---|
| 연결 상태(State) | 요청·응답 단위의 애플리케이션 통신이며 HTTP 연결 재사용 여부는 별도 설정 | 연결을 유지하며 세션 상태와 소켓 자원을 점유 |
| 통신 방식 | 단방향 Request-Response | 전이중(Full-Duplex) 비동기 양방향 통신 |
| 서버 자원 병목 | 핸들러, DB, 연결 풀 등 구현에 따라 달라짐 | 열린 소켓, 버퍼, 이벤트 처리 등 구현에 따라 달라짐 |
| 주요 계측 지표 | RPS(초당 요청 수), HTTP 응답 코드 | 동시 접속 유지 수(CCU), 연결 수립 시간, 메시지 RTT, 큐 지연 |

게임 로비 부하 테스트 시나리오 아키텍처는 어떻게 설계할까?
정밀한 스트레스 테스트를 작성하려면 실제 게임 클라이언트의 접속 라이프사이클을 정확히 모사해야 합니다. 인증 토큰 획득부터 매칭 완료 후 방 입장까지의 흐름을 정의합니다.
sequenceDiagram
autonumber
actor VU as k6 가상 유저 (VU)
participant Auth as 인증 서버 (HTTP)
participant Gateway as 로비 게이트웨이 (WS)
participant Match as 매치메이커
VU->>Auth: 1. POST /api/v1/auth/login (JWT 발급)
Auth-->>VU: Access Token 반환
VU->>Gateway: 2. WebSocket Upgrade Handshake
Gateway-->>VU: 101 Switching Protocols (세션 연결)
loop 애플리케이션 하트비트 예시 (매 5초)
VU->>Gateway: JSON PING 메시지
Gateway-->>VU: JSON PONG 메시지
end
VU->>Gateway: 3. SEND {"action": "join_match_queue", "mode": "ranked_5v5"}
Gateway->>Match: 큐 등록 이벤트 전달
Match-->>Gateway: 매칭 성사 (MatchFound)
Gateway-->>VU: 4. PUSH {"event": "MATCH_SUCCESS", "roomId": "room-9012"}
VU->>Gateway: 5. 세션 정리 및 소켓 Close (1000 Normal Closure)
k6 WebSocket 스트레스 테스트 스크립트 단계별 구현
k6의 JavaScript API를 사용해 단계별로 부하 테스트 스크립트를 작성합니다. 생성기 자원 사용량은 스크립트와 실행 환경에서 직접 측정합니다.
1단계: 테스트 옵션 및 부하 단계(Stages) 정의
단계적으로 가상 사용자 수를 늘리는 Ramping VUs 패턴과 임계치 예시를 설정합니다. 아래 수치는 설명용 목표값이며 해당 서버의 적정 용량이나 성능 결과가 아닙니다.
import ws from 'k6/ws';
import { check, sleep } from 'k6';
import http from 'k6/http';
import { Trend, Counter, Rate } from 'k6/metrics';
// 커스텀 성능 메트릭 정의
const wsConnectDuration = new Trend('ws_connect_duration_ms');
const matchWaitDuration = new Trend('match_wait_duration_ms');
const heartbeatRtt = new Trend('app_heartbeat_rtt_ms');
const wsErrorCounter = new Counter('ws_error_count');
const matchSuccessRate = new Rate('match_success_rate');
export const options = {
scenarios: {
lobby_stress_test: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 500 }, // 30초 동안 500명까지 램프업
{ duration: '2m', target: 2000 }, // 2분 동안 2000명 동시 접속 유지
{ duration: '1m', target: 5000 }, // 피크 부하 5000명
{ duration: '30s', target: 0 }, // 램프다운
],
gracefulRampDown: '30s',
},
},
thresholds: {
'ws_connect_duration_ms': ['p(95)<300'], // 95%의 연결은 300ms 이내에 수립
'match_wait_duration_ms': ['p(90)<5000'], // 90%의 유저는 5초 이내에 매칭 응답 수신
'match_success_rate': ['rate>0.98'], // 매칭 성공률 98% 이상
'ws_error_count': ['count<50'], // 에러 발생 50회 미만
},
};
2단계: 인증 토큰 발급 및 WebSocket 세션 핸들러 작성
각 가상 유저는 HTTP 인증을 거친 뒤 발급받은 토큰을 서버가 정한 인증 방식에 맞춰 WebSocket 연결에 전달합니다. URL 쿼리 문자열은 로그나 모니터링에 남을 수 있으므로 지원되는 경우 인증 헤더를 고려합니다.
const BASE_HTTP_URL = 'https://lobby-stage.game-server.internal';
const BASE_WS_URL = 'wss://lobby-stage.game-server.internal/ws/lobby';
export default function () {
// 1. 임의 유저 인증 및 JWT 획득
const userId = `tester_${__VU}_${__ITER}`;
const loginPayload = JSON.stringify({
username: userId,
clientVersion: '1.4.2',
});
const loginRes = http.post(`${BASE_HTTP_URL}/api/auth/token`, loginPayload, {
headers: { 'Content-Type': 'application/json' },
});
const isLoginOk = check(loginRes, {
'로그인 성공 (200 OK)': (r) => r.status === 200,
});
if (!isLoginOk) {
wsErrorCounter.add(1);
matchSuccessRate.add(0);
return;
}
const token = loginRes.json('accessToken');
const wsUrl = BASE_WS_URL;
const connectStart = Date.now();
let matchQueueStartTime = 0;
let matchResultRecorded = false;
// 2. WebSocket 연결 수립 및 이벤트 리스너 바인딩
const response = ws.connect(wsUrl, {
headers: { Authorization: `Bearer ${token}` },
}, function (socket) {
socket.on('open', () => {
const connectTime = Date.now() - connectStart;
wsConnectDuration.add(connectTime);
// 하트비트 주기 전송 (3초 주기)
socket.setInterval(() => {
socket.send(JSON.stringify({ type: 'PING', timestamp: Date.now() }));
}, 3000);
// 로비 입장 직후 1~3초 대기 후 매칭 큐 진입 시뮬레이션
socket.setTimeout(() => {
matchQueueStartTime = Date.now();
socket.send(JSON.stringify({
type: 'REQ_MATCH_QUEUE',
mode: 'RANKED_SOLO',
mmr: 1500,
}));
}, Math.floor(Math.random() * 2000) + 1000);
});
socket.on('message', (data) => {
try {
const msg = JSON.parse(data);
if (msg.type === 'PONG') {
// 서버가 PING의 timestamp를 그대로 돌려준다는 프로토콜 계약을 전제로 측정합니다.
if (Number.isFinite(msg.timestamp)) {
heartbeatRtt.add(Date.now() - msg.timestamp);
}
return;
}
if (msg.type === 'RES_MATCH_FOUND' && matchQueueStartTime > 0 && !matchResultRecorded) {
const validRoom = check(msg, {
'유효한 매칭 방 ID 수신': (m) => typeof m.roomId === 'string' && m.roomId.length > 0,
});
if (!validRoom) return;
const waitTime = Date.now() - matchQueueStartTime;
matchWaitDuration.add(waitTime);
if (!matchResultRecorded) {
matchSuccessRate.add(1);
matchResultRecorded = true;
}
// 매칭 완료 후 1초 대기 후 정상 종료
socket.setTimeout(() => {
socket.close(1000, 'Match completed');
}, 1000);
}
} catch (err) {
wsErrorCounter.add(1);
}
});
socket.on('error', (e) => {
wsErrorCounter.add(1);
});
socket.on('close', () => {
// 연결 종료 처리
});
// 단일 세션 최대 유지 시간(15초) 제한
socket.setTimeout(() => {
socket.close(1000, 'Max session timeout');
}, 15000);
});
const handshakeOk = check(response, {
'WebSocket 프로토콜 스위칭 성공 (101)': (r) => r && r.status === 101,
});
if (!handshakeOk) wsErrorCounter.add(1);
// 로그인부터 유효한 매칭까지의 전체 시도마다 정확히 한 번 기록합니다.
// 연결 실패, 조기 종료, 매칭 타임아웃도 성공률 분모에 포함합니다.
if (!matchResultRecorded) matchSuccessRate.add(0);
sleep(1);
}

부하 테스트 실행 및 성능 병목 분석 기법
목표 연결 수가 증가하면 클라이언트 생성기와 서버 양쪽에서 OS·네트워크 병목이 생길 수 있으므로 양측 지표를 함께 살펴봅니다.
1. 테스트 실행 호스트의 커널 파라미터 튜닝
부하 생성 호스트가 다수의 소켓을 열면 OS 파일 디스크립터 한도에 도달할 수 있습니다. 다음은 Linux 셸에서 soft limit을 설정하는 예시이며, 허용값은 사용자·서비스의 hard limit에 따라 달라집니다.
# Linux 환경 파일 디스크립터 상향
ulimit -n 65535
# k6 부하 테스트 실행 명령
k6 run --vus 2000 --duration 5m lobby_stress_test.js
2. 애플리케이션 메시지 왕복 시간(RTT) 및 처리량 계산
애플리케이션 하트비트 메시지의 왕복 시간()은 클라이언트에서 PING을 송신한 시각()과 서버로부터 PONG을 수신한 시각()의 차이로 계산할 수 있습니다. 이 샘플은 WebSocket control-frame ping이 아니라 JSON 메시지를 사용하므로 측정값은 애플리케이션 왕복 시간입니다. 서버가 PING의 timestamp를 PONG에 그대로 넣어 돌려주는 프로토콜을 전제로 하며, 서버가 이 계약을 제공하지 않으면 별도 request ID 매핑을 구현해야 합니다.
동일 시간 동안 로비 서버가 처리한 초당 메시지 처리량()은 전체 수신 메시지 수()를 측정 시간()으로 나눈 값으로 도출합니다.
3. 실시간 로비 서버의 주요 병목 지표 점검표
| 점검 대상 | 주요 증상 | 함께 확인할 후보 |
|---|---|---|
| TCP Handshake 지연 | ws_connect_duration_ms p99 급증 | 클라이언트·네트워크·TLS 종단·서버의 연결 지표와 한도를 함께 확인 |
| 매칭 큐 지연 누적 | match_wait_duration_ms 증가 | 큐 대기 시간, 소비 처리량, 락 대기와 매치메이커 처리 지표를 확인 |
| 소켓 비정상 끊김 | ECONNRESET, 1006 Abnormal Closure | 클라이언트·네트워크·로드 밸런서·서버 로그를 함께 확인; 코드만으로 원인을 특정할 수 없음 |
| 메모리 지속 증가 | 세션 종료 후 프로세스 메모리가 증가 | 반복 측정과 힙·세션 객체 프로파일링으로 회수 지연과 참조 유지 여부를 구분 |
자주 묻는 질문 (FAQ)
Q1. k6의 k6/ws와 k6/websockets는 어떻게 다른가요?
이 문서의 스크립트는 콜백 기반인 기존 k6/ws API 예시입니다. 신규 스크립트는 현재 k6 문서가 안내하는 k6/websockets API를 우선 검토하세요. 두 API의 메모리 사용량이나 처리량 우열은 스크립트와 실행 환경을 정해 직접 측정해야 합니다.
Q2. 테스트 실행 중 connection reset by peer 에러가 발생하면 무엇을 확인해야 하나요?
이 오류만으로 원인을 단정할 수는 없습니다. 클라이언트와 서버 로그, 로드 밸런서 상태, 연결 한도와 커널 설정을 함께 확인하고, 커널 파라미터는 실제 병목과 운영체제·서비스 구성에 맞춰 조정합니다.
Q3. 실제 테스트 시 PING/PONG 하트비트를 반드시 구현해야 하나요?
테스트 대상 서버와 네트워크 경로가 유휴 연결을 종료하는 정책을 사용한다면, 실제 클라이언트와 같은 하트비트 동작을 테스트에 반영해야 합니다. 구체적인 유휴 제한 시간은 프록시·로드 밸런서·서버 설정마다 다르므로 해당 환경의 설정을 확인하세요. WebSocket control-frame ping과 애플리케이션 JSON 하트비트는 서로 다른 메시지입니다.
정리 및 다음 단계
실시간 대전 게임 로비의 스트레스 테스트는 요청량뿐 아니라 실제 클라이언트의 행동과 소켓 수명을 재현하는 것이 핵심입니다. k6 시나리오로 부하를 단계적으로 높이고 서버·생성기 지표를 함께 기록하면, 설정한 조건에서 드러나는 용량 한계와 동시성 문제를 조사할 수 있습니다.
다음 단계로 CI/CD 파이프라인에 k6 테스트를 통합하고 Grafana 대시보드와 연동하여 실시간 메트릭 모니터링 체계를 구축해 보세요.



