Next.js 성능 개선기

Table of Contents

Next.js는 프레임워크로서 주어지는 React라고 할 수 있다. 주된 차이점은 라우팅이나, 서버 컴포넌트 등의 캡슐화도 있고, 링크, 이미지 등등 여러 기본적인 요소들에 대한 최적화가 포함되어 있다.

그런데 가끔 여기 포함된 로직을 원하지 않거나 수정해야 할 때가 있다. 회사에서 있었던 일인데, 이 과정에서 Next.js의 코드를 읽으면서 꽤 소중한 경험을 했다고 생각해서 기록으로 남겨놓는다.

Next.js는 내부 링크에 기본 anchor 태그를 사용하지 않을 것을 권장하고 있다. 이유는 next/link를 사용하지 않은 링크로 내부 페이지를 연결하면 브라우저는 이동시 마다 전체 앱을 새로 초기화하기 때문이다. 그 외에도 이런저런 최적화를 해 주긴 하는데, 그게 가장 중요하다. 이 동작은 next/routerrouter.push 함수를 통해 구현되어 있다.

그냥 router.push 를 사용하면 안되는걸까?

요 문제는 SEO와 연결되는데, 대부분의 검색 엔진은 페이지의 연결 관계를 anchor tag의 href 속성을 통해 파악하기 때문이다. 만약 onClick 핸들러에 router.push를 사용하여 페이지 이동을 구현한다면 검색 엔진은 두 페이지의 연결 관계를 제대로 이해하지 못할 것이다.

그런데 next/link가 해주는 일은 하나 더 있다. next/link 컴포넌트는 내부 링크가 가리키는 페이지의 props를 미리 prefetch해온다. 이 동작은 다음과 같은 상황에서 동작한다.

  • 링크 요소가 브라우저 viewport에 보일 때
  • 사용자가 링크 요소에 hover했을 때

next/link에서는 prefetch prop을 통해 이 동작을 비활성화할 수 있도록 했지만, hover시 prefetch는 비활성화가 불가능하다. 문서에서는 이 부분까지 제어하고 싶다면 직접 구현, 혹은 App router로의 마이그레이션을 권장하고 있다. 그 이유는 찾기 어려웠는데, 트래픽을 늘려 Vercel의 서버 호스팅 수익을 늘리기 위함이라는 추측이 가장 인상깊었다. (팩트라는 얘기는 아님)

이 prefetch 동작이 문제가 되는 것이, 게시글 리스트 페이지에서는 사용자가 원하지 않은 네트워크 요청을 드르르륵 보낼 수도 있었고, 무엇보다 prefetch의 기본값이 true였기 때문에 이 사실을 모르는 사람이 작업하면 페이지에 링크가 많은 경우(인기 검색어나 게시글 리스트가 있는 상황) 불필요한 트래픽이 발생하기 때문이다.

그래서! 링크 컴포넌트를 직접 구현하기로 했다. 내가 필요한 링크 컴포넌트의 기능들을 나열했다.

  • router.push를 통해 최적화된 페이지 이동이 가능해야 함
  • 커맨드 클릭, 휠 클릭(새 탭에서 열기) 등에 대한 동작을 native처럼 지원해야 함
  • 외부 링크의 경우 target="_blank"를 자동으로 추가해야 함
  • as prop을 지원해야 함; 쿼리스트링을 숨길 수 있어야 함
  • 구현한 링크 컴포넌트가 아닌 next/link의 사용을 경고 또는 에러를 통해 방지해야 함
  • 기존 next/link 컴포넌트를 대체하는데 어려움이 없어야 함(별도의 wrapper나 a tag를 추가 또는 제거할 필요가 없어야 함)

그 이후에는 내가 정리한 요구사항에 맞춰서 구현을 했다. next/link 코드를 많이 참고했다. href prop으로 제공된 주소가 내부 링크인지를 판단하거나, 페이지를 이동하는 등의 외부 로직들이 잘 분리되어 있어서 코드를 읽거나 활용하기 매우 편했다.

아무튼 이미지를 최적화하자

그 다음 차례는 next/image다. next/image가 이미지를 사용처에 맞는 크기로 최적화하여 제공한다는 사실은 꽤 잘 알려져 있다. 문제는 이 동작을 위해 이미지의 크기를 고정된 숫자로 요구하는데, 이 것이 많은 개발자나 디자이너가 좋아하지 않는 제약이기 때문에 이 최적화를 제대로 활용하지 못하는 경우가 많다. 우리 프로덕트의 경우에도 반응형 레이아웃을 위해 너비나 높이를 [64, 128]과 같은 형태로 넣으면 전역으로 정의해둔 breakpoint 기준으로 알아서 값이 변경되는데, 이 문제 때문에 이미지 컴포넌트를 제대로 활용할 수 없었다.
이미지 프록시 외에 next/image가 진행하는 최적화는 lazy loading과 mdn의 반응형 이미지 자습서에 나와 있는 것들인데, next/image 코드를 직접 보기보다 이걸 먼저 읽으면 도움이 많이 된다.

사실 이미지는 이미지 컴포넌트 자체에 불만이 있는 것은 아니고, 아이폰에서 편집된 사진(.heic) 파일을 처리하는 로직을 공통화할 필요도 있었고, alt 속성 기본값 지정이나 프록시를 태울 수 없는 외부 이미지에 대한 처리 등등.. 을 통합 관리하고 싶었기 때문에 이 쪽도 수정하기로 했다. 수정은 아니고, next/image 컴포넌트를 내가 필요한 수정과 함께 wrapping해서 사용한다는 개념이 맞겠다.

이미지도 링크와 마찬가지로 요구사항을 정리했다.

  • alt 속성의 기본값을 ""로 제공해야 함
  • 프록시를 태울 수 없는 이미지에 대해 에러를 표시하지 않고 원본 URL로 이미지를 제공해야 함
  • heic 포맷의 사진인 경우 표시할 수 있는 포맷으로 변환해서 보여줘야 함
  • styled-system으로 구현된 스타일 시스템에서 사용하는 반응형 width, height prop을 지원해야 함 (숫자 배열)

여기서 프록시를 태울 수 없는 이미지란, next.config.js에서 지정하지 않은 URL이 제공하는 외부 이미지를 뜻한다. 모든 URL을 허용하도록 와일드카드를 사용하는 방법도 있지만, 위험한 URL이 허용되거나 지나치게 많은 캐시 자원을 사용할 우려가 있었기 때문에 고려하지 않았다.
아무튼 next.config.js에서 허용한 도메인에 해당하지 않는 src가 입력된 경우, unoptimized proptrue를 주면 최적화 로직을 패스하고 이미지를 표시하는 것이 가능하고, 이를 이용하면 하나의 이미지 컴포넌트를 사용하면서 최적화가 가능한 녀석들만 골라서 프록시를 태울 수 있다.

이미지 작업을 하면서 next/image 컴포넌트의 동작에 대해 아주 잘 정리한 올리브영 기술블로그 포스트를 발견했는데 아주 큰 도움이 되었다. 개인적으로는 올리브영에 기술블로그가 잘 운영되고 있는 것도, Next.js를 사용하고 있는 것도 의외였는데 내용이 너무 좋아서 기억에 남았음..

얼마나 변했을까?

링크와 이미지를 직접 만들었는데, 원래보다 더 성능이 개선되지 않았다면 마음이 많이 아플 것 같았다. 그래서 얼마나 변했는지 한번 측정해 보았다. 측정 방법은 페이지를 강력 새로고침 후, 네트워크 탭에 찍히는 transferred 용량을 확인하였다.

payload 크기 비교

왼쪽이 최적화 전, 오른쪽이 최적화 후 payload이다.

우리 프로덕트의 콘텐츠 하나에 대한 비포/애프터를 측정해 보았다. 우선 링크의 preload 기능을 비활성화했기 때문에 불러오는 page props 자체도 많이 줄었고(사실 이 용량 자체가 큰 것도 문제지만), 이미지를 사용처 크기에 맞게 최적화해서 불러오기 때문에 이미지 용량 자체도 확 줄었다. 전반적인 payload 크기 비교 결과는 다음과 같았다.

페이지beforeafter변화
콘텐츠 리스트 페이지3.7MB689KB-81%
콘텐츠 페이지 12.0MB878KB-57%
콘텐츠 페이지 22.2MB873KB-61%

링크와 이미지가 많이 들어가는 리스트 페이지에서 가장 큰 용량 차이를 보였고, 추가로 lighthouse 성능 지표가 100점이 나오는 부수적인 경사가 있었다. 구글 검색 콘솔에서도 이미지가 있는 콘텐츠들은 개선이 필요하다는 경고가 표시되었는데(전체 페이지의 약 1/3), 개선이 이루어진 후 2달간 이런 경고가 표시되는 페이지가 더 이상 발생하지 않기도 했다. 이정도면 유의미한 개선이었다고 봐도 될 것 같아 안심했다.

lighthouse 성능 지표

반년 전엔 저게 58점이었는데…

만든 것을 쓰도록 강제하자

그러나 만약 새로운 개발자분이 오셔서 작업을 하시게 되면 어떻게 될까? 코드베이스가 작은 규모는 아니기 때문에, 이미지와 링크 컴포넌트를 제대로 인지하지 못한 개발자라면 Next.js에서 제공하는 컴포넌트를 반사적으로 사용할지도 모른다. 이런 상황에 대한 예방책을 고민하다가 ESLint 규칙을 추가하기로 했다. no-restricted-imports 규칙을 사용하면 된다.

// .eslintrc

// ...
    "no-restricted-imports": [
      "warn",
      {
        "paths": [
          {
            "name": "next/image",
            "importNames": ["default"],
            "message": "@/atoms/Image 컴포넌트를 사용해 주세요."
          },
          {
            "name": "next/link",
            "importNames": ["default"],
            "message": "@/atoms/Link 컴포넌트를 사용해 주세요."
          },
        ]
      }
    ]
// ...

배열의 첫 번째 값을 warn이 아니라 error로 사용한다면 경고가 아닌 에러가 발생하도록 할 수도 있는데, layout="fill"을 사용하는 일부 레거시 컴포넌트가 있어서 경고로만 정의하기로 했다. 아예 강제하는게 좋겠지만 그 컴포넌트에 대한 스펙이 어디에도 남아있지 않고, “이런 컴포넌트가 있다!” 라고만 알려줄 수 있다면 충분하다고 생각했다.

엄청 빠르고 가벼운 사이트

페이지의 용량이 줄어들면 어떤 이점이 있을까? 우선 트래픽이 감소하니 비용 측면에서도 이득일테고, 용량이 작은 페이지를 렌더링하면 당연히 브라우징 경험도 좋아질테고, 개인적으로는 휴대폰 요금제를 작은걸 쓰고 있어서(!) 우리 프로덕트가 제공하는 콘텐츠에 비해 너무 무겁다는 생각을 계속 갖고 있었다.

엄청 사소하고 개인적인 동기로 시작한 최적화긴 하지만, 가장 큰 장점은 SEO에서의 이점이다. 구글에서는 사이트 탐색 경험이 좋은 사이트에 검색 노출에 대한 우선순위 보상을 제공한다는 이야기를 지속적으로 해오고 있다. 물론 최근 밝혀지는 실험과 문서들에서 다른 큰 요인들이 대두되고 있긴 하지만 개발자 입장에서는 성능 개선을 하면서도 유입 유저 수에도 기여할 수 있으니 가장 의미가 큰 일 중 하나가 아닐까 하는 생각을 해본다.

그리고 작업 과정에서 Next.js의 레포지토리에서 코드를 직접 읽어본 경험이 뜻깊었다. 이 정도 규모의 레포지토리를 내가 읽는다고 뭐가 되나..? 하는 마음으로 시작했는데, 내 걱정보다 훨씬 쉬웠고, 많은 인사이트를 얻을 수 있었다. 물론 기존 동작에 대해 어느정도 알고 있고 - 이 코드가 구현하려는 지향점(웹 표준, 최적화 등)에 대해 공부한 상태에서 코드를 읽었지만 Next.js의 코드 자체도 읽기 편하게 작성되어 있었기 때문에 가능했던 일인 것 같다. 스터디에서 읽었던 클린 코드를 위한 권장사항들이 오버랩되면서 이런 식으로 실제 코드에 적용할 수 있구나 느끼면서 즐거운 시간을 보냈다.

작성한 링크 / 이미지 컴포넌트의 코드는 지금 다른 코드베이스와 엮여 있는 부분이 있는데, 기회가 되면 정리해서 gist같은 곳에 공유할 수 있도록 해야겠다.

Refs.