React State와 생명주기

0

State란 함수 내에 선언된 변수처럼 컴포넌트의 렌더링 결과물에 영향을 주는 데이터를 가진 객체를 말합니다.

React 시계 업데이트
function tick() {
  const element = (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {new Date().toLocaleTimeString()}.</h2>
    </div>
  );
  ReactDOM.render(element, document.getElementById('root'));
}

setInterval(tick, 1000);

위 코드는 이전 포스트에서 구현했던 “React 시계 업데이트” 예제로 setInterval() 함수로 렌더링 된 출력값을 변경하기 위해 ReactDOM.render()를 호출했었습니다.

다음의 코드는 앞에서 구현했던 “React 시계 업데이트” 예제의 엘리먼트를 개선하여, Clock이라는 컴포넌트로 만들어 캡슐화 한 코드입니다.

Clock 컴포넌트 캡슐화
function Clock(props) {
  return (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {props.date.toLocaleTimeString()}.</h2>
    </div>
  );
}

function tick() {
  ReactDOM.render(
    <Clock date={new Date()} />,
    document.getElementById('root')
  );
}

setInterval(tick, 1000);

코드는 정상적으로 잘 동작하지만 중요한 요건이 누락되어 있습니다.

Clock의 타이머를 설정하고 매초 UI를 업데이트하는 것이 Clock의 구현 세부사항이 되어야 하는데, 다음의 코드와 같이 Clock이 스스로 업데이트하도록 만드는 것이 가장 이상적이라고 할 수 있습니다.

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

위와 같이 Clock이 스스로 업데이트하도록 구현하기 위해서는 Clock 컴포넌트에 “state”를 추가해야 하는데, state는 props와 유사하지만 비공개이고, 컴포넌트에 의해 완전히 제어된다는 특징이 있습니다.

함수를 클래스로 변환하기

함수 컴포넌트를 클래스로 변환하기 위해서는 다음의 5단계가 필요합니다.

  • React.Component를 확장하는 동일한 이름의 ES6 class를 생성
  • render()라고 불리는 빈 메서드를 추가
  • 함수의 내용을 render() 메서드 안으로 옮김
  • render() 내용 안에 있는 propsthis.props로 변경
  • 남아있는 빈 함수 선언 삭제

위의 5단계를 참고하여 앞의 함수 컴포넌트 코드를 클래스 컴포넌트로 변환하면 코드가 다음과 같이 바뀌는데, 이렇게 클래스 컴포넌트로 변환한 Clock은 이제 함수가 아닌 클래스로 동작하게 됩니다.

클래스 컴포넌트
const root = ReactDOM.createRoot(document.getElementById('root'));

class Clock extends React.Component {
  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.props.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

function tick() {
  root.render(<Clock date={new Date()} />);
}

setInterval(tick, 1000);

render 메서드는 업데이트가 발생할 때마다 호출되지만, <Clock /> 처럼 같은 DOM 노드로 렌더링하는 경우에는 Clock 클래스의 단일 인스턴스만 사용되기 때문에 로컬 State와 생명주기 메서드와 같은 부가적인 기능을 사용할 수 있습니다.

클래스에 로컬 State 추가하기

앞에서 변환한 클래스에 로컬 State를 추가하려면 다음의 3단계에 걸쳐 dateprops에서 state로 이동해야 합니다.

  • render() 메서드 안에 있는 this.props.datethis.state.date로 변경
  • 초기 this.state를 지정하는 class constructor를 추가
  • <Clock /> 요소에서 date prop을 삭제

1단계: props를 state로 변경하기

1단계에서는 클래스 컴포넌트의 render() 메서드 안에 있는 this.props.datethis.state.date로 변경해야 합니다.

this.state.date로 변경
class Clock extends React.Component {
  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

2단계: class constructor 추가하기

2단계에서는 초기 this.state를 지정하는 class constructor를 클래스 컴포넌트에 추가해야 합니다.

초기 this.state를 지정하는 class constructor 추가
class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

여기서 유의해야 할 부분은 props를 기본 constructor에 전달하는 부분인데, 클래스 컴포넌트는 항상 props로 기본 constructor를 호출.해야 된다는 것에 유의할 필요가 있습니다.

3단계: date prop 삭제하기

컴포넌트 요소에서 date prop 삭제
ReactDOM.render(
  <Clock />,
  document.getElementById('root')
);

3단계에서는 기존의 <Clock /> 요소에 추가되어 있던 date prop을 삭제해 주면 되는데, 클래스에 로컬 State를 추가한 전체 코드는 다음과 같습니다.

클래스에 로컬 State를 추가한 전체 코드
class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

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

이렇게 클래스에 로컬 State를 추가했지만 아직 Clock 컴포넌트가 스스로 타이머를 설정하고 매초 스스로 업데이트하는 것을 구현하지 않았는데, 이 부분을 구현하는데 필요한 것이 바로 생명주기 메서드입니다.

클래스에 생명주기 메서드 추가하기

많은 컴포넌트가 있는 애플리케이션에서 컴포넌트가 삭제하는 경우에는 해당 컴포넌트가 사용 중이던 리소스를 확보하는 것이 중요합니다.

앞에서 State를 추가했던 Clock 컴포넌트는 처음 DOM에 렌더링 될 때마다 타이머를 설정하려고 하는데, 이것을 React에서는 “마운팅”이라 하고, Clock에 의해 생성된 DOM은 삭제될 때마다 타이머를 해제하려고 하는데, 이것은 “언마운팅”이라고 합니다.

컴포넌트 클래스에서는 특별한 메서드를 선언하여 컴포넌트가 마운트되거나 언마운트 될 때 일부 코드를 작동시킬 수 있는데, 이런 메서드들을 “생명주기 메서드”라고 합니다.

생명주기 메서드 추가하기
class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  componentDidMount() {
  }

  componentWillUnmount() {
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

componentDidMount() 메서드는 컴포넌트 출력물이 DOM에 렌더링 된 후에 실행되는데, 타이머를 설정하는 경우에는 이 메서드를 사용하는 것이 가장 좋겠죠?

생명주기 메서드에 타이머 설정하기
componentDidMount() {
    this.timerID = setInterval(
      () => this.tick(),
      1000
    );
}

위 코드에서 타이머 ID는 this라는 객체에 timerID, 즉 this.timerID라는 속성을 추가하여 저장하고 있습니다. 이렇게 this.props는 React에 의해 스스로 설정되는데, this.state에는 특수한 의미가 부여되지만, timerID와 같이 데이터의 흐름 안에 포함되지 않는 이름이라면 클래스에 자유롭게 부가적인 필드로 추가할 수 있습니다.

타이머 종료시키기
componentWillUnmount() {
    clearInterval(this.timerID);
}

위 코드와 같이 componentWillUnmount() 생명주기 메서드에는 타이머를 종료시키는 코드를 추가하고, 마지막으로 Clock 컴포넌트를 매초마다 작동하기 위해 로컬 State를 업데이트하는 this.setState()라는 메서드로 tick()이라는 메서드를 구현하면 정상적으로 업데이트되는 React 시계를 확인할 수 있습니다.

생명주기 메서드가 구현된 React 시계 코드
class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  componentDidMount() {
    this.timerID = setInterval(
      () => this.tick(),
      1000
    );
  }

  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  tick() {
    this.setState({
      date: new Date()
    });
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

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

<Clock />ReactDOM.render()로 전달되면 React는 Clock 컴포넌트의 constructor를 호출하는데, 이 후에는 다음과 같은 순서로 메서드가 호출됩니다.

  • <Clock />ReactDOM.render()로 전달되면 React는 Clock 컴포넌트의 constructor를 호출
  • Clock이 현재 시각을 표시해야 하기 때문에 현재 시각이 포함된 객체로 this.state를 초기화, 나중에 이 state를 업데이트함
  • React는 Clock 컴포넌트의 render() 메서드를 호출, 이를 통해 React는 화면에 표시되어야 할 내용을 알게 됨
  • React는 Clock의 렌더링 출력값을 일치시키기 위해 DOM을 업데이트
  • Clock 출력값이 DOM에 삽입되면, React는 componentDidMount() 생명주기 메서드를 호출
  • 그 안에서 Clock 컴포넌트는 매초 컴포넌트의 tick() 메서드를 호출하기 위한 타이머를 설정하도록 브라우저에 요청
  • 매초 브라우저가 tick() 메서드를 호출
  • 그 안에서 Clock 컴포넌트는 setState()에 현재 시각을 포함하는 객체를 호출하면서 UI 업데이트를 진행
  • setState() 호출 덕분에 React는 state가 변경된 것을 인지하고 화면에 표시될 내용을 알아내기 위해 render() 메서드를 다시 호출
  • 이 때 render() 메서드 안의 this.state.date가 달라지고 렌더링 출력값은 업데이트된 시각을 포함하는데, React는 이에 따라 DOM을 업데이트함
  • Clock 컴포넌트가 DOM으로부터 한 번이라도 삭제된 적이 있다면 React는 타이머를 멈추기 위해 componentWillUnmount() 생명주기 메서드를 호출함

State 제대로 사용하기

setState()를 사용하는 경우 다음의 3가지에 대해 유의 및 인지 할 필요가 있습니다.

  • State를 직접 수정하면 안된다.
  • State 업데이트는 비동기적일 수도 있다.
  • State 업데이트는 병합된다.

State 수정하기

잘못된 코드
this.state.comment = 'Hello';

위의 코드는 컴포넌트를 다시 렌더링하지 않는데, 대신 setState()를 사용하면 됩니다. this.state를 지정할 수 있는 유일한 공간은 바로 constructor입니다.

올바른 코드
this.setState({comment: 'Hello'});

State의 비동기적 업데이트

React는 성능을 위해 여러 setState() 호출을 단일 업데이트로 한꺼번에 처리할 수 있는데, 이때 this.propsthis.state가 비동기적으로 업데이트될 수 있기 때문에 다음 state를 계산할 때 해당 값에 의존해서는 안됩니다.

다음과 같은 코드는 카운터 업데이트에 실패할 가능성이 높습니다.

잘못된 코드
this.setState({
  counter: this.state.counter + this.props.increment,
});

위와 같은 문제를 피하기 위해서는 객체보다 함수를 인자로 사용하는 다른 형태의 setState()를 사용해야 하는데, 그 함수는 이전 state를 첫 번째 인자로 받아들이고, 업데이트가 적용된 시점의 props를 두 번째 인자로 받아들일 수 있기 때문입니다.

올바른 코드
this.setState((state, props) => ({
  counter: state.counter + props.increment
}));

위 코드는 화살표 함수 대신 일반적인 함수를 사용해도 정상적으로 작동합니다.

일반 함수를 사용한 경우
this.setState(function(state, props) {
  return {
    counter: state.counter + props.increment
  };
});

State 업데이트 병합

setState()를 호출할 때 React는 제공한 객체를 현재 state로 병합하는데, 다음의 코드와 같이 state는 다양한 독립적인 변수를 포함할 수 있습니다.

독립적인 변수를 포함하는 state 객체
constructor(props) {
    super(props);
    this.state = {
        posts: [],
        comments: []
    };
}

위와 같은 경우 별도의 setState() 호출로 이러한 변수를 독립적으로 업데이트할 수 있는데, 병합은 얕게 이루어지기 때문에 this.setState({comments})this.state.posts에 영향을 주지는 않지만 this.state.comments는 완전히 대체됩니다.

데이터의 흐름

부모 컴포넌트와 자식 컴포넌트 모두 특정 컴포넌트의 상태를 알 수 없기 때문에, 해당 컴포넌트들이 함수나 클래스로 정의되었는지에 대해 고려해야 할 필요는 없습니다.

이 때문에 state는 로컬 또는 캡슐화라고 불리는데, state가 소유하고 설정한 컴포넌트 이외에는 어떠한 컴포넌트에도 접근할 수 없기 때문입니다. 하지만 컴포넌트는 다음과 같이 자신의 state를 자식 컴포넌트에 props로 전달할 수 있습니다.

자식 컴포넌트에 props 전달하기
<FormattedDate date={this.state.date} />
컴포넌트의 props 처리
function FormattedDate(props) {
  return <h2>It is {props.date.toLocaleTimeString()}.</h2>;
}

FormattedDate 컴포넌트는 date를 자신의 props로 받지만, 이것이 Clock의 state로부터 왔는지, Clock의 props에서 왔는지, 수동으로 입력한 것인지에 대해서는 알지 못합니다.

일반적으로 이를 “하향식” 또는 “단방향식” 데이터 흐름이라고 하는데, 모든 state는 항상 특정한 컴포넌트가 소유하고 그 state로부터 파생된 UI 또는 데이터는 오직 트리구조에서 자신의 “아래”에 있는 컴포넌트에만 영향을 미칠 수 있습니다.

트리구조에서 props들은 폭포와 같이 아래로 흐르는데, 각 컴포넌트의 state는 임의의 점에서 만나지만 동시에 아래로 흐르는 부가적인 수원이라고 할 수 있습니다.

다음은 모든 컴포넌트가 완전히 독립적이라는 것을 보여주기 위해 App을 렌더링하는 세 개의 <Clock>을 구현한 코드입니다.

모든 컴포넌트의 독립성 테스트
function FormattedDate(props) {
  return <h2>It is {props.date.toLocaleTimeString()}.</h2>;
}

class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  componentDidMount() {
    this.timerID = setInterval(
      () => this.tick(),
      1000
    );
  }

  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  tick() {
    this.setState({
      date: new Date()
    });
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <FormattedDate date={this.state.date} />
      </div>
    );
  }
}

function App() {
  return (
    <div>
      <Clock />
      <Clock />
      <Clock />
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

위 코드의 각 Clock은 자신만의 타이머를 설정하고 독립적으로 업데이트를 하는데, React 앱에서 컴포넌트의 상태는 시간이 지남에 따라 변경될 수 있는 구현 세부 사항으로 간주합니다. 즉 유상태 컴포넌트 안에서도 무상태 컴포넌트를 사용할 수 있고, 그 반대의 경우도 마찬가지입니다.

답글 남기기