- [프로젝트] 성능 최적화 해보자!!!2024년 12월 26일 15시 00분 26초에 업로드 된 글입니다.작성자: 동혁이
들어가기 앞서..
처음 프로젝트를 배포하고 중간 피드백을 받았습니다.
약 50명의 부트캠프 동료들과 실제 직원분들께 유저 테스트를 진행했고, 그 결과 "검색 시 로딩 상태가 불편해요", "페이지 전환할 때 깜빡임이 있어요", "에러가 났을 때 어떻게 해야 할지 모르겠어요" 등의 피드백을 받으면서 코드가 '동작하는 것'과 '잘 동작하는 것'의 차이를 실감했습니다.
더 나은 서비스를 만들기 위해 Next.js와 Tanstack Query를 공부했고 최적화 관련 강의를 들으면서 선언형 프로그래밍, 체계적인 에러 처리, SSR 등 다양한 기술을 접하게 되었고, 프로젝트에 적용해 보기로 했습니다.
단순히 기능만 동작하는 코드가 아닌, 유지보수 하기 좋고 사용자 경험도 개선된 서비스를 만들기 위해 아래와 같은 최적화 계획을 세웠습니다.
프로젝트 최적화 계획
프로젝트 최적화 계획 1. Suspense를 활용한 로딩 상태 관리 - 데이터 페칭 시 fallback UI (사용자 경험 향상) - 선언형 프로그래밍 (코드 가독성 향상) 2. SSR(Server Side Rendering) 적용 (Next.js + Tanstack Query) - HydrationBoundary 활용 (데이터 일관성 보장) - 초기 렌더링 시간 개선 - SEO 최적화 3. 코드 스플리팅 - Next.js dynamic import (번들 크기 감소) - 무거운 크기의 컴포넌트 지연 로딩 4. QueryCache를 통한 상태 관리 개선 (useQuery의 onSuccess, onError 사라졌기 때문) - 데이터 호출 / 상태 관리 분리 (관심사 분리) 5. 서버, 클라이언트 에러 처리 관리 - 서버 에러: 404.tsx, _error.tsx - 클라이언트 에러: ErrorBoundary (선언적 에러 처리) - 사용자 친화적인 에러 메시지 6. 검색 기능 최적화 - 검색값의 상태 변화에 따른 불필요한 리렌더링 문제 해결 - 디바운스(debounce) vs 검색 결과를 한번에 받아 API를 호출할 상태값 하나 더 추가 (검색 상태 분리) 7. 이미지 최적화 - png -> WebP 포멧 적용 (높은 호환성과 효율적인 압축률 - 약30% 용량 감소) - AVIF 포멧 적용 (지원 유무에 따라) (더 높은 압축률 - WebP 대비 약20% 추가 감소) - 이미지 압축 (사이즈 최적화) - priority옵션 활용 (중요한 이미지) - blur 효과 적용 (CLS 개선) 8. +) 추가 성능 개선 - 불필요한 애니메이션 제거 - 불필요한 이미지 제거
최적화하기 전 LightHouse 점수
(Crome Devtools)
최적화 진행
1. Suspense를 활용한 로딩 상태 관리
목표
- 데이터 페칭 시 fallback UI (사용자 경험 향상)
- 선언형 프로그래밍 (코드 가독성 향상)적용 및 트러블 슈팅 (내용이 너무 길어 따로 작성했습니다.)
- 여기클릭!!!!
+) 다음 최적화를 진행하기에 앞서...
SSR(Server Side Rendering) 과 Suspense는 동시에 사용할 수 없습니다.
동작과정 살펴보기 1. SSR에서 getServerSideProps를 통해 데이터를 미리 가지고 오고 있습니다. 2. 동시에 클라이언트에서 Suspense를 사용해 데이터 로딩을 처리하고 있습니다. 3. 이로 인해 서버에서 렌더링 된 HTML과 클라이언트의 Hydration 과정에서 불일치가 발생합니다.
해결 방안
두가지 접근 방법이 있습니다.
1. CSR(Client Side Rendering)유지 + Suspense 사용
2. SSR(Server Side Rendering)변경 + Suspense 제거
2. SSR(Server Side Rendering) 적용 - Next.js + Tanstack Query
목표
- HydrationBoundary 활용 (데이터 일관성 보장)
- 초기 렌더링 시간 개선
- SEO 최적화기존 문제점
현재 CSR(Client Side Rendering) 방식 사용중
- JavaScript 번들 모두 다운로드 후 첫 렌더링 실행하면서 인증, 데이터 요청 등으로 인한 긴 렌더링 시간
- 초기 데이터 undefined 이후 API 호출 과정을 거치며 화면에 렌더링
이 방식이 사용자 관점에서 좋지 못하다고 생각했고 초기 렌더링 속도를 더 향상하기 위해 SSR을 적용하기로 했습니다.
Next.js 에서는 getServerSideProps를 이용하면 데이터 패칭을 할 수 있다고 알고 있습니다.
Data Fetching: getServerSideProps | Next.js
Fetch data on each request with `getServerSideProps`.
nextjs.org
그리고 현재 프로젝트에서는 Tanstack Query를 채택해서 사용하고 있습니다.
Tanstack Query를 이용해 SSR을 적용하기 위해 2가지 방법이 있습니다.
1. initialData 를 사용해 서버에서 fetch한 데이터 사용하기
2. Hydration API 사용하기
공식 문서에서는 서버의 최신 데이터 유지하기 위해 후자의 방법을 권장하고 있기에 Hydration API 를 사용했습니다.
Advanced Server Rendering | TanStack Query React Docs
Welcome to the Advanced Server Rendering guide, where you will learn all about using React Query with streaming, Server Components and the Next.js app router. You might want to read the before this on...
tanstack.com
구현 코드
(무한 스크롤 사용 중이라 prefetchInfiniteQuery 사용)
적용 후 콘솔창
LightHouse
개선 결과
- 렌더링 성능 약 90% 향상 (FCP - 2.8s -> 0.3s)
+) SEO 최적화
SSR을 이용하면 검색 엔진이 페이지의 내용을 더 잘 이해할 수 있기 때문에
페이지의 노출 상위에 올라갈 가능성이 높아지며, 검색 엔진 최적화에 큰 도움이 됩니다.
시작하기 전 LightHouse SEO 점수
아래와 같이 공통 컴포넌트를 만들어 페이지별로 다르게 하고 싶은 부분을 props로 받도록 커스텀해서 제작해 보았습니다.
interface Props { description?: string; ogImage?: string; title?: string; } export function SEO({ title, description, ogImage }: Props) { / ... / return ( <Head> <title>{TITLE}</title> <link rel="canonical" href={URL} /> <meta name="description" content={DESCRIPTION} /> <meta charSet="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta property="og:title" content={TITLE} /> <meta property="og:description" content={DESCRIPTION} /> <meta property="og:image" content={IMAGE} /> <meta property="og:url" content={URL} /> <meta property="og:image:width" content={WIDTH} /> <meta property="og:image:height" content={HEIGHT} /> {/* for twitter */} <meta name="twitter:title" content={title} /> <meta name="twitter:description" content={description} /> <meta name="twitter:image" content={IMAGE} /> </Head> ); }
SEO 컴포넌트 적용 후 LightHouse 점수
개선 결과
- 렌더링 성능 약 90% 향상 (FCP - 2.8s -> 0.3s)
- SEO 최적화 (82 -> 100점 향상)
3. 코드 스플리팅
목표
- Next.js dynamic import (번들 크기 감소)
- 무거운 크기의 컴포넌트 지연 로딩구현 과정에서의 있었던 일
Dynamic import 적용시 고려사항
- 로딩 상태 표시
- 컴포넌트 렌더링 우선순위 고려
- CLS 방지
Lighthouse 점수를 다시 확인해보니 CLS가 엉망이였습니다.
원인을 찾기 위해 Network 로딩속도를 3G로 바꿔 변화를 지켜봤습니다.
컴포넌트 순서가 뒤죽박죽으로 렌더링이 되면서 CLS가 생기는 모습을 볼 수 있었습니다.
최적화 방안
문제 원인을 파악했으니 각 컴포넌트 별 로딩시 보여줄 스켈레톤을 구현하기로 했습니다.
무거운 Carousel Slide 컴포넌트를 dynamic을 사용해 코드 스플리팅하고 컴포넌트 loading 중에 보일 스켈레톤을 제작해 CLS를 줄일 수 있었습니다.
마찬가지로 CardSection에도 진행해봤습니다.
결과
최적화 하기 전 CLS - 약 0.63
최적화 후 CLS - 0.006 (약 99% 개선)
+) bundle-analyzer + Gzip 적용 경험 (bundle size 줄이기)
아래 게시글은 이후에 공부한 내용을 바탕으로 나중에 bundle-analyzer와 Gzip을 적용해 본 공부, 경험 게시글입니다!
4. QueryCache를 통한 상태 관리 개선 (useQuery의 onSuccess, onError 사라졌기 때문)
목표
- 데이터 호출 / 상태 관리 분리 (관심사 분리)
배경
Tanstack Query V5 업데이트 변화 (궁금하면 여기 클릭!! , 한국어 설명 블로그)
- useQuery의 onSuccess, onError 없어졌습니다. (+ onSettled)
- suspense 옵션 => useSuspenseQuery 변경
해결방안
1. QueryCache 활용
- QueryCache 내부의 value로 query객체 내부의 모든 정보들을 가져와
query 내부의 meta에 있는 onSuccess와 onError를 읽어 사용하는 방법을 이용할 수 있습니다.
이렇게 데이터 호출 로직과 상태를 별도로 분리해 선언적 코딩을 적용시켜 봤습니다.
app.tsx
QueryProvider.tsx (app.tsx에 적용)
- QueryCache 적용 코드 예시
// QueryProvider.tsx const queryClient = new QueryClient({ queryCache: new QueryCache({ onSuccess: (data, query: { meta?: QueryMetaType }) => { if (query.meta?.onSuccess) query.meta.onSuccess(data); }, onError: (error, query: { meta?: QueryMetaType }) => { if (query.meta?.onError) query.meta.onError(error); }, }), defaultOptions: { queries: { / ... / }, }, }); export default function QueryProvider({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> <ReactQueryDevtools /> {children} </QueryClientProvider> ); }
적용 예시 코드
// hooks/useAppliedCard.ts interface UseAppliedCardProps { onSuccess?: () => void; onError?: () => void; } export default function useAppliedCard({ onSuccess, onError }: UseAppliedCardProps) { return useSuspenseQuery({ queryKey: ['applied-card'], queryFn: () => getAppliedCard(), meta: { onSuccess, onError, }, }); }
사용 예시
// components/AppliedCard.tsx export default function AppliedCard() { const { data } = useAppliedCard({ onSuccess: () => { console.log('데이터 호출 성공'); }, onError: () => { console.log('getAppliedCard에서 문제가 발생했습니다.'); }, }); return ( // ... 렌더링 로직 ); }
개선 효과
- 데이터 호출 로직과 상태 관리 로직의 명확한 분리
- 선언적 프로그래밍을 통한 코드 가독성 향상
- 재사용 가능한 커스텀 훅 구현참고 자료
- [Breaking React Query's API on purpose](https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose)
- [tanstack query V5 에서 사라진 onSuccess, onError, onSettled](https://velog.io/@devohda/tanstack-query-V5-%EC%97%90%EC%84%9C-%EC%82%AC%EB%9D%BC%EC%A7%84-onSuccess-onError-onSettled)5. 서버, 클라이언트 에러 처리 관리
목표
- 클라이언트 에러: ErrorBoundary (선언적 에러 처리)
- 서버 에러: 404.tsx, _error.tsx
- 사용자 친화적인 에러 메시지1) 커스텀 ErrorBoundary 컴포넌트 제작
- 클라이언트 측 에러 관리
- props로 fallbackComponent를 받아 확장성 있게 수정
import type { ErrorInfo } from 'react'; import React from 'react'; interface Props { children: React.ReactNode; fallbackComponent?: React.ReactNode; } interface State { hasError: boolean; // 에러가 발생했는지 여부 } // ErrorBoundary는 라이프 사이클을 사용해야 하기 때문에 클래스형 컴포넌트를 사용해야 할 수 밖에 없습니다. class ErrorBoundary extends React.Component<Props, State> { constructor(props: Props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { // 1. 이 라이프 사이클에서 에러가 발생하면 컴포넌트를 업데이트를 하면서 리턴된 값을 가지고 state를 업데이트 합니다. // 2. 이렇게 업데이트된 state는 return { hasError: true }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.log({ error, errorInfo }); } render() { const { hasError } = this.state; const { fallbackComponent, children } = this.props; // 3. 다시 리렌더링이 될 거고 이 당시에 state를 바라보고 에러가 발생을 했다면 아래 컴포넌트로 대체를 해줍니다. if (hasError) { if (fallbackComponent != null) return fallbackComponent; // 공통 에러 컴포넌트 return ( ....... ); } // 4. 에러가 발생하지 않았다면 기존에 우리가 그려주고 싶은 컴포넌트를 그려주는 역할을 합니다. return children; } } export default ErrorBoundary;
전체 에러 처리 (_app.tsx)
컴포넌트별 에러 처리
에러 발생 후 모습
(개별 컴포넌트 fallback UI 적용)
2) 404.tsx, _error.tsx 생성
- 전역 서버 에러 처리, 특정 에러 대응
404.tsx
export default function Custom404() { const router = useRouter(); return ( <div> // .... </div> ); }
_error.tsx
// _error.jsx import type { NextPageContext } from 'next'; import { AlertIcon } from '@/components/icons'; function Error({ statusCode }: { statusCode?: number }) { return ( <section> <div> <p> <AlertIcon /> </p> <h1> {statusCode === 500 ? '서버 에러가 발생했습니다' : '페이지를 찾을 수 없습니다'} </h1> <p> {statusCode} 에러가 발생했습니다. <br /> 잠시 후 다시 시도해주세요 :) </p> <button type="button" onClick={() => window.history.back()}> 재시도 </button> </div> </section> ); } Error.getInitialProps = ({ res, err }: NextPageContext) => { const statusCode = res ? res.statusCode : err ? err.statusCode : 404; return { statusCode }; }; export default Error;
적용 후 모습
참고 자료
- [Error Handling] (https://nextjs.org/docs/pages/building-your-application/configuring/error-handling)
- [not-found.js] (https://nextjs.org/docs/app/api-reference/file-conventions/not-found)
- [error.js] (https://nextjs.org/docs/app/api-reference/file-conventions/error)
- [ErrorBoundary] (https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
6. 검색 기능 최적화
목표
- 검색값의 상태 변화에 따른 불필요한 리렌더링 문제 해결
- 디바운스(debounce) VS 검색 결과를 한번에 받아 API를 호출할 상태값 하나 더 추가 (검색 상태 분리)구현 방안 비교
1) Debounce 방식
import { useEffect, useState } from 'react' // value: 내가 사용할 값, delay: 지연시간 export default function useDebounce<T = any>(value: T, delay = 800) { const [debouncedValue, setDebouncedValue] = useState<T>(value) useEffect(() => { const timeout = setTimeout(() => { setDebouncedValue(value) }, delay) // 맨 마지막 액션만 캐치 return () => clearTimeout(timeout) }, [delay, value]) return debouncedValue }
2) 검색 상태 분리 방식
export default function Search() { const [keyword, setKeyword] = useState(''); // 입력값 저장하는 상태 (실시간 변경되지만 API 호출 X) const [searchValue, setSearchValue] = useState(''); // 실제 검색 수행을 위한 상태 (검색 버튼 클릭 시에만 API 호출 O) / ... / return ( / ... / ) }
방식별 특징 비교
1) Debounce 방식 장점 - 자동 검색 기능 - 실시간 피드백 가능 단점 - 디바운스 시간 동안 불필요한 상태 업데이트 - 메모리 사용량 증가 - 디바운스 로직 관리 2) 상태분리 방식 장점 - API 호출 횟수 최소화 - 불필요한 상태 업데이트 방지 - 서버 부하 감소 - 의도하지 않은 검색 방지 단점 - 사용자 액션 필요 - 실시간 검색 기능 제공 불가
프로젝트 적용 (상태 분리 방식 체택)
- 자세한 구현과 성능 개선 결과는 아래 블로그 게시글에 작성했습니다!
https://edongdong.tistory.com/259
7. 이미지 최적화 (Next/Image)
목표
- 이미지 로딩 성능 향상
- 용량 최적화
- CLS(Cumulative Layout Shift) 개선
아래 최적화 과정에 대해서 공부하고 적용해봤던 게시글입니다.
https://edongdong.tistory.com/230
최적화 적용
기존 코드의 문제점
- 문제 : 2048 x 1152 크기의 이미지를 234 x 200 크기로 변환시 비용 발생
// Before <div className="relative"> <Image src="~~" alt="~~" fill className="object-cover" /> </div>
1. 이미지 사이즈 최적화
- 해결 : Next.js는 지정된 크기를 기반으로 최적의 이미지를 계산해 제공합니다.
// After <Image src="~~" alt="~~" width={~~} heigth={~~} />
결과
187kB → 42kB (약 77% 감소)
2. 지원 브라우저에 따라 최신 avif 포멧 사용
- png -> WebP 포멧 적용 (높은 호환성과 효율적인 압축률 - 약30% 용량 감소)
- AVIF 포멧 적용 (지원 유무에 따라) (더 높은 압축률 - WebP 대비 약20% 추가 감소)적용 코드
// next.config.js 일부 코드 const nextConfig = { images: { formats: ['image/avif', 'image/webp'], }, } export default nextConfig
3. 이미지 크기 압축 (https://tinypng.com/)
4. Blur 효과 적용
- sharp, plaiceholder, @plaiceholder/next 라이브러리 사용
사용 이유: blurDataUrl에는 무조건 base64로 처리된 이미지 형식만 사용 가능합니다.
주의 - Next.js 13에서 plaiceholder 라이브러리는 브라우저에서 동작하지 않기 때문에 서버 컴포넌트에서 써야 합니다.
getBase64.ts (util 함수 구현)
import fs from 'node:fs/promises'; import path from 'node:path'; import { getPlaiceholder } from 'plaiceholder'; const getBase64 = async (src: string) => { // public 폴더 내 이미지 파일을 버퍼로 읽어옴 const buffer = await fs.readFile(path.join('./public', src)); // plaiceholder로 이미지 처리 // size: 10은 생성될 blur 이미지의 크기 (작을수록 base64 문자열이 짧아짐) const { metadata: { height, width }, ...plaiceholder } = await getPlaiceholder(buffer, { size: 10 }); return { ...plaiceholder, // base64 등의 데이터 img: { src, height, width }, // 원본 이미지 정보 }; }; export default getBase64;
적용 예시 코드
export async function getServerSideProps() { const base64Image = await getBase64('이미지 경로'); return { props: { base64: base64Image }, }; }
적용한 모습
최적화 후 LightHouse 점수
8. 추가 최적화
- 불필요한 애니메이션 제거
- 불필요한 이미지 제거
+) 추가적인 오류 해결
터미널에 나오는 이 오류는 기존의 nextConfig 설정파일에서 images 안에 remotePatterns로 변경해 달라고 하는 말입니다.
// 기존코드 const nextConfig = { reactStrictMode: true, images: { domains: ['~~', '~~'], }, }; export default nextConfig;
// 변경후 const nextConfig = { reactStrictMode: true, images: { remotePatterns: [ { protocol: 'https', hostname: '~~', pathname: '**', }, ], formats: ['image/avif', 'image/webp'], }, }; export default withPlaiceholder(nextConfig);
변경 후 터미널에 다시 보이지 않았습니다.
최종 빌드 후 LightHouse 점수입니다.
다음글이 없습니다.이전글이 없습니다.댓글