SSR 서버 최적화로 비용 아끼기
최근 프론트엔드 개발에서 사실상의 표준으로 자리매김 하는 기술이 있습니다. 바로 SSR, Server Side Rendering인데요. Next.js를 비롯한 다양한 프레임워크에서 SSR을 손쉽게 구축할 수 있는 솔루션을 제공하고 있습니다. 토스 또한 SSR을 도입하여 많은 사용자에게 빠르고 안정적인 경험을 제공하려고 노력하고 있는데요.
오늘은 SSR 아키텍처 운영을 위해 반드시 알아두어야 할, SSR 서버의 최적화와 관련된 이야기를 해보려 합니다. 최적화를 통해 토스는 서비스 운영에 필요한 SSR 서버의 수를 절감하여 비용을 개선할 수 있었습니다.
SSR이란?
SSR은 Server Side Rendering의 약자로, 화면의 렌더링이 서버에서 이루어지는 아키텍처를 의미합니다. 사전적인 의미는 이러하지만 일반적으로 현대의 SSR은 “첫 HTML 렌더링을 서버에서 처리하고, 이후의 렌더링 사이클은 클라이언트에서 처리”하는 하이브리드 형태의 SSR을 가리킵니다. Next.js, Astro 등의 현대적인 웹 프레임워크는 기본적으로 제공하는 아키텍처입니다. Static Site Generation이나 Dynamic SSR처럼 다양한 방식이 있습니다.
SSR은 “첫 HTML 렌더링을 서버에서 처리”하기 때문에, 사용자의 화면에 컨텐츠가 그려지는데 걸리는 시간(FCP, First Contentful Paint)가 더 짧습니다. 토스는 이전에 CSR(Client Side Rendering) 아키텍처를 사용하고 있었는데요. 사용자의 화면에 JavaScript 번들이 모두 다운로드된 다음 첫 렌더링을 실행하면서 인증, 데이터 요청 등의 과정을 거치다보니 화면이 렌더링되는 시간이 상대적으로 길었습니다.
이때 SSR 아키텍처를 도입하게 되면서, 인증과 데이터 처리의 첫 과정이 서버에서 먼저 모두 이루어진 다음 사용자는 완성된 HTML을 받아보면서 로딩 속도를 상당히 감축할 수 있었습니다.
하지만 공짜 점심은 없죠. SSR을 도입하면 SSR 서버를 구축해야 합니다. Static Serving으로 처리가 가능한 CSR 아키텍처에서는 별도의 로직을 구현하는 서버를 만들 필요가 없습니다. 요청자가 요청한 위치의 파일을 돌려주기만 하면 됩니다. 하지만 SSR은 요청에서 필요한 정보를 파악하고, 적절한 페이지 파일을 가져와 렌더링을 처리한 후, 완성된 HTML과 JS 번들을 돌려주어야 합니다. 다행히 토스에서 사용하는 Next.js는 SSR 서버 구축을 아주 쉽게 할 수 있도록 도와줍니다. 토스 앱의 동작에 필요한 몇몇 미들웨어와 라우팅 로직을 커스텀 서버에 구현하는 정도로 마무리할 수 있었으니까요.
// 서드파티 Server Framework(e.g. Express)를 사용할 수도 있습니다.
// Next.js에 기본적으로 내장된 서버는 built-in http module을 사용합니다.
const { createServer } = require('http')
const next = require('next')
const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()
app.prepare().then(() => {
createServer(async (req, res) => {
// 여기서 Next.js의 handle을 처리하게 됩니다.
})
.once('error', (err) => {
console.error(err)
process.exit(1)
})
.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`)
})
})
그러나 서비스의 성장에 따라 SSR 서버로 유입되는 트래픽이 늘어나면서, 서버의 숫자도 상당히 늘어나게 되었습니다. 또한 SSR 아키텍처의 빠른 로딩이라는 이점을 위해 SSR을 도입하는 서비스의 숫자도 점점 늘어났습니다. 이에 따라 SSR 서버의 숫자가 관리하기 어려울 만큼 많아졌고, 이들의 숫자를 줄여야 한다는 문제 의식이 발생합니다. 트래픽을 줄일 수는 없기 때문에, 같은 트래픽에서 서버의 숫자를 감축하는 것은 한 대의 서버가 감당할 수 있는 최대 트래픽의 수를 늘린다는 뜻이 됩니다.
SSR 서버 성능 측정하기
“측정”은 성능 개선 작업에 매우 필수적인 요소이자, 실제 개선보다도 어쩌면 더욱 중요한 부분입니다. 가장 먼저 저희 팀은 SSR 서버의 성능을 반복적으로 측정할 수 있는 환경을 마련하는데 집중했는데요. 프론트엔드 개발팀에서 서버 성능에 집중적으로 투자하는 첫 경험이다보니 여러 시행착오가 있었습니다. 이를 보완하기 위해 서버 플랫폼 팀과 주기적인 미팅을 진행하여 서버 운영 및 성능 측정에 필요한 다양한 노하우를 공유받았고, 성공적으로 성능 최적화의 기반을 마련할 수 있었습니다.
변인 통제하기
컨테이너는 격리된 환경이니 성능에 영향을 주는 요소가 덜할 것이라고 착각할 수 있습니다. 하지만 컨테이너 또한 프로세스이기 때문에, 해당 프로세스가 실행되는 호스트 환경에 따라 성능의 차이를 가질 수 있습니다. 처음 저희 팀은 개발 서버의 K8S 클러스터에 SSR 컨테이너를 배포하고 성능을 측정했는데요. 배포 시마다 성능이 달라지는 현상을 겪었고 DevOps팀과 확인 했습니다. 그 결과 개발 서버의 Pod Scheduling에 특별한 제한이 없어서, Pod이 어떤 Node에 배치되는지에 따라 성능에 편차가 발생할 수 있다는 사실을 알았습니다.
이 그래프는 소스코드의 아무런 수정없이 재배포 전후로 성능을 테스트한 결과입니다. 성능이 거의 2배 가까이 차이가 나는 것을 볼 수 있는데요. Pod이 얼마나 여유로운 Node에 배치되는지에 따라 성능 차이가 극심하게 발생할 수 있고, 이런 상황에서는 정상적인 변인 통제와 신뢰할 수 있는 성능 측정이 어려워집니다.
따라서 EC2 스팟 인스턴스를 하나 두고 해당 인스턴스에는 성능 측정만을 위한 Pod을 배포하도록 수정했습니다. 격리된 환경은 반복적인 배포에서도 서비스 소스코드의 변경을 제외한 어떠한 다른 변인도 존재하지 않기 때문에, 수정된 코드가 성능에 어떻게 영향을 미치는지 투명하게 확인할 수 있는 매우 중요한 환경이 됩니다.
RPS 이해하기
RPS, Request Per Second(혹은 TPS)는 서버의 트래픽 소화 능력을 확인하는 가장 중요한 지표입니다. 정해진 시간 내에 얼마나 많은 양의 요청을 처리할 수 있는지 나타내는 값인데요. 따라서 이 값이 높을 수록 더 적은 서버로 더 많은 트래픽을 감당할 수 있게 됩니다. 일반적으로 테스트 환경에서는 테스트 프로그램을 사용해 Maximum RPS를 쏟아 부어서 서버의 최대 트래픽을 확인하는 방식을 사용합니다.
처음 성능 측정을 진행할 때는 이 Max RPS만 보고, CPU 등 다른 리소스 사용량의 변화를 제대로 측정하지 않는 실수가 있었습니다. 서버의 비용과 갯수는 결국 리소스의 사용량으로 환산되기 때문에, 같은 트래픽 대비 리소스 사용량의 절감을 반드시 확인해야 합니다. Max RPS를 2배 향상시켰지만 리소스도 2배 더 사용한다면 적절한 의미의 비용 최적화라고 보기는 어려울 수 있습니다.
추가적으로 RPS 측정 시 주의해야 하는 것이 있는데요. Maximum RPS를 넘어서는 트래픽에 대해 서버는 정상적인 응답을 할 수 없게 됩니다. 일반적으로는 Timeout이 발생하는데, 이렇게 서버가 비정상 응답을 돌려주는 시점의 트래픽 부하를 Max RPS로 볼 수 있습니다. 이때, 내가 측정 중인 성능이 테스트 대상 서버의 것인지 아니면 측정 프로그램의 것인지 착각하면 안됩니다. 측정 프로그램이 만들어낼 수 있는 부하보다 서버가 수용할 수 있는 한계가 더 높다면 정상적인 측정이 이루어지지 않습니다.
저희 팀은 Grafana팀이 제작한 K6을 사용해 성능을 측정했는데요. JavaScript로 테스트 스크립트를 작성할 수 있으며 다양한 테스트 시나리오 설정을 제공하여 유연하고 정확한 성능 측정이 가능했습니다.
Event Loop 매트릭
CPU나 메모리 사용 이외에도 Node.js에 특화된 매트릭도 확인해야 합니다. 일반적으로 가장 널리 알려진 것은 Event Loop Lag와 Event Loop Utilization입니다. 모두 Event Loop와 관련된 매트릭인데요. Event Loop은 주기적으로 메인 스레드를 점유하여 대기 중인 task를 처리합니다. 이때, 메인 스레드가 다른 작업으로 인해 점유되어 Event Loop가 대기하는 시간이 길어지는 것을 Event Loop Lag로 표현합니다. Lag이 높다면 특정한 작업이 메인 스레드를 오래 점유(block)하고 있다는 것으로, Node.js의 Event Loop 패턴을 적극적으로 활용하지 못하고 있다는 뜻이 됩니다. 마찬가지로 Event Loop Utilization은 Event Loop가 얼마나 바쁘게 작업을 하고 있는지 나타냅니다. 0과 1 사이의 값을 갖는데, 1에 가까울 수록 Event Loop가 쉬는 시간 없이 항상 어떤 작업을 처리하고 있다는 뜻이 됩니다. 만약 서버가 이미 Max RPS에 도달하여 더 이상의 트래픽을 받지 못하고 있는데, Event Loop Utilization이 1보다 낮은 상태라면 Loop를 꽉 채워 사용하지 못하고 있다는 뜻이 됩니다.
Event Loop Uitilization은 비교적 최근에 등장한 개념입니다. 이 매트릭의 등장 배경은 Introduction to Event Loop Utilization in Node.js 아티클에 더욱 심도있게 소개되어 있습니다.
반복 측정
다양한 수정사항이 지금 이 순간에도 서비스에 반영됩니다. 이는 곧 성능의 변동을 의미하기도 합니다. 만약 성능이 조금씩 하락하는 것을 눈치채지 못하고 있다가 어느 순간 이를 개선하려 한다면, 어디서부터 개선을 시도해야 하는지 막막할 겁니다. 따라서 변동사항이 있을 때 벤치마크를 통해 성능의 하락과 증가를 확인하는 것은 실질적인 최적화만큼이나 중요한 과제입니다. 저희 팀은 상기한 격리 환경과 매트릭 측정을 PR 머지 시마다 진행하도록 만들어서, 매번 서비스에 변경사항이 있을 때마다 성능의 변화를 그래프로 나타내도록 만들었습니다.
최적화와 Profiling
측정 환경이 모두 마련되었다면, 이제부터는 성능 저하의 의심 지점이나 개선 가능한 지점을 파악하고 하나씩 개선해볼 차례입니다. 저희 팀은 먼저 아키텍처를 몇몇 굵은 컴포넌트 단위로 쪼개고, 각각의 컴포넌트가 도입될 때마다 순정 상태로부터 얼마나 성능 하락이 발생하는지를 측정했습니다. 이 과정은 CPU Profiling을 이용하여 어떤 작업이 얼마 동안 메인 스레드를 점유하고 있는지 확인하는 방식으로 처리됩니다.
Opentelemetry 추적
Next.js는 OpenTelemetry를 사용해, 렌더링 과정에서 발생하는 오버헤드 퍼포먼스를 측정할 수 있습니다. 그런데 이 기능은 최신 버전에서만 제공되고 있고 현재 저희 팀에서 사용 중인 버전에는 제공되지 않았습니다. 최신 버전에 수정된 hook을 직접 현재 사용중인 버전에 삽입한 후 아래와 같이 매트릭을 측정했습니다.
이를 통해, 몇몇 부분에서 성능 저하가 발생하고 있음을 확인했습니다. 개선된 부분 중 몇 가지 사례를 소개합니다.
Prefetch와 Serializing
위에 설명한 Opentelemetry로 SSR 서버의 성능을 확인하면 getInitialProps
단계에서 매우 많은 시간이 소요되는 것을 알 수 있습니다. getInitialProps
는 페이지 렌더링 전에 필요한 데이터를 가져오는 작업을 수행하는데요.
토스 프론트엔드는 React Query를 통해 Remote 데이터를 가져오고 있습니다. 이때 SSR 환경에서 prefetch된 데이터는 serializer를 통해 전달 가능한 형태로 변환된 후, 클라이언트에서 hydration을 거칠 때 다시 인스턴스화 됩니다. 이 작업은 Date 객체 등 곧바로 스트림이 불가능한 데이터 형식을 위해 삽입된 레이어인데요. 데이터 형식에 관계없이 serializer가 항상 진행되다보니 SSR 렌더링 과정에서 적지 않은 오버헤드를 차지하고 있었습니다.
저희 팀은 이 serializer를 아예 없애기로 했습니다. 이 작업은 React Query의 queryClient cache를 hydrate 하기 위해 필요한데, 이 cache 자체가 serialize 가능한 형식이 되도록 바꾸면 serializer가 개입하는 단계를 삭제할 수 있습니다.
yarn 버전 업데이트
저희 팀은 yarn 3.2.4 버전과 pnp linker를 사용하고 있었는데요. pnp는 런타임에서도 모듈을 불러오고 해석하는 역할을 맡게 됩니다. 그런데 Profiler를 통해 모듈이 resolve되는 과정을 조사하던 중 이미 resolve된 Module에 대해 다시 import할 때 불필요한 오버헤드가 발생하고 있는 것을 발견했습니다. 이로 인해 SSR 서버의 성능이 다소 하락할 수 있었고, 최신 yarn을 설치했을 때는 발생하지 않는 것으로 보아 중간의 어떤 yarn 업데이트에서 해소된 적이 있었다는 것을 파악했습니다.
그래프를 보면 3.6.1에서 극적인 성능 개선이 발생했다는 것을 알 수 있습니다. 실제로는 서비스 구현에 따라 변화의 정도에 차이가 있겠지만 상당히 큰 개선인 것은 분명한데요. 이는 yarn pnp가 기존에 캐싱된 Module을 즉시 사용하지 않고 모듈에 대한 resolve를 재차 진행하는 불필요한 오버헤드를 갖고 있었기 때문이었습니다.
이 문제는 yarn 3.6.1에서 해결되었고, 저희 팀도 yarn 버전을 3.x 최신으로 업데이트하여 오버헤드를 제거할 수 있었습니다.
Express 제거
또 다른 Next.js의 custom 서버에서 Express를 제거하는 일이었습니다. Express는 Node.js로 서버를 구성할 때 가장 많이 사용되는 프레임워크입니다. 그러나 토스 프론트엔드의 Next.js SSR 서버의 요구사항에 비해 다소 과한 기능들을 가지고 있었고, 미들웨어 등의 아키텍처가 오버헤드를 만들고 있습니다.
그래서 Express와 관련된 모든 코드를 제거하고 Node.js 내장 http 서버를 사용하는 패턴으로 변경하였고, CPU 사용량을 대략 4~5%p 낮출 수 있었습니다.
개선 결과
상기한 지점 이외에도 크고 작은 개선점들이 추가로 있었고, 종합적으로 CPU 사용률을 평균적으로 20% 가까이 줄임으로서 서버의 대수도 비례해서 줄일 수 있었습니다.
교훈
브라우저 JavaScript를 개발할 때와 서버 사이드 JavaScript를 개발하는 것은 비슷하지만 조금은 다른 특성을 갖고 있습니다. 브라우저는 (탭을 영원히 열어두지 않는 이상) 일시적인 프로세스가 활성화되고, 원격지로부터 코드를 받고, JavaScript 이외에 HTML과 CSS에 대해 염두해야 합니다. 한 편 서버 사이드 JavaScript는 반영구적으로 동작하는 프로세스, 네트워크와 호스트 환경에 대한 더욱 심도있는 이해, Node.js 등 사용 중인 런타임에 대한 이해가 매우 깊게 필요합니다. 이러한 이해를 바탕으로 성능을 측정할 수 있는 환경까지 마련된다면, 최적화는 어려운 일이 아닙니다. 그래서 만약 Node.js SSR 서버의 성능을 개선하고 싶다면, 다음의 과정을 한 번 고민해보시면 좋겠습니다.
- 현재의 런타임은 어떻게 구성되어 있는가? 네트워크 토폴로지는 어떻게 구성되어 있으며, 어떤 요청이 오가는가?
- 측정이 필요한 성능은 무엇이 있는가? 측정할 수 있는가?
- 개선 전후를 확인할 수 있는, 변인이 통제된 재현 환경이 존재하는가?