모의 사용자는 단순히 서버에 Ping을 날리는 존재가 아니라 실제 사용자의 시나리오를 그대로 따라하는 bot이어야 진정한 경쟁이 성립합니다. 따라서, bot은 아래와 같은 행동 방식을 따라야 합니다.
Waiting Queue의 숫자를 폭발적으로 늘려야 합니다. (실제 사용자의 예상 대기 시간을 늘림)실제 사용자처럼 대기열 진입 → 입장 → 좌석 선택 전 과정을 수행하는 가상 사용자(Virtual User)를 대량 생성하는 방식입니다. 서버 입장에서 보면 실제 사용자와 구분 불가능한 요청 흐름을 만들어냄
커스텀 Node.js 스크립트 구현
방식: Axios 같은 가벼운 HTTP 클라이언트를 사용하여 API 요청을 보냄
장점: 봇의 행동(대기 시간, 버튼 클릭 타이밍 등)을 프로젝트에 맞게 세밀하게 조절 가능
구현 방법:
사람들은 목표와 다르게 오픈 후 0.N초 늦게 진입(예매버튼 클릭)할 수 있음 → 각 봇의 시작 시간에 Math.random()을 사용하여 Delay 설정
티켓팅을 시도하는 사용자들은 예매 오픈 시간 정각에 진입하는 것을 목표로 함. 그러나, Node.js가 싱글 스레드이기 때문에, 1번째 봇이 실행되고 1,000번째 봇이 실행될 때까지 미세한 시간 차가 발생 ➡️ flag를 사용하여 네트워크 요청만 동시에 발생 시킴
// 1. 출발 신호용 장치 (Gate)
let fireSignal;
const startGate = new Promise(resolve => {
fireSignal = resolve; // 이 함수를 호출하면 문이 열립니다.
});
class TicketingBot {
constructor(id) {
this.id = id;
}
async prepareAndRun() {
console.log(`Bot ${this.id}: 총알 장전 완료. 신호 대기 중...`);
// [핵심] 여기서 모든 봇이 멈춥니다! (Start Gate가 열릴 때까지 대기)
await startGate;
// --- 문이 열리는 순간 아래 코드가 동시에 실행됨 ---
this.sendRequest();
}
async sendRequest() {
try {
// 최대한 가볍게 요청만 딱 보냄
const startTime = Date.now();
await axios.post('<http://localhost:3000/queue/enter>', { id: this.id });
console.log(`Bot ${this.id}: 요청 발사! (Latency: ${Date.now() - startTime}ms)`);
} catch (e) {
// 에러 처리
}
}
}
async function runPerfectTraffic() {
const bots = Array.from({ length: 1000 }, (_, i) => new TicketingBot(i));
// 1. 모든 봇을 준비시킵니다. (아직 요청은 안 날아감)
// map을 돌면서 prepareAndRun이 실행되지만, 내부의 await startGate에서 멈춥니다.
const botPromises = bots.map(bot => bot.prepareAndRun());
console.log("=== 모든 봇이 출발선에 섰습니다. 3초 뒤 발사합니다. ===");
setTimeout(() => {
console.log("!!! 탕! (트래픽 폭발) !!!");
// 2. 방아쇠를 당깁니다.
// startGate 프라미스가 resolve되면서, 대기하던 1000개의 await가 동시에 풀립니다.
fireSignal();
}, 3000);
}
runPerfectTraffic();
Node.js는 싱글 스레드 이벤트 루프를 사용하므로, 위 방식을 써도 결국 CPU가 요청 1,000개를 처리하는 아주 미세한 순서는 존재 ➡️ docker-compose의 scale 기능을 사용하여 진짜 병렬 처리 가능
# traffic-maker 컨테이너를 4개로 늘려서 실행하라
docker-compose up --scale traffic-maker=4
전체 테스트 코드
const axios = require("axios");
// === 설정 ===
const TARGET_URL = "<http://localhost:3000/book>";
const USER_COUNT = 500;
// === 1. Start Gate (출발 신호) ===
let fireSignal;
const startGate = new Promise((resolve) => {
fireSignal = resolve;
});
// === 2. 가상 유저 클래스 (Virtual User) ===
class VirtualUser {
constructor(id) {
this.id = `User-${id}`;
// 모든 유저가 0~99번 좌석 중 하나를 무작위로 노림
this.targetSeat = Math.floor(Math.random() * 100);
}
// 준비 및 대기 로직
async prepare() {
// [핵심] 여기서 멈춤! 출발 신호를 기다림
await startGate;
// 신호가 떨어지면 즉시 실행
this.attack();
}
// 공격 로직
async attack() {
try {
await axios.post(TARGET_URL, {
userId: this.id,
seatId: this.targetSeat,
});
console.log(`[${this.targetSeat}]\\t좌석 선점 성공 - BOT ${this.id}`);
} catch (e) {
console.error(
`[${this.targetSeat}]\\t이미 선택된 좌석입니다 - BOT ${this.id}`
);
}
}
}
// === 3. 메인 실행 함수 ===
async function runSimulation() {
console.log(`🤖 가상 유저 ${USER_COUNT}명 생성 중...`);
const users = Array.from(
{ length: USER_COUNT },
(_, i) => new VirtualUser(i)
);
// 1. 모든 유저를 출발선에 세움 (Promise Pending 상태)
const readyPromises = users.map((user) => user.prepare());
console.log(`✅ ${USER_COUNT}명의 유저가 출발선에서 대기 중입니다.`);
console.log("⏳ 3초 뒤 Start Gate가 열립니다...");
setTimeout(() => {
console.log("🔫 탕! (Start Gate Open) -> 트래픽 폭발");
const startTime = Date.now();
// 2. 출발 신호 발사! (모든 유저가 동시에 attack() 실행)
fireSignal();
}, 3000);
}
runSimulation();
https://drive.google.com/file/d/1d1MP5gf6I8jggtWRgmRL60H5MGmv8FMg/view?usp=sharing
k6
부하 테스트 도구 - 봇을 수만, 수십만 명 단위로 늘려 서버를 극한까지 테스트하고 싶을 때 사용
Go 언어로 구축되어 있지만 스크립트는 JavaScript로 작성하여 동작
장점: VUs (Virtual Users) 개념이 있어 "1분 동안 봇 5000명 투입" 같은 시나리오를 간단하게 구현 가능
단점: k6 전용 모듈을 사용해야함 (Node.js의 모든 라이브러리(npm 모듈)를 자유롭게 사용 불가)
테스트 코드
import http from "k6/http";
import { check } from "k6";
import { randomIntBetween } from "<https://jslib.k6.io/k6-utils/1.2.0/index.js>";
export const options = {
scenarios: {
ticket_opening: {
executor: "per-vu-iterations",
vus: 500, // 500명 동시 공격
iterations: 1,
maxDuration: "10s",
},
},
};
const BASE_URL = "<http://localhost:3000>";
export default function () {
const userId = `User-${__VU}`;
const seatIndex = randomIntBetween(0, 99);
const payload = JSON.stringify({
seatId: seatIndex,
userId: userId,
});
const params = { headers: { "Content-Type": "application/json" } };
try {
const res = http.post(`${BASE_URL}/book`, payload, params);
// === 결과 로그 분류 ===
if (res.status === 200) {
console.log(`✅ [${seatIndex}] 좌석 선점 성공! - BOT ${userId}`);
} else if (res.status === 409) {
console.error(
`⚔️ [${seatIndex}] 이미 뺏긴 좌석(경쟁 실패) - BOT ${userId}`
);
} else if (res.status === 0) {
// [중요] 여기가 연결 거부(Connection Refused) 상황입니다.
console.error(
`🔥 [서버 폭주] 접속 거부됨 (Connection Refused) - BOT ${userId}`
);
} else {
console.error(
`❓ [${seatIndex}] 기타 에러(${res.status}) - BOT ${userId}`
);
}
} catch (e) {
console.error(`☠️ 치명적 에러: ${e.message}`);
}
}
[https://drive.google.com/file/d/1tSgnnLEJYzRyIEDbcB7ylPva6SW1TgY5/view?usp=sharing](https://drive.google.com/file/d/1tSgnnLEJYzRyIEDbcB7ylPva6SW1TgY5/view?usp=sharing)
Artillery
기존 queue-backend, ticket-backend와 분리하여 모의 사용자들을 생성하고 제어할 별도의 서버가 추가 되어야 합니다. 서버 분리의 이유는 아래와 같습니다.
traffic-maker는 별도의 CPU 자원(컨테이너)을 사용하여 공격만 전담하고 ticket-server 는 요청 처리에만 자원을 사용 ➡️ 서버가 정상 동작하지 않을 때 원인 추적 수월