본문으로 건너뛰기

"nextjs" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

모든 태그 보기

도커 이미지 78% 경량화와 CDN 적용기

· 약 4분
김현재
개발자

들어가며

학교 캡스톤 프로젝트로 시작한 체험단 서비스인 ‘체험콕'을 개발중이며, 실제 운영까지 목표로 하다 보니 CI/CD 구축은 필수였습니다.

다만 서비스 초기에 설정해둔 CI/CD 과정은 실제로 서비스가 가동되기까지 오랜 시간이 걸렸고, 이 문제를 해결하기 위해 필자가 겪었던 과정을 정리했습니다. 또 저와 같은 주니어 개발자들이 본인의 프로젝트에도 직접 적용해보는 과정을 겪으면 좋겠다는 마음에 이번 포스팅을 작성했습니다.

도커 이미지 경량화

Next.js 프로젝트를 컨테이너로 배포하기 위해서는 도커 이미지를 빌드해야 합니다. 도커 허브에 푸시하거나 빌드 서버에서 배포 서버로 이미지를 옮기는 상황을 고려하면, 이 도커 이미지의 용량이 작을수록 속도 면에서 유리할 것이라고 예측할 수 있습니다. 결국 도커 이미지의 용량을 줄이기 위해서는 빌드 파일의 용량을 경량화 시켜야 했고, Next.js에서는 빌드 결과물을 경량화할 수 있는 기능을 제공하고 있습니다. 저는 이 기능을 이용해서 경량화를 진행했습니다.

Next.js standalone 공식문서

standalone 옵션으로 빌드 결과물 경량화하기

먼저 Next.js 프로젝트의 next.config.tsoutput: ‘standalone’ 옵션을 추가했습니다.

next.config.ts의 output옵션 설정

옵션을 추가한 이후 next build 명령어를 실행하면, 빌드 결과물과 함께 .next/standalone 폴더가 생성됩니다. 이 폴더 안에는 프로덕션 서버 구동에 필요한 최소한의 의존성 파일만 존재합니다.

따라서 이미지를 빌드할 때 standalone 폴더와 함께 public, .next/static 폴더 그리고 환경 변수 파일 등 서비스 구동에 필요한 파일을 포함시키면 됩니다.

빌드 결과

Next.js 공식 문서에서는 static과 같은 정적 파일 (js, css 등)은 CDN을 통해 서빙하는 것을 권장하고 있습니다. 이 내용은 아래 섹션 CDN 파트에서 다룰 예정입니다.

체험콕의 경우에는 Jenkins가 구동 중인 서버에서 이미지를 빌드하고 있습니다. Jenkins 파이프라인에서 프로젝트를 빌드 할 때, Jenkins에 저장된 환경 변수 파일을 주입하는 방식을 사용합니다. 빌드가 완료되면, 위에서 설명한 서비스 구동에 필수적인 파일들만 포함하여 최종 도커 이미지를 생성합니다.

아래 이미지는 standalone 옵션을 사용하여 생성된 도커 이미지와, 사용하지 않고 생성된 도커 이미지의 용량 비교 사진입니다. standalone 옵션 적용 전인 1.58GB에서 354.4MB로 약 78%의 용량 개선이 이뤄졌습니다.

도커 이미지 용량 비교

빌드 시간 단축

CI/CD 구조를 개편하면서 빌드 시간도 단축됐습니다. 기존 6분 많게는 8분까지 걸리던 전체 파이프라인이 약 4분 내외로 완료되어 2~3분 가량의 시간을 단축시켰습니다. (아쉽게도 변경 전 빌드 시간을 확인하지 못하여 변경 후 사진만 업로드했습니다. 추후 확인이 가능하면 추가로 업데이트 할 예정입니다.)

파이프라인 소요 시간

CDN 적용

앞서 언급했듯이 publicstatic 폴더에 있는 정적 데이터(css, js, 이미지, 폰트 등)는 CDN을 통해 서빙하는 것이 성능 최적화에 유리하기 때문에 Next.js 공식 문서에서 권장하고 있습니다.

이 글에서 AWS S3 버킷 생성 또는 CloudFront 설정과 같은 구체적인 클라우드 서비스 설정 방법은 다루지 않을 예정입니다. 개인마다 사용하는 환경이 다를 수 있고, 이 부분은 본인이 사용하고 있는 스펙에 맞춰 다른 블로그를 참고해주길 바랍니다.

CDN을 연동하는 방법은 매우 간단합니다. next.config.ts 파일에서 assetPrefix 옵션을 설정해주면 끝입니다.

CDN 설정

저는 S3에 정적 파일들을 업로드하여 CDN과 연동했습니다. 주의할 점은 static 폴더 내부의 파일 또는 폴더들은 빌드마다 명칭이 변경됩니다. 따라서 빌드마다 새로 업로드를 해주도록 설정했습니다.

그렇다면 왜 빌드마다 명칭이 변경될까요?

CDN은 엣지 서버를 통해 정적 파일을 서빙합니다. 그리고 각 엣지서버는 더 나은 성능을 위해 정적 파일들의 정보를 캐싱합니다. 이 때, 우리가 프로젝트의 기능을 업데이트해서 새로 빌드하고 올렸는데 파일의 명칭이 동일하다면 엣지 서버는 파일이 변경되었음을 인식하지 못합니다. 이러한 문제를 방지하기 위해 빌드 시 파일명에 고유한 해시값을 붙여 파일이 변경되었음을 감지하도록 하며, 사용자는 이를 통해 최신 버전의 파일을 전달받습니다.

S3에 업로드한 정적 파일

이렇게 설정을 마무리하고 실제 배포 서비스에서 데이터를 어떻게 받아오는지 확인했습니다. CDN 적용 전에는 서비스를 실행중인 Next.js 서버에서 파일을 받아오는 것을 확인할 수 있습니다.

CDN 적용 전

CDN 적용 후에는 설정한 CloudFront 주소에서 받아오는 것을 확인할 수 있습니다. CDN 적용 후

마무리

CI/CD 파이프라인을 직접 구축하고 CDN을 적용해본 경험은 아주 값진 경험이었습니다. 주니어 개발자로서 인프라를 직접 만져볼 기회도 적었고, 어드민이나 B2B 서비스 개발에 주로 참여하다 보니 CDN 같은 기술을 사용할 기회도 많지 않았습니다. 이번 경험을 통해 서비스 성능 최적화의 중요성과 인프라에 대한 이해도를 한층 높일 수 있었고, 앞으로도 많은 시도를 해볼 예정입니다.

RSC + Streaming SSR로 대시보드 체감 속도 2배 높이기

· 약 4분
김현재
개발자

들어가며

사내 보안 대시보드를 개발하던 중, 이상한 점을 발견했습니다. 기능 구현은 완료됐는데 Lighthouse를 돌려보니 FCP(First Contentful Paint)가 3초를 훌쩍 넘기고 있었습니다. 사용자는 페이지에 접속하자마자 3초 이상 빈 화면을 마주하고 있었던 것입니다.

원인이 무엇이였을까요? 대시보드 페이지는 여러 API를 동시에 호출해야 했고, SSR 환경에서 서버가 모든 데이터를 다 받아온 뒤에야 HTML을 클라이언트로 전송하고 있었습니다. 모든 응답이 끝날 때까지 기다리는 구조였으니, FCP가 3초를 넘는 건 당연한 결과였습니다.

이번 포스팅에서는 Next.js의 RSC(React Server Components)Streaming SSR 을 조합하여 이 문제를 해결한 경험을 정리했습니다.

본 포스팅은 사내 프로젝트의 경험을 바탕으로 작성되었으나, 보안 정책상 실제 코드와 이미지를 그대로 사용할 수 없습니다. 따라서 프로젝트에서 해결한 핵심 로직과 메커니즘을 동일하게 재현한 별도의 데모 코드와 자료를 활용하여 정리했습니다.

기존 방식의 문제점

App Router에서는 페이지 컴포넌트 자체를 async로 선언하고 직접 데이터를 fetch할 수 있습니다. Promise.all로 병렬 요청을 보내면 총 대기 시간을 줄일 수 있지만, 근본적인 한계가 있습니다.

// page.tsx — async Server Component
export default async function DashboardPage() {
// 병렬 요청이지만, 모두 완료될 때까지 렌더링 불가
const [threats, assets, compliance] = await Promise.all([
fetchThreats(), // 800ms
fetchAssets(), // 700ms
fetchCompliance(), // 1200ms ← 이 API가 전체를 결정
]);

return (
<div>
<ThreatCard data={threats} />
<AssetCard data={assets} />
<ComplianceCard data={compliance} />
</div>
);
}
서버                          브라우저
|── fetchThreats() ──▶ 800ms |
|── fetchAssets() ───▶ 700ms | 사용자는
|── fetchCompliance()▶ 1200ms | 이 동안
| | 아무것도
|◀── 모든 응답 완료 ──────────| 볼 수 없음
|── HTML 전송 ───────────────▶|

Promise.all로 병렬 요청을 보내더라도, 모든 응답이 완료될 때까지 HTML 전송 자체가 불가능합니다. 가장 느린 API(1,200ms)가 전체 페이지 속도를 결정하며, 이는 Web Vital의 핵심 지표인 FCP에 직접적인 영향을 줍니다.

그렇다면 await을 하나씩 순차적으로 사용하면 어떨까요? 오히려 더 느려집니다. 직렬로 요청하면 800 + 700 + 1,200 = 2,700ms를 기다려야 하기 때문입니다. Promise.all이든 순차 await이든, 페이지 컴포넌트의 async 함수가 완전히 끝나야 React가 렌더링을 시작한다는 구조 자체가 문제입니다. 그렇다면 어떻게 이 문제를 해결할 수 있을까요?

해결책: RSC + Streaming SSR

Next.js App Router는 두 가지 핵심 기능을 제공합니다.

  • RSC(React Server Components): 컴포넌트 자체가 async 함수가 될 수 있어, 컴포넌트 단위로 데이터 패칭이 가능합니다.
  • Streaming SSR: HTML을 조각(chunk) 단위로 나눠서 준비되는 대로 클라이언트에 전송합니다.

이 두 가지를 Suspense와 조합하면 섹션별로 독립적인 스트리밍이 가능해집니다.

구현 방법

루트 페이지를 Shell로 만들기

페이지 컴포넌트를 async로 선언하지 않으면, 서버는 데이터를 기다리지 않고 즉시 HTML을 생성해 전송합니다. 이 Shell이 클라이언트에 도착하는 순간 FCP가 기록됩니다.

Shell이란 데이터 없이 즉시 렌더링할 수 있는 페이지의 뼈대를 말합니다. 사용자는 빈 화면 대신 페이지 구조를 먼저 볼 수 있습니다.

// page.tsx — async 없이 즉시 렌더링
export default function DashboardPage() {
return <main>{/* 각 섹션은 Suspense로 감싸 독립적으로 스트리밍 */}</main>;
}

각 섹션을 RSC로 분리하기

// ThreatSection.tsx — async RSC
export default async function ThreatSection() {
const data = await fetchThreatData(); // 이 컴포넌트만을 위한 fetch
return <ThreatCard data={data} />;
}

각 섹션이 독립적인 컴포넌트로 분리되어 있기 때문에, 모든 fetch가 동시에 시작됩니다. 페이지 컴포넌트가 await Promise.all로 묶어서 기다리지 않아도 됩니다.

Suspense로 감싸 스켈레톤 UI 제공하기

Suspense로 감싼 RSC는 데이터가 준비될 때까지 fallback(스켈레톤)을 보여줍니다. 데이터가 도착하면 React가 스켈레톤을 실제 컴포넌트로 교체하고, 그 결과를 스트리밍으로 클라이언트에 전송합니다.

<main>
<Suspense fallback={<ThreatCardSkeleton />}>
<ThreatSection /> {/* 800ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<AssetCardSkeleton />}>
<AssetSection /> {/* 700ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<IssueTableSkeleton />}>
<IssueSection /> {/* 600ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<ComplianceTableSkeleton />}>
<ComplianceSection /> {/* 1200ms 후 스트리밍 */}
</Suspense>
</main>

스트리밍 타임라인

이렇게 구현하면 다음과 같은 순서로 화면이 채워집니다.

0ms     → Shell 즉시 전송 (FCP 기록)
0ms → 4개 섹션이 동시에 fetch 시작 (병렬)
+600ms → IssueSection 완료, 스트리밍
+700ms → AssetSection 완료, 스트리밍
+800ms → ThreatSection 완료, 스트리밍
+1200ms → ComplianceSection 완료, 스트리밍

기존 방식이라면 1,200ms가 지나도록 빈 화면이었겠지만, 이제는 600ms부터 순차적으로 콘텐츠가 채워집니다.

성능 비교

지표기존 방식RSC + Streaming
FCP (First Contentful Paint)~1,200ms 이상~200ms (shell)
LCP (Largest Contentful Paint)~3,000ms~900ms
체감 로딩 시간~3,000ms~1,500ms 이하

수치상으로는 절반이지만, 사용자가 느끼는 체감 속도 차이는 훨씬 큽니다. 기존에는 로딩이 끝날 때까지 아무것도 할 수 없었다면, 이제는 화면이 뜨는 순간부터 순차적으로 정보를 확인할 수 있습니다.

주의할 점

스켈레톤 UI는 필수

Suspensefallback이 없으면 섹션 자리가 비어있다가 갑자기 내용이 나타나 레이아웃 시프트(CLS)가 발생합니다. 실제 컴포넌트와 동일한 크기의 스켈레톤을 반드시 제공해야 합니다.

섹션 경계 설계

Suspense 경계를 너무 잘게 나누면 관리가 복잡해지고, 너무 크게 잡으면 스트리밍 효과가 줄어듭니다. 사용자에게 의미 있는 단위로 섹션을 나누는 것이 중요합니다.

예를 들어 Suspense 경계를 너무 잘게 나누면 네트워크 요청 수가 늘어나 오히려 비효율적인 방법이 될 수 있습니다.

마무리

CSR에서 SSR로 전환하면 성능이 좋아질 거라 막연히 기대했습니다. 하지만 SSR도 만능이 아니었습니다. 서버에서 모든 데이터를 기다렸다가 한 번에 렌더링하는 구조는, 오히려 CSR보다 긴 공백 화면을 만들어낼 수 있습니다.

RSC와 Streaming SSR은 그 한계를 보완하기 위해 Next.js가 내놓은 해답이었습니다. 이번 경험을 통해 기술을 도입할 때 "왜 이 기술이 등장했는가"를 먼저 이해하는 것이 중요하다는 걸 다시 한번 느꼈습니다.