이전에 Next.js Image 컴포넌트가 어떻게 동작하는지 설명했습니다. 이번에는 Next.js 이미지 컴포넌트를 가지고 어떻게 이미지 최적화를 시도했는지 설명드리도록 하겠습니다. 설명에 앞서서 제가 한 방식이 옳은 방식인지 아직 모르겠으며 시도하고 있는 과정 중에 있음을 말씀드립니다.
Next.js 에서 기본적으로 제공하고 있는 이미지 최적화 기술에는 이미지 파일 크기 최적화
, CLS 방지
, lazy loading
, 이미지 크기 최적화
가 있습니다. 이 글에서는 적용기(라 쓰고 분투기라 읽는다)를 위주로 소개해드리고자 하므로 각각의 자세한 설명은 공식 홈페이지 또는 다른 블로그를 참고하시기를 바랍니다.
간단하게만 설명을 드리자면, 다음과 같습니다.
Size Optimization
이미지의 파일 크기를 줄여서 네트워크를 통해 가져오는 이미지 로드 시간을 줄일 수 있습니다.
기능 구현을 위해 사용하는 방법으로는 1) 적절한 이미지 크기 요청, 2) 이미지 압축률 높이기 가 있습니다.
Visual Stability
화면에 페이지가 렌더링된 이후에 이미지 소스의 로딩이 완료되면서 이미지가 차지하는 공간만큼 다른 컨텐츠가 밀려나게 됩니다. (Cumulative Layout Shift, a.k.a. CLS)
이를 위해 Next.js 에서는 Image 태그에서 width, height 를 props 로 받아서 이미지 크기만큼 공간을 미리 잡아줌으로써 이미지가 로딩된 이후에 Layout Shift 가 발생하지 않도록 막습니다.
Faster Page Loads
이미지를 한꺼번에 불러오게 되면 페이지 가장 상단에 있는 이미지가 늦게 불러와질 수 있으며, 이 경우 사용자에게 빈 이미지 공간을 보여주게 됩니다.
현재 viewport 에 해당하는 이미지를 먼저 불러오고, 보이지 않는 이미지는 이후에 스크롤이 되어서 해당 이미지 위치가 viewport 에 진입했을 때 불러오도록 합니다.
Asset Flexibility
서비스를 빌드할 때 이미지 resizing 을 하는 것이 아니라 요청 시점에 필요한 크기를 담아서 서버에 요청합니다. 따라서 local 에 있는 (보통 assets 나 public 폴더에 있는) 이미지뿐만 아니라 서버에서 요청한 이미지도 이미지 요청 시점에 적절한 이미지 크기로 조절해서 요청합니다.(= On-demand image resizing
)
Next.js 의 이미지 캐시는 on-Demand 로 동작합니다. 즉, 이미지 리소스 요청 시점에 캐시를 만들게 되는데 해당 캐시 파일은 <distDir>/cache/images
하위에 저장이 됩니다. (저의 경우는 .next/cache/images 하위에 저장이 되었습니다.)
이 시점에서 문제가 발생하게 됩니다. 제가 다니는 회사에서는 Next.js SSR 서비스를 쉽게 관리하기 위해서 docker 를 사용하고 있는데, 새로 빌드를 하게 되면 새로운 docker 이미지가 만들어지면서 이전에 있던 빌드 파일이 모두 사라지게 되었습니다.
해결을 위해서는 docker 이미지가 교체되어도 /cache 폴더가 남아있도록 개발해야만 했습니다.
제가 여기서부터 잘못 해결한 것 같다는 생각이 듭니다. 쓸데없는 삽질을 더 보고 싶지 않으시다면 아래로 이동해주세요. Docker setting 을 제대로 했다면 두 번째 시도부터는 하지 않아도 됐을 것 같습니다.. (아직 확실치는 않음)
docker 이미지가 교체돼도 /cache 폴더를 남아있게 하기 위해서 SRE 파트 팀원 분과 협업을 진행했습니다. 처음 제안주신 방법은 기존 docker 이미지를 새로운 이미지로 교체하기 전에 S3에 cache 파일을 업로드한 후, 교체 후 이미지를 실행시키기 이전에 다시 S3 에 있던 데이터를 저장하는 방식 이었습니다.
- name: Save Image cache
run: |
POD=`kubectl get pods --no-headers -o custom-columns=":metadata.name" | grep [저장할 폴더명]`
kubectl cp $POD:/app/.next/cache/images/ ./images -c [저장할 폴더명]
- name: Copy image cache to webview container
run: |
POD=`kubectl get pods --no-headers -o custom-columns=":metadata.name" | grep [저장할 폴더명]`
kubectl cp images $POD:/app/.next/cache/ -c [저장할 폴더명]
kubectl exec $POD -c [저장할 폴더명] -- ls /app/.next/cache/images
CI/CD 쪽을 직접 세팅하지는 않아서 자세한 사항은 모르지만 이미지를 교체하기 전에 hook 같은 것을 이용해서 해당 작업을 진행했습니다.
위의 해결책을 사용할 경우 발생하는 문제가 몇 가지 있습니다.
첫 번째로, ‘안 쓰는 이미지의 캐시 제거 불가’ 입니다. 캐시가 새로 생성되지 않고 계속 유지되기 때문에 S3에 안 쓰는 이미지의 캐시가 계속 존재합니다. 물론 이는 S3의 세팅으로 해결이 가능하긴 합니다. hit 되지 않은 채 특정 기간이 지나게 되면 자동으로 삭제되는 기능이 존재하기 때문입니다. 그치만 해당 기능을 쓰지 않는 경우 발생할 수 있는 문제이기 때문에 기술했습니다.
두 번째로, ‘새로운 이미지에 대한 캐시가 존재하지 않는다’ 입니다. S3에 저장을 하는 cache 폴더는 이전 빌드 파일에 대한 이미지들입니다. 즉, 해당 빌드 파일이 배포된 이후에 새로운 이미지가 추가된 경우 이와 관련된 캐시는 존재하지 않습니다. on-demand 일 때(사용자 요청 시) 이미지 캐시가 생성되므로 여전히 처음 추가한 이미지에 대해서는 캐시가 생성될 때까지 기다려야 합니다.
세 번째로, docker 에서 auto scaling 이 발생해서 새로운 pod 가 뜨는 경우에도 새로운 pod에서 똑같이 추가된 이미지의 캐시가 존재하지 않으므로 첫 번째로 이미지를 요청한 사용자는 이미지 캐시가 만들어지기까지 대기해야 하는 문제가 발생한다는 점입니다. 사용자가 어떤 pod 로 진입할 지 알 수 없고 auto scaling 시점에 개발자가 직접 해당 pod 에서 이미지 캐시 생성을 요청할 수도 없는 노릇이기 때문에 해당 방법으로는 이 문제를 해결할 수 없었습니다.
캐시를 복사하는 방법으로는 완벽하게 캐시를 유지할 수 없고, 명확한 문제(3번)가 존재하는 방법이기 때문에 안 되는 방법이라고 판단했습니다.
대신 위의 문제를 해결하기 위해서 사내에서 사용하는 자체 image cache 서버를 이용해보기로 했습니다. 캐시를 관리하는 또 다른 저장소를 사용한다면 pod 의 교체, 새로운 빌드 파일과는 관계없이 캐시가 유지될 것이기 때문입니다.
Next.js 에서 제공하는 loader props 에 image cache 서버를 연결함으로써 Next.js 가 제공하는 이미지 최적화 기술 4가지( 이미지 파일 크기 최적화
, CLS 방지
, lazy loading
, 이미지 크기 최적화
) 중에 이미지 파일 크기 최적화, 이미지 크기 최적화를 image cache 서버에 위임하게 되면서 배포 시 캐시를 새로 만들어야 하는 문제를 해결할 수 있었습니다. CLS 방지와 lazy loading은 기존에 사용하던 Next.js 에서 Image 컴포넌트를 계속 사용하면서 기능을 제공받을 수 있었습니다.
두 가지를 모두 사용할 수 있는 이유에는 Image 컴포넌트의 loader
props 가 있습니다. custom 하게 만든 loader function 을 props 로 전달하게 되면 내장된 loader 대신 전달된 custom loader 가 사용됩니다. loader 에는 src, width, quality가 파라미터로 들어오게 되며 해당 값을 사용해서 loader를 생성할 수 있습니다.
import Image from 'next/image';
// custom loader
const imageLoader = ({ src, width, quality }) => {
return `[**image loader 주소(=image cache 서버 주소)**]/${src}?w=${width}&q=${quality || 75}`;
};
export default function Page() {
return (
<Image
loader={imageLoader}
src="me.png"
alt="Picture of the author"
width={500}
height={500}
/>
);
}
첫 번째로, image proxy 서버를 이용하게 되면 로컬에서 개발할 때는 public이나 assets에 추가된 이미지를 사용할 수 없게 됩니다. (로컬에만 존재하기 때문) 따라서 새로운 이미지를 추가하기 위해서는 별도의 클라우드 공간을 이용해야 하는 문제가 발생하게 되었습니다. 물론 이 문제는 클라우드 환경에 들어가서 직접 이미지를 올린다던지 등의 방법을 통해 해결할 수는 있습니다.
두 번째로, 모든 이미지를 Remote Image 로 사용하면서 Image 컴포넌트에 width, height 를 무조건 입력해주어야 하는 상황이 발생했습니다. width, height 를 요구하는 이유는 CLS 를 해결하기 위해서 입니다. Next.js 에서 CLS 를 방지하기 위해서 로드 전에는 이미지 크기만큼 공간을 미리 할당해놓는데, remote 이미지는 빌드 시에는 아무런 정보를 알 수 없기 때문에 이미지의 비율 또한 모릅니다. 이를 해결하기 위해서 사용자로부터 width, height 를 필수적으로 props 로 받게 됩니다. 지금까지 정적 이미지를 사용하도록 개발해왔기 때문에 width, height 가 필수가 아니였는데 필수로 변경되면서 모든 이미지들을 일일이 width, height 를 조회해서 props 로 전달해야 하는 공수가 발생했습니다. 이 또한 귀찮기는 하지만 아예 해결할 수 없는 문제는 아니긴 했습니다.
Since Next.js does not have access to remote files during the build process, you'll need to provide the width, height and optional blurDataURL props manually.
하지만 세 번째로 가장 큰 문제가 있었는데, 사내 image proxy 서버의 한계점 때문이었습니다. image proxy 서버를 태우기 위해서는 필수로 querystring 에 width 뿐만 아니라 height 값을 보내야 했습니다. 전달된 width, height 값으로 이미지를 resizing 하기 때문이었습니다. 사내 image proxy 서버는 width 만 전달되면 원본 이미지를 비율대로 줄여주는 기능이 없었고, 또한 next.js 에서는 viewport 의 width 크기에 따라서 자동으로 그에 맞는 이미지 사이즈를 요청하는데 해당 기능을 제대로 이용하면서 이미지 최적화를 진행하려면 반드시 호출 시 height 값을 구해야만 했습니다. (이미지 resize 는 내부적으로 sharp 으로 진행하고 있었기 때문에 백엔드 파트에 요청해서 얼마 뒤에 width 만 필수값으로 변경하고 비율대로 줄이는 기능이 추가되긴 했습니다.) loader 함수의 param으로 height 가 넘어오지 않기 때문에 직접 호출할 height 값을 구해야 하는데, 이미지가 클라우드에 저장되어 있기 때문에 불러와야만 height 를 구할 수 있었습니다. 캐시를 사용하기 위해서 원본 이미지를 불러오는 더 나쁜 상황이 만들어지게 된 것입니다.
이미지를 클라우드 공간에서 사용할 수 없게 되면서 개발 시에는 public 에 있는 이미지를, 배포 시에는 proxy 서버를 이용해서 public 공간에 있는 원본 이미지를 캐싱하는 방법을 생각하게 되었습니다.
loader에 전달되는 src 값이 public 폴더 기준이 아니라, Next.js 내부적으로 가지는 원본 경로값(캐시 이전에 한 번 더 저장)이였기 때문에 호출 시점에 public 하위에 있는 폴더구조를 알 수 없었습니다. 따라서 public/images/remote 라는 폴더에 캐싱하고자 하는 이미지를 모두 몰아넣고 replace 하는 방식으로 구현을 했습니다.
export function nextImageLoader(src: string, width: number | string) {
const publicPath = `/public/remote${src
.replace(/^\/_next\/static\/media/, '')
.split('.')
.reduce((acc, cur, idx) => {
if (idx === 0 || idx === 1) return acc;
return `${acc}.${cur}`;
})}`;
return `${NEXT_IMAGE_PROXY_URL}${APP_WEBVIEW_URL}${publicPath}&w=${width}&rs=sharp`;
}
이로서 개발 시에는 public, 배포 시에는 proxy 서버를 쓰면서 4가지의 모든 이미지 최적화 기능을 사용할 수 있게 되었습니다.
아직 적용해보지 않은 방법 중에 next github discussion 에 저와 같은 문제로 글을 올렸다가 해결책이라며 올리신 분이 있습니다. (관련 링크) 이 글을 쓰면서 찾아보니 Next.js 공식문서에서 .next/cache 폴더를 CI 에서 배포 시 유지할 수 있도록 설정하라는 문구가 있음도 알게 되었습니다. (관련 링크) discussion의 글이9 가장 효율적인 방법일지도 모르겠습니다. docker setting 을 잘 했으면 생기지 않았을 문제일 것 같기도 합니다.
글의 서두에서 적었듯, 이 글의 목적은 문제 해결법 제시 보다는 분투기에 가깝습니다. 제가 시도한 방법이 옳은 방법이라는 확신이 전혀 없고 오히려 댓글로(혹은 DM?) 더 좋은 방법을 제시해주시기를 바라고 있습니다. 다만, 지름길은 아닐지라도 문제를 해결하기 위해 고민했던 시간들의 흔적을 남기기 위해, 이 글을 읽는 개발자분들이 저와 같은 삽질을 조금이라도 덜 하셨으면 좋겠는 마음에 이 글을 작성했습니다.
저는 조금 더 찾아보고 적용해보면서 최적의 방법을 찾아가보려고 합니다. 언제쯤 레벨업이 되고 삽질을 멈출 수 있을까요? 다음 번 글을 작성할 때엔 제가 한 방향이 옳은 방향이라는 확신이 들었으면 좋겠습니다. (레벨업 x2 드링크가 있다면 마시고 싶습니다.) 지금까지 주니어 개발자의 삽질 분투기를 읽어주셔서 감사합니다.