Push 서버는 고객에게 푸시 알림으로 전달할 메시지를 메인 서버로부터 전송받아 Google FCM(Firebase Cloud Messaging)을 통해 사용자에게 전송하는 역할을 합니다. 메인 서버는 보안을 위해 방화벽 내부에 위치하고 있기 때문에, 이러한 미들웨어 성격의 서버가 필요합니다.
서버를 처음 구축했을 당시에는 하루 평균 1만~3만 건의 데이터를 처리하면 충분했기 때문에 성능에 큰 신경을 쓰지 않았습니다. 대신 유지보수 측면에서 유리하도록 코드를 작성했고, 여러 라이브러리를 적극적으로 활용하여 구현했습니다.
하지만 최근 회사가 푸시 알림을 활용한 적극적인 마케팅 전략을 도입하면서, 하루 처리해야 하는 메시지의 양이 급격히 증가했습니다. 이에 따라 한 번에 수십만 건의 트래픽이 몰리는 상황이 발생했고, 서버에서 문제가 나타나기 시작했습니다.
보통 여러 고객에게 동일한 제목과 내용을 전달할 경우, Firebase-admin 의 Admin.messaging.sendEachForMulticast를 사용해 최대 500건씩 묶어 전송할 수 있습니다. 이런 방식으로 대량 처리를 효과적으로 수행할 수 있었지만, 이번 요구사항은 달랐습니다.
요구사항에 따라 10만 건의 메시지는 각각 다른 제목과 내용을 가져야 했고, 메시지마다 고객의 개인정보가 포함되어 있었습니다. 결국, 한 건씩 전송해야 하는 상황이 되었고, 전송이 시작되자 서버의 메모리 사용량이 급격히 증가하며 결국 서버가 다운되고 말았습니다.
이 문제를 해결하기 위해 다양한 방법을 시도했고, 그중 효과적이었던 몇 가지 방법에 대해 공유하려고 합니다.
그전에, Node.js에서 메모리 사용량을 확인하는 방법을 먼저 소개하겠습니다.
0. Pm2 monit
Pm2를 사용해 클러스터 모드로 여러 프로세스를 실행했습니다. 이렇게 하면 분산 처리 효과를 얻을 수 있을 뿐만 아니라 무중단 서비스 제공도 가능합니다.
PM2는 강력한 모니터링 기능을 제공하여 프로세스 전반적인 메모리 흐름을 쉽게 파악할 수 있습니다. 또한, 이후에 자세히 설명하겠지만, 메모리가 지정한 한도까지 차면 자동으로 프로세스를 재기동하는 기능도 있어 안정적인 서비스 운영이 가능합니다.
1. 주기적으로 메모리를 기록
function memoryInterval(time: number) {
setInterval(() => {
const memoryUsage = process.memoryUsage();
const log =
`------- ${process.env.SERVER_NAME} -------\n` +
`TIME : ${formatTime(getFmtTime())}\n` +
`RSS : ${(memoryUsage.rss / 1024 / 1024).toFixed(2)} MB\n` +
`Heap Total : ${(memoryUsage.heapTotal / 1024 / 1024).toFixed(2)} MB\n` +
`Heap Used : ${(memoryUsage.heapUsed / 1024 / 1024).toFixed(2)} MB\n` +
`External : ${(memoryUsage.external / 1024 / 1024).toFixed(2)} MB\n`;
fs.appendFileSync("log/memory-usage.log", log);
}, time);
}
이 방법은 직관적으로 메모리 사용 현황을 확인할 수 있다는 장점이 있습니다. 그러나 단점은 특정 요소가 메모리 누수를 일으키는 원인을 직접적으로 파악하기 어렵다는 점입니다. 데이터를 기반으로 유추만 할 수 있을 뿐입니다.
2. heapdump 를 활용한 메모리 녹화
heapdump 라이브러리를 사용하면 힙 덤프 파일을 생성하여 Chrome DevTools에서 분석할 수 있습니다. 이를 통해 어떤 요소가 가장 많은 메모리를 점유하고 있는지 파악할 수 있습니다.
더 편리한 방법은 inspect 모드를 활성화하는 것입니다. 아래 명령어를 사용하면:
node --inspect dist/index.js
chrome://inspect 사이트에서 Inspect 버튼을 클릭하여 Chrome 개발자 도구로 진입할 수 있습니다.
개발자 도구의 Memory 탭에서 힙 스냅샷을 찍어 어떤 요소가 가장 큰 메모리를 차지하고 있는지 확인할 수 있으며, 여러 스냅샷을 비교하거나 통계를 확인할 수도 있습니다.
저에게는 스냅샷 비교와 통계 확인이 가장 유용했습니다.
주의: Inspect 모드를 활성화하면 메모리를 복제하기 때문에 기존보다 더 많은 메모리를 사용할 수 있습니다.
여러장의 스냅샷을 찍어 비교도 해볼 수 있고 통계량 확인도 가능합니다. 저에게는 비교와 통계가 가장 도움이 되었습니다.
한 가지 주의해야할 점은 inspect 모드를 옵션으로 주면 메모리를 복제하기 때문에 기존 메모리 사용량보다 많이 사용하게 됩니다.
3. Clinic.js 를 활용한 메모리 흐름 확인
Clinic.js 라이브러리를 설치하고 프로그램을 실행하면, 프로그램 종료 시 자동으로 **보고서(report)**를 생성하여 보여줍니다.
저는 이미 메모리 흐름과 CPU 사용량을 어느 정도 알고 있었기 때문에 큰 도움은 되지 않았지만, 시각적으로 타임라인을 통해 데이터를 확인할 수 있다는 점이 유용했습니다. 또한, 하단에 표시되는 **Recommendation(추천 사항)**에서 메모리 관리 조언을 제공하지만, 실질적인 도움은 크지 않았습니다.
위 방법들을 활용해 메모리를 측정하고, 다양한 관점에서 데이터를 분석할 수 있었습니다. 이제 이러한 분석을 통해 파악한 원인들을 소개하겠습니다.
원인 0. 서버 스펙
운영 환경에서는 메모리 8GB 서버 두 대를 운영하고 있습니다. 반면 개발 환경에서는 메모리 4GB 한 대를 운영하고 있습니다. 만약 동일한 데이터양이 운영 환경에서 전송되었다면 정상적으로 처리될 수 있었겠지만 그것또한 혹시 모르는 것이었겠죠 ..
해결 방법은 간단했습니다. 서버 스펙을 늘리자 !
하지만 스스로 이 문제를 해결하고 싶었던 욕구가 커서 그렇게 하지 않았습니다. 물론 추후에 도저히 감당이 안된다면 늘려야하겠지만요.
원인 1. 매 요청 마다 발생하는 데이터베이스 read - write - update
Push 메시지가 잘 전송되었는지 그리고 어떤 고객에게 전송되었는지 확인하기 위해 redis 데이터베이스를 선택했고 메인 서버로부터 메시지가 들어올 때 write, 그리고 FCM으로부터 응답이 왔을 때 전송 결과를 update 하도록 했습니다. 관리자(admin) 사이트도 만들었기 때문에 express를 활용한 웹서버를 띄워 API로 처리결과를 admin 사이트에 제공했습니다.
하지만 지금은 전송 결과를 확인하는 것보다 메시지를 처리하는 과정에서 메모리가 과부화되는지가 더 중요했기 때문에 잠시 데이터베이스 관련 코드를 전부 제거했습니다.
해결 방법) 데이터베이스 사용X
결과) 프로그램에 실행 시 최소 확보 메모리 공간을 조금 줄일 수 있었습니다
원인 2. 무차별적인 변수, 버퍼의 생성
메모리를 전혀 고려하지 않은 채 개발자 입장에서만 고려한 코드를 작성했었습니다.
예를 들어 원시 데이터(버퍼)를 받아 전문 규격에 맞게 전처리를 거친 후 FCM으로 전송하는 과정이라고 했을 때 이전 코드는 아래와 같았습니다.
socket.on("data", async(originalBuffer : Buffer)=>{
const bufferToString = originalBuffer.toString();
const bufferHeader = createHeaderFromBuffer(bufferToString);
const bufferBody = createBodyFromBuffer(bufferToString);
const msgs = createFcmMessageFromBody(bufferBody);
const msgTokens = msgs.map((msg)=> msg.token);
const msgTitle = msgs.title;
const msgContent = msgs.content;
for(const msgToken of msgTokens){
await sendToFCM(msgToken, msgTitle, msgContent);
}
});
주석이 필요없을 정도로 한 눈에 참 보기 쉽고 유지보수가 편리한 코드 아닌가요 ? 코드에 나와있지 않지만 각 함수 내부에서는 iconv-lite 라이브러리를 사용한 인코딩과 디코딩, Buffer 생성 및 자르기 등 불필요한 과정이 너무 많았습니다. 버퍼 객체의 생성은 Node.js의 메모리 공간 중 external memory 에 쌓이고 제대로 해제해주지 않으면 메모리 누수를 발생시킵니다.
따라서 버퍼 풀링을 통해 버퍼 객체의 생성을 최소한으로 해야합니다.
버퍼를 관리할 클래스를 생성합니다.
class ABuffer{
totalBuffer = Buffer.alloc(0);
get(){}
set(){}
subArray(start : number, end? : number){}
update(value : string, start : number){}
clear(){}
}
버퍼의 복제, 신규 버퍼의 생성 등 불필요한 과정은 제거하고 하나의 버퍼만을 사용해 필요한 데이터를 추출해 메시지를 가공합니다.
let totalBuffer : ABuffer | null = new ABuffer();
socket.on("data", async(originalBuffer : Buffer)=>{
totalBuffer.set(originalBuffer)
const msgTitle = totalBuffer.subArray(TITLE_OFFSET, TITLE_OFFSET+TITLE_LENGTH).toString();
const msgContent = totalBuffer.subArray(CONTENT_OFFSET, CONTENT_OFFSET+CONTENT_LENGTH).toString();
const msgTokens = totalBuffer.subArray(TOKEN_OFFSET, TOKEN_OFFSET+TOKEN_LENGTH).toString().split(",");
for(const msgToken of msgTokens){
await sendToFCM(msgToken, msgTitle, msgContent);
}
totalBuffer.clear();
totalBuffer = null; //의미있는 코드는 아닌 것 같음.
orinialBuffer = null; //의미있는 코드는 아닌 것 같음.
});
해결 방법) 버퍼 풀링을 활용한 버퍼의 재활용, 불필요한 변수 생성 제한
결과) external memory 가 더 이상 증가하지 않았습니다.
원인 3. fake data로 인한 firebase 응답 지연
가장 머리를 아프게 했던 원인입니다. 개발 환경에서 10만건의 데이터를 진짜 데이터로 만들 순 없으니 가짜 device-id(token)을 생성해 FCM으로 전송했습니다. 가짜 device-id를 전송하니 FCM에서는 토큰이 올바르지 않다는 에러 메시지를 5~10초가 지나 응답으로 내려줍니다. 일관적으로 5~10초면 좋겠지만 가짜 데이터가 쌓일수록 응답을 주는 시간은 지수적으로 증가했습니다.
관련 링크 : https://firebase.google.com/docs/cloud-messaging/scale-fcm?hl=ko
FCM 메시지를 대규모로 전송할 때의 권장사항 | Firebase Cloud Messaging
2024년 데모 데이에서, Firebase를 사용하여 AI 기반 앱을 빌드하고 실행하는 방법에 관한 데모를 시청하세요. 지금 시청하기 의견 보내기 FCM 메시지를 대규모로 전송할 때의 권장사항 컬렉션을 사
firebase.google.com
따라서 메인서버로부터 데이터는 계속 들어오는데 FCM으로 나가 하염없이 응답을 기다리는 요청들이 쌓여 힙 메모리가 누적이 되더군요.
Pm2 monit 명령어를 치면 현재 누적되어 있는 IO 요청 개수를 확인할 수 있습니다.
비동기 작업이 완료되지 않고 대기 상태에 머무를 경우, Node.js의 이벤트 루프에 active handles나 active requests가 남아있게 됩니다.
물론 운영 환경에서는 가짜 데이터를 전송할 일이 없으니 이와같은 문제가 재현되진 않을겁니다. 하지만 혹시 모를 상황에 대비는 해야했습니다.
해결 방법0) 비동기처리
"FCM으로부터 응답이 늦게 오면 그냥 안기다리면 되는거 아닌가 ?" 라는 단순한 생각으로 코드를 전부 비동기 방식으로 변경했습니다. 그래도 요청건마다 쌓여가는 active handles, active requests를 줄일 순 없었습니다. 당연 메모리도 계속 증가햇죠.
그 이유는 Chat GPT가 알려주었습니다.
나 : 응답이 매우 늦게 오는 api에 요청을 비동기처리해도 active handles, active requests가 줄어들지 않는 이유는 io가 열려있어서 그런가 ?
GPT : 네, 맞습니다. 응답이 매우 늦게 오는 API에 요청을 비동기로 처리했을 때 **active handles**와 **active requests**가 줄어들지 않는 주된 이유는 I/O 작업이 여전히 진행 중이기 때문입니다.
이유
1. Active Handles:
• Node.js는 이벤트 루프를 기반으로 동작하며, I/O 작업은 비동기로 처리됩니다.
• API 요청이 완료되지 않은 상태에서는 해당 요청을 처리하는 소켓 핸들이 여전히 열려 있어서 active handles의 수가 유지됩니다.
• 이는 Node.js 내부에서 비동기로 처리되는 모든 작업(파일 핸들, 네트워크 소켓 등)을 추적하기 때문입니다.
2. Active Requests:
• active requests는 현재 처리 중인 I/O 요청을 나타냅니다.
• 응답이 완료되기 전까지 해당 요청은 **active requests**에 포함됩니다.
• 즉, 비동기 호출을 사용하더라도 요청의 라이프사이클이 끝날 때까지(응답이 완료될 때까지) **active requests**는 줄어들지 않습니다.
해결 방법1) 단건 전송을 묶음으로 처리
"그럼 트랜잭션을 줄이면 되지 않을까?"
10만 건의 메시지는 각 메시지가 다른 제목과 내용을 가지고 있었습니다. 그래서 하나의 제목과 내용으로 묶어서 보내는 다건 전송을 하지 않았고 firebase admin sdk 에서 제공하는 Admin.messaging().send(message)으로 메시지를 전송했었습니다.
트랜잭션을 줄이기 위해 queue를 만들어 메시지를 담았고 1초에 한번씩 이 큐에서 메시지들을 꺼내어 Admin.messaging().sendEach(messages) 으로 메시지를 전송하도록 수정했습니다.
const msgQueue = [];
const maxQueueSize = 500; //Firebase admin sdk 에서 제공하는 최대 메시지 개수
setInterval(()=>{
if(msgQueue.length > 0){
const msgQueueToSend = msgQueue.splice(0, maxQueueSize);
Admin.messaging().sendEach(msgQueueToSend);
}
}, 1000)
socket.on("data", async (buffer: Buffer) => {
msgQueue.push(buffer);
});
초당 20건의 데이터만 수신해도 API 요청 트랜잭션은 20배 줄일수 있었습니다. 하지만, 제가 간과했던 사실 한 가지는 Firebase admin sdk에서 제공하는 sendEach 함수도 결국 모듈 내부적으로 메시지를 건당 처리한다는 사실이었습니다. 결국 메모리가 증가하는건 해결할 수 없었습니다.
해결 방법2) 응답이 오지 않는 요청은 취소하기
"응답 기다리지말고 그냥 취소해버리면 어떨까?" firebase admin sdk 에는 해당 기능이 없어 직접 FCM으로 https.request를 전송하는 함수를 짰고 setTimeout 으로 특정 시간 뒤면 요청을 취소해버렸습니다.
function sendHttpRequestWithTimeout(url: string, options: any, timeout: number): Promise<any> {
return new Promise((resolve, reject) => {
const req = http.request(url, options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', (err) => reject(err));
// Timeout 설정
req.setTimeout(timeout, () => {
req.abort(); // 요청 중단
reject(new Error('Request timed out'));
});
req.end();
});
}
이 방법으로 증가하는 active handles를 줄일 수 있었지만 active requests의 증가는 막을 수 없었습니다. 도대체 active requests는 어떻게 줄일 수 있을까.. 만약 active requests까지 줄여서 힙 메모리를 줄일 수 있다고 한들, 3초의 시간이 지난 요청을 무작정 취소해버리는 것이 과연 올바른 방법일까 ? 라는 생각이 들었습니다. 그래서 이 방법은 사용하지 않기로 했습니다. 나중에 도저히 방법이 없을 때 사용하기로 했습니다.
해결 방법3) 메모리 제한을 두기
계속 증가하는 힙 메모리를 정리하는데 직빵인 방법은 프로세스를 재기동 하는 것입니다. 좀 극단적이긴 하지만 제일 효과가 확실했습니다.
메모리 4GB의 서버에서 80% 정도의 메모리만을 점유한다고 가정했을 때 3.2G의 메모리를 할당할 수 있습니다.
Pm2 클러스터 모드로 다중 프로세스를 띄우기 때문에 총 8개의 프로세스라고 가정하면 한 프로세스 당 400MB가 한도입니다.
따라서 Node.js 를 실행할 때 메모리 상한을 400MB로 두고 그에 대한 80% 지점인 320MB에 도달했을 때 Pm2 를 재기동해주도록 합니다.
node --max-old-space-size=400 app.js
위 명령어를 통해 노드 자체의 메모리를 제한할 수 있습니다.
ecosystem.config.js를 수정해 Pm2의 재기동 옵션을 부가해줍니다.
module.exports = {
script: 'api.js',
max_memory_restart: '320M'
}
메모리가 320M에 도달하는 순간 Pm2 는 안정적으로 프로세스를 내렸다가 다시 올리기 때문에 메모리 정리가 이루어집니다. 여러 프로세스를 띄웠기 때문에 데이터 손실에 대한 걱정도 안해도 됩니다.
문제와 해결 요약
원인 0: 서버 스펙
- 문제: 개발 환경(4GB 메모리)과 운영 환경(8GB 메모리) 간의 스펙 차이로 메모리 부족 발생.
- 해결 방법: 서버 스펙을 늘리는 대신 코드 최적화로 문제 해결을 시도.
원인 1: 데이터베이스 read/write/update의 과도한 사용
- 문제: 메모리와 성능을 희생하면서 Redis를 사용해 메시지 상태를 관리.
- 해결 방법: 데이터베이스 연동 로직을 임시적으로 제거하여 메모리 사용 감소.
- 결과: 최소 메모리 사용량 감소.
원인 2: 무분별한 변수 및 버퍼 생성
- 문제: 매 요청마다 새로운 버퍼를 생성, 불필요한 메모리 누수 발생.
- 해결 방법:
- 버퍼 풀링(Buffer Pooling) 도입.
- 버퍼 객체 재활용 및 불필요한 변수 생성 최소화.
- 결과: 외부 메모리(external memory) 사용량 안정화.
원인 3: FCM 응답 지연으로 인한 메모리 누적
- 문제: 가짜 device-id 사용으로 FCM에서 응답 지연 발생 → Active handles, Active requests 증가.
- 해결 방법:
- 단건 전송 대신 다건 전송
- 큐(queue)를 사용하여 메시지를 배치 전송.
- 제한점: Firebase 내부에서 여전히 메시지를 건별 처리.
- 응답 대기 시간 초과 시 요청 취소
- http.request와 setTimeout을 사용해 타임아웃 처리.
- 제한점: Active requests 문제 해결 불가.
- 메모리 상한 설정 및 자동 재기동
- Node.js 실행 시 --max-old-space-size로 메모리 제한.
- PM2의 max_memory_restart 옵션으로 메모리 초과 시 프로세스 재기동.
- 결과: 메모리 누수를 효과적으로 관리하고 안정적인 서비스 운영.
- 단건 전송 대신 다건 전송
운영을 하며 가장 머리가 아팠던 기간이지 않을까 싶습니다. 하지만 새로운걸 배우는 즐거움은 있었습니다. 해결방법을 찾아나가는 과정이 재밌었습니다.
'개발 프로젝트' 카테고리의 다른 글
[PyQt5] 자동 배포 프로그램 (2) | 2024.01.03 |
---|---|
[PyQt5] 성경 프롬프터 프로그램 (32) | 2023.11.29 |
[Programmers] 숫자 카드 나누기 -Javascript (1) | 2023.10.28 |
Chrome Extension - Html Tag wrapper (0) | 2023.10.27 |
아파트 매물 알리미 - NestJs / ReactJs (0) | 2023.10.14 |