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

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

관련 글

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

2024. 12. 30.

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

2026. 6. 21.

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

2026. 3. 31.

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

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

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

다만 기존 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하도록 변경했어요. 그 결과 불필요한 자바스크립트 파일이 사라졌고, 점수 또한 소폭 상승했어요.

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