Serverless 에 대해서 공부해야겠다고 마음을 갖게 된 계기는 블로그의 성능을 개선하는 과정에서 겪었던 일에서부터 시작 되었습니다.
저의 블로그는 분명 개념적인 부분에 있어서는 블로그 포스트를 읽어오는 속도가 느릴 이유가 없다고 생각 했습니다. 하지만 실제 Vercel에 배포되어 있는 실제 사이트의 페이지 간 이동 속도가 대략 1~3초 사이의 시간이 걸린다는 문제가 있었습니다. 이 시간은 사용자가 지루해하고 사이트가 정상적으로 동작하지 않는다고 생각하기에 충분한 시간이기 때문에 해결해야 한다고 생각 했습니다.
그래서 저는 Worker를 사용하여 기존의 I/O작업의 효율성을 더 증진 시켜보자고 생각 했습니다. 저는 Next.js의 Server Action 함수를 이용해 .mdx 파일을 읽어오는 작업을 수행하고 있었습니다.
Promise.all을 사용하여 폴더 내에 있는 .mdx 파일을 읽어오는 작업을 병렬로 처리하고자 했습니다. 폴더 내부에 있는 파일들은 70여개의 파일만 있었기 때문에 웹 페이지 이동 시에 실행이 되더라도 1s 이내에 작업을 완료할 것이라고 생각 했지만 배포된 결과는 그렇지 않다는 문제가 있었습니다.
그래서 Worker를 사용하여 문제를 해결하고자 했습니다. Worker를 사용해도 되겠다고 판단한 이유는 각 폴더는 독립적이고 기존에도 포스트의 카테고리 별로 데이터를 관리하였기 때문에 Worker가 유용할 것이라고 판단 했습니다.
Worker를 사용한 결과에 대해서 추적하기 이전에 공부를 하다보니 생긴 의문점이 있었습니다. Promise.all을 사용하는 것이 반드시 효율적인지에 대한 의문이 들었습니다. 그래서 여러 경우의 코드를 작성하고 밴치마크를 해보았습니다.
IO 작업 벤치마킹 해보기
파일 생성 Shell Script
우선 테스트를 위한 스크립트 부터 작성하였습니다. 위에 작성한 쉘 명령어는 100000개의 1000줄의 글이 작성되어 있는 텍스트 파일을 생성하는 작업을 수행합니다. 이 명령어를 작성하면서 공부 했던 내용도 간단하게 정리하고 넘어가겠습니다.
yes $LONG_TEXT 명령어는 기본적으로 무한히 "y"를 출력하는 명령어인데, 여기에 인자를 주면 그 인자를 무한히 반복 출력합니다. 위의 명령어는 $LONG_TEXT를 인자로 주었기 때문에 이 문자가 무한히 생성되는 작업을 수행합니다.
head -n 1000명령어는 앞서 실행했던 무한히 출력되는 결과물 중 상위의 결과 1000줄을 받고 나면 종료되는 필터입니다. 하지만 지금까지의 설명만 본다면 작업이 어떻게 수행되는지 의아할 수 있습니다. 무한히 출력되는 명령어를 어떻게 1000개만 잘라서 수행할 수 있는 이유는 |(pipeline) 명령어 덕분입니다.
리눅스에서 파이프라인(|) 는 전체 데이터를 한꺼번에 모아서 처리하는 것이 아니라, 한 줄씩 데이터를 넘겨 받으며 스트리밍 방식으로 처리합니다. 위의 명령어는 A의 stdout 을 B의 stdin으로 전달하고 B의 stdout을 C의 stdin으로 병렬로 한줄씩 처리하게 됩니다. 이를 파일을 생성하는 명령어 적용하면 다음과 같은 과정을 처리하게 됩니다.
yes는 $LONG_TEXT를 stdout으로 계속 작성합니다.
head는 그 스트림에서 한 줄씩 읽습니다.
head가 1000줄을 읽고 나면 head는 스스로 종료합니다.
파이프가 끊기면 yes는 Broken pipe 신호(SIGPIPE) 를 받아서 종료됩니다.
그 다음, head -n 1000이 출력한 1000줄 (abc\nabc\n...)은 tr -d '\n'로 전달되고, 여기서 줄바꿈 문자를 모두 제거하여 하나의 긴 문자열로 만듭니다.
쉘(Bash, Zsh 등)은 사용자의 명령을 파싱해서 프로세스를 생성하고 표준 입출력(IO) 을 연결해주는 역할을 합니다. 대부분의 유닉스 도구들 예를 들어 cat, grep, head는 전체 데이터를 한꺼번에 메모리에 올리지 않고, 한 줄 또는 한 블록씩 스트리밍으로 처리를 합니다.
다음은 리디렉션 명령어입니다. 리다이렉션 명령어는 프로그램 실행 후 출력 결과, 커멘드 실행 후 출력 결과를 특정 파일에 로그처럼 남기고 싶은 경우 리디렉션 명령어를 사용하여 실행 결과를 특정 파일에 저장할 수 있습니다. 이외의 쉘 명령어를 이해하기 위한 기초 지식들은 다음 포스트에서 자세하게 다루어보겠습니다.
Promise.all을 사용하는 것이 반드시 효율적인가?
Promise.all 메서드는 iterable 을 매개 변수로 받고 매개변수로 객체 내부의 Promise의 상태들이 모두 fulfilled 상태를 만족하기 이전까지 iterable로 주어진 Promise 내부의 비동기 함수를 수행합니다.
iterable로 주어진 작업을 수행하는 과정에서 오류가 발생할 경우, reject되었을 경우에는 어떻게 될까요? Promise.all은 한 가지 작업이라도 오류가 발생한다면 모든 작업을 무시합니다. 여기서 주의할 점은 이미 진행중인 Promise 들을 강제로 종료하는게 아니라 계속 실행되지만 결과를 무시한다는 점입니다. 이러한 부분은 Promise.allSettled과 대조되는 부분입니다.
Promise.all 메서드를 사용하여 작업을 수행하였을 경우의 총 수행 시간은 위의 그림과 같습니다. iterable 내의 작업 중 작업 처리 시간이 가장 긴 작업시간이 종료되기 이전에 Promise.all의 작업은 완료되지 않습니다. 또한 각각의 Promise 들은 다른 Promise의 진행 상황에 영향을 받지 않고 병렬적으로 작업이 처리가 됩니다.
병렬로 작업을 처리하기 이전에 위의 코드의 결과를 살펴보겠습니다. 위의 코드는 비동기적으로 파일 IO 작업을 처리하고 있지만 먼저 수행하는 작업이 종료되기를 기다린 후 다음 작업을 수행할 수 있기 때문에 비효율적으로 코드가 작성되어 있습니다.
위의 결과는 모든 작업을 순차적으로 처리하기 때문에 앞으로 적용할 방법 중에서 가장 긴 처리 시간을 갖고 있습니다. 그럼 이제 이 작업을 Promise.all 을 사용하여 해결 해보겠습니다.
위의 코드는 기존에 파일 IO 작업을 병렬적으로 처리할 수 있게끔 수정한 것이고 결과를 봐보겠습니다.
위와 같이 처리시간이 많이 빨라진 것을 확인할 수 있습니다. 이러한 결과를 살펴보았을 때 여러개의 IO 작업을 처리할 경우에는 반드시 Promise.all 을 사용하는 것이 좋다고 이야기할 수 있을까요? 위의 결과만 보았을 경우에는 그렇게 말할 수 있지만 반드시 그런 것은 아닙니다.
위의 코드의 결과는 어떻게 될까요? 위의 로직에서는 Promise.all 을 사용하지 않고 비동기적으로 IO 작업을 수행하고 있기 때문에 비효율적으로 작업을 처리하고 있을까요?
위의 코드를 실행 했을 경우의 결과는 그렇지 않다는 것을 알 수 있습니다. Promise.all을 사용하지 않았지만 Promise.all을 사용한 것과 큰 차이가 없는 결과가 출력이 되었습니다. 이런 결과가 나온 것은 메인 스레드에서 files.map()은 각 파일에 대해 즉시 fs.readFile()을 호출합니다. readFile 메서드는 async 내부에서 호출이 되기 때문에 비동기 작업으로 전환되어 Promise를 반환하며 이 작업은 libuv Thread Pool에 등록이 됩니다. libuv는 I/O 바운드 작업을 위해 별도의 스레드 풀, 기본 4개의 스레드를 사용하여, 동시에 여러 작업을 실행합니다.
앞서 설명한 작업은 Promise.all 또한 libuv Thread Pool을 활용하여 병렬적으로 작업을 처리하기 때문에 Promise.all 을 사용하지 않았지만 유사한 결과가 나온다는 것을 확인할 수 있었습니다.
위에서 보았듯이 반드시 Promise.all을 사용하는 것이 작업을 효율적으로 처리하는 것은 아닙니다. 단순 병렬 처리를 원한다고 할 경우에는 Promise.all을 사용하지 않을 이유는 없지만 이러한 차이를 알고 사용하는 것과 그렇지 않은 것에는 차이가 있기 때문에 용도의 차이에 대해서는 알고 있는 것이 좋을 것 같습니다.
Promise.all 을 사용하였을 경우에는 병렬적으로 작업을 처리하는 것을 보장하며 itreable에 묶여 있는 Promise 작업들에 대한 에러 처리, 트랜잭션과 같이 일부 작업만 수행되지 않기를 원하지 않는 경우에 사용하기 적합하다는 특징을 갖고 있습니다. 이러한 작업에 부합할 경우, 단순 병렬 작업만을 위한 용도로 Promise.all을 사용하는 것이 아니라는 것을 배울 수 있었습니다.
Worker를 사용하여 작업을 처리하기
메인 스레드에서 4개의 Worker를 사용하여 기존의 100000개의 문서를 읽어오는 작업을 분산하여 처리하고 각 Worker에서는 Promise를 사용할 경우와 동일한 읽기 작업을 수행하고 있습니다. 이 작업이 다른 작업과 어떤 차이가 있는지 살펴보겠습니다.
위의 결과를 보았을 때 기존의 읽기 작업이 대략적으로 두 배가량 빨라진 것을 볼 수 있습니다. 앞서 JavaScript가 비동기적으로 작업을 수행할 때 이벤틀르 기반으로 기본적으로 libuv의 4개의 Thread Pool을 이용하여 작업을 처라한다고 이야기 했습니다. 하지만 Worker를 사용할 경우 각 Worker는 독립적인 실행 환경(Context) 를 갖고 있습니다.
또한 Node.js의 Thread Pool은 별도의 V8 인스턴스와 함께 독자적인 이벤트 루프를 가집니다. 이 Thread Pool은 파일 시스템 작업, DNS 조회, 기타 블로킹 작업 등 libuv가 처리하는 비동기 작업들을 위해 사용되며, 메인 스레드의 Thread Pool과는 격리되어 있어 서로의 부하에 영향을 주지 않습니다.
이러한 이유로 인해서 앞서 제한된 libuv의 Thread Pool의 갯수의 제한에서 벗어나 더 효율적으로 작업을 처리할 수 있다는 장점이 있습니다. 하지만 Worker를 사용할 때는 주의해야 할 점이 있습니다. 이를 Serverless 와 함께 자세하게 설명 해보겠습니다.
Serverless 환경에서 Worker 사용하기
Worker를 사용할 때 일반적인 권장사항은 CPU 코어 수와 동일한 수의 워커를 생성하는 것입니다. 현재 재가 사용하고 있는 CPU는 8-Core Processor 이기 때문에 4개의 카테고리를 4개의 Worker를 사용하여 작업을 수행하기 때문에 이를 사용한다면 성능을 향상 시킬 수 있을 것이라고 생각 했습니다.
하지만 여기서 저는 배포 환경을 고려하지 않았다는 실수를 했습니다. 프로젝트를 배포할 경우에는 배포 환경이 어느정도의 작업을 처리가 가능한지를 고려했어야 했는데 이를 고려하지 않고 로컬 환경을 기준으로 생각을 했다는 것이 큰 실수였습니다.
vercel의 hobby plan의 CPU 는 0.6 vCPU 를 사용할 수 있다는 것을 확인할 수 있습니다. 여기서 vCPU란 클라우드 플랫폼에서 물리적 CPU의 자원을 가상화한 단위를 이야기 합니다. 보통 1 vCPU는 하나의 물리적 코어나 하드웨어 스레드에 대응됩니다.
0.6 vCPU는 60%의 CPU를 사용하다는 의미입니다. 전체 코어의 성능이 100% 일 경우 그 중 60%만 사용이 가능하다는 이야기입니다. 이 말이 의미하는 것은 하나의 전체 코어의 100% 성능을 독점적으로 사용할 수 없고 약 60%의 연산 능력을 할당받는다는 것을 의미합니다. 따라서 CPU 집약적 작업의 경우, 1코어 전체를 사용하는 것보다 성능이 낮을 수 있습니다.
vercel의 hobby plan의 배포환경은 1개의 CPU를 온전하게 사용하지 못하기 때문에 Worker를 사용하는 작업이 오히려 Promise.all을 사용하여 병렬 처리를 수행하는 것보다 더 느릴 수 있다는 것입니다. 이를 단순히 이론적으로 예측하지 않고 실제로 테스트를 진행해보았습니다.
처음에 Worker를 사용하지 않고 비동기 작업을 처리하는 시간으로 돌아왔습니다. 이 경우 오히려 Worker를 사용하는 것보다 못한 상황이 되었습니다. Worker를 생성하는 것도 결국 비용이 발생하고 작업 수행을 시작하고 종료할때 메시지를 전송해야 하기 때문에 메인스레드에서 작업을 수행할때보다 못한 작업 속도가 출력되는 것을 확인할 수 있었습니다.
그리고 앞서 이야기한 0.6vCPU 에서 4개의 Worker을 이용하여 작업을 수행할 경우 결과는 더 심각해집니다. Worker가 할당받는 CPU 리소스는 극히 제한적이게 됩니다. 하나의 전체 코어를 사용할 수 없기 때문에, 4개의 Worker가 동시에 실행되면 각각 약 15% 정도의 CPU 리소스를 받게 되는 셈입니다. 그렇기 때문에 많은 수의 Worker를 사용함으로 인해서 오히려 작업당 연산 속도가 떨어지고, 경우에 따라 CPU 리소스를 분할해야 하므로 순차적으로, 동기적으로 작업을 처리하는 것보다 전체 실행이 길어질 수도 있습니다.
Serverless의 특징
재가 겪었던 문제는 여기에세 끝나지 않았습니다. Serverless 라는 의미는 서버가 없다는 뜻은 아닙니다. 이는 서버를 24시간 항시 대기 시키지 않아도 된다는 것을 의미합니다.
서버리스는 FAAS(Function as a Service), 함수를 서비스로 제공하는 것을 기본으로 합니다.
위의 그림은 서버 인프라의 추상화 수준에 따라 개발자의 비즈니스 로직 집중도와 스택 구현 제어의 권한이 어떻게 달라지는 지를 시각화한 다이어그램입니다. 이 그림에서 작성되어 있는 기술의 스택을 간단하게 설명하겠습니다.
Bare Metal Server : 물리 서버를 직접 다루는 가장 낮은 추상화 단계의 서버입니다. 모든 인프라 네트워크, OS, 보안, 패치 등을 직접 관리하고 비즈니시 로직보다는 서버 유지에 많은 비용을 사용합니다.
Virtual Machine : 하이퍼바이저 위에서 여러 가상 서버를 운영하며 물리 서버보다 유연하지만, 여전히 서버의 관리를 필요로 하는 추상화 단게입니다.
Container : 애플리케이션과 그 환경을 격리된 컨테이너로 배포합니다. 인프라의 일부는 자동화가 가능하고 높은 유연성과 이식성을 갖고 있다는 특징을 갖고 있습니다.
Serverless : AWS Lambda와 Vercel Functions등이 포함이 되며 서버를 직접 구성하지 않습니다. 요청이 들어오면 자동으로 실행되는 함수 단위의 운영이 이루어집니다.
위의 추상화 단계에 대한 다이어그램을 통해서 알 수 있듯이 완벽한 서버 인프라는 존재하지 않습니다. 그렇기 때문에 우리는 각각의 상황, 요구가 어떤 것인지를 판단하여 상황에 맞는 수준의 기술을 사용하기 위해 노력해야 합니다. 이제 다시 주제인 Serverless에 대해서 이야기 해보겠습니다.
앞서 Serverless 는 FAAS로 운영이 된다는 이야기를 했습니다. Serverless 가 이런 식으로 운영이 가능한 이유는 EDA(Event Driven Architecture) , 사용자의 이벤트를 기반으로 작업을 수행하기 때문입니다. Serverless 라고 서버가 필요하지 않은 것은 아닙니다! 반드시 서버는 존재해야 합니다.
하지만, Serverless는 사용자의 인터렉션으로 인한 Event가 발생하였을 경우에만 해당 로직에 필요한 Function 을 호출하고 해당 Function에 대한 결과를 Storage로 저장하는 방식으로 동작을 합니다. 이러한 특징 덕분에 Server는 항상 유지될 필요가 없이 Event가 발생하였을 경우에만 동작하면 되기 때문에 Serverless 라는 용어가 사용되는 것입니다.
이러한 특징이 갖는 장점은 명확합니다. 서버를 계속 유지하고 있지 않기 때문에 비용이 저렴하다는 것입니다. 또한 Serverless의 경우 Server에 관한 로직은 AWS와 같은 큰 기업에서 관리하고 오직 비즈니스 로직만 고려하기 때문에 확장에 유리하다는 장점을 갖고 있습니다.
하지만 Serverless는 Function 의 Timeout 문제가 존재합니다. timeout 상태 비저장 컨테이너로 잠시동안 분리된 후 삭제됩니다. 그러니 실행 코드가 그 시간 안에 완료되지 않으면 앱이 실패할 수 있다는 것입니다. 이로 인해 함수로 인해 발생한 결과나 인스턴스를 유지하지 않습니다. 또한 Serverless는 잠들어 있는 Server를 깨우는 작업이 필요하기 때문에 ColdStart 초기 요청에 대한 응답 시간이 길어질 수 있다는 문제를 갖고 있습니다.
환경을 고려하지 않은 결과
재가 Worker를 사용하여 얻고자 하는 효과는 명확했습니다. IO 작업으로 인한 시간을 단축시키고 Singleton을 이용하여 단일 인스턴스를 사용함으로써 함수를 재사용하여 성능을 개선하려고 하였습니다.
하지만, 앞서 재가 이야기한 것과 같이 저는 환경을 고려하지 않고 이론적으로만 접근하여 적용한 결과는 돌고 돌아 다시 제자리 걸음이었습니다. Worker를 사용하였을 경우에는 오히려 속도가 저하되었고 여러 캐싱 기법 LRU Cache, Next.js 에서 제공하는 Caching 기법을 사용하여 봤지만 전혀 유의미한 결과를 얻지 못했습니다.
이러한 결과는 어찌보면 당연했습니다. Serverless 는 함수 단위로 동작하고 유지되지 않기 때문에 Singleton을 이용한 단일 인스턴스를 사용하지 못하기 때문에 당연히 서버에서 유지하고 관리하는 Cache 또한 성능을 개선시키지 못하고 오히려 느리게 만들 수 밖에 없었습니다.
이번 일로 겪게 된 교훈은 명확했습니다. 환경을 고려한다는 것은 굉장히 중요한 사항이라는 것과 이론상 가능할 것이라고 생각하고 바로 적용할 것이 아닌 예상되는 상황을 구축하고 벤치마킹을 하는 등의 검증을 거친 후 적용하는 것이 길을 잃지 않게 할 수 있는 지표가 될 수 있다는 것을 배울 수 있었습니다.
Concurrent readFile with Promise.all: 13.577sConcurrent readFile with Promise.all: 13.096sConcurrent readFile with Promise.all: 13.469sConcurrent readFile with Promise.all: 13.324sConcurrent readFile with Promise.all: 12.651sConcurrent readFile with Promise.all: 12.159sConcurrent readFile with Promise.all: 12.633sConcurrent readFile with Promise.all: 12.434sConcurrent readFile with Promise.all: 12.221sConcurrent readFile with Promise.all: 12.241s
10000개의 파일을 처리: 14.315s10000개의 파일을 처리: 13.897s10000개의 파일을 처리: 14.147s10000개의 파일을 처리: 14.473s10000개의 파일을 처리: 15.334s10000개의 파일을 처리: 14.181s10000개의 파일을 처리: 14.376s10000개의 파일을 처리: 13.466s10000개의 파일을 처리: 14.348s10000개의 파일을 처리: 14.275s
Worker [50000 ~ 75000] 파일 읽기: 5.445sWorker [0 ~ 25000] 파일 읽기: 5.943sWorker [25000 ~ 50000] 파일 읽기: 6.062sWorker [75000 ~ 100000] 파일 읽기: 6.469sWorker 작업 수행 시간: 7.252sWorker [0 ~ 25000] 파일 읽기: 4.534sWorker [75000 ~ 100000] 파일 읽기: 5.525sWorker [25000 ~ 50000] 파일 읽기: 6.184sWorker [50000 ~ 75000] 파일 읽기: 6.418sWorker 작업 수행 시간: 6.905s
Worker [0 ~ 100000] 파일 읽기: 15.976sWorker 작업 수행 시간: 16.026sWorker [0 ~ 100000] 파일 읽기: 15.837sWorker 작업 수행 시간: 15.883sWorker [0 ~ 100000] 파일 읽기: 16.350sWorker 작업 수행 시간: 16.394sWorker [0 ~ 100000] 파일 읽기: 17.016sWorker 작업 수행 시간: 17.061sWorker [0 ~ 100000] 파일 읽기: 16.807sWorker 작업 수행 시간: 16.854sWorker [0 ~ 100000] 파일 읽기: 17.226sWorker 작업 수행 시간: 17.284s
Worker [50000 ~ 75000] 파일 읽기: 20.062sWorker [25000 ~ 50000] 파일 읽기: 20.113sWorker [75000 ~ 100000] 파일 읽기: 20.708sWorker [0 ~ 25000] 파일 읽기: 20.806sWorker 작업 수행 시간: 21.756s
#!/usr/bin/env bashNUM_FILES=100000TARGET_DIR="./longtext-files"LONG_TEXT="longonglndinfdfkjds"mkdir -p "$TARGET_DIR"LONG_TEXT_REPEATED=$(yes "$LONG_TEXT" | head -n 1000 | tr -d '\n')echo "총 $NUM_FILES 개의 파일을 생성합니다."for ((i = 0; i < NUM_FILES; i++)); do echo "$LONG_TEXT_REPEATED" > "$TARGET_DIR/file-$i.txt" if ((i > 0 && i % 10000 == 0)); then echo "$i 개의 파일 생성 완료" fidoneecho "모든 파일 생성 완료!"
LONG_TEXT_REPEATED=$(yes "$LONG_TEXT" | head -n 1000 | tr -d '\n')