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을 더 잘 사용해야겠다고 깨달았습니다.