들어가기 전
푸드피커(https://foodpick.co.kr/)라는 거창한 이름과 달리 짤막한 프로젝트 재개발을 들어가기 전과 후에 경험했던 문제들을 정리한 포스트 입니다. 리액트과 익스프레스를 사용해서 개발이 되었고, 초기 개발 시(2023년 7월) 에는 문서 정리를 제대로 하지 않아서 비교적 최근 기점(2023년 12월 이후, 2024년 5월 기점)으로 정리된 내용이 들어 갔습니다.
사소한 문제일지라도 해결에 어려움을 겪고 있거나, 우연히도 동일한 문제를 경험하는 분이 있다면, 언제가 되었든 참고할 수 있는 그런 글이 되기를 바라며 기록으로 남겨봅니다. 다만, 포스트의 퀄리티는 보장하지 못합니다. 부족한 부분은 계속해서 개선해 나가보도록 하겠습니다.
페이지네이션을 통한 페이지 변경 시 레이아웃 깨짐 및 깜빡임 개선
※ 이 문제는 도구(사용된 도구는 Tantack-query/react)가 아무리 다양한 기능을 잘 갖춰 놓았다 해도, 그걸 사용하는 사람이 모른다면 여러 이점을 놓칠 수도 있다는 점, 이것이 사용자 경험의 저해로 이어질 수 있음을 많이 느꼈던 문제였습니다.
,문제상황
기존의 Tanstack-query/react의 useQuery 로 구현된 페이지네이션을 통해 페이지 변경이 발생하면, 레이아웃이 깨지고, 새로운 데이터가 화면에 표시되기 전에 로딩 스피너가 표시되면서 깜빡이는 문제가 발생하여 사용자 경험이 저해되는 문제를 경험 하였습니다.
크롬 하우트 하우스의 성능을 측정해보니 CLS 가 0.277로 좋은 사용자 환경을 제공하려면 사이트에서 CLS 점수가 0.1 미만이어야 하는 조건에 크게 벗어나는 문제로 판단되는 상황입니다.
,개선과정
원인 분석
해당 문제가 발생한 원인은 useQuery 의 queryKey 로 등록된 페이지 인덱스가 변경됨에 따라 새로운 데이터를 서버로 부터 받아오면서 그 전 까지 화면에 표시할 데이터가 존재하지 않아서 생기는 문제였습니다. 보통 데이터를 페치하면 로딩 상태가 pending 과 success 를 반복하게 되는데, 페이지 인덱스가 변경되면 앞서 단계가 반복됨에 따라 로딩 스피너를 계속해서 보여주는 것으로 추정 되었습니다.
placeholderData 와 keepPreviousData 적용
해당 문제를 개선하기 위한 옵션으로 Tanstack-query/react 에서는 placeholderData 와 keepPreviousData 를 제공해주고 있어서 이를 사용하기로 하였습니다.
placeholderData 는 새로운 데이터를 화면에 그리기 전에 임시적으로 사용자에게 보여줄 데이터를 설정하는 옵션이고, keepPreviousData 는 새로운 데이터 페치 이전에 성공하여 받아왔던 데이터를 담고있는 객체입니다.
따라서 이를 활용하여 코드에 적용하였습니다.
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { ApiType, getDefaultFetcher } from '../api/get.api';
import { toast } from 'react-toastify';
/**
* 내부 백엔드 호출용 리액트 쿼리
* @param key 예) useQuery의 식별키 ['localfood', 5]
* @param url 예) fetch 요청 주소 '/localfood?page=1'
* @returns
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function useDefaultQuery(key: any[], url: string) {
const { data, isPending, isError, error, isFetching, isPlaceholderData } =
useQuery({
queryKey: key,
queryFn: () => getDefaultFetcher(url, ApiType.INTERNAL),
placeholderData: keepPreviousData,
});
if (isError) toast.error('데이터 조회에 실패하였습니다.');
return { data, isPending, isError, error, isFetching, isPlaceholderData };
}
한 가지 더 고려해볼 점 : 요소의 최소 높이와 넓이
현재 사례하고는 큰 연관이 없지만, 여기서 한 가지 더 고려해야 하는 부분이 있습니다. 캐싱이 전혀 되어 있지 않은 상태에서 페이지를 접속하는 경우에는 목록이 렌더링 되는 경계의 최소 높이를 지정하였냐의 부분입니다. 저의 경우에는 이를 고려하였음에도 형제 요소 끼리 레이아웃을 침범하는 문제가 발생하여 CLS 가 나쁘게 나왔지만, 목록이 렌더링 되는 컴포넌트가 가지는 최소 높이나 넓이 등이 작더라도 CLS 가 나쁘게 나올 수 있습니다.
따라서 이 부분은 여러 관점에서 문제를 생각해보는 것이 중요하다고 봅니다.
'성과
결과적으로, 더 이상 페이지 인덱스가 변경되어도 화면이 깜빡이는 문제가 개선되었고, 크롬 라우트 하우스 측정 결과 초기 CLS 0.277 에서 0.018 로 약 93.5% 정도 개선되는 성과를 얻었습니다. _ㅇ
[렌더링 이슈, 이미지 크기] 이미지 리소스 최적화
※ HACCP 페이지는 카테고리를 제외하고도 상품목록 전체가 이미지를 사용하는 만큼 페이지 방문시 로드 되는 이미지가 많은 상황입니다. 이 때 29개의 카테고리 이미지가 최적화가 안 된 상태에서 각기 사용되었고, 이로 인해 3.8MB 라는 거대한 이미지 크기가 문제가 되어 이를 개선했던 문제입니다.
.문제상황
Haccp 페이지의 경우 사용자가 각 식품의 유형을 쉽게 식별할 수 있도록 이미지를 배경으로하는 카테고리를 사용중입니다. 29개의 음식 카테고리가 있고, 초기에는 각 항목 마다 이미지를 제공하여, 렌더링할 수 있도록 처리하였습니다.
그러나 배포 이후에 간혈적으로 이미지 렌더링이 지연되는 문제가 발생했습니다. 네트워크 탭을 확인해보니 29개의 이미지가 총합 약 3.8MB 크기로 다운로드 되고 있었고, 그 외에도 각 카테고리에 해당하는 식품 목록 100개의 아이템이 다운로드가 되면서 문제를 일으킨 것으로 추측되는 상황입니다.
.개선과정
개선 방법 선택 | 이미지 스프라이트 기법 선택
29개의 이미지라고 하더라도 애초에 최적화된 이미지를 각각 사용했다면, 3.8MB 까지 커지지는 않았을 겁니다. 예를 들어, 각 카테고리별로 사용되는 이미지의 크기에 맞게 이미지를 자르고, webp 와 같은 최적화가 기본으로 적용된 확장자를 사용하는 등의 방법이 있습니다.
따라서 이 문제를 개선하는 가장 단순한 방법은 최적화되지 않는 29개의 이미지를 각각 최적화하여 크기를 줄이는 방법 입니다. 하지만, 이미지를 다운로드 하는 과정(리소스 요청) 자체는 줄이지 못합니다. 또한 캐싱이 개별적으로 이루어지기 때문에, 단일 이미지에 비해서 캐싱 효율성도 떨어지는 편입니다.
저는 여러 방법 중 이미지 스프라이트 기법을 곧바로 적용하기로 결정했습니다. HACCP 페이지의 카테고리의 이미지를 한 번 설정해두면 수정될 일이 없기 때문에, 단일 이미지로 처리하는 것이 HTTP 요청 수를 감소시키고, 한 번의 캐싱으로 이미지를 재사용할 수 있는 이점이 크다고 판단하였기 때문입니다.
피그마 작업 및 Export
이미지 스프라이트는 결국 하나의 이미지를 여러 아이템에 재사용하는 기법이기 때문에, 각 이미지 파일을 피그마로 가져와서 일정한 간격에 따라 이미지를 배치하였습니다.
완성된 이미지는 Export 하여 내보내주고, 프로젝트 폴더의 pulib 경로에 보관해주는 작업 까지 합니다.
이제 이 이미지를 활용하여 각 카테고리에 알맞은 위치로 배치하는 작업을 CSS로 해주면 됩니다.
CSS 작업
각 카테고리의 배경에 정상적으로 이미지가 표시될 수 있도록, 배치 작업을 시도해줍니다. 참고로, 아래는 실제 사용된 코드입니다.
/* CategoryGrid.module.scss*/
/** 이미지 스프라이트 처리 */
// 양념육
.haccp_category_grid_cell:nth-of-type(2) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -1);
}
}
// 김치
.haccp_category_grid_cell:nth-of-type(3) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -2);
}
}
// 빵
.haccp_category_grid_cell:nth-of-type(4) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -2.98);
}
}
// 과자
.haccp_category_grid_cell:nth-of-type(5) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -3.96);
}
}
// 가공
.haccp_category_grid_cell:nth-of-type(6) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -4.95);
}
}
// 혼합장
.haccp_category_grid_cell:nth-of-type(7) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -5.95);
}
}
// 음료
.haccp_category_grid_cell:nth-of-type(8) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -6.95);
}
}
// 소스
.haccp_category_grid_cell:nth-of-type(9) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -7.93);
}
}
// 조미김
.haccp_category_grid_cell:nth-of-type(10) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * 0);
top: calc(var(--default-interval-top) * -0.99);
}
}
// 신선편의식품
.haccp_category_grid_cell:nth-of-type(11) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -1);
top: calc(var(--default-interval-top) * -0.99);
}
}
// 청국장
.haccp_category_grid_cell:nth-of-type(12) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -1.98);
top: calc(var(--default-interval-top) * -1);
}
}
// 고춧가루
.haccp_category_grid_cell:nth-of-type(13) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -2.975);
top: calc(var(--default-interval-top) * -0.99);
}
}
// 절임
.haccp_category_grid_cell:nth-of-type(14) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -3.98);
top: calc(var(--default-interval-top) * -0.99);
}
}
// 천일염
.haccp_category_grid_cell:nth-of-type(15) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -4.95);
top: calc(var(--default-interval-top) * -0.99);
}
}
// 소금
.haccp_category_grid_cell:nth-of-type(16) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -5.95);
top: calc(var(--default-interval-top) * -0.99);
}
}
// 건조저장육류
.haccp_category_grid_cell:nth-of-type(17) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -6.93);
top: calc(var(--default-interval-top) * -0.99);
}
}
// 수산물
.haccp_category_grid_cell:nth-of-type(18) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -7.93);
top: calc(var(--default-interval-top) * -0.99);
}
}
// 농산물조림
.haccp_category_grid_cell:nth-of-type(19) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * 0);
top: calc(var(--default-interval-top) * -1.98);
}
}
// 어묵
.haccp_category_grid_cell:nth-of-type(20) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -1);
top: calc(var(--default-interval-top) * -1.98);
}
}
// 햄
.haccp_category_grid_cell:nth-of-type(21) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -2);
top: calc(var(--default-interval-top) * -1.98);
}
}
// 치즈
.haccp_category_grid_cell:nth-of-type(22) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -2.98);
top: calc(var(--default-interval-top) * -1.98);
}
}
// 발효유
.haccp_category_grid_cell:nth-of-type(23) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -3.95);
top: calc(var(--default-interval-top) * -1.98);
}
}
// 곡류
.haccp_category_grid_cell:nth-of-type(24) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -4.95);
top: calc(var(--default-interval-top) * -1.98);
}
}
// 즉석조리식품
.haccp_category_grid_cell:nth-of-type(25) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -5.95);
top: calc(var(--default-interval-top) * -1.98);
}
}
// 빙과
.haccp_category_grid_cell:nth-of-type(26) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -6.95);
top: calc(var(--default-interval-top) * -1.98);
}
}
// 고추장
.haccp_category_grid_cell:nth-of-type(27) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -7.95);
top: calc(var(--default-interval-top) * -1.98);
}
}
// 베이컨
.haccp_category_grid_cell:nth-of-type(28) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -0.01);
top: calc(var(--default-interval-top) * -2.999);
}
}
// 참기름
.haccp_category_grid_cell:nth-of-type(29) {
.haccp_category_grid_cell_img {
left: calc(var(--default-interval-left) * -1);
top: calc(var(--default-interval-top) * -2.99);
}
}
저의 경우 CSS 의 calc 메소드와 사용자 정의 변수인 --default-interval-left 와 top 에 미리 정해둔 규격을 지정해두고, 이를 재사용하는 방식으로 작업은 단순화하였습니다. 비록 29장 모두를 조정하는 작업을 거치는 것은 불가피했지만 말이죠.
적용 결과
완성된 이미지 스프라이트를 카테고리에 적용 하였습니다. 겉으로 보기에는 여러 이미지를 사용한 것 같아 보입니다.
하지만, 네트워크 탭을 통해 확인해보면 하나의 파일만 다운로드한 것을 볼 수 있습니다. 원래 라면 29장 모두를 일일이 다운로드 해야 했지만, 그럴 필요가 사라졌습니다.
.성과
결과적으로 이미지의 크기를 3.8MB 에서 534KB 까지 줄일 수 있었고, 네트워크 요청의 경우에도 29 회 에서 1회만 이루어지도록 개선되었습니다. 그리고 간혈적으로 이미지 로드가 지연되어 하얀색 배경이 잠시간 보이는 문제도 개선 되었습니다.
끝으로, 제가 경험한 해당 문제의 1차적인 문제는 최적화되지 않은 이미지를 그대로 사용한 점에서 시작했습니다. 인공지능을 사용하여 제작된 이미지라서 각 파일의 사이즈가 생성된 결과마다 일정하지 못한 것을 그대로 사용했던 점이 시작점이었던 만큼 사전에 방지할 수 있는 문제는 미리 방지할 수 있도록 처리하는 것이 좋음을 많이 느꼈던 문제였습니다.
[성능최적화] 루트 페이지 접속 시 렌더링이 지연되는 문제
※ 해당 문제를 경험했던 시기는 프로젝트를 재개발 하기 전 입니다. 코드 스플릿이라는 코드 분할 기법을 몰랐던 입장에서 주구장창 페이지를 늘려갔던 것이 화근이었고, 당시에 처음 접했던 기법을 적용하면서 나름대로 많은 깨달음이 있었던 문제 경험 이었습니다.
문제상황.
루트 페이지를 접속하면, 완성된 화면을 렌더링 하는데 오랜 시간이 걸리는 문제가 발생하였습니다. 개발 서버인 것을 감안하더라도 50점 밑의 점수는 성능 최적화가 반드시 필요한 부분이라 판단하였습니다.
그리고, 크롬 브라우저의 라이트 하우스로 성능 측정을 해보니 , 빌드 이후의 bundle.js 파일이 3.082.0 KiB로 매우 컸기에, 렌딩 페이지와는 연관이 없는 js 파일까지 다운로드 하는데 많은 시간이 소요된 것으로 보였습니다. 사실 체감상으로 본다면, 그리 느리게 느껴지지는 않았으나, 약간 지연된다는 느낌을 무시할 수는 없는 것 같습니다.
FCP 가 4.8초로 매우 느렸고, 그에 따라 LCP 의 경우도 5.5초, TBT의 경우 560 ms 로 그 동안 사용자가 사이트를 방문할 시 오랫동안 동작되지 않는 화면을 보게 되는 문제에 직면했습니다. 물론 TBT의 경우 1초 미만이라면 느린 편이라고 볼 수는 없으나, 사이트의 확장성 및 간단한 사이트임을 감안하면 이 정도 시간이 걸리는 것은 매우 좋지 못한 경우이라 보았습니다.
제가 이해한 각 용어의 정의를 풀어서 정리해보면 다음과 같습니다. |
FCP : 처음으로 의미 있는 요소가 화면에 그려지는데 걸리는 시간 LCP : 요소 중 가장 커다란 요소가 처음 화면에 그려지는데 걸리는 시간 TBT : 사용자가 UI 와 상호작용하는데 제한이 걸리는 총 시간 |
개선과정.
원인분석
결국 빌드 이후의 번들 파일이 컸기 때문에, 브라우저 입장에서는 사이트에 접속 시 그 커다란 파일을 모두 다운로드 함에 따라 지연시간이 발생한 것으로 보았습니다. 따라서 필요한 페이지 내에서만 해당 모듈을 import 하여 다운로드하도록 하는 코드 분할 기법을 적용하기로 하였습니다.
코드 분할이 무엇인지 이론상으로만 알았는데, 실제 적용하게 되니 어떤 변화가 생길지 많이 기대가 됩니다.
Lazy 와 Suspense | 리액트에서 제공하는 코드분할 기법을 위한 도구
리액트에서는 마침 코드 분할을 위해서 Lazy 함수를 이용한 동적 import 기능을 지원하고 있었고, 이와 함께 Suspense 컴포넌트를 활용하여 fallback 처리도 함께 할 수 있다는 이점을 바탕으로 이를 적용하기로 결정하였습니다.
코드 분할 기법 적용
react-router-dom 을 사용하고 있었기에, 라우트 별로 사용자가 해당 페이지에 접속할 때 파일을 다운로드할 수 있도록 라우트별로 lazy 함수로 동적 import 를 적용하고, 코드 분할을 실시하였습니다.
import { lazy,Suspense } from "react";
const Home = lazy(()=> import('../pages/Home/Home'))
const LocalFoodPage = lazy(()=> import('../pages/LocalFood/LocalFoodPage'))
const NutritionPage = lazy(()=> import('../pages/Nutrition/NutritionPage'))
const NecessitiesPage = lazy(()=> import('../pages/Necessities/NecessitiesPage'))
const HaccpPage = lazy(()=> import('../pages/Haccp/HaccpPage'))
const RecipePage = lazy(()=> import('../pages/Recipe/RecipePage'))
const RecipeDetail = lazy(()=> import('../pages/Recipe/RecipeDetail'))
const BmiPage = lazy(()=> import('../pages/Bmi/BmiPage'))
const router = createBrowserRouter([
{
path: "/",
element:
<Suspense fallback={<PageLoading/>}>
<Home/>
</Suspense>,
},
{
path: "/",
element: <Header isStyle={true} />,
children: [
{
path: "/localfood",
element:
<Suspense fallback={<PageLoading/>}>
<LocalFoodPage />
</Suspense>
,
},
// -- 중략 --
]);
export default router;
막상 적용하고 보니, 남길게 별로 없는 것 같네요. 어디에 적용하면 좋을지 고민을 많이 했었는데, 딱 router 를 담당하는 파일 내부가 적합해 보였고, 이에 적용 했습니다.
성과.
우선, 코드 분할 기법을 적용 결과 다음과 같이 FCP 는 1.3 초, LCP 는 2.1 초로 성능이 크게 개선된 것을 확인할 수 있었습니다. 최종적으로 개발 환경에서 성능 측정 점수는 34점 에서 77 점으로 큰 증가폭을 보였습니다.
결과만 보면, 사이트 렌더링 속도가 빨라졌고, 데스크톱/모바일 환경 전체적으로 렌딩 화면 접속 시 장시간 멈추는 문제를 개선할 수 있었습니다.
배포 환경에서 재측정 해보니 FCP, LCP, TBT도 크게 개선되었고, 성능 점수가 91점으로 증가된 것을 볼 수 있었습니다. 코드 분할 이전의 배포 환경에서 성능 점수 77 점을 추정한 것과 비교하면 많은 개선을 경험하였다고 볼 수 있을 것 같습니다.
[렌더링 이슈] 렌더링 된 지도의 그래픽(이미지) 중첩 문제[일부 해결 사례]
※ 스크립트 태그를 useEffect 내부에서 계속 생성하는 방식이 문제가 되었던 이슈 입니다. vite 의 경우 html 파일 내에서 script 를 사용 시 환경변수도 불러와서 사용할 수 있는데, 이 당시 해당 문제를 경험할 때는 그 방법을 몰랐으므로 뻘짓을 많이 했던 기억이 납니다. 다행히 현재는 해결되어 이슈 전체가 개선되었습니다.
문제상황..
지역별 향토음식과 시장을 소개하는 세부 페이지에서 해당 식당의 주소를 기반으로 지도에 마커를 표시하는 기능을 적용 했으나, 최초 렌더링 이후 지도에 하얀 화면이 중첩되어 표시되는 문제가 발생하였습니다
해결과정..
원인분석
해당 문제가 발생한 원인을 찾아보기 위해 크롬 개발자 도구의 Element 탭을 확인 해보니 다음과 같이 동일한 요소가 중첩으로 렌더링된 것을 확인할 수 있었습니다.
실제 해당 문제가 화면에 렌더링되는 지도에 어떠한 영향을 미치는지 검증하기 위해 두 번째 <div/> 태그에 임의로 스타일 속성 display:none 을 추가해 보았더니, 정상적으로 지도가 렌더링되는 것을 볼 수 있었습니다.
그럼 중복으로 그려지는 해당 태그가 생기는 부수적인 원인을 찾기만 하면 되겠네요. 빨리 원인을 찾아서 다행입니다.
음.. 그런데, 여기서 갑자기 드는 의문점
위 문제를 알고 나서 한 가지 들었던 의문점은 문제의 페이지에서 리렌더링을 하면, 정상적으로 지도가 표시되기도 했다는 점 입니다. 이에 리렌더링 마다 페이지에 지도를 그리는 div 요소가 어떻게 동작하는지 개발자 도구의 Element 탭에서 다시 확인해보기로 하였습니다.
계속 해서 중첩되고 있는 div 요소들
확인 결과 div 요소가 계속해서 중첩생성 되는 문제를 발견할 수 있었습니다
이 문제의 경우 코드에디터에서 파일 수정 시 생기는 중첩 문제이고, 다른 페이지에서 해당 페이지로 재접근 하는 경우에도 동일한 문제가 발생하고 있습니다. |
현재 까지 발견한 문제점은 두 가지 였습니다.
첫 번째, 최초 렌더링 시 정상적인 지도 위에 백지의 지도가 중첩으로 그려지는 문제 두 번째, 리렌더링 시(페이지 재방문시 등) 마다 지도 그래픽을 그리는 div 요소가 게속해서 생성되는 문제 |
이 중에서 제일 만만해 보이는 첫 번째 문제 부터 해결해 보겠습니다.
[문제 개선] 우선, 최초 렌더링 시 지도가 중첩되는 문제 부터
첫 번째 문제의 경우에는 여러가지 시도를 해본 결과 매개변수로 전달받은 값이 정상적인 값인지 아닌지를 분별하지 않고 값을 사용함에 따라 발생한 것이었습니다.
따라서 이른 반환을 통해서 조건을 통과하지 못하는 경우에는 애초에 useEffect 내에 로직이 실행되지 못하도록 차단하기로 하였습니다.
보시면, 위도(la), 경도(lo), 주소(address) 의 경우 임의로 정한 최소 기준치를 넘어서지 못하면 지도를 렌더링하는 로직이 실행되지 못하도록 막을 수 있도록 하고 있습니다.
해당 로직을 적용해보니 이제는 백지 화면이 생기지 않고, 정상적으로 렌더링되고 있는 것을 확인할 수 있었습니다.
[문제 개선] 두 번째, 리렌더링 시 지도를 렌더링하는 그래픽 요소가 중첩되는 문제
이제 두 번째 문제입니다. 해당 문제는 코드 에디터에서 변경 사항을 저장할 때, 혹은 다른 페이지로 이동 후 다시 해당 페이지에 방문하는 경우, 기존에 렌더링 된 그래픽 요소 위로 새로 생성된 요소가 중첩해서 쌓이는 것이 었습니다.
여러 시도를 통해 알게된 해당 문제의 원인 중 하나는 kakao map sdk 를 추가하는 script 태그를 useEffect 내에서 동적으로 생성하고 있던 부분이었습니다(파고 팔수록 계속해서 보이는 문제들)
따라서 useEffect 의 클린업 함수를 활용해서 컴포넌트가 디마운트 될 때 script 태그를 제거해주도록 로직을 추가하였고,
load 이벤트의 경우에도 계속 등록만 되고 있고, 해제되지 않고 있었기에 메모리 누수를 방지하기 위한 목적으로 이를 제거하는 로직도 같이 추가하였습니다.
script.addEventListener('load', mapLoader)
return () => {
script.removeEventListener('load',mapLoader)
script.remove()
}
해당 로직을 추가하고, 리렌더링 시 스크립트 태그가 중첩해서 추가되지 않는 것을 볼 수 있었습니다.
스크립트 관련 이슈를 해결하고 나서, 다른 페이지에서 다시 현재 페이지로 돌아오는 경우 발생하는 div 요소 중첩 문제는 해결되었습니다.
그러나, 코드 에디터에서 파일저장을 하는 경우 발생하는 div 요소 문제는 해결하지 못했습니다. 즉, 이번 트러블 슈팅의 개선책은 반쪽 짜리라고 볼 수 있습니다.
성과와 변경점(25/5/27 변경사항 반영)
성과
- 결과적으로 지도 위에 지도가 렌더링 되는 문제를 개선할 수 있었습니다.
- 스크립트 태그를 동적으로 등록하는 이벤트 리스너가 제거되지 않아서 발생할 수 있는 메모리 누수 문제도 덤으로 해결하였습니다.
2024년 5월 27일 기준으로 변경된 점
앞서 트러블 슈팅에서 개선한 방식은 useEffect 가 호출될 때 마다 생성되는 스크립트를 디마운트 시 제거해주고, 이벤트 등록을 취소하는 방식으로 메모리 누수와 지도 svg 중첩 문제를 어느 정도 해결했었습니다. 그러나 24년 5월 25일 기점으로 리팩터링을 진행했고, Vite 에서는 index.html 파일에서 환경변수를 %env% 형식으로 접근할 수 있으므로 굳이 해당 컴포넌트 내에서 스크립트를 생성할 필요가 없었습니다.
따라서 index.html 파일 내에서 아래와 같이 스크립트 태그를 head 태그의 마지막 자식 요소로 추가해주었습니다.
<script
type="text/javascript"
src="//dapi.kakao.com/v2/maps/sdk.js?appkey=%VITE_KAKAO%&libraries=services,clusterer"
></script>
[캐싱 문제] 레시피 조회 세부 페이지의 새로고침 시 흰 화면이 렌더링 되는 문제(→ 2024.07.18. 첨언: 이 방식의 트러블 슈팅은 매우 비효율적임)
※ 해당 이슈의 경우에는 2024.07.18 기준으로 생각해보면 Open API 로 부터 받은 데이터를 개인 백엔드 서버를 통해 DB 에 모두 저장하고, 클라이언트측에서는 Tanstack-query/react 등의 캐싱관리를 위한 도구를 사용하여 요청 자체를 캐싱해두었다면 해당 문제를 개선할 수 있는 것으로 보입니다. 이 부분은 시간을 내어서 개선해봐야 할 듯 합니다. 현재 개선책은 너무 비효율적입니다.
문제상황-
레시피 데이터의 경우 공공 데이터 포털의 api 를 사용하여 외부 백엔드에서 데이터를 GET 요청으로 받아오고 있습니다. 1일 트래픽의 한도는 1000 회가 한도이므로 세부 페이지에서 매번 새로운 데이터를 GET 요청으로 받아오는 것은 불필요한 리소스 낭비로 이어지기 때문에, 이를 Redux-toolkit 을 이용한 메모리 캐싱을 통해 관리하였습니다.
그러나 해당 로직의 문제점은 사용자가 새로고침을 하는 순간 상태가 빈 배열로 초기화된다는 점 입니다.
리덕스 툴킷을 이용한 전역 상태를 기반으로 렌더링된 세부 페이지(새로고침 전)
참고로 24.05.28 기준, 리덕스 툴킷은 제거되었습니다. 기존에 Recoil 을 사용하고 있고, 추가적으로 Tanstack-query/react 를 사용하고 있는 상황에서 리덕스 툴킷이 현재 프로젝트에서는 불필요하다고 판단했기 때문입니다. |
새로고침 후 보여지는 세부 페이지
GIF
이렇듯, 빈페이지가 보여짐에 따라 사용성을 저해하는 문제가 발생했고, 새로고침 이후에도 캐시 처리된 데이터가 유지될 수 있도록 해야하는 상황 이었습니다.
개선과정-
도구 선택
앞서 살펴본 문제상황에 따르면, 새로고침 이후에 임시적으로 할당되어 있던 메모리가 초기화된 부분이 문제이므로, 이를 유지할 수 있는 몇 가지 도구를 찾아보았습니다.
일단 브라우저 캐싱처리 용도로 사용할 수 있는 세션과 로컬 스토로지가 있고, 브라우저 내에서 데이터베이스 역할을 수행할 수 있는 indexedDB 라는 것이 있습니다. 또한 URL 을 기반으로 Key-value 형식으로 저장하는 방식, 쿠키를 이용한 방식 등도 보였습니다.
저는 이 중에서 브라우저 스토로지인 세션과 로컬 스토로지를 선택했습니다.
다른 방식을 배제한 이유 |
- indexedDB는 초기 설정이 생각보다 까다롭고, 대규모 데이터를 다루는 용도로 적합하지만, 너무 대규모의 데이터를 저장한다고 가정하더라도 초기렌더링 성능에 부정적 영향을 줄 수 있으므로 배제하였습니다. - 쿠키를 사용하는 방식은 서버로 데이터를 요청하는 경우 해당 쿠키의 데이터도 같이 전달되는 네트워크 리소스를 낭비할 수 있는 문제가 존재하여 배제하였습니다. - URL을 사용한 방식은 키-값 쌍이 비교적 짧은 경우는 괜찮겠으나, 긴 배열을 담아서 저장하는 경우 사용자가 URL을 통해 페이지 전환 등의 작업을 시도한다고 가정하면 사용성을 저해하는 문제가 있기 때문에 배제하였습니다. |
비록 5MB 의 크기 제한이 있으나, 레시피 데이터 자체가 5MB 를 넘어설 정도로 큰 데이터는 아니고, 다른 캐싱처리 방식에 비해 사용성도 좋다고 판단했기 때문입니다.
TMI |
해당 스토로지의 Get 과 Set 를 편리하게 여러 곳에서 재사용할 수 있도록 해당 함수를 모듈화 하기로 하였습니다. |
재사용성을 높이기 위한 함수 모듈화 | setStorage 함수 선언
타입스크립트를 사용하므로 해당 스토로지의 유형을 명확하게 구분할 수 있도록 enum (열거형)을 사용하여 LOCAL 과 SESSION 을 구분토록 했고, key 와 value , type 을 { } 로 묶어서 매개변수로 받아오도록 처리했습니다.
export enum StorageType {
LOCAL = 'LOCAL',
SESSION = 'SESSION'
}
interface SetStorageType {
type: StorageType
key: string
value: any
}
export function setStoreage({ type, key, value }: SetStorageType) {
if (type === StorageType.LOCAL) {
window.localStorage.setItem(key, JSON.stringify(value))
}
if (type === StorageType.SESSION) {
window.sessionStorage.setItem(key, JSON.stringify(value))
}
}
재사용성을 높이기 위한 함수 모듈화 | getStorage 함수 선언
Setter 역할을 하는 함수를 통해 저장된 데이터를 불러올 수 있도록 getStorage 함수도 정의했습니다. 해당 함수의 경우도 인자로 type 과 key 를 전달받는데 앞서 setStorage 와 다른 점이 있다면 각 타입의 스토로지에서 데이터를 읽어오는 로직을 getLocalStorageItem 과 getSessionStorageItem 으로 모듈화하였다는 점입니다.
function getLocalStorageItem(key: string) {
return window.localStorage.getItem(key)
}
function getSessionStorageItem(key: string) {
return window.sessionStorage.getItem(key)
}
/**
* 스토로지에 저장된 데이터를 읽어온다.
* @param type 스토로지 타입
* @param key 스토로지에 저장된 데이터의 키
* @returns 데이터 반환
*/
export function getStoreage(type: StorageType, key: string) {
try {
if (type === StorageType.LOCAL) {
const value = getLocalStorageItem(key)
if (value) return JSON.parse(value)
}
if (type === StorageType.SESSION) {
const value = getSessionStorageItem(key)
if (value) return JSON.parse(value)
}
throw new Error(`현재 저장소에 존재하지 않는 키를 전달하였습니다. 전달된 키는 "${key}" 입니다.`)
} catch (error) {
console.error(error)
return '잘못된 키'
}
}
아래 로직만 다시 살펴보면, try ~ catch 로 감싼 후 해당하는 key의 value 를 읽어오지 못했다면 에러를 띄우도록 예외처리를 해주었습니다.
export function getStoreage(type: StorageType, key: string) {
try {
if (type === StorageType.LOCAL) {
const value = getLocalStorageItem(key)
if (value) return JSON.parse(value)
}
if (type === StorageType.SESSION) {
const value = getSessionStorageItem(key)
if (value) return JSON.parse(value)
}
throw new Error(`현재 저장소에 존재하지 않는 키를 전달하였습니다. 전달된 키는 "${key}" 입니다.`)
} catch (error) {
console.error(error)
return '잘못된 키'
}
}
적용하기 | 레시피 데이터를 조회할 때 setStorage 를 호출
이제 새롭게 도입된 캐싱로직을 적용할 차례입니다. 우선 레시피 데이터를 조회하는 순간 해당 데이터를 sessionStorage 에 저장하도록 로직을 추가했습니다. 아래 함수의 경우 Recipe 페이지의 루트에 해당하는 컴포넌트 입니다. onSearch 라는 함수가 호출되면 외부 백엔드로 GET 요청을 하게 되고, 그 역할을 하는 것이 getFetchRecipeData 라는 함수 입니다. 그리고 이 함수가 반환하는 Recipes 와 totalCount 를 setStorage 의 인자로 전달하여 저장합니다.
// src/pages/Recipe/Recipe.tsx
/** 버튼 검색 액션 */
async function onSearch(e: MouseEvent<HTMLButtonElement>) {
const input = e.currentTarget.previousElementSibling as HTMLInputElement
const productName = getSearchValue(input) || ''
const { totalCount, recipes } = await getFetchRecipeData(productName)
setProductName(productName)
setRecipeInfo({ totalCount, recipes })
setStoreage({ type: StorageType.SESSION, key: 'recipes', value: { recipes, totalCount } })
}
저장이 되었는지 확인해보니 정상적으로 저장이 되었습니다.
적용하기 | 레시피 세부 페이지에서 useEffect 를 이용한 getStorage 호출
그 후 캐싱된 데이터를 재사용하기 위해 RecipeDetail 페이지의 루트 컴포넌트로 와서 사용합니다. 현재 키가 'recipes' 로 되어 있기 때문에 이를 getStorage(_, 'recipes') 형태로 전달해주었고, 그 반환값을 recipeInfo 변수에 담고 있습니다.
// src/pages/Recipe/RecipeDetail.tsx
import { useParams } from 'react-router-dom';
import { StorageType, getStoreage } from '@/utils/storage';
// --- 중간에 불필요한 로직 부분 생략 ---
const params = useParams();
/** 사용자가 선택한 레시피 반환 */
function extractRecipe(recipes: RecipeType[], recipe_id: string) {
return recipes.filter(recipe => recipe.RCP_SEQ === recipe_id)[0]
}
/** 레시피 업데이트 */
function updateRecipeData(recipe_id: string = '0') {
const recipeInfo: RecipeInfoType = getStoreage(StorageType.SESSION, 'recipes')
const recipe = extractRecipe(recipeInfo.recipes, recipe_id)
setRecipe(recipe)
}
useEffect(() => {
updateRecipeData(params.id)
}, [params.id])
다만 현재 로직에서 recipeInfo 의 경우 totalCount 라는 레시피 전체 개수를 나타내는 속성을 가지고 있고, 이는 현재 페이지에서는 불필요 합니다. 또한 레시피 목록 전체가 필요한 것이 아니고 사용자가 선택한 레시피에 대한 데이터만 필요하므로 이를 처리하기 위해 extractRecipe 함수를 만들어 주었습니다.
/** 사용자가 선택한 레시피 반환 */
function extractRecipe(recipeInfo: RecipeType[], recipe_id: string) {
return recipes.filter(recipe => recipe.RCP_SEQ === recipe_id)[0]
}
부연 설명 |
여기서 recipe_id 의 경우에는 react-router-dom 의 useParams() 를 호출하여 얻은 {id:"135"} 에서 value에 해당하는 예시의 135 와 같은 값만을 추출하여 전달받고 있고, recipe 목록 중에서 RCP_SEQ 와 일치하는 경우만 배열로 반환해주고 있습니다. |
그리고 이렇게 만들어진 함수를 useEffect() 내에서 호출처리하는데, 여기서 중요한 부분은 params.id 를 의존성 배열에 넣어줌으로써 사용자가 향후 이전 레시피 혹은 다음 레시피를 클릭하여 /:id 부분이 변경되었을 때 새로운 데이터로 상태를 갱신해주도록 하는 것입니다.
useEffect(() => {
updateRecipeData(params.id)
}, [params.id])
성과 및 앞으로 고려할 점
해당 로직을 적용한 이후에는 더 이상 빈 화면이 렌더링되는 일이 발생하지 않게 되었습니다. 다만, 현재의 경우에는 레시피 데이터의 크기가 크지 않기 때문에 문제는 없으나 5MB 를 넘어서는 경우에 발생할 수 있는 문제에 대한 예외처리도 필수적으로 고려되어야 하는 부분인 것 같습니다. 이 부분은 차후 개발이 완료된 이후에 어떻게 처리할 지 고민해볼 예정입니다.
참고로 현재 레시피 세부 페이지 디자인은 2024.05.28 기준으로 변경되었습니다.
[빌드 문제] CRA 의 느린 빌드 속도 및 유지보수 미흡으로 인한 높은 취약성 문제
※ 해당 프로젝트는 초기에 Create React App 이라는 보일러 플레이트를 사용하여 생성되었는데, React 팀에서 더 이상 CRA 에 대한 관리를 하지 않음으로 크리티컬 수준의 취약성 문제가 계속해서 발생했었습니다. 또한, 새로고침 시 마다 적은 파일 사이즈임에도 불구하고 변경된 사항이 반영되기까지 지연시간이 걸렸고, 빌드 또한 느려서 개발자 경험의 수준을 따진다면 최하 라고 평가할 만큼 답답한 상황이었습니다. 때마침 Vite 빌드 도구를 알게되고, 개선하는 과정을 담은 포스트 입니다.
문제상황
해당 프로젝트는 CRA(Create React App) 으로 처음 빌드되어 구축되었습니다.
처음에는 개발 서버를 구동하는데 걸리는 빌드 시간에 의미를 부여하지 않았으나, Vite 기반의 간단한 애플리케이션을 만들어보면서, CRA의 느린 빌드 속도에 큰 불만을 가지게 되었습니다. 특히, CRA 의 경우 변동 사항을 반영하여 렌더링되는데 체감상 Vite 기반의 앱에 비해서 1초 이상 차이가 났습니다.
왜 이런 차이가 나는지 공식문서를 살펴보니, 일반적인 웹펙과 같이 의존성 모듈 전체를 재렌더링하는 것이 아니라, 변동사항이 존재하는 모듈만 빠르게 대체하기 때문임을 알게 되었습니다. 여러모로 바꾸지 않을 이유가 없었습니다.
무엇보다 CRA는 react-script 패키지와 관련한 취약성 문제가 계속 발생하고 있음에도 불구하고, 개선하고자 하는 움직임이 보이지 않았습니다. 이에 대한 심각성을 바탕으로 빠른 시일 이내에 vite 기반으로 마이그레이션 하자는 목적을 가지게 되었습니다.
개선과정
개선방법 선택 : CRA 에서 VITE 로 빌드 환경 마이그레이션
CRA 와 VITE 기반의 앱의 초기 프로젝트 환경의 구성을 비교하면서, 그 차이점을 기반으로 마이그레이션을 진행하였습니다.
간략히 거친 과정을 언급하자면 다음과 같습니다.
package.json 에서 CRA 기반의 비의존성 및 의존성 패키지 정리 → vite 설치 및 플로그인 설치 → vite.config.ts 설정 → react-app-env.d.ts 설정 → 환경변수 → eslint 설정 → 빌드 과정 |
과정1 | vite.config.ts 파일 추가
vite 의 경우에는 vite.config.ts 파일을 추가하고, 해당 vite 앱이 react 기반임을 vite에 알리고, 빌드 시 해당 파일이 어디에 저장될 것인지를 기본값으로 설정해야 했기에 다음과 같이 플로그인 등록과 경로 설정을 실시하였습니다.
과정2 | 환경변수의 타입을 타입스크립트가 추론할 수 있도록 지원하는 react-app-env.d.ts 파일 수정
환경변수의 타입을 지정하는 react-app-env.d.ts 파일의 경우에는 기존 CRA 설정 방식이었던 reference를 vite 기반으로 변경하였습니다.
과정3 | process.env 를 Vite 기반의 import.meta.env 로 수정
환경변수 파일의 경우 vite 기반의 리액트 앱에서는 환경변수에 접근할 때 imprt.meta.env 로 접근하여 사용함을 확인하였고, 다음과 process.env 부분을 vite 환경변수에 맞게 수정하였습니다.
참고로 process.env 혹은 import.meta.env 로 접근할 시 타입스크립트가 해당 환경변수에 대한 타입을 추론할 수 있도록 하는 파일이 앞서 설정한 react-app-env.ts 입니다. |
과정4 | 불필요한 %PUBLIC_URL%/ 을 삭제하기 위해 index.html 파일 수정
또한, index.html 파일의 경우에도 변동이 발생하였는데, CRA에서는 public 폴더 접근 시 %PUBLIC_URL%/ 로 접근하였으나, Vite 에서는 이 부분이 생략되어도 내부적으로 public 폴더로 설정되는 것을 확인하였고 변동사항을 체크 후 이제는 사용하지 않는 %PUBLIC_URL%/ 을 제거하 작업을 수행하였습니다.
결과 | 변경 사항을 반영한 의존성을 재설정하기 위한 처리 및 빌드 실행
이후 이전 기록이 남아있는 node_modules 폴더를 rm -rf 명령어를 통해 하위 디렉토리 까지 모두 강제 삭제 처리하고, npm install 을 통하여 변경된 종속성을 재설치 하였습니다.
그 후 빌드 명령어를 실행하여 성공적으로 마이그레이션이 되었음을 확인하였습니다.
성과
- 체감상 빌드 속도와 코드 변경 사항에 대한 리렌더링에 있어서 CRA 보다 Vite 가 효율적이고 빠르게 느껴졌습니다. CRA 에서는 파일 자체가 그리 큰 사이즈가 아님에도 변경 사항이 반영되는데 약간의 지연을 느꼈다면, Vite 에서는 지연됨이 거의 느껴지지 않을 정도 였습니다.
- 개발 서버의 빌드 속도 또한 눈에 띄게 증가하였습니다. 배포 파일 빌드 시 빌드 시간이 감소 하였는데, webpack: 18.35s → vite: 5.66s 으로 크게 감소하였습니다. 다만 수치적으로 측정하는 부분은 매번 오차가 있어서 명확하지는 않습니다.
- 끝으로, 이번 마이그레이션의 주요 이슈였던 CRA 에서 뜨던 react-scripts 취약성 문제가 해결되었습니다.
참고자료 | 프로젝트 회고
트러블 슈팅 포스트 후기
'프로젝트 > 푸드피커' 카테고리의 다른 글
[푸드피커] Github Actions 를 활용한 NodeJS 백엔드 배포 CI/CD 구축(With 국내 클라우드 플랫폼 Cloudtype ) (0) | 2024.06.12 |
---|---|
[푸드피커] Github Actions 을 통한 AWS S3+CloudFront 배포 자동화 구축 (1) | 2024.06.11 |
[푸드피커] AWS S3 + CloudFront 를 활용한 정적사이트 배포(ReactJS) (0) | 2024.06.11 |
[푸드피커] 기능 구현 정리본 (0) | 2024.06.02 |