리액트 최적화


React 최적화

1. 렌더링 최적화

1. 불필요한 렌더링 막기

만약 useState 의 변경으로 상태가 변경된다면, 상태에 관련있는 컴포넌트로 구성/분리한다.

참고 : https://overreacted.io/before-you-memo/

다음 코드의 문제점은?

export default function App() {
  let [color, setColor] = useState('red');
  return (
    <div>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
      <ExpensiveTree />
    </div>
  );
}

[after]

export default function App() {
  return (
    <>
      <Form />
      <ExpensiveTree />
    </>
  );
}

function Form() {
  let [color, setColor] = useState('red');
  return (
    <>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
    </>
  );
}

2. 참조타입 render밖에 선언하기

렌더링 역할을 하는 return 안에 참조가 되는 타입(함수, 객체, 배열) 을 직접 선언하지 않는다.

이렇게 되면 업데이트 될 때 값을 비교하는 코드가 있다면, 그 결과는 매번 다르다고 판단할 것이다. 따라서 다시 렌더링이 된다.

return (
 <>
  	<Title>{title is..}</Title>
	<ShowPost postClick={()=>{console.log('click handler')}} />
  </>
)

위코드의 postClick은 동일한 함수임에도 매번 생성되고 있다.
postClick의 인자로 들어가는 함수를 jsx 밖에 선언하고, 함수 캐시의 활용 기회를 만들자.

3. useCallback : 함수 캐시

함수를 props로 전달할때 매번 새로운 함수가 전달될 수 있다.
매번 새로운 함수를 생성할 필요는 없기 때문에 이를 재사용하도록 구현할 수 있음.

props로 함수를 전달받는 하위 컴포넌트는 새로운 props가 전달됐기 때문에 다시 렌더링 할 수 있다.

새로운 함수를 계속 생성하지 않고 기존 함수를 기억해서 사용하고, 이를 props로 전달하는 것이 React에서는 권장되는 방법이다.

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

여기서도 두번째 인자([a,b])가 함수 재사용을 할지에 대한 조건역할을 한다.
의존성배열을 설정하지 않으면 캐시되지 않음.

참고)
과연 모든 컴포넌트에 전달되는 함수를 캐시해야 하는가? 🤔
캐시된 함수를 하위컴포넌트에 전달해도 해당 컴포넌트는 렌더링을 하려고 한다. (리액트 특징)

4. useEffect 매번 실행되지 않게 하기

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // Only re-run the effect if count changes

두 번째 인자는 아래 다른 Hook에서도 콜백함수의 실행에 대한 조건으로 활용된다.

 useEffect, useMemo, useCallback

5. React.memo : 컴포넌트 렌더링 결과 재사용

불필요하게 자기 자신을 렌더링 하지 않게 하기.

아래 코드에서 ShowCountInfoMemo에 전달되는 props값은 대부분 동일하다. ShowCountInfo 컴포넌트는 매번 동일한 렌더링 결과를 만들고 있는 것으로 보인다.

[App.jsx]

//ShowCountInfo.jsx
const ShowCountInfo = ({postCount, pickedCount}) => {
    return (
        <div>
            <span>총 {postCount}의 post 중 {pickedCount}개가 선택됐습니다.</span>
        </div>
    )
}


//[App.jsx]
const App = () => {
  return (
          <div>
              <h1> Main Page ^^ </h1>
              <ShowCountInfo postCount={posts.length} pickedCount={pickedCount}/>
              <div onClick={rerender}>re-render</div>
              <div>{reloadCount}</div>
          </div>
      )
  }
}

개선이 필요하다.
React.memo 메서드를 통해서 컴포넌트 렌더링 결과를 캐시하도록 할 수 있다.

[ShowCountInfoMemo.jsx]

const ShowCountInfo = ({postCount, pickedCount}) => {
    return (
        <div>
            <span>총 {postCount}의 post 중 {pickedCount}개가 선택됐습니다.</span>
        </div>
    )
}

const ShowCountInfoMemo = React.memo(ShowCountInfo);

참고) 조건상황을 통해서 re-rendering을 결정할 수 있음.

const PressItemMemo = React.memo(PressItem, (prev, next) => {
   return (prev.newsData === next.newsData)
})

6. React.memo, UseCallback을 활용한 시나리오.

먼저 props로 function을 전달하고 싶은경우에는, useCallack을 통해 한번 감싼 function을 전달한다.
전달받은 컴포넌트는 React.memo를 통해서 감싼다.
React.memo는 props가 동일하다면 다시 렌더링되지 않도록 한다.
따라서 useCallback을 통해서 동일한 props를 전달받았다면 컴포넌트가 다시 렌더링 되지 않고, 렌더링 결과를 재사용하게 된다

7. 실습

https://codesandbox.io/s/react-react-memo-usecallback-problem-mrrqn

  1. Child 컴포넌트의 불필요한 Re-rendering 확인

  2. Child 컴포넌트에 React.memo 적용

  3. useCallback 을 사용해보기 (의존성 배열 필요, [])

8. useMemo : 연산 결과 캐시

아래 코드는 무엇이 문제인가?

import React, { useState, useMemo } from 'react';

const Child = ({ items }) => {
  const [count, setCount] = useState(0);

  const selectedItem = items.find((item) => {
    console.log('finding value');
    return item === 9;
  }); 

return (
    <>
        <h1>Count: {count} </h1>
        <button onClick={() => setCount(count + 1)}>Increment</button>
        <h1>selectedItem : {selectedItem}</h1>
    </>
    );
};

const App = () => {
    const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    return <Child items={items}></Child>;    
};

export default App;

리렌더링시 불필요한 중복계산이 일어나지 않도록 dependency 설정을 해서 캐시를 하도록 할 수 있음.
즉, 반복적인 리액트 렌더링 과정에서, 동일한 계산이 일어나는 문제를 막을 수가 있음.

const selectedItem = useMemo(() => {
    return items.find((item) => {
        console.log('finding value');
        return item === 9;
    });
}, [items]);

9. profiling : 렌더링 최적화 확인

개발자도구-React의 Profiler 패널을 확인한다.

‘profiling버튼’을 눌러서 렌더링과정을 녹화한다.

  • 몇번 실제 렌더링(commit)이 발생했는지 살펴본다. 예상과 달리 많은 렌더링이 일어나고 있지 않는지 확인한다.

  • 매번 렌더링이 발생할때마다 동일한 렌더링이 일어나는 것은 없는지 확인한다.

    • React.memo를 사용할때 같은 렌더링이 일어나지 않는 것도 확인할 수 있다.
  • 특별히 오래 걸리는 컴포넌트 렌더링은 없는가? 병목이 되는 지점을 찾고 원인을 찾아본다.

Profiler 중에 불필요한 component tree가 많이 보인다면 설정버튼을 눌러서, 필터링을 할 수 있다. (Context, forwardRef 등을 제거해보자)

10. 서버컴포넌트

  • 서버에서 처리 가능한 부분은 서버에서 처리하되, React 문법을 사용.

  • 서버에서 처리한 코드는 클라이언트에 내려줄 필요가 없음 번들링 사이즈 감소

  • 비즈니스 로직이 많은 경우 서버컴포넌트 활용이 유리함

예시코드

// Server Component
import Expandable from './Expandable';

async function Notes() {
  const notes = await db.notes.getAll();
  return (
    <div>
      {notes.map(note => (
        <Expandable key={note.id}>
          <p note={note} />
        </Expandable>
      ))}
    </div>
  )
}

서버컴포넌트의 결과(RSC Payload) 는 아래의 형식으로 전송됨

196c2c6dffdb1bad35a70a369c9a05d0e92e316b-1540x578


2. React 기타 최적화

1. Code-splitting

React.lazy 를 활용한 dynamic import (ES Modules 스펙)

  • TC39 stage 4( Finished )

React.lazy로 선언된 컴포넌트는 production build에서 별도 파일로 분리됨(code - splitting).

참고. React.suspense 와 함께 사용해야 함

  • 비동기 요청이 오기 전 pending상태에서 효과적으로 fallback UI(loading~~)를 효과적으로 노출 할 수 있음.

  • 렌더링 이후 fetch가 아닌, fetch와 동시에 렌더링이 가능한 기술

  • 참고: https://react.dev/reference/react/Suspense

오리지날 코드

import './App.css';
import { NewsListCount } from './NewsList';
import { PressNameList } from './PressNameList';
import React, { Suspense } from 'react';
import { TestComponent } from './TestComponent.jsx';
import PressDetail from './PressDetail';

function App() {
    return (
        <div className="App">
            <TestComponent />
            <Suspense fallback="loading...">
                <h1> hello world </h1>
                <NewsListCount />
                <PressNameList />
                <PressDetail />
            </Suspense>
        </div>
    );
}

export default App;

code splitting 작업결과
import './App.css';
import { NewsListCount } from './NewsList';
import { PressNameList } from './PressNameList';
import React, { Suspense } from 'react';
import { TestComponent } from './TestComponent.jsx';

//lazy로 감싸주기
const PressDetailLazy = React.lazy(() => import('./PressDetail'));

function App() {
    return (
        <div className="App">
        <TestComponent />
        <Suspense fallback="loading...">
            <h1> hello world </h1>
            <NewsListCount />
            <PressNameList />
            <Suspense fallback="loading...">
            	<PressDetailLazy />
            </Suspense>
        </Suspense>
        </div>
    );
}

export default App;

code-splitting & on-demand lazy loading
import './App.css';
import { NewsListCount } from './NewsList';
import { PressNameList } from './PressNameList';
import React, { Suspense } from 'react';
import { useRecoilValue } from 'recoil';
import { currentDetailPressIDAtom } from './Store';
import {TestComponent} from './TestComponent.jsx';
const PressDetailLazy = React.lazy(() => import('./PressDetail'));

function App() {
    const currentPressId = useRecoilValue(currentDetailPressIDAtom);

    return (
        <div className="App">
            <TestComponent />
            <Suspense fallback="loading...">
                <h1>recoil fetch test</h1>
                <NewsListCount />
                <PressNameList />
                {currentPressId && (
            	    <Suspense fallback="loading...">
            	        <PressDetailLazy />
            	    </Suspense>
                )}
            </Suspense>
        </div>
    );
}

export default App;

react-router 에서의 lazy-loading
function App() {
    const [bNewFormView, toggleNewFormView] = useState(false);

    const MileStoneViewLazy = React.lazy(() => import('./views/MileStoneView/MileStoneView'));
    const GNBLazy = React.lazy(() => import('./views/GNB.js'));

    return (
        <Router>
            <Main>
                <GlobalStyle />
                <Suspense fallback={<div>Loading...GNB</div>}>
                    <GNBLazy {...{ toggleNewFormView }} />
                </Suspense>
              
                <Route path="/labels">
                    <Content
                        bNewFormView={bNewFormView}
                        toggleNewFormView={toggleNewFormView}
                    ></Content>
                </Route>

                <Route path="/milestone">
                    <Suspense fallback={<div>Loading...GNB</div>}>
                        <MileStoneViewLazy></MileStoneViewLazy>
                    </Suspense>
                </Route>
            </Main>
        </Router>
    );
}

useTransition

  • 상태 업데이트를 “transition”으로 처리하여, 우선순위가 낮은 업데이트로 처리되도록 함

  • 끊김없는 UX가능(Interruptible )

  • 사용자 인터페이스가 즉각적인 응답성을 유지하면서도, 배경에서 상태 업데이트가 처리.

  • 언제 사용하지?

    • 다른 UX에게 양보하고 싶을때.

    • 불필요한 loading indicator 를 노출하지 않고 싶을때

useDeferredValue:

  • 특정 상태의 업데이트를 지연시켜서 자식 컴포넌트에 전달.

  • 이로 인해 고빈도 상태 업데이트가 UI 렌더링에 미치는 영향을 줄임.

    • 반복적인 상태 업데이트시 최종상태만 반영.
  • 예를 들어, 입력창의 반복적인 빠른 업데이트가 다른 복잡한 컴포넌트의 렌더링을 방해하지 않도록 할 때 유용.

  • 언제 사용하지?

    • 빈번한 업데이트가 많아서 UX에 방해가 될때. 렌더링을 아예 지연시켜 버린다.

    • 이런점에서 transition 보다 더 성능상 유리하게 동작할 수 있음.

웹프론트엔드 최적화

최적화란?

1. 웹 사이트 최적화 방법

  • 베스트 : 아무것도 하지 않는다.

  • 현실적인 선택 : 덜 일한다.

2. 웹 사이트 체감 속도

3. 결국, 개선해야 할 항목은 ?

**- 첫 페이지 로딩을 빠르게 하기

  • 훌륭한 반응

  • 부드럽게 이어져보이기 **

4. 최적화 작업 진행 방법

진단 > 개선 > 테스트 > 진단

  1. 진단
  • 어디가 왜 얼마큼 느린가?

  • 어느 인터랙션이 사용자에게 중요한 가치를 주는가?

  • 정량적인 지표는 얼마인가?

  • 어떻게 진단하는가?

    • 크롬 개발자도구 - lighthouse

    • 크롬 개발자도구 - performance, memory(누수는 없나?)

    • pagespeed insights

  1. 개선

  2. 테스트

    • side effect는 없는가?
  3. 진단

    • 어디가 얼마큼 빨라졌는가?

모던 웹 개발에서의 최적화 요약

1. 공통 원리

바닐라 JavaScript나 Framework 기반 프로젝트에서 성능 개선의 원리는 유사함
SPA에서는 로딩 지연, 반응 지연, 애니메이션 문제는 Framework와 관계없이 발생 가능

2. Framework의 장단점

Framework는 개발 생산성을 높이는 데 초점이 맞춰져 있으므로 내부 최적화 로직이 포함되어 있음
그러나 최적화에 대한 구체적인 책임은 여전히 개발자에게 있음

3. 개발자가 고려해야 할 점

불필요한 코드와 의존성을 줄이는 것이 기본
성능 병목 구간을 지속적으로 모니터링하고, 적절한 도구와 전략을 활용하여 개선


실제 문제 해결방법

1. 첫 페이지 로딩 지연문제 해결

  • HTML Parsing 방해하지 않기

    • script 위치 조정 (body 끝으로 이동)

    • defer/async 속성 활용

  • Build를 통한 코드 최적화

    • 코드 압축 (minification) 및 번들링

    • 코드 스플리팅 (Code Splitting)

    • ViteESBuild와 같은 최신 빌드 도구 활용

  • 클라이언트 동적 렌더링 피하기

    • Server-Side Rendering (SSR)

    • Pre-Rendering (예: prerender-loader)

    • Static Site Generation (SSG) 활용 가능 여부 확인

  • Lazy Loading

    • Lazy Component Loading (Dynamic JS Loading)

    • Image Lazy Loading (loading="lazy")

    • IntersectionObserver API 활용

  • HTTP Header의 속성 활용

    • Cache-Control (max-ageno-cacheno-store)

    • Expires (캐시 만료 시간)

    • Last-Modified / If-Modified-Since (마지막 갱신 시간)

    • ETag 활용: 리소스 변경 여부를 서버에서 판단

      • 서버가 리소스를 제공할 때 ETag 값을 생성해 응답 헤더에 포함 (ETag: “abc123”)

      • 브라우저는 이후 요청에서 If-None-Match 헤더에 ETag 값을 포함 (If-None-Match: “abc123”)

      • 서버는 ETag를 비교 (값이 같으면 리소스가 변경되지 않았으므로 304 Not Modified를 반환)

  • 이미지 최적화

    • WebP, AVIF 포맷으로 이미지 변환

    • WOFF2 폰트 활용: WOFF보다 더 작은 크기로 압축 가능

    • 기존 폰트 파일을 WOFF2로 변환하여 로딩 속도 개선


2. 반응 지연, 애니메이션 지연 해결

  • 메인 스레드 Blocking 방지

    • DOM 수정 최소화 (Reflow와 Repaint 단계 제거 노력)

    • Reflow 줄이기

      • Composite 단계에서 동작하도록 GPU 가속 속성 사용

      • 3D Transform (translate3d)

      • Video/Canvas 요소 활용

      • transform/opacity 기반 CSS 애니메이션 (Keyframes, Transition)

      • will-change 속성 적용

  • 중복 계산 줄이기

    • Memoization (useMemo)
  • 네트워크 요청 캐시 전략

    • Service Worker 및 PWA를 활용한 캐싱

    • Cache Header 전략

  • Prefetch 및 Preload 활용

    • Preload 사용 :

    • 특정 리소스가 사용될 것을 확실히 알고 있을 때, 브라우저가 이를 우선적으로 다운로드

    <link rel="preload" href="/styles/main.css" as="style">
    <link rel="preload" href="/scripts/main.js" as="script">
    
    • Prefetch 사용 :

    • 예측 로딩이나 사용자가 다음에 필요할 리소스를 미리 로드할 때 사용.

    <link rel="prefetch" href="/images/background.jpg">
    

성능 관점으로 보는 HTTP의 발전

1. HTTP/1.1 (1997년)

  • TLS 암호화 통신

  • Keep-Alive 기본 제공 (TCP 연결 재사용)

  • XMLHttpRequest


2. HTTP/1.1의 한계

  • 리소스 우선순위 없이 다운로드

  • 헤더 크기 증가

  • 동시성을 위해 여러 개의 연결(connection) 필요


3. HTTP/2.0 (2015년)

  • HTTP/2.0의 스트림-프레임 관계

    • 여러 개의 스트림을 동시에 전송 가능

    • 각 스트림은 다수의 프레임으로 구성

      • 예:

        • 스트림 1 → 프레임 A, B, C

        • 스트림 2 → 프레임 X, Y, Z

    • 프레임은 독립적으로 전송되며 고유 ID를 가짐

    • 스트림과 프레임의 독립적 처리를 통해 병렬 데이터 전송 가능

  • 기능 개선

    • 다중 요청/응답 처리 (Multiplexing)

    • 헤더 필드 압축 (HPACK)

    • Server Push 지원


4. HTTP/3 (2020년)

  • QUIC 프로토콜 기반

    • UDP 위에서 동작하며 TCP의 오버헤드를 제거

    • 3-way Handshake 생략

    • 패킷 손실 복구를 위해 패킷 재전송 및 일련번호 지정

  • 병렬 처리 강화

    • 여러 개의 요청과 응답이 하나의 연결에서 동시에 처리

    • 프레임 단위로 분리된 UDP 패킷 사용

  • HTTP/3 적용 사례

    • 주요 CDN 제공자들이 HTTP/3 지원 (Cloudflare, AWS CloudFront 등)