새소식

반응형
REACT/성능

시니어 개발자가 알아야 할 15가지 고급 React 기술

  • -
반응형

 

 

React 15가지 고급 기술 Text img

 

React 애플리케이션이 점점 더 복잡해짐에 따라, 초기에 사용하던 패턴들이 점차 한계를 드러낼 수 있습니다. 예를 들어, 간단한 MVP는 잘 작동하지만 시간이 지나면서 성능 문제나 상태 관리의 복잡성이 커질 수 있습니다.

 

이러한 문제는 주니어 개발자가 시니어로 성장하면서 마주하는 자연스러운 과정입니다. 다행히도, React에서 복잡한 문제를 해결할 수 있는 고급 기술들이 존재합니다.

 

이번 글에서는 useCallbackref의 효과적인 활용법부터 Suspense를 활용한 데이터 패칭, 가상화, 에러 핸들링, 성능 최적화까지 총 15가지 기법을 소개합니다. 글을 다 읽고 나면 더 탄탄한 React 개발 역량을 가질 수 있을 것입니다.

 

1. useCallback을 사용하여 서비스 참조 유지하기

useCallback은 이벤트 핸들러에서 함수 참조를 유지하는 데 주로 사용됩니다. 이를 통해 불필요한 리렌더링을 방지할 수 있습니다. 고급 개발에서는 이 패턴을 확장하여 WebSocket이나 Worker와 같은 지속적인 리소스의 참조를 유지하는 데 활용할 수 있습니다.

예시 코드:

function createConnection() {
  // 예를 들어, WebSocket 연결 설정
  return { sendMessage: (message) => console.log('Message sent:', message) };
}

function useConnection() {
  const connection = useRef(createConnection());

  // sendMessage 함수가 매번 새로 만들어지지 않도록 안정화
  const sendMessage = useCallback((message) => {
    connection.current.sendMessage(message);
  }, []);

  return sendMessage;
}

function App() {
  const send = useConnection();
  return <button onClick={() => send('Hello World!')}>Send Message</button>;
}

 


2. 상태 대신 ref를 사용하여 간결한 코드 작성

모든 값을 useState로 관리할 필요는 없습니다. useRef를 사용하면 렌더링과 관계없이 내부적으로 값을 유지할 수 있습니다. 예를 들어, UI와 무관하게 카운터 값을 증가시키는 경우 useRef를 사용하는 것이 더 효율적입니다.

예시 코드:

function Counter() {
  const count = useRef(0);

  const increaseCount = () => {
    count.current += 1;
    console.log('Current count:', count.current);
  };

  return <button onClick={increaseCount}>Increment</button>;
}

 


3. Suspense와 글로벌 리소스 캐시를 활용한 데이터 패칭

데이터 패칭을 할 때, 일반적으로 useEffect와 로딩 상태를 수동으로 관리합니다. 하지만 Suspense를 사용하면 이러한 로직을 간소화할 수 있습니다. 데이터가 아직 로드되지 않았다면 해당 컴포넌트는 자동으로 "대기 상태"로 전환됩니다.

예시 코드:

function createDataResource(fetchData) {
  let status = 'loading';
  let result;
  const promise = fetchData().then(
    (data) => {
      status = 'success';
      result = data;
    },
    (error) => {
      status = 'failure';
      result = error;
    }
  );

  return {
    fetch() {
      if (status === 'loading') throw promise;
      if (status === 'failure') throw result;
      return result;
    },
  };
}

const userResource = createDataResource(() =>
  fetch('/api/user').then((response) => response.json())
);

function UserProfile() {
  const user = userResource.fetch();
  return <div>Welcome, {user.name}!</div>;
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile />
    </Suspense>
  );
}

 


4. Suspense와 동적 import 및 미리 로딩

React.lazy()를 사용하여 컴포넌트를 지연 로딩할 수 있습니다. 이를 개선하여, 사용자가 컴포넌트를 필요로 할 때 빠르게 로딩되도록 미리 데이터를 가져오는 방법을 적용할 수 있습니다.

예시 코드:

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

function usePreloadChart() {
  useEffect(() => {
    import('./Chart'); // 미리 컴포넌트 로드
  }, []);
}

function Dashboard() {
  usePreloadChart();

  return (
    <Suspense fallback={<div>Loading chart...</div>}>
      <Chart />
    </Suspense>
  );
}

 


5. 자동 재시도하는 오류 경계(Error Boundaries)

앱에서 네트워크 요청이나 지연 로딩된 컴포넌트가 실패할 수 있습니다. 기존의 오류 경계는 대체 UI를 표시하지만, 이를 개선하여 일정 시간 후 자동으로 재시도하도록 만들 수 있습니다. 이렇게 하면 일시적인 오류를 원활하게 복구할 수 있습니다.

예시 코드:

import { useState, useEffect } from 'react';

function useRetryErrorBoundary() {
  const [hasError, setHasError] = useState(false);
  const [retries, setRetries] = useState(0);

  const resetError = () => {
    setHasError(false);
    setRetries((prevRetries) => prevRetries + 1);
  };

  useEffect(() => {
    if (hasError) {
      const retryTimeout = setTimeout(resetError, 3000);
      return () => clearTimeout(retryTimeout);
    }
  }, [hasError]);

  return { hasError, setHasError, retries };
}

function RetryErrorBoundary({ children }) {
  const { hasError, setHasError, retries } = useRetryErrorBoundary();

  const handleError = () => {
    setHasError(true);
  };

  if (hasError) {
    return <div>Retrying...</div>;
  }

  return children(retries, handleError);
}

function ProblematicComponent({ retries, onError }) {
  useEffect(() => {
    if (retries < 2) {
      onError();
    }
  }, [retries, onError]);

  return <div>Loaded after {retries} retries</div>;
}

// 사용법
function App() {
  return (
    <RetryErrorBoundary>
      {(retries, onError) => <ProblematicComponent retries={retries} onError={onError} />}
    </RetryErrorBoundary>
  );
}

 


6. 동적 항목 높이를 가진 리스트 가상화

리스트 항목이 동적으로 높이를 가지는 경우, react-window와 함께 useRef를 사용하여 항목의 크기를 추적하고 가상화할 수 있습니다. 이렇게 하면 메모리 사용량을 줄이고 렌더링 성능을 개선할 수 있습니다.

예시 코드:

import { VariableSizeList as List } from 'react-window';

function useItemSize(items) {
  const sizeMap = useRef({});
  const setItemSize = (index) => (el) => {
    if (el) {
      const height = el.getBoundingClientRect().height;
      sizeMap.current[index] = height;
    }
  };
  const getSize = (index) => sizeMap.current[index] || 50;
  return { setItemSize, getSize };
}

function VariableHeightList({ items }) {
  const { setItemSize, getSize } = useItemSize(items);

  return (
    <List height={400} itemCount={items.length} itemSize={getSize} width={300}>
      {({ index, style }) => (
        <div style={style} ref={setItemSize(index)}>
          {items[index]}
        </div>
      )}
    </List>
  );
}

 


7. 복잡한 UI 흐름을 위한 상태 머신(State Machine) 사용

UI 상태가 복잡해지면 조건문을 많이 사용하게 되는데, XState를 사용하면 상태와 전환을 명확히 정의할 수 있어 유지보수성이 높아집니다.

예시 코드:

import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';

const formMachine = createMachine({
  initial: 'editing',
  states: {
    editing: { on: { SUBMIT: 'validating' } },
    validating: {
      invoke: {
        src: 'validateForm',
        onDone: 'success',
        onError: 'failure',
      },
    },
    success: {},
    failure: {},
  },
});

function Form() {
  const [state, send] = useMachine(formMachine, {
    services: { validateForm: async () => {/* 검증 로직 */} },
  });

  return (
    <button onClick={() => send('SUBMIT')}>
      {state.value === 'editing' ? 'Submit' : state.value}
    </button>
  );
}

8. useTransition과 작업 큐를 이용한 제어된 동시성

React 18에서 useTransition을 활용하면 특정 상태 업데이트를 "비긴급(non-urgent)"으로 처리할 수 있습니다. 이를 통해 대량의 데이터를 가져오거나 무거운 계산을 수행할 때 UI가 멈추지 않고 반응성을 유지할 수 있습니다.

예제 코드:

function ComplexUI() {
  const [isPending, startTransition] = useTransition();
  const [data, setData] = useState([]);
  
  function loadMore() {
    startTransition(() => {
      setData((old) => [...old, ...generateMoreData()]);
    });
  }

  return (
    <>
      <button onClick={loadMore}>더 보기</button>
      {isPending && <span>데이터 로딩 중...</span>}
      <List data={data} />
    </>
  );
}

9. useImperativeHandle을 사용한 제어 가능한 컴포넌트 API 만들기

부모 컴포넌트가 자식 컴포넌트를 직접 제어해야 할 때 useImperativeHandle을 사용하면 부모가 자식 컴포넌트의 특정 메서드만 호출할 수 있도록 깔끔한 API를 설계할 수 있습니다.

예제 코드:

const FancyInput = forwardRef((props, ref) => {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    getValue: () => inputRef.current.value,
  }));

  return <input ref={inputRef} {...props} />;
});

function Parent() {
  const fancyRef = useRef();

  return (
    <>
      <FancyInput ref={fancyRef} />
      <button onClick={() => fancyRef.current.focus()}>입력 필드 포커스</button>
    </>
  );
}

10. 커스텀 훅을 활용한 점진적 하이드레이션 (Progressive Hydration)

SSR을 사용하면 빠르게 콘텐츠를 사용자에게 제공할 수 있지만, 큰 앱을 한 번에 하이드레이션하면 초기 상호작용이 느려질 수 있습니다. 페이지의 비핵심 부분을 지연시켜 하이드레이션하면 초기 로드를 더 빠르게 유지할 수 있습니다.

예제 코드:

function useProgressiveHydration(delay = 1000) {
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    const t = setTimeout(() => setHydrated(true), delay);
    return () => clearTimeout(t);
  }, [delay]);

  return hydrated;
}

function HeavyComponent() {
  const hydrated = useProgressiveHydration();
  return hydrated ? <ExpensiveTree /> : <Placeholder />;
}

11. 포털과 Suspense를 조합하여 무거운 모달 최적화

모달은 종종 크고 복잡하여, 처음부터 로딩하면 번들 크기가 커지고 성능이 저하될 수 있습니다. 이를 해결하기 위해 모달을 지연 로딩하고, 포털을 사용하여 메인 DOM 밖에서 렌더링하면 성능을 최적화할 수 있습니다.

예제 코드:

const HeavyModal = lazy(() => import('./HeavyModal'));

function PortalModal({ open }) {
  return open
    ? createPortal(
        <Suspense fallback={<div>Loading Modal...</div>}>
          <HeavyModal />
        </Suspense>,
        document.body
      )
    : null;
}

12. 부드러운 레이아웃 측정을 위한 useLayoutEffect 활용

useEffect는 화면을 그린 후 실행되므로 대부분의 경우 충분하지만, 특정 요소의 위치나 크기를 즉시 조정해야 할 때는 useLayoutEffect를 사용해야 합니다. 이를 통해 화면 깜빡임 없이 매끄러운 UI 업데이트가 가능합니다.

예제 코드:

function Popover({ anchorRef }) {
  const popoverRef = useRef();

  useLayoutEffect(() => {
    const anchorRect = anchorRef.current.getBoundingClientRect();
    popoverRef.current.style.top = `${anchorRect.bottom}px`;
  }, [anchorRef]);

  return <div ref={popoverRef} className="popover">Content</div>;
}

13. React Profiler API를 활용한 동적 성능 최적화

React Profiler API를 사용하면 렌더링 시간을 측정하고 성능이 느린 컴포넌트를 감지할 수 있습니다. 이를 통해 메모이제이션 임계값을 조정하거나 업데이트를 지연 처리하는 최적화 전략을 적용할 수 있습니다.

예제 코드:

import { unstable_Profiler as Profiler } from 'react';

function DynamicTuner() {
  const [threshold, setThreshold] = useState(10);

  function onRender(id, phase, actualDuration) {
    if (actualDuration > threshold) {
      // 성능 데이터를 기반으로 동적 최적화 수행
      setThreshold((t) => t + 5);
    }
  }

  return (
    <Profiler id="App" onRender={onRender}>
      <MyAppComponents />
    </Profiler>
  );
}

14. Immer와 useReducer를 활용한 불변 상태 관리

useReducer를 사용하면 상태 업데이트를 예측 가능하게 만들 수 있습니다. Immer를 활용하면 불변성을 유지하면서 객체를 직접 수정하는 듯한 코드를 작성할 수 있습니다.

예제 코드:

import produce from 'immer';

function reducer(state, action) {
  return produce(state, (draft) => {
    if (action.type === 'ADD_ITEM') {
      draft.items.push(action.payload);
    }
  });
}

function ComplexList() {
  const [state, dispatch] = useReducer(reducer, { items: [] });

  return (
    <button onClick={() => dispatch({ type: 'ADD_ITEM', payload: 'X' })}>
      Add Item
    </button>
  );
}

15. 윈도잉(Windowing)과 포털(Portals)을 활용한 대형 SVG/캔버스 요소의 효율적인 렌더링

대형 SVG나 캔버스를 다룰 때 렌더링 성능을 최적화하기 위해서는 필요한 부분만 렌더링하는 방식이 유효합니다. 리스트 가상화와 비슷한 개념으로, 윈도잉을 사용하여 필요한 부분만 렌더링하고 포털을 활용해 UI를 분리합니다.

예제 코드:

function Segment({ color, target }) {
  return createPortal(
    <svg width="200" height="100"><rect width="200" height="100" fill={color} /></svg>,
    target
  );
}

export default function App() {
  const containerRef = useRef(null);
  const [visible, setVisible] = useState([0, 1]);

  useEffect(() => {
    const onScroll = () => {
      const start = Math.floor(containerRef.current.scrollTop / 100);
      setVisible([start, start + 1]);
    };

    containerRef.current.addEventListener('scroll', onScroll);
    return () => containerRef.current.removeEventListener('scroll', onScroll);
  }, []);

  return (
    <div ref={containerRef} style={{ height: 200, overflowY: 'auto' }}>
      {[...Array(10)].map((_, i) => <div key={i} id={'seg' + i} style={{ height: 100 }} />)}
      {visible.map((i) => (
        <Segment key={i} color={i % 2 ? 'blue' : 'green'} target={document.getElementById('seg' + i)} />
      ))}
    </div>
  );
}

마무리 생각

이러한 기술들은 모든 코드베이스에서 한 번에 사용할 필요는 없습니다. 다만, 애플리케이션이 점점 복잡해지고 기존의 단순한 패턴들이 한계를 보일 때 유용하게 활용할 수 있습니다. 필요할 때 적절한 방법을 선택하여 적용하면 성능과 유지보수성을 크게 향상시킬 수 있습니다.

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.