올해 6월 즈음에 NextJS로 블로그를 다시 만들었다.
당연히 배포는 편리성을 위해 Vercel로 해버렸다. 엄청나게 편리했지만, Vercel의
Image Optimization
에 예상하지 못한 문제가 있었다.
이 글에서 그 문제를 해결하는 과정을 설명해 보겠다. (이 글은 Next v12 기준으로 작성되었다.)먼저 Vercel과 NextJS에서
Image
컴포넌트를 사용하면 어떠한 이점이 있는 지 알아보자!Image Optimization with Vercel
NextJS에서 아래와 같이
Image
컴포넌트를 사용하면 기본적으로 NextJS가 이미지를 최적화를 해준다.NextJS Image 사용 시 이점
- 이미지를 Lazy Load한다. 현재 페이지에 보이는 시점에서 이미지를 로드하기 때문에 불필요한 요청을 보내지 않는다.
- • priority 속성을 적용하면 Lazy Load는 무시된다.
- 디바이스 사이즈 별로 이미지를 만들어 놓고 사용자의 디바이스에 맞는 이미지를 로드한다.
- Source Set 활용
- width, height를 지정하지 않고 layout: fill를 사용하면 100vw(default) 기준으로 크기를 인식해 이미지를 가져온다.
- 이미지 형식을 webp와 같은 용량이 작은 형식으로 변환하고 이미지를 로드한다.
- 이미지를 불러오기 전 width가 0px 또는 height가 0px인 경우 이미지를 불러온 후 이미지 크기 만큼 레이아웃이 밀리는 CLS (Cumulative Layout Shift) 현상을 width와 height를 미리 지정해 방지할 수 있다.
- 추가로, 이미지를 불러오기 전 보여줄 placeholder를 지정할 수 있다.
런타임에서의 NextJS Image
NextJS로 만든 프로젝트가 배포된 후
<Image />
로 로드한 이미지들은 어떻게 불러올까? 이미지가 프로젝트 내부에 있을 때, 외부에 있을 때에 따라 약간의 차이가 있다.프로젝트 내에 있는 Local Image
프로젝트를 빌드할 때 해당 이미지에 접근할 수 있어, 빌드 시 변환된 이미지가 생성된다. 또한 이미지의 width와 height를 자동으로 결정할 수 있다. (CLS 현상 방지 가능)
cache invalidation
이미지의 이름을 바꾸고 재배포한다.
cache expiration
Vercel로 배포 시 Edge Network에 한 달 동안 Cache된다. (직접 배포할 경우 커스텀 필요)
프로젝트 외부에 있는 Remote Image
빌드 중에 접근할 수 없기 때문에 width, height, blurDataURL를 직접 입력해줘야한다. 또한 배포 후 런타임에서 Remote Image가 웹 페이지에 표시되는 시점에 이미지 최적화를 수행한다.

런타임에서 특정 페이지(이미지)에 대한 첫 사용자가 접근할 때 이미지를 최적화하고
.next/cache
에 저장한다. 그리고 이후 다음 사용자가 동일한 이미지를 볼 때 cache된 이미지를 로드한다. cache invalidation
이미지 URL에 query string를 추가한다.
cache expiration
next.config.js
파일의 minimumCacheTTL(default : 60초)과 Cache-Control max-age 중 큰 값에 의해 결정된다.
Vercel를 사용할 경우, 최적화된 이미지들은 자동으로 Vercel Edge Network(CDN)에 올라간다.
Vercel을 사용하지 않을 경우, 따로 커스텀할 수 있다.예상하지 못한 문제
비용
여기까지 NextJS
Image
컴포넌트를 사용할 때, Vercel로 배포할 때 어떤 서비스를 사용하게 되는지 설명했다.
자동으로 알아서 최적화도 해주고 CDN에도 올려주고 아주 좋은 서비스다. 그럼, 저자는 무엇이 문제였을까?문제는 바로 비용이었다. 우리가 여태까지 알아본 NextJS
Image
컴포넌트를 사용하면 이미지를 최적화할 때마다 아래 오른쪽의 Image Optimization이 하나씩 증가한다.
내 블로그에서는 수많은 이미지를 사용하고 있다. 사용하는 이미지가 다 최적화되면 한 달에 Max 값인 1,000건 정도는 금방 채울 것이다. (실제로 블로그를 배포하고 Next
Image
사용할 때 첫 달에 최적화 건이 800개가 넘었다.)또한, 노션과 연동된 내 블로그는 프로젝트 재배포 없이 수정된 내용을 반영하기 위해 페이지에
revaildate
를 설정해 놓았다. revalidate
가 설정된 페이지는 다시 빌드할 때, 해당 페이지에 있는 모든 요소를 다시 생성하고 최적화한다. 따라서 해당 페이지에 있는 Remote Image들도 다시 런타임에서 최적화된다. Remote Image들이 다시 최적화될 수 있으니 페이지 방문자가 많을수록 Image Optimization은 빠르게 Max를 향해 갈 것이다.물론 Vercel에서 제공하는 Pro Plan으로 업그레이드하면 무제한으로 활용할 수 있지만 한 달에 $20는 아깝다..
그래서
next.config.js
에 아래와 같이 이미지 최적화를 하지 않겠다는 의미인 unoptimized
를 추가해 주면서 일단 비용 문제는 해결되었다. S3
내 블로그는 노션과 연동되어 있고 노션에 이미지를 업로드하면, 노션은 AWS S3에 이미지를 저장한다.
그리고 블로그의 페이지가 95% 이상 SSG로 구성되어 있어 빌드 시에 노션 API를 호출해서 페이지 정보를 가져온다. 이때 페이지 정보에 포함된 이미지 리스트에는 S3 링크들이 있고 각 링크에는 각각 만료 시간이 있다.
이미지 최적화를 하지 않기 위해
unoptimized
를 추가하면 이미지를 로드할 때 만료 시간이 있는 S3 링크를 그대로 가져와 로드한다.여기서 문제가 있다. 빌드 후 만료 시간이 지나고 사용자가 페이지를 방문하면, 이미지 링크는 만료가 되어 이미지가 표시되지 않는다.
이 문제를 해결하기 위해
revalidate
를 설정해 두었다. revalidate
시간이 지난 후에 사용자가 페이지를 방문하면 페이지가 다시 빌드되고 노션 API에서 만료 시간이 갱신된 이미지 링크를 다시 제공받아 페이지를 만들기 때문에 이미지 만료 문제가 해결된다. 그런데 이 방식에도 문제가 있다.바로 이미지 링크가 만료된 상태에서
revalidate
시간이 지난 후에 처음으로 페이지를 방문하는 사용자는 이미지가 만료된 페이지를 보게 되는 문제가 있다.revalidate
: 지정된 시간이 지난 후에 처음으로 페이지를 방문하는 경우, 페이지를 다시 빌드한다. 처음 방문한 사용자에게는 이전 페이지를 제공하며, 재빌드된 이후부터는 새로 갱신된 페이지를 제공한다.
어떻게 해결할까?
Next API Routes
위에서 설명했듯이 빌드 시에 노션 API를 호출하여 이미지 경로를 가져온다. 이때 만료 시간만 없애주면 문제가 해결될 것 같다. NextJS에서 제공하는 API Route를 활용해서 만료 시간을 없애보자!
방식은 간단하다.
위 코드는 특정 페이지에 대한 id로 노션 API를 호출하고 해당 정보에서 이미지를 가져와 만료 시간을 없애고 이미지를 보내줄 수 있다.
getStaticProps
에서 Cover 이미지의 경로는 /api/coverImage?pageId=${id}
으로 할당한다.
이로써 배포가 된 후 클라이언트 사이드 렌더링을 할 때 브라우저는 /api/coverImage?pageId=${id}
경로로 이미지를 요청하고 Next 서버가 해당 요청을 받아 만료 시간이 없는 이미지를 제공한다.Preview
비용과 S3 만료 문제는 해결되었지만, 이미지 로드 속도를 위 방식으로는 해결할 수는 없다.
그래서 아래와 같이 이미지가 아직 로드 중일 때는 Preview 이미지를 제공해 주자!

Preview 이미지는 클라이언트 사이드 렌더링에서 만드는 것이 아니라 빌드 시에 미리 만들어 놓아야 바로 사용할 수 있다.
이 방식은 Cover 이미지를 제공할 때랑 거의 같다. 이미지를 fetch해서 이미지 buffer를 추출한다.
여기서는
lqip-modern
라이브러리를 사용했는데 iqip는 low quality image placeholder의 약자로 이미지가 아직 로드 중일 때 낮은 품질의 동일한 이미지를 보여주는 방법이다. Preview 이미지는 경로를 전달하는 게 아니라 이미지를 빌드 시에 만들어 서버에서 가지고 있는다.
위와 같이 작성하여 이미지를 로드하는 중일 때 이미지에 대한 Preview가 존재하면
previewImage?.dataURIBase64
를 보여주도록 설정했다. DEFAULT_BLUR_BASE64
는 Preview 이미지 생성이 실패했을 때 보여줄 기본 placeholder이다.Cache-Control
마지막으로 이미지에 Cache-Control를 설정해 주었다.
이미지의
Cache-Control
이 public, max-age=3600
으로 설정되어 있다면 해당 이미지는 클라이언트의 로컬 캐시에 1시간 동안 저장된다. 따라서 이전에 방문한 사용자는 1시간 동안은 서버에 재요청을 보내지 않고 캐시된 이미지를 사용하게 된다. 이미지를 바꾸는 일은 거의 없어서 1시간으로 지정하였다.이렇게 페이지에는
revalidate
가 60초, 이미지 캐싱을 1시간으로 지정했을 때 유저 스토리는 아래와 같다.- 첫 번째 요청 : 클라이언트가 페이지를 요청하면 Next.js는 서버 사이드 렌더링을 통해 페이지를 생성하고 응답한다. 이때 이미지의 응답 헤더에는
Cache-Control: public, max-age=3600
이 포함된다. 이는 클라이언트가 해당 이미지를 1시간 동안 캐시할 수 있음을 의미한다.
revalidate
시간 경과 후 접속:revalidate
시간 후 처음으로 접속하면 페이지는 다시 빌드된다. 하지만 그 사용자는 이전 버전 페이지를 보게된다. 이미지는 브라우저의 메모리 캐시에서 가져온다.
- 업데이트된 페이지 접속:
revalidate
로 인해 재빌드된 페이지를 접속하면 이전 버전과 현재 버전의 페이지가 다른 경우 Status Code : 200를 받는다. 이전 버전과 현재 버전의 차이가 없다면 Status Code : 304와 함께 페이지를 Next 서버로부터 전달받는다. 이미지는 사용자가 처음 접속한 후 1시간이 지나지 않았다면 브라우저의 메모리 캐시에서 가져온다.
- 접속한 지 1시간 후 다시 접속 : 클라이언트는 이미지의 변경 여부를 확인하기 위해 서버에 조건부 요청을 보낸다. 서버는 이미지의 변경 여부를 확인하고 변경이 없다고 판단하면 Status Code : 304를 반환한다. 클라이언트는 이 경우에는 이전에 캐시된 이미지를 계속 사용한다. 페이지는 3번과 동일하다.
max-age=3600
는 1시간 동안 해당 리소스가 유효하다는 의미지 1시간 동안 해당 리소스를 캐시에 저장하고 그 후는 삭제하겠다는 의미가 아니다.추가로 고민해본 것들
빌드시에 이미지 만들기
Preview 이미지를 만들 때처럼 이미지를 빌드 시에 만드는 것이다. 하지만 이 방법은 빌드 시간이 늘어나는 문제와 서버에서 더 많은 처리와 더 많은 리소스를 가지고 있어야 하는 문제가 있다.
위 글에서 적용된 방식은 이미지 캐시를 사용해서 첫 요청에만 이미지를 Next 서버에서 생성하고, 그 이후 1시간 동안 브라우저에 캐시된 이미지를 사용한다. 그 이후에도 캐시가 유효한지 확인하고 변경되었을 때만 해당 API를 통해 이미지를 가져오기 때문에 굳이 빌드 시에 이미지를 만들 필요는 없다고 생각했다. 또 이미 Preview를 사용 중이므로 이미지 로딩이 느린 느낌도 딱히 없다.
normalizeUrl
블로그는 아래 참조에 걸린
nextjs-notion-starter-kit
를 많이 참고 했고 포스트를 렌더하는 부분은 transitive-bullshit가 만든 react-notion-x
라이브러리를 사용하고 있다.
포스트 렌더 관련 소스를 구경해보니 노션에서 제공해 주는 notion-utils의 normalizeUrl 사용하고 있다.
자세히는 못 봤지만, S3 링크의 만료 시간을 지우고 https://www.notion.so/image..
로 시작하는 이미지 경로로 변환할 수 있는 것 같다. Next Api Route (/api/coverImage
) 대신 해당 함수를 사용할 수도 있을 것 같다. 나중에 블로그를 추가 개발할 때 다시 살펴봐야겠다.Reference
nextjs-notion-starter-kit
transitive-bullshit • Updated Jan 6, 2024