props가 변할 때마다 state도 바꿔주고 싶어! (그런데 이렇게 하면 안 되는..)

props가 변할 때마다 state도 바꿔주고 싶어!

발단: 문제 발생.. 그런데 뭐라고 검색해야 하지?

흰색 깃발에 커서를 올리면 깃발이 무지개색으로 변하고, 커서를 내리면 원래 색으로 돌아가는 기능을 구현하던 중이었다. 여러개의 백기가 마우스가 지나갈 때마다 색깔이 변하는 게 보고 싶었기 때문이다.

막상 코딩을 시작하니 문득 click 이벤트를 추가하고 싶다는 생각이 들었다. 그래서 깃발 하나를 클릭하면 모든 깃발의 색깔이 검은색으로 바뀌는 기능을 추가하기로 했다. 나는 막연히 'parent component에서 모든 child component에게 기본 깃발 색이 바뀌었단 사실을 알려주면 되겠지'라고 생각하며 코딩을 시작했다.

나는 먼저 child component에 flag라는 state를 만들었다. 그리고 mouseOver event handler를 추가하여 무지개색으로 변하는 기능을 추가하였다. 그 후에는 parent component에 defaultColor라는 state를 만들었다. 그리고 해당 state를 child component에게 props로 넘겨주었다. props가 변하면 child의 state도 바꿀 생각이었다.

그렇게 비극이 시작되었다. props 값에 따라 state를 바꾸는 코드를 어디에 어떻게 구현을 해야할지 감이 안 왔다. 본인이 못하는 건데 할 수 있다고 착각하는 것이 초보자다웠다.. 모든 깃발의 색깔 정보를 parent component에서 관리하는 방법을 생각해보기도 했으나 다른 해결 방법을 찾아보겠다며 이틀 동안 구글을 떠돌았다.

전개: 해결방법을 찾기까지

문제를 해결하기 위해 여러모로 검색하던 중에 React.Component에는 ComponentDidMount, ComponentDidUpdate 등의 메서드가 있다는 걸 알게 되었다. Component가 생성되거나 변화할 때에 호출된다기에 혹시 도움이 될까 싶어 해당 React 자습서의 React.Component 생명주기 파트를 읽게 되었다.

Component의 생명주기는 크게 마운트, 업데이트, 마운트 해제, 오류 처리로 나누어져 있었다. 각 파트에서는 해당 생명 주기에서 호출되는 메서드들을 순서대로 설명하고 있었다. 내 눈을 잡아끈 것은 업데이트 파트였다.

업데이트 파트에는 다음과 같은 다섯 개의 메서드가 나열되어 있었다.

  • static getDerivedStateFromProps()
  • shouldComponentUpdate()
  • render()
  • getSnapshotBeforeUpdate()
  • componentDidUpdate()

그 중에서도 내가 주목한 것은render() 이전의 메서드들이었다. 내가 하고 싶은 것은 props가 변할 때 이 변화를 감지해서 state를 바꾸고, 그 바뀐 state를 rendering 하는 것이었기 때문이다. 따라서 내가 원하는 작업은 render() 이전에 이루어져야 하는 일인 것이다. 그래서 나는 getDerivedStateFromProps()shouldComponentUpdate()를 알아보았다.

먼저 shouldComponentUpdate()를 살펴보았다. 자습서를 보니(자습서 없었으면 큰일날 뻔 했다..)shouldComponentUpdate()는 '현재 state 또는 props의 변화가 컴포넌트의 출력 결과에 영향을 미치는지 여부'를 알 수 있는 메서드라고 한다. 또한 props 또는 state가 업데이트 되어 렌더링이 발생하기 직전에 호출된다는데 버그가 발생할 수 있으니 '성능 최적화'에만 사용하라고 한다. 게다가 해당 메서드는 '잘 사용하지 않는 생명주기 메서드'로 분류되어 있었다. 이쯤 되니 내게 도움이 될 메서드는 아니라는 판단이 들었다. 그리고 나는...

절정: 두 개의 솔루션과 한 개의 문제점

getDerivedStateFromProps() 설명에서 이런 advice를 보게 되었다.

state를 끌어오면 코드가 장황해지고, 이로 인하여 컴포넌트를 이해하기 어려워집니다. 보다 간단한 다른 대안들에 익숙해지는 것을 권장합니다.

  • props 변화에 대응한 부수 효과를 발생시켜야 한다면 (예를 들어, 데이터 가져오기 또는 애니메이션), componentDidUpdate 생명주기를 대신해서 사용하세요.
  • props가 변화했을 때에만 일부 데이터를 다시 계산 하고 싶다면, Memoization Helper를 대신해서 사용하세요.
  • props가 변화할 때에 일부 state를 재설정 하고 싶다면, 완전 제어 컴포넌트 또는 key를 사용하는 완전 비제어 컴포넌트로 만들어서 사용하세요.

마지막 advice가 돌파구였다. 문제 해결을 위한 두 가지 솔루션,

  • Fully controlled component (완전 제어 컴포넌트)
  • Fully uncontrolled component with a key (key를 사용하는 완전 비제어 컴포넌트)

에 대해 간단히 알아보자.

1. Fully controlled component

  • 내부 state를 없애고 오로지 props에 의존하는 component로 바꾸는 방법이다.
  • component의 표현이 props에 의해 전적으로 결정되므로 코드도 간단하고 로직도 간단하다.

2. Fully uncontrolled component with a key

  • props가 변할 때 기존 component를 업데이트 하는 대신, 바뀐 props를 기반으로 한 새로운 component를 생성하는 방법이다.
  • key props를 변경해주면 알아서 기존의 component는 언마운트 되고 새로운 component가 마운트 된다.
  • 내부 state가 있을 수 있지만, props를 state에 저장하지는 않는다.

각 방법들을 살펴보다 보니 React에서는 state가 props에 의존하는 패턴을 지양한다고 한다. 이유가 궁금해서 찾아보니 중요한 설계 원칙이 숨어 있었다. 배운 바가 커서 바로 아래(Derived State 파트)에 정리해두었다.

Derived State

props에 의존하는 state를 Derived State라고 부른다. state 값이 props의 값에 따라 결정되는 경우를 말하는 건데, 이 예시로 component에게 받은 props를 child component의 state로 저장해서 사용하는 경우를 들 수 있다.

state가 props에 의존하는 건 좋지 않다. 방금 본 예시를 보고 문제점을 살펴보자. 이 예시에서는 같은 상태(여기에서 상태란 정보를 담는 source를 뜻한다. React에서 사용되는 state 기능이 아니다.)를 나타내는 변수가 여러개(parent의 props와 child의 state) 존재한다. source에 대응하는 변수를 하나만 두고 그 변수를 직접 조작하거나 참조하는 방향(source of truth)으로 가야 예측할 수 없는 에러를 줄일 수 있는데, 이 경우에는 source에 대응하는 변수가 여러개여서 어떤 변수에 의해 component 동작이 달라졌는지 추적하기 어렵다. 그렇기에 자습서에서는 "derived state should be used sparingly"라고 하며, 해당 패턴을 지양하라고 설명해놓았다.

결말: 굴러는 가는데요..

앞에서 살펴본 두 가지 해결책 중, 두 번째 방법을 사용해서 기능을 구현하였다. 결과 코드는 다음과 같다.

Parent Component

class FlagContainer extends React.Component{
    constructor(props){
        super(props)
        this.state = {
            defaultColor: true,
            count: 0,
        }
    }

    onClick() {
        const { defaultColor, count } = this.state
        this.setState({
            defaultColor: !defaultColor,
            count: (count+30)%60
        })
    }

    render(){
        const { defaultColor, count } = this.state
        const arr = Array(30).fill(0)

        return (
            <div>
                {
                    arr.map((value, index) => {
                        return (
                            <Flag
                                key={count+index}
                                initialFlag={defaultColor}
                                onClick={() => this.onClick()}
                            ></Flag>
                        )
                    })
                }
            </div>
        )
    }
}

Child Component

class Flag extends React.Component{
    constructor(props){
        super(props)
        this.initialFlag = this.props.initialFlag ? "🏳️" : "🏴"
        this.state = {
            flag: this.initialFlag
        }
    }

    onMouseOver() {
        this.setState({
            flag: "🏳️‍🌈"
        })
    }

    onMouseOut() {
        this.setState({
            flag: this.initialFlag
        })
    }

    render(){
        const { flag } = this.state
        return(
            <span
                onClick={this.props.onClick}
                onMouseOver={() => this.onMouseOver()}
                onMouseOut={() => this.onMouseOut()}
            >{flag}</span>
        )
    }
}

사실 이 방법이 괜찮은 방법인지는 잘 모르겠다. 이번에 삽질을 하면서

  • 데이터나 상태는 parent component에서 저장, 관리
  • child component에서는 parent component에서 받아온 데이터나 상태를 표현하는 것에 집중

하는 것이 중요한 것 같단 생각을 했기 때문이다.

React가 왜 만들어졌는지, React는 어떤 패턴을 지향하고 어떤 패턴을 지양하는지를 공부해야겠다는 생각이 들었다.

Reference

getDerivedStateFromProps()
You Probably Don't Need Derived State
React Derived State 다시 보기

좋은 웹페이지 즐겨찾기