ryong.logryong.log
Next.js 마이그레이션 및 라이트하우스 성능 최적화 배너
2024년 12월 12일·읽는 데 약 5분

Next.js 마이그레이션 및 라이트하우스 성능 최적화

관련 글

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

2024. 12. 30.

OpenClaw를 활용한 hotfix 대응구축팀 설계 및 문서 관리 체계

2026. 3. 6.

BottomSheet Drag Close Handler 적용 가이드

2026. 2. 27.

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

CRA, Craco 기반으로 동작하던 사내 웹 프론트 서비스를 Next.js로 마이그레이션 하는 작업에 리드로써 착수하게 되었다. 예상 기간은 새로운 기능 추가가 이루어지지 않을 수 있는 마지노선인 2주로 빠듯한 편이었다. 프레임워크에서 제공해주는 여러 기능을 사용하면서 얻는 개발 생산성 향상과 클라이언트 렌더링으로 모든 것을 처리하던 것을 서버 컴포넌트에 렌더링과 API fetch를 일부 이관시킴으로써 좀 더 빠른 페이지 렌더링을 기대하는 것이 목표였다.

pages 라우터 방식과 app 라우터 방식 중에서 리액트 서버 컴포넌트를 사용하기 위해 app 라우터 방식을 채택하였다. 모든 코드를 폴더 구조에 맞도록 옮기고, SEO 또한 기존 index.html에 태그들로 나열되어 있던 것들을 프레임워크에 맞도록 파일 형식과 객체 형식으로 반영하였다.

다만, 기존 클라이언트 컴포넌트들로만 동작하는 CRA 코드들을 모두 서버 컴포넌트로 일부 이관시키는 작업은 너무 큰 작업 공수가 필요하기 때문에 모두 클라이언트 컴포넌트로 동작하도록 우선 처리하였다. app 디렉토리의 페이지 자체는 서버 컴포넌트로 두되, 한 단계 아래 위계의 컴포넌트들에 모두 'use client'를 설정함으로써 이를 해결하였다.

이로써, 로직이 수정된 부분은 없기 때문에 화면 렌더링만 성공하면 이후 동작들은 원래대로 작동하는 것이 보장되었으나, 기존 react-router-dom을 사용하던 코드들은 모두 다 Next app router 코드들로 교체를 해줘야 했고, 이 부분은 철저한 테스트가 필요했다.

위 과정을 모두 마치고 나서, 이전에는 신경쓰지 않았던 메인 페이지(첫 페이지) 라이트하우스 검사와 이로 인한 최적화를 진행하였다.

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 하도록 변경하였더니 불필요한 자바스크립트 파일이 거짓말처럼 사라지며, 점수 또한 소폭 상승하는 쾌거를 이루게 되었다.

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