[21/10/25] React QnA 2회차

rat8397·2021년 10월 25일
2

react

목록 보기
3/4
post-thumbnail

리액트가 말하는 컴포넌트의 '합성'에 대해 설명해주세요

우선 합성이라는 개념은 서로 다른 객체를 여러개 붙여서 새로운 기능이나 객체를 구성하는 것을 말합니다. 상속보다는 유기적으로 객체를 합칠수 있습니다.

상속과 합성

  • 상속보다 합성을 사용하는 코드를 변경하는 것이 더 노력이 덜 들어간다.

  • 상속을 사용하는 코드는 일반적으로 합성의 경우보다 빠르다.

  • 상속과 합성을 고민하고 있다면, 자식클래스를 사용해야하는 곳에서 부모 클래스(확장된)를 사용할 수 있다면 상속.

  • 단지 다른 클래스의 서비스만을 이용한다면 합성이 좋다.

개인적으로 상속의 경우 컴포넌트간 독립성이 합성의 경우보다 좋지 못할 것같다. -> 상속 시 부모 클래스의 내용을 상속받는 입장에서 알 수 있게됨. 또 부모 클래스의 내용을 변경 시 하위 클래스의 내용에도 영향이 가게되어 적절한 모듈화를 이뤄내지 못할 수 있다.

합성의 경우, 독립된 컴포넌트들의 결합이라면 상속에서 문제가 될 수 있는 개체간 독립성은 유지될 수 있다고 생각한다.

리액트에서의 합성

리액트는 상속보다 합성을 사용하여 구성 요소간 코드를 재사용하는 것을 강조한다.

리액트는 크게 두 가지 방식으로 합성을 구현하여 확장된 컴포넌트를 만들어 낸다.

props.children
특수 prop children을 이용하여 리액트는 합성을 구현한다. 다음과 같이 상위 컴포넌트로 합성될 컴포넌트를 감싸면, 상위 컴포넌트에 props.children으로 합성될 컴포넌트가 전달된다.

function FancyBorder(props) {
  return (
    <div className={'FancyBorder FancyBorder-' + props.color}>
      {props.children}
    </div>
  );
}

function WelcomeDialog() {
  return (
    <FancyBorder color="blue">
      <h1 className="Dialog-title">
        Welcome
      </h1>
      <p className="Dialog-message">
        Thank you for visiting our spacecraft!
      </p>
    </FancyBorder>
  );
}

props
만약 합성할 컴포넌트 개체가 여러개라면, slot이 여러개가 될 수 있다. 그렇다면 특수 prop을 이용하지 않고, 커스터마이징(개발자 개인의)된 property를 추가하여 합성될 컴포넌트를 전달할 수 있다.

function SplitPane(props) {
  return (
    <div className="SplitPane">
      <div className="SplitPane-left">
        {props.left}
      </div>
      <div className="SplitPane-right">
        {props.right}
      </div>
    </div>
  );
}

function App() {
  return (
    <SplitPane
      left={
        <Contacts />
      }
      right={
        <Chat />
      } />
  );
}

정리하자면

리액트는 props로 모든 값을 전달할 수 있게 구현되어, 상속 계층구조 없이 서로 다른 컴포넌트를 유기적으로 합성할 수 있다. 합성은 재사용과 확장이라는 개념을 위해 사용되는데, 주로 props.children이라는 특수 프로퍼티와 커스터마이즈된 프로퍼티를 만들어 합성될 컴포넌트를 부모컴포넌트로 전달하는 방식으로 구현된다.

상속에 비해 유기적이라는 점, 변경이 힘들지 않다는 점, 각 개체간 독립성을 유지할 수 있다는 점을 들어 React는 합성이라는 개념을 선호하는 것 같다.

context를 사용해야 할 때

컴포넌트 계층 구조가 깊어지면 깊어질 수록 props를 전달하는 역할만 하는 컴포넌트가 생기기 마련이다. (props drilling 이라고 한다) 더군다나 일부 컴포넌트 계층 구조안에서 전역적으로 공유해야하는 (알아야하는) 상태가 있다면 이런 불필요한 구조가 많이 생길 수 있다. 이를 해결하고자 한다면, react context를 사용할 수 있다.

무조건 사용해야하는 가?

context의 주된 쓰임은 중첩된 많은 컴포넌트들에게 데이터를 전달해야할때이다. 하지만 context를 사용하게되면 컴포넌트를 재사용하기 어려워질 수 있어 꼭 필요할때만 써야한다.

  • 여러 레벨에 걸쳐 props를 넘기는 경우, context 뿐만 아니라 컴포넌트의 합성으로도 이 문제를 해결할 수 있다.
<Page user={user} avatarSize={avatarSize} />
// Page - PageLayout
<PageLayout user={user} avatarSize={avatarSize} />
// Page - PageLayout - NavigationBar
<NavigationBar user={user} avatarSize={avatarSize} />
// ...
<Link href={user.permalink}>
  <Avatar user={user} size={avatarSize} />
</Link>

위 코드에서 실제로 data 가 사용되는 것은 Avatar 컴포넌트 뿐이다. 만약 이 경우 props의 구조가 변경 된다면? 모든 레벨의 컴포넌트들의 props를 수정해주어야 한다. 이외에도 여간 번거로운게 아니다.

다음과 같이 context 없이 Component Composition으로 해결할 수 있다.

function Page(props) {
  const user = props.user;
  const userLink = (
    <Link href={user.permalink}>
      <Avatar user={user} size={props.avatarSize} />
    </Link>
  );
  return <PageLayout userLink={userLink} />;
}

// 이제 이렇게 쓸 수 있습니다.
<Page user={user} avatarSize={avatarSize} />
// ... 그 아래에 ...
<PageLayout userLink={...} />
// ... 그 아래에 ...
<NavigationBar userLink={...} />
// ... 그 아래에 ...
{props.userLink}

이렇게 되면 중간 네스팅된 컴포넌트들이 user, avatarSize 같은 데이터들을 전달받을 필요가 없어지게 된다.

부모 컴포넌트인 Page 만 user, avatarSize 등의 데이터가 어디에 쓰이는지 알게되므로, 부모 컴포넌트의 제어력이 더 커지게 된다.

이러한 패턴은 렌더링 되기 이전부터 자식컴포넌트들이 부모컴포넌트와 소통하게 되어 더 깔끔하다고 볼 수 있다. 하지만 같은 데이터를 트리 안 여러 레벨이 있는 많은 컴포넌트들에게 주어야 할 때도 있다.(더 단위가 큰 경우를 말함.) 이런 데이터 값이 변할 때 마다 하위 컴포넌트들에게 알려주는 것이 context이다.

이러한 제어의 역전(inversion of control) 을 이용하면 넘겨줘야 하는 props의 수는 줄고 최상위 컴포넌트의 제어력은 더 커지기 때문에 더 깔끔한 코드를 쓸 수 있는 경우가 많습니다.

하지만 이러한 역전이 항상 옳은 것은 아닙니다. 복잡한 로직을 상위로 옮기면 이 상위 컴포넌트들은 더 난해해지기 마련이고 하위 컴포넌트들은 필요 이상으로 유연해져야 합니다.

정리하자면
props drilling이 생기게 되면, 유지보수에 있어 - 깔끔한 코드를 만드는데 있어 불편한 점이 많아지게 된다. 이러한 문제는 contextcomponent composition으로 해결할 수 있지만, component composition으로 inversion of control을 만들어 낼 시 모든 경우에 좋다고는 할 수 없다. 좀 더 다양한 레벨의 많은 컴포넌트들이 상태를 observe하고 있다면, context를 사용하는 것이 좋다.

Context API의 단점
다시 렌더링 해야함을 결정할 때 reference를 확인하기 때문에, 불필요하게 다시 렌더링 될 수 있다. 리덕스에 비해 최적화가 되어 있지는 않으나, 번들 크기를 키우지 않는다는 점에서 context API도 프로젝트에 충분히 적용해볼 수 있다.

문제가 되는 상황은 다음과 같다. (데이터가 실제로는 변경되지 않았음에도 참조 값이 변경되어 렌더링이 다시 발생하는 경우)

class App extends React.Component {
  render() {
    return (
      // App이 다시 렌더링 될 때 마다
      // value의 객체가 새롭게 만들어지면 ref가 바뀐다.
      // 그 하위에서 이 데이터를 구독하고 있는 컴포넌트 모두가 다시 렌더링된다.
      <MyContext.Provider value={{something: 'something'}}>
        <Toolbar />
      </MyContext.Provider>
    );
  }
}
class App extends React.Component {
  constructor(props) {
    super(props);
    
    // 부모의 상태로 만들어버린다.
    // 상태가 변경되는 것이 아니라면, 참조가 어긋나지 않게됨
    this.state = {
      value: {something: 'something'},
    };
  }

  render() {
    return (
      <MyContext.Provider value={this.state.value}>
        <Toolbar />
      </MyContext.Provider>
    );
  }
}

fragments와 key prop

fragments에는 유일하게 key prop 만을 전달할 수 있다. 리스트를 렌더링할 때 key prop은 중요하므로 알고넘어가자.

function Glossary(props) {
  return (
    <dl>
      {props.items.map(item => (
        // React는 `key`가 없으면 key warning을 발생합니다.
        <React.Fragment key={item.id}>
          <dt>{item.term}</dt>
          <dd>{item.description}</dd>
        </React.Fragment>
      ))}
    </dl>
  );
}

고차 컴포넌트에 대해 아시나요 ?

고차컴포넌트는 컴포넌트를 전달받아 새로운 컴포넌트를 만들어 반환하는 함수를 말한다. 이는 React API의 일부가 아니라, 리액트의 구성적인 특징에서 나오는 패턴이다.

일반 컴포넌트는 props를 받아 UI로 반환하는 반면에 고차 컴포넌트는 컴포넌트를 새로운 컴포넌트로 반환한다.

횡단 관심사 Cross Cutting Concerns

횡단 관심사들은 시스템의 수많은 다른 부분에 의존하거나 영향을 미쳐야 하는 프로그램의 일부분이다.

의무기록을 기록하는 프로그램은 인증 모듈, 의무기록에 의존하는 다른 데이터 관리 영역에 영향을 주거나 받게된다. 이러한 관심사들을 횡단 관심사라고 부른다.

횡단 관심사들을 해결하기 위해 고차 컴포넌트를 사용해볼 수 있다.

다음은 외부 데이터에 의존하는 CommentList (댓글 목록을 출력하는) 컴포넌트이다.

class CommentList extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      // "DataSource" 는 글로벌 데이터 소스입니다.
      comments: DataSource.getComments()
    };
  }

  componentDidMount() {
    // 변화감지를 위해 리스너를 추가합니다.
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // 리스너를 제거합니다.
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    // 데이터 소스가 변경될때 마다 comments를 업데이트합니다.
    this.setState({
      comments: DataSource.getComments()
    });
  }

  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
      </div>
    );
  }
}

마찬가지로 다음은 DataSource라는 외부 상태에 의존하는 BlogPost 컴포넌트이다.

class BlogPost extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      // 외부 상태를 구독중.
      blogPost: DataSource.getBlogPost(props.id)
    };
  }

  componentDidMount() {
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    this.setState({
      blogPost: DataSource.getBlogPost(this.props.id)
    });
  }

  render() {
    return <TextBlock text={this.state.blogPost} />;
  }
}

둘은 동일한 컴포넌트는 아니지만 여러 면에서 유사하다.

  • 컴포넌트가 마운트 되면 이벤트 리스너를 등록

  • 외부 데이터가 변경되면 상태를 업데이트

  • 마운트 해제시 리스너 제거

규모가 큰 어플리케이션에서 DataSource를 구독하고 setState를 호출하는 동일한 패턴이 반복적으로 발생한다면 많은 컴포넌트에서 로직을 공유할 수 있도록 추상화 해주는 작업이 필요하다. 이러한 경우 고차 컴포넌트가 해결해줄 수 있다.

// withSubscription은 고차함수, 고차 컴포넌트이다. 로직은 오로지 이 컴포넌트 내부에서 관리된다.

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);
// 이 함수는 컴포넌트를 매개변수로 받고..
function withSubscription(WrappedComponent, selectData) {
  // ...다른 컴포넌트를 반환하는데...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ... 구독을 담당하고...
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }

    render() {
      // ... 래핑된 컴포넌트를 새로운 데이터로 랜더링 합니다!
      // 컴포넌트에 추가로 props를 내려주는 것에 주목하세요.
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

장점

  • DRY하다.

  • 외부 데이터 변경에 모든 컴포넌트를 수정할 필요 없어진다.

  • 추상화 (내부 로직에 대해서 신경 쓸 필요 없어진다)

주의사항

  • render 메서드에서 고차 컴포넌트 사용하지 말것. 일관성이 사라지게됨.
render() {
  // render가 호출될 때마다 새로운 버전의 EnhancedComponent가 생성됩니다.
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // 때문에 매번 전체 서브트리가 마운트 해제 후 다시 마운트 됩니다!
  return <EnhancedComponent />;
}

고차 컴포넌트를 사용할 때 ref

부착하는 ref가 내가 원하는 컴포넌트가 아닌 고차 컴포넌트에 전달되어 원하지 않는 흐름이 생길 수 있다. forwardRef로 전달해주면 된다. 고차컴포넌트에서 받아 원하는 컴포넌트에 부착하면 됨.

Ref

profile
Frontend Ninja

0개의 댓글