[번역] 탄력적인 컴포넌트 작성하기
Writing Resilient Components
(이 글은 React의 코어 컨트리뷰터인 Dan Abramov 의 Writing Resilient Components를 번역한 글입니다.)
React를 배우기 시작하는 사람들은 보통 스타일 가이드를 찾아본다. 프로젝트 전반에 적용되는 일관적인 규칙을 가지려는 자세는 좋으나 대부분은 각자가 정한 임의의 규칙에 따르게 된다. 그래서 React는 어떤 규칙을 지키라고 특별히 강요하지 않고 있다.
사람마다 다른 타입 시스템을 사용할 수 있고, 함수 선언 또는 화살표 함수 중 하나를 더 선호할 수 있으며, 나중에 찾기 쉬우려고 props를 알파벳 순으로 정렬해 둘 수도 있다.
이런 유연함은 React를 이미 규칙(convention)이 존재하고 있는 프로젝트에 도입해서 사용하는 일을 가능하게 한다. 하지만 끝없는 논쟁도 불러일으키기도 한다.
React에는 모든 컴포넌트가 지키기 위해 노력해야 할 중요한 디자인 원칙들이 있다. 그런데 내 생각엔 스타일 가이드들이 이 원칙들을 잘 받아들여서 만들어져 있지 않은 것 같다. 먼저 스타일 가이드에 대해 살펴보고, 그 후에 정말로 유용한 원칙들에 대해 얘기하려고 한다.
상상 속의 문제에 주의를 빼앗기지 마라
컴포넌트 디자인 원칙을 이야기하기 전에, 나는 스타일 가이드에 대해 몇 마디를 하고 싶다. 이건 사람들이 좋아할 만한 얘기는 아니지만, 누군가는 해야 한다!
자바스크립트 커뮤니티에서는 린터1가 강요하는 매우 편항적인 스타일이 몇몇 존재한다. 내가 보기에는 어떤 이들은 그 스타일을 가지고 필요 이상으로 다른 사람들과 마찰을 일으키곤 한다. 나는 아무런 문제가 없는 코드를 내게 보여주면서 “React가 이 코드에 문제가 있다고 하네요?”라고 말하는 사람을 도대체 몇 명이나 봤는지 셀 수가 없을 정도다. React가 아니라 그들이 사용하고 있는 린터의 규칙이 그렇게 말하고 있는 것이란 말이다! 이는 세 가지 이슈로 이어진다.
- 사람들은 린터가 개발에 유용한 도구 역할을 하기보다 지나치게 시끄러운 감시인 역할을 하는 것에 익숙해져 있다. 코딩 스타일의 깔끔함을 유지하기 위해 검사기가 발생시키는 너무나 많은 메시지 때문에 유용한 경고문들은 다 묻혀버린다. 결과적으로 사람들은 디버깅할 때 린터의 메시지를 살펴보지 않게 되며 유용한 팁도 놓치게 된다. 게다가 자바스크립트 작성에 익숙하지 않은 사람들(예를 들어 디자이너)은 코드를 작성하는데 더 어려운 시간을 보내게 된다.
- 사람들은 특정 패턴이 맞는 것인지 틀린 것인지 구분할 수 없게 된다. 예를 들어 인기 있는 규칙 중에
setState
를componentDidMount
에서는 호출하지 말라는 것(react/no-did-mount-set-state)이 있다. 하지만 만약 저것이 항상 “나쁜” 패턴이었다면 React는 애초에 그런 코드를 작성할 수 있게 만들지도 않았을 것이다! 저 패턴의 적법한 사용 사례에는 DOM 노드의 레이아웃을 확인하는 것 – 예를 들면 툴팁의 위치 결정 – 이 있다. 나는 저 규칙에 걸리지 않으려고setTimeout
을 추가하는 사례를 많이 봤다. 완전히 요점을 놓쳐버린 것이다. - 결국 사람들은 린터 덕분에 “강압적인 마인드”를 가지게 되고 별다른 차이도 없으면서 코드에서 확인하기 쉬운 규칙을 지키려 하게 된다. “당신은 함수 선언에 익숙하다고 했지만, 우리 프로젝트에서는 화살표 함수를 사용할 것입니다.” 나는 저런 규칙을 강요한다는 느낌이 들 때마다 내가 감정적인 소모를 한다는 것을 알게 되어서, 이제는 그냥 놓아버리려고 노력한다. 그것들은 내 코드의 품질을 높여주지는 않고 단지 스타일을 지키면서 잘못된 성취감을 느껴보라고 어르고 있는 것 같다.
그렇다고 내가 린터를 사용하지 말라고 하는 건가? 전혀 그렇지 않다!
좋은 규칙과 함께라면 린터는 버그가 일어나기 전에 잡아줄 수 있는 아주 훌륭한 도구다. 하지만 스타일에 너무 치중해서 개발에 오히려 방해되고 있다는 말이다.
필요한 문법 규칙만 남기고 정리하자
당장 다음 주 월요일에 할 수 있는 일을 제안하려고 한다. 팀원들과 함께 30분만 투자해서 프로젝트에 설정된 문법 규칙을 하나씩 살펴보기 바란다. “이 규칙이 정말로 우리가 버그를 잡는 데 도움이 줬던가?”라고 물어보자. 만약 아니라면 그냥 꺼 버려라.(또는 스타일 규칙이 없는 깔끔한 eslint-config-react-app에서 시작해도 된다)
최소한 저런 규칙 때문에 팀 내부에서 생길 수 있는 잡음을 일찌감치 제거하는 과정이 필요하다. 당신이나 누군가가 수년 전에 린터에 추가해 둔 어떤 규칙도 “모범 사례”라고 여기지 않았으면 한다. 사용 중인 규칙에 대한 의구심을 가지고 정답을 찾아보자. 그 누구도 당신이 린터 규칙을 결정할 정도로 똑똑하지 않다고 말할 순 없다.
그런데 코드 포매팅은 어떻게 할까? Prettier를 사용하고 “스타일의 깔끔함”에 대해서는 잊어버리자. 코드에서 빠진 세미콜론이나 빈칸을 알아서 추가해 줄 수 있는 도구(=Prettier)를 이미 사용하고 있는데도 다른 도구가 잘못된 곳이 있다고 소리치도록 둘 필요는 없다. 린터는 버그를 찾기 위해서 사용해야지, 우리에게 코드의 심미성을 강요하게 만들어선 안 된다.
물론 포매팅과 직접적인 관계는 없지만, 프로젝트 전반에 일관적으로 적용되지 않으면 신경을 거슬리게 하는 코딩 스타일이 있긴 하다.
그러나, 그런 것들은 대부분 린터의 규칙으로 잡아내기에는 너무나 미묘하다. 그래서 동료들 간에 신뢰를 구축하는 일과 본인이 공부한 내용을 위키 형식이나 짧은 디자인 가이드 형태로 공유하는 일이 중요하다.
모든 것을 자동화할 필요는 없다! 동료가 시간을 들여 작성한 가이드를 읽으며 얻는 통찰은 그저 단순히 “규칙”을 따르는 것보다 훨씬 더 가치 있는 일이다.
그런데 엄격한 스타일 가이드를 따르는 것이 방해된다면, 무엇이 정말로 중요한가?
그것이 이 포스트의 주제다.
탄력적인 컴포넌트 작성하기
들여쓰기를 맞추는 일이나 모듈 import를 알파벳 순으로 정리하는 일이 잘못된 컴포넌트 디자인을 바로잡아 주지 않는다. 그래서 코드가 어떻게 보이는지에 초점을 맞추는 대신 나는 어떻게 동작하는지에 초점을 맞추려고 한다. 내가 도움된다고 여기는 몇 개의 컴포넌트 디자인 원칙들이 있다.
- 데이터 흐름을 멈추지 마라
- 언제라도 렌더링을 할 준비가 되어 있게 하라
- 싱글턴(singletone)인 컴포넌트는 없다.
- 로컬 state를 독립된 상태로 유지하라
만약 당신이 React를 사용하지 않는다고 하더라도, 단방향(unidirectional) 데이터 흐름 모델을 사용하는 UI 라이브러리를 사용하다 보면 이와 같은 규칙을 찾아내게 될 것이다.
원칙 1: 데이터 흐름을 멈추지 마라
렌더링에서 데이터 흐름을 멈추지 마라
다른 사람이 당신이 만든 컴포넌트를 사용할 때, 그들은 다른 값을 가진 props를 계속 전달할 수 있고 컴포넌트에 그 변화가 반영될 것이라 기대한다.
// isOk 는 상태 값에 기반을 두며 언제든지 변경될 수 있다
<Button color={isOk ? `blue` : `red`} />
일반적으로 이것이 React가 기본적으로 동작하는 방식이다. 당신이 color
prop을 Button
컴포넌트에 전달한다면 위의 렌더링 과정에서 전달된 값을 확인할 수 있다.
function Button({ color, children }) {
return (
// ✅ `color` 값은 언제나 신선!
<button className={'Button-' + color}>
{children}
</button>
);
}
하지만 React를 배우면서 하게 되는 흔한 실수는 props를 state로 복사하는 것이다.
class Button extends React.Component {
state = {
color: this.props.color
};
render() {
const { color } = this.state; // 🔴 `color`는 오래됨!
return (
<button className={'Button-' + color}>
{this.props.children}
</button>
);
}
}
이는 클래스를 React 밖에서 사용한 경험이 있다면 더 직관적이었을 것이다. 하지만 어떤 props를 state에 복사한다면 당신은 Button에 오는 모든 업데이트를 무시하게 된다.
// 🔴 color를 state에 복사한 구현으로는 더는 제대로 작동하지 않는다.
<Button color={isOk ? `blue` : `red`} />
이런 구현을 의도적으로 하는 경우가 아주 드물게 있긴 하다. initialColor
또는 defaultColor
값을 받아서 그 값들의 업데이트를 무시하겠다고 확실히 하는 것이다.
하지만 일반적으로 당신은 props를 컴포넌트에서 직접 읽을 수 있길 바랄 것이며 props(그리고 props로부터 계산된 어떤 값이라도)를 state에 복사하는 일은 피하고 싶을 것이다.
function Button({ color, children }) {
return (
// ✅ `color` 값은 언제나 최신!
<button className={'Button-' + color}>
{children}
</button>
);
}
계산된 값2은 사람들이 이따금 props를 state로 복사하게 되는 원인 중 하나다. 예를 들어 버튼 텍스트 컬러를 결정하는데 color
를 파라미터로 받으면서 결과를 얻는데 시간과 자원이 많이 소모되는 함수를 사용한다고 생각해보자.
class Button extends React.Component {
state = {
textColor: slowlyCalculateTextColor(this.props.color)
};
render() {
return (
<button className={
'Button-' + this.props.color +
' Button-text-' + this.state.textColor // 🔴 `color` prop 업데이트를 반영하지 않는다
}>
{this.props.children}
</button>
);
}
}
이 컴포넌트는 color
prop이 변경되었을 때 this.state.textColor
를 다시 계산하지 않으므로 버그가 있다고 할 수 있다. 가장 쉬운 해법은 textColor
계산을 render
메소드 안에서 하고 PureComponent
를 사용하는 방법이다.
class Button extends React.PureComponent {
render() {
const textColor = slowlyCalculateTextColor(this.props.color);
return (
<button className={
'Button-' + this.props.color +
' Button-text-' + textColor // ✅ 언제나 최신
}>
{this.props.children}
</button>
);
}
}
문제가 해결되었다! 이제 props가 변경되면 textColor
를 다시 계산한다.PureComponent
를 사용했으니 같은 prop이 넘어오면 무시하므로 비싼 계산을 다시 하지도 않는다.
하지만 우리는 더 최적화를 하고 싶다. 만약 children
prop이 바뀐다면 어떻게 되나? 렌더링을 할 때마다 그 textColor
를 계산하는 무거운 함수(slowlyCalculateTextColor
)를 다시 실행하게 될 것이다. 이를 해결하기 위한 솔루션은 아마도 componentDidUpdate
에서 textColor
를 계산하는 방법이 될 것이다.
class Button extends React.Component {
state = {
textColor: slowlyCalculateTextColor(this.props.color)
};
componentDidUpdate(prevProps) {
if (prevProps.color !== this.props.color) {
// 😔 color가 업데이트 될 때마다 추가적인 렌더링이 들어간다.
this.setState({
textColor: slowlyCalculateTextColor(this.props.color),
});
}
}
render() {
return (
<button className={
'Button-' + this.props.color +
' Button-text-' + this.state.textColor // ✅ 최종 렌더링에서는 prop 업데이트를 반영한다
}>
{this.props.children}
</button>
);
}
}
하지만 이는 우리의 컴포넌트가 prop이 바뀔때마다 한 번 더 렌더링하게 될 것을 의미한다. 우리가 최적화를 시도한 상황이라면 이건 이상적인 방법이 아니다.
이제는 레거시가 된 componentWillReceiveProps
라이프사이클 메소드를 사용할 수도 있다. 하지만 사람들은 보통 거기에 사이드 이펙트도 함께 집어넣는다. 그리고 그것은 앞으로 React에서 구현될 동시 렌더링(Concurrent Rendering) 기능과 관련된 시분할과 지연(Time slicing and Suspense) 문제를 야기한다. 그리고 “안전한” getDerivedStateFromProps
라이프사이클 메소드는 이제 좀 구식이다.
두 번째로 돌아가 보자. 사실 우리는 메모이제이션(memoization)을 원한다. 어떤 입력값이 있고, 우리는 그 입력값이 바뀌지 않는 한 다시 계산하지 않길 바란다.
클래스를 사용한다면 메모이제이션을 위한 헬퍼를 사용할 수도 있다. 하지만 한 단계 더 나가서 비싼 계산을 메모이제이션 할 수 있는 기능을 제공하는 React의 Hooks를 사용해보자.
function Button({ color, children }) {
const textColor = useMemo(
() => slowlyCalculateTextColor(color),
[color] // ✅ `color`가 바뀔 때만 실행하라는 의미
);
return (
<button className={'Button-' + color + ' Button-text-' + textColor}>
{children}
</button>
);
}
이게 당신이 필요한 코드 전부다!
클래스 컴포넌트에서는 memoize-one 같은 헬퍼를 사용할 수 있다. 함수형 컴포넌트에서는 useMemo
훅(Hook)이 비슷한 기능을 제공한다.
이제 우리는 비싼 계산을 최적화하는 과정에서도 props를 state에 복사하는 것은 좋은 방법이 아니라는 사실을 확인했다. 우리의 렌더링 결과는 props의 변경을 계속 반영해야 한다.
사이드 이펙트 안에서 데이터 흐름을 멈추게 하지 말라
지금까지 얘기한 내용은 prop 변경이 있을 때 렌더링 결과를 일관적으로 유지하는 방법에 관한 것이다. props를 state에 복사하지 않는 것이 거기에 포함된다. 그런데 한편으로는, 사이드 이펙트 또한 데이터 흐름의 일부라는 것도 중요하다.
아래의 React 컴포넌트를 살펴보자.
class SearchResults extends React.Component {
state = {
data: null
};
componentDidMount() {
this.fetchResults();
}
fetchResults() {
const url = this.getFetchUrl();
// 원격에서 데이터 가져오기 ...
}
getFetchUrl() {
return 'http://myapi/results?query' + this.props.query;
}
render() {
// ...
}
}
아주 많은 React 컴포넌트가 이와 비슷한 형태다. 하지만 조금만 더 가까이서 보면, 우리는 버그를 발견할 수 있다. fetchResults
메소드는 데이터를 가져올 때 query
prop을 사용한다.
getFetchUrl() {
return 'http://myapi/results?query' + this.props.query;
}
그런데 query
prop이 바뀐다면? 위의 컴포넌트에서는 아무런 일도 일어나지 않는다. 즉 우리가 작성한 컴포넌트의 사이드 이펙트가 props의 변경을 고려하지 않고 있다는 말이다. 이런 코드는 React 앱에서 발생하는 아주 흔한 버그 중 하나다.
이 컴포넌트를 고치기 위해서 해야 할 일은 다음과 같다.:
-
componentDidMount
안에서 호출되는 모든 메소드를 살펴본다.- 예제에서는
fetchResults
와getFetchUrl
이다.
- 예제에서는
-
그 메소드들이 사용하고 있는 모든 props와 state를 정리한다.
- 예제에서는
this.props.query
하나다.
- 예제에서는
-
그 props들이 변경될 때마다 사이드 이펙트가 확실하게 실행되도록 만들어야 한다.
componentDidUpdate
메소드를 추가함으로써 구현할 수 있다.
class SearchResults extends React.Component {
state = {
data: null
};
componentDidMount() {
this.fetchResults();
}
componentDidUpdate(prevProps) {
if (prevProps.query !== this.props.query) { // ✅ query가 변경되었을 때 fetchResults 실행
this.fetchResults();
}
}
fetchResults() {
const url = this.getFetchUrl();
// 원격에서 데이터 가져오기 ...
}
getFetchUrl() {
return 'http://myapi/results?query' + this.props.query; // ✅ 업데이트가 반영됨
}
render() {
// ...
}
}
만약 이런 실수를 자동으로 잡아줄 수 있다면 더 좋지 않을까? 린터 같은 도구가 우리를 도와줄 수 있진 있을까?
안타깝게도 클래스 컴포넌트의 일관성을 자동으로 검사하는 것은 몹시 어려운 일이다. 모든 메소드는 다른 모든 메소드 안에서 호출될 수 있다. componentDidMount
와 componentDidUpdate
에서의 함수 호출을 정적으로 분석하는 것은 잘못된 긍정(false positives)이 있어서 곤란하다.
하지만, React에 일관성을 확인하기 위해 정적으로 분석될 수 있는 API를 설계할 수 있었다. React useEffect
Hook이 그런 API다.
function SearchResults({ query }) {
const [data, setData] = useState(null);
const [currentPage, setCurrentPage] = useState(0);
useEffect(() => {
function fetchResults() {
const url = getFetchUrl();
// 원격에서 데이터 가져오기 ...
}
function getFetchUrl() {
return (
'http://myapi/results?query' + query +
'&page=' + currentPage
);
}
fetchResults();
}, [currentPage, query]); // ✅ 변경되었을 때 데이터를 다시 가져오기
// ...
}
우리는 effect 안에 로직을 넣어뒀기에, React 데이터 흐름에서 어떤 값들에 의존하고 있는지 더 쉽게 확인할 수 있다. 이 예제에서는 [currentPage, query]
다.
이 “effect 의존성(dependencies)” 배열이 새로운 개념이 아니라는 사실에 유의해야 한다. 클래스에서는 함수가 호출될 때마다 매번 이 “의존성”을 확인했었다. useEffect
API는 단지 같은 개념을 더 명시적으로 만들었을 뿐이다.
그리고 이 API를 사용하면 린터가 자동으로 의존성 검사를 할 수 있다.
(새로운 추천 규칙인 exhaustive-deps을 사용하는 예제다. 이 규칙은 eslint-plugin-react-hooks에 포함되어 있다. 그리고 곧 Create React App에 포함될 것이다)
당신이 컴포넌트 작성에 클래스를 사용하든 함수를 사용하든, 사이드 이펙트에는 모든 prop과 state의 변경을 고려해야 한다는 점이 중요하다는 것을 꼭 알아야 한다.
만약 클래스 API를 사용한다면 당신은 렌더링 일관성에 대해 스스로 생각해야 하며 componentDidUpdate
메소드 안에서 관련된 모든 prop또는 state의 변경을 직접 확인해야 한다. 그러지 않으면 당신의 컴포넌트는 prop과 state의 변경에 탄력적일 수 없다. 이것은 단지 React에 국한된 문제는 아니다. 컴포넌트의 “생성”과 “업데이트”를 분리해서 제어할 수 있는 모든 UI 라이브러리에 적용된다.
useEffect
API는 일관성에 중심을 두고 기존의 구현을 뒤집었다. 이건 처음에는 익숙하지 않아서 어색하게 느껴질 수도 있다. 하지만 결국에는 당신의 컴포넌트가 로직 안의 데이터 변경에 더 탄력성이 있도록 만든다. 그리고 “의존성”이 이제는 명시적이기 때문에, 린터의 규칙을 사용해서 effect가 일관성이 있고 문제가 없는지 검증할 수 있다. 우리가 사용하는 린터가 버그를 찾아주는 것이다!
최적화 과정에서 데이터 흐름이 멈추게 하지 마라
의도치 않게 props의 변경을 무시하게 되는 사례가 하나 더 있다. 이것은 컴포넌트를 최적화를 직접 시도할 때 발생할 수 있다.
PureComponent
와 React.memo
를 사용한 얕은(shallow) 동등성(equality) 검사를 사용하는 기본적인 비교에 기반을 둔 최적화 시도는 안전하다는 사실을 기억해 두자.
하지만 만약 당신이 비교 구문을 직접 작성해서 “최적화”를 시도한다면, 함수 props의 완벽한 검사를 실수로 놓치게 될 가능성이 있다.
class Button extends React.Component {
shouldComponentUpdate(prevProps) {
// 🔴 this.props.onClick은 비교하지 않는다
return this.props.color !== prevProps.color;
}
render() {
const onClick = this.props.onClick; // 🔴 업데이트를 반영하지 않는다
const textColor = slowlyCalculateTextColor(this.props.color);
return (
<button
onClick={onClick}
className={'Button-' + this.props.color + ' Button-text-' + textColor}>
{this.props.children}
</button>
);
}
}
클래스를 사용하면 이런 실수를 하기 쉽다. Button
컴포넌트에 onClick
prop으로 메소드를 내려보내면, 그 메소드는 어찌 되었던 동일성(identity)을 가지게 될 것이다.
class MyForm extends React.Component {
handleClick = () => { // ✅ 처음부터 끝까지 항상 같은 함수
// Do something
}
render() {
return (
<>
<h1>Hello!</h1>
<Button color='green' onClick={this.handleClick}>
Press me
</Button>
</>
)
}
}
우리의 최적화는 바로 망가지진 않는다. 하지만, Button
컴포넌트는 전달되는 값이 변경되더라도 오래된 onClick
값을 계속 “바라보고” 있게 될 것이다.
class MyForm extends React.Component {
state = {
isEnabled: true
};
handleClick = () => {
this.setState({ isEnabled: false });
// Do something
}
render() {
return (
<>
<h1>Hello!</h1>
<Button color='green' onClick={
// 🔴 onClick이 null로 바뀌더라도 Button 컴포넌트는 무시한다.
// 🔴 shouldComponentUpdate에서 비교를 안하기 때문이다.
this.state.isEnabled ? this.handleClick : null
}>
Press me
</Button>
</>
)
}
}
이 예제에서는 버튼을 한 번 클릭한 후에 버튼이 더 이상 동작하지 않아야 한다(onClick
prop이 null
로 바뀌어야 하므로). 하지만 버튼은 onClick
prop의 모든 업데이트를 무시하기에 기대했던 대로 동작하지 않는다.
이것은 함수의 동일성이 계속해서 바뀔 수 있는 값에 의존하고 있으면 더욱 혼란스러워진다. 아래 코드의 draft.content
가 그러하다.
drafts.map(draft =>
<Button
color='blue'
key={draft.id}
onClick={
// 🔴 버튼은 onClick props의 업데이트를 무시한다
this.handlePublish.bind(this, draft.content)
}>
Publish
</Button>
)
draft.content
는 값이 계속 바뀔 가능성이 있지만, 우리의 Button
컴포넌트는 onClick
prop의 변화를 무시하게 되어 있어서 최초의 draft.content
가 바인딩된 “첫번째 버전”의 onClick
메소드를 계속 바라보게 된다.
그러면, 어떻게 하면 이 문제를 피할 수 있을까?
나는 shouldComponentUpdate
메소드를 직접 작성하지 않는 것을 권장하고 싶으며, React.memo
에 커스텀 비교 구문을 전달하는 것도 피하라고 하고 싶다. React.memo
에 구현된 비교는 함수 동일성(identity)의 변화를 감지할 수 있다.
function Button({ onClick, color, children }) {
const textColor = slowlyCalculateTextColor(this.props.color);
return (
<button
onClick={onClick}
className={'Button-' + color + ' Button-text-' + textColor}>
{children}
</button>
);
}
export default React.memo(Button); // ✅ 얕은(shallow) 비교를 사용한다
클래스에서는, PureComponent
가 같은 역할을 한다.
이는 다른 함수를 prop으로 보내면 항상 업데이트가 반영됨을 보장한다.
만약 당신이 커스텀 비교를 하고 싶다면, 함수를 빠뜨리지 않도록 확실하게 구현해야 한다.
shouldComponentUpdate(prevProps) {
// ✅ this.props.onClick을 비교한다
return (
this.props.color !== prevProps.color ||
this.props.onClick !== prevProps.onClick
);
}
앞서 언급한 것처럼, 클래스 컴포넌트에서는 이 문제를 놓치기 쉽다. 왜냐하면, 메소드의 동일성은 보통 안정적이기 때문이다(하지만 항상 그렇지는 않다 - 그렇기에 이 문제로 인한 버그는 고치기 어렵다). Hooks를 사용한다면, 얘기는 조금 다르다.
- 함수를 렌더링 할 때마다 달라지기 때문에 이 문제를 바로 찾아낼 수 있다
useCallback
과useContext
를 사용하면, 함수를 컴포넌트 트리 깊은 곳에 전달하는 문제를 전부 해결할 수 있다.
이 섹션을 한마디로 요약한다면, 데이터 흐름을 멈추지 마라!라고 할 수 있겠다.
props와 state를 사용할 때마다, 그 값들이 바뀌면 어떤 일이 일어나야 하는지 생각해보자. 거의 모든 사례에서 컴포넌트는 최초의 렌더링에 머무르지 말고 계속 업데이트되어야 한다. 그것이 로직의 변화에 탄력을 준다.
클래스를 사용하면 라이프사이클 메소드 안에서 props와 state를 사용할 때 업데이트에 대해 잊어버리기 쉽다. Hooks는 당신이 제대로 된 방법을 사용할 수 있도록 유도해준다. 하지만 지금 사용하고 있지 않다면, 컴포넌트를 사고하는 방식의 조정 기간이 조금 필요하다.
원칙 2: 언제라도 렌더링할 준비가 되어 있게 하라.
React를 사용하면 컴포넌트 렌더링에 걸리는 시간은 크게 신경 쓰지 않고 코드를 작성할 수 있다. 당신은 컴포넌트가 어떤 시점에 어떻게 보일지 묘사해야 하며, React는 그것이 가능하게 한다. 이 모델의 장점을 최대한 활용하자!
컴포넌트가 어떤 타이밍에 어떻게 동작할지 추측을 하지 말아야 한다. 당신이 작성한 컴포넌트는 어떤 순간에도 다시 렌더링(re-render)될 준비가 되어 있어야 한다.
이 원칙을 어떻게 어기게 될까? React는 쉽게 어길 수 없게 하지만 – 만약 레거시 라이프사이클 메소드 componentWillReceiveProps
를 사용한다면 그럴 수 있다.
class TextInput extends React.Component {
state = {
value: ``
};
// 🔴 부모 컴포넌트로부터 props를 받을 때마다 로컬 state `value`를 재설정한다
componentWillReceiveProps(nextProps) {
this.setState({ value: nextProps.value });
}
handleChange = (e) => {
this.setState({ value: e.target.value });
};
render() {
return (
<input
value={this.state.value}
onChange={this.handleChange}
/>
);
}
}
이 예제에서 우리는 value
를 로컬 state에 보관한다. 그런데 value
를 props를 통해 받기도 한다. “새로운 값을 받을 때마다” 컴포넌트는 value
를 state에 재설정한다.
이 패턴의 문제는 업데이트를 완전히 제어할 수 없는 타이밍에 의존하고 있다는 점이다.
오늘은 이 컴포넌트의 부모가 업데이트를 거의 안할 수도 있어서 TextInput
컴포넌트가 어떤 중요한 상황에서만 props를 전달받을 수도 있다. 예를 들면 폼 데이터를 저장하는 것과 같은 상황에서 말이다.
하지만 내일은 TextInput
의 부모 컴포넌트에 어떤 애니메이션 효과를 추가할 수도 있다. 만약 그 부모 컴포넌트가 더 자주 렌더링을 한다면 계속해서 자식 컴포넌트의 state를 “날려버리게” 될 것이다!3 이 문제에 대해서는 “당신은 아마 Derived State가 필요없을 것이다”에서 더 자세하게 읽어볼 수 있다.
그러면 어떻게 이 문제를 고칠 수 있을까?
무엇보다도, 우리는 머릿속의 디자인 모델을 바로잡아야 한다. “props를 전달받는 것”을 “렌더링”과 다른 별개의 이벤트라고 생각하는 걸 그만둬야 한다. 부모 컴포넌트의 리렌더링에 의해 일어나는 자식 컴포넌트의 리렌더링은 자식 컴포넌트가 가진 로컬 state의 업데이트로 발생하는 리렌더링과 다르게 동작해서는 안 된다. 컴포넌트는 많고 적을 수 있는 렌더링 횟수에 탄력적이어야 한다. 그렇지 않으면 그 컴포넌트는 특정 부모 컴포넌트에 너무 강하게 연결된 것이기 때문이다.4
(이 데모는 부모의 리렌더링이 어떻게 자식 컴포넌트의 state를 망가뜨리는지 보여준다.)
당신이 정말로 props를 통해 생성된 state(derived state)의 사용이 필요할 때 쓸 수 있는 다른 솔루션들이 있긴 하지만, 보통은 부모 컴포넌트가 완전히 제어하는 컴포넌트(controlled component)를 사용해야 한다.
// 옵션 1: 로컬 state가 없는 완전히 제어되는 컴포넌트.
function TextInput({ value, onChange }) {
return (
<input
value={value}
onChange={onChange}
/>
);
}
아니면 제어되지 않는 컴포넌트(uncontrolled component)를 사용하면서 부모에서 필요할 때 자식 컴포넌트를 렌더링할 수 있도록 키를 부여할 수도 있다.
// 옵션 2: 로컬 state를 가진 제어되지 않는 컴포넌트
function TextInput() {
const [value, setValue] = useState('');
return (
<input
value={value}
onChange={e => setValue(e.target.value)}
/>
);
}
// key 값을 바꿈으로써 내부 state를 리셋할 수 있다.
<TextInput key={formId} />
이 섹션의 요점은 당신의 컴포넌트가 상태가 부모 컴포넌트가 더 자주 리렌더링 된다고 해도 망가지지 않아야 한다는 것이다. React API 디자인은 레거시 componentWillReceiveProps
라이프사이클 메소드를 사용하지 않는다면 그것을 매우 쉽게 구현할 수 있도록 해 두었다.
컴포넌트 스트레스 테스트를 위해서는 임의로 아래의 코드를 부모 컴포넌트에 추가해볼 수 있다.
componentDidMount() {
// 테스트 후에는 바로 제거해야 한다!
setInterval(() => this.forceUpdate(), 100);
}
테스트를 위한 것이므로 이 코드를 남겨둬서는 안 된다. 이 코드를 부모 컴포넌트가 예상한 것보다 자주 렌더링이 일어날 때 어떤 일이 발생하는지 확인할 수 있는 빠른 방법이다. 저렇게 해도 자식 컴포넌트를 망가뜨려선 안 된다!
당신은 이렇게 생각할 수도 있다: “나는 props가 변경될 때 state를 계속 다시 설정할 것이다. 하지만 불필요한 리렌더링은 PureComponent
를 사용해서 피할 것이다.”
그렇다면 아래의 코드는 제대로 동작해야 한다. 맞을까?
// 🤔 불필요한 리렌더링을 방지해야 하는데... 그렇게 될까?
class TextInput extends React.PureComponent {
state = {
value: ''
};
// 🔴 부모 컴포넌트가 렌더링 할때마다 state를 설정한다
componentWillReceiveProps(nextProps) {
this.setState({ value: nextProps.value });
}
handleChange = (e) => {
this.setState({ value: e.target.value });
};
render() {
return (
<input
value={this.state.value}
onChange={this.handleChange}
/>
);
}
}
처음에는 부모 컴포넌트의 리렌더링이 state를 “날려버리는” 문제를 해결할 것처럼 보일 수 있다. 어쨌든, props가 같다면 업데이트를 건너뛴다 – 그리고 componentWillReceiveProps
도 호출되지 않는다.
하지만 이는 우리에게 보안에 관한 잘못된 인상을 부여한다. 이 컴포넌트는 여전히 prop의 변경에 탄력적이지 않다. 예를 들어, 만약 컴포넌트에 자주 변경되는 또 다른 값(애니메이션이 적용된 스타일 같은 것)을 prop으로 전달한다면, 컴포넌트의 로컬 state를 여전히 “잃어버리게” 될 것이다.
<TextInput
style={{opacity: someValueFromState}}
value={
// 🔴 TextInput 컴포넌트의 componentWillReceiveProps는
// style이 바뀔 때마다 value를 재설정하게 될 것이다.
value
}
/>
그러므로 이 접근법에도 문제가 있다. PureComponent
, shouldComponentUpdate
, and React.memo
같은 다양한 최적화 방법을 컴포넌트의 반응을 제어하는 데 사용해서는 안 된다. 대신 최적화에 도움이 될 때만 사용해야 한다. 최적화를 제거했다고 컴포넌트가 제대로 동작하지 않는다면, 그건 애초에 쓰기도 어려운 것이다.
이 해결책은 우리가 앞서 살펴본 것과 같다. “props를 받는 것”을 특별한 일로 여기지 말아야 한다. props와 state의 “동기화”를 하지 말자. 대부분은 모든 값은 완전히 제어되거나(props를 통해서만 업데이트) 그 반대여야 한다(로컬 state만 사용). 그리고 할 수만 있다면 파생된 state(derived state)의 사용은 피해야 한다. 그리고 언제나 렌더링할 준비를 해두자!
원칙 3: 싱글턴인 컴포넌트는 없다.
가끔 우리는 어떤 컴포넌트가 딱 한 번만 표시된다고 생각된다. 예를 들면 내비게이션 바 같은 것이다. 어떤 경우에는 맞는 말일 수 있다. 하지만 이 가정은 한참 후에야 마주치게 되는 디자인 문제를 종종 일으킨다.
예를 들어, 라우트가 변경될 때 2개의 Page
컴포넌트 – 이전 Page
와 다음 Page
– 사이에 전환 효과를 주는 애니메이션을 구현할 필요가 생겼다고 가정해보자. 두 페이지 모두 애니메이션이 진행될 때 마운팅 된 상태여야 한다. 하지만 당신은 각각의 컴포넌트가 화면에 마운팅되는 오직 1개의 Page
컴포넌트로 생각하고 만들어졌다는 사실을 뒤늦게 발견하게 된다.
이 문제를 확인하는 방법은 매우 간단하다. 재미삼아 당신의 앱을 동시에 2개 렌더링해 보자.
ReactDOM.render(
<>
// 앱의 루트 컴포넌트를 2번 렌더링
<MyApp />
<MyApp />
</>,
document.getElementById(`root`)
);
여기저기 클릭해보자. (아마 이 실험을 위해서 CSS를 조금 조작해야 할 수도 있다)
당신의 앱이 여전히 기대했던 대로 동작하는가? 아니면 이상하게 깨지거나 오류 메시지가 발생하는가? 복잡한 컴포넌트에 이런 스트레스 테스트를 한 번씩 해보는 것은 좋은 아이디어다. 그리고 같은 컴포넌트를 여러 개 동시에 렌더링해도 서로에게 영향을 미치지 말아야 한다.
내가 직접 작성했던 컴포넌트에서 가끔 문제가 되었던 패턴은 componentWillUnmount
에서 전역 state를 “초기화” 시키는 것이었다.
componentWillUnmount() {
// Redux 스토어에 있는 어떤 값을 초기화
this.props.resetForm();
}
당연하겠지만 페이지에 저런 컴포넌트가 2개 있는 상태에서 하나를 없애면 다른 하나에 문제를 일으키게 된다. “전역” state를 컴포넌트 마운팅 시점에 초기화하는 것도 마찬가지다.
componentDidMount() {
// Redux 스토어에 있는 어떤 값을 초기화
this.props.resetForm();
}
두 번째로 마운팅되는 컴포넌트가 첫번째 컴포넌트가 사용하고 있던 Redux 스토어의 값을 초기화시켜버리게 될 것이다(입력된 폼 데이터를 날려버린다든지).
이러한 패턴들은 우리가 작성한 컴포넌트가 오류가 발생하기 쉬운지 아닌지를 판단할 수 있는 좋은 지표가 된다. 컴포넌트 트리를 보여주고 숨기는 과정이 다른 트리에 있는 컴포넌트를 망가뜨려서는 안 된다.
컴포넌트를 두 번 렌더링할 계획이 있든 없든, 이런 문제를 해결하는 것은 장기적으로 이득이 된다. 그리고 당신을 더욱 탄력적인 컴포넌트 디자인으로 이끌어줄 것이다.
원칙 4: 로컬 State를 독립된 상태로 유지하라
소셜 미디어의 Post
컴포넌트를 생각해보자. 그것은 Comment
스레드(확장될 수 있는)를 가지고 있으며 NewComment
입력을 가지고 있다.
React 컴포넌트는 로컬 state를 가질 수 있다. 하지만 어떤 state가 정말로 ‘로컬’이라고 할 수 있는가? 포스트 콘텐츠 자체는 로컬인가 아닌가? 코멘트 목록은? 어떤 코멘트의 스레드가 열려 있었는지에 관한 기록은? 코멘트 입력은?
당신이 만약 모든 것을 “상태 관리자”5에 집어넣는 일에 익숙하다면 다소 대답하기 어려운 질문이 될 수 있다. 여기에 경계를 나누기 위한 간단한 방법이 있다.
만약 어떤 state가 로컬이어야 하는지 아닌지 확실치 않다면, 이렇게 자문해보라: “만약 이 컴포넌트가 두 번 렌더링된다면, 이런 값들의 변경이 다른 카피에 반영되어야 하는가?” 대답이 “아니다”라면 로컬 state가 맞다.
예를 들며, 어떤 Post
컴포넌트를 두 번 렌더링했다고 가정하자. 그리고 이 컴포넌트 안에서 바뀔 수 있는 값들을 생각해보자.
- 포스트 내용. 포스트를 수정한 후에 다른 위치에 있는 컴포넌트에서 업데이트하고 싶을 수 있다. 그러므로 수정된 포스트 내용은 아마도
Post
컴포넌트의 로컬 state에 있어서는 안될 것이다.(그러는 대신, 포스트 내용은 Apollo, Relay, 또는 Redux같은 저장소에서 유지될 것이다) - 코멘트 목록. 이것은 포스트 컨텐츠와 비슷하다. 코멘트를 추가하면 다른 곳에도 반영되어야 한다. 그래서 이상적으로는 코멘트 목록을 위한 저장소를 사용해야 할 것이고 Post 컴포넌트의 로컬 state에 있어서는 안된다.
- 어떤 코멘트 스레드가 확장되어 있는지. 어떤 코멘트의 스레드를 클릭해서 열었는데 다른 곳에서도 열린다면 뭔가 잘못된 것처럼 보일 것이다. 이 경우에서 우리는 추상적인 “코멘트 개체”를 다루는 것이 아니라 특정
Comment
UI를 조작하고 있다고 봐야 한다. 그러므로 “열림” 플래그는Comment
컴포넌트의 로컬 state에 있어야 한다. - 새로운 코멘트 입력값. 코멘트를 입력하고 있는데 다른 코멘트 입력창에 추가된다면 역시 이상하게 보일 것이다. 복수의 입력창이 명확하게 같은 값이라고 라벨이 붙어있지 않다면, 사람들은 보통 각각의 입력을 독립된 값으로 여긴다. 그러므로 입력값은
NewComment
컴포넌트의 로컬 state에 있어야 한다.
나는 지금 제안하고 있는 이 규칙들을 무조건 지켜야 한다고 말하는 것이 아니다. 물론, 간단한 앱에서 당신은 저 “저장소”를 포함한 모든 것을 로컬 state에서 관리하고 싶을 수도 있다. 나는 단지 첫번째 원칙 에 있는 이상적인 사용자 경험에 관해서 이야기하고 있을 뿐이다.
확실히 로컬한 state를 전역 값으로 만드는 것은 피해라. 이것은 우리가 얘기하고 있는 “탄력성” 구현에 도움을 준다: 컴포넌트 사이의 놀라운 동기화는 많이 없다. 보너스로, 이것은 또 아주 넓은 범주의 성능 이슈를 해결하기도 한다. “과다 렌더링”은 당신의 state가 알맞은 위치에 있다면 그렇게 큰 이슈가 되지 않는다.
정리
지금까지 얘기한 원칙들을 정리하자면:
- 데이터 흐름을 멈추지 마라. Props와 state는 바뀔 수 있다. 그리고 컴포넌트는 그 변화를 계속해서 처리할 수 있어야 한다.
- 언제라도 렌더링할 준비가 되어 있게 하라. 컴포넌트는 렌더링 빈도에 상관없이 제대로 동작해야 한다.
- 싱글턴인 컴포넌트는 없다. 컴포넌트가 한 번만 렌더링 된다 하더라도, 두 번 렌더링 되었을 때 문제가 없게 한다면 당신의 디자인은 더 좋아질 것이다.
- 로컬 state를 독립된 상태로 유지하라. 각각의 UI에 어떤 state가 로컬이어야 할지 생각해보라 – 그리고 필요 이상으로 state를 높은 곳으로 끌어올리지 마라.
이 원칙들은 변화에 최적화된 컴포넌트를 작성하는 데 도움을 준다. 그런 컴포넌트는 컴포넌트 트리에 추가하기 쉽고, 바꾸기 쉽고, 삭제하기 쉽다.
그리고 가장 중요한 점으로, 작성하는 컴포넌트가 탄력적이게 되면, props가 알파벳 순으로 정렬되어야 하는지 아닌지를 고민하는 것과 같은 어려운 딜레마로 돌아갈 수 있다.
-
린터(linter) 또는 린트(lint). 소스 코드를 분석하여 프로그램 오류, 버그, 스타일 오류에 표지(flag)를 달아놓기 위한 도구. 자바스크립트 린터에는 eslint, tslint가 있다
↩ -
computed values. 이미 존재하는 값으로부터 유래된 값. React 컴포넌트에서 어떤 계산된 값을 state를 사용해서 만들었다면, state가 업데이트 되면 그 계산된 값도 업데이트를 반영한다.
↩ -
사용자가 직접 입력한 값을 this.state.value에 업데이트하고 있다. 그런데 부모 컴포넌트로부터 새로운 value 값이나 다른 prop이 전달된다면 사용자가 지금까지 입력했던 값은 날아가버리게 될 것이다.
↩ -
특정 컴포넌트가 부모일 때만 제대로 동작한다면 재사용성이 떨어질 것이다.
↩ -
Redux, Mobx, etc
↩