realworld-example::react-redux

White Piano·2021년 1월 16일
3

이 글은 필자가 realworld examplereact-redux version을 보며 느꼈던 내용을 담고 있는 일종의 감상문입니다. 따라서 지식 전달에 목적을 두지 않습니다.

주의! 필자는 개발의 'ㄱ'도 제대로 모르는 초보입니다. 가르침은 언제나 감사합니다!

realworld-example::react-redux

Web 기술은 끊임없이 발전하고 있고, 우리는 언제나 새로운 언어, 프레임워크를 익혀야 합니다. 하지만 공식 문서를 찾아 읽고, 블로그 포스팅을 북마크 하며 배워도 막상 사용하려고 하면 어떻게 해야 할지 막막한 경우가 있습니다. 그런 사람들에게 도움을 주기 위해 시작된 프로젝트가 realworld-example입니다. Medium.com클론 하며 Auth와 HTTP request, response 등 Web App을 만드는 데 필요한 기본기를 쌓을 수 있도록 도와줍니다. 이 글은 그중에서도 react-redux 버전을 살펴본 후기입니다.

File Structure

아무래도 가장 먼저 눈에 들어오는 건 파일 구조였습니다. 평소 고민이 많은 영역이었죠.

필자가 원래 쓰던 방법

제가 나름 정형화시켜 사용하던 구조는 일종의 트리 형태였습니다. 가령 A 모듈이 BC 모듈을 import 한다면, 다음과 같은 구조를 사용했습니다.

$ tree -L 2
.
├── A
│   ├── B.js
│   └── C.js
└── A.js

하지만 위 방법은 좋은 방법이 아니었습니다. 깊이가 쓸데없이 길어지는 일도 잦았고, 한 폴더 안에 여러 모듈이 있는 경우 가독성이 떨어졌습니다.

$ tree -L 3 --dirsfirst
.
├── A
│   ├── AA
│   │   └── AAA.js
│   ├── AA.js
│   └── AB.js
├── B
│   └── BA.js
├── C
│   └── CA.js
├── A.js
├── B.js
└── C.js

게다가 하나의 모듈을 여러 곳에서 import하게 되는 경우에는 규칙에 맞춰 구조를 재정비하는 데 오랜 시간이 걸리거나 아예 그렇게 하는 게 불가능한 경우도 있었습니다.

Realworld Example의 경우

index.js를 활용

정확한 명세를 찾을 순 없었지만, 일반적으로 import하는 모듈이 폴더일 경우, 해당 폴더 내의 index.js 파일을 import하게 됩니다. realworld example에서는 이를 활용해 다음과 같은 구조를 사용했습니다.

$ tree src/components -L 3 --dirsfirst
src/components
├── Article
│   ├── ArticleActions.js
│   ├── ArticleMeta.js
│   ├── Comment.js
│   ├── CommentContainer.js
│   ├── CommentInput.js
│   ├── CommentList.js
│   ├── DeleteButton.js
│   └── index.js
├── Home
│   ├── Banner.js
│   ├── MainView.js
│   ├── Tags.js
│   └── index.js
├── App.js
├── ArticleList.js
├── ArticlePreview.js
├── Editor.js
├── Header.js
├── ListErrors.js
├── ListPagination.js
├── Login.js
├── Profile.js
├── ProfileFavorites.js
├── Register.js
└── Settings.js

ArticleHome 모듈은 다른 하위 모듈 여러 개를 import 해야 합니다. 제가 한 것과 같이 별도의 폴더를 생성하는 대신에, 자기 자신을 index.js로 만들어서 가독성을 향상시켰습니다.

// App.js

import Article from 'components/Article';
import Home from 'components/Home';

App.js를 보면 import 과정의 직관성도 해치지 않는 모습을 확인할 수 있습니다.

하나의 모듈을 여러 곳에서 import 하는 경우

# tree의 일부분만 나타냈습니다.

$ tree src/components -L 2 --dirsfirst
src/components
├── Home
│   ├── Banner.js
│   ├── MainView.js
│   ├── Tags.js
│   └── index.js
├── ArticleList.js
└── Profile.js

ArticleList.js 모듈은 Home/MainView.js 모듈과 Profile.js 모듈에 모두 사용됩니다. realworld에서는 ArticleList.js가 두 모듈(Home/MainView.js, Profile.js)의 공통 조상src/components 아래에 위치하고 있습니다. 이 방법은 매우 깔끔해 보입니다. 앞으로는 파일 구조에 이 규칙을 적용해야겠습니다.

아쉬운 점. redux(or store) 폴더의 부재

src의 파일 구조는 다음과 같습니다.

$ tree src -L 1 --dirsfirst
src
├── components
├── constants
├── reducers
├── agent.js
├── index.js
├── middleware.js
├── reducer.js
└── store.js

이 중 agent.jsservice를, index.jsreact component를, store.jsredux를 담당(?)하는 가장 상위 파일입니다. 하지만 middleware.jsreducer.jsstore.js에서 사용되는 일종의 하위 모듈입니다. 이 파일들과 reducers 폴더는 redux(or store) 폴더를 만들고 그 안에 위치시키는 게 더 좋지 않았나 생각해 봅니다.

React

Class Component는 틀렸나?

React 컴포넌트를 만드는 방법은 클래스를 이용하는 방법과 함수를 이용하는 방법의 2가지로 나뉩니다. 하지만 저는 오직 함수를 이용한 방법만을 사용하며, "클래스를 이용한 방법은 틀렸다"고 믿었습니다. 돌이켜보면 특별한 이유가 있지는 않았습니다. 함수를 이용한 방법이 더 "가볍고", 더 "현대적인" 방법으로 느껴졌던 건 사실이지만, 어쩌면 공부량이 절반이 되리란 기대를 했기 때문일지도 모릅니다.

Realworld Example에는 클래스를 이용해 디자인한 컴포넌트가 대다수였습니다. 처음 코드를 확인하고 놀랐던 저는, 작성된 시기가 못해도 3년이 지남을 확인하고 단지 코드를 업데이트하지 않았을 뿐이라고 생각했습니다. 하지만 정말로 그 이유뿐이었을까요?

useState가, Hook이, 확실히 더 좋은 방법이었을까?

아래의 둘 중 어느 게 더 읽기 편하신가요? 아마 사람마다 다를 거라고 생각합니다.

// functional component
const FunctionalArticle = () => {
  const [articleTitle, setArticleTitle] = useState('');
  const [articleCategories, setArticleCategories] = useState([]);
  const [articleContent, setArticleContent] = useState('');
}

// class component
class ClassArticle extends React.Component {
  constructor(){
    super();
    this.articleTitle = '';
    this.articleCategories = [];
    this.articleContent = '';
  }
}

제겐 클래스로 만들어진 컴포넌트가 더 보기 편합니다. useState의 경우 변수와 초깃값 사이의 거리가 멀어지고 setter를 자유롭게 정의하지 못하는 부분이 마음에 들지 않습니다. 클래스를 사용한 경우와 비교하면 거추장스럽게 느껴집니다.

비단 useState만이 아닙니다. Realworld Example의 코드를 보면 useEffect없이 componentDidMount 친구들(?)만으로 충분히 직관적이고 깔끔한 컴포넌트를 만들 수 있었습니다.

상속이라니, 상상도 못 했다

src/ProfileFavorites.js 파일에는 ProfileFavorites 컴포넌트가 정의되어 있습니다. 독특하게도 이 컴포넌트는 render 메서드를 정의하지 않습니다.

class ProfileFavorites extends Profile {
  componentWillMount() {
    ...
  }
    
  componentWillUnmount() {
    ...
  }
  
  renderTabs() {
    ...
  }
}

사실 이상하지도 독특하지도 않습니다. ProfileFavrotiesrender 메서드가 없는 이유는 ProfileFavroitesReact.Component 대신 Profile이라는 다른 사용자 정의 컴포넌트를 상속하기 때문입니다. 하지만 언제나 함수로 컴포넌트를 만들었던 전 다른 컴포넌트로부터 상속받을 수 있단 사실을 깨닫기까지 꽤 오랜 시간이 걸렸습니다. 그래서 구글에 "react renderTabs()"를 검색하기도 했습니다. render랑 비슷한 이름이니 제가 모르는 다른 문법이라고 생각했기 때문이죠.

위와 같이 다른 컴포넌트를 상속하는 방법이 유용한진 잘 모르겠지만, 적어도 사용할 수 있는 옵션이 하나 늘었다는 것만으로도 충분히 의미 있지 않나 생각합니다.

Functional Component vs Class Component

이전까지는 함수를 사용한 방법이 클래스를 사용한 방법보다 더 "진보된"방법이라 믿어 의심치 않았습니다. 하지만 Realworld Example을 살펴보며 너무 섣부른 판단은 아니었는지 고민하게 됩니다. 애초에 Functional Component는 Stateless Component라고도 불렸었던 만큼 stateless한 컴포넌트만 함수를 이용해 만드는 게 정석이 아닐까요? 컴포넌트 정의 방법만 보고도 해당 컴포넌트에 기대하는 역할을 더 명확히 할 수 있을 테니까요.

React에서 계속해서 훅을 디자인하고, 함수를 이용한 방법에 최적화를 한다는 사실을 생각하면, 어쩌면 정말 클래스로 컴포넌트를 만드는 건 잘못된 판단일지도 모릅니다. 하지만 뭐 어떻습니까? realworld는 클래스도 충분히 좋다고 알려줬고 제겐 클래스를 이용한 방법이 멋져 보입니다!

Redux

async action을 처리하는 방법

필자의 경우. redux-thunk

import axios from 'axios';

// define action
export const REQUEST_ARTICLE_LIST = 'REQUEST_ARTICLE_LIST';
export const RECEIVE_ARTICLE_LIST = 'RECEIVE_ARTICLE_LIST';

// define action creator
const requestArticleList = () => {
  return {
    type: REQUEST_ARTICLE_LIST,
  };
};
const receiveArticleList = (articleList) => {
  return {
    type: RECEIVE_ARTICLE_LIST,
    payload: {
      articleList: articleList,
    },
  };
};

// define fetcher (thunk)
const fetchArticleList = (filter, requestGenerator) => {
  return async (dispatch) => {
    dispatch(requestArticleList());
    const articleList = (await axios(requestGenerator(filter))).data.list;
    dispatch(receiveArticleList(articleList));
  };
};

export const fetchArticleListIfNotFetching = (filter, requestGenerator) => {
  return (dispatch, getState) => {
    if (getState().articleList.isFetching === false) {
      return dispatch(fetchArticleList(filter, requestGenerator));
    }
  };
};

위 코드는 제가 작성했던 코드로, 비동기 액션을 처리하기 위한 redux-thunk입니다.

import { fetchArticleListIfNotFetching } from 'redux/action/articleList';
import { someOtherAsyncThunk } from 'redux/action/someOtherAsyncAction';

// dispatch async action
dispatch(fetchArticleListIfNotFetching(filter, requestGenerator);
dispatch(someOtherAsyncThunk);

문제는 각각의 비동기 액션마다 thunk를 만들어야 했고, thunk가 단지 비동기 작업을 위한 것이었던 만큼, actiondispatch 하는 부분을 제외하면 대부분의 로직이 동일하다는 것이었습니다. 좋은 구조가 아님을 알았지만, 다른 방법은 떠오르지 않았습니다.

Realworld Example의 경우. middleware

const promiseMiddleware = (store) => (next) => (action) => {
  if (isPromise(action.payload)) {
    // notify that an async action has started
    store.dispatch({ type: ASYNC_START, subtype: action.type });

    action.payload.then(
      (res) => {
        // replace action's payload from promise to result of promise
        action.payload = res;
        
        // notify that an async operation has ended successfully
        store.dispatch({ type: ASYNC_END, promise: action.payload });
        
        // dispatch action
        store.dispatch(action);
      },
      (error) => {
        // dispatch action with error
        action.error = true;
        action.payload = error.response.body;
        store.dispatch(action);
      }
    );

    return;
  }

  next(action);
};

realworld는 미들웨어를 사용함으로써 모든 문제를 해결했습니다. 덕분에 액션을 정의하는 파일에 별도의 로직 없이 액션, 혹은 액션 크리에이터만 작성함으로써 한층 더 나은 구조를 만들 수 있습니다.

import axios from 'axios';

// define action
export const GET_ARTICLE_LIST = 'GET_ARTICLE_LIST';

// define action creator
export const GET_ARTICLE_LIST = (filter, requestGenerator) => {
  return {
    type: GET_ARTICLE_LIST,
    payload: axios(requestGenerator(filter)).then((res) => res.data.list),
  };
};

Technique? Basic?

JS

POJO(Plain Old Javscript Object) 활용

// src/agent.js

const Auth = {
  current: () =>
    requests.get('/user'),
  login: (email, password) =>
    requests.post('/users/login', { user: { email, password } }),
  register: (username, email, password) =>
    requests.post('/users', { user: { username, email, password } }),
  save: user =>
    requests.put('/user', { user })
};

전 지금껏 POJO에 오직 value만 담았습니다. 하지만 밀접히 연관된 함수들을 보관하는 용도로도 사용될 수 있었네요. 마치 네임스페이스처럼 보입니다. 이렇게 정의된 Auth는 다양한 곳에서 활용됩니다.

// src/components/Login.js

const mapDispatchToProps = (dispatch) => ({
  onChangeEmail: (value) =>
    dispatch({ type: UPDATE_FIELD_AUTH, key: 'email', value }),
  onChangePassword: (value) =>
    dispatch({ type: UPDATE_FIELD_AUTH, key: 'password', value }),
  onSubmit: (email, password) =>
    dispatch({ type: LOGIN, payload: agent.Auth.login(email, password) }),
  onUnload: () => dispatch({ type: LOGIN_PAGE_UNLOADED }),
});

Html

URL Scheme

public/index.html을 보면 Scheme을 생략한 URL을 사용하고 있습니다. 부끄럽지만 URL에서 Scheme이 생략 가능하단 걸 이번에 처음 알았네요.

<link rel="stylesheet" href="//demo.productionready.io/main.css" />
<link
  href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
  rel="stylesheet"
  type="text/css"
/>

아쉬운 점. <nosciprt />

public/index.htmlbody에는 root div만 존재합니다. create-react-app을 이용하면 noscript 태그가 자동으로 만들어지는데, 굳이 삭제한 이유를 모르겠네요.

<!-- create-react-app으로 만들어진 html body -->

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root"></div>
  <!--
    This HTML file is a template.
    If you open it directly in the browser, you will see an empty page.

    You can add webfonts, meta tags, or analytics to this file.
    The build step will place the bundled scripts into the <body> tag.

    To begin the development, run `npm start` or `yarn start`.
    To create a production bundle, use `npm run build` or `yarn build`.
  -->
  </body>

2개의 댓글

comment-user-thumbnail
2021년 1월 16일

포스팅 잘 보고 갑니다~

답글 달기
comment-user-thumbnail
2021년 1월 16일

(글쓴이의 요청으로 변경된 댓글입니다)

답글 달기