next/image로 구현하는 마우스 커서 기반 Parallax 배경 애니메이션

NARARIA03·2025년 5월 10일
0
post-thumbnail

개요

위와 같이 사용자가 마우스 커서를 움직이면, 배경 이미지가 흔들리는 Parallax 애니메이션을 구현했던 과정을 정리하려고 한다.

본격적인 구현에 앞서, 삽질을 하게 만든 원인인 next/image에 대해 먼저 정리해보자.

사용 기술 스택

  • Next: 15.2.5 page router
  • React: 19
  • @emotion/react, @emotion/styled: 11.14.0

next/image의 특징

이미지 렌더링을 직접 최적화하려면 생각보다 귀찮고 복잡한 작업이 많이 필요하다. 이러한 작업은 개발자의 리소스를 불필요하게 소모하게 된다.

그러나 Next.js가 제공하는 이미지 최적화 컴포넌트인 next/image를 사용하면, 다양한 이미지 최적화를 쉽게 적용할 수 있다. 어떤 최적화들을 제공하는지 정리해보자.


1. 이미지 포맷 최적화

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 서버로 이미지를 요청하면 이에 맞는 포맷의 이미지를 내려주기 때문에, 최신 포맷을 지원하지 않는 브라우저의 호환성도 신경쓰지 않을 수 있다.


2. 이미지 리사이징

img 태그의 srcset, sizes 속성을 활용하면 반응형 이미지를 렌더링할 때 브라우저의 뷰포트 크기에 가장 적절한 크기의 이미지를 로드할 수 있다. 이를 통해 이미지를 로드하는 시간을 최적화하고 렌더링 성능을 향상시킬 수 있다.

그러나 이 방법을 적용하려면 여러 크기의 이미지를 미리 생성하거나 이미지를 리사이징해주는 서버를 구축해야 하며, 아래처럼 이미지마다 srcsetsizes 속성을 일일히 작성해야 한다.

[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)

  • 지정된 크기로 이미지 렌더링
  • 브라우저가 이미지 요청 시 Next 서버가 width, height 기반으로 리사이징한 뒤 응답

반응형 방식 (fill, sizes props)

  • 부모 컨테이너에 꽉 차도록 이미지 렌더링
  • img 태그에 srcset 속성 자동 추가
  • 복잡한 반응형 이미지의 경우, sizes prop으로 정밀하게 이미지 요청 조건 제어
  • 브라우저가 특정 크기의 이미지 요청 시 Next 서버가 해당 크기로 리사이징한 뒤 응답

next.config.ts에서 별도의 설정을 하지 않으면 기본적으로 256px부터 3840px까지 10개의 사이즈로 이미지를 리사이징할 수 있다.


3. 캐싱

위에서 정리한 이미지 포맷 최적화와 리사이징 작업을 매 요청마다 수행한다면, 작업 시간 때문에 오히려 응답 시간이 길어질 것이고 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: 캐시에 이미지가 있지만 만료되어 재검증 필요

4. Lazy Loading

출처: 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가 콘솔에 아래와 같은 경고를 출력하므로, priority prop이 필요한지 쉽게 알 수 있다.

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

5. Layout Shift 방지

출처: 레이아웃 변경 횟수(CLS)

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/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;
`;

이미지에 여유 공간 확보하기

커서 위치에 따라 이미지가 움직이게 하려면 이미지가 부모 요소보다 더 커야 한다.

처음에는 이미지를 부모 요소보다 더 크게 만들기 위해 widthheight를 120%로 설정하고, 부모 요소에 overflow: hidden을 주는 방식으로 시도했지만, 제대로 적용되지 않았다.

원인은 next/image에 fill prop을 사용하면, 자동으로 position: absoluteinset: 0 스타일이 적용되기 때문이다. 이로 인해 이미지가 부모 요소를 기준으로 정확히 꽉 차게 렌더링되어 width와 height 속성이 적용되지 않는 것이다.

위에서 fill prop을 사용할 때, 부모 요소에 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() 비율이 너무 크면, 이미지 품질 저하가 발생할 수 있으므로 적절한 값을 사용해야 한다.


Parallax 애니메이션 커스텀 훅 구현

애니메이션을 구현하기 위해서는 마우스 커서 위치가 변할 때마다 이미지의 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]);

Parallax 커스텀 훅을 컴포넌트에 연결

이제 위에서 만든 커스텀 훅을 컴포넌트에 연결해보자.

부모 요소에 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 애니메이션 구현에 대해 궁금했던 분들에게 도움이 되었기를 바랍니다.

이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다!

profile
신입 프론트엔드 개발자입니다. React와 RN 생태계를 좋아합니다.

0개의 댓글