"Life is Full of Possibilities" - Soul, 2020

우아한테크코스

웹 사이트의 성능을 높여보자 (2) (같은 건 매번 새로 요청하지 않기, 최소한의 변경만 일으키기)

m2ndy 2024. 9. 17. 00:14
본 글은 2024.11.06에 마지막으로 수정되었습니다.

 

 

1편과 이어집니다.

 

 

웹 사이트의 성능을 높여보자 (1) (요청 크기 줄이기, 필요한 것만 요청하기)

개선 전후 성능 측정 결과   요청 크기 줄이기 1. 소스 코드 줄이기  Home 페이지에서 불러오는 스크립트 번들 크기가 951KB였다. 웹 페이지의 권장 번들 사이즈는 250KB 미만으로, 번들 사이즈의

cho-sim-developer.tistory.com

 

 

 

 

같은 건 매번 새로 요청하지 않기

 

1. CDN 설정

 

1-1. CDN 캐시 설정

 

npm run build를 통해 생성된 정적 파일을 S3에 업로드 한 뒤 CDN을 통해 접근 가능하도록 했다.

CDN의 캐시는 CachingOptimized를 적용! 새롭게 변할 데이터가 없을 것이라 판단하여 최대 TTL을 1년으로 설정해 두었다.

 

S3의 메타데이터 설정도 max-age=31536000 (1년)으로 설정했다. max-age는 HTTP Cache-Control 헤더의 설정값으로, S3의 정적 파일을 얼마 동안 캐싱할지 지정할 수 있다.

S3의 메타데이터 설정 시, max-age는 브라우저의 캐시를 정하는 것이고, s-max-age는 CDN의 캐시를 설정한다.

따라서 max-age=60으로 지정 시 브라우저는 웹 사이트를 60초 동안 캐싱하고, s-max-age=600으로 지정 시 CDN이 S3 객체를 10분 동안 캐싱한다.

2024.11.05 추가

 

 

여기서 S3와 CDN의 캐시 설정이 헷갈려서 정리해 보았다. 글이 길어져서 따로 작성한 뒤 링크로 첨부한다.

 

S3, AWS CloudFront(CDN) 캐시 설정의 차이점

성능 개선 중 S3와 CDN에 배포를 진행하며 캐시를 설정했다.CDN과 S3의 데이터에 새롭게 변할 데이터가 거의 없을 것이라 판단하여 모두 캐싱 기간을 1년으로 설정해 두었다. 여기서 궁금한 점이

cho-sim-developer.tistory.com

 

 

 

CDN의 캐싱 설정
S3의 메타데이터 설정

 

 

 

 

CDN에 캐시 설정이 잘 되어 있는지 확인하려면?

 

 

  • CDN > 배포 경로로 들어가서 확인했을 때 캐시 정책 이름 부분이 채워져 있다면 캐시 정책이 잘 설정되어 있는 것이다.

 

 

 

 

캐시 정책 설정 후 웹 사이트로 다시 돌아가서, 개발자 도구를 열어 네트워크 탭을 열면 다음과 같은 프로퍼티가 있을 것이다.

 

  • 이는 X-Cache가 Hit from cloudfront로 뜬다면 server까지 거치지 않고 CDN에 캐싱된 데이터를 받아온다는 의미!

CDN 캐싱이 되어 있을 때의 소요 시간

 

  • Request Sent : 브라우저가 서버나 CDN에 요청을 보내는 시간
  • Waiting for server response (TFFB (Time To First Byte)) : 브라우저가 서버나 CDN에 파일을 요청한 뒤, 서버나 CDN으로부터 첫 번째 바이트가 응답될 때까지의 시간
  • Content Download : 서버나 CDN이 응답한 뒤 컨텐츠를 다운로드하는 데 걸리는 시간

 

 

만약 CDN에 캐싱이 되어 있지 않은 상태라면?

- CDN 캐시 무효화 후 진행해 봤다.

 

CDN에 캐싱이 되어있지 않은 상태

 

CDN 캐싱이 되지 않았을 때의 소요 시간

 

CDN에서 캐싱이 되어 있지 않기 때문에 CDN을 거쳐 서버까지 요청을 보내야 한다. 따라서 TTFB인 Waiting for server response가 캐싱되어 있을 때보다 (10.05ms) 시간이 오래 걸린다. (53.08ms)

 

 

 

1-2. CDN 압축 설정

 

 

브라우저에서 서버로 요청을 보낼 때 원하는 압축 형식을 명시할 수 있다. 여기서는 gzip과 deflate, br (brotli), zstd를 요청하고 있다. 이 형태로 압축된 파일을 보내주면 브라우저가 처리해 줄 수 있어!라는 의미이다.

 

 

 

 

 

CDN에 앞서 적용했던 CachingOptimized 설정해두면, 알아서 압축을 활성화시켜 준다. 현재는 gzip과 brotli 압축 방식이 적용되어 있다.

 

CDN 설정
서버로부터 받은 데이터 형식 : br로 압축된 형태

 

 

 

2. API 반복 호출 방지

 

특정 페이지에서 호출하는 API가 있을 때, 하지만 이 API를 통해 받아오는 데이터는 실시간으로 업데이트될 필요가 없을 때, API 반복 호출을 방지할 수 있다.

 

 

방법 1 : SessionStorage 활용

 

첫 번째 방법으로는 sessionStorage의 TRENDING_CACHE_KEY를 확인한 뒤, 값이 없다면 API를 호출하고 값이 있다면 API를 호출하지 않고 캐싱된 데이터를 꺼내오는 방식이 있다.

 

// fetchTrending 함수를 useCallback으로 저장
  const fetchTrending = useCallback(async () => {
    // 캐싱된 gif 데이터가 있는지 확인
    const cachedGifs = sessionStorage.getItem(TRENDING_CACHE_KEY);

    if (cachedGifs) {
      return JSON.parse(cachedGifs) as GifImageModel[];
    }
		
    // 캐싱된 gif 데이터가 없다면 API 호출
    try {
      const gifs = await gifAPIService.getTrending();
      sessionStorage.setItem(TRENDING_CACHE_KEY, JSON.stringify(gifs));
      return gifs;
    } catch (error) {
      handleError(error);
      return [];
    }
  }, []);

  // fetchTrending 함수의 주소값이 변경되거나 status가 변경 될 때 API 호출
  useEffect(() => {
    if (status !== SEARCH_STATUS.BEFORE_SEARCH) return;

    const fetchData = async () => {
      const gifs = await fetchTrending();
      setGifList(gifs);
      setStatus(SEARCH_STATUS.FOUND);
    };

    fetchData();
  }, [status, fetchTrending]);

 

Commit 보러 가기

 

 

 

방법 2 : CacheStorage API 사용

 

위 SessionStorage 방법으로는 S3나 CDN이 업데이트되었을 때 fetchTrending API가 호출되지 않는 문제점이 있다. SessionStorage가 사용자의 브라우저에 의해 제어되므로, 임의로 저장되어 있는 캐싱값을 삭제하거나 세션을 닫는 등 브라우저 조작을 통해 캐싱 데이터를 관리할 수 있다. 이를 사용자가 아닌 개발자가 제어할 수 있도록 하기 위해, CacheStorage API를 적용할 수 있다.

 

CacheStorage API는 Cache 객체에 데이터를 저장하고, open, match 등의 메서드를 통해 자유롭게 꺼내고 관리할 수 있다. Cache 객체에 시간 정보를 저장한 뒤 저장 시간으로부터 1시간이 지난 경우 API를 다시 호출하도록 하면 사용자는 주기적으로 새로운 데이터를 받을 수 있게 된다.

 

주의할 점으로는, Cache, CacheStorage는 항상 Promise를 반환하기 때문에 반환값을 사용하는 위치에서는 await과 같은 비동기 처리를 꼭 해야 한다.

 

 

 

 

최소한의 변경만 일으키기

 

1. 컴포넌트 리렌더링 방지

 

React는 부모 컴포넌트가 리렌더링 되면 자식 컴포넌트도 함께 리렌더링 되는 특징이 있다. 이를 해결하기 위한 방법으로 React.memo를 활용하는 방법이 있다.

 

React.memo에 대해 알아보기 전에, 컴포넌트가 리렌더링 되는지 확인하는 방법은 무엇일까? 바로 React Profiler를 사용하여 확인할 수 있다. 크롬 익스텐션의 React Developer Tools를 다운로드한 뒤 개발자도구에서 Profiler 탭을 선택하면 된다.

 

  • Profiler의 설정에서 컴포넌트가 렌더 될 때 하이라이트가 되도록 설정해 주면 컴포넌트가 렌더링 될 때 하이라이팅 되는 것을 볼 수 있다.

 

 

개선 전의 Profiler

개선 전

css minimizer plugin을 설정해 둔 상태라면 컴포넌트명이 c, m 등 축약되어 나오기 때문에 이때는 잠시 꺼두는 것이 좋다.

 

Profiler에서는 렌더링에 걸리는 시간을 막대로 보여준다.

  • 노란색 : 많은 시간이 소요되는 요소
  • 파란색 : 적은 시간이 소요되는 요소
  • 회색 : 현재 커밋에서 렌더링 하지 않는 요소
  • 빗금 : 렌더링 되지 않는 요소

 

아래 gif에서는 Load More를 클릭하여 새로운 컴포넌트를 불러올 때, 기존 컴포넌트들이 하이라이팅 (리렌더링) 되고 있다.

 

 

 

 

코드를 살펴보니, useGifSearch 훅에서 loadMore 함수를 실행하면 gifList에 새롭게 받아온 데이터를 넣어주고 있다. 

// useGifSearch.tsx

const useGifSearch = () => {
  const [gifList, setGifList] = useState<GifImageModel[]>([]);
  
  // ... 기타 코드들

  const loadMore = async (): Promise<void> => {
    const nextPageIndex = currentPageIndex + 1;

    try {
      const newGitList = await gifAPIService.searchByKeyword(searchKeyword, nextPageIndex);

      setGifList((prevGifList) => [...prevGifList, ...newGitList]);
      setCurrentPageIndex(nextPageIndex);
    } catch (error) {
      handleError(error);
    }
  };
  
  // ... 기타 코드들
  
  return {
    gifList,
    loadMore
    // ... 기타 데이터
  } as const;
};

 

 

이렇게 업데이트된 gifList를 SearchResult로 전달하고, 

// Search.tsx

const Search = () => {
  const { status, searchKeyword, gifList, searchByKeyword, updateSearchKeyword, loadMore } =
    useGifSearch();

  const handleEnter = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      searchByKeyword();
    }
  };

  return (
    <div className={styles.searchContainer}>
      <SearchBar
        searchKeyword={searchKeyword}
        onEnter={handleEnter}
        onChange={updateSearchKeyword}
        onSearch={searchByKeyword}
      />
      <SearchResult status={status} gifList={gifList} loadMore={loadMore} />
      <HelpPanel />
    </div>
  );
};

export default Search;

 

 

gifList props가 업데이트되어 SearchResult도 함께 리렌더링 된다.

// SearchResult.tsx

const SearchResult = ({ status, gifList, loadMore }: SearchResultProps) => {
  const renderGifList = () => (
    <div className={styles.gifResultWrapper}>
      {gifList.map((gif: GifImageModel) => (
        <GifItem key={gif.id} imageUrl={gif.imageUrl} title={gif.title} />
      ))}
    </div>
  );
  
  // ... 기타 코드들
};

export default SearchResult;

 

 

 

이를 해결하고자, React memo를 사용하기로 했다.

React memo는 props 참조값의 변경을 감지하고 변경된 사항이 있다면 렌더링을 시켜준다. 그러나 참조값이 변화가 없다면 memoized 된 컴포넌트를 반환한다.

 

gifList는 배열로 이루어져 있는데, 배열에 새로운 값을 추가해도 gifList가 가리키는 값은 변하지 않는다. (자바스크립트 배열의 얕은 복사 참고) 따라서 loadMore를 통해 gifList에 새로운 요소들이 추가되어도 React.memo는 props에 변경사항이 없다고 판단하여 메모이제이션 된 GifItem 컴포넌트를 보여준다. 

// GifItem.tsx

const GifItem = ({ imageUrl = '', title = '' }: GifItemProps) => {
  return (
    <div className={styles.gifItem}>
      <img className={styles.gifImage} src={imageUrl} />
      <div className={styles.gifTitleContainer}>
        <div className={styles.gifTitleBg}></div>
        <h4 className={styles.gifTitle}>{title}</h4>
      </div>
    </div>
  );
};

export default React.memo(GifItem);

 

 

 

 

아래 gif를 자세히 보면 loadMore를 클릭했을 때 gifList가 아닌 가장 바깥 영역만 하이라이팅 되는 것을 볼 수 있다.

 

 

 

개선 후 Profiler

개선 후

 

개선 후 Profiler를 통해 렌더링 되는 부분을 살펴보면, SearchResult의 컴포넌트들에 빗금이 쳐져 있다. 이는 해당 컴포넌트들이 리렌더링 되지 않았음을 의미한다.

 

 

 

2. Layout Shift 줄이기

 

Layout Shift란?

브라우저 컨텐츠의 요소가 예기치 못하게 이동되어 레이아웃이 변동되는 것

 

Cumulative Layout Shift (CLS) 

MDN - Layout Shift

 

Layout Shift를 발생시키는 요인으로는 웹 폰트, 이미지 사이즈, 그리고 CSS 속성 등이 있다. html 요소가 모두 렌더링 되었지만 이미지나 다른 리소스가 아직 다운로드되지 않은 상태에서, 뒤늦게 다운로드가 완료된 리소스를 화면에 보여주면서 레이아웃이 변동된다. 또한, top, bottom, left, right CSS 속성에 의해서도 발생할 수 있다.

 

 

2-1. hover 이벤트 시 Layout Shift 줄이기

 

Performance로 측정했을 때, 여기서는 요소에 커서를 올려 hover 이벤트가 발생할 때 Layout Shift가 발생한다.

 

 

개선 전 Performance 측정

개선 전

  • 사진의 아래쪽에 Layout Shift가 발생하는 것을 볼 수 있다.

 

코드에서 hover 시 top 속성을 변경시키고 있는데, 이는 reflow를 발생시킬 수 있다.

.gifItem:hover {
  top: -0.75rem;
}

 

 

top, bottom, left, right와 같은 속성을 사용하는 경우 페이지 레이아웃을 다시 계산해야 하고(Reflow), 페이지 내의 요소가 이동하면서 CLS(Cumulative Layout Shift)가 발생한다. 또한, 이 속성은 CPU를 사용하기 때문에 복잡한 연산을 함께 진행하는 경우 성능에 영향을 줄 수 있다. 

CLS는 Core web vitals에서 중요한 성능 지표 중 하나

 

 

 

따라서 top, bottom, left, right 속성을 transform으로 대체했다.

 

.gifItem:hover {
  transform: translateY(-0.75rem);
}

 

 

transform은 화면에서만 보여지는 작업으로, GPU를 사용한다. 브라우저는 렌더링 과정 중 Paint 단계에서 GPU 속성을 사용하는 CSS를 별도의 레이아웃으로 분리한다. 따라서 메인 스레드의 작업과는 분리되어 GPU에서 병렬적으로 작업이 이뤄진다. 따라서 reflow가 일어나지 않고 더 부드럽고 끊김없는 애니메이션이 가능하다.

 

 

개선 후 Performance 측정

개선 후

Layout Shift는 사라진 것을 확인할 수 있다.

 

 

2-2. CustomCursor의 Layout Shift 줄이기

 

마찬가지로, Home 페이지에서 마우스 이동 시 발생하는 Layout Shift도 없앴다. Home 페이지에서만 마우스 커서에 아래 이미지와 같은 CustomCursor 디자인이 적용되고 있었는데, top, left 속성이 적용되어 있었다.

 

 

 

개선 전 Performance 측정

개선 전

  • 마우스 커서를 이동할 때 Layout Shift가 발생하고 있다.

 

개선 방법

// 개선 전 코드

  useEffect(() => {
    if (cursorRef.current) {
      cursorRef.current.style.top = `${mousePosition.pageY}px`;
      cursorRef.current.style.left = `${mousePosition.pageX}px`;
    }
  }, [mousePosition]);

 

 

// 개선 후 코드

  useEffect(() => {
    if (cursorRef.current) {
      cursorRef.current.style.transform = `translate(${mousePosition.pageX}px, ${mousePosition.pageY}px)`;
    }
  }, [mousePosition]);

 

top, left 속성을 transform으로 변경했다.

 

 

 

개선 후 Performance 측정

개선 후

  • 마우스 커서를 이동해도 Layout Shift가 발생하지 않는다.

 

2-3. cursor::before에 top, left가 있어도 Layout Shift가 일어나지 않는 이유

 

CustomCursor의 CSS 속성 중 cursor::before에 top, left 속성이 있지만 이를 바꿔주지 않아도 Layout Shift가 발생하지 않았다.

.cursor {
  pointer-events: none;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  padding: 0 16px;
  color: var(--white);
}

.cursor::before {
  content: '';
  display: block;
  position: absolute;
  top: -12px;
  left: -12px;
  width: 24px;
  height: 24px;
  background: rgba(255, 255, 255, 0.25);
  border-radius: 50%;
}

.cursor span {
  display: inline-block;
  animation: wave-text 1s ease-in-out infinite;
}

 

이는 cursor::before의 position 속성이 absolute이기 때문에 부모 요소인 cursor를 기준으로 위치가 지정된다. 따라서 top, left 속성이 변경되어도 다른 요소들의 레이아웃에 영향을 주지 않아 reflow도 일어나지 않는다.

 

즉, top, left 속성이 변하더라도 부모 요소 내에서만 적용이 되고, 다른 요소들은 변경이 일어나지 않는다. absolute 지정으로 인해 이 요소가 파일의 정적 흐름에서 분리되어 다른 요소와의 상호작용이 없고, Layout Shift가 발생하지 않게 된다.

MDN - 쌓임 맥락 참고

 

 


2-4. 추가 최적화 방법 : will-change

 

추가로 will-change CSS 속성으로 최적화를 더 진행할 수 있는데, 결론부터 말하자면 큰 차이가 없어서 적용하지 않았다.

 

will-change: transform

 

will-change는 애니메이션이 많이 일어나는 요소에 적용하면 더 큰 최적화가 이뤄지도록 한다. 실제 요소가 변화되기 전에 브라우저가 미리 최적화할 수 있도록 돕는다. 하지만 성능 비용이 매우 크고, 가장 마지막 단계의 최적화이기 때문에 최대한 적은 요소에 사용하는 것이 좋다고 한다.

MDN - will-change

 

하지만 will-change를 적용해도 Lighthouse의 CLS 점수가 같고, 큰 변화가 없어서 결국에는 이 속성을 뺐다. 눈에 띄게 큰 최적화가 일어나지 않는다면, 성능 비용이 드는 작업을 굳이 추가할 필요가 없기 때문이다.

 

 

 

 

3. Frame Drop 줄이기

프레임이란 스크린에 보여지는 정지된 화면 한 장을 의미하고, 초당 프레임(FPS)이란 1초당 보여지는 프레임의 수를 의미한다. 60fps라면 1초당 60개의 프레임을 보여준다는 것을 뜻한다.

 

컴퓨터가 초당 프레임이 60fps일 때, 한 프레임을 로드할 때 1/60, 즉 16.67ms 시간이 소요된다. 만약 프레임이 로드될 때 16.67ms보다 오래 걸린다면 Frame Drop이 발생하게 된다.

 

 

Frame Drop는 아래와 같이 다양한 이유로 인해 일어난다.

  • 고해상도 이미지 및 비디오 처리 - 복잡한 렌더링 작업, 렌더링 시 GPU나 CPU 부하가 높아짐
  • JS 연산이 오래 걸리는 경우
  • 불필요한 DOM 업데이트
  • 애니메이션 및 CSS 성능 문제
    • 복잡한 애니메이션
    • reflow, repaint
  • 네트워크 문제
  • 브라우저 리소스 부족
  • 이벤트 핸들러 성능 문제

 

 

 

3-1. Home 페이지의 Frame Drop 줄이기

 

Home 페이지에서 Fast 4G 환경으로 Performance를 측정했을 때, Frame Drop이 발생하고 있었다. (붉은 빗금 부분)

 

개선 전

 

Home 페이지의 스크롤을 내리면 스크롤에 따라 배경에 그림이 그려지는 디자인이 적용되어 있었는데, 이 애니메이션이 Frame Drop을 유발하는 것으로 판단했다.

 

 

// AnimatedPath.tsx

const TOP_PERCENTAGE_OF_DRAW_POINT = 0.8; // 현재 보이는 화면의 80% 지점에서 선이 그려지는 게 보이도록 함

const AnimatedPath = ({ wrapperRef }: AnimatedPathProps) => {
  const pathRef = useRef<SVGPathElement>(null);
  const [strokeOffset, setStrokeOffset] = useState(0);

  const drawPath = () => {
    const wrapper = wrapperRef.current;
    const path = pathRef.current;

    if (!wrapper || !path) {
      return;
    }

    const drawPointY = window.scrollY + window.innerHeight * TOP_PERCENTAGE_OF_DRAW_POINT;
    const scrollRatio = (drawPointY - wrapper.offsetTop) / wrapper.offsetHeight;

    const pathLength = pathRef.current.getTotalLength();
    const currentScrollOffset = pathLength - pathLength * scrollRatio;

    setStrokeOffset(clamp(currentScrollOffset, 0, pathLength));
  };

  useScrollEvent(drawPath);
  
  // ... 기타 코드들
};

export default AnimatedPath;

 

// useScrollEvent.tsx

const useScrollEvent = (onScroll: ScrollHandler) => {
  useEffect(() => {
    const handleScroll = (event: Event) => {
      onScroll();
    };

    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [onScroll]);
};

export default useScrollEvent;

 

 

useScrollEvent의 props로 받는 drawPath 함수를 살펴보면 다음과 같이 비용이 많이 드는 작업이 있다.

  • wrapperRef.current나 pathRef.current와 같이 DOM 요소에 접근하는 작업
  • getTotalLength로 경로의 전체 길이를 계산
  • setStrokeOffset을 통해 상태 업데이트

이 작업들이 스크롤 이벤트가 발생할 때마다 이뤄지면서 성능에 부하를 줄 수 있다.

 

 

스크롤 이벤트와 프레임 애니메이션을 최적화하기 위해, requestAnimationFrame을 적용했다.

 

requestAnimationFrame은 반복되는 애니메이션을 브라우저의 repaint 주기에 맞춰 콜백 함수를 실행해 준다. 브라우저에게 실행할 콜백 함수 애니메이션을 알리고, 다음 repaint 직전에 콜백 함수를 호출하도록 한다. 이렇게 하면 repaint와 애니메이션 타이밍을 일치시킬 수 있고, 업데이트 타이밍에 맞춰 한 번만 실행되므로 불필요한 reflow나 repaint를 방지할 수 있다.

 

추가로, 해당 탭이 비활성 되어 있는 상태인 경우, 즉 애니메이션을 실행하지 않는 경우 애니메이션 루프를 멈춰 시스템의 리소스를 절약할 수 있다는 장점이 있다.

 

 

개선 후 코드

// useScrollEvent.tsx

const useScrollEvent = (onScroll: ScrollHandler) => {
  useEffect(() => {
    let requestScroll: number;

    const handleScroll = () => {
      requestScroll = requestAnimationFrame(onScroll);
    };
    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);

      if (requestScroll) {
        cancelAnimationFrame(requestScroll);
      }
    };
  }, [onScroll]);
};

export default useScrollEvent;

 

 

 

개선 후 Performance 측정

개선 후

 

  • 스크롤을 내려도 Frame Drop이 발생하지 않는다.

 

 

 

 

 

 

 

 

참고 자료

MDN - CacheStorage

MDN - Cache

What is CacheStorage? - 캐시스토리지를 사용해 보자

Profiler

React memo

React Profiler를 통한 렌더링 최적화

Cumulative Layout Shift (CLS) 

MDN - Layout Shift

[CSS] 왜 transform 애니메이션 성능이 좋을까? (with. GPU, Reflow)

MDN - 쌓임 맥락

MDN - will-change

MDN - Window: requestAnimationFrame() method