D
Archive
커리어
  • SEO
  • 컴포넌트
  • 블로그
  • 성능
  • AI
  • 아키텍처
  • 회고
  • SSR 최적화는 어떻게 해야할까
    성능
    1달 전·조회 수 11

    SSR 최적화는 어떻게 해야할까

    코어 웹바이탈 웹 개발자가 서비스를 운영할 때 유념해야할 것으로 코어 웹바이탈이라는 지표가 있습니다. LCP INP CLS 이 3가지는 유저 입장에서 불편함을 느낄 수 있는 항목들을 지표화한 것인데요. 전부 사용자가 직접 인지하는 결과 지표입니다. 콘텐츠가 다 보였는가 내 클릭에 반응했는가 레이아웃이 흔들리지 않았는가 같은 것들이지요. UX적인 이유에서도 이 성능지표들은 중요하지만, SEO 순위와 직결되는 이유이기도 하기에 자연유입이 중요한 제 현업 도메인에서는 유독 더 중요합니다. 그럼 이 코어웹바이탈 항목을 개선하기 위해서는 어떻게 해야 될까요? LCP를 구성하는 요소 공식문서를 보면 각 코어 웹바이탈은 여러 요인을 합쳐놓은 것으로 볼 수 있는데, 그 중 LCP는 TTFB + 리소스 로드 딜레이/시간 + 요소 렌더 딜레이를 계산한 것으로 볼 수 있습니다. INP도 여러 요인의 집합체로 공식문서 참고 따라서 LCP를 개선하려면 하면 이에 속한 TTFB를 개선하거나 리소스 로드 시간을 줄이기 위해 JS 청크를 줄여야하는 것이지요. 즉, TTFB가 600ms면 LCP는 절대 600ms보다 빨라질 수 없습니다 그 중에서도 오늘 메인으로 다룰 주제는 TTFB인데요. 작년쯤 이맘때 쯤 현업에서 진행했던 작업에 대해 회고를 해보려고 합니다. TTFB 개선기 1) 주요 페이지 문제 확인 목적 조직에서 비즈니스 기능을 새로 시작하는 시기여서 백엔드 작업이 시작된 와중 프론트쪽 시간이 살짝 비어서 간단히 할 수 있는 기능 개선점을 찾고 있었습니다. 객관적으로 문제점을 찾기 좋은 영역이 네트워크 탭이든 성능검사 탭이든 브라우저 도구를 통한 것이라고 생각했고, 개발자 도구도 살펴보고 페이지 소스 보기도 살펴보던 도중,, 의아한 부분을 발견했는데요 바로 서버로부터 하이드레이션되는 데이터가 3653줄이나 된다는 점이었습니다. 질문상세 페이지가 주요 페이지이긴 하지만 서버로부터 받고 있는 데이터가 페이지 구성에 비해 너무 많다보니 원인분석을 하였고 아래와 같았습니다. 웹서버에서 SSR할 때 필요없는 유저 정보 기반의 데이터까지 API 서버에서 가져오고 있다. 주요 비즈니스 데이터의 요청 수를 줄이기 위해 비효율적으로 많은 데이터 더미를 한번에 가져오고 있다. 이 두 문제는 문서의 크기가 비대해지게 하며 불필요한 API RTT가 발생하는 원인이므로 개선 시 SSR시 문서 응답 시간 즉, TTFB를 상당히 낮출 수 있는 포인트로 보였습니다. 2) 서버측에서는 불필요한 유저 정보 기반의 데이터 이 블로그를 만들 때에도 언급했었던 내용으로, CDN 캐싱을 염두에 두고 있었기에 서버측에서 유저 정보 기반 UI는 렌더링하지 않도록 구성하였었습니다. ex) 답변 평가 여부, 좋아요/싫어요 여부 등 만약 A라는 유저의 정보를 토대로 렌더링된 페이지가 CDN에 캐싱이 되었다가 전혀 다른 B유저에게 서빙이 되면 곤란하기 때문이지요. 그러한 이유로 SSR을 할 때 사용하지 않고 있음에도 API는 유저정보 기반 데이터를 응답하고 있었습니다. ex) downVoted, upVoted 물론 웹서버측에서는 이 API를 요청할 때 헤더에 토큰을 싣지 않고 있기도 하고, CDN 캐싱 기법이 적용되어 있지 않기에 문제가 발생할 위험은 없지만 API 입장에서는 유저 토큰이 없어도 관련 테이블을 조인을 해보거나 예외처리는 하고 있다는 뜻이고 더욱이 가장 자연유입이 많기도 한 질문상세 페이지에 들어올 때 마다 요청되고 있는 API이다보니 이는 개선 포인트로 보였습니다. 즉, 프론트 입장에서는 불필요하게 하이드레이션 되는 데이터를 줄일 수 있는 작업이고, 백엔드도 MSA구조에서 테이블 조인으로 인해 발생하는 RTT를 개선 or 코드 가독성을 개선할 수 있는 작업이라 판단되어 기존 API를 웹서버측에서 유저 토큰 없이 요청할 정적 데이터 기반 API 클라이언트에서 유저 토큰이 있을 때 요청할 동적 데이터 기반 기반 API 로 분리하는 것이 어떠할지 제안을 드려봤습니다. 이 전략은 주로 저희 서비스에 들어오는 유저는 검색엔진을 통한 자연유입이기 때문에 비로그인 유저가 많아 웹서버측에서의 요청만으로 대부분의 트래픽을 처리가능하다는 기존 장점을 살림과 동시에 가장 다이어트된 형태로 데이터를 제공한다는 것입니다. 다만 해당 기술 작업은 당시 비즈니스 업무의 편중으로 작업 공수가 나질 않아 다음을 기약하게 되었습니다. 밀리고 밀렸지만 올해 하반기 있을 질문상세 페이지 리뉴얼이 비즈니스 업무로 예정되어 있어 같이 진행 가능할 것으로 보입니다 ㅎㅎ 3) 필요한 데이터를 비효율적으로 가져오고 있다 서비스에는 토픽이라는 주요 비즈니스 데이터가 존재하는데 이를 질문상세 페이지에서 렌더링하기 위해서 약 250개의 데이터 배열을 API 요청하여 가져온 후 질문에 맞는 토픽을 찾아서 사용하는 비효율이 있었습니다. 예를 들면 제가 질문상세 페이지에서 필요한 것은 형사 토픽 하나인데 [민사, 형사, 가족이혼, 인사 , ...약 240개]를 모두 벌크로 가져와서 find하는 방식인 것이죠. 실제로 하이드레이션 되는 데이터 중 거의 2/3가 이 토픽 배열 데이터였다보니 앞서 다루었던 문제보다 더 큰 비중을 차지하고 있었습니다. 이 벌크 조회 -> 단건 조회로 수정 작업은 2)에 비해서 아주 간단하면서도 타 챕터와의 협업 없이 가능하다는 장점이 있어 바로 작업하였습니다. 운영환경 기준 성과 1) 기존대비 문서크기 6~7% 감소 개선 작업을 통해 하이드레이션 페이로드 라인이 3653 -> 634줄(88% 감소)이 되었고 운영환경에서 실제 등록된 인기 질문을 기준으로 SSR된 문서의 크기가 7KB~10KB 정도 (기존대비 약 6~7%) 감소하였습니다. 또한 자연스럽게 TTFB 시간에 큰 개선이 있었는데요. 네트워크 탭을 통해 클라이언트가 HTML 문서를 받는데에 걸린 시간을 비교해보니 아래와 같았습니다. 2) 기존대비 TTFB 70% 감소 1️⃣ 개선 전 2️⃣ 개선 후 브라우저 캐시를 끄고 여러 번 반복 측정했을 때 (네트워크 노이즈 때문인지) ±20ms 수준의 변동은 있었으나 TTFB가 기존대비 약 70% 감소하는 경향은 일관되게 관찰되었습니다 3) 컨테이너 메모리 사용량 개선 서버측 메모리 점유율에도 10% ~ 15% 정도의 상당한 개선이 있었습니다. 약 240개 가량의 토픽 배열을 매 요청마다 메모리에 올렸다 버렸다하는 현상이 사라졌기 때문으로 보입니다. 앞으로는 어떻게 해야할까? 1) 렌더링 기법 변화 1️⃣ 콘텐츠의 성격에 맞는 렌더링 방식인가 이번 글에서 SSR 하에서 최적화하는 방법에 대해 고민/개선하였지만 사실 지금 SSR 자체가 서비스에 적합한 방식은 아니라고 생각합니다. 질문상세 페이지의 메인 콘텐츠는 내용의 수정 빈도가 높지 않은 텍스트이므로, 특징상 서버에서 동적으로 매 요청마다 새로 처리해야 될 필요가 없기 때문인데요. 이렇게 SSR로 돌아가는 상황에서 아무리 더 최적화한다한들 본래 적합한 정적 렌더링 방식에 비해 구조상 서버 비용 낭비 지금 언급한 TTFB처럼 서버가 요청을 처리하는데에서 필연적인 성능 저하 가 따라올 수 밖에 없습니다. 2️⃣ 어떻게 바꿔 볼 예정인가 지금까지는 서비스 인프라에 너무 큰 변혁을 가져다주고 비즈니스 업무에 밀려 함부로 작업하지 못했으나 올해 하반기 예정된 주요 페이지 리뉴얼을 통해 근간을 아래처럼 바꿔보려고 합니다. 주기적으로 정적 페이지를 생성하는 ISR 답변이 달렸거나 유저나 어드민에서 콘텐츠를 수정했을 때 이 정적 페이지를 갱신하기 위한 On Demand Revalidation 어드민같은 외부 서비스에서 곧바로 서비스에 갱신 요청을 할 수 있도록 Route Handler로 On Demand Revalidation를 트리거하도록 구성 을 조합하는 방식으로 인프라 변경을 구상하고 있습니다. 장점은 역시 정적 페이지가 생성되었다면 런타임에서 렌더링 단계가 제거되어 코어웹바이탈 지표 향상 - UX 개선 및 SEO 순위에 이점 서버 인프라 축소 가능 - 비용 감소 를 꼽을 수 있습니다. 2) CDN 캐싱 정적 렌더링 방식이 성공한다면 추가로 CDN 캐싱을 선택할 수 있습니다. 위 인프라 변혁이 이루어진다면 Cloudfront를 통해 요청 시 거쳐야할 인프라 RTT를 더 줄일 수 있는데요. 즉 Cloudfront는 인프라 설계 레이어의 제일 앞단에 위치하다보니 요청이 들어왔을 때 곧바로 응답할 수 있게 되므로 TTFB를 더 개선할 수 있고 캐시로 인해 오리진 서버(Next 서버)를 거치는 횟수가 꽤 줄어들테니 데이터 트랜스퍼 비용 절감 효과도 기대해볼 수 있습니다. 오리진 서버에서 정적 서빙을 한다면 CDN까지 붙이는 게 너무 투머치가 아닐까 싶지만 서버의 리전이 서울에 있는 상태이고, 저희 서비스가 글로벌 트래픽을 완전히 무시할 수는 없는 수치인 듯하여 고려해야 된다고 판단했습니다. 정리 이 작업이 저에게 의미가 깊었던 것은 프론트엔드 사이드에서 잘 언급되지 않는 부분을 개선하여 최적화를 성공했기 때문이었습니다. 개인적으로 느꼈던 프론트 성능 최적화 글은 대부분 번들 사이즈, 이미지 최적화, 코드 스플리팅에 집중하는 것을 볼 수 있습니다. 문서크기와 그 안의 하이드레이션 페이로드는 시야 밖에 있는 경우가 많은 것이지요. 그 부분을 찾아 LCP 하한선이라는 코어웹바이탈 개선의 작업이 될 수 있었다는게 뿌듯했네요 ㅎㅎ

  • 피드 성능 개선기
    성능
    1달 전·조회 수 14

    피드 성능 개선기

    1년반전 즈음 더욱더 주니어였을 시기에 했던 서비스의 피드 성능 개선기를 회고해보려 합니다. 피드 리스트 성능 이슈 당시 기준 몇달 전 서비스 대규모 리뉴얼이 있었고 직후 피드에 심각한 성능 문제가 발생했습니다. 어느 페이지 구간부터는 로드에 짧게는 3초, 길게는 7초 등 적지 않은 시간이 걸렸고 슬랙에서 공론화되기까지 했습니다. 리뉴얼 중 피드를 담당했던 인력이 한달 전 퇴사를 하면서 대응에 공백이 생겼었는데 다행히도? 당시 브라우저 성능 최적화를 위해서 해보고 싶었던 시도가 있었던지라 업무 카드를 겟했습니다. 성능에 얼마나 하자가 있는지 실제 지표로 보려고 성능 모니터 탭을 켜보니 아니 웬걸.. 고작 7번째 페이지를 불러왔음에도 브라우저가 사용하는 CPU가 100%까지 치솟는 것으로 보아 렌더링을 하는데에 너무나 많은 연산이 이루어지는 것으로 예상되었습니다. 원인 찾기 인피니티 스크롤이 수차례 되었을 때 문제가 부각되는 것으로 보아 두 가지를 예상했습니다 1) 백엔드 API의 레이턴시🤔 고려해볼 법한 원인으로 오프셋 페이지네이션 체계에서 조회하려는 데이터의 앞 데이터를 SKIP하는 과정에서 느려지지 않을까 생각도 해보았는데요. 하지만 그렇다고 하기엔 고작 10번도 안되는 스크롤부터 바로 문제가 발생했었고, 순서상 앞에 있는 데이터가 최대 100개 밖에 안되는데 이렇게 느릴리는 없다고 판단하여 제외하였습니다. 네트워크창에서 봐도 실제로 문제가 될만큼 레이턴시가 발생하진 않았기도 하구요. 2) 과다 재연산 이슈✅ 새로운 리스트 데이터를 받아오면서 리스트 상태가 갱신되고, 이로 인해 부모인 리스트 컴포넌트가 리렌더링되면서 자식인 아이템 컴포넌트가 전부 리렌더링되는 것이 문제였습니다. 다만 흔한 프론트엔드 문제 패턴으로 이런 형태의 결점이 있다고 해서 이렇게까지 심각하게 버벅이는 경우는 잘없었는데 각 피드 아이템이 꽤나 많은 상태 관리 로직과 무거운 로직의 함수를 갖고 있던 것이 원흉이었습니다. 즉 아이템 컴포넌트에는 재연산 시 부담이 있는 좋아요, 싫어요 토글함수나 답변 보상 배율 계산같은 무거운 로직이 있어서 리렌더링 1회당 비용도 비싼 편이었고, 또한 아이템 컴포넌트에는 좋아요, 싫어요, 토픽, 유저정보 총 4개의 지역 상태가 존재했는데 부모에서 내려주는 이 값을 자식에서 상태로 관리하고 있었습니다. 따라서 데이터의 정합성을 맞추기 위해 자식에서 useEffect로 이 상태들을 업데이트하는 로직이 있었고 이로 인해 자식당 2번의 리렌더링이 발생하고 있었습니다. derived state 패턴(= 파생 상태)이라고 불리며 리액트는 이를 지양할 것을 강조합니다. 그리고 인피니티 스크롤이 트리거되어 부모의 리스트 상태가 갱신되면 리렌더링되면서 자식인 아이템 컴포넌트들이 모두 영향을 받는데 이 때 새로 불러올 아이템 외에 기존에 있었던 아이템들도 리렌더링이 발생하게 되는 것도 성능 악화에 큰 지분을 차지한다고 생각했습니다. 기존에 있었던 것들은 UI가 그대로 유지되어 리렌더링이 불필요한데도 부모로 인해 영향을 받게 되는 것이죠 즉, 인피니티 스크롤로 6번째 리스트를 불러온다고 예시를 들었을 때 기존 1~50번째 아이템 컴포넌트 새로 불러온 51~60번째 아아템 컴포넌트 자식에서 상태 정합성을 위해 사용한 useEffect 로 인해 총 (50 + 10) * 2 = 120번의 리렌더링이 발생할 수 있게 됩니다. 차차 문제를 해결했던 스토리를 풀어보면 리액트 derived 패턴 제거 부모에서 상태 갱신으로 자식이 리렌더링되는 것은 자연스러운 흐름이므로 가장 최후의 개선 요소라고 판단하여 자식 자체에서 발생하는 리렌더링 원인을 제거하기로 하였습니다. 좋아요, 싫어요, 유저정보, 토픽 총 4개의 값 모두 부모 리스트에서 뿌려준 데이터로 아이템에서 각각 useState 와 useEffect로 관리중이었으나 그렇게 관리할 필요가 없었습니다. 유저정보나 토픽은 런타임에서 AJAX 처리될 필요 없는 정보이므로 상태로 다룰 필요가 전혀 없었고 좋아요나 싫어요는 토글 기능이 필요하므로 상태로 컨트롤 되긴해야 하나 리스트 데이터가 이미 tanstack-query에 의해 캐시(상태)처리 중이므로 이 캐시를 조작하면 되어 useState와 useEffect를 사용할 필요가 없어져 제거했습니다. 그 결과 아이템당 리렌더링 횟수가 2회에서 1회로 줄어 6번째 리스트를 불러온다고 하면 총 리렌더링 횟수를 벌써 절반인 60회로 줄였습니다. 동시에 코드 가독성도 굉장히 개선됐구요. 리스트를 새로 받아오면서 발생하는 리렌더링 현상 제거 1) memo로 컴포넌트 메모이징 적용 6번째 리스트를 불러올 때의 리렌더링 횟수를 120번 -> 60번으로 줄였지만 여전히 아쉬운 점이 있죠. 새로 불러올 데이터는 51~60번째 컴포넌트에 해당하는 데이터이지만 기존의 1~50번째 컴포넌트는 전혀 달라진 사항이 없음에도 부모 컴포넌트의 리스트 상태값이 갱신되었다는 이유로 다시 리렌더링 되어야 합니다. 따라서 기존 컴포넌트의 데이터가 변하지 않았다면 리렌더링하지 않도록 memo를 적용합니다. memo는 부모가 리렌더링되었더라도 그에 속하는 자식 컴포넌트의 prop값이 변하지 않았다면 리렌더링 없이 이전 값을 그대로 사용할 수 있는 기법으로 특히나 이러한 리스트성 UI에서 큰 빛을 발휘합니다. 2) 리스트 데이터를 메모이징할 때 흔한 함정 memo를 적용해보면? 예상대로 동작하지 않습니다. 분명 부모가 리렌더링되기 이전과 똑같은 prop값을 받았을텐데 리액트는 다른 값을 prop으로 받았다고 인식한 것이죠. 왜일까요? 이 현상을 이해하려면 자바스크립트의 원시타입과 참조타입에 대해서 이해를 해야합니다. 라는 원시타입의 값을 할당한 변수가 있을 때 a === b 는 true입니다. foo === foo를 비교하는 것이므로 값이 같기 때문이죠. 그럼 아래 코드는 어떨까요? 라는 코드가 있을 때 a === b 는 false입니다. 겉으로는 값이 같아보이지만 내부적으로는 변수 a, b가 각자 가진 객체값은 별도의 메모리에 저장되고, 각 변수는 메모리 주소를 참조하게 되기 때문이죠 즉 a === b는 내부적으로 메모리주소A === 메모리주소B를 비교하는게 되어 false가 나오게 되는 것입니다. 이러한 것이 참조타입의 특징으로 자바스크립트에서는 객체, 배열, 함수가 참조타입으로서 취급됩니다. 따라서 부모의 리스트 상태값이 바뀌어 리렌더링되면 자식이 prop으로 내려받고 있던 함수도 재연산되어 메모리주소가 이전과 달라지므로 리액트는 이미 존재하던 리스트 아이템들이 이전과 다른 prop 값을 받았다고 판단하게 되어 리렌더링을 해버리게 되는 것입니다. 그럼 어떻게 해야되는 걸까요? 이런 참조타입의 데이터 자체를 메모이징해서 내려줘야 합니다. 즉, 부모의 상태가 갱신되어 리렌더링되어도 동일한 레벨에 존재하는 함수의 재연산을 막는 방법인 것이죠. useCallback을 이용해서 의존성 배열에 넣은 값에 변화가 생겼을 때에만 함수가 재연산되도록 하면, 이제 부모 리스트 상태값이 바뀌더라도 함수는 로직에 영향을 미치는 값이 변하지 않는 이상 재연산되지 않아 이전 메모리주소 값을 유지할 수 있게 되어 정상적으로 memo가 동작하게 됩니다. 이제 120번에 달했던 렌더링 횟수가 딱 새로 불러온 개수만큼인 10번으로 줄일 수 있게 되었습니다. 마무리 1) 브라우저 성능 최적화 확인 보이듯 약 15페이지까지 계속 스크롤을 내렸을 때 크롬 성능 모니터 탭 기준으로 As Is CPU 최고 100%, 메모리 최고 220MB To Be CPU 최고 20%, 메모리 최고 80MB 정도로 상당히 개선이 되었습니다. 브라우저 이외 웹뷰 환경에서도 유의미하게 줄어든 것을 볼 수 있었습니다. 웹뷰의 경우 자원을 과하게 사용하게 되면 앱이 크래시되거나 하얀 화면이 되어버리는 문제가 있었는데 운영환경 배포 후에 관련 CS가 줄어드는 결과도 얻을 수 있었습니다. 2) 메모이징이 능사는 아니다 메모이징은 참 좋아보입니다. 하지만 그렇다고 남용은 있어선 안돼요. 왜냐하면 연산한 값을 '메모리'에 저장해둔 후 리렌더링/재연산이 될 때 추가 연산없이 메모리에서 꺼내 재활용하는 방식이기에 결국 메모리 자원을 잡아먹으며 매 렌더마다 이전과 비교하기 위해 CPU도 사용하게 됩니다. 지금처럼 리스트 형태인 경우 대개 데이터가 몇 백개씩 렌더링에 관여될 때 관련 값들의 재연산으로 인해 CPU 부하가 심해질 수 있을 때에 도움이 되는 것이죠. 즉 대부분의 경우에서는 재연산을 하는 것이 성능적으로 우월할 때가 대다수입니다. 따라서 메모이징이 도움이 되는 경우는 예외적인 케이스라는 것입니다. 그러므로 정말 무거운 작업임이 확실한 상황에서만 도입을 고려해야 합니다.