[React] 무한 캐러셀(Infinite Carousel) 구현하기

Mandy·2024년 6월 11일
post-thumbnail

아이디어

피그마에서 작업한 내용대로 구현하기 위해 모달 내부에서 동작할 이미지 캐러셀이 필요하게 되었다.

캐러셀은 부트스트랩이나 라이브러리를 이용해서 구현해봤지만 그건 사실 캐러셀의 동작 원리를 모르더라도 구현할 수 있었기 때문에

이번에는 리액트만으로 구현하고자 했다.

우선, 모달 내에 있는 캐러셀이므로 수동으로 좌우 이동이 가능할 필요는 없을 것 같아서 자동으로 넘어가는 무한 캐러셀을 만들어야겠다는 생각을 했다.

아이디어 단계는 다음과 같다.

  1. 오른쪽에서 왼쪽으로 이미지가 넘어가는 캐러셀을 구현한다.
  2. 마지막 인덱스의 이미지에 도달했을 때 다시 처음 인덱스의 이미지로 이어지는 듯한 "무한"한 느낌을 주도록 한다.

구현해야 될 단계가 심플하다고 생각했기에 바로 실행해보았다.




1. 오른쪽에서 왼쪽으로 이미지가 넘어가는 캐러셀 구현

레이아웃 구성

오른쪽에서 왼쪽으로 이미지가 넘어가는 캐러셀, 즉 한마디로 이미지가 순차적으로 보이는게 목적이다.

이를 위해서 우선 기본적인 캐러셀의 원리를 파악할 필요가 있다.


가장 바깥에 컨테이너 역할을 할 div를 설정하고(하늘색 선), 그 안에 이미지를 담아낼 이미지 컨테이너를 만든다.

나는 5개의 이미지가 캐러셀에 포함되었으면 했기에 이미지 파일 경로를 배열로 만들고

위와 같은 형태로 순차적으로 배치되게끔 레이아웃과 css를 수정했다.

display: flex 를 이용하면 수월하게 스타일을 적용시킬 수 있다.

그리고 이 5개의 이미지를 감싸는 이미지 컨테이너 div도 빨간 선으로 표시해두었다.




그리고 이 이미지들이 1개씩 보이면서 오른쪽에 있는 이미지로 넘어가면 좋겠다는 생각을 했다.

그러기 위해서는 이렇게 처음에 0번째 인덱스의 이미지만 보여야 할 것이다.

캐러셀 구현에서 중요한 것은 캐러셀 컨테이너의 width, height는 고정값이어야 한다는 사실이다!

그렇기에 캐러셀 컨테이너의 css에 width, height를 이 0번째 인덱스 이미지 크기와 동일하게 작성해두자.

컨테이너 의 css에 영역을 넘어가는 2, 3, 4, 5 이미지는 사용자 눈에 보이지 않도록 overflow:hidden 처리해두면 된다.

그러면 맨 앞의 1개의 이미지만 보이게 된다.

코드로 작성하면 아래와 같다.

.carousel-container {
  width: 412px;
  height: 630px;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
}

나는 컨테이너 내부에 포함될 이미지 컨테이너의 수직/수평 정렬을 위해 justify-content와 align-items를 사용했다.


그리고 이 이미지 컨테이너 안에 이미지 배열을 요소로 렌더링할 수 있도록 jsx를 작성한다.

  return (
    <div className="carousel-container">
      <div className="carousel-image-container">
        {imageSrcArray.map((image, index) => (
          <Image
            key={`${index - 0}`}
            src={image}
            alt={image}
            width={412}
            height={630}
          />
        ))}
      </div>
    </div>
  );

나는 Next.js로 개발 중이기 때문에 Nextjs에서 제공하는 <Image /> 컴포넌트를 사용했지만, <img /> 를 사용해도 무관할 것이다.

imageSrcArray는 일단, 기본 캐러셀 구현 중이므로 이미지 파일이 있는 경로 문자열을 5개 담은 배열로 작성해두자.

<Image /> 에서는 width와 height 속성이 필수여서 명시해두었으나 <img /> 이라면 신경 쓰지 않아도 된다.

반복되는 요소를 생성하므로 반드시 key 를 작성하여 요소 간 구분을 명확히 하여 리액트의 Virtual DOM이 잘 작동하도록 해주자.

나는 꼼수를 써서 index - 0 으로 적어두었으나 보다 명확한 key 생성 로직이 있다면 좋을 것이다.





스타일 추가(1)

위와 같이 레이아웃을 만들었다면 이제 0번째 인덱스 이미지에 고정돼있지 않고 오른쪽으로 넘어가는 효과를 줄 차례이다.

어떻게 구현해야 할까?

지금 우리 눈에는 1개의 이미지만 보일 뿐이지만 사실 5개의 이미지가 붙어있다는 사실을 알고 있다.

그렇기에 이 이미지를 1개씩 넘어가는 것처럼 보이게 하려면 transform 속성을 이용해서 스타일링을 해주면 된다.

MDN 문서 를 살펴보면 요소에 회전, 크기 조절, 기울이기, 이동 효과를 부여할 수 있다고 명시되어 있다.

이동 효과 에 주목해보자.

translate() css 함수에 의해 이동 효과를 발생시킬 수 있는데, 자세한 사용법은 MDN 문서를 참조하면 될 것이다.


우리가 알아야 할 것은 이 함수 중 translateX() 를 이용하면 X축 방향, 즉 수평으로 요소를 이동시킬 수 있다는 사실이다.

translateX() 의 괄호안에 들어갈 값이
양수일 경우 요소는 왼쪽에서 오른쪽으로 이동 (->) 하고
음수일 경우 요소는 오른쪽에서 왼쪽으로 이동 (<-) 한다.

그렇기에 우리가 오른쪽에서 왼쪽으로 이미지가 넘어가는 캐러셀 구현 을 위해서는 음수를 괄호 안에 작성해야 한다는 사실을 알 수 있다.

"근데 얼마를 적어야 하는 걸까? %로 하면 컨테이너 너비만큼 이동할 테니 -100%?"

잘 생각해보자.

우리 눈엔 지금 1개의 이미지만 보이고 있지만 실제로는 5개의 이미지가 붙어있는 기다란 직사각형 형태의 요소이다.

그렇기 때문에 예를 들어 1 -> 2 로 이동할 때엔 -100% 이겠지만, 2 -> 3 으로 이동할때엔 -100% 가 아니라 처음 이미지의 위치를 기준으로 -200% 일 것이다.


즉, 현재 인덱스 * -100%translateX 해야 할 것이다.

  return (
    <div className="carousel-container">
      <div className="carousel-image-container" style={{ transform: `translateX(-${carouselIndex * 100}%)` }}>
        {imageSrcArray.map((image, index) => (
          <Image
            key={`${index - 0}`}
            src={image}
            alt={image}
            width={412}
            height={630}
          />
        ))}
      </div>
    </div>
  );

위와 같이 inline-style로 css를 수정해주었다. 나는 scss를 사용하기 때문인데, 만약 Styled-Components나 Emotion을 사용한다면 자유롭게 부여해주도록 하자.

이렇게 하면 아래와 같이 구현된다. (아래 이미지는 gif이기에 무한 반복된다.)

뭔가 뚝뚝 끊기는 느낌은 들지만 일단 캐러셀의 토대는 구현되었다.



그렇다면 현재 인덱스값인 carouselIndex는 어떤 식으로 정했는지 알아보자.

눈치챘을수도 있지만, 이 값은 state이다. 유동적으로 인덱스를 변화시키기에는 이 방법이 이상적이라고 생각했다.

  const [carouselIndex, setCarouselIndex] = useState(0);

그리고 carouselIndex를 일정 시간에 따라 바꿔주려면 당연하게도 setTimeout이나 setInterval 같은 함수가 있어야 한다.

나는 캐러셀의 한 이미지당 2초(2000ms)의 시간을 주기로 했다.


  useEffect(() => {
    const timer = setInterval(() => {
      setCarouselIndex((prev) => (prev + 1) % imageSrcArray.length);
    }, 2000);

    return () => clearInterval(timer);
  }, [imageSrcArray.length]);

위와 같이 setInterval 를 이용해서 구현했으며, 현재 캐러셀 인덱스는 이전 값을 이미지 경로 배열의 길이로 나눈 나머지로 업데이트하게 했다.

이유인 즉슨,

setCarouselIndex((prev) => (prev + 1))

단순히 이전 값에서 1 증가하도록 업데이트시키면 carouselIndex가 배열 길이를 초과하여 계속 커지기 때문이다.

이해를 돕기 위해 예를 들어보면,

만약 현재 인덱스가 배열의 마지막 인덱스인 4라면

방법 A

setCarouselIndex((prev) =>  (prev + 1) % imageSrcArray.length) // (4 + 1 ) % 5 = 0

방법 B

setCarouselIndex((prev) => (prev + 1)) // (4 + 1 ) = 5

위와 같이 방법 A는 다시 처음 인덱스(0)로 되돌아가지만 방법 B는 배열 길이를 초과한 인덱스(5)로 업데이트된다는 사실을 알 수 있다.

그렇기에 이러한 식을 사용하여 인덱스를 업데이트하도록 했다.

그리고 자원이 낭비되지 않도록 언마운트 시 클린업 함수로 claerInterval() 을 리턴 콜백함수안에 작성해주었다.




스타일 추가(2)

이제 좀 더 자연스러운 효과를 주기 위해 뚝뚝 끊기는 현상을 해결해보자.

미닫이 문을 열듯이 부드럽게 옆으로 넘어가는 효과를 준다면 아주 좋을 것 같다.

transition() 속성을 이용하면 요소가 전환될 때의 효과와 시간을 조정할 수 있다.

transform 효과에 0.5초 정도 ease-in-out 전환 효과를 줘보자.

transition: transform 0.5s ease-in-out

마찬가지로 이미지 컨테이너의 inline-style에 위와 같이 작성해주면 될 것이다.

그렇지만 그렇게 하면 jsx가 너무 지저분해지고 보기 어렵다는 가독성 저하의 단점이 생기고 만다.

나는 getCarouselStyles 함수로 해당 부분을 대체하기로 했다.

  const getCarouselStyles = () => {
    return {
      transform: `translateX(-${carouselIndex * 100}%)`,
      transition: "transform 0.5s ease-in-out",
    };
  };

.
.
.

  return (
    <div className="carousel-container">
      <div
        className="carousel-image-container"
        style={getCarouselStyles()}
      >
        {imageSrcArray.map((image, index) => (
          <Image
            key={`${index - 0}`}
            src={image}
            alt={image}
            width={412}
            height={630}
          />
        ))}
      </div>
    </div>
  );

이제 캐러셀다운 모습으로 변했는지 확인해보면 된다.

(아래 이미지는 gif이기에 무한 반복된다.)

이제 제법 어엿한 하나의 캐러셀로 보인다!

하지만 이 캐러셀엔 치명적인 문제점이 있다.



바로 5 에서 1로 다시 되돌아갈 때 휘리릭 하고 되감아 지는 현상이 발생하는 것이다..!

이 현상을 고치기 위해 진짜 많이 고생해서 해결했다.😢

만약 여기까지 읽어본 후 스스로 해결할 수 있다면 도전해보는 것도 좋을 듯하다.

이제부턴 이 캐러셀의 문제점을 해결하고 "무한 캐러셀"로 만들기 위해 수정을 해보자.





2. 무한 캐러셀 구현

문제 해결

"왜 되감기 현상이 발생하는 걸까?"

0번째 인덱스부터 4번째 인덱스까지 순차적으로 이미지를 배치했고,

그에 따라 인덱스도 2초마다 증가하며 위에서 설명한 수식에 의해 마지막 인덱스에서 2초 뒤에는 다시 0번째 인덱스로 업데이트된다.

그러면 상상을 해봤을 때, 마지막 인덱스 다음에 0번째 인덱스의 이미지가 자연스럽게 등장해야 한다.

그렇지만 실제로는 마지 동영상을 되감기 하듯이 휘리릭 하고 전환 효과가 일어난다.

여기서 우리는 눈치챌 수 있다. 이 현상의 원인은 전환 효과 라는 것을!


그렇다면 전환 효과를 담당하고 있는 transition 속성에서 어떤 방식을 취해야 할까?

우선, 우리가 문제를 느끼고 있는 마지막 인덱스 -> 첫 인덱스 구간에서 transition 효과를 해제해야 될 것 같다.

  const getCarouselStyles = () => {
    return {
      transform: `translateX(-${carouselIndex * 100}%)`,
      transition: "transform 0.5s ease-in-out",
    };
  };

왜냐하면, 보다시피 transition 속성에 의해서 transform 이 전환 효과를 가지게 되어 휘리릭 하고 넘어가는 모션을 취한 것이기 때문이다.

생각을 했으니 바로 적용해보자.

  const getCarouselStyles = () => {
    return {
      transform: `translateX(-${carouselIndex * 100}%)`,
      transition: `${carouselIndex === 0 ? 'none' : 'transform 0.5s ease-in-out'}`,
    };
  };

만약 현재 캐러셀의 인덱스가 0이라면 transitionnone으로 설정하고 그 외엔 기존 방식대로 설정하기로 했다.

보다시피 휘리릭 하고 되감아 지는 효과는 사라졌지만, 뚝 하고 끊기는 느낌을 준다.

이러면 무한 캐러셀이라는 느낌을 받을 수 없다.



그러면 어떻게 해결해야 할까?

5 -> 1 이 되는 그 순간에 transitionnone 했기 때문에 전환 효과가 뚝 끊어진다면,

페이크로 1 을 하나 더 만들어서 transition을 적용시킨 다음,

진짜 1 로 전환될때 transitionnone 을 수행하면 페이크 1과 진짜 1의 구분이 되지 않으므로 1이 그대로인 것처럼 보일테니

1 -> 2 에서 다시 transition이 적용되면 자연스럽게 흘러가는 것 처럼 보일 수 있을 것이다.

이게 무슨 말인지 이해가 안 됐다면 정상이다! 😅 그림으로 다시 이해해보자.


이렇게 마지막 이미지인 5 뒤에 다시 첫 번째 이미지인 1을 넣어주면 배열 길이가 1 늘어난 길이 6의 배열이 된다.

빨간색 화살표가 transition: 'transform 0.5s ease-in-out' 을 의미한다면, 보라색 화살표는transition: 'none'을 의미한다.

그림에서 보다시피 5 뒤에 있는 페이크 1은 5에 이어서 자연스럽게 등장하게 된다.

그러나 페이크 1 다음에 다시 인덱스 0으로 되돌아갈 때 transition: 'none' 이기 때문에 페이크 1에서 진짜 1로의 전환 효과는 아무것도 적용되지 않는다. 마치 뚝 끊기듯 한 느낌으로 전환될 것이다.

그렇지만 같은 1이라는 이미지를 보는 우리의 입장에서는 페이크 1인지 진짜 1인지 구분할 수 없을 것이고

마치 5 다음 1 그리고 2가 순차적으로 무한하게 순환하는 것처럼 보일 것이다.

특히 페이크 1에서 진짜 1로 넘어가는 순간을 2초가 아닌 매우 짧은 시간, 예를 들어 10ms 정도로 설정한다고 가정하면 우리는 어떤 위화감도 느낄 수 없게 된다.


정리하자면, 필요한 로직은 다음과 같다.

  1. 캐러셀 이미지 배열의 마지막 요소로 첫 번째 요소를 복사한 것을 삽입할 것 & 기존에 배열 길이가 필요했던 부분에도 1에서 만든 배열의 길이를 사용할 것
  2. 1에서 만든 배열을 렌더링에 사용할 것
  3. 현재 캐러셀 인덱스가 마지막 인덱스일 때 캐러셀 인덱스를 0으로 업데이트하고 transitionnone으로 업데이트하는 로직을 매우 짧은 시간 내에 수행하도록 setTimeout 함수로 수행할 수 있게 할 것
  4. 기존에 2초 간격으로 제어했던 부분을 변수로 바꾸어 transitionnone일때는 매우 짧은 시간, 그 외엔 2초가 되도록 할 것

이것이 무한 캐러셀 구현의 핵심이라고 할 수 있다.






코드 작성

1. 캐러셀 이미지 배열의 마지막 요소로 첫 번째 요소를 복사한 것을 삽입할 것 & 기존에 배열 길이가 필요했던 부분에도 1에서 만든 배열의 길이를 사용할 것

  const imageSrcArray = useMemo(() => {
    const splitArray = imageSrc.split('\n');
    return [...splitArray, splitArray[0]] as string[];
  }, [imageSrc]);

나는 이미지가 \n으로 구분된 string 이었기 때문에 위와 같이 \n을 기준으로 배열을 만들고 해당 배열을 토대로 새롭게 만든 배열을 반환하여 사용하도록 했다.

어떤 방식으로든 아무튼 [...원래 배열, 배열 첫 번째 요소] 의 형태인 새로운 배열만 선언해주면 된다.

그리고 코드를 작성하면서 혹은 작성 이후에 기존 배열로 사용되었던 부분은 전부 새 배열로 업데이트해주면 된다.



2. 1에서 만든 배열을 렌더링에 사용할 것

return (
    <div className="carousel-container">
      <div
        className="carousel-image-container"
        style={getCarouselStyles()}
      >
        {imageSrcArray.map((image, index) => (
          <Image
            key={`${index - 0}`}
            src={image}
            alt={image}
            width={412}
            height={630}
          />
        ))}
      </div>
    </div>
  );

위와 같이 렌더링 대상 배열을 1에서 만든 새 배열로 바꿔주면 된다.


3. 현재 캐러셀 인덱스가 마지막 인덱스일 때 캐러셀 인덱스를 0으로 업데이트하고 transition을 none으로 업데이트하는 로직을 매우 짧은 시간 내에 수행하도록 setTimeout 함수로 수행할 수 있게 할 것

  const resetIndexAndTransition = useCallback(() => {
    setTimeout(() => {
      setCarouselIndex(0);
      setCarouselTransition('none');
    }, 10);
  }, []);

함수 하나를 선언해준다.

그리고 해당 함수를 useCallback을 이용해서 선언해 주었는데, 이렇게 해주지 않으면 렌더링마다 함수가 재생성되는 문제가 생기기 때문에 useCallback을 사용하라고 eslint가 알려줘서 그렇게 했다.

useCallback은 함수가 불필요하게 재생성되는 것을 방지할 수 있어 유용한 훅이다.

아무튼 이 함수의 콜백함수에 해당하는 부분에 setTimeout을 이용하여 10ms뒤에 캐러셀 인덱스를 0으로, transitionnone으로 업데이트해주는 코드를 작성해준다.

  useEffect(() => {
    const timer = setInterval(() => {
      if (carouselIndex === imageSrcArray.length - 1) {
        resetIndexAndTransition();
      }
      setCarouselIndex((prev) => (prev + 1) % imageSrcArray.length);
      setCarouselTransition('transform 0.5s ease-in-out');
    }, 2000);

    return () => clearInterval(timer);
  }, [
    carouselIndex,
    imageSrcArray.length,
    resetIndexAndTransition,
  ]);

그리고 기존에 만들었던 useEffect 콜백 함수 안에 생성한 setInterval 안에서 if 문을 작성하여 배열의 마지막 인덱스에 도달했을 때 위에 선언했던 함수를 호출한다.

의존성 배열 요소도 코드 수정에 맞춰서 업데이트해주자.



4. 기존에 2초 간격으로 제어했던 부분을 변수로 바꾸어 transition이 none일때는 매우 짧은 시간, 그 외엔 2초가 되도록 할 것

  const controlTime = useMemo(() => {
    return carouselTransition === 'none' ? 10 : 2000;
  }, [carouselTransition]);

  useEffect(() => {
    const timer = setInterval(() => {
      if (carouselIndex === imageSrcArray.length - 1) {
        resetIndexAndTransition();
      }
      setCarouselIndex((prev) => (prev + 1) % imageSrcArray.length);
      setCarouselTransition('transform 0.5s ease-in-out');
    }, controlTime);

    return () => clearInterval(timer);
  }, [
    carouselIndex,
    controlTime,
    imageSrcArray.length,
    resetIndexAndTransition,
  ]);

controlTime 이라는 변수를 선언하여 transition 상태 값에 따라 10ms와 2000ms 중에 하나가 반환되는 값으로 설정한다.

그리고 해당 변수를 기존에 2000이 적혀있던 곳에 넣어주면 된다. 이렇게 하면 페이크 1과 진짜 1이 전환된 직후에 10ms 간격으로 setInterval이 동작할 것이다.

그렇게 하면 2초 + 2초 = 4초 가 아니라 10ms + 2초 = 2초+@ 정도의 시간 뒤에 진짜 1에서 2 이미지로 넘어갈 것이다.

사람의 눈으로는 10ms는 인지할 수 없으므로 자연스러운 흐름으로 여겨진다.



이로써 모든 구현이 끝이 났다!

이것 외에 css도 수정해야 하지 않느냐는 궁금증이 생긴다면 아래에 첨부할 풀 코드를 참조하면 좋을 것이다.

사실 css는 이제 크게 수정할 부분이 없다. 그냥 정렬만 맞춰주는 게 고작일 것이다.





전체 코드

carousel.scss

.carousel-container {
  width: 412px;
  height: 630px;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
}

.carousel-image-container {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
}



Carousel.tsx

export const Carousel = () => {
  const imageJsonArray = modalCarouselImageJson.images;
  const currentCarouselIndex = useRecoilValue(currentCarouselIndexState);
  const [carouselIndex, setCarouselIndex] = useState(0);
  const [carouselTransition, setCarouselTransition] = useState('');

  const imageSrc = useMemo(() => {
    return imageJsonArray[currentCarouselIndex] ?? '';
  }, [currentCarouselIndex, imageJsonArray]);

  const imageSrcArray = useMemo(() => {
    const splitArray = imageSrc.split('\n');
    return [...splitArray, splitArray[0]] as string[];
  }, [imageSrc]);

  const resetIndexAndTransition = useCallback(() => {
    setTimeout(() => {
      setCarouselIndex(0);
      setCarouselTransition('none');
    }, 10);
  }, []);

  const controlTime = useMemo(() => {
    return carouselTransition === 'none' ? 10 : 2000;
  }, [carouselTransition]);

  useEffect(() => {
    const timer = setInterval(() => {
      if (carouselIndex === imageSrcArray.length - 1) {
        resetIndexAndTransition();
      }
      setCarouselIndex((prev) => (prev + 1) % imageSrcArray.length);
      setCarouselTransition('transform 0.5s ease-in-out');
    }, controlTime);

    return () => clearInterval(timer);
  }, [
    carouselIndex,
    controlTime,
    imageSrcArray.length,
    resetIndexAndTransition,
  ]);

  const getCarouselStyles = () => {
    return {
      transform: `translateX(-${carouselIndex * 100}%)`,
      transition: `${carouselTransition}`,
    };
  };

  return (
    <div className="carousel-container">
      <div
        className="carousel-image-container"
        style={getCarouselStyles()}
      >
        {imageSrcArray.map((image, index) => (
          <Image
            key={`${index - 0}`}
            src={image}
            alt={image}
            width={412}
            height={630}
          />
        ))}
      </div>
    </div>
  );
};



이제 코드가 정상적으로 동작하는지 확인해보면 된다!

무한 캐러셀이 성공적으로 구현되었다! 🤣🎉✨

gif는 2회 루프가 마무리된 순간 촬영을 컷했기 때문에 마지막에 끊겨 보일 수 있는데, 실제론 무한 루프처럼 동작한다.

아무튼 잘 동작해서 뿌듯하다.



마무리

직접 무한 캐러셀을 구현해본 것은 처음인데 생각보다 어려웠고 무엇보다 css를 많이 다뤄보지 않았던 탓에 응용이나 아이디어에서 어려움을 겪었다.

그리고 캐러셀의 동작 원리와 눈속임 기술에 대해 알아갈 수 있어서 좋았다.

작업이 어려웠고 처음이었던 만큼 부족한 부분도 많았을 것 같다.

지금 완성한 코드도 분명 리팩토링을 거쳐야겠지만 그래도 혼자 완성해봤다는 성취감이 기분 좋았다. 😁

포스팅을 이렇게나 세세하게 작성한 이유는 기존의 수많은 블로그 글과 chatGPT 의 도움으로 쉽게 개발할 수 있는 기능이지만

내가 직접 이해하고 납득 가능한 로직을 작성하면서 하나하나 차근차근 만들어 보는 것이 좋은 경험이 될 것 같아서였다.

다음에도 처음 만들어보는 기능에 대해서는 이렇게 포스팅 해봐야겠다.
-끝

profile
즐코 행코 하세용

0개의 댓글