Next.js Image 컴포넌트 동적 로컬 이미지 해결기

May 14, 2023

CLS를 피하고 싶어

Next.js Image 컴포넌트 동적 로컬 이미지 해결기

개요

next.js 프레임워크는 내장 컴포넌트로 Image 컴포넌트가 존재한다. Image 컴포넌트는 이미지 최적화를 위해 제공되는 기능이 다양하다.
  • wemp 확장자 자동 변환
  • 이미지 퀄리티 스케일링
  • Lazy Loading을 통한 CLS 방지
Cumulative Layout Shift
Cumulative Layout Shift
Cumulative Layout Shift(누적 레이아웃 이동, CLS) - web.dev
이 외에도 여러 기능이 존재하지만 위 3가지로 인해 성능적인 측면과 UX적인 측면에서 큰 이점을 가질 수 있다.

Image 컴포넌트 사용법

공식 문서를 기반으로 한 Lazy Loading이 적용된 이미지 컴포넌트 사용법은 다음과 같다.
1import Image from 'next/image'; 2 3export default function Page() { 4 return ( 5 <div> 6 <Image 7 src="/profile.png" 8 alt="alt" 9 width={500} // optional(Remote Image) 10 height={500} // optional(Remote Image) 11 placeholder="blur" // optional 12 blurDataURL="blurURL..." // optional(Remote Image) 13 /> 14 </div> 15 ); 16} 17
placeholderblur를 부여하면 아래 사진과 같은 효과를 볼 수 있다. 리모트 이미지의 경우에는 추가적으로 blurDataURL을 입력해주어야한다.
레이지 로딩이 적용된 이미지
레이지 로딩이 적용된 이미지

하지만 로컬 이미지가 동적이라면?

현재 내가 만드는 블로그 같은 경우는 포스트의 이미지 컴포넌트에 src로 로컬 경로를 받으면 해당 경로를 할당한다. 하지만 이런 방식으로 구현한다면 Image 컴포넌트가 로컬 이미지로 취급하지 않고 리모트 이미지로 취급하기에 placeholder의 효과를 볼 수 없게 된다. 왜냐면 import로 불러온 이미지를 src에 넣는 케이스와 문자열 경로를 넣는 케이스와 내부에서 다르게 처리하기 때문인듯 하다.
import를 통해 불러온 이미지를 콘솔에 찍었을 시
import를 통해 불러온 이미지를 콘솔에 찍었을 시
보시다싶이 import를 통해 이미지를 불러올 경우 객체 형태로 불러오게 되며 프로퍼티로 blurDataURL과 원본 크기 또한 가지고 있다. 하지만 단순히 파일 경로를 문자열로 넣을 경우에는 blurDataURLwidth 같은 정보들이 없기 때문에 이를 일일히 입력해주어야한다. (물론 입력한다고 lazy loading이 적용되지는 않았다) 하지만 import의 경우 동적인 값을 기반으로 불러올 수 없기 때문에 이를 해결할 방법을 찾아야 했다.

내가 해결한 방법

나는 이 문제를 해결하기 위해 import 대신에 require()를 사용하여 이미지를 가져오는 방식으로 수정했다. 기존의 코드는 다음과 같다.
1const PostContentImg = ({ src, alt, ...props } : Props) => { 2 return ( 3 <PostContentImgBox> 4 <img src={src} alt={alt} /> 5 {alt && <figcaption className="image-desc">{alt}</figcaption>} 6 </PostContentImgBox> 7 ) 8} 9
이를 동적 import 로 바꾸기 위해 require()를 적용시켰다.
1const PostContentImg = ({ src, alt } : Props) => { 2 const image = require(`../../../public${src}`).default; 3 const aspectRatio = image.width / image.height; 4 5 return ( 6 <PostContentImgBox aspectRatio={aspectRatio}> 7 <div className="image-box"> 8 <Image 9 src={image} 10 alt={alt} 11 placeholder='blur' 12 fill 13 loading = 'lazy' 14 sizes="100%" 15 /> 16 </div> 17 {alt && <figcaption className="image-desc">{alt}</figcaption>} 18 </PostContentImgBox> 19 ) 20} 21
이렇게 하면 이미지를 props의 경로에 따라 동적으로 가져올 수 있게 된다. 하지만 당연히 문제는 이것만으로 끝나지 않았다.

첫번째 이슈, 빌드 오류

너가 왜 거기서 나와
너가 왜 거기서 나와
위와 같은 방법으로 작성 후 페이지에 들어가보니 예상치 못한 오류가 나타났다.
./public/robots.txt Module parse failed: Unexpected character ' ' (1:1) You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders | User-agent: * | Allow: /
갑자기 뜬금없는 robots.txt 관련 오류가 나타났다. 어디서 연관이 생겼길래 robots.txt에 에러가 생겼는지 알 수가 없어서 일단 robots.txt을 제거하고 재빌드를 해보니 이번엔 사이트맵에서 동일한 오류가 났다. 현재 사용하고 있는 next-sitemap 라이브러리와 충돌이 일어난건지 robots.txt, sitemap.xml, sitemap-0.xml 이 3가지 파일에서 빌드시 에러 요소가 나타났다. 원인 불명이고 stackoverflow나 깃허브 이슈를 봐도 관련 내용이 없길래 이를 어쩌지 하다가 해결방법이 떠올랐다.

해결법

사실 해결이라기엔 뭐한게 그냥 문제의 근원을 없애버렸다. 그저 웹팩 빌드 시 사이트맵과 robot.txt를 제외하는것. 마치 교통사고를 줄이기 위해서 자동차를 없애는 무식한 방법이라 마음에 걸리긴 했지만 저 3개의 파일 자체가 빌드와 크게 상관이 없는 파일들이라 그냥 실행하기로 했다. 제거 방법으로는 npm을 통해 ignore-loader를 설치한 후 next.config.js에서 예외 처리를 실행했다.
1/** @type {import('next').NextConfig} */ 2const nextConfig = { 3 reactStrictMode: true, 4 compiler: { 5 styledComponents: true, 6 }, 7 webpack: (config) => { 8 config.module.rules.push({ 9 test: /^.*\/(robots\.txt|sitemap(-\d+)?\.xml)$/, 10 loader: 'ignore-loader', 11 }); 12 return config; 13 }, 14}; 15 16module.exports = nextConfig; 17
위와 같이 입력할 시 robots.txt, sitemap.xml, sitemap-0.xml 이 3가지 파일을 빌드시 제외하게 된다. 하지만 예외처리 한다고해서 sitemap-0.xml이 자동 생성 되지 않는것은 아니라서 SEO에는 문제가 없었다.

두번째 이슈, gif

첫번째 문제점을 고치자 두번째 문제점이 나왔다. 이번 이슈는 Image 컴포넌트의 gif blurDataURL 미지원.
import를 통해 불러온 gif를 콘솔에 찍었을 시
import를 통해 불러온 gif를 콘솔에 찍었을 시
만약 gif를 불러온 후 콘솔에 찍어보면 blurDataURL가 없는 걸 볼 수 있다. 그렇다보니 gif의 경우에는 blurDataURL를 추가적으로 입력해주어야한다.
1const PostContentImg = ({ src, alt } : Props) => { 2 const image = require(`../../../public${src}`).default; 3 const [loaded, setLoaded] = useState(false); 4 5 6 return ( 7 <PostContentImgBox> 8 <div className="image-box"> 9 { 10 image.src.includes('.gif') ? 11 <Image 12 src={image} 13 alt={alt} 14 placeholder='blur' 15 blurDataURL={image.src} 16 fill 17 loading="lazy" 18 sizes="100%" 19 /> 20 : 21 <Image 22 src={image} 23 alt={alt} 24 placeholder='blur' 25 fill 26 loading = 'lazy' 27 sizes="100%" 28 /> 29 } 30 </div> 31 {alt && <figcaption className="image-desc">{alt}</figcaption>} 32 </PostContentImgBox> 33 ) 34} 35 36export default PostContentImg; 37
이를 해결하기 위해 이미지 파일의 확장자에 대한 분기처리를 추가하였다. 이렇게만 해줘도 기본적으로 사이즈를 가지고 있기에 CLS는 일어나지 않지만, 시각적인 부분에서 이미지 영역에 대한 정보를 얻을 수 없다는 점이 아쉬웠다. 그래서 스켈레톤 UI까진 아니어도 간단하게 로드 전에 gif의 영역을 표시하도록 수정하였다.
1const PostContentImgBox = styled.figure<{ aspectRatio: number, loaded: boolean }>` 2 position: relative; 3 height: fit-content; 4 display: flex; 5 flex-direction: column; 6 max-width: 100%; 7 8 .image-box { 9 position: relative; 10 width: 80%; 11 margin: 0 auto 10px auto; 12 aspect-ratio: ${props => props.aspectRatio}; 13 background-color: ${props => props.loaded ? "transparent" : props.theme.blockColor}; 14 } 15 16 .image-desc { 17 color: #a6a6a6; 18 text-align: center; 19 } 20 21 @media (max-width: 900px) { 22 .post-img { 23 max-width: 100%; 24 } 25 } 26` 27 28const PostContentImg = ({ src, alt } : Props) => { 29 const image = require(`../../../public${src}`).default; 30 const aspectRatio = image.width / image.height; 31 const [loaded, setLoaded] = useState(false); 32 33 function loadComplete() { 34 setLoaded(true); 35 } 36 37 return ( 38 <PostContentImgBox aspectRatio={aspectRatio} loaded={loaded}> 39 <div className="image-box"> 40 { 41 image.src.includes('.gif') ? 42 <Image 43 src={image} 44 alt={alt} 45 placeholder='blur' 46 blurDataURL={image.src} 47 fill 48 onLoad={loadComplete} 49 loading='lazy' 50 sizes="100%" 51 /> 52 : 53 <Image 54 src={image} 55 alt={alt} 56 placeholder='blur' 57 fill 58 onLoad={loadComplete} 59 loading='lazy' 60 sizes="100%" 61 /> 62 } 63 </div> 64 {alt && <figcaption className="image-desc">{alt}</figcaption>} 65 </PostContentImgBox> 66 ) 67} 68 69export default PostContentImg; 70
Image 컴포넌트의 onLoad를 활용해서 이미지 로딩이 완료되는걸 판단하여 배경색을 변경하도록 수정하였다. 추가적으로 데스크탑 환경의 경우 레이아웃에서 포스트 영역의 가로 최대 80%만 차지하도록 구현하였는데, 이때 종횡비도 유지하기 위해 종횡비를 계산 후 컴포넌트에 부여하여 원본 비율을 유지하도록 변경하였다.