Node.js 모니터링 앱 로직, 30분을 30초로 줄이기까지

최근, 구름IDE의 가격 정책을 개편했습니다. 사용하지 않은 스펙과 리소스에 대한 비용을 지불해야 하는 기존의 플랜형 구독제를 사용자가 이용한 만큼만 과금하는 종량형 요금제로 변경했습니다. 그러기 위해서는 모든 컨테이너들의 사용량을 분 단위로 모니터링해야 했습니다.

즉, 우리에게 허용된 모니터링 앱 로직의 실행 시간은 단, 1분 미만인 것이었죠. 그렇게 우리의 성능 최적화가 시작됐습니다. 이 글은 가장 처음에 아이디에이션(Ideation) 했던 방법으로는, 처참하게도 30분이나 걸렸던 모니터링 로직과 설계를, 결국 초 단위로까지 줄이는 데 성공한 과정의 기록입니다.

written Lia, Wynter
editor Snow

☁️ 구름IDE란?
구름IDE는 구름에서 제공하는 클라우드 기반의 클라우드 개발 환경(CDE)입니다. 컨테이너라고 부르는 기술을 통해서 클라우드 기반의 개발 환경을 제공해 언제나 어디서든 웹페이지에서 다양한 디바이스로 프로그램을 개발할 수 있습니다.

우선 오래 걸리는 모니터링 앱의 로직이 어디인지 파악해야 했습니다. 문제의 원인을 찾고자 로직들을 나눠서 모든 로직에 대한 시간을 측정했습니다. 

Node.js 앱에서 특정 부분의 작업 시간을 측정하는 방법 중 console.time()과 console.timeEnd()를 사용했습니다. 타이머를 시작하려는 부분에 console.time()을 넣고, 괄호 안에 적당한 이름의 label을 설정하고 메소드를 호출하면 시작됩니다. 작업이 종료되는 부분에서 타이머를 멈추고, console.timeEnd() 메소드를 호출해 소요 시간을 출력합니다. 이때 작업 시간을 측정하는 두 타이머의 label은 같아야 합니다. 

console.time("A로직");
await doAFunc();
console.timeEnd("A로직");

// Result
// A로직: 1:23.986 (m:ss.mmm)

1. DB 인덱싱

const 컨테이너_정보 = [
{ name: 'containerA', user: 'wynter' },
{ name: 'containerB', user: 'lia' },
{ name: 'containerC', user: 'snow' }
];

이와 같이 컨테이너 정보를 담은 배열이 있다고 가정해 봅시다. 이 컨테이너 정보 배열에서 특정 컨테이너의 정보를 조회하려면, 배열의 모든 컨테이너 정보를 순회하며 데이터베이스를 조회해야 합니다. 아무 처리도 들어가지 않은 코드에서 하나의 컨테이너를 조회하는 데에는 약 0.54초가 소요됐습니다. 모든 컨테이너를 조회해 보니 22분이나 걸렸죠. 다른 로직도 넣어야 하는데, 데이터베이스 조회만 22분이 걸리는 건 개선이 시급했습니다.

Execution Time: 547.969ms

데이터베이스에는 인덱스(Index)라는 개념이 있습니다. 인덱스는 데이터베이스 테이블의 검색 속도를 높여주는 자료 구조를 일컫습니다. 인덱스는 테이블 내 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있습니다. 데이터를 빠르게 찾을 수 있게 해줄 뿐 아니라, 데이터를 효율적으로 정렬하고 접근하는 데도 도움을 줍니다.

이 인덱스는 무조건 넣는 것이 좋아 보이지만 삽입, 수정, 삭제가 무수히 많이 일어나는 테이블에 무분별하게 사용하면 오히려 성능 저하를 일으킬 수 있습니다. 사용에 주의해야 하죠.

MongoDB Index

MongoDB 공식 문서를 토대로 쉽게 설명하자면, 인덱스는 데이터의 순서를 미리 정렬해둔 것입니다. 위 그림에서 { userid: 1, score: -1 } Index일 때, userId는 오름차순으로, score는 내림차순으로 정렬되어 있습니다.

공식 문서에서는 복합 인덱스 이용 시 필드 순서(field order), 정렬 순서(sort order) 등을 고려하라고 명시되어 있습니다. 필드 순서가 달라지면 정렬이 다르게 되고, 오름차순과 내림차순이 달라져도 다른 정렬이 되니까요.

이런 점을 신경 써서 컨테이너 정보 컬렉션에 원하는 방향으로 정렬이 되도록 인덱스를 추가하니, 컨테이너 정보 조회 속도가 2배 이상 빨라졌습니다. 기존에 한 컨테이너 조회에 약 0.54초의 시간이 걸리던 것을 약 0.2초까지 줄일 수 있었죠.

Execution Time: 200.283ms

2. 병렬화

ESLint에는 no-await-in-loop라는 룰(Rule)이 있습니다. 빠르게 개발해야 하는 저희는 ‘일단 개발하고, 나머지는 기능이 완성되고 보자’라는 생각에서 린트 룰을 꺼 버리는 아주 큰 실수를 저질렀습니다. 린트 룰을 무시한 채로 개발했기 때문일까요? 컨테이너 정보 DB 조회 시간을 0.2초로 줄여도, 모든 로직 수행은 2분이 넘게 걸렸습니다.

no-await-in-loop는 ESLint 공식 문서의 첫 줄에 Disallow await inside of loops라고 나와있습니다. 해석하자면 반복문에서 await를 허용하지 않는다는 것입니다. 이렇게 하지 않으면 이전 작업이 완료될 때까지 다음 작업이 수행되지 않는다고 친절하게 적혀있습니다.

성능을 위해서라면 당연히 지켜야 하는 이 쉬운 룰은 다음과 같이 적용할 수 있습니다.

// 첫 번째 방식
for(let i=0; i<컨테이너_정보.length; i++) {
	await containerService.doSomething(컨테이너_정보[i].name);
	await userService.doSomething(컨테이너_정보[i].user);
}

// 두 번째 방식
const results = [];
const handleContainer = async (컨테이너_정보) => {
	await containerService.doSomething(컨테이너_정보.name);
	await userService.doSomething(컨테이너_정보.user);
}
for(let i=0; i<컨테이너_정보.length; i++) {
	results.push(handleContainer(컨테이너_정보[i]);
}
await Promise.all(results);

첫 번째 방식을 사용하면, 각 비동기 작업이 순차적으로 실행됩니다. 즉, 첫 번째 doSomething 호출이 완료될 때까지 두번째 호출이 시작되지 않습니다. 이로 인해 전체 실행 시간이 길어질 수 있습니다. 

반면, 두 번째 방식은 각 요소에 대해 handleContainer 함수를 호출하고 해당 호출의 Promise를 results 배열에 추가합니다. 그리고 Promise.all을 사용해 results 배열에 있는 모든 Promise가 완료될 때까지 기다리기 때문에 모든 handleContainer 호출이 병렬로 실행됩니다. 즉, 모든 비동기 작업이 동시에 시작되기 때문에 전체 실행 시간을 단축할  수 있습니다. 

단, 첫 번째 반복의 결과가 두 번째 반복에 쓰이거나, 반복에서 실패한 비동기 작업을 다시 시도해야 한다거나, 과도한 양을 병렬로 요청을 보내는 경우에는 사용에 주의해야 합니다.

해당 린트 룰을 켜고 함수를 수정한 뒤, 로직 수행 시간을 측정했습니다.

---- before ----
Execution Time: 2:41.013 (m:ss.mmm)

---- after ----
Execution Time: 28.454s

2분 41초에서 28초로 5배 이상 시간이 단축됐습니다. 단순히 코드를 정리하고 기본적인 규칙을 따르는 것만으로도 성능이 이렇게나 향상될 수 있는 것을 보며, 기본을 지키는 것이 얼마나 중요한 것인지 다시금 생각해 봅니다.

3. JSON.stringify

성능 개선이 한참일 무렵, 동료 개발자분들이 JSON.stringify 성능이 좋지 않다며 다른 Node.js 라이브러리를 추천했습니다. 크게 고민할 것 없이 typia, fast-json-stringify를 바로 적용해 봤습니다. fast-json-stringify 라이브러리는 스키마 기반 최적화를 통해 매우 빠르게 JSON 문자열을 생성합니다. 대량의 데이터를 직렬화해야 하는 경우 유용하죠.

라이브러리 적용 전 ----
로직1: 20.939ms
로직2: 769.971ms

라이브러리 적용 후 ----
로직1: 52.927ms
로직2: 779.874ms

여러 벤치마크 결과에서 이 라이브러리가 더 빠른 것을 보고 내심 기대했지만, 저희 데이터에서는 라이브러리 적용 후 미묘하게 더 느려서 적용하지 않았습니다.

4. redis scan

Node.js 앱 각각의 정보 동기화를 위해 외부 저장소로 redis를 사용하고 있었는데요. 이 레디스 입출력 과정이 데이터 규모가 커지자 너무 느려졌습니다. 기존에 약 1초 걸리던 로직이 40초나 걸렸습니다.

저희는 Redis에서 정보 조회를 할 때, 성능을 고려해서 keys 대신 scan을 사용했었습니다. 공식 문서에서도 대규모 데이터베이스에서는 keys 대신 scan을 사용하라고 권장하고 있고요. Redis 자체가 싱글 스레드로 동작하기 때문에 블로킹의 영향으로 성능 이슈가 발생할 수도 있기 때문입니다. scan은 다음과 같이 사용할 수 있습니다.

let cursor = '0';
do {
	const [nextCursor, matchedKeys] = await node.scan(
		cursor,
		'MATCH',
		pattern,
	);
	cursor = nextCursor;
	keys = keys.concat(matchedKeys);
} while (cursor !== '0');

주의사항을 지켰기 때문에 이것은 문제의 원인은 아니었습니다. Node.js 앱이나 Redis를 모니터링해도 로직 수행에 과부화가 있진 않았습니다. 병렬 처리도 잘 적용되어 있었고요.

결국, redis-cli로 직접 똑같이 로직을 수행해봤습니다. 수행 결과는 공식 문서 결과로 대체합니다.

redis 127.0.0.1:6379> scan 0
1) "17"
2)  1) "key:12"
    2) "key:8"
    3) "key:4"
    4) "key:14"
    5) "key:16"
    6) "key:17"
    7) "key:15"
    8) "key:10"
    9) "key:3"
   10) "key:7"
   11) "key:1"
redis 127.0.0.1:6379> scan 17
1) "0"
2) 1) "key:5"
   2) "key:18"
   3) "key:0"
   4) "key:2"
   5) "key:19"
   6) "key:13"
   7) "key:6"
   8) "key:9"
   9) "key:11"

두 번째 호출인 scan 17에서 17은 이전 호출에서 반환된 커서인 17입니다. scan 17의 결과가 0이기 때문에 서버는 컬렉션이 완전히 탐색되었다고 호출자에게 신호를 보냅니다.

공식 문서에서는 데이터의 수가 별로 되지 않아서 두 번만에 호출이 끝났지만, 저희는 데이터가 많았습니다. 실제로 해보니까 수작업으로는 찾기 힘들었습니다. 그래서 전체 데이터 수만큼은 아니되, 호출 수를 줄일 수 있게 COUNT 옵션으로 스캔 작업량을 지정했습니다. COUNT 옵션은 scan 명령어가 한 번에 반환하는 항목 수를 정할 수 있는 옵션입니다. Redis 서버 성능 최적화를 위해 사용할 수 있는 값으로 기본값은 10입니다.

let cursor = '0';
do {
	const [nextCursor, matchedKeys] = await node.scan(
		cursor,
		'MATCH',
		pattern,
		'COUNT',
		count
	);
	cursor = nextCursor;
	keys = keys.concat(matchedKeys);
} while (cursor !== '0');

이렇게 count 옵션으로 작업량을 늘리니, 로직 실행 시간이 다시 1초대로 줄었습니다.

그렇게 우리는 30분이라는 벽을 깨뜨렸다

급할수록 돌아가라는 말이 있습니다. 돌이켜보면, 공식 문서를 잘 읽고 기본을 잘 지켰다면 대부분 겪지 않았을 문제들이었습니다. 구조 설계 경험이 부족한 것도 절절히 느꼈죠.

아무리 꼼꼼히 해도 B 상황 대응할 수 있을까? C 상황은? 스스로 물으며 기획이나 구조를 빈번히 변경해야 했습니다. 또, 어떤 식으로 데이터를 수집할지, 어떤 식으로 앱을 실행하는 것이 좋을지, 장애 대응은 어떻게 할 것인지 등 아직 최적의 답을 찾지 못한 것들도 있습니다.

때론 막막하기도, 벽을 느끼기도 했지만 함께한 동료가 있어 컨테이너 사용량을 무사히 모니터링하고 가격 정책을 개편할 수 있었습니다. 이 글을 계기로 동료들에게 감사의 마음 전합니다.

“감사합니다!”

Posted by
wynter.kim

구름 GDS TOOLS 스쿼드의 리더를 맡고 있습니다. 든든한 개발자가 되기 위해 노력하고 있습니다.