홈으로 이동컴포넌트 라이브러리로 이동이모티콘 스토리지로 이동
GitHub 프로필 새 창으로 열기
Next.js 마이그레이션 및 페이지 성능 개선 회고 배너
2024년 12월 12일·읽는 데 약 5분

Next.js 마이그레이션 및 페이지 성능 개선 회고

관련 글

음성인식 기반 아동 영어 교육 서비스 RockieTalkie

2024. 12. 30.

AI로 학생의 성장을 피드백하기까지: TOPIA Growth 시스템 개발기

2026. 6. 21.

유료 라이브러리 PSPDFKit 대체 :: PDF 에디터 내재화 여정

2026. 3. 31.

CRA에서 Next.js로 프로젝트 마이그레이션 진행

CRA, Craco 기반으로 운영되던 사내 메인 웹 프론트 서비스를 Next.js로 마이그레이션하는 작업을 제안하고 단독으로 진행한 후기를 공유하고자 합니다.

기존 프로젝트는 CRA 위에 Craco를 얹어 webpack 설정을 오버라이드하는 구조였어요. 하지만, CRA의 react-scripts가 deprecated 처리된 이후 이 위에서 서비스를 계속 키워가기는 어렵다고 판단이 들어 마이그레이션을 자연스럽게 생각하게 됐어요.

CSR을 유지하면서 Vite로 갈 것인지, SSR을 지원하는 Next.js, Remix로 넘어갈 것인지를 고민하다가 Next.js를 선택하게 되었고 그 이유는 아래와 같았어요.

가장 급한 건 이미지였어요. 기존 서비스는 원본 이미지를 아무 처리 없이 그대로 브라우저에 내려줬어요. CloudFront 같은 CDN 단의 리사이징이나 포맷 변환도 없어서 무거운 이미지가 원본 크기 그대로 로드되며 LCP를 악화시키고 있었죠. CDN 레벨에서 이미지 파이프라인을 직접 구축하는 방법도 있었지만 프론트 개선 작업치고 인프라 공수가 너무 컸어요. Next.js가 제공하는 next/image가 포맷 변환과 사이즈 대응, lazy loading을 대신해준다면 마이그레이션 하나로 같은 결과를 얻는 셈이니, 이쪽이 가성비가 좋다고 판단했어요.

또한, CRA 안에서는 SEO를 풀 방법이 없었어요. 아무리 helmet 같은 라이브러리를 사용해도 클라이언트 사이드 렌더링 특성상 크롤러에게는 빈 HTML이 먼저 도달하게 되고 메타 태그도 index.html에 정적으로 나열한 값이 전부이니까요. 페이지별 SEO나 소셜 공유 미리보기(OG 태그)를 제대로 만들려면 서버에서 완성된 메타데이터를 내려줘야 하는데, 이건 CRA에서 우회할 성질의 문제가 아니었어요. 렌더링을 서버까지 확장할 수 있는 프레임워크로 옮기는 게 유일한 방향이었죠.

당장 급하지는 않았지만, 모든 페이지가 CSR 하나에 묶여 있다는 점도 마음에 걸렸어요. 서비스가 커질수록 첫 페이지는 서버 렌더링, 정적인 페이지는 SSG처럼 페이지마다 다른 선택이 필요해질 텐데, 그때 가서 프레임워크를 옮길 수는 없으니까요. API Routes로 BFF 레이어를 둘 수 있다는 것도 같은 맥락이었어요. 전부 쓰지 못하더라도 필요할 때 꺼낼 수 있는 구조를 미리 확보해두고 싶었어요.

마이그레이션 진행

예상 기간은 새 기능 추가를 멈출 수 있는 마지노선인 2주였기 때문에 빠듯한 편이어서 선택과 집중이 요구되는 챌린지 작업이었어요.

우선, pages 라우터 방식과 app 라우터 방식 중에서는 리액트 서버 컴포넌트를 사용하기 위해 app 라우터를 택했어요. 모든 코드를 폴더 구조대로 옮기고 기존 index.html에 태그로 나열해두었던 SEO 설정도 프레임워크에 맞춰 파일 형식과 객체 형식으로 반영했어요.

다만 기존 CRA 코드가 클라이언트 컴포넌트만으로 동작했기 때문에, 전부 서버 컴포넌트로 옮기려면 너무 큰 작업 공수가 필요했어요. 그래서 우선 전체가 클라이언트 컴포넌트로 동작하도록 처리했어요. app 디렉토리의 페이지 자체는 서버 컴포넌트로 두되, 한 단계 아래 컴포넌트에는 모두 'use client'를 설정하는 방식으로 해결했어요.

로직을 고친 부분이 없었으니 화면 렌더링만 성공하면 이후 동작은 원래대로 작동한다고 보장할 수 있었죠. 기존 react-router-dom을 쓰던 코드는 전부 Next app router 코드로 교체해야 했어요. 이 부분은 기존에 쓰던 라이브러리에서 내부적으로 라우트 컴포넌트에서 처리하는 상태 로직이 있어서 마이그레이션 시 테스트에 신중해야 했어요.

이 시점에 전역 상태 관리 라이브러리도 Redux(Redux Toolkit)에서 Zustand로 함께 교체했어요. 일정이 빠듯한데 범위를 늘리는 결정이라 고민이 있었지만, 라우터 교체로 어차피 전역 구조를 전부 손보는 중이라 전환 비용이 가장 낮은 타이밍이라고 판단했어요.

Redux는 슬라이스, 액션, 디스패치로 이어지는 보일러플레이트가 많아 간단한 상태 하나에도 손이 많이 갔고, Next.js에서는 스토어를 쓰기 위한 Provider 세팅도 한 겹 더 필요했거든요. Zustand는 스토어 자체가 훅이라 Provider 없이 동작하고, 상태 정의부터 사용까지의 코드가 눈에 띄게 줄어서 이후 유지보수 비용을 생각하면 바꿀 때 한 번에 넘어가는 게 맞다고 봤어요.

2. 라이트하우스 검사 결과

우선 페이지 최적화를 진행하기에 앞서 현재 상태를 파악하기 위한 라이트하우스 검사를 진행했으며, 결과는 아래와 같았어요.

LCP까지의 시간이 약 3.9초로 측정되어 권장 소요 시간보다 훨씬 좋지 않게 나왔어요. LCP는 렌더링 과정 중 후반부에 발생하는 페이지 로딩 성능을 측정하는 지표로, 사용자가 페이지에서 가장 큰 콘텐츠(텍스트 블록, 이미지, 비디오 등)를 보기 시작한 시점을 측정해요. 참고로 라이트하우스는 CPU를 4배 감속하고 네트워크를 빠른 4G로 설정한 상태에서 측정한 결과값의 평균을 낸다고 해요.

LCP에 악영향을 미치는 요소들을 살펴보니, 위 이미지와 같이 비디오 요소가 렌더링되기까지 3,870밀리초가 소요되고 있었고 이 부분이 원인으로 지목됐죠.

3. 성능에 악영향을 미치는 요소들을 하나씩 해결

문제의 요소가 되는 비디오 컴포넌트 코드는 아래와 같아요.

typescript
Copied
1<video2  className='aspect-video rounded object-fill'3  src={실제수업영상}4  poster={비디오포스터}5  title='TOPIA Live Main Video'6  disablePictureInPicture={false}7  controls8  controlsList='nodownload'9  muted10  autoPlay11  loop12  playsInline13  onContextMenu={(e) => {14    e.preventDefault()15    return false16  }}17/>

코드로만 봐서는 딱히 문제될 점을 찾기 어려웠어요. 비디오 소스는 원격 CDN 서버에 저장된 mp4 비디오 파일을 다운로드하고 있었고, 비디오 데이터가 사용자에게 보이기 전까지 보여줄 썸네일도 poster 속성으로 처리해 딜레이를 예방하고 있었어요.

하지만 poster에 사용되는 로컬 이미지의 크기가 무려 1,280 x 720였고, 용량도 981.13KB로 거의 1MB에 가까웠어요. 심지어 poster에서 실제로 보여질 이미지의 크기보다 훨씬 커서 비효율적이었죠.

해당 이미지를 375 x 211 크기로 줄이고, png 형식에서 webp로 변환했더니 크기가 106.93KB까지 줄었어요. 추가로 video 태그에 preload='none' 속성을 추가한 뒤 다시 검사를 진행했으며, 결과는 아래와 같았어요.

이미지 사이즈만 줄였음에도 약 1,000밀리초의 단축을 이루어낼 수 있었죠.

하지만 여전히 페이지를 렌더링하기까지 오랜 시간이 걸렸고, 점수도 쉽게 상승하지 않았어요. 단순히 비디오 최적화 외에도 다른 원인이 있을 것으로 판단했어요.

4. LCP에 직접적으로 악영향을 주는 원인 해결

라이트하우스에서는 비디오 요소가 원인이라고 짚어주었지만, 실제 원인은 따로 있을 수 있다고 판단했어요. 여러 테스트를 해 본 결과 원인은 하나가 아닐 수도 있었고, 생각보다 쉽게 발견하기 어려운 영역에 숨어 있었거든요.

테스트 삼아 비디오를 렌더링하는 컴포넌트를 주석 처리하고 나머지 페이지를 빌드해 보았더니, 전혀 성능에 영향을 줄 것 같지 않은 작은 텍스트 요소들이 새로운 원인으로 지목되었어요.

이를 통해 단순히 비디오나 이미지만 원인이 아니라는 것을 확인했고, 여러 컴포넌트를 주석 처리하면서 직접적인 원인을 찾아야 했어요. 테스트 중 동기적으로 리소스를 다운로드하는 시간이 길어질수록 영향을 받을 가능성이 높아진다는 점을 확인했고, 그중 link 태그로 폰트를 다운로드하고 있는 부분을 발견했어요.

typescript
Copied
1<head>2	{/* Pretendard 폰트 다운로드 */}3	<link4	  rel='stylesheet'5	  as='style'6	  crossOrigin='anonymous'7	  href='https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard-dynamic-subset.min.css'8	/>9	{/* Lilita 폰트 다운로드 */}10	<link11	  rel='stylesheet'12	  as='style'13	  crossOrigin='anonymous'14	  href='https://fonts.googleapis.com/css2?family=Lilita+One&display=swap'15	/>16	// ...17</head>

서비스 전반에서 쓰이는 Pretendard 폰트에 비해, 극히 일부에만 사용되는 Lilita 폰트도 동기적으로 다운로드되고 있었기 때문에 렌더링 지연에 영향을 주고 있었어요.

단순히 폰트 link 다운로드 과정을 렌더링 완료 이후로 미루어 시간을 단축할 수도 있었어요. 하지만 그럴 경우 메인 화면에서 바로 필요한 Pretendard 폰트가 깜박이는 현상이 발생할 수 있었기 때문에 다른 조치가 필요했죠.

구글 폰트가 아닌 Pretendard 폰트는 우리 서비스에서 쓰이는 문자만으로 재구성한 가벼운 subset 폰트를 로컬 파일에 woff2, woff 형식으로 내장하는 방법을 채택했어요. 또한 구글 폰트인 Lilita 폰트는 Next에서 제공하는 구글 폰트 최적화 방식을 사용했어요.

typescript
Copied
1import { Lilita_One } from 'next/font/google'23const lilitaOne = Lilita_One({4  weight: '400',5  subsets: ['latin'],6  variable: '--font-lilita',7  display: 'swap',8})

위 요소들을 반영한 결과, 렌더링을 차단하면서 link 태그를 통해 다운로드되던 폰트들이 사라졌고 유의미한 시간 단축이 이루어졌어요.

상단 이미지에 보이는 css 파일까지 정리하면 더 좋았겠지만, 우리 서비스 프로젝트의 특성상 당장은 불가능에 가까웠어요. 1년 전 css, scss를 사용하던 스타일링 코드들을 tailwind로 마이그레이션하는 과정에서 여전히 일부 페이지는 css를 사용하고 있었고, 이 부분과 밀접하게 관련되어 있어 당장은 손대기 어렵다고 판단했어요.

동기적으로 다운로드되던 폰트 파일들을 최적화했지만, 점수가 상승하는 폭은 미미했어요. 또 다른 원인들이 LCP를 더 지연시키고 있는 것으로 보였죠.

5. 우리 코드가 아닌 외부 코드의 방해도 영향하

전반적인 원인을 파악하기 위해 모든 과정을 직접 볼 필요가 있었어요. 성능 탭에서 라이트하우스와 동일한 환경인 CPU 4배 감속과 빠른 4G 속도를 설정한 뒤 첫 페이지 렌더링을 캡처해 보았고, 아래 이미지와 같은 결과가 도출되었어요. LCP까지의 시간은 라이트하우스와 비슷하게 측정되어 단서를 찾을 수 있을 것으로 보였어요.

작업 박스의 빨간 부분은 권장 소요 시간을 초과한 부분을 나타내요. 전반적으로 싱글 스레드에서 많은 자바스크립트 코드를 읽는 데 상당한 시간을 소요하는 것으로 보였어요.

이는 대표적으로 물리적인 코드의 양이 지나치게 많거나, 쓰이는 곳은 많지 않은데 무거운 써드파티 라이브러리를 사용하고 있을 때 종종 발생한다고 경험했어요.

의심되는 부분을 찾기 위해 라이트하우스에서 위험 요소로 제시한 자바스크립트 관련 오류 문구를 살펴보니 아래와 같았어요.

Next에서 자체적으로 코드를 스플리팅하기 때문에 어떤 코드인지 바로 알 수는 없었지만, 눈에 띄게 관련 없는 코드 문구들을 발견했어요. 상단 오른쪽 이미지의 extension 관련 코드들이었죠.

크롬 익스텐션도 자바스크립트로 동작하기 때문에 자바스크립트 라이브러리들을 사용하게 되고, 이에 따라 페이지 렌더링에 영향을 미치고 있었어요.

이는 배포한 페이지 자체에는 영향을 미치지 않았지만, 생각했던 원인이 맞는지 확인하기 위해 크롬 시크릿 모드에서 아무런 익스텐션이 없는 상태로 테스트했어요. 그 결과 extension 관련 자바스크립트가 깔끔하게 사라지는 것을 확인했어요.

그럼에도 여전히 사용하지 않는 자바스크립트 파일들의 크기는 무시할 수 없었고, 이를 해결하기 위해 하나씩 코드를 따라가던 중 놀라운 사실을 하나 알게 되었죠.

6. 프로젝트에 따라 어쩔 수 없는 부분도 있습니다

우리 프로젝트에서는 pspdf라는 라이브러리를 사용해 PDF 이미지 파일 위에 글씨를 기록하는 유료 라이브러리 서비스를 구독하고 있었는데, 이 부분이 일반적인 라이브러리와는 다르게 동작하고 있었어요.

보통 컴포넌트를 Lazy Load하면 관련 없는 첫 페이지를 로드할 때는 다운로드하지 않다가, 해당 라이브러리 코드가 사용되는 컴포넌트가 페이지에 렌더링될 때 다운로드를 받게 돼요. 하지만 pspdf의 경우, 빌드 과정에서부터 코드를 복사하여 public 폴더에 위치시키고 실행하게 돼요.

이에 따라 pspdf가 사용되지 않는 첫 페이지가 렌더링될 때도 해당 코드를 자바스크립트가 파싱하기 때문에 상당한 시간이 소요될 것으로 예상했어요.

위와 같은 케이스를 고려하면, 페이지 라우트만 75개가량이고 코드 내부에는 비효율적인 로직과 중복 코드도 많은 우리 프로젝트 특성상, 첫 페이지만 개선한다고 점수를 만점에 가깝게 올리기는 어렵다는 결론에 도달했어요.

물론 전반적인 코드를 리팩토링하는 과정에서 더 나은 개선점을 찾을 수는 있겠지만, 약 2일에 걸쳐 살펴본 첫 페이지 렌더링 최적화는 아래 결과로 일단락했어요.

성능 점수 77점, LCP 3.9초라는 좋지 않은 결과에서 성능 90점, LCP 2.0초까지 개선했어요.

7. 레거시 코드구조 제거와 지표 추가 개선

LCP가 2초대에서 더 이상 줄지 않는 현상을 외부 라이브러리의 동기적 다운로드 문제로만 의심하던 중, 프로젝트 내 라우트에 필요한 최상단 페이지들이 하나의 index 파일로 수렴하고 있는 레거시 구조를 발견했어요.

예를 들어 /pages/index.ts 파일에 아래와 같이 라우트에 연결되는 모든 페이지가 한 번에 묶여 내려오고 있었어요.

javascript/pages/index.ts
Copied
1export {2  APage,3  BPage,4  CPage,5  DPage,6} from './student'

이렇게 되면 하나의 페이지만 로드하더라도 나머지 페이지들이 함께 엮여 모두 다운로드가 필요한 상황이 되죠. 이에 따라 첫 페이지에서 사용되지 않는 컴포넌트 청크 파일들도 한꺼번에 다운로드되며, 이 때문에 불필요한 자바스크립트 파일을 아무리 지워도 500KB 정도가 남아 있었어요.

따라서 위 index로 이어지는 코드 구조를 분해하고, 각 페이지마다 필요한 컴포넌트만 import하도록 변경했어요. 그 결과 불필요한 자바스크립트 파일이 사라졌고, 점수 또한 소폭 상승했죠.

  • CRA에서 Next.js로 프로젝트 마이그레이션 진행
  • 마이그레이션 진행
  • 2. 라이트하우스 검사 결과
  • 3. 성능에 악영향을 미치는 요소들을 하나씩 해결
  • 4. LCP에 직접적으로 악영향을 주는 원인 해결
  • 5. 우리 코드가 아닌 외부 코드의 방해도 영향하
  • 6. 프로젝트에 따라 어쩔 수 없는 부분도 있습니다
  • 7. 레거시 코드구조 제거와 지표 추가 개선