-
Notifications
You must be signed in to change notification settings - Fork 2
성능 측정 세팅
USJUN edited this page Feb 5, 2026
·
1 revision
- 배틀 서비스의 HTTP + [Socket.IO](http://Socket.IO) 부하 시나리오를 만들고, 실시간 플로우(채팅 → 배틀 시작 → 페이즈 별 행동 → 종료)까지 재현한다.
- 부하 테스트를 진행하며 메트릭을 수집해 다음 통계 결과를 수집한다.
- RPS : Requests Per Second. 초당 처리한 요청 수
- p95: 95th percentile 지표. 모든 요청 중 느린 쪽 상위 5%를 제외하고, 나머지 95% 요청 처리 시간
- p99 : 99% 요청 처리 시간
- Error Rate : 라우트별 에러 비율
- HTTP
- 배틀 생성 (Author_ID 주입)
- 게스트 생성
- 배틀 join 정보 조회
- open/closed 목록 조회
- 결과 조회
해당 시나리오로 HTTP 부하 테스트 정상 작동 확인
-
Socket.IO
- 공식적으로는 K6는 socket.io를 지원하지 않음 → 확장 사용 시도
- xk6 extension 설치해서 시도했지만 socket.io-client는 정상적으로 연결되는데 battle:join에서 실패함 : ID 형식이 틀리는 걸로 추정
-
목표 시나리오
- Artillery에서 HTTP로 userId 발급
- [socket.io](http://socket.io) 연결로 시나리오 실행
-
실패 1 : processor 로딩/실행 문제
- 소켓 연결 전 처리 과정을
beforeScenario훅으로 분리하여 처리 - socketIO 엔진에서 훅이 실행되지 않음
- 소켓 연결 전 처리 과정을
-
해결 1 : 테스트 스크립트 내부에 setupVU function을 선언하여 flow 첫 단계로 배정하여 해결
-
실패 2 : handshake auth 전달 실패
- 서버 로그:
auth={}로 수신됨 - Artillery socketio 엔진이 handshake auth 전달 실패
- auth에 userID를 넣어서 소켓 연결하는 로직이 부하 테스트들과 호환되지 않는다고 추정
- 서버 로그:
- 서버 로직 변경 없이 handshake auth.userID 전달 가능
- 배틀 생성 → 게스트 생성 → 소켓 연결 → join → 채팅 → 배틀 시작 → 페이즈 별 행동 재현 성공
- HTTP : K6
- [Socket.io](http://Socket.io) : Node + socket.io-client (스크립트 실행)
- 배틀 생성 (AUTHOR_ID 필요)
- 게스트 생성
- 배틀 join 정보 조회
- open/closed 목록 조회
- 결과 조회
-
스크립트
import http from 'k6/http' import { check, sleep } from 'k6' export const options = { scenarios: { steady: { executor: 'ramping-vus', startVUs: Number(__ENV.START_VUS || 1), stages: [ { duration: __ENV.RAMP_UP || '30s', target: Number(__ENV.VUS || 10) }, { duration: __ENV.HOLD || '2m', target: Number(__ENV.VUS || 10) }, { duration: __ENV.RAMP_DOWN || '30s', target: 0 }, ], gracefulRampDown: '10s', }, }, thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<800'], }, } const BASE_URL = 'http://49.50.138.219:3000' function createBattle() { const body = { authorId: `019c1e16-959d-76ba-b0df-903e47cf2b1b`, title: 'k6 load test', description: 'k6 load test battle', aCode: 'function a(){return 1}', bCode: 'function b(){return 2}', language: 'TS', type: 'PUBLIC', category: 'ETC', playTime: 'FIFTEEN_MIN', topics: ['perf'], } const res = http.post(`${BASE_URL}/battles`, JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, }) const ok = check(res, { 'create battle status 200/201': r => r.status === 200 || r.status === 201, }) if (!ok) { throw new Error(`create battle failed: ${res.status} ${res.body}`) } const data = res.json() return data.battleId } export function setup() { const battleId = createBattle() return { battleId } } function createGuest(battleId) { const res = http.post(`${BASE_URL}/auth/guest/${battleId}`, null) check(res, { 'guest created': r => r.status === 200, }) return res.json() } export default function (data) { const battleId = data.battleId const guest = createGuest(battleId) const joinInfo = http.post(`${BASE_URL}/battles/${battleId}/join`, null) check(joinInfo, { 'join info 200': r => r.status === 200, }) const openBattles = http.get(`${BASE_URL}/battles/open?limit=10&offset=0`) check(openBattles, { 'open battles 200': r => r.status === 200, }) const closedBattles = http.get(`${BASE_URL}/battles/closed?limit=10&offset=0`) check(closedBattles, { 'closed battles 200': r => r.status === 200, }) const result = http.get(`${BASE_URL}/battles/${battleId}/result`) check(result, { 'battle result 200 or 404': r => r.status === 200 || r.status === 404, }) if (!guest?.id) { throw new Error('guest missing id') } sleep(Number(__ENV.SLEEP || 1)) }
- 배틀 생성 (AUTHOR_ID 필요)
- 게스트 생성 (USERS 수)
- 모든 유저 socket.io 연결 후 join
- 30초 채팅
- 리더 1명 battle:start
- phase:updated 기준으로 ATTACK/DEFENSE/TEAM_SWITCH 행동
- ATTACK/DEFENSE 페이즈 1분 경과 시 skip
- battle:closed까지 유지 후 leave/disconnect
-
스크립트
const { io } = require('socket.io-client') const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:3000' const WS_URL = process.env.WS_URL || 'http://127.0.0.1:3000' const AUTHOR_ID = process.env.AUTHOR_ID const USERS = Number(process.env.USERS || 20) const CHAT_INTERVAL_MS = Number(process.env.CHAT_INTERVAL_MS || 500) const CHAT_COUNT = Number(process.env.CHAT_COUNT || 10) const HOLD_MS = Number(process.env.HOLD_MS || 0) const CONNECT_STAGGER_MS = Number(process.env.CONNECT_STAGGER_MS || 0) const DEBUG = process.env.DEBUG_SOCKET_LOAD === 'true' const JOIN_WAIT_MS = Number(process.env.JOIN_WAIT_MS || 0) const START_BATTLE = process.env.START_BATTLE === 'true' const MAIN_FLOW = process.env.MAIN_FLOW === 'true' const PHASE_ACTION_INTERVAL_MS = Number(process.env.PHASE_ACTION_INTERVAL_MS || 1000) const ATTACK_RATE = Number(process.env.ATTACK_RATE || 0.4) const DEFENSE_RATE = Number(process.env.DEFENSE_RATE || 0.4) const VOTE_RATE = Number(process.env.VOTE_RATE || 0.2) const SKIP_AFTER_MS = Number(process.env.SKIP_AFTER_MS || 60000) const TEAM_VOTE_RATE = Number(process.env.TEAM_VOTE_RATE || 0.2) const CHAT_SCOPE_ALL_RATE = Number(process.env.CHAT_SCOPE_ALL_RATE || 0.2) const MIN_VOTE_POOL = Number(process.env.MIN_VOTE_POOL || 1) const START_WAIT_MS = Number(process.env.START_WAIT_MS || 1000) if (!AUTHOR_ID) { console.error('AUTHOR_ID is required') process.exit(1) } async function createBattle() { const body = { authorId: AUTHOR_ID, title: 'socket load test', description: 'socket load test battle', aCode: 'function a(){return 1}', bCode: 'function b(){return 2}', language: 'TS', type: 'PUBLIC', category: 'ETC', playTime: 'FIFTEEN_MIN', topics: ['perf'], } const res = await fetch(`${BASE_URL}/battles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok && res.status !== 201) { const text = await res.text().catch(() => '') throw new Error(`create battle failed: ${res.status} ${text}`) } const data = await res.json() return data.battleId } async function createGuest(battleId) { const res = await fetch(`${BASE_URL}/auth/guest/${battleId}`, { method: 'POST' }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`create guest failed: ${res.status} ${text}`) } return res.json() } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } async function connectAndJoin({ battleId, userId, nickname, index, isLeader }) { return new Promise((resolve, reject) => { const startedAt = Date.now() const team = index % 2 === 0 ? 'A' : 'B' const socket = io(WS_URL, { transports: ['websocket'], auth: { userId }, }) let joined = false const attackIds = [] const defenseIds = [] let currentPhase = null let currentRound = null let skipTimer = null let battleClosed = false socket.on('connect', () => { if (DEBUG) { console.log(`[socket] connect userId=${userId} team=${team}`) } socket.emit('battle:join', { battleId, team, nickname, }) }) socket.on('battle:joined', payload => { if (joined) return joined = true if (DEBUG) { console.log( `[socket] joined userId=${userId} team=${payload?.team} counts=${JSON.stringify(payload?.counts)}`, ) } resolve({ socket, team, startedAt, attackIds, defenseIds, isLeader, currentPhase, battleClosed }) }) socket.on('battle:join:error', err => { socket.disconnect() reject(new Error(`join error: ${JSON.stringify(err)}`)) }) socket.on('connect_error', err => { socket.disconnect() reject(new Error(`connect error: ${err?.message || err}`)) }) socket.on('battle:phase:updated', payload => { currentPhase = payload?.phase socket.data = socket.data || {} socket.data.currentPhase = currentPhase if (payload?.round) { currentRound = payload.round } if (DEBUG) { console.log(`[socket] phase updated userId=${userId} phase=${currentPhase} round=${currentRound ?? 'na'}`) } if (skipTimer) { clearTimeout(skipTimer) skipTimer = null } if (currentPhase === 'ATTACK' || currentPhase === 'DEFENSE') { skipTimer = setTimeout(() => { if (currentPhase === 'ATTACK' || currentPhase === 'DEFENSE') { socket.emit('battle:user:skip', { battleId, skip: true }) } }, SKIP_AFTER_MS) } }) socket.on('battle:attack:created', payload => { if (payload?.discussionId) { attackIds.push(payload.discussionId) } }) socket.on('battle:defense:created', payload => { if (payload?.discussionId) { defenseIds.push(payload.discussionId) } }) socket.on('battle:round:updated', payload => { currentRound = payload?.round ?? currentRound attackIds.length = 0 defenseIds.length = 0 }) socket.on('battle:closed', () => { battleClosed = true socket.data = socket.data || {} socket.data.battleClosed = true if (DEBUG) { console.log(`[socket] battle closed userId=${userId}`) } }) socket.on('disconnect', reason => { if (skipTimer) { clearTimeout(skipTimer) skipTimer = null } if (DEBUG) { console.log(`[socket] disconnect userId=${userId} reason=${reason}`) } }) }) } async function main() { const battleId = await createBattle() console.log(`battleId=${battleId}`) if (DEBUG) { console.log( `[config] USERS=${USERS} CHAT_COUNT=${CHAT_COUNT} CHAT_INTERVAL_MS=${CHAT_INTERVAL_MS} START_BATTLE=${START_BATTLE} MAIN_FLOW=${MAIN_FLOW} HOLD_MS=${HOLD_MS} JOIN_WAIT_MS=${JOIN_WAIT_MS}`, ) } const guests = [] for (let i = 0; i < USERS; i += 1) { const guest = await createGuest(battleId) guests.push({ userId: guest.id, nickname: guest.nickname || `guest-${guest.id}` }) } const joinTasks = guests.map(async (g, i) => { if (CONNECT_STAGGER_MS > 0) { await sleep(CONNECT_STAGGER_MS * i) } return connectAndJoin({ battleId, userId: g.userId, nickname: g.nickname, index: i, isLeader: i === 0, }) }) const joinResults = await Promise.allSettled(joinTasks) const joinFailures = joinResults.filter(r => r.status === 'rejected') const joined = joinResults.filter(r => r.status === 'fulfilled').map(r => r.value) if (JOIN_WAIT_MS > 0) { await sleep(JOIN_WAIT_MS) } let battleStarted = false const chatTasks = joined.map(async (j, index) => { for (let i = 0; i < CHAT_COUNT; i += 1) { j.socket.emit('battle:chat', { battleId, scope: 'TEAM', team: j.team, text: `[${new Date().toISOString()}] hello ${index}-${i} from ${j.socket.id}`, }) await sleep(CHAT_INTERVAL_MS) } if (START_BATTLE && j.isLeader && !battleStarted) { battleStarted = true if (DEBUG) { console.log('[socket] leader start battle (after chat)') } j.socket.emit('battle:start', { battleId }) await sleep(START_WAIT_MS) } if (MAIN_FLOW) { const endAt = HOLD_MS > 0 ? Date.now() + HOLD_MS : null while (!j.socket?.data?.battleClosed && (!endAt || Date.now() < endAt)) { const phase = j.socket?.data?.currentPhase || j.currentPhase const r = Math.random() if (phase === 'OPINION_SHARE') { const scope = Math.random() < CHAT_SCOPE_ALL_RATE ? 'ALL' : 'TEAM' j.socket.emit('battle:chat', { battleId, scope, team: j.team, text: `[${new Date().toISOString()}] opinion ${j.socket.id}`, }) } else if (phase === 'ATTACK') { if (r < ATTACK_RATE) { j.socket.emit('battle:attack', { battleId, team: j.team, content: `attack ${j.socket.id}`, }) } else if (r < ATTACK_RATE + VOTE_RATE) { const list = j.attackIds if (list.length >= MIN_VOTE_POOL) { const discussionId = list[Math.floor(Math.random() * list.length)] j.socket.emit('battle:attack:vote', { battleId, discussionId, team: j.team, }) } } } else if (phase === 'DEFENSE') { if (r < DEFENSE_RATE) { j.socket.emit('battle:defense', { battleId, team: j.team, content: `defense ${j.socket.id}`, }) } else if (r < DEFENSE_RATE + VOTE_RATE) { const list = j.defenseIds if (list.length >= MIN_VOTE_POOL) { const discussionId = list[Math.floor(Math.random() * list.length)] j.socket.emit('battle:defense:vote', { battleId, discussionId, team: j.team, }) } } } else if (phase === 'TEAM_SWITCH') { if (Math.random() < TEAM_VOTE_RATE) { const voteTeam = Math.random() < 0.5 ? 'A' : 'B' j.socket.emit('battle:team:vote', { battleId, team: voteTeam, }) } } await sleep(PHASE_ACTION_INTERVAL_MS) } } else if (HOLD_MS > 0) { await sleep(HOLD_MS) } j.socket.emit('battle:leave', { battleId }) j.socket.disconnect() return { ms: Date.now() - j.startedAt } }) const results = await Promise.allSettled(chatTasks) const failures = results.filter(r => r.status === 'rejected') const successes = results.filter(r => r.status === 'fulfilled').map(r => r.value.ms) const total = results.length const successCount = successes.length const failureCount = failures.length const sum = successes.reduce((a, b) => a + b, 0) const mean = successCount ? sum / successCount : 0 const sorted = successes.slice().sort((a, b) => a - b) const p = q => (sorted.length ? sorted[Math.floor(q * (sorted.length - 1))] : 0) console.log( `done users=${total} joined=${joined.length} join_failures=${joinFailures.length} successes=${successCount} failures=${failureCount}`, ) console.log(`time_ms mean=${mean.toFixed(1)} p50=${p(0.5)} p95=${p(0.95)} p99=${p(0.99)}`) if (failures.length) { failures.slice(0, 5).forEach(f => console.error(f.reason?.message || f.reason)) } } main().catch(err => { console.error(err) process.exit(1) })
-
실행 예시
```bash BASE_URL=http://127.0.0.1:3000 \ WS_URL=http://127.0.0.1:3000 \ AUTHOR_ID=<유효한-UUID> \ USERS=8 \ CHAT_INTERVAL_MS=1000 \ CHAT_COUNT=30 \ HOLD_MS=0 \ START_BATTLE=true \ MAIN_FLOW=true \ PHASE_ACTION_INTERVAL_MS=1000 \ ATTACK_RATE=0.4 DEFENSE_RATE=0.4 VOTE_RATE=0.2 \ TEAM_VOTE_RATE=0.2 CHAT_SCOPE_ALL_RATE=0.2 MIN_VOTE_POOL=2 \ SKIP_AFTER_MS=60000 \ DEBUG_SOCKET_LOAD=true \ node perf/socketio-load.js
- ❓ 왜 코문철인가요?
- 🧠 이 게임은 어떻게 태어났나요?
- 🛠️ 어떤 서비스가 제공되나요?
- ⚙️ 코문철을 지탱하는 기술
- 🏗️ 코문철의 든든한 설계