리엑트 JS로 만들기 - 리펙토링(추상화)2

조 은길·2022년 2월 16일
0

React

목록 보기
8/12
post-thumbnail

SearchResult

💡요구사항: 검색 결과가 검색폼 아래 위치한다. 검색 결과가 없을 경우와 있을 경우를 구분한다

만약에 SearchResult가 검색 결과 데이터를 내부 상태로 가지고 있다면, 외부에서는 이 값에 접근할 방법이 없습니다.

App 컴포넌트에서는 검색어를 키워드로 들고 있기 때문에, 마찬가지로 검색도 할 수 있고, 검색 결과도 앱 컴포넌트가 바로 알수있다.

따라서, 검색 결과를 SearchResult가 가지고 있는 것보다는 앱 컴포넌트가 가지고 있는 것이 더 좋을 것같다.

그리고 앱 컴포넌트는 데이터를 자식 컴포넌트인 SearchResult 쪽으로 전달 해주는 쪽이 구조가 더 깔끔하다.

정리하면, SearchForm처럼 SearchResult도 검색 결과 데이터를 props로 받아서 리엑트 엘리먼트로 반환하는 컴포넌트를 만들자!!
state 값이 해당 컴포넌트에서는 필요가 없기 때문에, 함수 컴포넌트로 만들겠다.

// 1
const SearchResult = ({ data = [] }) => {
  // 2
  if (data.length <= 0) {
    return <div className="empty-box">검색 결과가 없습니다</div>
  }

  // 3
  //  props로 받은 data로 ul 태그를 그리자!!
  return (
    <ul className="result">
      {data.map(item => (
        <li key={item.id}>
          <img src={item.imageUrl} />
          <p>{item.name}</p>
        </li>
      ))}
    </ul>
  )
}
  • 상태가 필요 없어 함수 컴포넌트로 만들었다. => (1)
  • 검색 결과를 props의 data 변수로 받는다. 데이터가 없을 경우 검색 결과가 없는 메세지를 보일 것이고 => (2)
  • 그렇지 않을 경우 리스트를 출력한다. => (3)

이제 App 컴포넌트를 변경해보자!!

기존에 Sprint에서 사용하던 Store.js, helpers.js 그리고 storage.jscomponents 폴더로 가져와 줬다.

class App extends React.Component {
  constructor() {
    super();

    this.state = {
      searchKeyword: "",
      searchResult: [], // 1
      submitted: false, // 2
    }
  }

  search(searchKeyword) {
    const searchResult = store.search(searchKeyword);
    // 3
    this.setState({
      searchResult,
      submitted: true,
    });
  }

  render() {
    return (
      <>
        <Header title="검색" />
        <SearchForm onSubmit={searchKeyword => this.search(searchKeyword)} />
        {/* 4 */}
        {submitted && <SearchResult data={searchResult} />}
      </>
    )
  }
  • 검색 결과를 저장할 searchResult 상태를 추가하고 빈 배열로 초기화 했다(1).
  • 첫 렌딩 시에는 검색 결과를 숨기고 폼이 제출된 다음 보이기 위한 submitted 플래그도 두었다(2). searchResult 배열만으로는 검색 여부를 알 수 없기 때문이다.
  • SearchForm 컴포넌트에서 submit 이벤트를 수신하면 search() 메소드가 호출되는데 이 때 스토어에서 검색 결과를 가져와 state를 갱신한다(3).
  • 그럼 render()에서 의존하는 SearchResult 컴포넌트가 다시 그려질 것이다(4).

💡요구사항: x버튼을 클릭하면 검색폼이 초기화 되고 검색 결과가 사라진다

현재 x 버튼 클릭시, 검색폼을 초기화된다. 그러나, 아직 검색결과는 사라지지 않는다.
검색 결과도 검색폼 값이 변하면, 반응하게 하려면, App의 state 값을 조절해야한다.

검색폼 초기화 시에 submitted를 false로 만들고 주고, 좀 더 깔끔하게 해주기 위해서, searchResult도 빈 배열로 재할당해서 이 문제를 해결해보자!!

reset을 담당하는 handleReset()을 수정하자!!

class App extends React.Component {
  // 1
  handleReset() {
    this.setState({
      searchKeyword: "",
      searchResult: [],
      submitted: false,
    });
  }

  render() {
    return (
      <>
        <Header title="검색" />
        <SearchForm
          onSubmit={searchKeyword => this.search(searchKeyword)}
          onReset={()=>this.handleReset()} {/* 2 */}
        />
        {submitted && <SearchResult data={searchResult} />}
      </>
    )
  }
}
  • SearchForm의 reset 이벤트를 처리하는 handleReset() 메소드를 수정하면 된다(1).
    검색 결과의 출력 여부를 submitted 상태가 관리하고 있기 때문에 이를 false로 설정한다. 검색어도 빈 문자열로 초기화하면 이를 사용하는 SearchForm의 input 값이 사라질 것이다. 검색 결과를 담은 searchResult도 빈 배열로 초기화 했다.

Tabs

💡요구사항: 추천 검색어, 최근 검색어 탭이 검색폼 아래 위치한다

💡요구사항: 기본으로 추천 검색어 탭을 선택한다

💡요구사항: 각 탭을 클릭하면 탭 아래 내용이 변경된다

지금까지는 선택된 탭을 의미하는 state.selectedTab으로 상태 관리를 했다. 하지만, 지금은 App 컴포넌트는 선택한 탭에 따라 "추천 검색어"나 "최근 검색어" 목록을 노출한다. 그런 이유로, selectedTab도 Tabs 컴포넌트로 숨기는 것 보다는 App 컴포넌트에 위치하는 것이 더 낫다.

  • 🙋로직 이해를 위해 알아둬야 될 점
    => TabLabel["KEYWORD"] 가 어떻게 "추천 검색어"를 출력하는지 이해하자!!
const TabType = {
  KEYWORD: "KEYWORD",
  HISTORY: "HISTORY",
}

const TabLabel = {
  [TabType.KEYWORD]: "추천 검색어",
  [TabType.HISTORY]: "최근 검색어",
}

// 1
const Tabs = ({ selectedTab, onChange }) => (
  <ul className="tabs">
    {Object.values(TabType).map(tabType => (
      <li
        key={tabType}
        className={selectedTab === tabType ? "active" : ""} // 2
        onClick={() => onChange(tabType)} // 3
      >
        {TabLabel[tabType]}
      </li>
    ))}
  </ul>
)

상태가 필요 없기 때문에 함수 컴포넌트로 만들었다.

  • 선택된 탭 selectedTab과 탭을 변경할 때 알려줄 onChange 콜백 함수을 props로 받는다(1).
  • 선택된 탭일 경우 active CSS 클래스를 추가해 선택된 탭으로 표시한다(2).
  • 탭을 클릭하면 이를 부모 컴포넌트로 전달한다(3).

App를 수정해보자!!

import Tabs, { TabType } from "./components/Tabs.js"; // 추가해줌

class App extends React.Component {
  constructor() {
    super();

    this.state = {
      searchKeyword: "",
      searchResult: [],
      submitted: false,
      selectedTab: TabType.KEYWORD, // 1
    }

  render() {
    const { submitted, selectedTab } = this.state

    return (
      <>
        <Header title="검색" />
        <SearchForm onSubmit={/* 생략 */} onReset={/* 생략 */} />
        {submitted ? (
          <SearchResult />
        ) : (
          // 2
          <>
            <Tabs
              selectedTab={this.state.selectedTab} // 3
              onChange={selectedTab => this.setState({ selectedTab })} // 4
            />
            {selectedTab === TabType.KEYWORD && <>{`TODO: 추천 검색어`}</>}
            {selectedTab === TabType.HISTORY && <>{`TODO: 최근 검색어`}</>}
          </>
        )}
      </>
    )
  }
}
  • 💡요구사항: 추천 검색어, 최근 검색어 탭이 검색폼 아래 위치한다
    • selectedTab을 두어 선택된 탭 상태를 관리한다(1).
      초기값은 추천 검색어로 설정했다. Tabs는 폼이 제출되기 전에 SearchForm 아래에 놓았다(2).
  • 💡요구사항: 기본으로 추천 검색어 탭을 선택한다
    • 기본 값인 추천 검색어가 selectedTab 속성으로 전달되어 화면 첫 렌딩시에는 추천 검색어 탭이 표시될 것이다(3).
  • 💡요구사항: 각 탭을 클릭하면 탭 아래 내용이 변경된다
    • 그리고 각 탭을 클릭하면 change 이벤트가 발생하는데 선택한 탭을 selectedTab 상태로 반영한다(4). 이 값에 따라 아래 내용이 변경될 것이다.

이렇게 컴포넌트로 바꾸고 보니 App의 render() 메소드 안에 있던 앨리먼트 변수를 각 각 컴포넌트로 분리한 결과가 되었다. 여전히 state는 이동하지 않고 App 컴포넌트의 소유로 남아있는데, 추천 검색어, 최근 검색어에서는 자신만의 고유한 state를 관리하는 컴포넌트로 분리해 보겠다.


추천 검색어, 최근 검색어

  • 추천 검색어
    💡요구사항: 번호와 추천 검색어 이름이 목록 형태로 탭 아래 위치한다
    💡요구사항: 목록에서 검색어를 클릭하면 선택된 검색어의 검색 결과 화면으로 이동한다

  • 최근 검색어
    💡요구사항: 최근 검색어 이름, 검색일자, 삭제 버튼이 목록 현태로 탭 아래 위치한다
    💡요구사항: 목록에서 검색어를 클릭하면 선택된 검색어로 검색 결과 화면으로 이동한다
    💡요구사항: 목록에서 x 버튼을 클릭하면 선택된 검색어가 목록에서 삭제된다
    💡요구사항: 검색시마다 최근 검색어 목록에 추가된다

🙋컴포넌트도 "재활용"할 수 있는 방법이 있다.

  1. 상속
  2. 조합: 컴포넌트 담기
  3. 조합: 특수화

1. "상속"으로 컴포넌트 재활용

  • 추천 검색어

KeywordList.jsHistoryList.js 의 부모 컴포넌트가 될 List.js를 만들자.
추천검색어와 최근검색어의 중복된 기능을 List라는 컴포넌트로 모았다.

// 1
class List extends React.Component {
  constructor() {
    super()

    this.state = { data: [] } // 2
  }

  // 3
  renderItem(item, index) {
    throw "renderItem()을 구현하세요"
  }

  // 4
  render() {
    const { onClick } = this.props
    const { data } = this.state

    return (
      <ul className="list">
        {data.map((item, index) => (
          // 5
          <li key={item.id} onClick={() => onClick(item.keyword)}>
            {this.renderItem(item, index)} {/* 6 */}
          </li>
        ))}
      </ul>
    )
  }
}
  • 배열 상태를 사용하기 때문에 클래스 컴포넌트를 사용했다(1, 2).
  • render() 메소드에서는 이 상태 값으로 리스트 렌더링을 한다(4).
  • 리스트를 클릭하면 외부의 콜백 함수를 호출해 선택한 키워드 문자열을 전달한다(5).
  • 리스트 렌더링에 보면 renderItem()이란 추상 메서드를 호출해서 각 아이템을 그리고 있다(6).
  • List 클래스를 구현한 자식 클래스에서는 이 renderItem()을 오버라이딩해서 각자에 맞는 형태로 리스트를 그릴수 있도록 열어둔 것이다(3).
    추상 메서드 : 구현부가 없는 메서드

이제 List.js 를 상속할 KeywordList.js 파일을 추가하자.

// 1
class KeywordList extends List {
  // 2
  componentDidMount() {
    const data = store.getKeywordList()
    this.setState({ data })
  }

  // 3
  renderItem(item, index) {
    return (
      <>
        <span className="number">{index + 1}</span>
        <span>{item.keyword}</span>
      </>
    )
  }
}
  • List 클래스를 상속했다(1).
  • renderItem()을 오버라이딩해 키워드 목록을 그리는데 순서와 키워드를 출력한다(3).
  • 이 메서드는 부모 클래스의 render() 메소드에서 호출되어 리액트 앨리먼트를 만들 때 사용될 것이다. 이때는 data가 빈 배열이라, 빈배열을 render()한다.
    • state.data에는 빈 배열이 초기값인데, 외부에서 데이터를 가져오기 위해 생명 주기 메소드 componentDidMount()를 사용했다. 컴포넌트가 돔에 마운트 된 직후에 실행되는데 store에서 키워드 목록을 가져와 컴포넌트 상태를 갱신할 것이다(2).
      State 변화를 감지한 리액트는 다시 render() 메소드로 화면을 다시 그리고 자식 클래스에서 완성한 renderItem()도 호출 되어 번호와 추천 검색어 이름을 출력할 것이다.
  • 최근 검색어

HistoryList.js 파일도 만들어보자!!

// 1
class HistoryList extends List {
  // 2
  componentDidMount() {
    this.fetch()
  }

  // 3
  fetch() {
    const data = store.getHistoryList()
    this.setState({ data })
  }

  // 4
  handleClickRemove(event, keyword) {
    event.stopPropagation()
    store.removeHistory(keyword)
    this.fetch()
  }

  // 5
  renderItem(item) {
    return (
      <>
        <span>{item.keyword}</span>
        <span className="date">{formatRelativeDate(item.date)}</span>
        <button
          className="btn-remove"
          onClick={event => this.handleClickRemove(event, item.keyword)}
        />
      </>
    )
  }
}
  • List 클래스를 상속하고(1), renderItem() 메소드를 오버라딩 한다(5).
  • 컴포넌트가 돔에 마운트 된 후 데이터를 불러오기위해 fetch() 함수를 호출한다(2, 3).
    • 동일한 코드 내용을 데이터를 받아올 때, 한 번 그리고 클릭된 데이터를 삭제할 때, 또 한 번 써줘야 하기 때문에, fetch()라는 함수로 코드를 담고 재활용한다.
  • 스토어에서 데이터를 불러와 컴포넌트 상태로 갱신하면 리액트는 상태 변화를 감지하고 리액트 앨리먼트를 다시 만들 것이다. 그리고 x 버튼 클릭을 처리하는 handleClickRemove() 메소드도 추가했다(4). 스토어에서 검색 이력을 삭제하고 다시 불러오는 역할이다.

App.js도 이에 맞게 수정해보자!!

class App extends React.Component {
  render() {
    const { submitted, selectedTab } = this.state
    return (
      <>
        <Header />
        <SearchForm />
        {submitted ? (
          <SearchResult />
        ) : (
          <>
            <Tabs />
            {selectedTab === TabType.KEYWORD && (
              <KeywordList onClick={keyword => this.search(keyword)} />
            )}
            {selectedTab === TabType.HISTORY && (
              // 1
              <HistoryList onClick={keyword => this.search(keyword)} />
            )}
          </>
        )}
      </>
    )
  }
}

최근 검색어를 보여주는 부분을 텍스트에서 HistoryList 사용으로 대체했다. KeywordList를 사용한 방식과 동일하다. App은 selectedTab에 따라 KeywordListHistoryList를 렌더링한다. 각 컴포넌트는 생겼다 사라졌다 하는데 매번 componentDidMount() 메서드에 기술한 로직이 실행될 것이다. 이 때 외부 데이터를 가져오고 자신만의 리스트를 렌더링하는 방식으로 각자의 화면을 그린다.


조합으로 컴포넌트 재활용

리액트는 클래스 상속으로 컴포넌트 재활용하는 것을 권장하지는 않는다.

대신, 상속보다 더 쉬운 조합이라는 방법을 권장한다.
조합은 2가지 방법으로 이뤄질 수 있다.
1. 컴포넌트 담기
2. 특수화
=> 둘 다 props를 통해 컴포넌트를 합성할 수 있다.

Facebook에서는 수천 개의 React 컴포넌트를 사용하지만, 컴포넌트를 상속 계층 구조로 작성을 권장할만한 사례를 아직 찾지 못했습니다. - 출처: 리액트 문서

2. 조합: 컴포넌트 담기

List 컴포넌트를 조합할수 있는 방식으로 변경하고 이를 활용해 KeywordList, HistoryList 컴포넌트를 조합해 만들어 보면서 이 방식의 장점을 알아보자.

List 컴포넌트를 조합할 수 있는 형태로 변경하자.

// 리스트를 렌더링하는 부분과 각 리스트를 클릭했을 때에 처리를 List가 담당
// 1
const List = ({ data = [], onClick, renderItem }) => {
  return (
    <ul className="list">
      {data.map((item, index) => (
        <li key={item.id} onClick={() => onClick(item.keyword)}>
          {renderItem(item, index)} {/* 2 */}
        </li>
      ))}
    </ul>
  )
}
  • 클래스를 사용하지 않고 함수 컴포넌트로 만들었다(1).
    • 외부에서 렌더링에 필요한 데이터를 주입 받겠다는 의도이다.
  • 키워드 목록과 검색 목록을 props.data로 받았다.
    • 리스트 출력까지만 담당하고 리스트를 구성하는 각 항목을 출력하는 함수는 props.renderItem이란 이름으로 전달 하도록 했다.
    • renderItem() 함수에 아이템과 인덱스를 전달해 List 컴포넌트를 사용하는 측에서 구체적으로 그리도록 역할을 위임한 셈이다(2).

      참고로, props에 함수를 전달할 수 있다고 했다. 이러한 함수 중 리액트 앨리먼트를 반환하는 함수를 render props라고 부른다. 전달된 함수로 UI 렌더링을 하기 때문이다. renderItemrender props이다.
      render props : 리엑트 엘리먼트를 반환해서, 그것으로 UI를 그리는 용도로 활용하는 함수
  • 추천 검색어

이제 이걸 사용한 KeywrodList를 만들어 보자.

// List 컴포넌트를 조합하는 방식으로 KeywrodList를 만들었다
// KeywrodList는 자신만의 데이터를 관리하고 그 데이터를 List로 전달해줬다.
// 그리고 renderItem()을 통해서 추천검색어 모양을 출력했다.
// 1
class KeywordList extends React.Component {
  constructor() {
    super()
    this.state = { keywordList: [] } // 2
  }

  // 3
  componentDidMount() {
    const keywordList = store.getKeywordList()
    this.setState({ keywordList })
  }

  render() {
    const { onClick } = this.props
    const { keywordList } = this.state

    return (
      // 4
      <List
        data={keywordList}
        onClick={onClick}
        // 5
        renderItem={(item, index) => (
          <>
            <span className="number">{index + 1}</span>
            <span>{item.keyword}</span>
          </>
        )}
      />
    )
  }
}
  • 키워드 목록을 내부 상태로 갖기 위해 클래스 컴포넌트로 선언했다(1).
  • state.keywordList란 이름으로 빈 배열로 초기화했다(2).
  • 이 값은 render() 메소드에서 사용하는데 List 컴포넌에 전달했다(4).
  • 배열을 보고 List 컴포넌트가 목록을 그리도록 renderItem 함수도 함께 전달해 세부사항을 그리도록 했다(5).
  • 돔이 마운트 된 후 스토어에서 키워드 목록을 가져오고 상태로 갱신되면 목록을 그릴 것이다(3)

    List가 공통 로직과 UI를 담는 컴포넌트이고 이걸 이용해서 KeywordListHistoryList를 다시 만들었다.
    클래스 상속과는 달리 List안에 renderItem이라는 render props를 전달해서 List가 컴포넌트를 그리도록 한 점이 차이다. 이 뿐만 아니라 컴포넌트 자체로 props로 전달할수 있다. 이렇게 props로 컴포넌트를 전달하거나 렌더하는 방법을 전달하는 방식을 컴포넌트 담기라고 부른다.
  • 공식 문서에서 제공하는 컴포넌트 담기 예시
    좀 더 단순하고 직관적인 예시를 통해서 "컴포넌트 담기"를 완벽하게 이해하자!!
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 />
      } />
  );
}

자료출처 : 합성 (Composition) vs 상속 (Inheritance)

  • 최근 검색어

// 1
class HistoryList extends React.Component {
  constructor() {
    super()
    this.state = { historyList: [] } // 2
  }

  // 3
  componentDidMount() {
    this.fetch()
  }

  // 4
  fetch() {
    const historyList = store.getHistoryList()
    this.setState({ historyList })
  }

  //  5
  handleClickRemove(event, keyword) {
    event.stopPropagation()
    store.removeHistory(keyword)
    this.fetch()
  }

  render() {
    const { onClick } = this.props
    const { historyList } = this.state

    return (
      // 6
      <List
        data={historyList}
        onClick={onClick}
        renderItem={item => (
          <>
            <span>{item.keyword}</span>
            <span className="date">{formatRelativeDate(item.date)}</span>
            <button
              className="btn-remove"
              // 7
              onClick={event => this.handleClickRemove(event, item.keyword)}
            />
          </>
        )}
      />
    )
  }
}
  • 최근 검색어 데이터를 상태로 관리하기 위해 클래스 컴포넌트로 만들고(1) state.historyList를 빈 배열로 초기화 했다(2).
  • 이 값을 render() 메소드에서 List 컴포넌트와 함께 사용했다(6). 돔이 마운트되면 데이터를 불러와 historyList 상태를 갱신하고(4) 리스트를 다시 그릴 것이다.
  • render() 메소드를 다시보면 히스트리 리스트의 각 항목을 그릴 수 있도록 renderItem 함수도 전달했는데, 이 부분이 KeywordList와 다른 점이다.
  • 키워드 옆에 날짜와 삭제 버튼을 추가했다. 삭제 버튼을 클릭하면 handleClickRemove() 함수를 호출하는데(7) 이벤트 전파을 막고 검색 이력에서 삭제한 뒤 데이터를 다시 불러온다(5).

2. 조합: 특수화

// 1
const List = ({
  data = [],
  hasIndex = false,
  hasDate = false,
  onClick,
  onRemove,
}) => {
  const handleClickRemove = (event, keyword) => {
    event.stopPropagation()
    onRemove(keyword)
  }

  // 2
  return (
    <ul className="list">
      {data.map(({ id, keyword, date }, index) => (
        <li key={id} onClick={() => onClick(keyword)}>
          {/* 3 */}
          {hasIndex && <span className="number">{index + 1}</span>}
          <span>{keyword}</span>
          {hasDate && <span className="date">{formatRelativeDate(date)}</span>}
          {/* 4 */}
          {!!onRemove && (
            <button
              className="btn-remove"
              onClick={event => handleClickRemove(event, keyword)}
            />
          )}
        </li>
      ))}
    </ul>
  )
}
  • List 컴포넌트가 외부에서 받는 props 갯수가 늘었다(1).
  • props 어떻게 설정하느냐에 따라 조금씩 다른 모양과 행위를 하는 컴포넌트를 만드는데 사용할 것이다. hasIndex를 설정하면 좌측에 순서를 표시하도록 했다(3).
  • KeywordList 컴포넌트에서 사용할 옵션이다.
  • hasDateremove 값에 따라 날짜와 삭제 버튼을 보이도록 했다(4). 최근 검색어를 위한 옵션이다.

Q&A

Q&A 중에서 알아둬야할 질문 몇 개 넣어둔다. 반드시 내 것으로 만들자!!

- 질문 1 : return this.handleReset(); 의미



- 질문 2 : 알아둬야 할 검사기 기능

- **질문 3 : 전혀 다른 곳에서 온 2개의 render()의 역할들


해당 github 링크

profile
좋은 길로만 가는 "조은길"입니다😁

0개의 댓글