Skip to content

성능 측정 세팅

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 : 라우트별 에러 비율

시도한 방법과 결과

1. K6로 HTTP/Socket 시나리오

  • HTTP
    1. 배틀 생성 (Author_ID 주입)
    2. 게스트 생성
    3. 배틀 join 정보 조회
    4. open/closed 목록 조회
    5. 결과 조회

해당 시나리오로 HTTP 부하 테스트 정상 작동 확인

  • Socket.IO
    • 공식적으로는 K6는 socket.io를 지원하지 않음 → 확장 사용 시도
    • xk6 extension 설치해서 시도했지만 socket.io-client는 정상적으로 연결되는데 battle:join에서 실패함 : ID 형식이 틀리는 걸로 추정

2. Artillery 시도

  • 목표 시나리오

  • 실패 1 : processor 로딩/실행 문제

    • 소켓 연결 전 처리 과정을 beforeScenario 훅으로 분리하여 처리
    • socketIO 엔진에서 훅이 실행되지 않음
  • 해결 1 : 테스트 스크립트 내부에 setupVU function을 선언하여 flow 첫 단계로 배정하여 해결

  • 실패 2 : handshake auth 전달 실패

    • 서버 로그: auth={}로 수신됨
    • Artillery socketio 엔진이 handshake auth 전달 실패
    • auth에 userID를 넣어서 소켓 연결하는 로직이 부하 테스트들과 호환되지 않는다고 추정

3. Node + socket.io-cilent로 전환

  • 서버 로직 변경 없이 handshake auth.userID 전달 가능
  • 배틀 생성 → 게스트 생성 → 소켓 연결 → join → 채팅 → 배틀 시작 → 페이즈 별 행동 재현 성공

최종 테스트 방식

테스트 툴

HTTP 시나리오 흐름

  1. 배틀 생성 (AUTHOR_ID 필요)
  2. 게스트 생성
  3. 배틀 join 정보 조회
  4. open/closed 목록 조회
  5. 결과 조회
  • 스크립트

    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))
    }

socket 시나리오 흐름

  1. 배틀 생성 (AUTHOR_ID 필요)
  2. 게스트 생성 (USERS 수)
  3. 모든 유저 socket.io 연결 후 join
  4. 30초 채팅
  5. 리더 1명 battle:start
  6. phase:updated 기준으로 ATTACK/DEFENSE/TEAM_SWITCH 행동
  7. ATTACK/DEFENSE 페이즈 1분 경과 시 skip
  8. 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

🧩 팀 문화

🎮 코문철 소개

🧾 스프린트 기록

1주차 회의록
2주차 회의록
3주차 회의록
4주차 회의록
5주차 회의록
6주차 회의록
7주차 회의록

🔊 유저 테스트

📖 팀 위키

Clone this wiki locally