React 이해하기

React의 기본 개념에 대해 알아봅시다

벌써 7년

프론트엔드 개발은 jQuery 전과 후로 나뉘고, React 전과 후로 나뉜다.

jQuery는 웹 개발자라면 한 번쯤은 사용해 보셨을 테죠. 언제 Release 되었을까요? 2006년이군요. 도대체 얼마나 지난 거죠? 이토록 오랜 시간 전 세계 수많은 웹 개발자들의 애증? 의 라이브러리가 될 거라고 John Resig은 상상이나 했을까요.

그럼 React는 어떨까요? 2013년에 릴리즈되었으니 벌써 7년이군요. React가 나온 이후로 프론트엔드 분야에서는 정말 많은 변화가 일어났습니다. 그리고 React는 정말 많은 사람들이 사용하는 툴이 되었습니다.

https://www.npmtrends.com/react-vs-vue-vs-@angular/core

그럼 React가 뭐죠?

React는 사용자 인터페이스(User Interface, 이하 UI)를 만들기 위한 Javascript 라이브러리입니다. React가 왜 만들어졌는지 이해하기 위해 DOM(Document Object Model)에 관해 먼저 이야기해보죠. DOM은 HTML이나 XML 문서의 interface입니다. 프로그래밍 언어를 사용하여 문서의 내용이나 스타일 등을 변경할 수 있게 해주는 거죠. 예제와 같이 표준 DOM에서는 element id를 이용해서 해당 element를 조회하는 getElementById를 제공합니다.

<button id="helloBtn">hello</button>
<script>
  var elBtn = document.getElementById('helloBtn');
  elBtn.addEventListener('click', handleClick);

  function handleClick() {
    alert('world');
  }
</script>

웹의 초창기만 해도 문서의 구성요소가 적었기 때문에 큰 문제 없이 잘 작동 했겠지만, 금방 풍부한 이미지나 여러 구성요소로 웹 페이지는 채워졌고, 많은 노드와 이벤트 등으로 구성된 웹페이지를 구현하고 유지하는 건 복잡하고 어려운 일이 되었죠.

컴포넌트와 Props

React를 설명하는 중요한 키워드 2가지는 컴포넌트(component-based)와 선언형(declarative)입니다. 먼저 컴포넌트에 대해 알아보겠습니다.

JS에서 HTML을 분리하기!?

그동안 마크업과 로직을 분리해서 관심사를 분리하려고 했지만 쉽지 않았죠. 하지만 UI 개발을 하는데, UI와 로직이 결합이 안 될 수가 없겠죠. 그래서 React는 로직과 마크업을 분리하는 대신 JS 코드 안으로 HTML을 가져왔습니다.

function Hello({ name }) {
  const handleClick = () => {
    alert(name);
  }
  return (
    <button onClick={handleClick}>hello</button>
  );
}

HTML이 JS 안에 있다고?

당연히 위 예제 코드는 JS가 아닙니다. HTML도 아닙니다. JSX라고 부르는 Javascript를 확장한 문법입니다. JSX를 사용할 땐 Babel을 통해 예제와 같이 순수한 JS로 변환해야 합니다. 물론 JSX를 사용하지 않아도 됩니다.

return React.createElement('button', {
  onClick: handleClick
}, 'click');

Babel에서 JSX가 어떻게 변환되는지 확인해 보세요

그렇게 하면 뭐가 좋은 거죠?

관심사를 분리할 수 있습니다. React는 위 예제에서 보았듯이, 로직과 마크업이 하나의 유닛에 담기게 됩니다. 복잡한 웹페이지를 여러 유닛으로 나눠서 관심사를 분리할 수 있죠. 이 유닛을 "컴포넌트"라고 합니다. (물론 뷰를 담당하는 컴포넌트, 로직을 담당하는 컴포넌트로 구분해서 구현할 수도 있습니다)

어떻게 실제 DOM에 적용되나?

React.createElement를 통해 React elements가 반환됩니다.

// Note: this structure is simplified
{
  type: 'button',
  props: {
    onClick: function handleClick() { alert('world') },
    children: 'hello'
  },
};

이렇게 생성된 React elements는 ReactDOM 을 통해서 실제 DOM에 적용됩니다.

ReactDOM.render(
  <Hello name="kim" />,
  document.getElementById('root')
);

Props

위 예제에서 <Hello /><div>와 같은 DOM 태그가 아닌, 사용자 정의 컴포넌트입니다. 이렇게 React element를 작성하게 되면 JSX attribute와 children 노드를 해당 컴포넌트에 객체로 전달합니다. 예제에서는 { name: "kim" } 이렇게 전달되죠. 이 객체를 props라고 합니다.

컴포넌트를 사용해서 React가 어떻게 관심사를 분리하는지, 어떻게 실제 DOM에 적용되는지 그리고 사용자 정의 컴포넌트와 props에 대해 알아보았습니다. 이제 선언형에 대해 알아보죠.

선언형, State 그리고 render

DOM API를 사용해서 명령형으로 코드를 작성하면 "어떻게" 동작하고 보여지는지 기술하게 됩니다. 이러한 코드를 이해하려면 많은 로직과, 화면에 어떻게 표현하는지를 따라가며 코드를 읽어야만 하죠. 반면에 React와 같은, 선언형으로 UI를 구성할 수 있는 라이브러리를 사용하면 "어떻게" 보다는 "무엇"이 보여지면 되는지 기술합니다. HTML과 SQL처럼요. 상태가 변경되었을 때 실제로 화면에 어떻게 적용해서 보일지는 React가 알아서 합니다.

선언형으로 바꿈으로써 우리는 상태만 관리하고, 어떻게 보일지 고민할 필요가 없어졌습니다. 그래서 이전보다 쉽게 코드를 작성할 수 있고, 디버깅하기 쉽게 만들어 줍니다.

State

그럼 상태를 어떻게 관리할 수 있을까요?

class TextField extends React.Component {
  state = { isBlank: false };

  handleChange = (e) => {
    // 상태를 변경한 다음 어떻게 보일지 작성하는 명령형과 달리,
    // 상태를 변경하는 로직만 작성합니다.
    this.setState({ isBlank: e.target.value !== '' });
  };

  render() {
    return (
      <>
        <input type="text" onChange={this.handleChange} />
        {/* 상태에 따라 무엇이 보이면 되는지 작성합니다. */}
        <span>{this.state.isBlank ? '작성 중' : '작성해 주세요'}</span>
      </>
    );
  }
}

React는 상태를 갱신할 수 있는 setState를 제공합니다. setState를 호출하면 state가 갱신되고, render 함수가 호출되면서 새로운 React element가 반환됩니다. 그럼 setState를 2번 호출하면 render 함수도 2번 호출될까요?

class TextField extends React.Component {
  state = { text: '', isBlank: false };

  handleChange = (e) => {
    this.setState({ text: e.target.value });
    this.setState({ isBlank: e.target.value !== '' });
  };

  render() {

    return (
      <>
        <input type="text" onChange={this.handleChange} />
        {this.state.text}
        <span>{this.state.isBlank ? '작성 중' : '작성해 주세요'}</span>
      </>
    );
  }
}

이 경우에 React는 render 함수를 1번만 호출합니다. React는 render 함수 호출을 최소화하고 렌더링을 최적화하는 데 초점이 맞춰져 있기 때문에 setState가 비동기로 동작하도록 구현되어 있습니다. setState를 호출하더라도 즉시 반영되는 게 아니라 React가 원하는 시점에 rerender 하게 됩니다. 이것을 스케쥴링 업데이트라고 부릅니다.

자식 컴포넌트가 있는 경우에는 어떨까요? 부모 컴포넌트가 rerender 되면 자식 컴포넌트도 rerender 됩니다.

class HelperText extends React.Component {
  render() {
    console.log('render HelperText')
    return <span>{this.props.isBlank ? '작성 중' : '작성해 주세요'}</span>;
  }
}

class TextField extends React.Component {
  state = { isBlank: false };

  handleChange = (e) => {
    this.setState({ isBlank: e.target.value !== '' });
  };

  render() {
    return (
      <>
        <input type="text" onChange={this.handleChange} />
        <HelperText isBlank={this.state.isBlank} />
      </>
    );
  }
}

HelperText 컴포넌트는 isBlank props가 true인지 false인지에 따라 2가지 경우만 render 되면 됩니다. 하지만, TextField 컴포넌트가 rerender될 때마다 isBlank 값과 상관없이 같이 계속 rerender 됩니다.

shouldComponentUpdate 구현 전

shouldComponentUpdate

동일한 props이면 동일한 결과를 반환하기 때문에 계속 rerender 될 필요는 없을 거 같습니다. rerender 되더라도 대부분 문제가 없겠지만, 경우에 따라 퍼포먼스 이슈가 발생할 수 있습니다. 그래서 React는 최적화를 위해 shouldComponentUpdate를 제공합니다. false가 반환되면 render 함수를 재호출 하지 않습니다.

class HelperText extends React.Component {
  shouldComponentUpdate(nextProps) {
    if (this.props.isBlank !== nextProps.isBlank) {
      return true;
    }
    return false
  }
  render() {
    console.log('render HelperText')
    return <span>{this.props.isBlank ? '작성 중' : '작성해 주세요'}</span>;
  }
}

shouldComponentUpdate 구현후

예제와 같은 얕은 비교(shallow compare)가 필요한 컴포넌트가 많이 필요할 수 있습니다. 그럴 땐 매번 shouldComponentUpdate를 구현할 필요 없이 state와 props를 이용한 얕은 비교가 구현된 PureComponent를 사용하면 됩니다.

class HelperText extends React.PureComponent {
  render() {
    return <span>{this.props.isBlank ? '작성 중' : '작성해 주세요'}</span>;
  }
}

문제가 있군요. 우리가 사용한 예제에서는 props로 primitive value인 string을 사용했기 때문에 얕은 비교를 해도 문제가 없었습니다. 하지만 아래 예제와 같은 reference value를 사용하면 배열에 새로운 값을 push 해서 넣어도, 얕은 비교를 했을 때 동일한 값으로 인식하여 render 함수를 재호출 하지 않아 결과적으로 화면이 업데이트되지 않습니다.

class ListOfWords extends React.PureComponent {
  render() {
    return <div>{this.props.words.join(',')}</div>;
  }
}

class WordAdder extends React.Component {
  state = { words: ['abc'] };

  handleClick = () => {
    const words = this.state.words;
    words.push('abc');
    this.setState({words: words});
  }

  render() {
    return (
      <>
        <button onClick={this.handleClick}>click</button>
        <ListOfWords words={this.state.words} />
      </>
    );
  }
}

위 예제에서 Arraypush를 통해 mutating 해서 문제가 발생했습니다. 그래서 mutating 하지 않고 새로운 Array를 생성해서 해결할 수 있습니다.

this.setState(state => ({
  words: [...state.words, 'abc'],
}));

하지만 간단한 예제와 달리 깊게 중첩된 객체를 똑같은 방식으로 처리할 때는 복잡하겠죠. 이런 경우에는 immer와 같은 라이브러리를 사용해서 좀 더 쉽게 코드를 작성할 수 있습니다.

import produce from 'immer';

this.setState(produce(draft => {
  draft.words.push('abc');
}))

성능 최적화에 대해서 더 알아보기

Reconciliation

state나 props가 갱신되면 render 함수는 새로운 React elements를 반환합니다. shouldComponentUpdate를 이용해서 불필요한 rerender를 최소화 시킬 수도 있죠. 이렇게 만들어진 React elements 트리를 가장 효과적으로 실제 DOM과 동기화하는 단계가 있습니다. 이를 reconciliation(재조정)이라고 합니다.

다시 화면 기록을 보죠. 텍스트를 입력할 때마다 console에 render HelperText 가 찍히는 것을 확인 할 수 있습니다. 하지만, 브라우저의 Elements탭을 보면 "작성 중" 노드는 갱신되지 않는 것을 알 수 있습니다. 이는 컴포넌트가 rerender는 되었지만, reconciliation을 통해서 결과는 변경되지 않은 노드임을 확인하고 갱신하지 않기 때문입니다.

shouldComponentUpdate 구현 전

상태에 따라 어떻게 보이는지만 선언되어 있으면 "언제", "어떤 부분"을 업데이트할지 전략이 필요합니다. React는 최소한의 변경된 부분만 업데이트하는 전략을 취합니다. 그래서 React는 여러 가지 비교 알고리즘과 보통 Virtual DOM이라고 부르는 기법들을 통해 변경된 부분과 변경되지 않은 부분을 찾을 수 있습니다.

reconciliation에 대해서 더 알아보기

React가 선언형 API를 제공할 수 있는 이유와 그로 인한 편리함을 어떻게 React가 제공하는지 알아보았습니다.

Hook

지금까지 class 컴포넌트만 예제로 사용했군요. 마지막으로 hook을 사용한 방식도 알아보죠. React의 백미는 심플함에 있다고 생각합니다. hook을 사용하면 class를 사용하는 것보다 훨씬 심플하게 컴포넌트를 구현할 수 있습니다. functional 컴포넌트에서 state를 사용하기 위해서는 useState hook을 사용하면 됩니다.

function TextField() {
  const [text, setText] = useState('');
  const [isBlank, setIsBlank] = useState(false);

  function handleChange(e) {
    setText(e.target.value);
    setIsBlank(e.target.value !== '');
  }

  return (
    <>
      <input type="text" onChange={handleChange} />
      <span>{text}</span>
      <HelperText isBlank={isBlank} />
    </>
  );
}

class 컴포넌트에서 상태 관련 로직을 여러 컴포넌트에서 재사용하기 위해서는 high-order component(HOC)와 render props를 이용해야 합니다. HOC와 render props는 컴포넌트를 감싸는 wrapper를 새로 생성해야 하는 단점이 있습니다. 그렇다 보니 아래 예제 코드처럼 HOC로 감싸고, render props wrapper 컴포넌트로 또 감싸는 wrapper hell이 되는 경우가 발생합니다.

function with1(WrappedComponent) {
  return class extends React.Component {
    componentDidUpdate(prevProps) {
      // do something
    }
    render() {
      return <WrappedComponent {...this.props} />;
    }
  }
}

const EnhancedComponent = with1(with2(with3(WrappedComponent)))

HOC에 대해서 더 알아보기

그래서 hook은 별도의 wrapper 없이 상태 관련 로직을 공유할 수 있도록 구현되었습니다.

// window width를 반환하는 custom hook.
function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  // useEffect는 rendering이 완료된 이후에 실행됩니다.
  React.useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  // 두 번째 인자에 배열 안의 값이 있으면, 해당 값이 변경될 때만 조건부로 useEffect가 실행되게 됩니다.
  // 빈 배열을 통해 한 번만 실행되게 할 수 있습니다.
  }, []);
  return width;
}

function MyWindowWidth() {
  // custom hook을 여러 컴포넌트에서 재사용 할 수 있습니다.
  const width = useWindowWidth();
  return <div>{width}</div>
}

custom hook에 대해서 더 알아보기

useState hook을 사용할 때 주의할 점은 setState와 달리 이전 state와 새로운 state를 merge 하지 않습니다.

class TextField extends React.Component {
  state = { text: '', isBlank: false };

  handleChange = (e) => {
    // setState에 전달한 새로운 state는 기존 state와 자동으로 merge됩니다.
    this.setState({ text: e.target.value });
  };
  ...
}

function TextField() {
  const [state, setState] = useState({ text: '', isBlank: false });

  function handleChange(e) {
    // 이전 state와 merge되지 않아서 isBlank는 없어집니다.
    setState({ text: e.target.value });
  }
  ...
}

class 컴포넌트의 setState처럼 동작하는 custom hook을 만들 수도 있습니다.

function useLikeSetState(initialValue) {
  const [state, setState] = useState(initialValue);
  function handleSetState(newValue) {
    setState((prevState) => ({ ...prevState, ...newValue }));
  }
  return [state, handleSetState]
}

function TextField() {
  const [state, setState] = useLikeSetState({ text: '', isBlank: false });

  function handleChange(e) {
    setState({ text: e.target.value });
    setState({ isBlank: e.target.value !== ''});
  }
  return (...);
}

redux의 reducer를 좋아하는 분들을 위해 useReducer도 사용할 수 있습니다. reducer를 구현한 custom hook을 기본 API로 제공한다고 볼 수 있습니다.

function reducer(state, action) {
  switch (action.type) {
    case 'changeText':
      const text = action.payload;
      return { text, isBlank: text !== '' };
    default:
      throw new Error();
  }
}

function TextField() {
  const [state, dispatch] = React.useReducer(reducer, { text: "", isBlank: false });

  function handleChange(e) {
    dispatch({ type: 'changeText', payload: e.target.value });
  }

  return (...);
}

useMemo를 사용해서 최적화하는 방식을 사용할 수도 있습니다.

function TextField() {
  const [text, setText] = useState('');
  const isBlank = React.useMemo(() => text !== '', [text]);

  function handleChange(e) {
    setText(e.target.value);
  }

  return (...);
}

class 컴포넌트에서 사용한 PureComponentmemo를 사용하면 props 얕은 비교를 통해 동일하게 동작하게 됩니다.

const HelperText = React.memo(function HelperText ({ isBlank }) {
  return <span>{isBlank ? '작성 중' : '작성해 주세요'}</span>;
})

Hook에 대해 더 알아보기

간략하게 React에 대해서 알아보았습니다. 컬리 프론트엔드 개발팀에서는 React를 이용하여 여러 과제를 수행하고 있습니다. 저희와 함께 하실 분을 찾습니다. 채용정보

지원해주세요 🤙 🙏 뿌잉뿌잉