본문 바로가기

COTATO.KR 프로젝트

[K6 테스트] websocket 발전을 위한 성능 테스트

프로젝트에서 websocket을 이용한 선착순 문제풀이가 존재한다.  

 

현재 방식은 websocket에서 퀴즈 상태(현재 상태), 명령어(command), 퀴즈 아이디만 소켓을 통해 프론트로 메세지를 보낸다.

이 메세지는 quiz의 현재 상태를 넘겨주는 메세지다

 

해당 메세지를 받은 프론트는 1번 퀴즈를 status(공개) 하고 문제풀이(start)는 허용하지 않은 화면을 반환한다.

 

프론트에 소켓 메세지가 도착하면 프론트에서/quiz/{quizId} http 요청을 통해 해당 퀴즈 정보를 가져오는 두 단계 방식으로 진행된다.

 

기존에는 문제의 정보를 전체 다 넘겨주려고 했다.

 

하지만 당시까지는 개발한 소켓은 불안해 최대한 안정적으로 메세지를 보내기 위해 quizId만 보내기로 했다.

하지만 프로젝트 V2에 들어서면서 소켓 안정성이 향상돼 많은 데이터도 안정적으로 보낼 수 있게 되었다.

따라서 기존에 계획했던 방식과 현재 방식 중 어떤 방식이 더 빠를지 테스트 하기 위해 K6 테스트를 진행하였다.

 

K6 테스트

소켓에 대한 테스트는 크게 2가지가 있다

 

Jmeter, K6

K6를 사용한 이유는 XML이 아닌 JS를 통해 테스트가 가능하다는 점이다.

백엔드다보니.. XML보다는 그래도 JS가 좀 더 편했다. 물론 어려운 코드는 아니지만 GPT가 잘 짜준다..ㅎ

 

성능을 지표로만 확인하면 되기 때문에 GUI가 딱히 중요하지 않아서 가볍게 테스트 할 수 있는 K6를 선택했다.

 

K6 설치

다음 설치는 Window용 설치다.

 

window power shell을 통해 설치했다.

 

- choco install k6

choco가 설치되어 있지 않다면 먼저 설치하고 설치하자

 

원하는 디렉토리로 이동 후New-Item socket-test.js 로 테스트 파일을 만들어준다

 

code socket-test.js를 통해 에디터를 선택하고 파일을 연다.(저는 VS-CODE 사용)

 

실행은

k6 run socket-test.js를 하면 Spring Boot 처럼 돌아가면서 실행될 것이다.

 

 

테스트 전 백엔드 처리

먼저 기존 프로젝트에서는 소켓에 접속하기 위해서는 소켓 토큰을 받아 memberId, role를 찾아 memberId를 기준으로 소켓 접속자 Session을 관리했다.

 

private static final ConcurrentHashMap<String, WebSocketSession> CLIENTS = new ConcurrentHashMap<>();

 

하지만 테스트는 서로 다른memberId를 200개 만들 수 없으므로

 

socket관련해서는 JWT 인가, 인증을 임시로 모두 제외 시켰다.

또한 CLIENTS의 키 값도 memberId에서 UUID 값으로 변경해 매번 새로운 세션을 만들도록 처리했다.

+ MEMBER_ROLE이나 들어갈 퀴즈 리스트도 고정시켰다.

 

 

 

웹소켓으로 quizId만 넘기고 Http로 문제 정보를 넘기는 방식

전체 코드

import ws from 'k6/ws';
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import { check } from 'k6'; // check를 올바르게 임포트합니다.

export const options = {
    stages: [
        { duration: '30s', target: 200 }, // Load stage: ramp up to 10 VUs
        { duration: '2m', target: 0 }, // Recovery stage: ramp down to 0 VUs
    ],
};

const durationTrend = new Trend('duration');

export default function () {
    const url = 'ws://localhost:8080/websocket/csquiz';
    const params = { tags: { my_tag: 'hello' } };

    const res = ws.connect(url, params, function (socket) {
        socket.on('open', function open() {
            console.log('WebSocket connection opened.');
        });

        socket.on('message', function (message) {
            console.log('Received message:', message);

            // Assume message is a JSON string with a quizId
            const data = JSON.parse(message);
            const quizId = data.quizId;
            const sendTime = data.sendTime;
            if (quizId) {
                const apiUrl = `http://localhost:8080/v1/api/quiz/${quizId}`;
                console.log(apiUrl);

                // Ensure http is defined
                if (http) {
                    const response = http.get(apiUrl);

                    const endTime = new Date().getTime();
                    const duration = endTime - new Date(sendTime).getTime();

                    durationTrend.add(duration); // duration 값을 Trend에 추가

                    // Check the response status
                    check(response, {
                        'status is 200': (r) => r.status === 200,
                    });

                    console.log(`Requested ${apiUrl}, duration: ${duration}`);
                } else {
                    console.log('http module is not defined.');
                }
            } else {
                console.log('No quizId found in the message.');
            }
        });

        socket.on('error', function (e) {
            if (e.error() !== 'websocket: close sent') {
                console.log('An unexpected error occurred: ', e.error());
            }
        });

        socket.on('close', function () {
            console.log('WebSocket connection closed.');
        });
    });

    check(res, { 'status is 101': (r) => r && r.status === 101 });
}
  • 요약
    1. 200명의 사용자를 websocket에 접속 시킨다
    2. 메세지를 받으면 받은 quizId를 기반으로 http 요청을 보낸다
    3. 백엔드에서 소켓 메세지를 보낼 때 현재 시간을 같이 보내주고 프론트에서는 http api를 보내고 response를 받을 때 시간을 계산한다.
    4. 해당 값을 Trend 에 저장해 200개의 데이터 분포를 알 수 있다.

 

또한 프론트에 넘겨주는 Response에 sendTime을 기록해 프론트에서 언제 메세지를 넘기기 시작했는지 알 수 있게 했다.

public class QuizStatusResponse {

    private QuizStatus status;
    private QuizStatus start;
    private Long quizId;
    private String command;
    private String sendTime;
}

 

 

테스트 결과

 

먼저 duration이 위 코드에서 직접 넣은 분석이다. (백엔드에서 메세지를 보냄 -> 프론트에서 받아서 API 요청을 보냄 -> API 응답을 받음) 까지 시간이다

 

단위는 ms이고 400개 소켓이 평균 187ms, 최소 26ms, 최대 405ms 걸렸다.

 

웹소켓으로 문제 정보를 까지 같이 넘기는 방식

전체 코드

import ws from 'k6/ws';
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import { check } from 'k6'; // check를 올바르게 임포트합니다.

export const options = {
    stages: [
        { duration: '10s', target: 10 }, // Load stage: ramp up to 10 VUs
        { duration: '2m', target: 0 }, // Recovery stage: ramp down to 0 VUs
    ],
};

const durationTrend = new Trend('duration');

export default function () {
    const url = 'ws://localhost:8080/websocket/csquiz';
    const params = { tags: { my_tag: 'hello' } };

    const res = ws.connect(url, params, function (socket) {
        socket.on('open', function open() {
            console.log('WebSocket connection opened.');
        });

        socket.on('message', function (message) {
            console.log('Received message');

            // Assume message is a JSON string with a quizId
            const data = JSON.parse(message);
            const quizId = data.quizId;
            const sendTime = data.sendTime;
            const quizData = data.response;
            console.log("quizData",quizData)
            if (quizId) {
                const endTime = new Date().getTime();
                const duration = endTime - new Date(sendTime).getTime();

                durationTrend.add(duration); // duration 값을 Trend에 추가
                console.log("response duration: ", {duration})
            } else {
                console.log('No quizId found in the message.');
            }
        });

        socket.on('error', function (e) {
            if (e.error() !== 'websocket: close sent') {
                console.log('An unexpected error occurred: ', e.error());
            }
        });

        socket.on('close', function () {
            console.log('WebSocket connection closed.');
        });
    });

    check(res, { 'status is 101': (r) => r && r.status === 101 });
}
  • 요약
    1. 백엔드에서 요청이 올 때 시작 시간을 기록
    2. 백엔드에서 퀴즈에 대한 정보를 만들고 프론트에 웹소켓으로 전송
    3. 프론트에서 response를 받을 때 시간을 계산한다.

 

백엔드에서는 메세지를 보내는 시점이 아닌 service에서 문제 정보를 받기 전에 현재 시간을 sendTime에 넣었다.

현재 시간을 저장하고 findQuiz를 한다.

 

다음은 테스트 결과다

 

400개 데이터 평균 24ms 최소 5ms, 최대 48ms아이다

 

테스트 결과

전체적으로 http 까지 2번 요청을 보내는 것보다 한 메세지에 많은 데이터를 넣어서 보내도 문제가 없었고 더 빠른것을 확인할 수 있었다.

 

 

분석 결과를 백엔드 회의 때 발표해서 프론트와 조율해 수정하기로 결정하였다.