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

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

관련 글

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

2024. 12. 30.

TOPIA Live를 세계로 - i18n 전환기

2026. 8. 2.

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

2026. 6. 21.

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

TOPIA Live에 입사한 이래로 가장 하고 싶었던 프로젝트가 하나 있었어요. 바로 기존에 CRA, Craco 기반으로 운영되던 사내 메인 웹 프론트 서비스를 Next.js로 마이그레이션하는 작업이었죠. 학기 내 계획된 개발을 빨리 마무리하고, 해당 작업을 제안하고 진행한 후기를 공유하고자 합니다.

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

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

첫 번째 이유는 이미지 최적화였습니다. 기존 서비스는 원본 이미지를 아무 처리 없이 그대로 브라우저에 내려주고 있었어요. CloudFront 같은 CDN 단의 리사이징이나 포맷 변환도 없어서 무거운 이미지가 원본 크기 그대로 로드되며 LCP를 악화시키고 있었죠. CDN 레벨에서 이미지 파이프라인을 직접 구축하는 방법도 있었지만 프론트 개선 작업치고 인프라 공수가 아무래도 크다고 생각이 들었어요.

그러다 보니 next/image가 눈에 들어왔어요. 요청 시점에 서버가 디바이스에 맞는 크기로 리사이징하고 WebP 같은 포맷으로 변환해주니, 최적화 서버를 직접 운영하지 않고도 같은 효과를 얻는 셈이었죠. 여기에 크기 지정이 강제되어 레이아웃 시프트(CLS)를 막아주고, 뷰포트 밖 이미지는 lazy loading으로 미루고 첫 화면 이미지는 priority로 먼저 가져오는 식으로 지표별 대응이 프레임워크 안에서 가능하다는 점이 매력이었어요.

또한 CRA 안에서는 SEO 처리에 한계가 있었습니다. react-helmet 같은 라이브러리로 페이지별 메타 태그를 만들 수는 있지만, 태그가 JS 실행 이후에야 주입되다 보니 최초 HTML 응답에는 index.html에 정적으로 박아둔 값이 전부잖아요. Googlebot은 JS를 실행하니 뒤늦게라도 태그를 읽어가지만 렌더링 큐에 밀려 색인이 늦어지거나 빠질 수 있고, 카카오톡이나 페이스북처럼 JS를 실행하지 않는 소셜 스크래퍼는 아예 볼 방법이 없죠. 검색해보니 프리렌더링 같은 우회로도 있긴 한데, 결국 서버에서 완성된 HTML을 내려줘야 한다는 결론은 같더라고요. 그래서 렌더링을 서버까지 확장할 수 있는 프레임워크로 옮기는 게 근본적인 해결이라고 봤습니다.

당장 급하지는 않았지만 모든 페이지가 CSR 하나에 묶여 있다는 점도 마음에 걸렸어요. 서비스가 커질수록 첫 페이지는 SEO와 최신 데이터가 중요하니 SSR로, 내용이 고정된 페이지는 빌드 때 미리 만들어두는 SSG로, 페이지마다 다른 렌더링 전략이 필요해질 텐데요. 그때 가서 프레임워크를 옮길 수는 없으니까요.

Route Handlers로 BFF 레이어를 둘 수 있다는 것도 같은 맥락이었습니다. 클라이언트에 노출하면 안 되는 키를 숨기거나 여러 API 응답을 묶어서 내려주는 층이 언젠가 있어야 할 테니, 당장 전부 쓰지는 못해도 필요할 때 꺼낼 수 있는 구조를 미리 확보해두고 싶었어요.

마이그레이션 진행

작업에 쓸 수 있는 기간이 2주 남짓이라 처음부터 선택과 집중이 필요했습니다.

우선, pages 라우터와 app 라우터 중에서는 리액트 서버 컴포넌트를 쓸 수 있는 app 라우터를 택했어요. 모든 코드를 폴더 구조대로 옮기고, 기존 index.html에 태그로 나열해두었던 SEO 설정은 metadata 객체와 파일 컨벤션에 맞춰 설정했습니다.

다만 기존 CRA 코드는 클라이언트 컴포넌트만으로 동작해서 전부 서버 컴포넌트로 옮기기에는 공수가 너무 컸어요. 그래서 우선 전체가 클라이언트 컴포넌트로 동작하도록 처리했습니다. app 디렉토리의 페이지는 서버 컴포넌트로 두고 바로 아래 컴포넌트에 'use client'를 선언하는 방식이었죠.

이렇게 하면 페이지가 서버 컴포넌트로 남으니 generateMetadata 같은 서버 전용 기능을 그대로 쓸 수 있어요. 나중에 서버 컴포넌트 전환도 페이지 단위로 진행할 수 있고요.

로직을 고친 부분이 없으니 화면 렌더링만 성공하면 이후 동작은 원래대로 돌아간다고 보장할 수 있었습니다. 문제는 Next.js가 클라이언트 컴포넌트도 서버에서 한 번 프리렌더링한다는 부분이었는데요. CRA에서는 모든 코드가 브라우저에서만 돌았으니 window를 어디서든 참조해도 됐는데, 이제는 같은 코드가 서버에서도 한 번 실행되면서 window가 undefined인 환경을 만나게 된 거죠.

그래서 서버 실행과 클라이언트 실행을 구분하는 분기 작업을 함께 진행했어요. 렌더링 중에 브라우저 API를 만지던 코드는 typeof window 체크로 분기하거나 클라이언트에서만 실행되는 useEffect 안으로 옮겼고, 브라우저 전제가 깊어서 분리가 어려운 컴포넌트는 dynamic import의 ssr: false 옵션으로 아예 서버 렌더링에서 제외했습니다.

또한, react-router-dom을 쓰던 코드는 전부 Next 라우터로 교체하는 노가다 작업을 병행했어요. 단순 치환으로 끝나는 작업은 아니었죠. navigate로 넘기던 location state처럼 라우터가 들고 있던 상태는 Next 라우터에 대응 개념이 없어서 화면 전환이 있는 흐름마다 신중하게 테스트가 필요했습니다.

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

2주간의 마이그레이션은 큰 사고 없이 마무리됐습니다. 화면도 기존 동작도 모두 원래대로 돌아갔고, 서비스는 이제 Next.js 위에서 굴러가기 시작했죠. 그런데 배포된 페이지를 이리저리 둘러보다 보니, 서비스의 얼굴이나 다름없는 메인 페이지가 어딘가 살짝 무겁게 느껴졌어요.

마이그레이션의 명분 중 하나가 성능이었던 만큼 체감으로만 넘길 수는 없어서, 라이트하우스로 현재 상태부터 정확히 측정해보기로 했어요.

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. 레거시 코드구조 제거와 지표 추가 개선