Skip to content

순수 리액트로 커스텀 무한이동 캐러셀 구현기

lybell edited this page Aug 3, 2024 · 1 revision

배경상황

저희 프로젝트에는 기대평 카드가 옆으로 무한정 스크롤되는 기능이 있어요. 이 기능의 구체적인 요구사항은 다음과 같아요.

  • 기본적으로, 기대평 카드는 옆으로 무한히 스크롤할 수 있다.
  • 사용자가 기대평 카드 섹션에 마우스를 올려놓으면, 기대평 카드 스크롤은 일시정지된다.
  • 사용자는 드래그로 기대평 카드 섹션을 직접 움직일 수 있다.
  • 모바일 환경에서도 드래그를 할 수 있다. 단, 모바일 환경은 마우스가 없기 때문에, 마우스를 올려놓을 시 기대평 카드가 스크롤을 멈추는 기능은 없다.
  • 기대평 카드가 다시 스크롤을 개시할 때, 기대평 카드가 스냅되지 않는다.

처음에는, swiper.js를 이용해서 구현해보고자 했어요. swiper.js는 freemode(자유롭게 드래그로 이동 가능하고, 스냅되지 않음)와 autoplay(자동재생) 기능이 있기에, 이 두 기능을 잘 조합하고 CSS를 수정하면 구현이 가능할 것이라고 생각했어요.

<swiper-container className="w-full h-96 [--swiper-wrapper-transition-timing-function:linear]" 
	speed="2500" loop="true" free-mode="true"
	autoplay="true" autoplay-delay="0" autoplay-pause-on-mouse-enter="true" 
	autoplay-disable-on-interaction="false" >
	{
		comments.map( comment=>(
			<swiper-items key={comment.id}>
				<div>{comment.content}</div>
			</swiper-items>
		) )
	}
</swiper-container>

하지만, 생각보다 swiper.js로 요구사항을 전부 구현하는 것은 쉽지 않았어요. 비록 코어 기능인 자동 무한 스크롤, 드래그 가능, 마우스 호버 시 스크롤 일시중지는 가능하지만, swiper.js 선에서 해결이 불가능한 2개의 버그가 있었어요.

  1. 스와이퍼 애니메이션이 선형적이지 않음

    • 이 문제는 swiper.js에서 freemode를 활성화하면 사용자가 설정한 --swiper-wrapper-transition-timing-function 변수를 무시하고, 무조건 transition 속성을 ease-out으로 바꿔버려서 생기는 문제에요. 이걸 해결하려면 swiper-container 내부 엘리먼트에 swiper.js가 설정한 스타일보다 더 강력한 스타일을 주입해야 하지만, swiper.js 구현의 근간이 되는 shadow dom이 외부에서 스타일을 쉽게 변경할 수 없게 되어 있어요.
  2. 드래그를 떼거나, 마우스를 올리고 뗄 때 무조건 스와이퍼 아이템이 스냅됨

    • 이 문제는 swiper.js의 기본 동작 때문에 일어나요. 특히 마우스를 올리고 뗐을 때는 freemode의 관할이 아니다 보니까, 무조건 swiper.js가 기본 동작인 엘리먼트 자동 스냅 동작을 수행해버려요.

이러한 문제 때문에, 저희는 기대평 카드 캐러셀을 swiper.js로 구현하지 않고 직접 구현하기로 했어요. 직접 구현하면 힘들 수는 있겠지만, 적어도 모든 동작이 저희의 관리 하에 있는 셈이니까요. 더 이상 라이브러리의 커스텀할 수 없는 기본 동작을 우회하느라 씨름을 하지 않아도 된다는 거죠!

좌우 무한 스크롤

좌우 무한 스크롤의 원리는 다음과 같아요. 러닝 액션 게임에서 배경이 무한히 스크롤되는 것과 같은 원리를 갖고 있어요.

  1. 같은 요소를 연달아 3번 좌우로 이어붙여요. 대상을 기준점에서 대상의 너비만큼 오른쪽으로, 왼쪽으로 붙이면 돼요.
  2. 스크롤하는 대상의 위치를 x라고 정의해요. 만약, x가 대상의 너비보다 더 크면 x를 대상의 너비만큼 줄이고, x가 0보다 작으면 x를 대상의 너비만큼 더 키워요.

이것을 리액트로 구현해야 하는데, 관건은 다음과 같아요.

  1. 동일한 요소를 어떻게 여러 개 렌더링할 것인가?
  2. 스크롤할 대상의 너비는 어떻게 알아낼 것인가? (성능의 저하 없이)

1번은 다행히도, 리액트가 동일한 리액트 엘리먼트 객체를 여러 위치에다 갖다 놓아도 여러 번 렌더링할 수 있어서 금방 해결했어요. 무조건 DOM 내에서 단 하나의 위치에만 존재해야 하는 실제 DOM 엘리먼트와는 달리, 리액트는 같은 리액트 엘리먼트가 여러 위치에 위치되어도 여러 위치에 잘 렌더링할 수 있어요. 즉, 이런 동작이 리액트에서는 가능해요.

function Carousel({children})
{
	return <div className="w-full h-full overflow-hidden" >
		<div style={{"--gap": gap+"px", transform: `translateX(-${position}px)`}} 
		className="relative">
			<div className={`-translate-x-[calc(100%+var(--gap,0px))]`}>{children}</div>
			<div>{children}</div>
			<div className={`translate-x-[calc(100%+var(--gap,0px))]`}>{children}</div>
		</div>
	</div>
}

children은 props로 받아온 동일한 객체지만, 렌더링할 때 children을 여러 위치에다 동시에 놓아도 여러 번 잘 렌더링되는 모습을 볼 수 있어요.

2번의 경우, dom api 중 Element.innerWidth라는 프로퍼티가 존재한다는 사실을 알고 있지만, Element.innerWidth는 강제 리플로우를 일으키는 요소 중 하나라고 들은 적이 있어서 사용하기가 꺼려졌어요. 하지만, innerWidth와 같이 dom의 실제 너비, 높이 등 좌표값을 알아내는 api는 대상 엘리먼트가 자바스크립트 코드 실행 중 스타일이 도중에 바뀌지 않는 이상, 리플로우를 일으키지는 않아요. 실제로도 swiper.js에서 innerWidth와 비슷하게 강제 리플로우를 일으킬 수 있는 getComputedStyle을 무리없이 사용하는 것으로 봐서 사용해도 괜찮다고 생각했어요.

마지막으로, 스크롤 애니메이션의 x좌표를 어떻게 관리할지가 문제였는데, css로 관리하면 성능은 매우 뛰어나지만, 향후 드래그시 좌표 수정이 일어나는 부분이 있었기 때문에, 자바스크립트로, 구체적으로는 리액트의 상태(useState)로 관리하기로 했어요.

const childRef = useRef(null);
const [position, setPosition] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const timestamp = useRef(null);

useEffect( ()=>{
	if(isHovered) return;

	let progress = true;
	timestamp.current = performance.now();
	function animate(time)
	{
		if(childRef.current === null) return;

		const interval = performance.now() - timestamp.current;
		setPosition(position => {
			const newPos = position + speed * interval;
			return newPos % childRef.current.clientWidth;
		});
		timestamp.current = time;
		if(progress) requestAnimationFrame(animate);
	}

	requestAnimationFrame(animate);

	return ()=>{
		progress = false;
	}
}, [speed, isHovered] );

시간에 따라 x좌표를 움직이는 부분은 위와 같이 구현했어요. 자바스크립트로 상시로 움직이는 애니메이션 동작을 제어해야 하기 때문에, requestAnimationFrame을 이용해서 애니메이션을 구현했어요. requestAnimationFrame은 브라우저가 리페인트될 준비가 될 때, 사용자의 화면 주사율에 맞게 실행되어서, 브라우저의 렌더링 주기에 일관되게 애니메이션을 구현할 수 있어서 setTimeout에 비해 버벅임이 줄어든다고 해요. (setTimeout의 경우 실행 타이밍이 일관적이지 않아서 하나의 브라우저 리페인팅 주기에 번 실행되거나 실행이 안 될 수 있어요.)

requestAnimationFrame에 들어가는 animate 함수는 time 인자로 숫자를, 정확히는 DOMHighResTimeStamp를 받아요. 이 수치는 브라우저가 처음 시작되었을 때부터 계산된 타이머로, time 인자는 직전 리페인팅 주기의 타임스탬프에요. 추가로 사용자 OS의 시간에 구애받지 않기 때문에, 사용자의 악의적인 시스템 시간 변경에 대응할 수 있다는 소소한 장점이 있어요. time 인자와 performance.now() 함수를 이용해서 직전 프레임과 현재 프레임 사이에 걸린 시간을 계산해서, 브라우저가 프레임이 드랍되어도 일관성 있는 속도의 움직임을 계산할 수 있어요.

실제로 좌표를 변경하는 부분은, (position + speed * intrval)%(대상 요소의 clientWidth)로 쉽게 계산할 수 있어요. useEffect의 정리 함수로 progress = false를 설정했는데, 이는 컴포넌트가 언마운트될 때 requestAnimationFrame을 중지하기 위해서에요.

드래그 이동

드래그 통합은 예전에 드래그 인터랙션을 구현할 때 만들어 둔 useMountDragEvent 커스텀 훅을 쓰기로 했어요. 이 훅은 드래그 도중과 드래그 종료 시의 이벤트 리스너를 window에 마운팅하는 역할을 하고 있어요.

const dragging = useRef(false);
const prevDragState = useRef({x:0, mouseX:0});

const onDrag = useCallback(({x: mouseX})=>{
	if(!dragging.current) return;

	let newPos = prevDragState.current.x - mouseX + prevDragState.current.mouseX;
	newPos %= childRef.current.clientWidth;
	setPosition( newPos );
}, []);

const onDragEnd = useCallback((e)=>{
	if(!dragging.current) return;
	dragging.current = false;
	if(e.pointerType === "touch") setIsHovered(false);
}, []);

useMountDragEvent( onDrag, onDragEnd );

onPointerDown(e) {
	setIsHovered(true);
	dragging.current = true;
	prevDragState.current.x = position;
	prevDragState.current.mouseX = e.clientX;
}

먼저 마우스가 드래그를 시작할 때, dragging 속성을 true로 변화시키고, 이전의 요소의 x좌표와 드래그가 시작된 마우스의 x좌표를 저장해요. 추가로 isHovered 상태를 true로 지정해서, 드래그 도중에는 자동으로 이동하는 애니메이션이 실행되지 않도록 했어요.

다음으로, 드래그 도중에는 드래그 시작했을 때 x좌표에서, (현재 마우스 좌표 - 이전 마우스 좌표)를 빼 줬어요. 더하지 않고 뺀 이유는 요소의 x좌표가 절대 좌표 기준 요소의 위치를 의미하는 것이 아니라, 화면의 좌측이 요소의 내부에서 어디에 위치하는지가 기준이기 때문에, 반대로 계산해야 해요.

마지막으로, 드래그가 마무리되면 dragging 속성을 false로 변화시키고, 이 이벤트가 터치 이벤트에서 기반된다면 isHovered 상태를 false로 지정해서, 애니메이션이 즉시 시작되도록 했어요.

추가로, 터치 이벤트에서 드래그 인터랙션이 취소되지 않도록 하기 위해, touch-action CSS 속성을 none으로 잡아주었어요. 이렇게 하지 않으면 터치 제스처를 수행할 때 브라우저의 기본 동작을 하기 위해 드래그가 취소되는 문제가 있기 때문이에요.

x좌표를 뷰포트 기준 요소의 위치 기준이 아니라 요소 기준 뷰포트의 위치로 잡게 된 이유는 크게 2가지에요.

첫째로, 뷰포트가 가운데 요소, 즉 실제 요소를 보여주도록 하고 싶었어요. 뷰포트 기준 요소 위치 기준으로 잡으면 대부분의 경우 왼쪽 요소를 보여주기 때문에, 언제라도 무한 스크롤이 끊겨 보일 수 있다는 문제가 있었어요.

다음으로, 기본 동작을 요소가 오른쪽에서 왼쪽으로 흐르는 동작으로 만들고 싶었기 때문이에요. 사람들은 왼쪽부터 오른쪽으로 내용을 읽기 때문에, 오른쪽에 다음 내용이 있을 것이라고 기대해요. 하지만, 뷰포트 기준 요소 위치를 x좌표로 잡으면 기본 동작이 왼쪽에서 오른쪽으로 흐르는 동작이 되어버려서, 새로운 내용이 왼쪽에서부터 나오는 모양이 되어버려요. 요소 기준 뷰포트의 위치로 x좌표를 잡으면, 기본 동작이 자연스럽게 화면이 요소의 왼쪽에서 오른쪽으로 훑는 모양이 되기 때문에, 사용자가 더 자연스럽게 내용을 볼 수 있을 것이라고 생각했어요.

관성 구현

실제 스와이퍼 요소는 관성이 적용되어 있어요. 사용자가 스와이프를 강하게 하면 관성이 작용해서 터치를 종료해도 종료한 방향으로 그대로 빠르게 이동해요. 반면, 스와이프를 약하게 하면 관성이 약하게 적용되어요. 하지만 지금까지 구현된 드래그 이동은 관성이 적용되지 않아서 사용자가 기대한 관성 동작과 다르다는 문제가 있었어요.

관성을 구현하기 위해서는, 다음의 2개가 필요했어요.

  1. 마우스 드래그 속도를 알아야 했어요. 마우스 드래그 속도는 직전 마우스 위치와 현재 마우스 위치의 차이로 결정할 수 있어요.
  2. 드래그가 종료될 때, 시스템이 x좌표를 자동으로 계산해야 해요. 이 때, x좌표의 속도는 원래 속도 혹은 관성이 적용된 속도가 적용되게 해야 해요.
const [isControlled, setIsControlled] = useState(false);
const prevDragState = useRef({ x: 0, mouseX: 0, prevMouseX: 0 });
const momentum = useRef(speed);

우선, 사용자가 드래그 도중임을 알려주는 isControlled라는 상태를 추가하고, 관성과 이전 마우스 좌표를 담는 변수도 새로 만들어 주었어요.

```js
momentum.current = (prevDragState.current.prevMouseX - mouseX) * MOMENTUM_RATE;
prevDragState.current.prevMouseX = mouseX;

다음으로, 마우스가 드래그 중일 때 이전 마우스 좌표와 현재 마우스 좌표를 기반으로, 관성이 적용될 속도를 갱신했어요.

// 마우스 뗐을 때 관성 재계산
const baseSpeed = isHovered ? 0 : speed;
momentum.current -= (momentum.current - baseSpeed) * FRICTION_RATE;

// 관성 속도가 원래 속도와 적게 차이난다면, 관성의 적용을 멈추고 원래 속도로 돌아감
if (Math.abs(momentum.current, baseSpeed) < MOMENTUM_THRESHOLD)
  momentum.current = baseSpeed;
const finalSpeed = momentum.current;

// 인터벌과 실제 x 포지션 계산
const interval = performance.now() - timestamp.current;
setPosition((position) => {
  const newPos = position + finalSpeed * interval;
  return newPos % childRef.current.clientWidth;
});

마지막으로, 마우스를 뗐을 때, 즉 시스템에서 x좌표를 컨트롤 중일 때에는 관성을 적용시키고, 관성에 마찰력을 주어서 관성의 속도를 시간이 갈수록 줄이게 만들었어요. 관성이 무한정 줄어들면 속도가 영원히 균일하지 않게 되는 문제가 있어서, 이를 방지하기 위해 적용된 속도와 원래 속도의 차이를 비교해서, 일정 수치 이하면 원래 수치가 적용되게 했어요.

트러블슈팅 - 모바일 환경에서 캐러셀을 아래로 내려서 스크롤을 하지 못함

실제 모바일 화면에서는, 화면을 아래에서 위로 스와이프해서 스크롤을 아래로 내려요. 하지만, 저희가 만든 캐러셀에서 터치를 시작해서 스크롤을 내리려고 하면, 스크롤이 내려지지 않는 대신 캐러셀이 움직이게 되어요. 사용자는 스크롤을 밑으로 내려서 다음 컨텐츠를 보고 싶은데, 기대했던 스크롤 동작이 아니니 사용자의 경험이 저해되는 것이죠.

원인은 드래그 동작을 보장하기 위해 설정한 css 속성인 touch-action:none에 있었어요. 이 속성은 모든 브라우저의 기본 터치 액션을 무효화하기 때문에, 스와이프로 스크롤하는 동작도 막혀버려요.

저희는 해당 드래그 이벤트가 시작되는 요소의 touch-action 속성을 pan-y로 설정했어요. y축 패닝 동작을 브라우저의 기본 동작으로 취한다는 의미에요. 하지만 이렇게 되면 y축 스와이프 이벤트를 수행할 때 조금씩 요소가 드래그되었다가 끊기는 이상한 버그가 발생해요.

이를 방지하기 위해, pointermove 이벤트는 이벤트가 발생한 주체가 터치 이벤트라면 실행되지 않게 했어요. 마우스 등 이벤트를 수행하면 pointermove 이벤트만 수행되고, 터치 이벤트를 수행하면 touchmove 이벤트만 수행하게 하는 거에요. 만약 y축으로 패닝하는 동작과 같이 브라우저의 네이티브 동작이 수행된다면, pointercancel 이벤트가 먼저 실행되어서 터치를 종료했다고 먼저 판정되기 때문에, touchmove 이벤트도 실행되지 않게 되어요. 이를 적용하면, 모바일 환경에서 세로로 패닝하면 스와이프가 발생하지 않고, 가로로 패닝하면 스와이프가 발생하는 동작을 구현할 수 있어요.

딥다이브 - pointer move 이벤트와 touch-action 속성

기본적으로, touch-action이 아무것도 적용되지 않은 상태에서, 사용자가 터치로 패닝 액션을 수행하면 다음과 같은 일이 일어나요.

  1. pointermove 이벤트가 처음 몇 ms동안 일어나요.
  2. 브라우저가 사용자의 제스처를 감지해서, 브라우저의 네이티브 제스처 동작으로 동작을 전환시켜요. 이 과정에서 pointermove 이벤트가 취소되어요.
  3. touchmove 이벤트가 일어나요.

하지만, touch-action:none을 적용시켜서 브라우저의 네이티브 액션을 막는다면, pointermove 이벤트와 touchmove 이벤트가 동시에 실행되게 되어요. 이것이 바로 저희가 드래그 인터랙션을 구현할 때 touch-action:none 속성을 줬던 이유였어요. 저희는 기본적으로 pointercancel 이벤트로 드래그의 종료를 판정하는데, touch-action:none을 설정하면 터치 드래그 제스처가 브라우저의 네이티브 액션으로 전환되지 않기 때문에, 원치 않는 pointercancel 이벤트를 막음으로써 드래그를 지속할 수 있기 때문이었어요.

지금은 터치 환경에서는 pointermove 이벤트가 실행되지 않고, touchmove 이벤트로 드래그 인터랙션을 구현하도록 바뀐 상태에요. 이렇게 되면, 브라우저의 네이티브 이벤트가 실행될 때에는 드래그 이벤트가 동작하지 않고, 네이티브 이벤트를 막는다면 드래그 이벤트가 동작하게 되어요. 다음의 과정을 보시죠.

  • 브라우저의 네이티브 동작이 일어나야 할 때 (touch-action에서 지정된 동작)
    1. pointermove 이벤트가 처음 몇 ms 동안 일어나요. 하지만, 사용자가 터치를 수행했으므로 이 이벤트는 일어나지 않아요.
    2. 브라우저가 네이티브 제스처 동작으로 동작을 전환시키면서, pointercancel 이벤트를 발송해요. 이 과정에서 '드래그중' 상태가 false로 바뀌어요.
    3. touchmove 이벤트가 일어나요. 하지만, '드래그중' 상태가 false이기 때문에, 이벤트는 일어나지 않아요.
  • 브라우저의 네이티브 동작을 막을 때 (touch-action에서 지정하지 않은 동작이거나 touch-action:none일 때)
    1. pointermove 이벤트와 touchmove 이벤트가 일어나요. pointermove 이벤트는 무시하고, touchmove 이벤트에서 저희가 정의한 동작을 수행해요.
    2. touch-action css 동작에서 브라우저의 네이티브 동작 전환을 막기 때문에, pointercancel 이벤트는 일어나지 않고, 원치 않는 드래그 종료를 방지할 수 있어요.
Clone this wiki locally