
2022.01.07 ~ 2022.01.28
이번 프로젝트는 특별한 기능 구현보다는 공부에 목적을 둔 프로젝트이며 목표는 아래와 같습니다.
이론으로만 공부한 리액트와 리덕스를 제대로 사용하는 법을 숙지하고 프론트엔드 개발자로서 신경써야하는 부분을 생각하며, 완성도에 집중하고자 하였습니다.
컴포넌트를 분리하는 기준에 대해 명확하게 알기
렌더링 최적화
웹 접근성
SEO (Search Engine Optimization)
Redux로 상태관리와 비동기 통신 제어하기
Yelp API에서 받는 데이터를 기반으로 레스토랑 리스트를 구현했습니다.
레스토랑 리스트를 기반으로 그려지는 지도를 구현했습니다.
hover시 map marker highlighting
map marker에 hover시 highlighting + restaurant info 제공
cra에서 안내하는 proxy 사용법package.json
{
...
"proxy": "http://localhost:4000",
...
}
cra에서는 package.json에 위와 같은 내용을 추가하면 fetch('/api/todos');와 같은 요청에 위에서 설정한 origin으로 대체해준다고 기술되어 있습니다.
proxy서버가 필요했던 이유처음에는 client측에서 요청을 보냈을 때 응답을 잘 해주는지 확인하기 위해 알맞는 URI를 모두 입력하고 요청을 보내보았으나 CORS에러가 발생했습니다.
yelp에서는 client측의 요청에 CORS Header를 제공하지 않기 때문에, 즉 Access-Collect-Allow-Origin을 제공하지 않기 때문에 CORS에러가 발생합니다.
때문에 우리 팀은 proxy서버를 통해 요청을 우회하고자 하였습니다.
proxy에 대한 오해리액트에서 proxy 설정을 하면 리액트 앱이 내부적으로 proxy서버를 구동하여 별개의 서버가 존재하는 줄 알았습니다.
Port번호를 proxy로 두고 사용했으나 proxy에러가 발생했습니다.별도의 서버가 돌아가는 것이 아니라면 origin 그 자체를 바꿔 요청을 보내는 줄 알았습니다.
yelp API의 origin으로 proxy를 설정하였으나 역시 CORS 에러가 발생했습니다.cra에서 사용하는 proxy 옵션의 역할은 cra에서 기술하는대로 요청 시 origin을 대체하여 주는 것입니다.
때문에 위에서 언급한 오해 1번은 origin은 대체했으나 존재하지 않는 서버에 요청을 보냈기 때문에 에러가 발생했고, 오해 2번은 origin을 대체해서 제대로 요청을 보냈지만 본질적으로 yelp에서 CORS Header를 제공하지 않으니 초기의 문제와 다르지 않은 상황이므로 CORS에러가 발생했던 것입니다.
CORS Header를 제공해주는 proxy 서버를 구축하고 proxy서버에서 요청을 받으면 client에게 데이터를 전송해주는 방식으로 수정했습니다.
cra의 proxy 옵션은 서버를 구동하거나 CORS을 해결하기 위해 존재한다기 보다는 요청 시에 origin을 바꿔주는 역할만 할 뿐입니다.
redux에 대한 이해가 부족한 상태에서 비동기 통신을 하려했기 때문에 redux에서 비동기 통신을 하기 위해서는 반드시 middleware를 사용해야 하는 줄 착각했습니다.redux 아키텍처redux 아키텍처 자체는 어려운 개념이 아니었습니다, 단순히 store는 상태를 관리하고 dispatch에 action객체를 전달해 reducer를 통해 action을 수행하는 형식의 간단한 구조입니다.
middleware?redux의 비동기 통신에 대한 예제를 설명할 때 대부분 middleware를 사용하며 설명하기에 redux에서 비동기 통신을 하기 위해선 반드시 middleware를 사용해야 하는 것이라고 착각했습니다.
하지만 middleware는 비동기 작업을 동기처럼 작업하기 위해 존재하는 것 뿐입니다.
단순히 비동기 통신을 하기 위해선 Promise의 후속 처리 메서드 then 내부에서 dispatch를 사용하거나 async, await을 사용한다면 데이터를 받은 후 dispatch를 하는 로직만 작성하면 됩니다.
그렇게 되면 비동기 작업이 완료된 후 dispatch가 실행되기 때문에 비동기 작업이 완료되었을 때 store를 업데이트 할 수 있습니다.
redux에서 비동기 통신을 하기 위해서는 middleware가 필요한 것이 아니다.middleware를 사용해야 한다.storybook으로 e2e test를 했습니다.e2e test를 진행했으나 간단한 기능만으로도 e2e test의 편의성을 알 수 있었습니다.이번 프로젝트는 단순히 리액트를 사용하는 것에 목적을 두는 것이 아닌 리액트를 잘 사용해보자라는 취지로 진행한 프로젝트 였기에 컴포넌트 설계부터 신경썼습니다.
저희가 컴포넌트 분리 기준을 atomic 관점으로 잡은 이유는 커스텀 훅을 잘 활용하면 상태에 따라 분류하는 과정이 용이해지게 되므로 표현 위주의 컴포넌트를 구현하게 되는데 이 과정에서 쟁점은 어떻게하면 표현 컴포넌트의 재사용성을 극대화하고 유지보수, 즉 잘게 쪼개진 컴포넌트의 탐색이 용이하게 할 수 있을까 였습니다.
이런 관점에서 태그의 집합 단위로 잘게 쪼개어 레이아웃과 페이지까지 그룹화할 수 있는 atomic이 표현 컴포넌트를 분리하는 기준으로 적합하다고 생각했습니다.
├─components
│ ├─common
│ ├─atoms
│ ├─molecules
│ ├─organisms
│ ├─templates
│ └─utils
├─hooks
├─pages
하나의 태그부터 레이아웃까지의 단계를 담은 폴더이며, 재사용성이 적은 페이지보다 재사용성을 염두에 둔 컴포넌트들의 집합입니다.
atoms의 집합molecules를 구성하는 atom은 atoms 폴더에 반드시 존재해야하는 것은 아님atoms와 molecules의 집합atoms와 molecules, organisms의 집합organisms보다 낮음templates단위에 포함되지만 독립적으로 분리되는 레이아웃이라기 보다는 페이지에 공통적으로 사용되는 레이아웃
atomic관점의 컴포넌트 분리를 표현적 관점에서 가능하도록 만든 핵심 폴더입니다.
커스텀훅을 구현하여 hooks 폴더에서 관리하며 atomic 관점에서 분리된 컴포넌트들은 hooks에 구성되어 있는 커스텀 훅에 의존합니다.
이렇게 하면 커스텀 훅을 사용하는 컴포넌트가 해당 상태에 의존하는 컴포넌트이므로 상태에 따른 분류가 명확해집니다.
재사용관점보다는
components가 모여 이뤄낸 하나의 집합체, 즉 사용자에게 하나의 구성품으로 제공되는 페이지들의 집합체입니다.
해당 폴더를 components에서 관리하지 않고 분리한 이유는 아래와 같습니다.
router로 경로를 관리하는데 pages 폴더를 분리하면 routing되는 페이지를 명확하게 알 수 있습니다.pages는 components가 의미하는 부품이라는 느낌보다는 부품이 모여 완성된 하나의 구성품이므로 분리를 하는 것이 논리적으로 옳다고 생각했습니다.Code splitting
Router를 기준으로 React.lazy를 사용하여 하나의 bundle을 splitting하였습니다.
const App = lazy(() => import('pages/App/App'));
const SearchPage = lazy(() => import('pages/SearchPage/SearchPage'));
const DetailPage = lazy(() => import('pages/DetailPage/DetailPage'));
const PageNotFound = lazy(() => import('pages/PageNotFound/PageNotFound'));
ReactDOM.render(
<StrictMode>
<GlobalStyle />
<HelmetProvider>
<StoreProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Suspense fallback={<Circles />}><App /></Suspense>}>
<Route index element={<Navigate to={defaultQuery} replace={true} />} />
<Route path="search" element={<Suspense fallback={<Circles />}><SearchPage /></Suspense>} />
<Route path="restaurant/:id" element={<Suspense fallback={<Circles />}><DetailPage /></Suspense>} />
<Route path="page-not-found" element={<Suspense fallback={<Circles />}><PageNotFound /></Suspense>}/>
<Route path="*" element={<Navigate to="page-not-found" replace={true} />} />
</Route>
</Routes>
<InitSVG />
</BrowserRouter>
</StoreProvider>
</HelmetProvider>
</StrictMode>,
document.getElementById('root')
);
data lazy Loading
Restaurant Card에서 사용되는 데이터를 받아올 때 Observer API를 사용하여 화면 상에 보이지 않는 Card는 데이터를 천천히 받아오게 하였습니다.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
axios(`http://localhost:4001/api/businesses/${id}`).then(
({ data: { restaurantDetail, restaurantReview } }) => {
let { hours } = restaurantDetail;
hours = hours || [{ is_open_now: null, open: [initialOpen] }];
const { reviews } = restaurantReview;
const { is_open_now: isOpenNow, open } = hours[0];
if (!open.find(({ day }) => day === today)) open.push(initialOpen);
const { start, end } = open?.find(({ day }) => day === today);
setReview(reviews[0].text);
setOperationState({ isOpenNow, start, end });
}
);
observer.unobserve(ref.current);
});
}, observerOptions);
observer.observe(ref.current);
React.memo
memoization된 컴포넌트를 반환하도록 도와주는 React.memo를 활용하여 props이 변경되지 않는다면 리렌더링하지 않도록 제어하였습니다.
RTK Query
비동기 처리된 결과를 캐싱해주는 SWR을 차용한 Redux-toolkit의 RTK Query를 사용하여 데이터 제어하였습니다.
useEffect의 dependency 배열 관리
컴포넌트의 업데이트 기준이 되는 state 혹은 props를 dependency배열에서 정확히 지정하여 불필요한 리렌더링을 방지하였습니다.
컴포넌트 내부에서 head 태그에 접근할 수 있는 react-helmet-async를 사용하여 검색엔진의 탐색을 도와주는 meta태그를 작성하여 검색엔진에 최적화하도록 노력했습니다.
SearchPage<Helmet>
<title>The Best 10 Restaurants in {location}</title>
<meta name="description" content={`The Best 10 Restaurants in ${location}`} />
<meta name="robots" content="ALL" />
<meta name="keywords" content={`yalp, restaurant, ${location}`} />
<meta name="author" content="Dumboz" />
<meta name="content-language" content="en" />
<meta httpEquiv="content-type" content="text/html; charset=en" />
<meta name="keywords" content={keywords} />
</Helmet>
DetailPage<Helmet>
<title>{restaurantDetail?.name || 'Detail Page'}</title>
<meta name="robots" content="ALL" />
<meta name="keywords" content={`yalp, restaurant, ${restaurantDetail?.name}`} />
<meta name="author" content="Dumboz" />
<meta name="content-language" content="en" />
<meta httpEquiv="content-type" content="text/html; charset=en" />
<meta name="description" content={restaurantDetail?.name || 'Detail Page'} />
</Helmet>
매일 Daily Scrum을 Wiki에 기록하며 팀원이 한일과 자신이 한일을 명확하게 할 수 있도록 하며, 목표를 확실히 하여 프로젝트의 진행을 원활하게 하였습니다.
프로젝트를 진행하며 기술적으로 어려웠던 부분은 기술로그에 정리하며 어려웠던 부분을 정리하며 지식을 확실히하고 복기할 수 있도록 하였습니다.
task단위로 commit을 작성하여 어떤 Issue에서 무슨 일을 했는지 자세히 알 수 있도록 하였습니다.
평소 특별한 생각없이 git flow를 통해 feature 브랜치를 생성하고 사용했는데 이번에 revert를 해야하는 상황을 마주하며 브랜치 관리의 중요성을 깨달았습니다.
이때 만약 브랜치를 생성하고 따로 작업을 했다면 revert할 필요 없이 기존 브랜치로 돌아가 작업하던 브랜치를 삭제하면 되는 간단한 작업이었을테지만 브랜치를 세세하게 분리하지 않으니 revert같은 명령어를 사용해야하는 상황이 생겼습니다.
앞으로는 상세한 branch 관리를 통해 git을 더 잘 사용해야겠다고 깨달았습니다.