
근래 면접을 다니면서 자주 들었던 얘기가 "이 스트리밍 서비스 몇명까지 들어가는지 테스트 해보셨나요? 어떻게 테스트하셨나요?"였다.

사실 그동안 테스트한건 단순히 브라우저 15개정도를 직접 실행해 원활히 돌아가는지 확인해봤다 정도여서 딱히 맘에 드는 답을 하지 못했다.
수용 가능한 인원에 대해서 말할 때도 mediasoup 공식문서에서 제공하는 추정치를 사용했을 뿐이지 정확한 수치는 전혀 아니였다.
... Depending on the host CPU capabilities, a mediasoup C++ subprocess can typically handle over ~500 consumers in total. If for example there are 4 peers in a room, all them sending audio and video and all them consuming the audio and video of the other peers, ...
공식 문서에서 말한 CPU당 500 consumers로 따지면 e2-micro의 코어 수인 2개에서 1000 consumers를 감당할 수 있고, 시청자 당 화면, 화면 소리, 캠, 마이크가 필요한 우리의 방송에선 250명이 화질 저하 없이 가능하다고 보았다.
(물론 잘못되었다. e2-micro는 공유 코어를 사용해 0.25개의 CPU 성능을 내는데다 네트워크적인 요소를 전혀 고려하지 않았다.)
그래서 이번엔 웹을 직접 동작할 수 있는 High-level API를 사용해서 이를 테스트해보고자 한다.
import puppeteer from 'puppeteer';
// Get username from command line arguments
const username = process.argv[2];
if (!username) {
console.error('Please provide a username as an argument');
process.exit(1);
}
const browser = await puppeteer.launch({
executablePath: '/usr/bin/google-chrome',
});
async function createViewer(username) {
const page = await browser.newPage();
await page.goto(`https://kwitch.online/channels/${username}`);
await page.waitForSelector('::-p-xpath(//*[@id="root"]/div/div[2]/div[1]/div[2]/div/div/span)')
const viewerCountEle = await page.$('::-p-xpath(//*[@id="root"]/div/div[2]/div[1]/div[2]/div/div/span)')
const viewerCount = await page.evaluate(element => element.textContent, viewerCountEle);
console.log("시청자 수:", viewerCount);
return page;
}
// Create 50 viewers asynchronously
const createAllViewers = async () => {
const promises = [];
for (let i = 0; i < 50; i++) {
promises.push(createViewer(username));
}
const pages = await Promise.all(promises);
// Close all pages and browser 10 seconds after the last viewer is created
console.log('All viewers created. Will close in 10 seconds...');
setTimeout(async () => {
for (const page of pages) {
await page.close();
}
await browser.close();
console.log('All pages and browser closed.');
}, 10000);
};
// Start the process
createAllViewers();
createAllViewers()를 통해 50명의 뷰어를 생성하고, 시청자 수를 체크하여 정상적으로 접속이 되었는지 확인한다.
그리고 이때의 CPU 사용량과 네트워크 사용량을 측정해보기로 했다.
크롬 클라이언트를 headless하게 생성하지만 스트리밍을 진행하는 것과 동시에 진행하면 성능적인 문제가 있을까 해서 두개의 PC를 준비하고, 한개의 PC에서 스트리밍을 하고 다른 PC에서 테스트를 실행했다.

먼저 시청자 수를 확인해 본 결과 50명의 수용은 원활하게 진행되었다.
하지만 결과는 예상과 완전히 달랐다.
위의 추정치와 같이 적어도 200명은 원활하게 수용하지 않을까? 라고 생각했던 내 생각은 벌써 73%에 육박하면서 완전히 잘못되었음을 깨달았다.
물론 이 단계에서도 시청에 크게 문제가 있지는 않았지만, 100명의 수용조차도 힘들 것으로 예상되는건 당연했다.
추가적으로 100개의 클라이언트 생성을 진행하면서 테스트의 문제점도 발견되었다.
await page.waitForSelector('::-p-xpath(//*[@id="root"]/div/div[2]/div[1]/div[2]/div/div/span)')
이 waitForSelector() 함수는 해당 태그가 웹 상에 로드가 되었는지 확인하는 함수이다.
기본값으론 30000ms를 기다리고, 이를 넘으면 에러를 뿜는다.
const promises = [];
for (let i = 0; i < 50; i++) {
promises.push(createViewer(username));
}
const pages = await Promise.all(promises);
하지만 이를 배열에 집어넣고 Promise.add()을 실행하게 되면 해당 await에서 microtask queue로 들어가게 되면서 뒤쪽의 createViewer() 함수는 오랜 시간을 기다리게 되면서 timeout이 발생한다.
async function createViewer(username, index) {
try {
const page = await browser.newPage();
// Reduce resource usage
await page.setRequestInterception(true);
page.on('request', (request) => {
// Block unnecessary resources
const resourceType = request.resourceType();
if (['image', 'stylesheet', 'font', 'media'].includes(resourceType)) {
request.abort();
} else {
request.continue();
}
});
// Reduce memory usage
await page.setViewport({ width: 800, height: 600 });
console.log(`Viewer ${index}: Navigating to channel...`);
await page.goto(`https://kwitch.online/channels/${username}`, {
waitUntil: 'domcontentloaded', // Use a less strict wait condition
timeout: 60000
});
// Use a more reliable selector strategy with retry logic
let viewerCount = null;
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries && viewerCount === null) {
try {
await page.waitForSelector('::-p-xpath(//*[@id="root"]/div/div[2]/div[1]/div[2]/div/div/span)', {
timeout: 20000
});
const viewerCountEle = await page.$('::-p-xpath(//*[@id="root"]/div/div[2]/div[1]/div[2]/div/div/span)');
if (viewerCountEle) {
viewerCount = await page.evaluate(element => element.textContent, viewerCountEle);
console.log(`Viewer ${index}: 시청자 수: ${viewerCount}`);
}
} catch (err) {
retries++;
console.log(`Viewer ${index}: Retry ${retries}/${maxRetries} - ${err.message}`);
// Short delay before retry
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
if (viewerCount === null) {
console.log(`Viewer ${index}: Failed to get viewer count after ${maxRetries} retries`);
}
return page;
} catch (error) {
console.error(`Error creating viewer ${index}:`, error.message);
return null;
}
}
또한 추가적으로 해당 상황이 발생했을 때, 재시도를 하게 함으로써 최대한 테스트의 성공률을 높였다.
const browser = await puppeteer.launch({
executablePath: '/usr/bin/google-chrome',
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-sandbox',
'--disable-extensions',
'--disable-audio-output',
]
});
추가적으로 클라이언트를 실행할 때, 리소스 소모를 줄이기 위해서 여러 옵션들을 적용했다.
--disable-gpu: GPU 가속을 사용할 수 있으나, headless 모드에서 일부 GPU 관련 문제(예: 그래픽 렌더링 성능 저하)나 headless 모드에서 특정 그래픽 문제가 발생할 수 있습니다.
--disable-dev-shm-usage: 컨테이너 환경에서 shm 공간이 부족하면 크래시가 발생할 수 있습니다.
--disable-setuid-sandbox와 --no-sandbox: 보안 강화된 환경에서 샌드박스 기능을 비활성화하지 않으면, Docker와 같은 환경에서 브라우저가 실행되지 않을 수 있습니다.
--disable-extensions: 확장 프로그램이 의도치 않게 동작할 수 있으며, 테스트 중 원치 않는 동작을 유발할 수 있습니다.
--disable-audio-output: 오디오가 출력되어 불필요한 리소스를 소모하거나, 특정 페이지에서 오디오 관련 작업을 처리할 때 문제가 발생할 수 있습니다.

역시 예상대로 60명부터 retry가 발생하더니 그 후론 시청자의 입장이 불가능했다.
최대 64명까지 들어가고 retry가 반복되며 종료되었다.
아마 스트리밍의 정보를 받아와야 하는데 서버의 과부하로 응답이 오지 않는 것 같았다.
그도 그럴 것이 CPU 사용량이 최대 124%(!)까지 올라가버렸다.
추가적으로 네트워크 사용량은 9.97MIB/s이 발생하고 있었다.
e2-micro 인스턴스는 최대 1 Gbps의 이그레스(출력) 대역폭을 지원하기 때문에 네트워크 적으론 크게 문제 없다고 생각했다.
내가 만든 스트리밍 서비스는 기껏해야 64명이 들어간다는 결론이 나왔다.
만약 동시 시청자 수가 어느정도 나온다고 생각하면 공유 코어가 아닌 표준 머신 유형을 사용하고 App Engine과 같은 PaaS 서비스를 사용해서 동적으로 스케일 아웃하면 몇천명 초반대는 커버할 수 있다고 생각한다.
물론 대형 스트리밍 플랫폼을 보면 화질도 굉장히 높은데 프레임 단위도 높으면서 거의 실시간으로 제공한다..
이번엔 관측한 데에 의미를 두고 나중에 개선시켜 보도록 하겠다.