해당 글은 React-admin을 구성하는 일부 기술을 소개하는 글입니다.
React-admin의 사용법과 관한 정보를 얻기에는 적합하지 않은 글일 수 있음을 밝힙니다.
들어가기 앞서... React Admin은 어땠나?
1년 가까이 좋든 싫은 React-admin ( 중략 RA ) 을 사용해왔다.
RA는 우리에게 정형화된 UI와 Data구조 내에서 폭팔적인 생산력을 보여왔다. 이는 아마 opinionated한 모듈이라면 가지는 일반적인 장점일것이다.
export const PostList = (props) => (
<List {...props}>
<Datagrid>
<TextField source="id" />
<TextField source="title" />
<DateField source="published_at" />
<TextField source="category" />
<BooleanField source="commentable" />
</Datagrid>
</List>
);
JavaScript
복사
< react-admin Documentation 참고 >
API Fetch, Styling등 고도화된 추상화를 통해 단 몇줄로 복잡한 수준의 DataGrid List를 생성해낸다.
이렇게 압도적인 생산성에도 불구하고 팀에서는 RA에 대해 아주 긍적적이지만은 못했다.
요구사항은 RA의 기능만으로는 역부족인 경우가 너무 많이 발생했고, 많은 경우에 커스터마이징을 해야했다.
이런 경우에 RA를 사용하지 않았을 때보다 더 많은 업무 공수를 들여야 할 때도 있었다. 특히 주니어 프론트엔드 개발자에게는 상당히 어려운 작업이여서 RA에 익숙한 개발자만이 해결할 수 있는 업무도 생겼다. 기획자의 요구사항와 RA를 사용하는 개발자의 합의점을 찾는 과정에서 서로의 충돌도 잦아졌다.
그럼에도 불구하고 RA는 좋은 어플리케이션이라고 생각한다.
RA의 Main Container인 François Zaninotto는 ng-admin , admin-on-rest 와 같이 많은 Admin UI Framework를 개발한 경험이 있는 믿음직한 엔지니어라고 생각한다.
Francois Zaninotto
ng-admin react-admin 와 같은 CMS Framework 와 fake data generator인 Faker 개발자인 Francois Zaninotto 이시다.
프론트엔드 스타 개발자중에서는 꽤 정석적인 코드를 많이 작성한다는 느낌을 많이 받는다. 흔히 말하는 읽기 쉬운 코드에 가까운 느낌이다.
구조적인 문제를 겪을시에 꽤 큰 작업들도 진행하는 PR들을 보는데, 프로젝트가 어마무시하게 큰데 비해 이런 무난하고 깔끔한 코드 덕분에 유지보수하는데 도움이 되는 편인가? 하는 생각이 든다. ( 개인적으로 배울점이 많은 코드라고 생각한다. )
또한 RA의 커뮤니티는 활발하고 RA팀 역시 여전히 계속해서 새로운 API를 제공하고 있다.
이미 많은 기능을 제공하는것도 한 몫한다. 대체로 없는 기능보다 있는지 몰라서 사용하지 못하는 경우가 많다.
이번 글은 이렇게 좋은 어플리케이션이 어떤 마법을 부리는지 살펴보고자 한다.
Hash Based Routing
< 리액트 어드민의 URL 체계 / Hash Sign( # ) 이후에 리소스가 따른다. >
이러한 URL을 가지는 전략을 Hash Based Routing 라고 부른다. SPA가 나오면서 덩달아 나온 개념인데, 기존에 Server에서 관리하던 Routing을 클라이언트의 어플리케이션에게 전담하는 방식이다.
Hash Sign( # ) 이후의 주소는 브라우저가 라우팅 즉, 페이지의 이동으로 간주하지 않기 때문에 주소를 변경해도 어플리케이션의 index.html만 계속 로드한다.
RA 공식 문서에는 다음과 같이 설명되어 있다.
This strategy has the benefit of working without a server, and with legacy web browsers.
좀 더 정리하자면 Hash Based Routing는 다음과 같은 장단점을 가진다.
장점
1.
SPA의 Path마다 웹서버의 Route설정을 해 줄 필요가 없다. 오로지 웹 어플리케이션의 Root (index.html)만 관리하면 된다.
2.
API와 같이 사용하는 서버라면 클라이언트와의 Route분리에 있어 api만 신경쓰면 되기 때문에, 더욱 쉬운 이점을 가진다.
3.
일반적인 URL구조는 SPA에서 새로고침이나 특정 URL로 접근시에 full page reloads를 할 수 있으므로 주의가 필요하다. 하지만 Hash Based Routing는 무조건 index.html만을 로드하기 때문에 그럴 필요가 없다.
단점
1.
SEO에 적합하지 않다.
2.
일반 URL에 비해 이상하게 느껴질 수 있다.
RA팀은 해당 전략을 통해 클라이언트 어플리케이션을 서버 혹은 브라우저와의 의존성을 제거하기 원했던거 같다. 특히 많은 유저가 사용하는 프레임워크라면 이러한 의존성 제거는 좋은 선택이라고 생각한다.
또 재미있는 점은 서비스에 SEO가 적합하지 않다는 것은 크리티컬한 이슈이다. 하지만 RA, 관리자 페이지에서는 그렇지 않다는 점이다. 적어도 Routing전략에 있어서는 RA팀이 트레이드 오프에서 얻어 간 것만 있는 느낌이다.
< 왼쪽은 Hash Based Routing 오른쪽은 Ordinary URL paths의 네트워크 결과 각각 localhost와 users >
구현에 있어서는 RA가 상태관리 도구로써 Redux를 사용하는 만큼, ConnectedRouter 를 적극활용해서 Hash Based Routing를 구현하는 것을 확인해볼 수 있었다.
Adapter Injection System
< RA Data Provider 아키텍쳐>
해당 아키텍쳐는 RA의 핵심이 되는 시스템이다. 우리는 어떤 API를 사용하고 있더라도 인터페이스에 맞게 전달만 한다면 쉽게 데이터를 Fetch하고 컴포넌트에 적절히 데이터를 바인딩 할 수 있었다.
이러한 마법이 어떻게 이루어질 수 있는지 알아보기 전에, 다시 한번 사용법을 살펴보자.
import { Admin, Resource } from 'react-admin';
import dataProvider from '../myDataProvider';
const App = () => (
<Admin dataProvider={dataProvider}>
<Resource name="posts" list={PostList} create={PostCreate} edit={PostEdit}/>
</Admin>
)
JavaScript
복사
Admin 컴포넌트는 dataProvider와 하나 이상의 Resource 컴포넌트를 포함해야만 한다.
그리고 DataProvider는 다음과 같은 객체를 반환해야 한다.
const dataProvider = {
getList: (resource, params) => Promise,
getOne: (resource, params) => Promise,
getMany: (resource, params) => Promise,
getManyReference: (resource, params) => Promise,
create: (resource, params) => Promise,
update: (resource, params) => Promise,
updateMany: (resource, params) => Promise,
delete: (resource, params) => Promise,
deleteMany: (resource, params) => Promise,
}
TypeScript
복사
이러한 설명만 봤을 때는 아마 List와 같은 컴포넌트가 렌더링 시에 dataProvider에 등록한 적절한 함수들이 실행 할 것이라 예상 할 수 있었다. 하지만 어떻게? 에 대한 질문은 Resource컴포넌트와 그 인자들을 좀 더 살펴보아야지만 알 수 있었다.
텅빈 컴포넌트 Resource의 역할
const Resource = (name, list, create, edit, show) =>
<span>해당 컴포넌트는 인자를 전달하기 위한 수단입니다. 렌더링하지 않습니다.</span>;
TypeScript
복사
실제로 Resource는 조금 더 많은 인자들을 가지고 있지만 핵심적인 부분만 요약하자면 다음과 같이 생겼다.
나는 이런 식의 패턴을 처음 본지라, 처음에는 꽤 충격을 받았는데 좀 더 살펴보니 꽤 그럴듯한 이유가 있었다.
내가 느낀 Resource 컴포넌트가 하는 역할은 데이터와 컴포넌트를 바인딩하기 위한 개념적인 도구이다.
이해를 돕기위해 다음 코드를 보자.
const Admin = ({children}) => {
const resources = Children.map(children, ({ props }) => props) || [];
const customHistory = createBrowserHistory();
return (
<Router history={customHistory}>
<Switch>
<Route path="/" render={() => (
<Layout resources={resources} />
)} />
</Switch>
</Router>
)
}
TypeScript
복사
Admin의 Resource들은 모두 children인자로써 넘어가게 된다. 그리고 React.Children.map함수를 통해 Resource들의 인자들을 모두 resources 라는 하나의 배열로 만든다. 이것들은 RA의 Layout에 인자로써 전달한다.
const Layout = ({ resources }) => (
<Switch>
{resources.map((resource) => (
<Route
path={`/${resource.name}`}
key={resource.name}
render={() => (
<CrudRoute
resource={resource.name}
list={resource.list && resource.list}
create={resource.create && resource.create}
edit={resource.edit && resource.edit}
show={resource.show && resource.show}
/>
)}
/>
))}
</Switch>
);
TypeScript
복사
Layout 컴포넌트들은 resources를 돌며 CrudRoute라는 컴포넌트들을 렌더링 하게 된다.
이 때 resource인자들을 모두 풀어 전달한다.
const CrudRoute = ({
resource,
list,
create,
edit,
show,
remove,
options,
props,
}) => {
return (
<Switch>
{list && (
<Route
exact
path={`/${resource}`}
render={() => createElement(list, { ...props, resource })}
/>
)}
{edit && (
<Route
exact
path={`/${resource}/edit`}
render={() => createElement(edit, { ...props, resource })}
/>
)}
...
</Switch>
);
};
TypeScript
복사
Resource 컴포넌트에서 전달한 list, edit와 같은 인자들을 떠올려보자. 모두 컴포넌트들이다. Resource에서 전달한 데이터명을 통해 route를 생성하고, 컴포넌트들은 React.createElement 를 통해 생성한다. 이 때, dataProvider에서 함수를 fetch 전달할 props나 resource명 역시 전달한다.
이 때, 마법 같던 RA의 안개가 조금은 걷히는 기분이였다. Admin에서 Context.Provider 를 통해 DataProvider 를 제공하고 List와 같은 컴포넌트들이 fetch를 시도한다. 와 같은 것들을 충분히 예측 할 수 있게 되었다. 정말 그런지 당장 확인해보자.
Data Provider의 실체
3.13.5 버전 기준으로 여전히 CoreAdminContext라는 컴포넌트에서 dataProvider를 하위 컴포넌트들에게 전달하는 모습을 확인 할 수 있다.
실제로 어떻게 사용되는지 List 컴포넌트를 통해 살펴보자.
const List = (props) => {
const controllerProps = useListController(props);
return (
<ListContextProvider value={controllerProps}>
<ListView {...props} {...controllerProps} />
</ListContextProvider>
);
};
TypeScript
복사
List 가 호출하는useListController 라는 이름의 Hooks를 주목하자.
이 값은 컴포넌트의 값을 그리기 위한 데이터를 fetch한다.
const useListController = (
props
) => {
const {
basePath,
exporter = defaultExporter,
filterDefaultValues,
hasCreate,
sort = defaultSort,
perPage = 10,
filter,
debounce = 500,
} = props;
const resource = useResourceContext(props);
const [query, queryModifiers] = useListParams({
resource,
filterDefaultValues,
sort,
perPage,
debounce,
syncWithLocation,
});
const { ids, data, total, error, loading, loaded } = useGetMainList(
resource,
{
page: query.page,
perPage: query.perPage,
},
{ field: query.sort, order: query.order },
{ ...query.filter, ...filter },
{
action: CRUD_GET_LIST,
...
}
);
const totalPages = Math.ceil(total / query.perPage) || 1;
useEffect(() => {
if (
query.page <= 0 ||
(!loading && query.page > 1 && ids.length === 0)
) {
queryModifiers.setPage(1);
} else if (!loading && query.page > totalPages) {
queryModifiers.setPage(totalPages);
}
}, [loading, query.page, ids, queryModifiers, total, totalPages]);
return {
basePath,
data,
ids,
loaded: loaded || ids.length > 0,
loading,
page: query.page,
perPage: query.perPage,
resource,
...
total: total,
};
};
TypeScript
복사
useListController 를 요약하자면 위와 같다. 혼란스러워 보일 수도 있지만 우리가 집중해야하는 부분은 useListParams 를 통해서 인자들을 전달하여 query를 가져오고 useGetMainList를 통해 데이터를 패치한다는 것이다! ( 이 외에도 위 코드에 관전포인트는 많지만 다음으로 미루자. )
export const useGetMainList = <RecordType extends Record = Record>(
resource: string,
pagination: PaginationPayload,
sort: SortPayload,
filter: object,
options?: any
): {
data?: RecordMap<RecordType>;
ids?: Identifier[];
total?: number;
error?: any;
loading: boolean;
loaded: boolean;
} => {
const requestSignature = JSON.stringify({ pagination, sort, filter });
const memo = useRef<Memo<RecordType>>({});
const {
data: { finalIds, finalTotal, allRecords },
error,
loading,
loaded,
} = useQueryWithStore(
{ type: 'getList', resource, payload: { pagination, sort, filter } },
options,
(state: ReduxState): DataSelectorResult<RecordType> => {
const ids = get(state.admin.resources, [
resource,
'list',
'cachedRequests',
requestSignature,
'ids',
...
const data = useMemo(
() =>
typeof finalIds === 'undefined'
? defaultData
: finalIds
.map(id => allRecords[id])
.reduce((acc, record) => {
if (!record) return acc;
acc[record.id] = record;
return acc;
}, {}),
[finalIds, allRecords]
);
return {
data,
ids: typeof finalIds === 'undefined' ? defaultIds : finalIds,
total: finalTotal,
error,
loading,
loaded,
};
TypeScript
복사
useGetMainList 는 useQueryWithStore 를 통해서 data를 가져온다!
const useQueryWithStore = <State extends ReduxState = ReduxState>(
query: Query,
options: QueryOptions = { action: 'CUSTOM_QUERY' },
...
): {
data?: any;
total?: number;
error?: any;
loading: boolean;
loaded: boolean;
} => {
const { type, resource, payload } = query;
const version = useVersion(); // used to allow force reload
const requestSignature = JSON.stringify({ query, options, version });
const requestSignatureRef = useRef(requestSignature);
const data = useSelector(dataSelector);
const total = useSelector(totalSelector);
const [state, setState]: [
StateResult,
(StateResult) => void
] = useSafeSetState({
data,
total,
error: null,
loading: true,
loaded: isDataLoaded(data),
});
useEffect(() => {
if (requestSignatureRef.current !== requestSignature) {
// request has changed, reset the loading state
requestSignatureRef.current = requestSignature;
setState({
data,
total,
error: null,
loading: true,
loaded: isDataLoaded(data),
});
} else if (!isEqual(state.data, data) || state.total !== total) {
// the dataProvider response arrived in the Redux store
if (typeof total !== 'undefined' && isNaN(total)) {
console.error(
'Total from response is not a number. Please check your dataProvider or the API.'
);
} else {
setState(prevState => ({
...prevState,
data,
total,
loaded: true,
loading: false,
}));
}
}
}, [
data,
requestSignature,
setState,
state.data,
state.total,
total,
isDataLoaded,
]);
const dataProvider = useDataProvider();
useEffect(() => {
if (!queriesThisTick.hasOwnProperty(requestSignature)) {
queriesThisTick[requestSignature] = new Promise<PartialQueryState>(
resolve => {
dataProvider[type](resource, payload, options)
.then(() => {
resolve({
error: null,
loading: false,
loaded: true,
});
})
.catch(error => {
if (
requestSignature !== requestSignatureRef.current
) {
resolve(undefined);
}
resolve({
error,
loading: false,
loaded: false,
});
});
}
);
setTimeout(() => {
delete queriesThisTick[requestSignature];
}, 0);
}
(async () => {
const newState = await queriesThisTick[requestSignature];
if (newState) setState(state => ({ ...state, ...newState }));
})();
}, [requestSignature]);
return state;
};
TypeScript
복사
useQueryWithStore 는 dataProvider 를 통해서 data를 fetch해오는 것을 확인 할 수 있다.
useQueryWithStore 는 앞 선 다른 코드와 달리 최대한 생략하는 부분 없이 코드를 가져왔다.
Redux를 이용하여 data fetch 최적화를 하는 것에 얼마나 공을 들였는지 보기 위함이다. 매 데이터를 memorize 하고 값을 비교하여 불필요한 fetch를 줄이거나 요청에 대한 상태관리를 자세히 함을 확인 할 수 있었다.
Open Closed Principle
수정에는 닫혀있고, 확장에는 열려있다.
function printType(value) {
let type;
switch(typeof value) {
case 'string':
type = '문자열.'
break;
case 'number':
type = '숫자.'
break;
case 'boolean':
type = '불리언.'
break;
}
return type + '입니다.'
}
JavaScript
복사
printType 은 들어온 값의 타입에 따라서 적절한 안내메시지를 리턴하는 함수이다.
이 때, 이 함수에 새로운 타입이 들어온다고 가정하면,
function printType(value) {
let type;
switch(typeof value) {
case 'string':
type = '문자열.'
break;
case 'number':
type = '숫자.'
break;
case 'boolean':
type = '불리언.'
break;
case 'symbol':
type = '심볼.'
break;
}
return type + '입니다.'
}
JavaScript
복사
위와 같이 printType 함수를 직접 수정해야 한다.
만약 printType 이 코어 모듈이라면 라이브러리 채로 패치해야 하고, 코드가 변경됬기 때문에 단위테스트도 다시 진행되야 할 것이다.
Command Pattern
이는 다음과 같이 수정할 수 있다.
const _printPrint = {
commands : {
'string' : () => '문자열',
'number' : () => '숫자',
'boolean' : () => '불리언'
},
print(value) {
return this.commands[typeof value]() + '입니다.';
}
}
function printType(value, _printPrint) {
return _printPrint.print(value);
}
JavaScript
복사
if, else if, switch와 같은 문을 식으로 변경하는 이러한 방식을 Command Pattern( Lambda )라고 한다.
새로운 타입이 생기더라도 더 이상 printType 를 직접 수정하지 않아도 된다.
그저 외부에 전달할 확장 가능한 객체를 수정할 뿐이다.
const _printPrint = {
commands : {
'string' : () => '문자열',
'number' : () => '숫자',
'boolean' : () => '불리언',
'symbol' : () => '심볼'
},
print(value) {
return this.commands[typeof value]() + '입니다.';
}
}
JavaScript
복사
원본 코드도 전혀 바뀌지 않았기 때문에 코어 코드에 대해서 새로운 테스트를 할 필요도 없다.
RA의 Resource역시 적절한 데이터 패치를 찾아가기 위해 Command Pattern을 사용하고 있다.
마치며
RA는 이렇게 컴포넌트별로 적절한 Data fetch를 실시하면서도, 확장성 있게 만들어졌다.
코드를 자세히 살펴보기 전에는 Data Provider Architecture 라는 하나의 디자인 패턴이 만들어낸 기술처럼 보였다.
하지만 코드를 자세히 살펴보니 단순히 하나의 패턴이나 기술이라기보다는 여러 구조가 유기적으로 동작하면서 만들어낸 정말 하나의 시스템( Adapter Injection System )처럼 느껴졌다.
Admin-Resource 컴포넌트 구조도 Context와 Redux로 만들어 낸 상태 관리 구조도 하나만으로는 이런 시스템을 만들지 못했지 않았을까?
그렇게 생각하고 나니 RA는 더욱 대단한 어플리케이션임을 느꼈고 나는 더욱 한없이 작은 존재라는 것을 깨닫는당.
끝.