본문 바로가기

FrontEnd+

[React] 공식문서로 시작하는 리액트 입문 2편

 

글이 생각보다 길어져 두 편으로 나누어 작성하게 되었다. 1편에 이어, 리액트는 공식 홈페이지의 '기본 가이드'조건부 렌더링 개념부터 이어서 살펴보겠다.

 

07. 조건부 렌더링

리액트에서는 각 컴포넌트에 원하는 동작을 캡슐화해두고, 조건에 따라 렌더링하게 할 수 있다.

A or B 렌더링

애플리케이션의 상태에 따라서 여러 컴포넌트 중 하나만 렌더링할 수 있다. JavaScript의 조건문 활용하거나 JSX 내에서 논리연산자(&&, ?)등을 활용하면 된다. 이 때 조건에 따라 선택된 요소(element)를 저장하기 위해 let으로 선언한 변수를 사용할 수도 있다. 만약 조건식이 너무 복잡해진다면 컴포넌트를 분리해야하는 타이밍이 아닌지 생각해보자.

// JavaScript 조건문 활용
function Greeting(props) {
  const isLoggedIn = props.isLoggedIn;
  if (isLoggedIn) {
    return <UserGreeting />;
  }
  return <GuestGreeting />;
}
// JSX 내에서 조건식 활용
render() {
  const isLoggedIn = this.state.isLoggedIn;
  return (
    <div>
      {isLoggedIn
        ? <LogoutButton onClick={this.handleLogoutClick} />
        : <LoginButton onClick={this.handleLoginClick} />
      }
    </div>
  );
}

A(O or X) 렌더링

애플리케이션의 상태에 따라서 컴포넌트 중 일부만을 렌더링할 수도 있다. 컴포넌트 자체를 숨기려면 null을 리턴하면 된다. null을 리턴하더라도 생명주기 메서드인 componentDidUpdate()는 호출되는데, render()에서 null을 리턴하더라도 생명주기 메서드 호출에 영향을 주지 않기 때문이다.

function WarningBanner(props) {
  if (!props.warn) {
    return null;
  }

  return (
    <div className="warning">
      Warning!
    </div>
  );
}

class Page extends React.Component {
  constructor(props) {
    super(props);
    this.state = {showWarning: true};
    this.handleToggleClick = this.handleToggleClick.bind(this);
  }

  handleToggleClick() {
    this.setState(state => ({
      showWarning: !state.showWarning
    }));
  }

  render() {
    return (
      <div>
        <WarningBanner warn={this.state.showWarning} />
        <button onClick={this.handleToggleClick}>
          {this.state.showWarning ? 'Hide' : 'Show'}
        </button>
      </div>
    );
  }
}

ReactDOM.render(
  <Page />,
  document.getElementById('root')
);

 

08. 배열 렌더링과 key

반복문으로 <li> 렌더링하기

리액트에서는 JavaScript에서 배열을 다루는 것과 비슷한 방식으로 여러 엘리먼트를 만들 수 있다. JSX를 사용하면 중괄호 안에 모든 표현식을 포함시킬 수 있으므로 map() 함수의 결과를 아래와 같이 인라인으로 처리할 수 있다.

function ListItem(props) {
  return <li>{props.value}</li>;
}

function NumberList(props) {
  const numbers = props.numbers;
  return (
    <ul>
      {numbers.map((number) =>
        <ListItem key={number.toString()} value={number} />
      )}
    </ul>
  );
}

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(
  <NumberList numbers={numbers} />,
  document.getElementById('root')
);

키(Key)

그런데 위와 같이 코드를 작성하면 아래와 같이 고유의 'key'가 없다는 경고메세지가 뜬다. 'key'는 배열을 렌더링할 때 반드시 포함해야 하는 prop이다. 'key'는 해당 항목의 고유성을 보장해줘서, 리액트가 항목의 추가, 삭제 또는 변경하기 위해 해당 항목을 식별하는데 사용된다. (+ 키가 꼭 필요한 이유 더 알아보기)

<li>에는 반드시 'key'를 지정해주어야 한다.

대부분의 경우 데이터의 id를 key값으로 사용한다. 꼭 id가 아니더라도 해당 항목을 고유하게 식별할 수 있는 문자열이면 된다. id가 없다면 index를 key로 사용할 수도 있다. 만약 리스트 항목에 명시적으로 key를 지정하지 않으면 React는 기본적으로 index를 key로 사용하기는 한다. 그러나 항목의 순서가 바뀔 수 있는 경우 key에 인덱스를 사용하는 것은 권장하지 않는다. 이로 인해 성능이 저하되거나 컴포넌트의 state와 관련된 문제가 발생할 수 있기 때문이다.

통상적으로 map() 함수 내부에 있는 엘리먼트에서 key를 넣어 주는 것이 좋다. React에서 key는 컴포넌트로 전달되지는 않는다. 컴포넌트에서 key와 동일한 값이 필요하면 다른 이름의 prop으로 명시적으로 전달해야 한다. 아래 예시 코드에서 Post 컴포넌트는 props.id를 읽을 수 있지만 props.key는 읽을 수 없다!

const content = posts.map((post) =>
  <Post
    key={post.id}
    id={post.id}
    title={post.title} />
);

 

 

09. <Form>

비제어 컴포넌트 방식

HTML에서 <form> 태그 내부의 <input>의 값을 가져오는 경우를 떠올려보자. 보통은 사용자가 <form>을 제출하면 event.target.value로 값을 읽어와서 후속 처리를 이어간다. 그 전까지 JavaScript에서는 사용자가 어떤 값을 입력했는지 알지 못한다. 이렇게 DOM이 상태(form의 데이터)를 가지고 있고, 리액트 컴포넌트에서는 사용자의 입력값을 제어하지 않고, 필요할 때 DOM에서 상태를 땡겨오는 방식을 '비제어 컴포넌트(Uncontrolled Component)' 방식이라고 한다. 이것으로 원하는 동작을 충분히 구현할 수 있다면, 간단하게 구현가능한 비제어 컴포넌트를 사용하는 것이 좋다.

참고로, 읽기전용 요소인 경우에는 항상 비제어 컴포넌트이다. <input type="file" />과 같이 사용자가 선택할 수는 있어도 코드로 값을 설정할 수 없는 경우가 그 예시이다.

제어 컴포넌트 방식

그런데 조금 더 정교하게 <form>을 구현하고 싶다면 어떨까? 사용자가 입력한 숫자가 1부터 10 사이의 값이면 노란색 표시하고, 11부터 20사이의 값이면 파란색으로 표시하고 싶은 경우를 생각해보자.

로또미션 중 참고 UI

이 경우에는 사용자가 입력한 값을 이벤트핸들러가 컴포넌트에게 전달하고 리액트에서 상태를 setState()로 업데이트한 후 원하는 대로 렌더하면 된다. 이렇게 리액트가 사용자의 입력값을 제어하고 상태를 관리하는 방식을 '제어 컴포넌트 (Controlled Component)' 방식이라고 한다.

앞서 살펴본 '비제어 컴포넌트'가 특정 시점에 DOM에서 상태값을 땡겨가는 것과 달리 '제어 컴포넌트'에서 <form>은 항상 사용자가 입력한 현재 값을 가지고 있다. '비제어 컴포넌트'에서 화면에 렌더되는 값은 사용자가 입력한 값이었다면, '제어 컴포넌트'에서 화면에 보여지는 값은 리액트에서 상태변화에 따라 다시 렌더된 값이다.(제어 컴포넌트 vs 비제어 컴포넌트 더 알아보기)

제어 컴포넌트 구현

제어 컴포넌트를 구현하려면 코드를 조금 더 작성해야 하지만, 다른 UI요소에 input 값을 전달하거나 다른 이벤트 핸들러에서 값을 재설정하는 등 폭넓은 구현이 가능해진다.

아래 예시 코드에서 <NameForm>의 value라는 상태를 갖고있다. 사용자가 키보드 입력을 할 때마다 이벤트핸들러 handleChange에 의해 <NameForm>의 상태값이 setState()되므로 사용자가 입력한 값으로 동기화된다. 덕분에 리액트가 관리하는 이 상태값은 단일 진실 공급원(Single Source of Truth)이라고 할 수 있다. setState()가 실행될 때마다 DOM도 함께 업데이트 되어 사용자가 보는 화면에 표시된 값은 리액트의 상태값인 것이다.

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};

    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(event) {
    this.setState({value: event.target.value});
  }

  handleSubmit(event) {
    alert('A name was submitted: ' + this.state.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" value={this.state.value} onChange={this.handleChange} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

제어 컴포넌트의 구현을 돕는 Formik, React Hook Form 과 같은 라이브러리도 있다. (Fomik vs React Hook Form 더 알아보기)

 

 

10. 상태 리프팅(Lifting State Up)

단일 진실 공급원

한 데이터가 변경되었을 때 여러 컴포넌트에 반영해야 하는 경우가 있다. 보통, 렌더링에 상태값을 필요로 하는 한 컴포넌트에 해당 상태가 먼저 추가되고, 다른 컴포넌트도 그 값이 필요로 하게 된다. 

이 경우, 각 컴포넌트가 동일한 상태값을 갖도록 동기화시키려는 시도는 버리고, 그들의 가장 가까운 공통 조상으로 상태를 끌어올리는 것이 좋다. 리액트에서는 변경이 일어나는 데이터에 대해서는 “진실 공급원(source of truth)“을 하나만 두고, 하향식 데이터 흐름(top-down data flow) 방식을 따라야 한다. 데이터가 흐르는 방향을 단방향(unidirectional)으로 만들어야 한다는 말이다. 

상태를 끌어올리는 것은 디버깅을 더 쉽게 만드는 등의 장점이 있다. 컴포넌트는 자신의 state를 스스로 변경할 수 있다. 따라서 분산된 컴포넌트에서 각각 상태를 관리한다면, 상위 컴포넌트 한 군데에서 상태를 관리하는 경우보다 버그가 존재할 수 있는 범위가 크게 늘어나 버릴 것이다.

상태 리프팅 구현

A, B 컴포넌트가 value라는 상태값을 공통적으로 필요로 한다면, 공통 조상 P 컴포넌트가 상태 value를 갖도록 한다. 이때 P 컴포넌트는 value의 진실 공급원(Source of Truth)으로서 A, B 컴포넌트가 일관된 값을 유지할 수 있도록 만들 수 있다.

 

 

11. 리액트스럽게 접근하기

[ 1단계 ] UI를 컴포넌트 계층구조로

이 단계에서는 모든 컴포넌트와 하위컴포넌트에 박스로 쪼개면서 각 박스에 이름을 붙인다. 함수나 객체를 만들 때 처럼 단일 책임 원칙(Single Responsibility Principle)을 준수하여, 한 컴포넌트가 한 가지 책임만 맡도록 분리하는 것이 이상적이다. 하나의 컴포넌트가 너무 커진 것 같다면 하위 컴포넌트로 분리하는 것이 좋다.

디자이너가 있다면, 디자이너가 이미 UI단위의 네이밍을 마쳤을테니 그것을 참고해서 컴포넌트의 이름을 지어도 좋다. 데이터를 받아오는 API가 완성되었다면, 받아오는 데이터의 구조를 참고하는 것도 좋다. UI와 데이터 모델이 같은 구조를 갖는 경향이 있기 때문이다.

[ 2단계 ] 정적인 React 만들기

이 단계에서는 리액트의 두 가지 데이터 모델 state, props 중 props만 사용해서 컴포넌트를 만든다. props는 부모가 자식에게 데이터를 넘겨줄 때 사용하는 방법이다. 리액트의 단방향 데이터 흐름(one-way data flow) 덕분에 UI를 쉽게 모듈화될 수 있다.

최상위 App컴포넌트부터 만드는 하향식(Top-down)과, 최하위 컴포넌트부터 만드는 상향식(Bottom-up) 모두 가능하다. 일반적으로, 작고 간단한 프로젝트는 하향식이,  크고 복잡하다면 상향식 방식이 더 적합하다.

[ 3단계 ] 최소한의 state 추가하기

이 단계에서는 state를 추가한다. state를 만들 때는 DRY원칙을 적용한다. (DRY: Don't Repeat YourSelf, 동일 코드를 반복하지 않는다는 프로그래밍 원칙.) 올바른 state를 만들기 위한 체크리스트를 살펴보자.
✅ 부모로부터 props 로 받아오고 있다면, state로 추가하면 안된다.
시간이 지남에 따라 값이 변하지 않는다면, state로 추가하면 안된다.
컴포넌트가 가지고 있는 다른 state나 props로 도출해낼 수 있는 값이라면, state로 추가하면 안된다.

[ 4단계 ] state 위치 정해주기

이 단계에서는 어떤 컴포넌트가 state를 갖고 있을지 결정한다. 우선 해당 state에 따라 렌더되어야 하는 모든 컴포넌트를 찾는다. 이 컴포넌트 들의 공통 상위 컴포넌트에 state를 위치키면 된다. 만약 공통 상위 컴포넌트가 없다면 컴포넌트를 상위에 하나 추가하고 이 컴포넌트가 해당 state를 갖도록 한다.

[ 5단계 ]  자식이 state를 변경할 수 있게

이 단계에서는 자식 컴포넌트에서 상위 컴포넌트가 소유하고 있는 state를 업데이트할 수 있도록 하는 단계이다. 상위 컴포넌트가 setState함수를 자식에게 props로 넘겨주고 이를 자식 컴포넌트의 이벤트 핸들러 등에서 실행하면 된다.

리액트의 단방향 데이터 바인딩은, 양방향 데이터 바인딩(two-way data binding) 방식보다 코드 작성량이 더 많아보이긴 한다. 하지만 단방향 데이터 흐름 덕분에, 앱 내에서의 데이터 흐름을 더 명시적으로 확인할 수 있고, 프로그램이 어떻게 동작하는지 쉽게 파악할 수 있다.