[React] 무한 자동 슬라이드 구현하기(ft. Typescript, Tailwind)

박기영·2022년 7월 29일
14

React

목록 보기
2/32
post-custom-banner

문제 상황

최근 웹 페이지는 슬라이드 효과가 정말 많이 보인다. 다양한 상품을 보여주기 위해서 자동적으로 페이지가 넘어가기도 하고, 유저가 직접 페이지를 넘길 수도 있고, 원하는 슬라이드로 이동할 수도 있다.
많은 웹 페이지에서 쓰이는 만큼, 기본적으로 다룰 줄 알아야하는 UI라고 생각된다.
그럼 도전해봅시다!

내용이 길지만, 나눠서 작성하기보다는 한번에 읽어가는게 편할거라 생각해서 하나에 전부 작성했습니다.

Semantic Tag

우선, 시멘틱 태그는 어떻게 사용했는지 부터 살펴보자.
css는 Tailwind를 사용하여 className에 스타일이 적용되어 있으므로 참고!

// 1번 div
<div className="relative overflow-hidden z-[1] group" ref={outRef}>
  <SlideBtn direction="left" onClick={() => slideHandler(-1)} />
  <SlideBtn direction="right" onClick={() => slideHandler(1)} />
  <Pagination setSlideIndex={setSlideIndex} slideIndex={slideIndex} />
  // 2번 div
  <div
	className="flex"
	ref={slideRef}
	style={{
		width: `${100 * COPIED_NUM}vw`,
		transition: "all 500ms ease-in-out",
		transform: `translateX(${
			-1 * ((100 / copiedArr.length) * slideIndex)
		}%)`,
	}}
  >
  {copiedArr.map((item, index) => (
	// 3번 div
	<div key={index} className="relative">
	  <img
		src={item.img}
		alt="banner"
		className="h-[500px] sm:h-[655px] lg:w-screen lg:h-[700px]"
	  />

	  <div className="absolute px-[5%] font-bold text-white top-1/2">
		<h1 className="text-[24px] lg:text-[28px]">{item.imgTitle}</h1>
		<p className="text-[36px] lg:text-[52px]">{item.imgDesc}</p>
	  </div>
	</div>
	))}
  </div>
</div>

크게 3개의 div로 나눠진다.
아래의 그림을 참고하면 더 쉬울 것 같다.(그림이 개떡같아도 찰떡같이 봐주실거라 믿습니다..)

참고 이미지

이런 구조다. 잘 이해가 되지않는다. 그래서 어떻게 슬라이드를 구현하겠다는건데??

슬라이드 작동 원리

자, 생각해보자. 화면은 우리가 무슨 짓을 해도 움직이지 않는다.
그럼 우리가 움직일 수 있는 것은 2,3번 중 하나일텐데..정답은 2번을 움직여야한다. 왜?
아래 그림을 보자.

참고 이미지
위가 3번이 움직일 경우, 아래가 2번이 움직일 경우이다.
바로 이해가 된다! 3번이 움직이면 다른 이미지들은 이동하지 않기 때문에, UI가 깨진 것 처럼 보일 것이다.
2번을 움직이려면 어떻게 해야할까?

슬라이드 이미지를 이동시키자. 부드럽게.

2번을 움직일 때 고려할게 무엇이 있을까? 슬라이드가 딱딱 끊겨서 움직이면 UX가 좋지않다.
따라서, 우리는 슬라이드를 움직이는 것과 동시에 "부드럽게" 움직이는 것도 생각해야한다.
그리고 다른 이미지들은 보이면 안된다.

다른 이미지가 안보이게 하는 것은 간단하다. 가장 바깥 div에 overflow:hidden을 설정하면 된다.(필자는 Tailwind로 css를 설정했다)

<div className="overflow-hidden">
/* ... */
</div>

이제 1번 div(그림에서 화면이라고 써있는 곳) 바깥은 전부 잘려서, 하나의 이미지만 볼 수 있게 되었다.

다음으로, 움직이는 것을 구현해보자.

  // 2번 div
  <div
	className="flex"
	ref={slideRef}
	style={{
		width: `${100 * COPIED_NUM}vw`,
		transition: "all 500ms ease-in-out",
		transform: `translateX(${
			-1 * ((100 / copiedArr.length) * slideIndex)
		}%)`,
	}}
  >
/* ... */
</div>

style 속성을 살펴보자.
width는 2번 div의 넓이이다. 위에서 보여드린 그림처럼 슬라이드 이미지 개수만큼 넓이를 가져야한다.
필자는 하나의 이미지가 전체 view-width를 가지도록 만들었으므로, 100vw에 이미지의 개수를 곱해서 넓이를 지정해줬다.

transform은 이미지를 이동시킬 것이다.(정확히 말하자면 2번 div를 이동시킨다)
필자는 x축 방향으로 슬라이드 이미지들이 나열되어 있으므로, translateX를 사용했다. 그 안에 식들을 살펴보자.

-1 * ((100 / copiedArr.length) * slideIndex)

-1은 이미지가 이동하는 방향을 말한다. -1은 이미지가 위에서 보여드린 그림처럼 왼쪽으로 이동하게 해준다. 만약, +1이 곱해졌다면 오른쪽으로 이동할 것이다.
방향을 정했으니, 얼만큼 움직일지를 정해야한다.
width가 300vw이므로, 1/3 만큼 이동해야 2번째 이미지가 보일 것이다.(필자는 이미지 크기가 100vw이기 때문!)
또한! 1/3이 추가로 이동해야 3번째 이미지가 보일 것이다.
즉, 3번째 이미지를 보려면 2/3만큼 이동시켜야하는 것이다.

100 / copiedArr.length

이미지 배열의 length는 3이다. 그러면 (100 / 3)이 되었다.

(100 / copiedArr.length) * slideIndex

이미지의 인덱스는 0부터 시작하므로, 두 번째 이미지는 1, 세 번째 이미지는 2의 인덱스를 가진다.
따라서, 두 번째 이미지가 보인다면, (100 / 3) * 1 = 33.33...% = 1/3만큼 이동한 것이 된다.
세 번째 이미지가 보인다면, (100 / 3) * 2 = 66.66...% = 2/3만큼 이동한 것이 된다.
이제, 2번 div의 이동을 코드로 이해했다! 다음은 "부드럽게" 넘어가도록 효과를 주자.

transition은 이미지가 변경될 때, 변경 효과를 부여한다.

transition: "all 500ms ease-in-out"

transform이 500ms의 시간동안 진행되고, ease-in-out 방식의 애니메이션을 보여줄 것이다.
예를 들어, 두 번째 이미지로의 이동이 발생할 때, 갑자기 1/3만큼 이동된 상태로 두 번째 이미지가 떡하니 보이는 것이 아니라, 0에서 1/3까지 500ms간 이동하게 된다. 즉, 부드럽게 넘어가는 효과가 생기는 것이다!

여기까지는 평범한 슬라이드이다. 보통 여기에 버튼으로 index를 조절하는 useState만 연결해준다면, 앞 뒤를 오가는 슬라이드가 완성된다.
그런데 우리는 "자동" 슬라이드를 구현하고 싶다. 어떡하지??

자동으로 슬라이드를 넘겨보자

바로 setInterval을 사용하여 설정한 시간당 한 번씩 이미지가 이동하게 만들면 된다.
React에서 setInterval을 사용하는 것은 개발자가 원하는 방식대로 동작하지 않을 확률이 높다.(대략적인 이유와 해결책은 여기를 봐주세요!)

위 링크를 보고 오셨다고 생각하고 진행하겠습니다.

// 보여줄 이미지의 인덱스 state
const [slideIndex, setSlideIndex] = useState(0);

// 이미지가 몇 초마다 이동할지 정하기 위한 state
const [custominterval, setCustomInterval] = useState(3000);

useInterval(
  () => setSlideIndex((slideIndex) => slideIndex + 1),
  custominterval
);

위 코드는 3000ms(3초)에 한 번씩 slideIndex를 1씩 증가시키는 것이다.
위에서 해부했던 translatX 식을 다시 봐보자.

-1 * ((100 / copiedArr.length) * slideIndex)

slideIndex가 그래서 들어간거였구나!
slideIndex가 변함에 따라, 0, 1/3, 2/3 이런식으로 증가하게 될 것이다!
변하는 시간 간격이 3000ms인 것이고!
이제 3초마다 슬라이드가 자동으로 넘어가는 것을 볼 수 있다!
하지만..여기서 끝일리가 없다. 여기까지 구현했을 때 나오는 문제는 다음과 같다.

마주하게 될 문제

끊임없이 옆으로 이동한다는 것. 멈추지를 않는다. 3초마다 slideIndex가 증가하는데, 우리는 한도를 정하지 않았다. 그럼 무한히 증가하고, 2번 div는 끝도없이 왼쪽으로 이동할 것이다.
해결해야할 것이 정해졌다. 바로, 슬라이드 이미지의 인덱스를 다시 돌려놓는 것!

다시 처음으로 가는 무한 슬라이드

방법은 매우 간단하다. slideIndex가 특정 값이 되었을 때 다시 setSlideIndex를 초기 상태로 돌려놓으면 된다!

if (slideIndex === 3) {
  setSlideIndex(0);
}

이미지 인덱스가 3이 되었을 때, 0으로 되돌려서 처음 이미지로 이동하는 것이다.
이미지가 3개면 인덱스가 2가 끝인데, 왜 3일까?
우리는 마지막 이미지의 인덱스인 2까지 유저에게 보여줘야한다.
만약, 조건문을 3이 아닌 2로 설정한다면, 마지막 이미지로 이동하다가! 갑자기 처음 이미지로 돌아가게 된다. useState는 바로 동작하기 때문이다.
따라서, 마지막 이미지 인덱스인 2까지는 정상 작동하다가, 그 이상인 3으로 가는 순간! 0으로 초기화를 해주는 것이다.
이제 계속 돌아가는 슬라이드를 만들었다!
완성!이 아니다.
여기까지 구현했을 때 나오는 문제는 다음과 같다.

마주하게 될 문제

slideIndex가 3이 됐을 때, 초기화가 되는건 좋다고 치자.
그런데..우리는 이미지가 3개밖에 없는데, 3번 인덱스에서는 도대체 뭘 보여주는걸까?
바로바로~ 배경 색깔을 보여줄 것이다...
갑자기 배경 색깔이 보이다가! 처음 슬라이드로 돌아가는 것이다.
그리고..슬라이드가 주르륵 흘러가서 끝에서 끝으로 이동하게 될 것이다.
2번 인덱스까지 흘러온 슬라이드가 0번 인덱스까지 다시 달려가서 멈추게 된다. 그것도 transition에서 설정한 duration인 500ms만에..!
이 모든게 UX에 굉장히 안 좋아보인다.
어떻게 해결해야할까?

무한 슬라이드를 더 자연스럽게 만들자

이는 원래 존재하던 이미지 배열의 끝과 끝에 이미지들을 추가해줌으로써 해결할 수 있다.
뭔....예???? 뭔 소리에요 이게 도대체??? 그림으로 살펴보자.
참고 이미지

이렇듯 원래 이미지 배열은 [0,1,2]가 전부이다.
우리는 여기서 아래 코드처럼 배열 원소를 꺼내와서 새로운 배열을 만들어 줄 것이다.

// 원래 배열의 길이
const SLIDE_NUM = slideArr.length;

// 원래 배열의 마지막 부분
const beforeSlide = slideArr[SLIDE_NUM - 1];

// 원래 배열의 첫 부분
const afterSlide = slideArr[0];

// 무한 슬라이드를 구현하기 위해 새롭게 배열을 만듦.
let copiedArr = [beforeSlide, ...slideArr, afterSlide];

이제 2번 div는 copiedArr로 대체되었다.
원래의 배열에 있던 이미지들은 인덱스가 하나씩 증가했겠지?
이제 인덱스는 [0,1,2,3,4]가 되었다. 오리지널은 1,2,3이다.

작동 원리는 다음과 같다.
인덱스가 3 -> 4가 될 때 우리는 복제된 0을 보게된다.
우리는 복제된 0이 보여지는 순간! 오리지널 0으로 바꿔 줄 것이다.
즉, 눈속임을 하겠다는 것이다. 유저 입장에서는 순식간에 오리지널 0으로 돌아왔기 때문에 슬라이드가 자연스럽게 되는 것으로 보일 것이다.
그런데...우리는 transition을 설정했다는 것을 잊으면 안된다! 이 것 때문에 끝에서 끝으로 달리기를 하는 걸 막아야한다.

아래 코드는 인덱스가 3으로 갈 때, slideIndex를 0으로 초기화했던 그 코드를 수정한 것이다.
이 때부터 useRef가 같이 사용된다.(useRef로 DOM 요소 조작하기는 여기를 읽어주세요)

읽으셨다고 생각하고 진행하도록 하겠습니다.

// 2번 div에 ref를 설정해서 조작할 수 있게 해주자!
const slideRef = useRef<HTMLDivElement>(null);

// 인덱스 4에서 3초가 지나 인덱스가 5가 되는 그 순간!
if (slideIndex === 5) {
  // transition 효과를 정지 시킨다.
  if (slideRef.current) {
    slideRef.current.style.transition = "";
  }
  
  // 오리지널 0인 이미지로 이동한다. 인덱스 번호는 1
  // 복제된 0과 오리지널 0은 같은 이미지이므로 유저는 뭐가 다른지 구분 할 수 없다.
  setSlideIndex(1);

  // transition 효과를 복구한다.
  setTimeout(() => {
    if (slideRef.current) {
      slideRef.current.style.transition = "all 500ms ease-in-out";
    }
  }, 0);
}

작동 원리를 코드로 구현한 것이다. 주석을 보면 이해가 빠르다.
여기서 setTimeout을 왜 사용했는지에 대해서 의문이 있으실거다.
setTimeout을 설정하지 않고, 바로 transition을 복구하면 슬라이드가 복제된 0에서 오리지널 0으로 움직임없이 딱 바뀌려고 하다가, 갑자기 다시 애니메이션이 살아나서 끝에서 끝으로 달리기를 해버린다.(이는 interval값과 관련이 있으니, 아래에서 언급하겠습니다)
따라서, setTimeout을 이용하여 비동기 처리를 해준 것이다.(time으로 0을 넣어도 비동기 처리가 된다)
이제, 무한 슬라이드가 자연스럽게 될 것이다!라고 생각하지말자.

마주하게 될 문제

사실, 자연스럽게 동작한다. 슬라이드 자체는.
문제는 interval이다. useInterval에 우리는 3000(3초)을 설정했었다.
그러면 복제된 0 -> 오리지널 0 -> 오리지널 1 ...
이런 동작을 할 때 각 단계마다 걸리는 시간을 얼마일까? 각각 3초다.
유저 입장에서는 복제된 0과 오리지널 0을 구분할 수 없는데, 각각 3초가 걸리기 때문에 총 6초간 슬라이드가 멈춘 것 처럼 보일 것이다.
이를 해결하기 위해, 꼼수를 썼다. 역시나 눈속임이다.

복제본과 오리지널 사이의 interval 조정

아래와 같은 코드를 추가했다.

// slideIndex의 변화를 감지한다.
useEffect(() => {
  // 만약 인덱스가 4라면 interval을 500으로 설정한다.
  if (slideIndex === 4) {
    setCustomInterval(500);
  } else {
    // 평소에는 interval을 3000으로 유지한다.
    setCustomInterval(3000);
  }
}, [slideIndex]);

이렇게 되면, 복제된 0 -> 오리지널 0 -> 오리지널 1 -> 오리지널 2 ...
각 단계의 시간은 3초, 0.5초, 3초이다.
위에서 transition의 복구가 interval과 관련이 있다고 했는데, 바로 여기서 그게 나타난다.
transition의 duration은 500ms이다.
이걸 setSlideIndex의 주기와 같이 표시하면 아래와 같다.

참고 이미지

3초의 interval은 transition의 duration이 끝나고 시작되는게 아니다.
slideIndex가 증가된 그 시점에서 동시에 시작된다. 이 것을 활용해 눈속임을 한 것이다.
복제된 0(인덱스 4)에서 오리지널 0으로 가는 시간은 500으로 설정했다.
그런데, transition의 duration도 500이다.

2(인덱스 3)에서 3초 뒤 복제된 0(인덱스 4)으로 인덱스가 증가함과 동시에 transition 효과가 보이고, 그 효과는 500ms간 지속된다.
2(인덱스 3)에서 3초 뒤 복제된 0(인덱스 4)으로 인덱스가 증가함과 동시에 if문으로 인해 interval이 500으로 변경된다. 따라서, 인덱스 4에서 인덱스 5로 가는 시간은 500ms 밖에 걸리지않는다.
즉, transition 효과가 끝남과 동시에 인덱스가 5로 증가한다!
인덱스 5가 되면, transition 효과가 삭제되고, 슬라이드 인덱스 초기화가 발생한다.
슬라이드는 오리지널 0부터 다시 반복된다.

이제 무한 자동 슬라이드가 매끄럽게 작동한다!!!!
복제된 0과 오리지널 0 사이에 약간의 딜레이가 있는 것으로 느껴지지만, 필자가 생각해낸 방법은 여기까지다...

번외 : 버튼으로 슬라이드 이동하기

const slideHandler = (direction: number) => {
  setSlideIndex((slideIndex) => slideIndex + direction);
};

return (
  // 1번 div
  <div>
    <SlideBtn direction="left" onClick={() => slideHandler(-1)} />
    <SlideBtn direction="right" onClick={() => slideHandler(1)} />
/* ... */
  </div>
)

버튼을 클릭하면 -1과 +1을 각각 파라미터로 보내도록 구현했다.
slideIndex에 -1을 하면 슬라이드는 앞으로 한 칸 갈 것이고, +1을 하면 뒤로 한 칸 갈 것이다.

번외 : 페이지네이션 구현하기

페이지네이션(pagination)에 현재 슬라이드의 위치를 보여주고, 특정 페이지네이션 요소 클릭 시 해당 슬라이드로 이동하는 것을 구현 할 것이다.

return (
  // 1번 div
  <div>
    <Pagination setSlideIndex={setSlideIndex} slideIndex={slideIndex} />
/* ... */
  </div>
)

컴포넌트를 따로 만들어서 import 했으므로, Semantic Tag를 한 번 봅시다.

<div className="absolute z-10 justify-center hidden w-screen bottom-2 group-hover:flex">
  <ol className="flex">
    {paginationArr.map((item, index) => (
      <li
        className="w-[15px] h-[15px] rounded-full bg-[rgba(255,255,255,0.5)] mx-1 hover:bg-[rgba(255,255,255,0.8)] hover:cursor-pointer"
        style={{
        backgroundColor: `${
          slideIndex === index + 1 ? "rgba(255,255,255,0.8)" : ""
              }`,
        }}
        key={index}
        onClick={() => paginationHandler(index)}
      ></li>
    ))}
  </ol>
</div>

구조는 간단하다 ol와 li를 사용했고, li를 클릭하면 그 것에 해당하는 인덱스로 슬라이드가 이동한다.

// 오리지널 배열이기 때문에 길이는 3이다.
const slideLength = slideArr.length;

// 개수를 동적으로 변경하기 위해서 slideLength만큼의 배열 원소를 가지는 배열 생성
let paginationArr = new Array(slideLength);
paginationArr.fill(1);

// pagenation 버튼을 클릭하면 해당 버튼의 index에 맞는 슬라이드로 이동
// setSlideIndex를 해당 버튼의 index로 맞춰주면 됨
// 조심해야할 것은, Pagenation.tsx에서 사용 중인 배열은 앞 뒤에 복제본이 없는 오리지널 배열이라는 것.
// 따라서 버튼 index + 1을 해줘야지 의도한대로 움직임.
const paginationHandler = (index: number) => {
  setSlideIndex(index + 1);
};

페이지네이션에서는 복제본을 표시하면 안되기 때문에 오리지널 배열을 사용했다.
import한 곳에서는 5개의 원소를 가지는, 즉, 복사본이 존재하는 배열에서 슬라이드가 진행되므로, 거기서 props로 받아온 slideIndex와 setSlideIndex를 활용하고자 한다면,
이 컴포넌트에서 +1씩 더해서 계산해줘야한다.

슬라이드 컴포넌트에서 사용하는 배열 : [0,1,2,3,4]
페이지네이션에서 사용하는 배열 : [0,1,2]

paginationHandler에서 인자로 받아온 인덱스가 1이라고 했을 때,
실제 슬라이드 컴포넌트에서는 인덱스가 2인 녀석을 가르키는 것이므로,
index + 1을 해준다!

번외 : 마우스 오버시 슬라이드 멈추기

필자가 참고해서 만들고 있는 사이트에서는 mouseover 이벤트가 발생하면 슬라이드가 멈춘다.
이 것을 구현해보자!

슬라이드가 자동으로 움직이는 이유는 무엇일까? 바로 useInterval로 인한 것이다.
useInterval에 설정된 customInterval로 인해서 slideIndex가 증가하기 때문이다.
그렇다면..슬라이드를 멈추려면 slideIndex의 증가를 멈추면 된다!!

const useInterval: IUseInterval = (callback, interval) => {
  const savedCallback = useRef<(() => void) | null>(null);

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    function tick() {
      if (savedCallback.current) {
        savedCallback.current();
      }
    }

    if (interval !== 10000) {
      let id = setInterval(tick, interval);
      return () => clearInterval(id);
    }
  }, [interval]);
};

원래 사용하던 useInterval을 약간 수정했다. if문으로 분기 처리를 해줬다.
만약 interval에 10000(10초, 유저에게 뭘 보여줄 때 절대로 사용 안 할 것 같은 시간..)을 입력하면 setInterval이 동작하지 않게된다.
따라서 slideIndex의 증가가 멈추고, 슬라이드는 정지한다.

마우스 이벤트를 처리하는 것은 아래와 같이 작성했다.

const outRef = useRef<HTMLDivElement>(null);

const stopSlide = () => {
  setCustomInterval(10000);
};

const restartSlide = () => {
  if (slideIndex === 9) {
    setCustomInterval(500);
  } else {
    setCustomInterval(3000);
  }
};

useEffect(() => {
  outRef.current?.addEventListener("mouseover", stopSlide);
  outRef.current?.addEventListener("mouseleave", restartSlide);

  return () => {
    outRef.current?.removeEventListener("mouseover", stopSlide);
    outRef.current?.removeEventListener("mouseleave", restartSlide);
  };
}, [custominterval]);

outRef는 가장 바깥 1번 div를 참조한다.
만약, 2번 div를 참조하는 slideRef를 사용한다면 버튼과 페이지네이션에 마우스가 올라갈 경우 슬라이드 정지 효과가 사라져버리기 때문에 모든 것을 포함하고 있는 1번 div를 ref로 참조해서 이벤트를 감지해줬다.

풀리지 않은 의문

슬라이드를 멈추는 기능을 구현한 것까지는 좋은데,
단 한번이라도 클릭을 하면 mouseover 이벤트가 사라진다...
이 부분은 아직도 알아보고 있는 중이라, 원인을 명확하게 알 수 없다.

전체 코드 Github

필자의 Github 코드

참고 자료

참고한 자료들이 정말 많다. 그만큼 많은 분들이 기본적으로 공부하고 계시는 거라고 생각이 든다.
필자는 쉽게 이해할 수 없었기 때문에 참고 자료를 보면서도 개인적으로 고민을 많이 했다. 아마 필자의 코드는 다른 분들과 많이 다를 수도 있다.
다른 분들의 코드가 훨씬 깔끔하다고 생각되지만, 필자는 우선 본인이 이해 가능한 방법으로 작성했기 때문에 이 점 양해 부탁드립니다..

참고 자료 1
참고 자료 2
참고 자료 3
참고 자료 4
참고 자료 5
참고 자료 6
참고 자료 7

profile
나를 믿는 사람들을, 실망시키지 않도록
post-custom-banner

1개의 댓글

comment-user-thumbnail
2022년 11월 8일

우와ㅏㅏ 정리 넘 잘하시네요 rkio님! 저도 슬라이드 꼭 하고 싶네요

답글 달기