2. Artillery를 활용한 Socket.IO 기반 채팅 서비스 부하 테스트: Local vs EC2 환경 성능 비교 및 분석
2025. 1. 14. 22:38ㆍTest/Artillery
Intro
실시간 시스템, 특히 채팅 서비스에서 부하 테스트는 매우 중요 Socket.IO는 실시간 통신을 쉽게 구현할 수 있는 라이브러리로, WebSocket을 기반으로 동작하며 자동 폴백 메커니즘을 제공
이 글에서는 Artillery를 사용해 Socket.IO 기반 채팅 서비스를 테스트하는 초기 단계 테스트 시나리오를 다룬다. 또한, 로컬 환경과 EC2 환경에서의 테스트 결과를 비교하고, 이러한 테스트가 시스템에 미치는 영향 분석
채팅 서비스 개요
1. 주요 기능
- 유저 인증: 이메일과 비밀번호를 사용한 로그인
- 친구 관리: 친구 목록 가져오기 및 친구 선택
- 실시간 채팅: 채팅방 생성 및 메시지 전송
2. 시스템 아키텍처
- Backend: Node.js 서버(NestJS)
- Database: PostgreSQL(+ TypeORM)
- Real-time Communication: Socket.IO
- Reverse Proxy: Nginx (EC2 환경)
테스트 시나리오
1. 목표
- 유저 인증, 친구 목록 로드, 채팅방 생성, 메시지 전송과 같은 실시간 기능의 부하 테스트.
- 서비스가 여러 사용자 요청을 안정적으로 처리할 수 있는지 검증.
2. 테스트 설정
YAML 파일 (Artillery 설정)
# artillery-http-config.yml
config:
target: # "DOMAIN"
phases:
- name: "Warm up"
duration: 30
arrivalRate: 1
rampTo: 2
processor: "./processor.ts"
defaults:
headers:
Content-Type: "application/json"
Accept: "application/json"
Authorization: "Bearer {{ token }}"
socketio:
url: "https://api.stahc.uk"
transports: ["websocket"]
verbose: true
scenarios:
- name: "Test Scenario"
engine: socketio
flow:
- function: "loadUserData"
- think: 1
- emit:
channel: 'sendMessage'
data: '{{ content }}'
- think: 1
테스트 프로세스
chat-users.json
파일에서 사용자 데이터를 읽어옴.- 사용자를 인증하고, 인증 토큰을 발급받음.
- 사용자 친구 목록을 로드.
- 랜덤한 친구와의 채팅방을 생성하거나 참여.
- 메시지(
sendMessage
이벤트)를 전송.
// ./processor.ts
import { readFileSync } from 'fs';
import axios from 'axios';
import { DoneCallback } from 'passport';
// import { io } from 'socket.io-client';
import { randomInt } from 'crypto';
// import { promises as fs } from 'fs';
interface User {
id: number;
email: string;
password: string;
}
interface Friend {
id: number;
email: string;
username: string;
}
let users: User[];
let userIndex = 0;
const API_URL = // "DOMAIN"
// Add retry configuration
const RETRY_DELAY = 1000; // 1 second
const MAX_RETRIES = 3;
async function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function retryOperation<T>(
operation: () => Promise<T>,
retries = MAX_RETRIES,
delayMs = RETRY_DELAY,
): Promise<T> {
try {
return await operation();
} catch (error) {
if (retries > 0 && error.response?.status === 429) {
await delay(delayMs);
return retryOperation(operation, retries - 1, delayMs * 2);
}
throw error;
}
}
async function login(email: string, password: string): Promise<string> {
return retryOperation(async () => {
try {
const response = await axios.post(`${API_URL}/auth/login`, {
email,
password,
});
return response.data.access_token;
} catch (error) {
console.error(`Login failed for ${email}:`, error.message);
console.log(`Login failed for ${email}:`, error.message);
throw error;
}
});
}
async function getFriends(token: string): Promise<Friend[]> {
try {
const response = await axios.get(`${API_URL}/users/friends`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return response.data.friends;
} catch (error) {
console.error('Error fetching friends:', error.message);
console.log('Error fetching friends:', error.message);
throw error;
}
}
async function getUserSecure(email: string): Promise<Partial<User>> {
try {
const response = await axios.get(`${API_URL}/users/getUserSecure/${email}`);
return response.data;
} catch (error) {
console.error(`Error fetching user ${email}:`, error.message);
console.log(`Error fetching user ${email}:`, error.message);
throw error;
}
}
async function getRoom(token: string, friendId: number): Promise<string> {
try {
const response = await axios.post(
`${API_URL}/rooms/find-or-create`,
{ friendId },
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
return response.data.roomId;
} catch (error) {
console.error('Error creating/finding room:', error.message);
console.log('Error creating/finding room:', error.message);
throw error;
}
}
// Add error tracking
const errorTracker = {
errors: new Map<string, number>(),
addError(type: string) {
const count = this.errors.get(type) || 0;
this.errors.set(type, count + 1);
if (count > 50) {
console.error(`High error rate for ${type}: ${count}`);
}
}
};
// Modify loadUserData function
export function loadUserData(
userContext: any,
events: any,
done: DoneCallback,
): void {
(async () => {
try {
// Add session tracking
if (!userContext.vars) {
userContext.vars = {};
}
userContext.vars.sessionStartTime = Date.now();
// Load data only once and cache it
if (!users) {
try {
const userData = readFileSync('./data/chat-users.json', 'utf-8');
users = JSON.parse(userData);
} catch (error) {
console.error('Error loading users:', error);
return done(new Error('Failed to load users data'));
}
}
// Validate users array
if (!users || !Array.isArray(users) || users.length === 0) {
console.error('No valid users found');
return done(new Error('No valid users available'));
}
// Cycle through users in the data file
const user = users[userIndex % users.length];
userIndex++;
try {
const token = await login(user.email, user.password);
const userInfo = await getUserSecure(user.email);
const friends = await getFriends(token);
if (!friends || friends.length === 0) {
console.error(`No friends found for user ${user.email}`);
return done(new Error('No friends available'));
}
const randomFriend = friends[Math.floor(Math.random() * friends.length)];
const roomId = await getRoom(token, randomFriend.id);
const currentUser = {
email: user.email,
id: userInfo.id,
friend: {
id: randomFriend.id,
email: randomFriend.email
}
};
// Set context variables
userContext.vars = {
userId: userInfo.id,
token: token,
roomId: roomId,
email: user.email,
friends: friends,
friendId: randomFriend.id,
// Export the current user data
currentUser: {
email: user.email,
id: userInfo.id,
friend: {
id: randomFriend.id,
email: randomFriend.email
}
},
content: randomInt(10),
};
// Validation
if (!userContext.vars.token) {
return done(new Error('Authentication failed'));
}
if (!userContext.vars.roomId) {
return done(new Error('Room creation failed'));
}
events.currentUser = currentUser;
// Pass the user data through done callback
return done(null, userContext.vars.currentUser);
} catch (error) {
console.error(`Error processing user ${user.email}:`, error);
errorTracker.addError(error.message);
return done(error);
}
} catch (error) {
console.error('Unexpected error:', error);
errorTracker.addError(error.message);
return done(error);
}
})();
}
주요 함수 설명
1. loadUserData
- 사용자 데이터를 로드하고 순환적으로 사용자를 인증.
- 친구 목록 가져오기 및 채팅방 생성.
- 인증 토큰, 채팅방 ID, 메시지 콘텐츠 등을 컨텍스트 변수에 저장.
2. 인증
- 엔드포인트:
/auth/login
- 사용자 이메일과 비밀번호로 로그인 요청.
- Rate Limit(429 에러) 발생 시 재시도 로직 적용.
3. 채팅방 생성
- 엔드포인트:
/rooms/find-or-create
- 선택한 친구와 채팅방을 생성하거나 기존 채팅방에 참여.
4. 메시지 전송
sendMessage
이벤트를 통해 메시지를 서버로 전송.
성능 비교 분석
- Socket.IO 기반 실시간 채팅 서비스를 위한 성능 테스트를 진행했다. 이번 테스트는 MacBook Pro (로컬 환경)과 AWS EC2 t2.micro (프리 티어)에서 각각 수행되었으며, 두 환경에서의 성능 차이를 비교하고 분석했다.
- 테스트는 Artillery를 활용하여 가상 사용자를 생성하고 메시지 전송 작업을 시뮬레이션 했다. 이 글에서는 하드웨어 및 모니터링 데이터를 기반으로 결과를 심층적으로 다룬다.
테스트 환경 비교
항목 | 로컬 환경 (MacBook Pro) | EC2 환경 (AWS t2.micro) |
---|---|---|
장치/인스턴스 타입 | MacBook Pro (Retina, 15-inch, Mid 2015) | t2.micro |
운영 체제 | macOS Monterey 12.7.6 | Ubuntu 20.04 LTS |
CPU | 2.5 GHz 쿼드 코어 Intel Core i7 | 1 vCPU |
메모리 | 16GB 1600 MHz DDR3 | 1GB |
그래픽 | Intel Iris Pro 1536 MB | 없음 |
네트워크 환경 | 로컬호스트 (네트워크 지연 없음) | 인터넷 (Low to Moderate 네트워크 성능) |
스토리지 | 로컬 SSD (빠른 I/O) | EBS (Baseline Throughput 제한적) |
요금 | 무료 | 프리 티어 무료 (추가 사용 시 비용 발생) |
배포 도구 | 직접 실행 | Nginx를 통한 배포 및 프록시 설정 |
테스트 환경 | 네트워크 오버헤드 없음 | 네트워크 및 리소스 제한이 포함된 현실적인 환경 |
운영 리소스 사용량 | 넉넉한 리소스로 부하를 쉽게 처리 가능 | 리소스 제한으로 높은 부하 시 성능 저하 발생 |
모니터링 데이터 (EC2)
아래는 AWS EC2 모니터링 데이터(볼륨 모니터링 및 인스턴스 세부 정보)를 기반으로 분석한 주요 성능 지표
- 평균 읽기/쓰기 지연 시간
- 읽기: 1ms 이하
- 쓰기: 2~3ms
- 읽기/쓰기 작업 (Ops/s)
- 읽기 작업: 최대 11.7 Ops/s
- 쓰기 작업: 최대 8.25 Ops/s
- CPU 및 메모리 사용량
- t2.micro는 리소스 제약이 있으므로, 부하가 증가할수록 성능이 감소
비교 요약
- CPU와 메모리
- 로컬 환경은 4코어와 16GB 메모리를 제공해 동시 작업 처리에 여유가 있음
- EC2 t2.micro는 1 vCPU와 1GB 메모리로 리소스 제약이 커서 동시 작업 처리에 한계가 있음
- 네트워크
- 로컬에서는 네트워크 지연이 없으므로 빠른 처리 속도를 보임
- EC2는 인터넷을 통해 통신해야 하므로 네트워크 지연 및 패킷 손실 가능성이 있음
- 스토리지
- 로컬 SSD는 높은 I/O 성능을 제공
- EC2 EBS는 기본 성능이 제한되어 부하 시 처리 속도가 느려질 수 있음
- 운영 비용
- 로컬은 추가 비용 없이 테스트 가능
- EC2는 프리 티어로 무료지만, 리소스를 추가하면 비용이 발생
- 테스트 환경의 현실성
- 로컬은 네트워크 오버헤드가 없는 이상적인 조건에서 실행
- EC2는 실제 프로덕션 환경에 가까운 네트워크와 리소스 제약 조건을 제공
테스트 시나리오
- 사용자 수: 45 가상 사용자(VUs)
- 작업
- 유저 로그인 및 인증
- WebSocket 연결 생성
socketio.emit
이벤트를 통해 메시지 전송
- 테스트 시간
- 로컬: 32초
- EC2: 35초
Local(좌) vs EC2(우)
테스트 결과 비교
주요 성능 지표
지표 | 로컬 테스트 | EC2 테스트 | 차이점 |
---|---|---|---|
Socket.IO Emit 횟수 | 45 | 45 | 동일 |
Emit Rate | 2/sec | 1/sec | 50% 느림 (EC2) |
응답 시간 (평균) | 0.3초 | 0.5초 | 66% 느림 (EC2) |
응답 시간 (p95) | 0.4초 | 1초 | 2.5배 느림 (EC2) |
응답 시간 (p99) | 1초 | 1.2초 | 20% 느림 (EC2) |
세션 길이 (평균) | 2280.9ms | 4800ms | 2.1배 길어짐 (EC2) |
세션 길이 (p95) | 2416.8ms | 5272.4ms | 2.2배 길어짐 (EC2) |
가상 사용자 완료 | 45 | 45 | 동일 |
가상 사용자 실패 | 0 | 0 | 동일 |
관찰 내용
응답 시간
- 로컬 테스트
- 평균 응답 시간은 0.3초, 네트워크 지연이 없기 때문에 빠르게 처리
- 95번째 백분위 응답 시간(p95)은 0.4초로, 부하 조건에서도 안정적
- EC2 테스트
- 평균 응답 시간이 0.5초로 증가, 네트워크 지연과 제한된 리소스의 영향을 받음
- 95번째 백분위 응답 시간(p95)은 1초로, 성능 저하가 발생
Emit Rate
- 로컬 테스트에서 초당 2개의 emit 이벤트를 처리한 반면, EC2 테스트는 1개/초로 처리량이 절반으로 감소
세션 길이
- 로컬 테스트:
- 평균 세션 길이는 2280.9ms, 전체 작업이 빠르게 처리됨
- EC2 테스트:
- 평균 세션 길이가 4800ms로, 로컬 테스트보다 2배 이상 느림
EC2에서의 병목 현상
CPU 및 메모리 제약
- EC2 t2.micro 인스턴스는 1 vCPU와 1GB RAM만 제공되므로, 동시 연결과 이벤트 처리가 느려짐
네트워크 지연
- 클라이언트와 EC2 인스턴스 간의 네트워크 지연이 응답 시간 증가에 영향을 미침
리소스 경쟁
- EC2 환경에서는 다른 프로세스나 시스템 서비스로 인해 리소스 경쟁이 발생, 처리량 감소의 원인이 됨
결론
로컬 테스트와 EC2 테스트는 각각의 목적에 따라 보완적으로 사용 필요
- 로컬 테스트는 빠른 개발 및 디버깅을 위해 유용하며, 초기 테스트 단계에 적합
- EC2 테스트는 실제 환경과 유사한 조건에서 성능을 측정하고, 병목 현상을 파악하는 데 적합
테스트 결과 요약
- 로컬 테스트는 응답 시간이 빠르고 처리량이 높았지만, 실제 배포 환경을 반영하지 못함
- EC2 테스트는 네트워크 지연과 리소스 제약으로 인해 응답 시간이 느렸지만, 현실적인 조건에서의 성능 평가가 가능했음
- EC2에서의 테스트는 10번 이상의 테스트 중 1번 정도
errors.Error: websocket error
가 발생 - 점진적 개선을 통해 failure를 줄이고 좀 더 높은 트래픽을 견딜 수 있도록 개선할 예정
'Test > Artillery' 카테고리의 다른 글
6. [최적화3] ulimit와 PAM 설정을 통한 테스트 환경 최적화 (0) | 2025.01.24 |
---|---|
5. [최적화2] Nginx 및 WebSocket 서버 설정 최적화 (0) | 2025.01.24 |
4. [최적화 1] Node.js 프로세스 최적화 (0) | 2025.01.24 |
3. AWS EC2 t2.micro 환경에서 WebSocket 타임아웃 문제 (0) | 2025.01.16 |
1. 부하테스트를 위한 테스트 도구 도입 (0) | 2025.01.14 |