기술부채가 쌓여있는 상황에서 서비스 성능 임팩트 있게 개선하기
클래스101에서는 지난 세달간 광고 이탈율 개선 및 레거시 코드 청산을 목표로 겁나빠른클원 프로젝트를 진행하였습니다. 이 글에서는 거대한 기술부채가 쌓여있는 상황에서 어떻게 임팩트있게 서비스 성능을 개선 했는지 에 대한 경험을 공유합니다.
프로젝트 참여자: 이현우, 네롤리(박성민), 서재우, retn0, GeonHo Tony Han
Context
클래스101은 2018년 3월에 첫 런칭을 한 이후 굉장히 많은 기능들을 굉장히 빠른속도로 추가해 왔습니다. 그 과정에서 수많은 기술 부채들이 쌓여왔고 이 부채들이 성능저하로 이어져 유저들의 불만이 쌓이기도 했습니다. 또한 퍼포먼스 마케팅 성과에도 영향이 있지 않을까? 하는 의심들이 커져갔습니다. 이제는, 제품의 품질에 위기의식을 가지고 개선을 해야겠다는 마음가짐으로 프로젝트가 킥오프 되었습니다.
How to
많은 클둥이(Class101 직원)들과 클래스메이트(Class101 유저)들의 염원도 모이고, 프로젝트도 킥오프 되었으니 어떻게 문제를 해결할 것인가 를 고민해야 했습니다. 위 이미지처럼 대부분의 레거시 코드들은 1년이 지날 즈음부터 변화에 대한 비용이 급증하게 됩니다. 코드 작성자가 퇴사하거나 퇴사하지 않았더라도 기억이 나지 않는 경우가 많기 때문이죠. Class101의 서비스는 많은 부분이 유기적으로 의존하고 있는, 아직은 Monolithic에 가까운 상태였고 이렇게 엉켜있는 로직들을 모두 건드리는 것은 불가능했습니다.
그래서 일단은 지표에 집중하기로 하였습니다. 어떤 지표를 개선할 것인가 를 정한 뒤 해당 지표의 Funnel을 분석하여 Bottleneck을 하나씩 지우다보면 해결이 될 것이라 생각하였습니다.
Core Web Vitals
그렇게 선택된 지표는 Core Web Vitals 였습니다. Web Vitals 는 유저에게 Web 환경에서 좋은 경험을 제공하기 위한 필수적인 제품 퀄리티 initiative 입니다. 이는 Google에서 제창한 것이며 Core Web Vitals 는 Web Vitals의 subset으로 모든 웹 페이지에 동일하게 적용되며, 모든 사이트 제작자들이 측정해야하는 지표입니다. Core Web Vitals의 세부 항목들은 저마다 명확한 기준점들이 있는데, 신호등처럼 경고. 주의. 양호 상태로 나누어 확인할 수 있어 동기부여에도 큰 도움이 되었습니다.
Reference: Web Vitals
첫 시작은 Lighthouse Score 였으나 프로젝트를 진행하다 보니 Core Web Vitals로 옮겨가게 되었습니다. 이렇게 옮겨가게 된 주된 이유는 다음과 같습니다.
- Lighthouse 보다 지표가 적다.
- UX 에서 연역적으로 도출된 지표이다.
- DataDog 에서 측정할 수 있다.
Lighthouse 에서 측정되는 Performance Score의 세부 항목들은 다음과 같습니다. FCP, SI, LCP, TTI, TBT, CLS. 2020년에 제시된 Core Web Vitals의 항목들은 다음과 같습니다. LCP, FID, CLS. 항목의 갯수가 3개 밖에 차이가 나지 않으며 새롭게 추가된 지표가 1개밖에 되지 않다는 점에서 Lighthouse와 Core Web Vitals의 차이가 별로 없다고 느껴질지도 모릅니다. 하지만 실제로 액션을 하다보면 어떤 세부 항목을 중점적으로 개선해야 할까 고민하게 되고 고민의 대상이 6개에서 3개로 줄어드는 것은 꽤 차이가 있었습니다.
또한 Web Vitals는 위에 쓰여진 내용처럼 Web 환경에서 유저에게 좋은 경험을 주기 위해 필요한 성능 지표가 무엇일까 를 먼저 고민하면서 만들어진 것입니다. LCP는 Loading, FID는 Interactivity, CLS는 Visual Stability를 각각 대응하고 이는 UX에 보다 가까운 지표임을 의미하며 이 지표가 개선되었을 시에 페이지 이탈율도 줄어들 것이다 라고 자연스레 생각할 수 있었죠.
Class101은 Logging & Monitoring Platform으로 ELK에서 DataDog으로 이전한 내역이 있습니다. DataDog에는 RUM(Real User Monitoring) 이라는 제품이 있는데 RUM은 JavaScript SDK를 제공하여 Core Web Vitals를 측정하는 것을 도와주었습니다.
Slack Notification 도 손쉽게 설정할 수 있었습니다. 지난 8시간 동안의 P75 LCP 를 수집하여 이를 12시간마다 프로젝트 채널에 전송하게 하였고 이를 통해 각 배포 시점마다 성능 개선치 에 대한 피드백을 얻을 수 있었습니다.
QA Process
또한 이번 겁나빠른클원 프로젝트를 진행하면서 시범적으로 QA 프로세스를 진행해 보았습니다. 기존의 Class101에서는 데일리 배포를 채택했었고 모든 기능 추가에 대한 책임을 각 엔지니어가 담당하는 형태였습니다. 그러다 보니 크고 작은 장애들도 많이 있었고 서비스가 성장함에 따라 장애들로 인한 피해가 커졌습니다. 성능개선에 대한 태스크들은 상대적으로 다루는 도메인도 광범위하고 서비스 전체에 영향을 미칠 수 있는 작업이다 보니 조금 더 조심할 필요가 있었습니다. 다행히 최근에 신설된 QA 파트에서 이에 대한 도움을 많이 주었고 이는 곧 서비스 전체에 대한 QA 프로세스로 발전되었습니다.
Tasks
이렇듯 본격적으로 성능 개선 작업들을 수행하기 전에 Core Web Vitals, QA Process 라는 든든한 쌍두마차가 준비되었습니다. 아래 내용들 에서는 실제로 수행한 성능 개선 작업들을 공유합니다.
Critical Rendering Path
Critical Rendering Path는 Google의 Web Fundamentals에 자세히 나와 있듯이 현재 사용자 작업과 관련된 콘텐츠 표시의 우선순위를 지정하는 것을 의미합니다. 이번 프로젝트에서는 <script>
태그의 Async & Defer 옵션, <link>
태그의 DNS Prefetch & Preconnect 옵션을 사용하였습니다.
DNS Prefetch, Preconnect
DNS Prefetch는 Link 태그를 이용하여 해당 도메인에 해당하는 IP주소를 미리 알아내는 것을 의미합니다. 이 과정을 DNS Resolution 이라고도 하는데 이 DNS Resolution을 요청 전에 미리 하도록 하는 것을 DNS Prefetch라 합니다. DNS Prefetch를 스크립트 요청 이전에 삽입해두면 DNS Resolution이 이미 진행이 되었을 때 요청이 진행되기 때문에 latency를 낮출 수 있게 되는 것이죠. 이와 함께 preconnect를 사용하게 되면 더욱 효과가 좋습니다. preconnect는 TCP 연결을 미리 맺어두도록 합니다. 하지만 이 또한 클라이언트의 한정된 네트워크 리소스를 사용하는 것이기 때문에 남용해서는 안됩니다. 실제로 적용할 때에는 아래의 MDN 문서에서 Best practices를 지켜가며 적용할 것을 추천합니다.
Class101은 Braze, Branch, Segment 등의 Growth Marketing Tool들과 간편결제 수단들을 다수 사용하고 있어 DNS Prefetch, Preconnect 를 통해 일정 수준의 성능 향상을 이뤄낼 수 있었습니다.
Reference: Using dns-prefetch — Web Performance | MDN
Defer loading & Async Executing
페이지를 렌더할 때 필요하지 않은 스크립트들을 defer하게 받아올 수 있도록 옵션을 추가하였습니다. 이 옵션을 추가하면 브라우저는 스크립트 다운로드를 백그라운드에서 진행하게 되는데요, 덕분에 HTML Parsing이 스크립트 다운로드로 인하여 블로킹 되는 것을 막을 수 있습니다.
defer 옵션과 비슷하게 async라는 옵션도 존재합니다. 다만 이는 다운로딩이 아니라 실행에 관련한 옵션인데요, async 옵션이 추가된 스크립트는 다운로드가 완료된 순서대로 스크립트가 실행되게 됩니다. 덕분에 번들링된 핵심 서비스 코드들이 먼저 실행되게 할 수 있죠.
UX Driven Improvement
Class101의 거의 대부분의 이미지들은 Lazy loading이 되도록 처리가 되어있습니다. 유저가 보고 있지 않은 이미지가 모두 로드된다면 불필요하게 네트워크 리소스를 잡아먹기 때문인데요, 이러한 Lazy Loading 처리가 LCP에 악영향을 미치고 있었습니다.
왜냐하면, Lazy Loading은 1. Div 태그를 통한 스켈레톤 표현 2. 이미지 다운로드 완료 시 img 태그로 교체 의 순서로 이루어지는데 이로 인해 img 태그의 순서가 뒤로 밀리게 되고 그만큼 이미지 다운로드가 늦게 되기 때문이죠. 이러한 Lazy Loading 기능을 처음 보는 화면에서는 모두 제거하고 나니 약 1초가량의 LCP 개선을 얻을 수 있었습니다. 이런 천재적인 아이디어는 Sukjae Lee 님께서 제공해 주셨습니다.
Bundling
JS 번들을 줄이는 것은 성능 최적화에 있어 항상 거론되는 내용입니다. JS 는 이미지와는 다르게 다운로드에서 리소스 소모가 끝나는 것이 아니라 (물론 이미지도 렌더링되는 시간이 존재하지만) 실행시간도 필요하기 때문에 이미지 최적화 보다 같은 용량의 개선이 더욱 임팩트가 컸습니다.
Reference: 토스ㅣSLASH 21 — JavaScript Bundle Diet
Granular Chunk
Next.js 에서는 Webpack 설정 조정을 통해 두 개 이상의 모듈에서 사용되는 공유 코드들을 하나의 청크로 나누는 Granular Chunk 전략을 따르고 있습니다. Class101에서도 이 전략을 채용함으로써 상당 수준의 번들 용량 감소를 확인할 수 있었습니다.
Reference: Improved Next.js and Gatsby page load performance with granular chunking
Remove unnecessary codes
import 되어 있으나 사용되지 않고 있는 코드들을 삭제하는 것 또한 성능에 도움이 되었습니다. 그 이유인 즉슨 이 코드들이 번들링되어 사용자에게 전달이 되고 있었기 때문인데요, 클래스101에서는 전역 상태관리를 위하여 Mobx, Redux, Apollo 세 라이브러리가 혼재한 상태인데 이 중 Apollo를 제외한 나머지 두 라이브러리 사용처를 없애면서 최상단의 MobxProvider를 조금 덜 수 있었습니다.
API Server (Apollo, GraphQL)
Data Dog을 통한 로그분석을 하다보니 아래 이미지와 같은 Tracing 결과를 발견할 수 있었습니다. 갈색은 GraphQL.execute
를 파란색은 mongodb operation
을 나타냅니다. 이 요청은 PDP를 렌더하는데 필요한 ProductViewPage 라는 GraphQL Query 로 수많은 mongodb operation
을 후기 섹션과 가격 요청에 사용하고 있었습니다. 이를 성능으로 대략 계산해보니 약 2초 가량을 모든 GraphQL Query 요청에 사용하고 있던 것입니다.
Messaging & Caching
import { EventHandler } from '@events/common/EventHandler';
import { RegisterEventHandler } from '@events/common/RegisterEventHandler';
import { KlassTicketService } from '@graphqlSchema/klassDomain/klassTicket/klassTicket.service';
import { PlanToken } from '@graphqlSchema/subscriptionDomain/subscriptionPlanV2/subscriptionPlanV2.constants';
import { SubscriptionPaymentService } from '@infrastructure/SubscriptionPaymentService';
import { ObjectId } from 'mongodb';
import { EventProps, name } from './definition';
@RegisterEventHandler(name)
export class KlassTicketIssuedHandler implements EventHandler<EventProps> {
constructor(
private readonly klassTicketService: KlassTicketService,
private readonly subscriptionPaymentService: SubscriptionPaymentService
) {}
async handle({ value }: { key: string; value: EventProps }) {
const { userId, klassTicketId } = value;
const subscription = await this.subscriptionPaymentService.getSubscriptions(PlanToken.Prime, userId);
if (this.subscriptionPaymentService.isSubscribingPlan(subscription)) {
await this.klassTicketService.applyPrimeBenefit({
userId,
klassTicketId: new ObjectId(klassTicketId),
nextPaidAt: new Date(subscription.nextBillingStartAt),
});
}
}
}
await publishKlassTicketIssuedEvent({ userId, klassTicketId: newKlassTicket._id.toHexString() });
이 작업을 진행하면서 가격정보를 반정규화할 것인가 캐싱할 것인가 고민을 하기도 했습니다. 반정규화는 데이터를 영속함에 있어 변화를 불러일으키기에 추후에 리팩토링이 이어질 커머스 시스템을 고려하여 캐싱을 하는 것이 더 빠르고 좋은 선택이 될 것이라 생각하여 인메모리 캐싱을 진행하였습니다.
SSR Server
Class101에서는 ReactDOMServer + Express 를 통해 Server Side Rendering을 하고 있었습니다. 이 때 Rendering된 HTML을 Redis에 저장하여 캐싱하고 있는데요, 이 때의 캐시 키는 다음과 같았습니다.
productID:{isSafari:bool,isInAppBrowser:bool,isInstagramInAp...}
이 캐시 키들 중에는 굳이 분기되어 있지 않아도 되는(React에서 Render시에 이용되지 않는) 키들도 존재했고 PDP의 경우 URL이 상품마다 달라 같은 상품을, 같은 기기에서, 비슷한 환경에서 보지 않는 경우 캐시된 PDP를 볼 수 없었습니다. 때문에 이러한 불필요한 캐시 키 분기점을 줄이는 것이 캐시 히트율에 영향을 줄 수 있었고 Product Entity의 정보가 업데이트 될 때 마다 Cache를 업데이트하도록 하고 Cache 지속시간을 3시간으로 늘려 PDP 캐시 히트율을 높일 수 있도록 하였습니다. (이와 반대로 메인 페이지는 항상 같은 URL을 이용하기 때문에 캐시 히트율이 높죠.)
Re-Renders
불필요한 리렌더를 줄이는 것은 (거의) 언제나 옳다. 외우셔도 됩니다. wdyr 이라는 라이브러리는 불필요한 리렌더가 발생했을 시에 이를 console을 통해서 노티해줍니다. 시간이 날 때 부업을 하듯이 리렌더를 줄이다 보면 initial load 시 페이지를 render하는 과정도 빨라지고 initial load가 끝난 뒤에도 유저 인터랙션에 있어서도 더욱 빨라지는 일거양득의 효과를 누릴 수 있습니다. (SSR인 경우에 한합니다.)
특히나 Root에 가까운 Re-render일 수록 다수의 하위 노드들이 Re-render 하도록 하기 때문에 더욱 높은 개선 우선순위를 가질 수 있죠.
Result
위에 정리된 액션들 말고도 여러 크고 작은 액션들이 모여 LCP를 10초 대에서 4.5초까지 줄이는 데에 성공하였습니다. 물론 아직 초록불(2.5초 이하)을 만나진 못했지만 그래도 많은 유저들이 이전보단 편안한 환경에서 Class101을 만날 수 있게 되었습니다. 앞으로도 이러한 성능 개선작업을 지속할 것이고 어떻게하면 지속가능하게 할 수 있을지에 대해 잠시 다뤄보도록 하겠습니다.
고성능의 제품이 지속가능하려면
여느 기술 기업이 그렇듯이 각 제품 조직마다 주로 다루고 있는 도메인이 다릅니다. 한 조직에서 서비스 성능 전체를 다루는 것은 거의 불가능하죠. 때문에 겁나빠른클원 프로젝트 이후에는 각 도메인별 (페이지별) LCP 모니터링을 세팅하여 해당 개발팀이 지속적으로 개선할 수 있게 Alert을 세팅할 예정입니다.
빠르게 발전하는 제품에서 성능과 코드 퀄리티를 모두 챙기기란 여간 어려운일이 아닙니다. 그러나 각 도메인의 주인이 생기고 적절한 성능 지표를 지속적으로 트래킹하고 꼭 필요한 실용적인 아키텍쳐를 적절히 잘 도입해 나간다면 꼭 그 이상향이 꿈으로만 남아있지는 않을 수도 있습니다. 이와 관련하여 엔터프라이즈 프론트엔드 애플리케이션 아키텍쳐 에 대한 고민이 잘 녹아있는 히로 님의 글도 있으니 한 번 살펴보시길 추천드립니다.
글쓴이: 한건호(Tony)
Class101과 함께 더 좋은 서비스를 만들기 위해 고민하며 성장하고 싶다면 아래 링크를 통해 지원서를 보내주세요. 피플팀과의 티타임 및 채용 문의(recruit@101.inc)도 언제든 환영입니다. 😉