이 글은 필자가 realworld example의 react-redux version을 보며 느꼈던 내용을 담고 있는 일종의 감상문입니다. 따라서 지식 전달에 목적을 두지 않습니다.
주의! 필자는 개발의 'ㄱ'도 제대로 모르는 초보입니다. 가르침은 언제나 감사합니다!
Web 기술은 끊임없이 발전하고 있고, 우리는 언제나 새로운 언어, 프레임워크를 익혀야 합니다. 하지만 공식 문서를 찾아 읽고, 블로그 포스팅을 북마크 하며 배워도 막상 사용하려고 하면 어떻게 해야 할지 막막한 경우가 있습니다. 그런 사람들에게 도움을 주기 위해 시작된 프로젝트가 realworld-example입니다. Medium.com을 클론 하며 Auth와 HTTP request, response 등 Web App을 만드는 데 필요한 기본기를 쌓을 수 있도록 도와줍니다. 이 글은 그중에서도 react-redux 버전을 살펴본 후기입니다.
아무래도 가장 먼저 눈에 들어오는 건 파일 구조였습니다. 평소 고민이 많은 영역이었죠.
제가 나름 정형화시켜 사용하던 구조는 일종의 트리 형태였습니다. 가령 A
모듈이 B
와 C
모듈을 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하게 되는 경우에는 규칙에 맞춰 구조를 재정비하는 데 오랜 시간이 걸리거나 아예 그렇게 하는 게 불가능한 경우도 있었습니다.
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
Article
과 Home
모듈은 다른 하위 모듈 여러 개를 import 해야 합니다. 제가 한 것과 같이 별도의 폴더를 생성하는 대신에, 자기 자신을 index.js
로 만들어서 가독성을 향상시켰습니다.
// App.js
import Article from 'components/Article';
import Home from 'components/Home';
App.js
를 보면 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.js
는 service를, index.js
는 react component를, store.js
는 redux를 담당(?)하는 가장 상위 파일입니다. 하지만 middleware.js
와 reducer.js
는 store.js
에서 사용되는 일종의 하위 모듈입니다. 이 파일들과 reducers
폴더는 redux(or store) 폴더를 만들고 그 안에 위치시키는 게 더 좋지 않았나 생각해 봅니다.
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() {
...
}
}
사실 이상하지도 독특하지도 않습니다. ProfileFavroties
에 render 메서드가 없는 이유는 ProfileFavroites
가 React.Component
대신 Profile
이라는 다른 사용자 정의 컴포넌트를 상속하기 때문입니다. 하지만 언제나 함수로 컴포넌트를 만들었던 전 다른 컴포넌트로부터 상속받을 수 있단 사실을 깨닫기까지 꽤 오랜 시간이 걸렸습니다. 그래서 구글에 "react renderTabs()"를 검색하기도 했습니다. render랑 비슷한 이름이니 제가 모르는 다른 문법이라고 생각했기 때문이죠.
위와 같이 다른 컴포넌트를 상속하는 방법이 유용한진 잘 모르겠지만, 적어도 사용할 수 있는 옵션이 하나 늘었다는 것만으로도 충분히 의미 있지 않나 생각합니다.
이전까지는 함수를 사용한 방법이 클래스를 사용한 방법보다 더 "진보된"방법이라 믿어 의심치 않았습니다. 하지만 Realworld Example을 살펴보며 너무 섣부른 판단은 아니었는지 고민하게 됩니다. 애초에 Functional Component는 Stateless Component라고도 불렸었던 만큼 stateless한 컴포넌트만 함수를 이용해 만드는 게 정석이 아닐까요? 컴포넌트 정의 방법만 보고도 해당 컴포넌트에 기대하는 역할을 더 명확히 할 수 있을 테니까요.
React에서 계속해서 훅을 디자인하고, 함수를 이용한 방법에 최적화를 한다는 사실을 생각하면, 어쩌면 정말 클래스로 컴포넌트를 만드는 건 잘못된 판단일지도 모릅니다. 하지만 뭐 어떻습니까? realworld는 클래스도 충분히 좋다고 알려줬고 제겐 클래스를 이용한 방법이 멋져 보입니다!
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가 단지 비동기 작업을 위한 것이었던 만큼, action을 dispatch 하는 부분을 제외하면 대부분의 로직이 동일하다는 것이었습니다. 좋은 구조가 아님을 알았지만, 다른 방법은 떠오르지 않았습니다.
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),
};
};
// 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 }),
});
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.html
의 body
에는 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>
포스팅 잘 보고 갑니다~