React의 기본, 컴포넌트를 알아보자

모두 알지만 잘 알지는 못하는 컴포넌트

TL;DR

  • UI = View(State)
  • React 컴포넌트 Lifecycle 함수들을 명확하게 이해하자.
  • React 컴포넌트의 props, state, setState API를 이해하자.
  • React 개발 시 알아두면 유용한 컴포넌트 디자인 패턴에 대해 알아보자.
  • State 관리와 React 앱의 성능에 대한 부분은 다루지 않는다.

React를 한마디로 설명하면?

아마도 홈페이지에서 “A JAVASCRIPT LIBRARY FOR BUILDING USER INTERFACES”라고 설명하듯이 UI를 위한 JS 라이브러리로 표현할 수 있겠죠. 하지만 이 설명으로는 조금 부족하다고 느껴집니다. 저는 UI를 아래의 수식으로 표현한다면, React를 아래 수식에서 View 함수에 해당한다고 설명합니다.

UI = View(State)

위 식에서 UI는 View 함수에 어떤 State 값을 대입했을 때 나온 결과입니다. 중요한 점은 View를 State가 같다면 항상 같은 UI를 결과로 갖는 함수로 본다는 것이죠.

만약 React를 View 함수 개발에 도움을 주는 라이브러리로 본다면 React의 특징이자 장점들을 아래와 같이 자연스럽게 이해할 수 있습니다.

  1. 함수의 정의가 그러하듯 단방향 사고 강제합니다.
  2. 함수가 그러하듯 특정 state, props에 따른 render 결과가 바뀌지 않습니다.
  3. 함수 내용을 정의하듯 JSX를 통해 어떻게 화면을 그릴지 정의합니다.
  4. 함수 간 합성(Composition)이 가능하듯이 컴포넌트 간 합성을 할 수 있습니다.

React를 이해하기 위해 기초인 View 함수에 집중해보죠.


React 컴포넌트

가장 먼저 알아야 하는 것은 React의 컴포넌트입니다. 컴포넌트는 개념적으로 props를 input으로 하고 UI가 어떻게 보여야 하는지 정의하는 React Element를 output으로 하는 함수입니다. 따라서 합성을 이용하여 “UI를 재사용할 수 있고 독립적인 단위로 쪼개어 생각”할 수 있게 합니다. 그래서 컴포넌트는 React.Component를 상속받아 정의하지만 컴포넌트 간에는 상속보다는 합성을 사용하길 권장합니다.

UI를 구성하기 위해서는 화면에 컴포넌트를 그리고(Mounting), 갱신하고(Updating), 지워야(Unmounting) 합니다. 컴포넌트는 각 프로세스가 진행될 때에 따라 Lifecycle 함수로 불리는 특별한 함수가 실행됩니다. 개발자는 이를 재정의하여 컴포넌트를 제어하게 됩니다. 그러므로 Lifecycle 함수들을 완전하게 이해해야 합니다. 프로세스와 세부 프로세스, 그리고 각 프로세스에 대응하는 Lifecycle 함수들은 아래 다이어그램을 통해 쉽게 파악할 수 있습니다.

React Lifecycle in ES6. Inspired by Simon Sturmer

위 개념을 이해하면 쉽게 저지르는 실수를 줄일 수 있는데, 예를 들면 다음과 같습니다.

  • Mouting: Creating 중인 componentWillMount()에서 Ajax 요청을 날리면 응답 시간만큼 컴포넌트를 그리는 것이 늦어짐을 알 수 있습니다. 따라서 일반적으로 componentDidMount()에서 Ajax 요청을 하는 게 낫다는 것을 알 수 있습니다.
  • Updating: Receiving State 중에 setState() API를 호출하면 프로세스가 끝난 후 또다시 Receiving State 할 것을 알 수 있습니다. 따라서 setState() API를 해당 Lifecycle 함수에서 호출하면 개념적으로 무한 루프에 빠질 수밖에 없다는 것을 알 수 있습니다. (물론 실제로도 무한 루프에 빠지게 됩니다.)

React 컴포넌트의 props와 state

컴포넌트는 두 가지 인스턴스 속성(property) propsstate를 가지고 있습니다. props는 컴포넌트의 mounting, updating 프로세스 시점에 값이 할당될 뿐 컴포넌트 내부에서 값을 변경할 수 없습니다. 상황에 따라 변경되어야 하는 값들은 state를 이용해야합니다. 왜 propsstate로 나누어 사용하도록 설계했을까요? 무슨 이점이 있을까요?

먼저 개발자들에게 명확한 관념 모델(static mental model)을 제공합니다. 관념 모델은 무엇이 어떻게 동작하는지 이해할 때 진행되는 일련의 사고 프로세스를 의미합니다. 즉, 논리적으로 이치에 맞는 사고 모델을 제공한다는 것이죠.

만약 input으로 들어오는 props를 컴포넌트 내부에서 변경할 수 있다면 어떻게 되어야할까요? props를 내려주는 부모 컴포넌트에도 영향이 가야 할까요? state가 없다면 유저 이벤트에 맞춰 변경돼야 하는 값은 어떻게 관리할까요? 개발자는 이러한 질문에 고민할 필요가 없습니다. 컴포넌트 간에는 무조건 props를 통해서만 데이터를 주고받고 props는 컴포넌트 내부에서 변경되지 않습니다. 따라서 위/아래 양쪽에 대해 동시에 고민할 필요가 없고 아래 한쪽 방향(uni-directional) 그리고 자기 자신에 대해서만 고민하면 됩니다.

계층 기준으로 부모 자식 관계를 표현한 React 컴포넌트 관계도

지금 컴포넌트에서 필요한 값이 props인지 state인지 판단하고 어느 Lifecycle과 관련이 있는지 이 값을 어떤 컴포넌트에 어떻게 넘겨줄지만 생각하여 코드를 작성하면 컴포넌트를 완성할 수 있습니다.

퍼포먼스 측면의 이점도 있습니다. 만약 propsstate가 하나의 객체로 관리된다면, Updating을 할지 결정하는 shouldComponentUpdate() 함수에서 O(keys(props+state))만큼 값이 변경되었는지를 비교해야 하지만, 애초에 propsstate로 분리되어 있으므로 O(keys(state))만큼만 비교하면 Updating을 결정할 수 있습니다.

React 컴포넌트의 setState() API

setState에 대한 질문이나 소개하는 글을 많이 보지 못했는데 아마도 setState를 이용하기보단 바로 Redux나 MobX와 같은 state 관리 라이브러리를 사용하는 것 같습니다. 하지만 setState를 꼭 사용해보고 난 후에 Redux 혹은 MobX 사용을 고민해봐야 합니다. 2016년도를 강타한 how-it-feels-to-learn-javascript-in-2016이라는 글에서 꼬집듯이 맹목적으로 사용하는 것이 아닌 왜 사용하는가?를 알아야 무엇이 편해지고 언제 사용해야 좋은지 알 수 있기 때문이죠.

컴포넌트는 setState()(이하 ‘setState’)라는 API가 존재합니다. 이름 그대로 컴포넌트의 state를 변경할 때 사용하는 API입니다. 그냥 state를 직접 변경할 수도 있을 텐데 왜 굳이 API를 통해서 변경해야 할까요? 자바스크립트의 비교 연산자는 피연산자의 값이 아닌 reference 값을 기준으로 참/거짓을 리턴하기 때문입니다.

비교 연산자는 오브젝트의 값이 아닌 reference 값을 비교한다.

만약 state의 값을 직접 변경할 경우에는 해당 오브젝트의 reference 값이 변하지 않아 컴포넌트는 state가 변경되지 않았다고 볼 수밖에 없습니다. 그러므로 화면이 갱신되지 않는 것이지요. 따라서 React는 setState를 이용해 기존 state와 머지하여 state의 변경 가능성을 명시적으로 알려줍니다. 머지를 통해 새로 생성된 statereference 값은 기존과 다르므로 컴포넌트에서는 shallow compare를 통해 변경되었음을 알 수 있습니다. 물론 reference 변경일 뿐이니 실제 값은 변경되지 않을 수도 있습니다.

한 가지 더 중요한 사실은 setState 호출 즉시 state가 변경되는 것이 아니라 비동기로 동작한다는 점입니다. 상태가 변경된 직후에 필요한 작업이 있다면 setState(nextState, callback)callback을 사용해야 합니다.

따라서 아래는 보장되지 않습니다.

  1. setState 호출 직후에 state가 즉시 갱신된다.
  2. 한 컨텍스트 내에서의 setState 호출 수와 컴포넌트 업데이트 수는 같다.

하지만 다음은 보장됩니다.

  1. setState 실행 순서
  2. setState callback의 실행 순서
  3. state 변화가 클릭 등의 event 실행 전에 컴포넌트에 반영된다.

그렇다면 setState는 왜 비동기로 동작할까요? 이는 끊김 없는 원활한 UI/UX를 제공하기 위해 일정 수의 render를 꼭 수행시키기 위해서입니다. setState가 동기로 동작한다고 가정해보겠습니다. state 변경이 많으면 많을수록 render는 모든 변경이 적용될 때까지 늦어지기 때문에 실제 화면에서는 엄청나게 부자연스럽게 동작하게 될 것입니다. 비동기로 동작하게 되면 render 시점과 별개로 동작하기 때문에 자연스러운 갱신이 가능해집니다.


React 컴포넌트 디자인 패턴

지금까지 다룬 내용이 컴포넌트에 대한 부분이라면 이번에는 실제 컴포넌트 디자인 패턴에 대해서 다뤄보겠습니다. 디자인 패턴은 일반적으로 아래와 같은 목적을 위해 고안되었습니다.

  • DRY (Don’t Repeat Yourself)를 유지한다.
  • 재사용 가능한 컴포넌트를 만든다.
  • 컴포넌트가 무엇을 하는지 명확하게 이해할 수 있다.

기본 컴포넌트 (Basic Component)

// class Button extends React.Component {
// render() {
// const { className } = this.props;
// return <button type="button" className={className} >;
// }
// }
<Button className="myBtn" />

기본 컴포넌트는 개발자가 컴포넌트에 일일이 <button type=”button”>를 작성해야 하는 것을 위와 같이 일반화하여 DRY를 유지하게 도와줍니다. 작은 노력으로 큰 효과를 주는 little-big 한 디자인 패턴입니다.

고차 컴포넌트 (Higher Order Component)

고차 컴포넌트는 Sebastian Markbågegist를 시작으로 대중화된 패턴입니다. react-reduxconnect()RelaycreateContainer() 등이 고차 컴포넌트에 해당합니다. 고차 컴포넌트는 컴포넌트를 input으로 하고 컴포넌트를 output으로 하는 함수라고 생각하시면 됩니다. 수도코드로 표현하면 다음과 같습니다.

// @flow
hoc = (input: React.Component): React.Component => output

여러 컴포넌트에서 공통으로 사용하는 로직을 한 컴포넌트의 역할로 분리하여 컴포넌트의 내부 로직을 간결하고 명확하게 유지하게 합니다. 이를 통해 컴포넌트들의 재사용성이 올라갑니다.

실제 구현은 아래와 같이 하게 됩니다.

HOC 패턴의 대중화에 기여한 gist
render 함수 안에서 HOC를 사용하면 매번 새로운 컴포넌트가 만들어져서 성능이 떨어집니다.

고차 컴포넌트는 공통 로직을 어떻게 분리하느냐가 핵심입니다.

Logic: { a, b, c, d }

위와 같이 a, b, c, d라는 로직을 원소로 가지는 길이가 4인 집합 Logic을 생각해봅시다. Logic은 컴포넌트가 내부 로직으로 사용할 수 있는 모든 로직을 원소로 가집니다. 그렇다면 컴포넌트가 사용하는 내부 로직은 총 2⁴ 만큼의 경우의 수가 존재합니다. 이 때, 가장 적은 수의 고차 컴포넌트로 가장 많은 수의 존재 가능한 내부 로직을 감당하려면 어떻게 해야 할까요? 수학적으로 당연히 원소 각각에 대한 고차 컴포넌트 4개를 작성하면 됩니다. 하지만 실제 애플리케이션에서 사용되는 로직의 조합의 수는 4보다 작을 수도 있습니다. 즉 실제로 사용하는 로직의 조합 수에 따라 고차 컴포넌트를 보다 atomic 하게 만들지 혹은 좀 더 specialize 하게 만들지 결정하면 됩니다.

무 상태 컴포넌트 (Stateless Component)

무 상태 컴포넌트는 재사용성이 굉장히 높은 컴포넌트 작성할 수 있게 도와줍니다. 컴포넌트를 완전한 함수로 정의한다는 점이 특징입니다. 개인적으로는 더욱 명확한 의미를 전달할 수 있다는 점에서 state가 없을 경우엔 무상태 컴포넌트로 작성합니다. 기본 컴포넌트에서 보여드렸던 예제를 이용해 보면 다음과 같습니다.

const Button = (props) => (
<button type="button" className={props.className} />
)

ES6의 Destructuring assignment를 이용하여 더 명확하게 표현할 수도 있습니다.

const Button = ({ className }) => (
<button type="button" className={className} />
)

혹은 ES6의 Spread SyntaxRest parameters를 이용하면 좀 더 안전하고 확장성이 높은 컴포넌트를 만들 수도 있습니다.

const Button = ({ className, ...remainProps }) => (
<button type="button" className={className} {...remainProps} />
)

특수화 (Specialization)

특수화는 컴포넌트의 역할을 specialize 해서 보다 명확한 컴포넌트로 만들어줍니다. 상태에 따라 특정 컴포넌트가 구분이 될 때 특수화를 이용하면 보다 시맨틱적인 코드를 작성할 수 있습니다.

const RedButton = () => <Button className="red">
const BlueButton = () => <Button className="blue">
// {this.props.theme === RED ? <RedButton> : <BlueButton>}

Presentational & Container 컴포넌트

이 패턴은 React 생태계의 슈퍼스타 Dan AbramovSmart & Dumb 컴포넌트란 이름으로 제시하였습니다. 후에 더욱 정확한 의미 전달을 위해 명칭이 변경되었습니다.

컴포넌트들은 MVC 구조에서 말하는 C(컨트롤러)의 역할을 해야 할 경우가 있습니다. 단순히 props, state로 화면을 그리는 데 필요한 값을 넘겨받는 것이 아니라 Ajax 요청이나 localStorage 등을 통해 데이터를 fetching 해야 할 경우에 말이죠. 컴포넌트가 화면에 대한 정의를 넘어서 데이터 fetching까지 담당하게 되면 specialize 하게 되어 재사용성이 떨어집니다. 또한 로직과 Lifecycle이 복잡해져 무엇을 하는 컴포넌트인지 이해하기 어려워집니다.

Presentational & Container 컴포넌트 패턴은 이러한 문제점을 해결하고 컴포넌트 테스트를 더욱 쉽게 합니다. 핵심 아이디어는 한 컴포넌트 내에 존재하는 render와 관련된 로직과 데이터와 관련된 로직을 각각 Presentational 컴포넌트, Container 컴포넌트로 분리하는 것이지요. 따라서 각 컴포넌트는 아래와 같은 특징이 있습니다.

Presentational 컴포넌트는

  • JSX를 이용한 마크업이 존재합니다.
  • render에 필요한 데이터는 이미 존재한다고 가정합니다.
  • UI를 위한 state가 존재할 수 있습니다.

Container 컴포넌트는

  • JSX를 이용한 마크업이 거의 없습니다.
  • Ajax 요청, HOC 등을 이용해 render에 필요한 데이터를 Fetching 합니다.
  • 데이터 Fetching 등을 위한 state가 존재할 수 있습니다.

위에서 알 수 있듯 state의 존재 여부가 Presentational & Container 컴포넌트를 구분 짓는 것이 아닙니다.

실제 예제는 michael changist를 참고하세요. 본문이 너무 길어져 따로 첨부하지 않겠습니다.

몇 가지 사소하지만 큰 Little-big tips

  • Property Initializer: Babel의 stage-2-preset부터 지원되는 Class properties transform이란 플러그인을 이용하면 아래와 같이 컴포넌트의 constructor를 깔끔하게 관리할 수 있습니다. 자세한 스펙은 proposal을 참고하세요.
class Button extends React.Component {
constructor(props) {
super(props);
this.state = { isChanged: false };
this.handleClickButton = this.handleClickButton.bind(this);
}
  handleClickButton(e) {
this.setState({ isChanged: true });
}
  render() {
const { value } = this.props;
return (
<button onClick={this.handleClickButton}>{value}</button>
);
}
}
Button.defaultProps = { value: 'default value' };

위와 같은 컴포넌트는 Property Initializer를 활용하면 아래와 같이 정의할 수 있습니다.

class Button extends React.Component {
static defaultProps = { value: 'default value' };
  state = { isChanged: false };  
  handleClickButton = (e) => this.setState({ isChanged: true });
  render() {
const { value } = this.props;
return (
<button onClick={this.handleClickButton}>{value}</button>
);
}
}
  • <body> 요소에 컴포넌트를 render 하지 않습니다: 특정 사이트나 크롬 익스텐션 등이 <body> 요소 기준으로 DOM을 조작할 수 있으므로 Virtual DOM을 통해 DOM을 관리하는 React에 엄청난 사이드 이펙트를 끼칠 수 있습니다.
  • jsx-control-statement: 만약 babel 6를 쓴다면 고려해볼만한 babel plugin입니다. JSX 상에서 제어문을 사용할 수 있게 확장해줍니다. 보다 가독성 높은 JSX 코드를 사용하게 도와줍니다.
  • React.PureComponent도 존재합니다: shouldComponentUpdate() 함수에서 shallow compare 하도록 이미 정의되어있는 Pure 컴포넌트를 이용하면 보다 수월하게 Updating 프로세스를 관리할 수 있습니다.

마무리

React를 사용하면서 컴포넌트에 대해 고민하고 정리했던 개념들을 최대한 쉽게 설명하고 싶었습니다. React를 기본부터 차근차근 이해하고 싶으신 분들께 도움이 되었으면 좋겠습니다. 피드백은 항상 환영합니다. 혹시 더 궁금하거나 소통하고 싶은 분들은 Little Big Programming Gitter 에서 만나요~

글쓴이에 대해서

현재는 Frontend 개발에 집중하고 있습니다. 개발 영역만이 아닌 삶 전반적인 부분에서 애자일을 지향합니다. 원격 근무에 관심이 많습니다. 개발자가 온전히 개발에 집중할 수 있도록 도와주는 업무 자동화, 개발 인프라 등에 재미를 느낍니다.