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

조 은길·2022년 2월 16일
0

React

목록 보기
7/12
post-thumbnail

추상화가 필요한 이유

관련된 코드 덩어리를 모아서, 하나의 독립된 개념으로 추상화하는 기법은 프로그래밍에서 개발자의 사고력을 비약적으로 높여준다. 상태와 UI 코드로 이루어진 화면 개발에서도 컴포넌트라는 개념을 사용해 추상화 할 수 있다.

현재의 완성본에서는 App이라는 거대한 컴포넌트 안에 모든 역할들이 들어가 있다. 물론, 엘리먼트 단위로 쪼개놔서 UI를 출력하는 render()의 가독성을 높였다고는 하지만, 코드가 더 복잡해질수록 App이라는 단일 컴포넌트로 관리하기에는 가독성면에서도 유지보수 측면에서도 않좋다.

예를 들어, 다른 개발자가 검색하는 기능을 수정하고 싶다고 하자!! 아마 App 컴포넌트 안에서 검색에 관련된 모든 곳들을 찾아보고, 코드를 이해해야 비로소 어떻게 수정할 수 있을지에 대한 로드맵이 세워질 것이다.
=> 이 상황은 코드를 이해하는데도 시간이 걸리고, 코드를 수정하는 곳을 찾는데에도 시간이 걸린다. 아직, 코드를 추가하지도 않았는데 말이다.

그래서, 이런 상황을 좀 더 개선해보고자 용도에 맞게 컴포넌트들을 나눠보자!!

서랍을 용도에 맞게 칸막이로 나누듯, 컴포넌트도 역할에 따라 더 작은 컴포넌트로 나눌 수 있다. 작은 컴포넌트는 비교적 읽기도 쉽고 수정도 안전하다.
프로그래밍 경험이 있다면, 함수나 클래스로 추상화하는 것과 같다.
모든 코드를 절차적으로 작성하면, 코드 양이 많아지고 전체 코드를 모두 머리속에 담고 있어야만 프로그램을 읽을 수 있다.
하지만, 추상화 기법을 사용하면, 하나의 역할을 수행하는 코드 덩어리에 이름을 부여하기 때문에 프로그램이 훨씬 잘 읽힌다.

ex)

  • 특정 역할을 수행하는 코드를 함수로 분리하고 "Login"이라는 이름표를 붙여주면 이 때부터 이름만 보고도 코드의 내용을 알 수있다.
  • 여러 속성과 동작을 표현하는 코드를 클래스로 분리하고 "User"라는 이름표를 붙여주면 이 때부터 이름표만 보고도 사용자를 기술한 코드인 것을 알 수 있다.
  • 어플리케이션 상태와 유저 인터페이스 코드를 컴포넌트로 분리하고 "LoginComponent"라는 이름표를 붙이면 내부 코드를 모두 들여다보지 않고도 로그인 UI를 다루는 코드라는 것을 단번에 알아챌 수 있을 것이다.

이처럼 컴포넌트를 사용하면 개발자는 추상화를 통해 더 크고 복잡한 코드를 만들 수 있는 힘이 생기는 것이다.


App 컴포넌트의 구조

다른 추상화처럼 컴포넌트를 나눌 때도 " 단일책임의 원칙 "으로 컴포넌트를 쪼개야 한다.

즉, 단일 책임의 원칙에 맞게 컴포넌트를 나누려면, App이라는 컴포넌트가 몇 가지 역할을 하는지 알아야한다.

  • Header는 상단에 제목을 보여준다 - 1 초록색
  • SearchForm은 검색어를 입력받는 받는다 - 2 빨간색
  • SearchResult는 불러온 검색 결과 목록을 보여준다 - 5 보라색
  • Tab 은 추천 검색어, 최근 검색어 탭을 보여주고 선택할 수 있다 - 3 주황색
  • KeywordList는 키워드 목록을 보여주고 선택할 수 있다 - 4 연두색
  • HistoryList로 대체될 수 있는데 최근 검색어 목록을 보이고 선택할 수 있다 - 6 파란색
  • 모든 컴포넌트를 둘러싸는 App은 전체 컴포넌트를 관리한다- 7 검정색
    => 1~6번 컴포넌트까지 쪼개서 7번인 App에 짚어넣을 예정이다.

프로젝트 구조 변경하기

컴포넌트를 만들 때는 하나의 파일 안에 하나의 컴포넌트를 만드는 것이 코드를 관리하는데 좀더 편하고, 리엑트 개발에 관례이기도 하다.

여러 파일을 모듈로 사용하려면, CDN 방식의 바벨 스텐드 얼론으로는 어렵다 그래서, 여러개로 쪼개질 컴포넌트를 다루기 위해서, 웹팩과 바벨을 사용했다.

지금까지는 라이트 서버를 사용해서, 실시간 UI를 로컬호스트로 출력했지만, 웹팩을 사용하기 때문에, 웹팩을 사용해도 호환이 되는 서버를 사용하겠다.
=> live-Server 역시 제대로 동작하지 않는다.

  • webpack-dev-server : 라이트 서버는 웹팩과 함께 쓸 수 없기 때문에, 웹팩과 함께 쓸 수 있는 웹팩 데브 서버를 쓴다.

지금까지는 main.js 안에다가 App 컴포넌트를 만들었다.
그리고 만든 것을 리엑트돔이 렌더하도록 했다.

이번에는 main.js가 있고, App 컴포넌트라는 것을 따로 만들어보자!!
=> 즉, 한 파일에 있던 것을 2개로 분리하는 것이다.

App.js에서는 App class를 만들었다. 이제 main.js에서는 App 컴포넌트를 가져와서 리엑트 돔에게 전달해주자!

// main.js
import React from "react" // 1
import ReactDOM from "react-dom" // 2
import App from "./App.js" // 3

ReactDOM.render(<App />, document.querySelector("#app"))
  • CDN을 사용하지 않고 NPM 저장소에서 라이브러리를 다운로드 했기 때문에 ES6 모듈시스템으로 이를 가져온다. JSX를 사용하기 위해 react 패키지를 가져온다 => (1).
    • JSX 문법을 사용하려면, 반드시 해당 값을 import 해와야 한다!!
  • ReactDOM의 render() 함수를 사용할 목적으로 react-dom 패키지도 가져온다 => (2).
  • 앞으로 만들 부모 컴포넌트인 App 컴포넌트도 가져온다 => (3).

현재 웹팩에는 main.js를 기준으로 출력값이 실행되도록 되있다.

즉,main.js에서 UI 출력을 담당하는 render()를 만들고, 모든 컴포넌트들을 관리하는 App을 넣어주면 되겠다.

리엑트 돔은 앱 컴포넌트를 가지고, 가상돔을 만들고 실제 돔에다가 연결해야되는데

그 위치를 바로 <div id="app"></div> 하겠다.


Header: 함수 컴포넌트

리엑트 컴포넌트를 만드는 방법은 2가지가 있다.

  1. class를 이용해서 만들기
  2. 함수를 이용해서 만들기

함수를 이용해서 만든 컴포넌트를 "함수 컴포넌트"라고 부른다. 그리고 리엑트 엘리먼트를 반환해야만 함수 컴포넌트로써 자격을 가진다.

그러나, 함수 컴포넌트는 클래스 컴포넌트와는 큰 차이가 있다.

  1. 생성자가 없다.
  2. render()가 없다.
  3. state가 없다.
    => 내부 상태가 필요없는 컴포넌트라면 함수 컴포넌트를 사용할 수 있다.
    => 예를 들어, 리펙토링 전에 만든 App 컴포넌트는 모든 변화값을 state값으로 제어했다.

즉, 함수 컴포넌트는 이런 state가 필요없을 때, 즉, 변화가 없는 정적일 때 사용할 수있다.

// 함수 컴포넌트 예제
const Hello = () => <>Hello world</>

<Hello />

1번은 우리가 만들 헤더이다.
타이틀만 "검색"이라고 출력하기 때문에, 정적인 엘리먼트이다. 즉, 내부에서 따로 관리하는 변화되는 상태가 없기 때문에, 함수 컴포넌트로써 적합하다.

  • App 컴포넌트에서 모든 것을 관리할 때,

  • 함수 컴포넌트로 Header를 구현해서 App에 추가했을 때,

이전에는 Header를 만들기 위해서, 3줄을 써야했는데, 지금은 단 한 줄만으로 동일한 작업의 Header를 App 컴포넌트에 구현했다. 바로 이것이 컴포넌트의 추상화 능력이다.

  • 리엑트 개발툴로 보면, 컴포넌트의 구조를 한 눈에 볼 수있다.


🙋컴포넌트를 만들 때는 컴포넌트 이름처럼 대문자로 시작되는 파일 이름을 만든다.

=> 즉, 컴포넌트 이름도 대문자로 시작하듯이, 그 컴포넌트를 담고 있는 파일명도 똑같은 이름으로 똑같이 대문자로 시작하게 만들자!!

ex)
Header.js

const Header = (props) => (
  <header>
    <h2 className="container">{props.title}</h2>
  </header>
);

그냥 이렇게 하는게 관례이다.

그리고 파일 이름과 컴포넌트 이름을 맞는 것이 나중에 해당 파일을 찾고, 유지보수하는데 더 편하다.


🙋리엑트 개발 도구

npm 상에서 설치를 해서 사용할 수도 있지만, 그것보다는 브라우져 익스텐션으로 사용하는 게 훨씬 좋다.

"React Developer Tools"를 검색해서 브라우져 상에서 설치하면 된다.

모든 브라우져에서 지원이 된다.

해당 툴을 열게 되면, 2가지 구성이 있다.

컴포넌트와 프로파일러 라는 탭이 브라우져 데브툴에 추가되는데,

프로파일러는 딱히 쓰는 일이 없는 것같고, 컴포넌트 탭을 주로 이용한다.

컴포넌트 탭에서는 리엑트 트리를 볼 수있고, 컴포넌트의 내부 상태를 디버깅하는데 주로 사용한다.


🙋재활용 가능한 컴포넌트로 개선 - Props

함수 컴포넌트는 state 값이 없다는 것이 가장 큰 특징이다. 그런데, 컴포넌트에 상태가 없다는 것은 "리엑티브하다"는 개념하고는 맞지 않는다. 상태가 변함에 따라서, UI가 자동으로 변하는 것이 리엑티브의 성질이기 때문이다.

리액트 컴포넌트에서 UI 상태로 사용할 수 있는 것은 state 말고도 props가 있다. State가 컴포넌트 내부에서 관리는 상태라면 props는 컴포넌트 외부에서 들어와 내부 UI에 영향을 줄 수 있는 녀석이다. 이 값에 의존한 리액트 앨리먼트를 만들면 props 변화에 따라 UI가 리액티브하게 반응한다.
자료 출처 : Components와 Props 공식문서

프로그래밍에서 함수는 재활용이 가능한 대표적인 개념이다. 함수 본연의 로직은 본문에 코드로 담고 있고, 유동적인 값을 함수의 인자로 전달하는 방식이다.
함수 컴포넌트 역시 그런 방식으로 재활용할 수있다. 바로 Props인자를 통해서 유동적인 값을 만들 수 있다.

// 공식 문서 예시
const Hello = ({ name }) => <>Hello {name}</>  // 1 in Header.js 적용

<Hello name="world" /> // 2  in App.js 에서 적용
  • 예시에 따른 적용
const Header = (props) => (
  <header>
    <h2 className="container">{props.title}</h2>
  </header>
);

이렇게 코드를 단순히 Header에서만 사용할 수 있게, 정적으로 만든 것이 아닌, 다른 곳에서도 재활용할 수있게 바꿨다.

export default class App extends React.Component {
  render() {
    // TODO
    return <Header title="검색" />;
  }
}

=> Header 컴포넌트를 정적으로 사용한 버전은 내부 구현이 감추어져 있고 단순히 Header 컴포넌트만 선언했다. 그러나, 이렇게 재활용성을 높혀서, App 컴포넌트에서 Header에 대한 가독성이 더 높아졌다는 장점도 생겼다.


SearchForm

💡요구사항: 검색 상품명 입력 폼이 위치한다

💡요구사항: 검색어를 입력하면 x 버튼이 보이고 없으면 x 버튼을 숨긴다

먼저 서치폼이 함수형 컴포넌트로 쓰는 게 적합할지 생각해보자!!
입력 폼에는 사용자 입력이 있겠죠??
인풋 엘리멘트가 있을텐데, 인풋 엘리멘트의 상태는 기본적으로 브라우져가 관리한다.

우리는 리엑트를 쓰기 때문에, 입력한 값을 저장하고 있어야하는데, 그려러면 스테이트가 필요하다.

즉, 클래스 컴포넌트를 써야한다.

// SearchForm.js
// 1
class SearchForm extends React.Component {
  constructor() {
    super()
    this.state = { searchForm: "" } // 2
  }

  // 3
  handleChangeInput(event) {
    const searchKeyword = event.target.value
    this.setState({ searchKeyword })
  }

  render() {
    const { searchForm } = this.state
    return (
      <form>
        <input
          type="text"
          placeholder="검색어를 입력하세요"
          autofocus
          value={searchForm} {/* 4 */}
          onChange={event => this.handleChangeInput(event)} {/* 5 */}
        />
      </form>
    )
  }
}
  • 사용자로부터 검색어를 입력받기 때문에 이를 저장할 상태가 필요하다. 따라서 state가 있는 클래스 컴포넌트를 상속해 만든다. => (1)
  • Input 엘리먼트를 리액트 컴포넌트에서 제어하려고 이를 저장할 searchForm 상태를 추가했다. => (2)
  • input에서 입력이 발생하면 처리할 메소드다. 입력 이벤트가 발생할 때마다 입력값을 가져와 searchKeyword 상태에 저장한다. => (3)
  • searchKeyword와 handleChangeInput() 메소드를 이용해 제어된 input 컴포넌트를 만들었다. => (4, 5)
// App.js
class App extends React.Component {
  render() {
    return (
      <>
        <Header title={"검색"} />
        <SearchForm /> {/* 1 */}
      </>
    )
  }
}

💡요구사항: 엔터를 입력하면 검색 결과가 보인다

일단 시작하기 앞서, SearchForm의 역할이 어디까지인지 정의해보자!

SearchForm은 "검색어 입력"만을 담당하기 때문에, 검색 결과에 대한 부분은 SearchForm의 역할은 아니다.

검색어를 입력하고, Enter를 입력했다는 것 딱!! 거기까지만 SearchForm의 역할이다.

그 이후의 조작은 부모 컴포넌트인 App으로 전달해서 App이 처리하도록 하자!

즉, App이 다른 컴포넌트를 제어해서 처리하도록 위임해야한다.

그런데, React.Component가 외부와 통신할 수있는 유일한 객체는 Props 객체뿐이다.

Header를 구현할 때, Props를 사용한 것을 생각해보면, 리엑트에서는 부모 => 자식으로 데이터를 전달하는 방식이 자연스러운 흐름이다.

반면에, SearchForm에서는 Enter를 입력하면, 폼이 제출되면 부모 컴포넌트인 App으로 알려줘야 하는 상황이다. 부모 => 자식으로 데이터를 보내야된다는 흐름과 어긋나보인다.

🙋"자식 => 부모"로 데이터를 보내는 유일한 방법

하지만, 이것을 가능하게 할 수 있는 방법이 하나있다. 바로, Props에다가 콜백함수를 전달하는 방식이다. Props에는 어떠한 값이라도 사용할 수있는데, JS에서는 함수도 값으로 다루기 때문에, 함수도 Props를 통해서 전달할 수 있다.

// searchForm.js

 handleSubmit(event) {
    event.preventDefault();
    // 여기서 props의 콜백함수를 호출해주는 거다.
    // 제출이 되면, 그 이후의 일은 App에서 다른 곳으로 넘겨줘야하기 때문에
    // 여기서는 App으로 끌어올려야 한다.
    this.props.onSubmit(this.state.searchKeyword);
    // 그러면, 이거를 사용하는 App 컴포넌트에서는 onSubmit()이라는 콜백함수를 전달해줘야겠죠??
    // 콜백함수에서는 입력한 검색어를 전달해줘야 하는데...
  }

  render() {
    return (
      <form onSubmit={(event) => this.handleSubmit(event)}>
        <input
          type="text"
          placeholder="검색어를 입력하세요"
          autoFocus
          value={this.state.searchKeyword}
          onChange={(event) => this.handleChangeInput(event)}
        />
        {this.state.searchKeyword.length > 0 && (
          <button type="reset" className="btn-reset"></button>
        )}
      </form>
    );
  }

다시 한번 정리하면, submit이 일어나는 순간, 그 이후에 작업은 SearchForm의 역할이 아니다. 그래서, 나머지 작업에 대해서는 App 컴포넌트로 끌어올려줘서, 다른 곳에서 작업을 진행할 수 있도록 위임해야하는 것이다.

그렇기 때문에, submit이 일어나는 순간, propsonSubmit()이라는 콜백함수에 searchKeyword를 인자로 보내줬다.

// App.js
search(searchKeyword) {
    console.log("TODO: search", searchKeyword);
  }

  render() {
    return (
      <>
        <Header title="검색" />
        <div className="container">
          <SearchForm
            onSubmit={(searchKeyword) => this.search(searchKeyword)}
          />
        </div>
      </>
    );
  }

App에서는 onSubmit을 받은 이후에, 제공받은 searchKeyword를 다시 search()로 보내줬다. 여기서 무슨 작업을 이어갈 지는 해당되는 요구사항에서 추가구현해보자!!

그리고 검색 결과가 보이는 파트는 검색 결과 구현 파트에서 구현하자!


💡요구사항: x 버튼을 클릭하거나 검색어를 삭제하면 검색 결과를 삭제한다

이것도 마찬가지도 콜백 함수로 해결하자!!

// SearchForm.js
 handleChangeInput(event) {
    const searchKeyword = event.target.value;

    // 검색어를 완전히 다 삭제했을 때도,
    // 리셋과 같은 상황이니, 리셋 이벤트를 넣자!!
    if (searchKeyword.length <= 0) {
      this.handleReset();
    }

    this.setState({ searchKeyword });
  }

 handleReset() {
    this.props.onReset();
  }

 render() {
    return (
      <form
        onSubmit={(event) => this.handleSubmit(event)}
        onReset={() => this.handleReset()}
      >
        <input
          type="text"
          placeholder="검색어를 입력하세요"
          autoFocus
          value={this.state.searchKeyword}
          onChange={(event) => this.handleChangeInput(event)}
        />
        {this.state.searchKeyword.length > 0 && (
          <button type="reset" className="btn-reset"></button>
        )}
      </form>
    );
  }

form 태그에 onReset()을 달아주었다. onReset()은 handleReset()을 부르고, 거기서 onReset()이라는 콜백함수를 App 컴포넌트로 전달해준다.

또한, 검색어가 완전히 다 삭제되어도, 리셋도 동일한 상황이기 때문에, 리셋 이벤트가 발생하도록 if절을 통해서 넣어줬다.

// App.js
search(searchKeyword) {
    console.log("TODO: search", searchKeyword);
  }

  handleReset() {
    console.log("TODO: reset", "리셋 성공");
  }

  render() {
    return (
      <>
        <Header title="검색" />
        <div className="container">
          <SearchForm
            onSubmit={(searchKeyword) => this.search(searchKeyword)}
            onReset={() => this.handleReset()}
          />
        </div>
      </>
    );
  }

아직 리셋 버튼 클릭시, 검색어가 삭제되는지 않는다. 그 작업은 SearchForm에서 할 작업은 아니라,App 컴포넌트가 다른 곳으로 위임해줄 예정이다.
리셋을 발생했을 시에, 무엇을 더 추가적으로 작업해줄지는 남아있는 요구사항에서 할 예정이라 "TODO"만 짚어넣었다.


State 끌어 올리기 (중요)

기존에는 App 컴포넌트 하나에서 모든 기능을 관리하기 때문에, 다른 컴포넌트에서 또다른 컴포넌트의 state 값을 쓴다는 것을 생각할 필요가 없다. 그러나, 역할별로 컴포넌트를 쪼갠 지금은 다르다!!

다양한 컴포넌트에서 사용하는 state 값은 최상위 컴포넌트인 App 컴포넌트로 끌어올려주는 게 좋다.

다시 말해서, state 값은 해당 컴포넌트 내에서만 접근이 가능한데, 그 값을 여러 컴포넌트에서 사용한다면, 최상위 컴포넌트로 끌어올려주는 것이 맞다. 이것을 공식 문서에서는 State 끌어 올리기라고 부른다.

종종 동일한 데이터에 대한 변경사항을 여러 컴포넌트에 반영해야 할 필요가 있습니다. 이럴 때는 가장 가까운 공통 조상으로 state를 끌어올리는 것이 좋습니다.

SearchForm 컴포넌트의 state를 부모 App 컴포넌트로 끌어 옮기자!!

class App extends React.Component {
  constructor() {
    super()
    this.state = { searchKeyword: "" } // 1
  }

  handleChangeInput(value) {
    this.setState({ searchKeyword: value }) // 2
  }

  render() {
    return (
      <>
        <Header title="검색" />
        <SearchForm
          value={this.state.searchKeyword}  // 3
          onChange={(value) => this.handleChangeInput(value)}  // 4
          onSubmit={() => this.search(searchKeyword)}
          onReset={() => this.handleReset()}
        />
      <>
    )
  }
}
  • SearchForm에서 관리했던 searchKeyword를 부모 컴포넌트인 Appstate로 끌어 올렸다. => (1)
  • 이 값을 SearchFormprops로 전달하고(3), 값을 갱신할 목적으로 onChange에 콜백 함수도 전달했다(4).
  • 콜백함수는 handleChangeInput() 메서드를 호출하는데 변경값으로 상태를 갱신한다. => (2)
  • SearchFormApp 컴포넌트에 의해 제어된 컴포넌트인 셈이다.

input의 입력값이 브라우져에서 관리되는 상태였는데, 그것을 리엑트 방식으로 바꾼 것이 "제어 컴포넌트"이다. 마찬가지로, SearchForm도 value와 변경방법을 사용하는 측에서 관리하고 있어서, SearchForm도 일종의 제어 컴포넌트의 셈이다.

그렇게 하려면, SearchForm의 구조도 바꿔야겠다.

// 1
const SearchForm = ({ value, onChange, onSubmit, onReset }) => {
  // 2
  const handleChange = event => {
    onChange(event.target.value)
  }

  const handleSubmit = event => {
    event.preventDefault()
    onSubmit()
  }

  const handleReset = () => {
    onReset()
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="검색어를 입력하세요"
        autoFocus
        value={value} // 3
        onChange={handleChange} // 4
      />
      {value.length > 0 && (
        <button type="reset" className="btn-reset" onClick={handleReset} />
      )}
    </form>
  )
}
  • 부모 컴포넌트로 옮겨버렸린 state는 더 이상 사용하지 않기 때문에, 함수 컴포넌트로 변경했다(1).
  • input을 제어 컴포넌트로 만들 듯 SearchForm도 제어 컴포넌트로 만들기 위해 props.onChange() 함수로 입력한 값을 전달한다(2, 4). 이 값은 곧장 props.value로 들어와 input의 값으로 반영될 것이다(3).

함수 컴포넌트는 리엑트 엘리먼트를 반환하기 때문에, 기존의 render()가 반환했던 값을 그대로 반환하도록 하자!! 또한, 더이상 클래스가 아니기 때문에, 클래스의 메소드들 역시 변경이 필요하다.

SearchForm이라는 거대한 함수가 props를 인자로 받고 있다. 그러나, 여기서 좀 더 업그레이드 시켜보자!!

함수 문법을 사용할 때, props객체를 구조분해할당으로 사용하면, 부모 컴포넌트로부터 어떤 것들을 받아오는지 한 눈에 알아볼 수 있다.

(props) => ({ value, onChange, onSubmit, onReset })

props.onSubmit(); => onSubmit();

이전보다 확실히 코드가 더 보기 편하고, 어떤 props를 받는지 한 눈에 볼 수있다.


해당 github 링크

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

0개의 댓글