리액트 컴포넌트 불순하게 만들어보기

Seoyong Lee·2023년 12월 4일
0

개발 공부

목록 보기
16/21
post-thumbnail

아래 글은 React.dev - Keeping Components Pure 의 내용을 기반으로 작성되었습니다.

React.dev에 따르면 React의 컴포넌트는 순수(Pure)해야 한다고 합니다. 그렇다면 순수함(Purity)이란 무엇이고 왜 리액트 컴포넌트는 순수해야 하는 것일까요? 만약 생각을 뒤집어서 컴포넌트를 의도적으로 불순하게 만들려면 어떻게 해야 할까요? 발상의 전환을 통해 리액트에서의 컴포넌트와 순수함수를 더 잘 이해해보는 시간을 가져보려 합니다.

순수함의 정의

문서에 따르면 일반적으로 Computer Science 에서 순수함수는 다음 특징을 가집니다.

  1. It minds its own business(자기 일에만 집중)
    • 함수 호출 전에 존재했던 다른 어떤 것에도 변화를 주지 않습니다
  2. Same Inputs, same output(동일 입력 동일 출력)
    • 순수 함수는 항상 입력이 같다면 같은 결과를 반환합니다

사실 순수 함수는 우리가 수학에서 배웠던 함수와 같은 개념입니다.

y = 2x

만약 위 식에서 x가 2라면 y는 무조건 4입니다. 같은 입력에 항상 같은 값을 반환하기 때문에 위 공식을 함수로 만들면 순수함수의 정의를 만족하게 됩니다.

function double(num:number) {
	return 2 * num;
}

리액트와 순수함수

그렇다면 이러한 순수함수와 리액트는 무슨 관계일까요? 기본적으로 리액트는 우리가 작성한 모든 컴포넌트가 순수함수라고 가정합니다.

위에서 보았던 순수함수의 정의대로 컴포넌트(함수)를 생각해보면 다음 특징을 만족해야 합니다.

  1. 자기 일에만 집중한다 - 컴포넌트 렌더링 이전에 존재했던 변수가 변하면 안 됩니다
  2. 동일 입력, 동일 출력 - 입력이 같다면 항상 같은 결과를 렌더링 해야 합니다

불순함수 1 - prop을 사용하지 않고 외부 변수 참조

이러한 특징을 파괴하고 컴포넌트를 불순하게 만드는 방법의 하나는 바로 하라는 대로 prop을 사용하지 않고 외부 변수를 사용하는 것입니다.

let guest = 0;

function Cup() {
  guest = guest + 1; // 1번 위반 - 자기 밖의 일인 guest를 변경함!
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {

	// 2번 위반 - 입력이 모두 동일한 Cup의 렌더링 결과가 모두 다름
	// Tea cup for guest #2
  // Tea cup for guest #4
  // Tea cup for guest #6
  return (
    <>
      <Cup />
      <Cup />
      <Cup />
    </>
  );
}

위 사례를 보면 Cup 컴포넌트가 2의 배수로 guest의 숫자를 변경하는 것을 보실 수 있습니다. 왜일까요? 이는 리액트의 ‘Strict Mode’가 컴포넌트를 2번씩 호출하기 때문으로, 만약 컴포넌트가 순수하고 제정신이었다면 2번째 호출에서도 정상적으로 1, 2, 3 을 반환했어야 한다는 점을 친절하게 알려주기 위해 설정된 안전장치입니다.

prop을 사용하도록 바꾸면 다음과 같이 컴포넌트는 순수해집니다.

function Cup({ guest }) {
	// 1번 만족 - 자기 밖의 일은 신경쓰지 않음
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {
	// 2번 만족 - 동일 입력, 동일 출력
	// Tea cup for guest #1
  // Tea cup for guest #2
  // Tea cup for guest #3
  return (
    <>
      <Cup guest={1} />
      <Cup guest={2} />
      <Cup guest={3} />
    </>
  );
}

여기서 하나 알아두면 좋은 리액트 사고방식 중의 하나는 바로 컴포넌트가 렌더링 되는 순서에 대해 가정하지 말아야 한다는 점입니다. 문서에서 이러한 이야기를 하는 이유는 바로 리액트에선 컴포넌트 렌더링이 동기적으로 진행되지 않을 수 있기 때문입니다. 리액트 문서에선 다음과 같은 비유적인 표현으로 이를 설명합니다.

Rendering is like a school exam: each component should calculate JSX on their own!
렌더링은 학교 시험과 비슷합니다: 각 컴포넌트는 JSX에 대한 계산을 각자 진행합니다

불순함수 2 - prop, state를 직접 변경하려는 시도

리액트에서 지켜야 하는 또 하나의 중요한 원칙은 바로 컴포넌트 렌더링 시에 받을 수 있는 3가지 형태의 input(prop, state, context)은 read-only로 취급해야 한다는 것입니다. 리액트를 처음 배우는 과정에서 prop, state(setState), context를 직접 변경하려는 창의적인 시도를 해보셨던 분들은 아시겠지만 리액트는 에러를 통해 이를 직접 교정합니다. 그중에서 state를 직접 변경하면 어떻게 되는지는 이 글에서 자세히 설명하고 있습니다.

만약 컴포넌트 밖에 있는 이러한 변수를 직접 변경하려고 시도한다면 리액트는 이를 mutation(돌연변이)로 규정하고 배척합니다. 리액트에서의 mutation에 대한 정의는 다음과 같습니다.

In the above example, the problem was that the component changed a preexisting variable while rendering. This is often called a “mutation” to make it sound a bit scarier.
위 사례에서 문제점은 바로 컴포넌트가 렌더링 시점에 이미 존재하는 변수를 변경하려 했던 점입니다. 이것은 흔히 무섭게 들리기 위해 “돌연변이”라고 부릅니다.

그러나 모든 mutation이 금지되는 것은 아닙니다. 다음 사례와 같이 렌더링 시점에 같이 일어나는 변화는 “local mutation”이라고 부르며 이는 허용됩니다.

function Cup({ guest }) {
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaGathering() {
  let cups = [];
  for (let i = 1; i <= 12; i++) {
    cups.push(<Cup key={i} guest={i} />);
  }
  return cups;
}

cups라는 배열 변수는 Cup 컴포넌트 입장에선 외부에 있지만 TeaGathering 함수 내부에 같이 있기 때문에 for문 연산과 렌더링이 동시에 진행되며 이러한 작업은 외부에 영향을 주지 않습니다.

순수함을 유지하면서도 변경이 필요한 것들

지금까지 컴포넌트의 순수함에 대해 알아보았습니다. 그렇다면 이러한 원칙을 충실히 지키기만 하면 좋은 서비스를 만들 수 있을까요? 실제 삶은 이론과 달리 여러 가지 기출변형의 연속입니다. 마찬가지로 리액트를 이용해서 실제 서비스를 만들기 위해서는 컴포넌트의 순수함을 유지하면서도 변화가 필요한 일들이 생깁니다. 이러한 변화를 리액트는 “side effects”라고 부릅니다. 렌더링 과정에서 벗어난 side에서 변화가 일어나기 때문입니다.

side effect의 대표적인 예는 바로 이벤트 처리입니다. 사용자는 렌더링 시점에 맞춰서 순수하게 버튼을 클릭하지는 않습니다. 생각해보면 너무 당연한 말이지만 이벤트 처리가 side effects라고 생각하고 개발해 보지는 못했던 것 같습니다. 엄밀히 말하면 이벤트 핸들러는 애초에 렌더링과 무관하게 동작하므로 순수할 필요까지는 없습니다. 역시 현실 문제를 다루려면 어느 정도는 불순한 생각을 해야 하나 봅니다.

만약 이벤트 핸들러로 도저히 어떻게 할 수 없는 애매한 변화가 필요한 순간에는 어떻게 해야 할까요? 이때 사용되는 것이 바로 “useEffect”입니다. 리액트에게 렌더링이 안전하게 끝나면 그때 무언가 변화를 주라고 만들어졌습니다. 그러나 리액트 문서는 우리에게 다음과 같은 일침을 날립니다.

However, this approach should be your last resort. When possible, try to express your logic with rendering alone. You’ll be surprised how far this can take you!
그러나 이 접근 방식은 최후의 수단이 되어야 합니다. 가능하다면 렌더링만으로 로직을 표현해 보세요. 이것이 당신을 얼마나 멀리 데려갈 수 있는지 놀라게 될 것입니다!

과연 useEffect가 최후의 수단이라고 생각하고 개발해 본 적이 있나요? 저는 개인적으로 반성합니다. 렌더링만으로 로직 표현이 가능하다면 굳이 useEffect를 사용해서 부수 효과를 발생시킬 필요가 없다는 점은 많은 생각이 들게 합니다.

결론

지금까지 리액트에서의 순수함수에 대해 문서를 기반으로 다뤄보았습니다. 그렇다면 마지막으로 궁금한 점이 생깁니다. 리액트는 왜 컴포넌트가 순수하길 바랄까요? 문서에선 다음 3가지 이유를 설명합니다.

  • 컴포넌트는 서버 등의 환경에서 동작할 수 있기 때문에 동일 입력, 동일 출력 원칙을 지키면 한 컴포넌트로 많은 사용자 요청을 처리할 수 있게 됩니다.
  • 입력이 변하지 않은 순수한 컴포넌트는 렌더링 생략(memo)을 할 수 있기 때문에 안전하게 캐싱 및 퍼포먼스 향상이 가능합니다.
  • 깊은 컴포넌트 트리를 렌더링하는 와중에도 특정 데이터가 변경되면 리액트는 언제든지 기존 렌더링을 중단하고 다시 시작할 수 있습니다. 이는 각 컴포넌트가 순수하기 때문에 가능합니다.

리액트는 지금도 많이 사용되며 당분간은 그럴 것이기에 리액트 개발자들이 리액트를 만든 의도는 무엇이었는지 알아보고 이를 따르려 노력하는 것은 나쁘지 않을 것 같습니다.

참고

react.dev - keeping components pure
Dave Ceddia - Why Not To Modify React State Directly

0개의 댓글