[React] Infinite-carousel 구현하기

정수완·2025년 4월 21일

React

목록 보기
9/9
post-thumbnail

이번에 사이드 프로젝트를 진행하며 캐러셀을 구현하게 되었는데 기존에 스크롤 방식의 캐러셀 구현 방식은 자신이 있었는데 클라이언트 요구사항으로 마지막 사진에서 다음 버튼을 눌렀을 때 다시 처음 사진이 나오는 무한 캐러셀을 요구했습니다.

이번 기회에 구현하며 React 스킬업을 목표로 도전해 보았습니다.

구현목표

  • 저는 최대한 라이브러리없이 바닐라 자바스크립트 와 React만을 이용해서 무한 캐러셀을 구현해 보고 싶었습니다.

처음 개발을 시작했을때 가르쳐주던 교수님께서 라이브러리 없이 최대한 구현할 줄 알아야 라이브러리를 사용해도 원하는 대로 사용이 가능하다고 말씀하셨고 깊은 감명을 받아 최대한 항상 최대한 라이브러리 없이 구현해보려고 합니다.

우선 저는 캐러셀에 이미지만 보이는게 아닌 이미지와 텍스트가 캐러셀 내에 구현되는 것을 목표로 하고 있습니다.

그러기 위해선 캐러셀을 구현할 때 캐러셀에 들어갈 내용을 List 형식으로 받아와 저장하고 받아온 데이터를 토대로 원하는 대로 구현해 주어야 합니다.

1. 캐러셀에 띄울 데이터 받아오기 및 기본구조 설정

import React, {useState, useEffect, useRef} from "react";

const InfiniteCarousel = ({ carouselList }) => {
  const [currentList, setCurrentList] = useState([])
  const carouselRef = useRef(null);
  
  useEffect(() => {
    if (carouselList.length !== 0) {
      const startData = carouselList[0];
      const endData = carouselList[carouselList.length - 1];
      const newList = [endData, ...carouselList, startData];
      setCurrentList(newList);
    }
  }, [currentList])
	return (
      <div className="carousel-facilities-container">
      <div className="carousel-wrapper">
        <button>{">"}</button>
      	<button>{"<"}</button>
        <ul className="carousel" ref={carouselRef}>
          <li>띄울 이미지 및 텍스트 정보</li>
        </ul>
      </div>
    </div>     
}

우선 이렇게 받아온 캐러셀 리스트 정보를 변형하여 새로운 newList를 생성하고 이를 currentList에 저장해 주었습니다.

구현하고자 하는 무한캐러셀은 마지막 이미지에서 다음 버튼을 클릭했을때, 첫 사진이 나오고 또 누르면 두번째 사진이 나오는 형식이 되어야 합니다.

그러려면 마지막 사진에서 다음 버튼을 눌렀을 때, 인덱스 번호가 마지막 번호에서 처음번호로 돌아가야 하는데 이를 그대로 구현하면 슬라이드가 되감기는 효과가 발생하겠죠?

이를 방지하기 위해서 각각 처음과 마지막에 마지막 데이터와 첫데이터를 추가해 준 것입니다.

그럼 다음버튼이나 이전버튼을 눌러 캐러셀이 이동해도 해당 사진이 떳다가 이 후 여기서 useEffect 를 활용해 인덱스를 초기화 해주면 될 것 같습니다.

이렇게 기존 구조를 설정했고 받아올 데이터를 새로운 리스트에 넣어주었습니다.

그럼 이 리스트 정보를 화면에 출력시켜 주면 되겠죠


2. 받아온 데이터 화면에 표시 및 버튼 설정

import React, {useState, useEffect, useRef} from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";

const InfiniteCarousel = ({ carouselList }) => {
  const [currentIndex, setCurrentIndex] = useState(1);
  const [currentList, setCurrentList] = useState([])
  const carouselRef = useRef(null);
  
  // 페이지 마운트시 받아온 데이터 정보 currentList에 저장
  useEffect(() => {
    if (carouselList.length !== 0) {
      const startData = carouselList[0];
      const endData = carouselList[carouselList.length - 1];
      const newList = [endData, ...carouselList, startData];
      setCurrentList(newList);
    }
  }, [currentList])
  
  // useEffect에서 인덱스를 주시해서 인덱스가 변경되면 해당 인덱스로 캐러셀 이동
  useEffect(() => {
    if (carouselRef.current) {
      carouselRef.current.style.transform = `translateX(-${currentIndex}00%)`;
    }
  }, [currentIndex]);
  
  // 버튼이벤트 
  const handleSwipe = (direction) => {
    // 현재 인덱스에 받아온 이동정보를 토대로 인덱스 초기화
    setCurrentIndex((prev) => prev + direction);
    if (carouselRef.current) {
      carouselRef.current.style.transition = "all 0.5s ease-in-out";
    }
  };
  
  
	return (
      <div className="carousel-facilities-container">
      <div className="carousel-wrapper">
        <button className="swipe-left" onClick={() => handleSwipe(-1)}>
          <ChevronLeft />
        </button>
        <button className="swipe-right" onClick={() => handleSwipe(1)}>
          <ChevronRight />
        </button>
        <ul className="carousel" ref={carouselRef}>
          <li>띄울 이미지 및 텍스트 정보</li>
        </ul>
      </div>
    </div>     
}

받아온 데이터를 화면에 표시하기 위해선 해당 이미지에 접근하기 위한 인덱스정보가 필요합니다.

이를 위해 currentIndex를 생성해주었고 기본 정보를 1로 설정해 주었습니다.

또 버튼 표시를 lucide 버튼으로 변경했고 사용할 함수정보를 지정해 주었습니다.

버튼을 클릭하면 handleSwipe 함수가 동작하는데 이는 버튼을 클릭했을 때 이전 인덱스에 받아온 인덱스를 더해서 인덱스를 초기화 시키고,

useEffect에서 인덱스를 주시해서 인덱스가 변경될 때 마다, 슬라이드를 해당 인덱스번호 슬라이드로 이동시켜줍니다.


3. 무한슬라이드로 구현

현재의 정보에서 무한슬라이드를 구현하려면

슬라이드를 마지막까지 이동했을때 인덱스를 1번으로 초기화시키면 되겠고
슬라이드가 처음으로 이동했을때 마지막으로 이동시켜주면 됩니다.

그럼 이걸 구현하려면 handleSwipe 함수의 동작이 달라져야겠죠

  // 기존의 버튼이벤트
  const handleSwipe = (direction) => {
    // 현재 인덱스에 받아온 이동정보를 토대로 인덱스 초기화
    setCurrentIndex((prev) => prev + direction);
    if (carouselRef.current) {
      carouselRef.current.style.transition = "all 0.5s ease-in-out";
    }
  };

////////////////////////////////////////////////////////////////////

  //  슬라이드 점프효과 -> style 일시제거
  const moveToNthSlide = (index) => {
    setTimeout(() => {
      setCurrentIndex(index);
      if (carouselRef.current) {
        carouselRef.current.style.transition = "";
      }
    }, 500);
  };

  // 변경된 버튼이벤트
  const handleSwipe = (direction) => {
    const newIndex = currentIndex + direction;

    // 현재 인덱스정보를 토대로 슬라이드 점프
    // 마지막 슬라이드인 경우 1번으로 이동, 0번인 경우 마지막 슬라이드로 이동
    if (newIndex === carouselList.length + 1) {
      moveToNthSlide(1);
    } else if (newIndex === 0) {
      moveToNthSlide(carouselList.length);
    }

    // 현재 인덱스에 받아온 이동정보를 토대로 인덱스 초기화
    setCurrentIndex((prev) => prev + direction);
    if (carouselRef.current) {
      carouselRef.current.style.transition = "all 0.5s ease-in-out";
    }
  };

이 코드를 보면 현재인덱스 정보에 받아온 방향을 더해 새로운 인덱스 정보인 newIndex를 생성합니다.

이때 newIndex가 받아온 리스트정보의 마지막정보의 번호와 동일한 경우
슬라이드 인덱스를 1로 초기화합니다.

또 newIndex가 처음이라면 받아온 리스트의 마지막으로 이동합니다.

이때 이동할 때 슬라이드의 이동이 부드럽게 이동되는 경우 무한캐러셀이 되감기는 효과가 발생하겠죠
그럼 우리가 원하는 제대로된 동작이 이루어지지 않습니다.

제대로된 동작이 되려면 마지막 슬라이드에서 처음 슬라이드로 되감기는게 아닌 점프 되어야 합니다.

이를 위해 마지막인덱스에서 처음인덱스로 가는 경우 혹은 처음 인덱스에서 마지막인덱스로 가는경우 style을 제거해 점프하는 효과를 주면 됩니다.

이렇게 버튼을 통한 이동이 모두 구현되었으면 제대로 동작되는지 확인해 주어야 겠죠?

저는 이미지, 타이틀, 설명 3가지를 받아오기 때문에 retrun 부분을 다음과 같이 지정해 주었습니다.

  return (
    <div className="carousel-facilities-container">
      <div className="carousel-wrapper">
        <button className="swipe-left" onClick={() => handleSwipe(-1)}>
          <ChevronLeft />
        </button>
        <button className="swipe-right" onClick={() => handleSwipe(1)}>
          <ChevronRight />
        </button>
        <ul className="carousel" ref={carouselRef}>
          {currentList.map((info, index) => (
            <li key={`carousel-${index}`} className="carousel-item">
              <img src={info[0]} alt="carousel" />
              <h1 className="carousel-title">{info[1]}</h1>
              {Array.isArray(info[2]) && (
                <ul className="carousel-description">
                  {info[2].map((desc, i) => (
                    <li key={`desc-${i}`}>{desc}</li>
                  ))}
                </ul>
              )}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );

4. 동작살펴보기

import React, { useState, useEffect, useRef } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import "../styles/components/InfinitieCarousel.css";

const InfiniteCarousel = ({ carouselList }) => {
  const [currentIndex, setCurrentIndex] = useState(1);
  const [currentList, setCurrentList] = useState([]);
  const carouselRef = useRef(null);

  // 받아온 데이터 정보 currentList에 저장
  useEffect(() => {
    if (carouselList.length !== 0) {
      const startData = carouselList[0];
      const endData = carouselList[carouselList.length - 1];
      const newList = [endData, ...carouselList, startData];
      setCurrentList(newList);
    }
  }, [carouselList]);

  // useEffect에서 인덱스를 주시해서 인덱스가 변경되면 해당 인덱스로 캐러셀 이동
  useEffect(() => {
    if (carouselRef.current) {
      carouselRef.current.style.transform = `translateX(-${currentIndex}00%)`;
    }
  }, [currentIndex]);

  //  슬라이드 점프효과 -> style 일시제거
  const moveToNthSlide = (index) => {
    setTimeout(() => {
      setCurrentIndex(index);
      if (carouselRef.current) {
        carouselRef.current.style.transition = "";
      }
    }, 500);
  };

  // 버튼이벤트
  const handleSwipe = (direction) => {
    const newIndex = currentIndex + direction;

    // 현재 인덱스정보를 토대로 슬라이드 점프
    // 마지막 슬라이드인 경우 1번으로 이동, 0번인 경우 마지막 슬라이드로 이동
    if (newIndex === carouselList.length + 1) {
      moveToNthSlide(1);
    } else if (newIndex === 0) {
      moveToNthSlide(carouselList.length);
    }

    // 현재 인덱스에 받아온 이동정보를 토대로 인덱스 초기화
    setCurrentIndex((prev) => prev + direction);
    if (carouselRef.current) {
      carouselRef.current.style.transition = "all 0.5s ease-in-out";
    }
  };

  return (
    <div className="carousel-facilities-container">
      <div className="carousel-wrapper">
        <button className="swipe-left" onClick={() => handleSwipe(-1)}>
          <ChevronLeft />
        </button>
        <button className="swipe-right" onClick={() => handleSwipe(1)}>
          <ChevronRight />
        </button>
        <ul className="carousel" ref={carouselRef}>
          {currentList.map((info, index) => (
            <li key={`carousel-${index}`} className="carousel-item">
              <img src={info[0]} alt="carousel" />
              <h1 className="carousel-title">{info[1]}</h1>
              {Array.isArray(info[2]) && (
                <ul className="carousel-description">
                  {info[2].map((desc, i) => (
                    <li key={`desc-${i}`}>{desc}</li>
                  ))}
                </ul>
              )}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default InfiniteCarousel;

carouselList에서 받아온 정보를 currentList에 저장했으니 이 리스트정보를 토대로 map 함수를 이용해 화면에 표시해 주었습니다.

key 설정은 const 를 이용해 key를 새로 생성해 index 사용을 피해줄 수 있지만 간단하게 carousel을 추가하는 방식으로 구현하였고, 설명 부분에서 설명이 여러줄 들어옵니다.

또한 설명이 잇는 경우가 있고 없는 경우가 있습니다.

이를 모두 구분하여 구현하기 위해 받아온 currentList의 info 정보가 있는 경우 ul 태그를 표시하고 없는 경우 표시하지않게 구현하였습니다.

제대로 동작하는데 gif가 올라가지 않네요 ㅠ

최종적으로 만든 InfiniteCarousel 컴포넌트와 css 파일은 다음과 같습니다.


최종파일

import React, { useState, useEffect, useRef } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import "../styles/components/InfinitieCarousel.css";

const InfiniteCarousel = ({ carouselList }) => {
  const [currentIndex, setCurrentIndex] = useState(1);
  const [currentList, setCurrentList] = useState([]);
  const carouselRef = useRef(null);

  // 받아온 데이터 정보 currentList에 저장
  useEffect(() => {
    if (carouselList.length !== 0) {
      const startData = carouselList[0];
      const endData = carouselList[carouselList.length - 1];
      const newList = [endData, ...carouselList, startData];
      setCurrentList(newList);
    }
  }, [carouselList]);

  // useEffect에서 인덱스를 주시해서 인덱스가 변경되면 해당 인덱스로 캐러셀 이동
  useEffect(() => {
    if (carouselRef.current) {
      carouselRef.current.style.transform = `translateX(-${currentIndex}00%)`;
    }
  }, [currentIndex]);

  //  슬라이드 점프효과 -> style 일시제거
  const moveToNthSlide = (index) => {
    setTimeout(() => {
      setCurrentIndex(index);
      if (carouselRef.current) {
        carouselRef.current.style.transition = "";
      }
    }, 500);
  };

  // 버튼이벤트
  const handleSwipe = (direction) => {
    const newIndex = currentIndex + direction;

    // 현재 인덱스정보를 토대로 슬라이드 점프
    // 마지막 슬라이드인 경우 1번으로 이동, 0번인 경우 마지막 슬라이드로 이동
    if (newIndex === carouselList.length + 1) {
      moveToNthSlide(1);
    } else if (newIndex === 0) {
      moveToNthSlide(carouselList.length);
    }

    // 현재 인덱스에 받아온 이동정보를 토대로 인덱스 초기화
    setCurrentIndex((prev) => prev + direction);
    if (carouselRef.current) {
      carouselRef.current.style.transition = "all 0.5s ease-in-out";
    }
  };

  return (
    <div className="carousel-facilities-container">
      <div className="carousel-wrapper">
        <button className="swipe-left" onClick={() => handleSwipe(-1)}>
          <ChevronLeft />
        </button>
        <button className="swipe-right" onClick={() => handleSwipe(1)}>
          <ChevronRight />
        </button>
        <ul className="carousel" ref={carouselRef}>
          {currentList.map((info, index) => (
            <li key={`carousel-${index}`} className="carousel-item">
              <img src={info[0]} alt="carousel" />
              <h1 className="carousel-title">{info[1]}</h1>
              {Array.isArray(info[2]) && (
                <ul className="carousel-description">
                  {info[2].map((desc, i) => (
                    <li key={`desc-${i}`}>{desc}</li>
                  ))}
                </ul>
              )}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default InfiniteCarousel;
.carousel-facilities-container {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  padding: 0 20em;
}

.carousel-wrapper {
  position: relative;
  width: 100%;
  overflow: hidden;
}

.swipe-left,
.swipe-right {
  display: block;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 1;
  padding: 8px 6px;
  background-color: black;
  opacity: 0.4;
  color: white;
  border: none;
  cursor: pointer;
}

.swipe-left {
  left: 2px;
}

.swipe-right {
  right: 2px;
}

.carousel {
  display: flex;
  width: 100%;
}

.carousel-item {
  flex: none;
  width: 100%;
  height: 70vh;
  padding: 10px 0 15px;
  overflow: hidden;
  border-left: 2px solid white;
  border-right: 2px solid white;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: start;
  transition: border 300ms;
}

.carousel-item img {
  width: 100%;
  height: 80%;
  object-fit: fill;
}

.carousel-title {
  width: 100%;
  height: 50px;
  text-align: center;
  background-color: #005c9d;
  color: white;
  font-size: 24px;
  line-height: 50px;
}

.carousel-description {
  margin: 1em 0;
  padding: 0;
}

.carousel-description > li {
  font-size: 1.2em;
  margin-bottom: 5px;
}

이렇게 만든 컴포넌트를 다른 page에서 불러와 리스트를 넘겨주는 형식으로 사용했습니다.

profile
빠르게 배우고 기록하는 개발자 정수완 입니다. @swan

0개의 댓글