프론트엔드 성능 최적화

늘보·2021년 12월 7일
3

성능 최적화

목록 보기
1/1

프론트엔드 성능 최적화가 왜 필요할까?

아마 많은 사람들은 이미지, 혹은 데이터가 느리게 로딩되는 사이트를 싫어할 것이다. 성능 최적화의 중요성은 TOAST UI에서 작성한 '성능 최적화' 글의 일부를 보면 더 쉽게 이해할 수 있다.

Pinterest는 긴 로딩 시간으로 인해 사용자가 페이지를 나가는 비율이 높았는데, 최적화를 통해 사용자 이탈율을 줄이고 매출은 40%로 증가시켰다.

회사 입장에서 저 정도의 매출 상승이라면, 당연히 성능 최적화를 중요하게 여기지 않겠는가? (물론 작성자는 아직 회사를 다녀본 경험이 없다)

성능 최적화가 이전보다 더 중요해진 이유

요즘 만드는 많은 애플리케이션은 이전과는 다르게 SPA(Single Page Application) 방식으로 제작되고 있다. SPA 방식은 하나의 HTML 파일을 제외하고 모두 자바스크립트 파일로 이뤄지기 때문에 파일 자체 또한 무겁다.

또한, 요즘의 웹 애플리케이션은 수많은 비동기 통신, 복잡한 UI 등이 추가되며 더 크고 무거워지게 된다. 웹 애플리케이션이 크고 무거워질수록 성능은 하락하게 되고, 위에서 봤듯이 이는 사용자 이탈로 이어질 수 있다. 따라서 꼭 성능을 개선해야겠지?

LightHouse 사용하기

Lighthouse: 크롬 브라우저에서 사용할 수 있는 성능 측정 도구로, Performance, Accessibility, Best Practice, SEO 분야에서 점수를 측정하여 보여준다.

  • Performance는 화면에 콘텐츠가 얼마나 빨리 표시되는지를 나타낸다.

  • Accessibility는 서비스의 접근성을 검사한다. <img> 태그에 alt 속성이 있는지, aria-* 태그가 role과 잘 맞는지 등에 대해 검사한다.

  • Best Practice에서는 웹에대한 표준 모범 사례를 잘 따르고 있는지 확인한다. console의 오류를 확인하는 등의 작업을 수행한다.

  • SEO는 서비스가 검색 최적화되어있는지 확인한다. robots.txt 파일이 유효한지, <title> 태그가 적절하게 들어갔는지 등을 확인한다.

성능 최적화 시작!

빌드 파일의 크기 줄이기

브라우저에서 어떤 사이트에 처음으로 들어간다고 했을 때, 브라우저는 서버에 이 사이트에 대한 내용들을 보내달라고 요청하게 된다. 이 요청의 크기가 작으면 작을수록 최초 페이지 로딩이 빨라질 것이다.

그렇기 때문에 일단 빌드하여 서버에 올려놓은 클라이언트 관련 파일의 크기를 줄이는 것이 첫 번째 Step이다. 일반적으로, 이런 파일 크기를 줄이는 데는 웹팩 관련 설정들을 먼저 고려해야 한다.

1. Production mode 사용하기

Webpack 5에서 production mode로 실행시키면, 성능 최적화에 도움이 되는 여러 플러그인들을 제공해주는데, 그 중 한가지인 TerserPlugin에 대해 알아보자.

TerserPlugin은 JS 코드를 줄여주고, 압축해준다(Minify). 난독화(Uglify)도 기본적으로 진행한다.

  • 압축화 - 모든 들여쓰기와 공백이 제거되고, 전체 코드가 한 줄로 병합됨. 원본 코드에서 들여쓰기, 공백, 콤마 등이 제대로 사용되지 않았다면 압축된 코드에서 문제가 생길 수 있음.

  • 난독화 - 자바스크립트 코드 자체를 분석하기 어렵게 만드는 과정. 변수, 함수명 등이 줄어 용량 감소. 하지만 난독화 단계가 높을수록 코드를 해석하고 실행하는 속도가 느려질 수 있음.

그렇기 때문에 Webpack5 production 모드를 사용하면 따로 압축이나 난독화를 해주지 않아도 최적화 작업이 적용된다. 자신이 production mode를 사용하는지는 아래와 같이 React Developer Tools for Chrome을 사용하면 확인할 수 있다.

2. 이미지 파일 최적화

이미지 파일을 최적화 하는 것은 성능 향상에 매우 큰 영향을 미친다. 사실상 가장 큰 요인이라고 봐도 무방할 정도다. 사실 이미지 최적화를 하지 않았다면, JS 파일을 모두 합쳐도 이미지가 압도적으로 큰 용량을 차지할 것이다.

png, jpg, gif 형식인 이미지들을 최적화 시켜보자. 특히, 그 중 gif 파일은 압도적으로 비효율적이다. 다음 사진을 보자. 매우 큰 차이를 보인다.

이렇게 GIF 파일을 사용할 경우, 특히 대역폭이나 사용량이 제한되어 있는 모바일 네트워크에서는 사용자 입장에서 손해가 더 크게 느껴진다. 모바일 웹이 보편화된 요즘, 당연히 고려해야만 하는 부분이다.

이렇듯 비효율적인 GIF 파일을 최적화 하는 방법 중의 하나는 MP4 형식으로 바꾸어 최적화하는 것이다. Convertio 등의 사이트를 활용하면 파일 형식을 간단하게 바꿀 수 있다. 이런 경우, 비디오 파일 형식인 MP4 포맷을 autoplay, muted, loop 속성을 넣어 마치 GIF처럼 보이게 할 수 있다.

<video
  src={imageSrc}
  type='video/mp4'
  autoPlay
  muted
  loop
  playsInline
/>

추가적으로, png, jpg 이미지 파일 형식 또한 마찬가지로 비효율적이다. 이에 구글에서 제공하는 WebP/WebM 등의 새로운 형식은 확실히 1000px 이상의 큰 사이즈 이미지를 제외하면 꽤나 효율적이라고 한다. 마찬가지로 위에서 언급한 Convertio 등의 파일 변환 툴을 이용해 변환할 수 있다.

그러나 WebP/WebM 형식은 단점도 있다! 비 구글 제품(IOS 등)에 대한 지원이 많이 부족하다고 하며, 아직 사용하기 적절할 정도로 널리 보급된 파일 형식이 아니다.

이미지 스프라이트

이미지 스프라이트(Image Sprite)란, 여러 개의 이미지를 독립적으로 사용하지 않고 단일 이미지로 결합한 다음 CSS를 적용해서 필요에 따라 개별 이미지로 사용(표시)하는 것이다.

다음과 같이 웹 페이지에서 아이콘마다 다른 이미지 파일을 사용할 경우 리소스 요청이 7번 이상 발생한다. 이런 경우 이미지 스프라이트 기법을 사용하여 요청을 1번으로 줄일 수 있다.

아래는 이미지 스프라이트를 적용한 예시이다. css의 background-position 속성을 사용해 부분적으로 이미지를 보여주는 방식으로 구현되었다.

<button class="btn">확인</button>
.btn {
  background-image: url(../images/icon-sprite.png);
  background-position: 10px 10px;
  width: 20px;
  height: 20px;
}

다음 이미지는 이미지 스프라이트를 적용했을 때와 하지 않았을 때를 보여주는 단적인 예시다.

여러 개의 이미지를 사용한 경우(최적화 전)

이미지 스프라이트를 사용한 경우(최적화 후)

눈에 띄게 성능이 향상된 것을 확인할 수 있다.

리액트에서의 성능 최적화

작성자는 FE 라이브러리로 React를 사용하고 있다. 그럼 React에서 성능 향상을 할 수 있는 방법은 무엇이 있을까?

1. useMemo()

  • 이 함수는 React Hook 중 하나로서 React에서 CPU 소모가 심한 함수들을 캐싱하기 위해 사용된다.
function App() {
    const [count, setCount] = useState(0)
    
    const expFunc = (count)=> {
        waitSync(3000);
        return count * 90;
    }
    const resCount = expFunc(count)
    return (
        <>
            Count: {resCount}
            <input type="text" onChange={(e)=> setCount(e.target.value)} placeholder="Set Count" />
        </>
    )
}

위 코드의 expFunc은 3분 후 실행되는 비싼 함수이다. 이 함수는 count를 입력받아 3분을 기다린 후 90을 곱하여 리턴한다. 또한 useState hook에서 count 변수를 받아 expFunc를 실행하는 resCount 또한 확인할 수 있다. 여기서 count는 입력할 때마다 값이 변경되어야 한다.

짧게 말하면, count가 변할 때마다 렌더링하는 데 최소 3분이 소요된다는 것이다. (상태값이 변할 때마다 렌더링을 새로 하기 때문) 중간에 다른 입력값을 다시 넣게 된다면(=상태값이 변한다면) expFunc는 또 3분동안 실행이 된다.

위와 같은 문제가 있을 때 useMemo를 사용할 수 있는데, 이 훅은 다음과 같은 구조를 가진다.

useMemo(() => function, [input_dependency])
  • function: 캐시하고 싶은 함수를 뜻한다.

  • input_dependency: useMemo가 캐시할 func에 대한 입력의 배열로서 해당 값들이 변경되면 function이 호출된다. (상태값이 들어간다는 말로 이해했다)

useMemo를 이용하여 위의 코드를 최적화 한 결과는 다음과 같다.

function App() {
    const [count, setCount] = useState(0)
    
    const expFunc = (count)=> {
        waitSync(3000);
        return count * 90;
    }
    const resCount = useMemo(()=> {
        return expFunc(count)
    }, [count])
    return (
        <>
            Count: {resCount}
            <input type="text" onChange={(e)=> setCount(e.target.value)} placeholder="Set Count" />
        </>
    )
}
  • 이제 expFunc는 입력에 대해 캐싱된다.

동일한 입력이 다시 발생할 때 useMemoexpFunc을 호출하지 않고 입력에 대해 캐시된 결과값을 리턴한다.

2.React.lazy를 통한 코드 스플리팅

React.lazy를 이해하기 위해서는 번들링에 대해 먼저 이해해야 한다. 다음을 보자.

대부분의 React App들은 Webpack 등과 같은 툴을 사용하여 여러 파일을 하나로 병합한(=번들링 된) 파일을 웹 페이지에 포함하여 한번에 전체 앱을 로드한다.

번들링은 다음과 같이 이루어진다.

// app.js
import { add } from './math.js';

console.log(add(16, 26)); // 42
// math.js
export function add(a, b) {
  return a + b;
}
// 번들링 된 파일이다. 실제 번들은 이 예시와는 많이 다르게 보인다.
function add(a, b) {
  return a + b;
}

console.log(add(16, 26)); // 42
  • 번들링은 이전의 각 파일들마다 서버에 요청하여 리소스를 얻어오던 방식에서 하나로 묶어서 요청/응답을 받기 때문에 네트워크 코스트가 줄어들게 된다.

  • 또한 Webpack 4버전 이상부터는 development, production 모드를 지원하면서(위쪽에서 언급한 부분이다!), production 모드로 번들링을 진행할 경우 최적화를 지원해주기 때문에 성능적인 측면에서도 이점이 있다.

이렇듯, 번들링은 훌륭하지만 앱이 커지면 커질수록 번들 또한 커지게 된다.

SPA(Single-Page-Application)에서는 이렇게 최초 접근 시 번들된 파일을 단 한번만 다운로드한다. 만약 번들이 매우 크다면, 최초 접근 시 페이지가 로드되는 속도가 매우 느릴 것이다. 이렇게 거대한 번들을 쪼갠다면, 로드 시간 또한 이전보다 향상될 것이다. 이런 경우 코드 분할을 지원하는 React.lazy를 사용하게 된다.

정확히 말하면 동적 import() 문법이 코드 분할을 도입하기 가장 좋은 방법이다. 웹팩이 이 구문을 만나면 앱의 코드를 분할한다. 그리고 React.lazy 이렇게 동적 import를 사용해서 컴포넌트를 렌더링 할 수 있게 해준다.

React.lazy는 동적 import()를 호출하는 함수를 인자로 가진다.

import React, { Suspense } from 'react';

// 동적 import가 된다.

// 원래 컴포넌트 import를 이렇게 했을 것이다.
// import OtherComponent from './OtherComponent';

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}
  • lazy 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링되어야 하며, Suspenselazy 컴포넌트가 로드되길 기다리는 동안 로딩 화면과 같은 예비 컨텐츠를 보여줄 수 있게 해준다.
import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
    // 바로 이렇게!
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

그러나, 서버 사이드 렌더링을 적용해야 할 때는 React.lazy를 적용할 수 없다. 서버 사이드 렌더링을 적용해야 할 때는 Loadable Comopents를 사용해야 한다.

react-loadable의 사용과 코드 스플리팅에 대한 더 상세한 내용이 필요하다면 다음 블로그를 꼭 읽어보자. 내용이 매우 길지만, 그만큼 매우 좋은 글이다!

리액트 프로젝트 코드 스플리팅 정복하기

3. React.memo()

  • React.memo()는 Functional Components를 캐시하는데 사용된다.
function My(props) {
    return (
        <div>
            {props.data}
        </div>
    )
}
function App() {
    const [state, setState] = useState(0)
    return (
        <>
      		// 클릭할때마다 이벤트가 발생한다.
            <button onClick={()=> setState(0)}>Click</button>
            <My data={state} />
        </>
    )
}

여기서 App 컴포넌트는 state라는 상태를 propsMy 컴포넌트로 전달한다. 버튼 엘리먼트의 onClick을 보면 클릭할 때마다 state 값을 0으로 변환해주는 작업을 한다. 만약 버튼을 계속 누른다면, state 값이 동일함에도 불구하고 My 컴포넌트는 계속 리렌더링이 된다.

만약 하위 컴포넌트가 많이 존재하면 존재할수록 하위 컴포넌트도 모두 리렌더링이 될 테니, 성능 이슈가 크게 발생할 것이다..😂

따라서 이런 잦은 리렌더링 방지를 위하여, My 컴포넌트를 memoized 버전으로 리턴하는React.memo를 이용하여 한번 감싼 후 App에 포함시켜줘보자.

function My(props) {
    return (
        <div>
            {props.data}
        </div>
    )
}
// React.memo 적용
const MemoedMy = React.memo(My)
function App() {
    const [state, setState] = useState(0)
    return (
        <>
            <button onClick={()=> setState(0)}>Click</button>
            <MemeodMy data={state} />
        </>
    )
}
  • 위와 같이 변경하면 버튼을 클릭하더라도 My 컴포넌트는 한번만 렌더링 된 후 다시 리렌더링 되지 않는다. 이것은 React.memoprops값을 기억한 후 캐싱된 결과를 리턴하기 때문에 동일한 입력에 대해서는 My 컴포넌트를 실행하지 않기 때문이다.

결론

  • 지금 작성한 방법보다, 프론트엔드 성능 최적화를 위한 방법은 훨씬 더 많다. 내가 작성한 글은 극히 일부에 불과하다. 나중에 시야가 조금 더 열리고, 기술적으로 이해하는 부분이 더 깊어진다면 성능 최적화에 대해 글을 한번 더 작성해야겠다 :)

  • 글을 읽고, 이해하고, 작성하면서 개념적인 내용을 정말 많이 배운 것 같다. 스터디 하기 잘한 것 같다😀 (다른 사람에게 내가 작성한 내용을 발표한다고 생각하니 이해를 할 수 밖에 없다..!)

참고 링크

2개의 댓글

comment-user-thumbnail
2022년 5월 4일

허어어얼 프론트는 처음 공부하는데!!! 완전 유용해요.

1개의 답글