이번 프로젝트를 통해 성능 최적화는 단순히 기술적인 작업이 아니라, 사용자 경험에 대한 깊은 이해에서 시작된다는 것을 깨달았습니다. SSR 최적화를 진행하면서 단순히 react-icons를 이모지로 바꾼 것처럼 사소해 보이는 결정이 전체 번들 크기를 수백 킬로바이트나 줄이는 결과를 만들어내는 등 작은 변화들이 큰 파급력을 가진다는 점에 놀랐습니다. 눈에 띄지 않는 부분까지 세심하게 관리하는 것의 중요성을 다시 한 번 느꼈습니다.
가장 중요한 배움은 서버와 클라이언트 역할의 재정의였습니다. 최대한 SSR을 많이 적용하려다가 오히려 성능이 안좋아지는 시행착오를 많이 겪었는데요, 이후 모든 것을 서버에서 렌더링하려 하기보다, 각 컴포넌트의 특성에 맞춰 렌더링 방식을 분리하고 최적화하는 것이 훨씬 효과적이라는 것을 깨닫게 되었습니다.
최근 진행했던 Next.js 기반 프로젝트의 SSR(Server-Side Rendering) 성능 최적화 경험을 공유하려 합니다. Lighthouse 성능 점수를 40점에서 70점으로 끌어올리면서 겪었던 시행착오와 핵심 기술들을 상세히 정리해 보았습니다.
왜 SSR 최적화가 필요한가요?
SSR은 사용자에게 더 빠른 첫 화면 로딩 경험을 제공하고, SEO(검색 엔진 최적화)에 유리하다는 장점 때문에 많이 활용됩니다. 하지만 클라이언트 측에서만 필요한 무거운 스크립트나 의존성이 SSR 과정에 포함되면, 오히려 성능을 떨어뜨리는 문제가 발생할 수 있습니다. 특히 JavaScript 번들 크기가 커지면 TBT(Total Blocking Time)와 LCP(Largest Contentful Paint) 같은 핵심 성능 지표가 악화됩니다.
제가 진행중인 프로젝트도 비슷한 문제를 겪고 있었습니다. 초기 Lighthouse 측정 결과 성능 점수는 40점에 불과했고, 특히 LCP와 TBT 지표가 매우 좋지 않았습니다. 사용자 경험을 개선하기 위해 전면적인 SSR 최적화 작업이 필요한 상황이였습니다.
성능 개선을 위한 세 가지 핵심 전략
이번 최적화의 핵심은 "꼭 필요한 것만 서버에서 렌더링하고, 나머지는 클라이언트에서 동적으로 로드한다"는 원칙을 적용하는 것이었습니다. 이 원칙을 바탕으로 다음 세 가지 방법을 도입해 해결했습니다.
1. 레이아웃 라우터를 통한 번들 분리
기존에는 모든 페이지가 무거운 단일 레이아웃 컴포넌트를 공유했습니다. 이 레이아웃에는 SSE(Server-Sent Events) 연결, 모달 상태 관리, 그리고 다양한 상태 관리 라이브러리(Zustand)가 포함되어 있어, 모든 페이지에 불필요한 번들이 로드되는 비효율적인 구조였습니다.
이 문제를 해결하기 위해 OptimizedClientLayout을 구현하고, next/dynamic을 활용해 페이지 경로에 따라 가장 가벼운 레이아웃을 동적으로 로드하도록 했습니다.
// OptimizedClientLayout.tsx
'use client';
import dynamic from 'next/dynamic';
import { usePathname } from 'next/navigation';
import { ReactNode, ComponentType } from 'react';
// 페이지에 따라 필요한 레이아웃만 동적으로 가져옵니다.
const ClientLayoutContent = dynamic(() => import('@/components/layout/ClientLayoutContent'), {
ssr: false, // SSE가 필요한 페이지는 클라이언트에서 렌더링
loading: () => <div className="relative flex min-h-[100dvh] w-full max-w-[430px] flex-col" />,
});
const BasicLayout = dynamic(() => import('@/components/layout/BasicLayout'), {
ssr: true, // 일반 페이지는 SSR
loading: () => <div className="relative flex min-h-[100dvh] w-full max-w-[430px] flex-col" />,
});
const HomeLayout = dynamic(() => import('@/components/layout/HomeLayout'), {
ssr: true, // 홈페이지는 SSR
loading: () => <div className="relative flex min-h-[100dvh] w-full max-w-[430px] flex-col" />,
});
export default function OptimizedClientLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const sseRequiredPaths = ['/chat', '/matching', '/alarm'];
const needsSSE = sseRequiredPaths.some((path) => pathname?.startsWith(path));
// 홈페이지는 SSE와 무관하므로 가장 가벼운 HomeLayout을 사용합니다.
if (pathname === '/home') {
return <HomeLayout>{children}</HomeLayout>;
}
// SSE가 필요한 경로에서만 무거운 ClientLayoutContent를 로드합니다.
if (needsSSE) {
return <ClientLayoutContent>{children}</ClientLayoutContent>;
}
// 그 외의 모든 페이지는 기본적인 BasicLayout을 사용합니다.
return <BasicLayout>{children}</BasicLayout>;
}
이 방법 덕분에 홈페이지의 경우, 모든 불필요한 의존성을 제거하여 번들 크기를 약 280KB 절약할 수 있었습니다.
2. 무거운 의존성 제거하기: react-icons → 기본 이모지
번들 크기 최적화에서 가장 효과적인 방법 중 하나는 무거운 라이브러리를 대체하는 것입니다. 저희 서비스는 react-icons를 사용하고 있었는데, 이 라이브러리가 모든 아이콘을 번들에 포함시켜 약 200KB의 추가 번들 크기를 유발했습니다.
이를 해결하기 위해 react-icons 대신 유니코드 이모지를 사용하도록 변경했습니다. 이모지는 폰트의 일부로 취급되므로, 별도의 라이브러리 번들 없이 아이콘을 표시할 수 있습니다.
// HomeLayout.tsx
// react-icons 대신 유니코드 이모지 사용
<nav className="fixed bottom-0 z-50 flex h-[3.5rem] w-full max-w-[430px] items-center justify-around bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)]">
{navItems.map(({ path, label, isActive }) => (
<button key={path} onClick={() => router.push(path)} className="...">
<div className="...">
{path === '/home' && '🏠'}
{path === '/report' && '📊'}
{path === '/chat' && '💬'}
{path === '/mypage' && '👤'}
</div>
<p className="text-[0.7rem] font-semibold">{label}</p>
</button>
))}
</nav>
이 작은 변화로 인해 200KB의 번들 크기를 절약할 수 있었습니다.
3. 미들웨어 인증 시스템 최적화
기존 미들웨어는 모든 요청에 대해 사용자 인증을 위한 API 호출을 수행했습니다. 이로 인해 페이지 로딩 전 200~500ms의 지연이 발생했습니다.
최적화된 미들웨어는 API 호출 없이 쿠키 정보만을 기반으로 인증 상태를 빠르게 확인하도록 변경했습니다. 또한 이미지나 정적 파일과 같은 불필요한 경로는 미들웨어 실행에서 제외하여 성능을 더욱 개선했습니다.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 정적 파일들은 미들웨어에서 제외
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname.startsWith('/images') ||
pathname.startsWith('/icons') ||
pathname.includes('.')
) {
return NextResponse.next();
}
// API 호출 없이 쿠키만으로 인증 체크
const refreshToken = request.cookies.get('refreshToken')?.value;
const hasAuthToken = Boolean(refreshToken);
if (hasAuthToken) {
return NextResponse.next();
}
// 인증 토큰이 없으면 로그인 페이지로 리다이렉트
return NextResponse.redirect(new URL('/login', request.url));
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
이 변경을 통해 인증 체크에 걸리는 시간이 1~5ms로 단축되어 라우팅 성능이 100배 이상 향상되었습니다.
최적화 결과 확인하기
이번 최적화 작업은 Lighthouse의 Core Web Vitals 지표에 직접적인 영향을 미쳤습니다.
| 지표 | 최적화 전 | 최적화 후 | 개선율 |
| Performance | 40점 | 70점 | +75% |
| FCP | 1.7s | 1.7s | 변화 없음 |
| LCP | 35.7s | 22.3s | -37.6% |
| Speed Index | 10.1s | 3.0s | -70.3% |
| TBT | 1,360ms | 210ms | -84.6% |
특히 LCP와 TBT 지표가 크게 개선되었는데, 이는 서버에서 불필요한 JavaScript 번들을 제거하고 메인 스레드 작업을 최소화한 결과로 보여줄 수 있습니다.
이미지로 확인하기




이번 프로젝트를 통해 성능 최적화는 단순히 기술적인 작업이 아니라, 사용자 경험에 대한 깊은 이해에서 시작된다는 것을 깨달았습니다. 특히 SSR 최적화를 진행하면서 작은 변화들이 큰 파급력을 가진다는 점을 느꼈는데, 단순히 react-icons를 이모지로 바꾼 것처럼 사소해 보이는 결정이 전체 번들 크기를 수백 킬로바이트나 줄이는 결과를 만들어내 놀라웠습니다. 눈에 띄지 않는 부분까지 세심하게 관리하는 것의 중요성을 다시 한 번 느꼈습니다.
가장 중요한 배움은 서버와 클라이언트 역할의 재정의였습니다. 최대한 SSR을 많이 적용하려다가 오히려 성능이 안좋아지는 시행착오를 많이 겪었습니다. 모든 것을 서버에서 렌더링하려 하기보다, 각 컴포넌트의 특성에 맞춰 렌더링 방식을 분리하고 최적화하는 것이 훨씬 효과적이었습니다. 앞으로는 아직 최적화되지 않은 영역에 이번에 적용했던 패턴을 확장하고, 실제 사용자 데이터를 기반으로 성능을 지속적으로 개선해 나갈 계획입니다. 감사합니다 :)
'👩🏻💻 Develop > Performance Optimization' 카테고리의 다른 글
| [Next.js] 성능 최적화를 위한 데이터 캐싱하기 (0) | 2025.10.13 |
|---|---|
| [Next.js] Next.js 이미지 최적화로 성능 60% 개선하기 (0) | 2025.09.19 |
| [Next.js] Dynamic Import로 번들 사이즈 25% 최적화하기 (0) | 2025.09.18 |
| [Next.js] Zustand를 활용한 성능 개선하기 (1) | 2025.09.13 |
| [Next.js] 메모이제이션 및 리렌더링을 활용한 TBT 43% 개선기 (3) | 2025.09.01 |