# 2024. 06.

# 06. 02.

# Vercel, supabase 등 Serverless 기반 서비스에서 ColdStart 이슈 해결

서비스를 배포하고 사용률이 저조할 때 들어가면 로딩속도가 현저히 느려지는 이슈가 있었다.

영상에서는 30분동안 사용률이 없을 때 접속해보았고, 긴 검은화면 대기 시간과 각 지역에 표시되는 숫자 로딩시간이 길었다.

측정을 해본 결과 Time To First Byte(TTFB)가 1.38s, 각 지역에 등록된 게시물 개수를 불러오는 API 응답 속도가 약 8s가 나왔다.

그림 1. TTFB.
그림 2. API 응답 시간.

반면, 사용률이 10분 이내에 있을 때는 응답 속도가 빨랐기에, 직감적으로 Cold Start 이슈임을 확신할 수 있었다.

Vercel에서도 자체적으로 ColdStart 이슈를 해결하는 방법 (opens new window)을 제시한다.

여기서는 8가지 방법을 제시하는데, 그 중 과금을 안들이는 방법중 가장 현실적인 방법 3가지를 적용 시켜볼려고 한다.

  • Use dynamic imports
  • Choose smaller dependencies inside your functions
  • Prewarm your functions using cron jobs

요약하자면, 번들 사이즈를 줄여 서버의 부팅시간을 줄여 주는 방법과 지속적으로 서버에 요청을 날려 warm 상태를 유지해주는 방법이다.

# Use Dynamic Imports

실제 바로 로딩이 필요 없는 컴포넌트가 Modal 밖에 없었는데 Dynamic Import로 바꿔서 적용시켜봤으나, 이 때 사용하는 dynamic 패키지가 더 커서 그런지 번들 사이즈가 오히려 더 커졌다.

# Choose smaller dependencies inside your functions

사용하지 않는 파일 import를 모두 제거해주었고 bundle-analyzer를 돌려본 결과 Lottie 패키지가 커서 이를 경량화 버전으로 교체해주었다.

그림 3. Lottie 패키지 경량화 전.
그림 4. Lottie 패키지 경량화 후.

Parsed Size 298kb와 Gzip 74kb에서 각각 168kb, 46kb로 미세한 개선이 이루어졌다.

# Prewarm your functions using cron jobs

서버에 주기적으로 요청을 보내 warm 상태를 유지하는 방법이다.

Vercel에서 기본적으로 제공해주는 Cron 서비스는 일 1회만 기본 제공되며, 초과 요청시 Pro Plan이 요구된다.

먼저 NextJS에 Cron 관련 API를 증설 해주었다.

export const dynamic = 'force-dynamic'

import { NextResponse } from "next/server";
import { InternalServerError } from "../error/server/InternalServer.error";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient()

export async function GET() {
  try {
    await fetch(`https://a-spot-thur.app`)
    await prisma.spot.count() // supabase warming용

    return new NextResponse(
        JSON.stringify({ data: true, message: "Cron Successfully Prewarming." }),
        { status: 200, headers: { "content-type": "application/json" } }
    )

  } catch (e) {
    return InternalServerError(e);
  }
}
  • Line 1 해당 요청이 캐싱되지 않게 하기 위함.
  • Line 11 TTFB용 API
  • Line 12 Supabase (Serverless Database)에 요청을 날려 Warm 상태 유지

그 후, 아직 서비스가 Pro Plan을 필요로 하지 않는 점에서 AWS에서 제공하는 Event Bridge와 Lambda를 사용하였다.

Event Bridge가 3분 간격으로 AT에 요청을 보내는 역할을 수행하는 Lambda 함수를 호출하게 하여 구축하였다.

그림 5. AWS Event Bridge 구축 화면.

# 결과

위와 같은 작업을 거친 후 사용률이 없을 때 다시 성능 측정을 해보았다.

서버를 항상 Warm 상태로 유지하여 사용률이 없을 때에도 빠른 응답 속도를 유지할 수 있었으며, TTFB는 약 110ms, API 응답 시간은 약 400ms로 사용자 경험을 크게 개선할 수 있었다.

그림 6. 개선 후 TTFB.
그림 7. 개선 후 API 응답 속도.

# 06. 27.

# Event Loop란

Javascript가 비동기 작업을 관리하기 위한 구현체

우리가 흔히 쓰는 브라우저에서 실행되는 자바스크립트의 환경은 다음과 같다.

출처 : https://dev.to/jasmin/difference-between-the-event-loop-in-browser-and-node-js-1113 그림 1. 브라우저 JS 환경.

자 이제 자바스크립트는 동기적으로 동작하는데 비동기 작업은 어떻게 처리하나? 실제 자바스크립트 엔진인 V8은 여기서 Call Stack을 담당하고 동기적으로 실행된다. (Call Stack는 현재 실행 중인 함수의 정보를 저장하는 자료구조이다.)

정답은 WEB API에게 있다.

브라우저는 실제로 멀티쓰레드로 동작하며 비동기 작업을 수행할 수 있는데 브라우저에서 직접 비동기 작업을 위임할 수 있도록 WEB API을 제공한다. V8엔진은 WEB API를 통해서 비동기 작업을 브라우저에게 위임하여 수행한다.

그럼 이후에는 어떻게 되는가?

브라우저에서 처리를 마친 비동기 작업은 V8에서 관리하는 Micro Task Queue나 Macro TaskQueue (이하 Task Queue)에게 결과값을 넘기는데, 이 때 Queue에 있는 값들을 Call Stack에 넘기는 역할을 이벤트 루프가 담당한다.

더 상세하게 접근해보자

브라우저에서 비동기 작업을 처리하고 Micro Task Queue나 Task Queue에 넘긴다고 했는데,

비동기 작업의 종류에 따라 나뉜다.

Micro Task Queue

  • Promise에서 then, catch, finally
  • new MutationObserver
  • await
  • queueMicroTask

Task Queue

  • Web API Callback
  • Event Handler
  • setTimeout

Micro Task Queue가 Task Queue보다 우선순위가 높다.

Event Loop는 항상 Micro Task Queue와 Task Queue를 지켜보고 있으며,

Call Stack이 비었을 때 Micro Task Queue > Task Queue 순으로 완료된 작업을 Call Stack으로 옮긴다.

new Promise((res, rej)=>{
	console.log(1)
  res()
}).then(()=>console.log(2))

setTimeout(()=>console.log(3), 0)

Promise.resolve().then(()=>console.log(4))

queueMicrotask(()=>console.log(5))

console.log(6)

위 코드의 출력 순서는 다음과 같다.

1 6 2 4 5 3 

# 그러면 NodeJS 환경에서의 EventLoop는?

이 때까지는 브라우저 환경에서의 이벤트 루프였고,

NodeJS 환경은 브라우저가 존재하지 않으므로 WEB API 역시 존재하지 않는다. 그럼 어떻게 비동기 작업을 수행할까?

출처 : https://www.c-sharpcorner.com/article/node-js-event-loop/ 그림 2. NodeJS 환경.

NodeJS는 플랫폼에서 제공하는 라이브러리를 사용하며, 대표적으로 Libuv (비동기 I/O 라이브러리)를 사용한다.

또한, 브라우저 처럼 Queue가 2개만 존재하는게 아니라 여러개가 존재한다.

출처 : https://www.korecmblog.com/blog/node-js-event-loop/ 그림 3. NodeJS의 EventLoop 사이클.

위 사진은 이벤트 루프의 한 사이클이다. 브라우저에서는 이벤트 루프가 실행될 때 2개의 Queue만 탐색 했지만,

NodeJS 환경에서는 8개의 Queue가 존재한다.

Timer부터 사이클이 시작되며 각각 페이즈라고 부르고 담당하는 비동기 작업이 다르다.

Timer : setTimeout, setInterval 등
Pending Callbacks : 이전 이벤트 루프에서 실행되지 못한 콜백
Idle, Prepare : 내부적인 관리를 위한 페이즈
Poll : Timer를 제외한 비동기 I/O작업 (fetch, readFile 등)
Check : setImmediate를 위한 페이즈
Close : 종료된 핸들러의 콜백을 처리하는 페이즈 ( socket.onClose, file.close 등)

nextTickQueuemicroTaskQueue는 우선순위가 가장 높으며 현재 페이즈와 상관없이 수행하고 있는 작업이 끝나면 그 즉시 실행된다.

nextTick이 microTask 보다 높다.

nextTick : process.nextTick() 콜백 관리
microTask : Promise 관련 콜백