신입사원 개발 정복기 #4. 숨 참고 롯데 다이브, 팀 5IVE의 다이빙 일지
추신. Frontend Lead 역할을 맡아 모든 프론트엔드 개발을 담당했습니다. 나머지 팀원은 DevOps 1명, Backend 3명으로 총 5명이었습니다.
React에서 Next.js로 프론트엔드 적용기
1. 왜 Next.js를 사용했는가?
이번 프로젝트에서 저희 조는 React를 사용하지 않고 Next.js를 사용했습니다. Next.js를 사용한 이유는 3가지가 있습니다.
1.1 CSR 중심에서 CSR & SSR로의 트렌드 변화
몇 년전까지만 해도 SPA(Single Page Application)가 득세하며, CSR(Client Side Rendering)이 대세였습니다. 하지만, CSR이 가진 단점을 보완하기 위해 SSR로 일부 돌아오는 모습을 보면서 이 둘을 적절히 구현해보고 싶었습니다.
1.2 Next.js의 매력적인 기능들
Next.js는 매우 다양한 기능들을 제공합니다. 그 중에서 Hydration과 Routing이 두 기능이 가장 흥미로웠습니다. Next.js에서는 Pre-rendering 후 Hydration 과정이 일어나기 때문에 렌더링 속도 관점에서 보았을 때, 성능적으로 좋아보였습니다.
1.2.1 Next.js의 Hydration
Next.js는 클라이언트에게 웹페이지를 보내기 전에 Server Side 단에서 미리 웹페이지를 Pre-Rendering 합니다. 그리고 Pre-Redering으로 인해 생성된 HTML document를 클라이언트에게 전송합니다. 그런데, 이 때 클라이언트(브라우저)가 받은 웹페이지는 단순히 웹화면만 보여주는 HTML일 뿐이고, 자바스크립트 요소들이 하나도 없는 상태입니다. 웹화면을 보여주고는 있지만, 특정 JS모듈뿐 아니라 단순 클릭과 같은 이벤트 리스너들이 각 웹페이지의 DOM요소에 하나도 적용되어 있지 않은 상태입니다. 그러면 이렇게 페이지만 보여주고 동작조차 하지 못하는 마치 빈껍데기 같은 웹페이지가 어떻게 정상적으로 동작하게 되는 것일까요? Next.js Server에서는 Pre-Rendering된 웹 페이지를 클라이언트에게 보내고 나서, 바로 리액트가 번들링된 자바스크립트 코드들을 클라이언트에게 전송합니다. 브라우저 개발자 도구를 열어 네트워크 탭을 보면, 맨 처음 응답 받는 요소가 document Type의 파일이고, 이후에 React 코드들이 렌더링 된 JS파일들이 Chunk 단위로 다운로드 되는 것을 확인할 수 있습니다. 그리고 이 자바스크립트 코드들이 이전에 보내진 HTML DOM 요소 위에서 한번 더 렌더링을 하면서, 각자 자기 자리를 찾아가며 매칭이 되는 것입니다. 이 과정을 Hydrate라고 부르는데, 마치 자바스크립트 코드들이 DOM 요소 위에 물을 채우 듯 필요로 하던 요소들을 채운다 하여 Hydrate(수화)라는 용어를 쓴다고 합니다.
1.2.2 Next.js의 Routing
React에서는 routing path를 일일이 선언해주어야 합니다. 페이지 수가 적다면 문제가 없겠지만, 페이지 수가 증가하게 될 경우, 가독성 문제 등 사용에 불편함이 생길 것이라고 생각했습니다. 반면에 Next.js의 경우 pages 폴더에 파일을 생성해주기만 하면 해당 주소로 routing이 됩니다.
1.3 학습에 대한 열망
React는 다루어 본 경험이 있었기 때문에, 이번 프로젝트에서는 전에 다루어보지 않은 것을 한번 학습해서 수행해보고 싶었습니다. 결과적으로, 새로운 경험 및 학습 차원에서 좋은 선택이었습니다.
2. 프로젝트에서의 CSR, SSR, SSG
2.1 각 Rendering 방식의 단점(CSR vs SSR vs SSG)
2.1.1 CSR 방식의 단점
초기 Javascript 파일을 전부 로드한 후, 뷰를 구성해야하기 때문에 어플리케이션이 커질수록 구동시간이 점점 느려집니다. 마찬가지로 Javascript 파일을 전부 로드해야, 페이지 정보를 구성할 수 있으므로 SEO(Search-Engine-Optimization)에도 취약한 문제가 있습니다.
초기 사이트 진입 시간이 사용자의 이탈률에 크게 영향이 있는 만큼 PWA(Progressive-Web-App)이 아닌 이상, 치명적인 단점입니다.
2.1.2 SSR 방식의 단점
페이지를 이동할 때마다, 서버에서 렌더링해주는 새로운 파일을 받기 때문에 페이지 전환 시에 깜빡임 현상이 존재합니다. 새로운 파일을 받아서 다시 필요한 파일을 로드하는 것이기 때문에, 클라이언트단에서 메모리에 데이터를 유지할 수가 없습니다. 이는 SSG 방식도 마찬가지입니다.
물론, SEO 문제는 해결할 수 있지만, SSR 방식으로만 어플리케이션을 구성하기에는 사용자가 늘어날수록 서버의 부하가 점점 커지기도 하고, 사용자 경험적으로도 한계가 존재하는 방식이라고 생각이 듭니다.
2.1.3 SSG 방식의 단점
이미 Pre-rendering된 정적 파일이 있으므로, 서버에서는 단지 그 파일을 클라이언트로 전달해줄 뿐이어서 정말 빠릅니다. 그렇지만, 웹 서비스에 존재하는 수많은 페이지들을 전부 정적 파일로 만들어주기에는 현실적으로 무리가 있어 보입니다.
2.2 상황에 따른 적절한 Rendering 방식 사용
앞서 살펴 보았듯이, Rendering 방식마다 각각 장단점이 있습니다. 그래서 이번 프로젝트에서는 하나의 방식만 사용하는 것이 아니라 상황에 따라서 Rendering 방식을 다르게 사용해보고 싶었습니다.
그렇다면 어떤 기준으로 나누는 것이 좋을까요?
초기 렌더링과 이후 렌더링을 나눠보자
여기서 저희 조가 생각했던 부분은 바로 렌더링 속도와 서버의 부하였습니다. 빠른 초기 렌더링과 정적인 부분은 서버측에서, 클라이언트와의 교류가 많은 부분은 클라이언트측에서 렌더링이 이루어지도록 했습니다.
2.2.1 서버
이번 프로젝트에는 많은 페이지들이 있습니다. 그 중에서 대표적으로 서버측에서 렌더링이 이루어지는 페이지를 언급하자면, 메인페이지와 상품상세페이지가 있습니다.
특히, 쇼핑몰에서 중요한 것은 메인페이지를 조금이라도 더 빨리 사용자에서 노출하는 것이 중요하다고 생각했기 때문에 서버측에서 렌더링해서 사용자에서 보여주고 싶었습니다. 그래서 SSG방식을 사용했습니다.
그리고 상품상세페이지 또한 사용자에서 빠르게 노출할 필요가 있고 정해진 데이터를 보여주는 부분을 SSR방식을 사용했습니다.
2.2.2 클라이언트
초기 렌더링 이후에는 SSR과 SSG 방식이 CSR방식에 비해 가지는 장점이 없다고 판단했습니다. 그래서 초기 렌더링 이후에 클라이언트와의 교류가 많은 부분은 CSR방식으로 렌더링이 되도록 구현했습니다.
프로젝트의 SSR(λ), SSG(o) 페이지
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export async function getServerSideProps(context) {
const { id } = context.query;
const res = await axios.get(
`${process.env.NEXT_PUBLIC_API_PATH}/api/products/${id}`,
);
const product = await res.data;
const reviewRes = await axios.get(
`${process.env.NEXT_PUBLIC_API_PATH}/api/reviews/${id}/all`,
);
const reviewCount = await reviewRes.data.reviewCount;
return {
props: {
product,
reviewCount,
},
};
}
상품상세페이지 SSR(getServerSideProps) 코드
3. 컴포넌트는 어떻게 구성하는게 좋은걸까?
FE개발자라면 의견이 분분하고, 항상 고민하는 것 중에 하나가 아마 컴포넌트가 아닐까라고 생각합니다. 이번 프로젝트를 수행하면서도 다양한 시도가 있었습니다…
컴포넌트의 구조, 역할, 크기 등등 여러가지 사항을 고민을 하면서 다양한 자료들을 찾아봤습니다. 그러던 중, 고민 끝에 아래의 디렉터리 구조에서 Atomic 디자인을 적절하게 이용해서 사용하기로 결정했었습니다.
1
2
3
4
5
6
7
8
9
|-- comonents => 공통 컴포넌트 관리
|-- hooks => 공통 hooks 관리
|-- layouts => 레이아웃 틀 컴포넌트 관리
|-- modules => api feature 단위 관리
|-- pages => router 페이지 관리
|-- public => assets 파일 관리
|-- typings => declare, global 타입 관리
|-- utils => 중복 로직 함수들을 pure 함수화 하여 util 파일 관리
|-- views => pages 폴더에서 사용하는 페이지 뷰 컴포넌트 관리
컴포넌트의 역할은 UI만 그리는 역할을 가지도록 하기 위해서 Layout부분을 따로 두어 모든 데이터를 호출하여 하위 컴포넌트에 전달하도록 하는 구조를 만들었습니다. 그 결과, 부모에서 자식으로 가는 props가 증가하였고, 계층이 깊어질수록 data 유실의 가능성도 상승하습니다. 그리고 마지막으로, 가장 치명적이었던 것은 Layout부분에서 초기에 모든 데이터를 호출하다보니 성능면에서도 문제가 생겼습니다. 그래서 결국, 개별 컴포넌트에서 데이터를 호출하는 형식으로 변경했습니다. 이 부분은 고민을 참 많이했지만, 앞으로도 개선의 여지가 많은 부분이라고 생각합니다.
상품상세페이지의 다양한 데이터들
4. SKELETON UI
마지막으로 소개시켜드릴 부분은 Skeleton UI입니다. React가 18버전이 되면서 새로운 기능들이 생겼습니다. 그 기능 중 저희는 Suspense 기능을 사용했습니다. 데이터가 로딩이 되지 않으면 fallback 컴포넌트를 보여주게 되는데 이 부분을 Skeleton UI로 구현했습니다. 이렇게 Suspense 기능을 사용하게 되면 렌더링을 순차적으로 할 수 있고, 데이터 렌더링과 UI 렌더링을 분리할 수 있다는 점에서 장점을 가지게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const ProductLists = () => {
const data = resource.productList.read();
useEffect(() => {
setLastPage(data?.pageInfo.lastPage);
setTotalHits(data?.totalHits);
}, [data]);
return (
<>
{data?.data?.length > 0 ? (
<ul className="productList">
{data?.data?.map((product) => {
return (
<li className="productInner" key={product.id}>
<div className="productBox">
<ProductRecCard
id={product.id}
imgsrc={product.imageUrl}
name={product.name}
price={product.price}
discount={product.discount}
/>
</div>
</li>
);
})}
</ul>
) : (
<NoSearch />
)}
</>
);
};
----------------------------------------------------------------------
<Suspense fallback={<div><ProductSkeleton /></div>}>
{resource && <ProductLists />}
</Suspense>







