
위와 같이 사용자가 마우스 커서를 움직이면, 배경 이미지가 흔들리는 Parallax 애니메이션을 구현했던 과정을 정리하려고 한다.
본격적인 구현에 앞서, 삽질을 하게 만든 원인인 next/image에 대해 먼저 정리해보자.
사용 기술 스택
- Next: 15.2.5 page router
- React: 19
- @emotion/react, @emotion/styled: 11.14.0
이미지 렌더링을 직접 최적화하려면 생각보다 귀찮고 복잡한 작업이 많이 필요하다. 이러한 작업은 개발자의 리소스를 불필요하게 소모하게 된다.
그러나 Next.js가 제공하는 이미지 최적화 컴포넌트인 next/image를 사용하면, 다양한 이미지 최적화를 쉽게 적용할 수 있다. 어떤 최적화들을 제공하는지 정리해보자.
png, jpeg 등의 포맷과 화질은 거의 동일하나 용량은 90%이상 작은 WebP, AVIF 같은 포맷을 사용하면 이미지 로드 속도를 향상시킬 수 있다.
그러나 최신 포맷을 사용하려면 이미지들을 직접 변환하거나 포맷을 변환해주는 이미지 서버를 구축해야 하며, 지원하지 않는 브라우저를 대비해 picture 태그를 사용해서 아래처럼 호환성을 챙겨줘야 한다.
<picture>
<source srcset="/images/sample.avif" type="image/avif" />
<source srcset="/images/sample.webp" type="image/webp" />
<img src="/images/sample.png" alt="샘플 이미지" />
</picture>
하지만 next/image를 사용하면 Next 서버에서 On-demand 방식으로 이미지 포맷을 알아서 최적화해준다.
브라우저가 이미지를 요청하면, Next 서버에서 해당 시점에 이미지를 WebP 등의 포맷으로 변환한 뒤 응답하는 방식이다. 또 기본적으로 75% 품질로 이미지를 압축해 로딩 속도를 향상시킨다. (이 수치는 quality prop으로 조절할 수 있다)
브라우저가 Accept 헤더에 지원하는 포맷 정보를 담아 Next 서버로 이미지를 요청하면 이에 맞는 포맷의 이미지를 내려주기 때문에, 최신 포맷을 지원하지 않는 브라우저의 호환성도 신경쓰지 않을 수 있다.

img 태그의 srcset, sizes 속성을 활용하면 반응형 이미지를 렌더링할 때 브라우저의 뷰포트 크기에 가장 적절한 크기의 이미지를 로드할 수 있다. 이를 통해 이미지를 로드하는 시간을 최적화하고 렌더링 성능을 향상시킬 수 있다.
그러나 이 방법을 적용하려면 여러 크기의 이미지를 미리 생성하거나 이미지를 리사이징해주는 서버를 구축해야 하며, 아래처럼 이미지마다 srcset과 sizes 속성을 일일히 작성해야 한다.
[600px 이하 뷰포트에선 100vw로 렌더링, 그 이상에서는 600px로 고정]
- 예시 1: 뷰포트가 400px이면 512w 이미지를 요청
- 예시 2: 뷰포트가 128px이면 256w 이미지를 요청
<img src="/images/sample-1024.webp" srcset="/images/sample-256.webp 256w, /images/sample-512.webp 512w, /images/sample-1024.webp 1024w" sizes="(max-width: 600px) 100vw, 600px" alt="샘플 이미지" />
하지만 next/image를 사용하면 Next 서버가 On-demand 방식으로 이미지 리사이징을 수행해준다. 이 때 렌더링할 이미지의 크기가 고정되어 있는지, 반응형인지에 따라 최적화 방법이 약간 달라진다.
크기 고정 방식 (width, height props)
반응형 방식 (fill, sizes props)
srcset 속성 자동 추가sizes prop으로 정밀하게 이미지 요청 조건 제어next.config.ts에서 별도의 설정을 하지 않으면 기본적으로 256px부터 3840px까지 10개의 사이즈로 이미지를 리사이징할 수 있다.
위에서 정리한 이미지 포맷 최적화와 리사이징 작업을 매 요청마다 수행한다면, 작업 시간 때문에 오히려 응답 시간이 길어질 것이고 Next 서버의 부하도 커질 것이다. 이런 문제를 해결하기 위해 next/image는 서버 사이드 캐싱을 자동으로 수행해준다.
| 캐시 전 | 캐시 후 |
|---|---|
![]() | ![]() |
위 테스트 결과를 보면 알 수 있듯 next/image를 사용하면 Next 서버가 이미지 포맷 최적화와 리사이징을 수행한 뒤 캐싱을 진행해, 이후 같은 포맷/사이즈의 이미지를 요청받으면 캐시를 사용하여 처리 시간을 크게 단축한다.
.next/cache/image디렉토리에서 아래처럼 캐싱된 이미지들을 확인할 수 있다.images ┣ 0M4yAlFC54VEsVUG6POOzL756N_JUyxnmF2LA-eHLbo ┃ ┗ 60.1746862037642...7nK7ZpGG8PqAF.webp ┣ 0pz72KZKkYQ_wA57ueDBWfXz8FtTKc6V0lIByeaUhq4 ┃ ┗ 60.1746862037589...4NzM5OTI4Ig.webp서버 사이드 캐시 적용 여부는 이미지 응답 헤더의
X-Nextjs-Cache를 통해 확인할 수 있다.
MISS: 캐시에 이미지가 없어 새롭게 최적화HIT: 캐시에서 이미지를 찾아 즉시 제공STALE: 캐시에 이미지가 있지만 만료되어 재검증 필요

출처: Simplifying Lazy Loading in Next.js
이미지에 Lazy Loading을 적용하면, 이미지가 뷰포트에 들어오기 전까지 로드되지 않기 때문에 LCP(최대 콘텐츠 렌더링 시간)를 단축시키는 데 도움이 된다.
next/image에는 기본적으로 Lazy Loading이 적용되어 있어 별다른 설정 없이도 성능 최적화를 기대할 수 있다.
참고로, 기본 img 태그에도
loading속성으로 Lazy Loading을 적용할 수 있다.<img src="/images/sample.png" alt="샘플 이미지" loading="lazy" />
단, 이미지가 처음부터 뷰포트 내에 표시되는 경우, Lazy Loading이 오히려 LCP를 지연시킬 수 있다. 이런 경우 priority prop을 추가해 Lazy Loading을 비활성화하고, 이미지를 preload 리소스로 등록해 브라우저가 우선적으로 로드하도록 설정해서 개선할 수 있다.
Lazy Loading으로 인해 LCP가 지연되는 경우 Next가 콘솔에 아래와 같은 경고를 출력하므로,
priorityprop이 필요한지 쉽게 알 수 있다.Image with src was detected as the Largest Contentful Paint (LCP). Please add the "priority" property if this image is above the fold. Read more: https://nextjs.org/docs/api-reference/next/image#priority

Layout Shift는 이미 렌더링된 요소의 위치가 갑자기 바뀌는 현상을 의미한다. 주로 이미지가 늦게 렌더링되거나, 조건부 렌더링 시 미리 자리를 확보하지 않은 경우 발생한다.
Layout Shift가 자주 발생하면 사용자 경험을 크게 저하시키기 때문에 최소화하는 것이 좋다. (갑자기 튀어나온 광고를 클릭해본 경험 다들 한 번쯤은 있을 것이다...)
next/image는 width, height props나 fill prop을 활용해 이미지가 로드되기 전에 렌더링 될 공간을 확보해서 Layout Shift를 방지한다.
참고로 img 태그도
width,height속성을 사용해 Layout Shift 현상을 막을 수 있다. (next/image도 이 방법을 사용한다)<img src="/images/sample.png" alt="샘플 이미지" width="800" height="450" />
next/image로 외부 이미지를 불러오려면, 먼저 next.config.ts에 외부 이미지 도메인을 등록해둬야 한다.
Q: 굳이 왜 이미지 도메인을 등록해야 할까?
A: 보안 문제를 막기 위함이다.
예를 들어 외부 API 응답에 담긴 이미지 src를 next/image로 렌더링하도록 구현했다고 가정해보자. next/image의 포맷 최적화, 리사이징, 캐싱 등은 모두 Next 서버에서 실행되는데, 외부 API 개발자가 악의적으로 이미지 src를 용량이 매우 큰 이미지로 변경하거나 과도한 요청을 유도한다면, Next 서버에 부하가 발생할 수 있다.이런 문제를 막기 위해
next.config.ts에 등록된 도메인이 아니면 이미지를 로드하지 않도록 제한하는 것이다.
사용할 이미지는 reddit에서 가져올 것이기 때문에, 와일드카드를 활용해 i.redd.it으로 시작하는 모든 url을 허용해주자.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
...
images: {
remotePatterns: [
{
hostname: 'i.redd.it',
pathname: '**/*',
},
],
},
};
export default nextConfig;
다음으로 next/image를 활용해 뷰포트 전체를 채우도록 이미지를 렌더링해보자. 반응형으로 렌더링해야 하므로 width, height props 대신 fill prop을 사용해야 한다.
// src/pages/test.tsx
import styled from '@emotion/styled';
import Image from 'next/image';
const Test = () => {
return (
<SSection>
<SImage
src="https://i.redd.it/66ic9c791ih61.png"
alt="background image"
fill
/>
</SSection>
);
};
export default Test;
const SImage = styled(Image)`
object-fit: cover;
`;
const SSection = styled.section`
width: 100vw;
height: 100vh;
`;

배경 이미지가 잘 렌더링됐지만, 콘솔에서 아래와 같은 경고를 확인할 수 있다.
Image with src "https://i.redd.it/66ic9c791ih61.png"
has "fill" and parent element with invalid "position".
Provided "static" should be one of absolute,fixed,relative.
fill prop을 사용해 이미지를 부모 요소에 꽉 채우는 경우 부모 요소의 position이 relative, absolute, fixed 중 하나여야 한다는 경고다.
아래처럼 부모 요소에 position: relative를 추가하면 해결할 수 있다.
// src/pages/test.tsx
...
const SSection = styled.section`
position: relative; /* 추가 */
width: 100vw;
height: 100vh;
`;
커서 위치에 따라 이미지가 움직이게 하려면 이미지가 부모 요소보다 더 커야 한다.
처음에는 이미지를 부모 요소보다 더 크게 만들기 위해 width와 height를 120%로 설정하고, 부모 요소에 overflow: hidden을 주는 방식으로 시도했지만, 제대로 적용되지 않았다.
원인은 next/image에 fill prop을 사용하면, 자동으로 position: absolute와 inset: 0 스타일이 적용되기 때문이다. 이로 인해 이미지가 부모 요소를 기준으로 정확히 꽉 차게 렌더링되어 width와 height 속성이 적용되지 않는 것이다.
위에서
fillprop을 사용할 때, 부모 요소에position: static(기본값)을 사용하지 말라는 경고가 나왔던 이유 역시 Image에position: absolute가 적용되기 때문이다.
이 문제를 해결하기 위한 다른 방법을 찾던 중, transform: scale() 속성을 활용해 해결할 수 있었다. 이미지에 transform: scale() 속성을 적용한 뒤, 부모 요소에 overflow: hidden을 주면 원하는 효과를 얻을 수 있다.
// src/pages/test.tsx
import styled from '@emotion/styled';
import Image from 'next/image';
const Test = () => {
return (
<SSection>
<SImage
src="https://i.redd.it/66ic9c791ih61.png"
alt="background image"
fill
css={{ transform: "scale(1.4)" }}
/>
</SSection>
);
};
export default Test;
const SImage = styled(Image)`
object-fit: cover;
`;
const SSection = styled.section`
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
`;
transform: scale()비율이 너무 크면, 이미지 품질 저하가 발생할 수 있으므로 적절한 값을 사용해야 한다.

애니메이션을 구현하기 위해서는 마우스 커서 위치가 변할 때마다 이미지의 transform: translate(xpx, ypx)을 업데이트해야 한다. 이를 위해 mousemove 이벤트를 활용할 것이다.
x, y 값 계산은 마우스 커서 좌표를 부모 요소의 크기로 나눠 0~1 사이 값으로 정규화한 뒤, 상수(커서 위치에 따라 이미지를 얼마나 움직일지 조정하는 값)를 곱해주면 된다.
이 로직을 커스텀 훅으로 작성해보자.
// src/hooks/useParallaxOffset
import { useEffect, useState, type RefObject } from 'react';
type TOffset = {
x: number;
y: number;
};
export const useParallaxOffset = (ref: RefObject<HTMLElement | null>) => {
const [offset, setOffset] = useState<TOffset>({ x: 0, y: 0 });
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleMouseMove = (e: MouseEvent) => {
const { width, height } = element.getBoundingClientRect();
const dx = (e.offsetX / width) * -25;
const dy = (e.offsetY / height) * -25;
setOffset({ x: dx, y: dy });
};
element.addEventListener('mousemove', handleMouseMove);
return () =>
element.removeEventListener('mousemove', handleMouseMove);
}, [ref]);
return offset;
};
mousemove이벤트는 초당 수백번까지도 발생할 수 있기 때문에,requestAnimationFrame(RAF)를 함께 사용해주는 것이 좋다.아래처럼 RAF를 함께 사용하면, 브라우저 렌더링 주기(일반적으로 1/60초)에 맞춰 콜백을 실행해 CPU 부하를 줄이고 부드러운 애니메이션을 적용할 수 있다.
useEffect(() => { ... let frameId: number | null = null; const handleMouseMove = (e: MouseEvent) => { if (frameId) return; frameId = requestAnimationFrame(() => { const { width, height } = element.getBoundingClientRect(); const dx = (e.offsetX / width) * -25; const dy = (e.offsetY / height) * -25; setOffset({ x: dx, y: dy }); frameId = null; }); }; ... }, [ref]);
이제 위에서 만든 커스텀 훅을 컴포넌트에 연결해보자.
부모 요소에 ref를 연결하고 훅의 파라미터로 넘긴 뒤, 반환된 offset을 이미지의 transform: translate() 값으로 넣어주면 된다.
// src/pages/test.tsx
import { useParallaxOffset } from '@/hooks/useParallaxOffset';
import styled from '@emotion/styled';
import Image from 'next/image';
import { useRef } from 'react';
const Test = () => {
const sectionRef = useRef<HTMLElement>(null);
const { x, y } = useParallaxOffset(sectionRef);
return (
<SSection ref={sectionRef}>
<SImage
src="https://i.redd.it/66ic9c791ih61.png"
alt="background image"
fill
css={{ transform: `scale(1.4) translate(${x}px, ${y}px)` }}
/>
</SSection>
);
};
export default Test;
const SImage = styled(Image)`
object-fit: cover;
`;
const SSection = styled.section`
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
`;
주의:
transform: translate()는 CSS 함수이기 때문에 파라미터(xpx, ypx)를 반드시 쉼표(,)로 구분해줘야 한다. 쉼표를 빠뜨리면 translate()가 적용되지 않는다.

여기까지 next/image의 최적화 기법에 대해 정리하고, 마우스 커서를 움직이면 배경이 따라 움직이는 Parallax 애니메이션을 구현해봤다.
단순한 효과지만, 글을 준비하며 next/image에 대해 공부하며 어떻게 이미지를 최적화하는지 정리해볼 수 있어 좋았다.
글 읽어주셔서 감사합니다. next/image 사용법이나 Parallax 애니메이션 구현에 대해 궁금했던 분들에게 도움이 되었기를 바랍니다.
이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다!