본문 바로가기
FRONT-END/React

React에서 컴포넌트를 분리한다는 관점에 대해 다시 생각해보기

by 랄라J 2024. 10. 21.

이 글의 시작은 지뢰 찾기 게임을 만들었는데, 그 코드 중 cells 데이터로 map을 돌리고, row로 다시 map을 돌려 버튼을 만드는 코드를 <Row /> 컴포넌트로 분리하면 성능이 향상된다는 말에 의문을 품은 게 시작이었습니다.

{cells.map((row, rowIndex) =>
  row.map((cell, colIndex) => (
    <Button .../>
  )),
)}

컴포넌트 분리에 대한 대화를 나누면서도 확실하게 해소되지 않는 부분이 생겼기 때문입니다.

컴포넌트 분리에 대해 다시금 이해하고, 저만의 원칙을 정한 뒤 작업이 필요할 것 같다는 판단이 들었습니다.

 

React에서 컴포넌트를 분리하는 목적은 뭘까요? 왜 분리해야할까요?

잘 알고 있듯 코드의 재사용성, 가독성, 유지보수성을 높이고, UI 구조를 명확하게 하기 위함이라고 합니다.

그 외에도 성능 관점에서 React는 컴포넌트로 분리하면 React가 더 작은 단위로 비교를 수행할 수 있어 성능 향상을 가져온다고 합니다.

 

제가 의문을 갖게 된 포인트는 작은 단위로 비교를 수행할 수 있어 성능 향상을 가져온다는 이 부분입니다.

성능 관점에서 작은 비교로 수행할 수 있어 성능 향상을 가져온다는 부분이 리액트 만들기를 통해 fiber를 공부했음에도 아직은 낯설고 이해되지 않는 포인트였기 때문입니다.

코드의 재사용성, 가독성, UI 구조 명확화는 사실 컴포넌트를 분리해 보면 바로 느낄 수 있는 이점이기 때문에 추가적으로 알아보는 것을 패스하고, 유지보수성을 높인다는 부분과 성능 관점에 이점이 있다는 부분을 더 이해하고 싶어 졌습니다.


궁금증 1. 유지보수성을 높인다는 측면은 어떤 말일까요?

유지보수성은 다른 사람이 내가 작성한 코드를 보고 문제가 생기거나 수정이 필요할 때 큰 어려움 없이 코드를 수정할 수 있는지를 의미합니다. 

React에서 컴포넌트를 분리해 유지보수성을 높인다는 것은 하나의 기능을 수정하거나 개선할 때 다른 부분에 영향을 덜 미치게 하는 것을 말합니다. 특히 큰 애플리케이션에서는 컴포넌트 단위로 수정이 가능하기 때문에 버그 수정이나 기능 추가 시 다른 부분에 문제가 발생할 가능성을 줄일 수 있게 됩니다.

코드로 예시를 살펴봅시다.

// EmailInput.js
const EmailInput = ({ value, onChange }) => (
  <input
    type="email"
    value={value}
    onChange={onChange}
    placeholder="Enter your email"
  />
);

// Login.js
import EmailInput from './EmailInput';

const Login = () => {
  const [email, setEmail] = useState('');
  const handleEmailChange = (e) => setEmail(e.target.value);

  return (
    <div>
      <h2>Login</h2>
      <EmailInput value={email} onChange={handleEmailChange} />
    </div>
  );
};

// SignUp.js
import EmailInput from './EmailInput';

const SignUp = () => {
  const [email, setEmail] = useState('');
  const handleEmailChange = (e) => setEmail(e.target.value);

  return (
    <div>
      <h2>Sign Up</h2>
      <EmailInput value={email} onChange={handleEmailChange} />
    </div>
  );
};

로그인 페이지와 회원가입 페이지에서 둘 다 이메일, 비밀번호를 입력하는 필드가 필요합니다. 이 경우 각 필드는 공통 컴포넌트로 개발합니다. 그 후 입력 필드의 디자인을 변경해야 하거나 유효성 검사를 추가해야 하는 경우에 공통 컴포넌트로 개발했다면 해당 컴포넌트만 수정하면 됩니다.

하지만, 컴포넌트로 분리하지 않고 각각 작업한 경우 이전 두 페이지를 내가 작업했더라도 한 곳만 수정하는 실수를 유발할 수 있습니다. 심지어 내가 작업하지 않았다면 다른 곳에서 공통으로 사용되는 부분을 모르니 그 실수는 당연해지겠죠.

또한 이렇게 공통된 부분을 컴포넌트로 분리함으로써 코드의 중복도 피할 수 있게 됩니다. 이렇게 분리된 컴포넌트는 수정이 필요할 때, 특정 기능만 업데이트할 수 있게 해 줘서 다른 부분에 영향을 미칠 가능성도 줄어들게 됩니다.

다시 정리해 보자면 컴포넌트를 분리하면 수정할 때 같은 역할을 하는 코드가 여러 군데 중복되어있지 않기 때문에 한 곳만 수정하면 되는 장점이 생깁니다. 이렇게 하면 코드의 일관성을 유지하고, 실수를 줄이며, 유지보수가 쉬워집니다.


궁금증 2. 컴포넌트 분리가 성능 관점에서 이점이 있다는 말은 무슨 의미일까요?

우선 이 부분을 이해하기 위해서는 React의 동작 원리를 이해해야 합니다. 그래서 전에 학습했던 나만의 React 만들기 부분을 다시 살펴보았습니다.

 

React는 가상 DOM을 사용해서 변경된 부분만을 업데이트할 수 있도록 diff 알고리즘이 구현되어 있습니다. 사실 가상 DOM이라 불리는 부분은 fiber tree라고 불리는 데이터 구조입니다. fiber는 가상 DOM에서 각 요소를 더 세밀하게 관리하고 업데이트하기 위한 기반을 제공합니다. (해당 문단에 대한 추가 설명은 나만의 React 만들기(Build your own react) - STEP 4에서 확인할 수 있습니다!)

 

궁금한 부분은 성능 관점에서의 동작원리이기 때문에 노드를 업데이트하거나 삭제하는 방법에 대한 부분을 들여다봐야 합니다. 나만의 React 만들기(Build your own react) - STEP 6 해당 글에서 익혔던 재조정에 대해 다시 살펴보겠습니다.

렌더링 함수에서 받은 요소를 DOM에 커밋한 마지막 fiber tree와 비교해야 했습니다. 그러기 위해 모든 fiber에 alternate 프로퍼티를 추가했고, 이 프로퍼티를 통해 이전과 현재 상태를 비교할 수 있었습니다. 이 비교를 통해 어떤 부분이 변경되었는지를 파악하고, 효율적으로 업데이트를 적용할 수 있었습니다.

 

fiber는 컴포넌트를 나타내는 객체입니다. 업데이트 시에는 각 컴포넌트 단위로 비교가 이루어집니다. 이 과정에서 컴포넌트가 분리되어 있으면 React가 더 작은 단위로 비교 작업을 수행할 수 있어 전체적인 성능 향상을 가지고 올 수 있습니다.

아직도 의문입니다. 작은 단위로 비교 작업을 한다고 성능 향상이 된다? 조금 더 알아봅시다.

 

비교가 수행되어 변경된 부분만 업데이트한다고 했던 부분을 살펴보기 위해 이전 fiber와 현재 fiber의 타입이 일치하면 처리하는 코드를 다시 살펴보겠습니다.

if (sameType) {
  newFiber = {
    type: oldFiber.type,
    props: element.props, // 새로운 props로 업데이트
    dom: oldFiber.dom,    // 기존 DOM 노드 유지
    parent: wipFiber,
    alternate: oldFiber,  // 이전 Fiber 기록 유지
    effectTag: "UPDATE",  // 업데이트 태그 설정
  };
}

Fiber 구조에서 type이 동일하면 DOM 노드는 유지되고 새로운 props로 업데이트가 진행됩니다. 여기서 DOM 노드를 교체하지 않는다는 부분이 포인트입니다. 기존 DOM 노드는 유지하면서, 새로운 props 값으로 필요한 부분만을 업데이트하는 방식입니다.

type이 같아서 DOM 노드를 새로 생성할 필요는 없지만 props나 상태 값이 바뀌었을 때 업데이트 작업을 실행해야 함을 의미합니다. 필요한 부분만 업데이트한다는 점이 중요합니다. props가 변경되면 변경된 부분만 반영되지 DOM 전체를 다시 그리는 것이 아니기 때문입니다. (props가 변하지 않았다면 DOM도 컴포넌트도 업데이트되지 않습니다.)

코드 상에서 props 변화에 따른 업데이트 여부 결정은 이전과 새로운 props를 비교하는 방식으로 이루어지는데 이 부분은 React.memo를 사용하는 형태에서 이해할 수 있습니다. (React.memo는 memo, useMemo 이해하기에서 알아봤었습니다.)

즉, 단순히 타입이 같다고 해서 업데이트를 무조건 건너뛰지는 않지만 최소한 DOM을 재생성하는 일은 피할 수 있는 것입니다. 최소한의 업데이트만 처리하는 방식으로 효율성을 유지하는 것입니다.

 

DOM을 다시 그리지 않는 것은 성능 최적화와 사용자 경험 측면에서 장점이 있습니다.

첫째, DOM 조작 비용은 비쌉니다. DOM은 브라우저에서 렌더링 엔진을 통해 동작합니다. 이를 다시 그리거나 조작하는 것은 많은 연산 자원을 소모하는 일입니다. DOM 조작은 여러 단계를 거치는데 모든 단계에서 성능 비용이 발생합니다. 이런 조작을 최소화하는 것은 애플리케이션의 전체 성능을 크게 향상할 수 있습니다.

둘째, reflow(재배치) 방지가 됩니다. DOM이 다시 그려지면 reflow가 필요할 수 있습니다. 하나의 DOM 노드를 추가 또는 삭제하면 브라우저는 해당 DOM과 관련된 모든 요소를 다시 계산해서 배치해야 합니다. 이 과정은 CPU 자원을 많이 소모할 뿐 아니라, 시각적으로 깜빡이는 문제를 야기해 사용자 경험에 악영향을 끼칠 수 있습니다.

자, 여기까지 우선 DOM을 다시 그리지 않는다. props만 업데이트될 수 있게 동작한다까지는 이해했습니다.

 

제가 드는 의문은 props의 변화를 비교하는 과정에서 React.memo와 같은 최적화를 위한 추가 작업을 해주지 않으면 상위 컴포넌트가 변경될 때 무조건 하위 컴포넌트는 리렌더링 되어 성능적으로 이점을 얻지 못한다는 생각입니다. 이 생각은 맞는 생각이었습니다. 실제 성능 최적화를 목표로 한다면 React.memo와 같은 기법을 사용해야 합니다.

하지만 React.memo가 없더라도 컴포넌트를 분리하는 것만으로도 성능적으로 의미가 있습니다.

작은 부분만 리렌더링 할 수 있게 됩니다. 컴포넌트를 분리하게 되면 전체 UI가 아니라 필요한 부분만 다시 렌더링 하는 구조가 될 수 있습니다. 부모 컴포넌트가 리렌더링 되더라도 자식 컴포넌트가 나뉘어 있으면 그 부분만 다시 계산되므로, 렌더링 범위가 제한되기 때문입니다.

결론적으로 컴포넌트를 분리하면 fiber 단위가 작아져 비교 범위가 줄어든다는 장점이 있고, 작은 부분만 리렌더링 할 수 있게 되어 성능적 이점을 가져오는 것이었습니다. 


이로써 궁금증은 해소되었습니다!

{cells.map((row, rowIndex) =>
  row.map((cell, colIndex) => (
    <Button .../>
  )),
)}

<Row /> 컴포넌트로 분리했을 때, 성능 최적화에 도움이 되는 이유는 아래 2가지와 같습니다.

1. Fiber Tree에서 Row가 독립적인 단위로 관리되어 비교 대상이 작아지기 때문입니다.

2. cells 배열 일부가 변해도 변하지 않은 Row는 그대로 유지되어 불필요한 리렌더링이 방지됩니다. 또한 Row 내 Button 컴포넌트들이 독립적으로 존재해, 각 Button의 상태나 이벤트 변화가 있을 때 해당 Row에 속한 Button 들만 영향을 받게 됩니다. 즉, 더 작은 단위로 리렌더링이 일어나게 되어 성능 최적화에 도움을 줍니다.

추가로 실제로 성능 이점을 얻기 위해서 Row 컴포넌트를 memo로 감싼다면 props에 변화가 없는 경우 리렌더링을 방지할 수 있어 성능이 좋아집니다.

 

여기까지  한 가지 중요한 점은 컴포넌트를 너무 세분화하면 복잡성이 증가할 수 있고, 과도한 컴포넌트 분리는 오히려 컴포넌트 생성과 관리에도 비용이 들기 때문에 성능 저하를 초래할 수 있다는 점을 기억해야 한다는 것입니다.


사실 컴포넌트를 분리하는 주된 목적은 성능보다는 유지보수성과 코드의 재사용성을 높이기 위함이라고 합니다.
(참고로 React가 아닌 Solid.js, Vue.js는 성능을 개선하려고 컴포넌트를 분리할 필요가 없게 설계된 부분이 있다고 합니다. React처럼 비교 작업이 필요한 구조가 아닌 fine-grained reactivity 기반이기 때문에 성능 최적화와 관련해 컴포넌트 분리의 필요성이 덜하다고 하네요.)

 

컴포넌트를 분리할 때 고려해야 할 디자인 패턴이나 원칙도 여러 가지가 있다고 합니다.

각 컴포넌트는 하나의 책임만을 가져야 한다는 단일 책임 원칙(SRP)부터, 작은 컴포넌트를 조합해 더 복잡한 컴포넌트를 만드는 컴포넌트 조합, 상태를 필요로 하는 여러 하위 컴포넌트가 있는 경우, 공통 부모 컴포넌트로 상태를 끌어올려 관리하는 상태 끌어올리기, UI를 정의하는 프레젠테이셔널 컴포넌트와 데이터와 상태를 관리하는 컨테이너 컴포넌트로 분리하는 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트 분리, HOC, Hooks를 통한 상태 관리, Lazy Loading과 코드 스플리팅이 있습니다.

다양한 패턴과 원칙까지 아는 부분과 더 자세히 알아보고 싶은 부분도 존재하네요 :)


여기까지 알고 나니 컴포넌트를 분리함에 있어 언제든 더 좋은 방향이 생기면 바뀔 수 있지만 저만의 원칙을 세우게 되었습니다.

컴포넌트 분리, 나만의 원칙

1. 페이지 생성 시 메인 컴포넌트를 하나 둔다.
  a. 내부에 필요한 부분들은 SRP 원칙에 따라 컴포넌트를 분리한다. 대신 동일한 파일 내에 위치시킨다.

2. 동일한 로직이나 동일한 UI로 2개 이상의 곳에서 사용할 경우 컴포넌트로 분리해 재사용성을 높인다.

3. 복잡한 로직이 포함되어 있는 경우, 유지보수성을 위해 Hooks를 활용해 분리한다.

4. 별도의 상태 관리가 필요한 경우 컴포넌트를 분리한다.

 

계속 꼬리를 물던 궁금증이 해소되어 후련합니다. React의 fiber, 그리고 비교 알고리즘도 한번 더 복습할 수 있던 좋은 시간이었습니다!

728x90

댓글