이거 가로 스크롤 안되는데요?
UI 작업 중 가로 스크롤 컴포넌트에 대한 이야기다.
담당하고 있던 홈과 챌린지 상세 정보 페이지에는 각각 가로로 스크롤을 필요로 하는 컴포넌트가 존재했다.
처음에는 이 컴포넌트들에 대해 굉장히 단순하게 스타일링 해두었었는데
(1) 컴포넌트를 감싸는 너비는 100%로 지정하여 화면 크기 만큼 채워주고
(2) 해당 너비 이상 넘치는 부분은 overflow-x: auto; 를 통해 접근을 허용한 뒤
(3) 모든 브라우저에서 스크롤바가 노출되지 않도록 display: none; 을 지정해주었다.
const SSectionScrollX = styled.div`
display: flex;
gap: 0.625rem;
width: 100%;
height: 254px;
overflow-x: auto;
padding: 1rem 1.25rem;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
위처럼 스타일링 한 이후 화면 테스트를 했을 때 드래그가 잘 동작하길래 "음 잘 됐네!" 하고 넘어갔었는데 문제는 코드리뷰에서 발생했다.
팀원은 스크롤이 안된다고 말하시는 것... 왜지?
확인해보니 모바일 기기에서만 드래그가 동작하고 PC에서는 동작하지 않았다. 그럼 나는 왜 동작하는 줄 알았던거지?
웨이브드는 모바일 기반 웹앱으로 서비스의 최대 너비를 430px로 제한해두고 있었다. 덕분에 화면을 작게 띄울 수 있어 주로 개발자 도구를 우측에 열어둔채로 작업을 했는데, 알고보니 나는 기기 변경 툴바를 통해 모바일 기기로 전환한 상태로 작업하고 있었던 것. 툴바를 해제하니 팀원이 말해준 것 처럼 드래그가 동작하지 않았다.
모바일에서는 애초에 스크롤 바를 노출하지 않고 터치로 동작을 대신하기 때문에 현재의 모양이 문제가 없지만, PC에서는 스크롤 바를 비노출함으로써 컴포넌트를 움직이게 할 수단을 잃어버린 셈이다.
웨이브드가 비록 모바일 기반이지만 PC에서 접근을 허용하고 있기 때문에, 모바일만 가능한 현재와 같은 상태는 부적절하다는 판단을 했다. 사용자에게 트랙패드로 넘기라고 강제 할 수도 없고 말이다🐴
이렇게 가로 스크롤 컴포넌트를 제대로 만들기 위한 여정에 떠났다.
시도해볼 수 있는 방법은 아래의 두 가지로 좁혔고, 하나씩 시도해보았다.
- 마우스 이벤트 애니메이션 구현
- react-indiana-drag-scroll 라이브러리 사용
(1) 마우스 드래그 이벤트 애니메이션 구현
해당 공통 컴포넌트의 이름은 DragXComponenet 라고 명명했다. 이 컴포넌트 안에 들어갈 자식 노드는 children으로 받을 예정이다.
useRef 훅을 활용하여 드래그 가능한 요소를 참조하고, 각 마우스 이벤트에 따라 드래그 동작을 처리하도록 구현했다. 그리고 앞서 스크롤바를 숨기는 등의 스타일링을 적용했다.
// components/common/DragXComponent.tsx
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
export default function DragXComponent({
children,
}: {
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState<boolean>(false); // 드래그 여부 표시
const [startX, setStartX] = useState<number>(0); // 드래그가 시작되는 X 좌표
// 마우스 드래그 시작 (onMouseDown)
const onDragStart = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
if (!ref.current) return;
setIsDragging(true); // 드래그 상태 활성화
setStartX(e.pageX + ref.current.scrollLeft); // 시작점 설정
};
// 마우스 드래그 끝 (onMouseUp, onMouseLeave)
const onDragEnd = () => {
setIsDragging(false); // 드래그 상태 비활성화
};
// 마우스 드래그 중 (onMouseMove)
const onDragMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || !ref.current) return;
const { scrollWidth, clientWidth, scrollLeft } = ref.current;
ref.current.scrollLeft = startX - e.pageX; // 스크롤 위치 조정
// 스크롤이 처음/끝에 도달했을 때 시작점 업데이트
if (scrollLeft === 0) {
setStartX(e.pageX);
} else if (scrollWidth <= clientWidth + scrollLeft) {
setStartX(e.pageX + scrollLeft);
}
};
return (
<SScrollXLayout
ref={ref}
onMouseDown={onDragStart}
onMouseMove={isDragging ? onDragMove : undefined}
onMouseUp={onDragEnd}
onMouseLeave={onDragEnd}
>
{children}
</SScrollXLayout>
);
}
const SScrollXLayout = styled.div`
width: 100%;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
이렇게 작성된 공통 마우스 드래그 컴포넌트는 아래와 같이 페이지 컴포넌트에서 불러와 사용하도록 했다.
// pages/index.tsx
export default function Home({
return(
<>
{/* .... */}
<DragXComponent>
<SListScrollX>
{getMyProcessingChallenges.map((challenge) => (
<ChallengeCardWide key={challenge.groupId} {...challenge} />
))}
</SListScrollX>
</DragXComponent>
<>
)
}
const SListScrollX = styled.ul`
margin: 0 1.25rem;
`;
이렇게 기본적인 마우스 이벤트 동작은 구현은 완료!
이어서 성능을 챙기기 위한 추가 작업으로 아래 항목들을 차례로 진행했다.
쓰로틀링 적용
현재 코드의 경우 마우스 드래그 이벤트가 발생하는 도중에 너무 빈번하게 호출되어 브라우저 성능에 영향을 줄 수 있다고 판단했다. 이를 방어하기 위한 수단으로 Throttling을 적용했다. 쓰로틀링을 통해 이벤트를 반복시킬 간격을 설정하고, 해당 간격마다만 이벤트를 호출하는 형식으로 개선할 수 있었다.
다음과 같이 throttle 유틸 함수를 별도로 생성하고 onDragMove에 적용해주었다.
// utils/throttle.ts
const throttle = <T extends any[]>(
callback: (...args: T) => void,
ms: number,
) => {
let isThrottling = false;
return (...args: T) => {
if (isThrottling) return;
isThrottling = true; // 쓰로틀링 활성화
// 주어진 ms 간격마다에만 이벤트 실행. 그 외에는 제한
setTimeout(() => {
callback(...args);
isThrottling = false;
}, ms);
};
};
export default throttle;
// components/common/DragXComponent.tsx
...
// 스크롤 동작에 쓰로틀링 적용
const onThrottleDragMove = throttle(onDragMove, 50);
return (
<SScrollXLayout
ref={ref}
onMouseDown={onDragStart}
onMouseMove={isDragging ? onThrottleDragMove : undefined}
onMouseUp={onDragEnd}
onMouseLeave={onDragEnd}
>
{children}
</SScrollXLayout>
);
}
메모이제이션 적용
각 이벤트 핸들러에는 useCallback 훅을 적용하고, 쓰로틀링이 적용된 onThrottleDragMove에는 useMemo 훅을 적용해주었다.
(2) react-indiana-drag-scroll 라이브러리 사용
다음은 손쉽게 라이브러리를 이용하는 방법이다. 패키지를 설치한 이후에 적용했다.
https://norserium.github.io/react-indiana-drag-scroll/
앞선 방법과 동일하게 공통 컴포넌트의 이름은 DragXComponenet 이고, 자식 노드는 children으로 받을 예정이다. 자식을 ScrollContainer 로 감싸기만 하면 끝이며, 스타일드 컴포넌트도 전과 동일하게 작성하면 끝이다.
// components/DragXComponent.ts
import styled from '@emotion/styled';
import ScrollContainer from 'react-indiana-drag-scroll';
export default function DragXComponent({
children,
}: {
children: React.ReactNode;
}) {
return <SScrollXLayout>{children}</SScrollXLayout>;
}
const SScrollXLayout = styled(ScrollContainer)`
width: 100%;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
마주한 차이, 두 방식 중 나의 선택은?
두 방식을 모두 테스트해보았다.
프로젝트에 무엇을 적용할 지에 대해 선택하는 것은 작업자인 나의 몫이었다. 고민고민.
개인적으로는 라이브버리 의존을 지양하는 편이라, 전자인 마우스 이벤트로 직접 애니메이션을 구현하고 싶었으나 작은 문제점이 있었다. 쓰로틀링 적용 등 성능적으로 괜찮은 애니메이션을 만들기 위해 노력을 기울였음에도 불구하고 라이브러리와 비교했을 때 동작에 묵직함이 느껴졌다. 제깍 제깍 마우스에 반응하는 라이브러리와는 달리 한 차례 느리게 '영~차' 하면서 반응하는 모습에 아쉬움이 있었다.
레퍼런스로 삼았던 다른 예제들에서는 이런 문제가 안보였는데 내 프로젝트에서만 이렇다는 것은 어디에서 문제가 있다는건데... 원인을 찾지 못했다. 호환 문제인가?
이 문제점을 파악하기 위해서는 리소스를 더 들여야 하는 상황이였는데, 앞으로 이어서 해야 할 작업들이 있었던 터라
후자인 라이브러리를 적용하는 방법을 프로젝트에 적용하기로 했다.
개발자인 내가 아쉬움을 느끼는 동작이라면 사용자 역시 캐치할테니, 사용자한테 더 좋은 방법을 채택하는 것이 맞는 것 같다.
그리고 이 내용을 팀원들에게 공유하면서 컴포넌트 작업을 마무리했다. (#27)
+) 이후 이어진 리팩토링 스프린트에서는 이 컴포넌트를 사용하는 곳이 한 곳으로 스펙이 축소되었다. 이 점과 더불어 배포 이후 전체적인 렌더링과 애니메이션 성능이 개선된 상태인 점과 PWA 도입으로 모바일 비중이 커진 점을 고려하여 1번 방법인 마우스 이벤트를 다시 적용하기로 하였다. (#89)
그리고 이 과정에서 코드 수정과 더불어 커스텀 훅으로 분리하는 작업도 함께 진행하며 정리에 힘썼다. 작업 코드는 다음 커밋에서 볼 수 있다. https://github.com/Senity-Waved/Waved_FE/pull/94/commits/777e64fac3eeca93af65e69f09b30b0686bdfa78
완성본
참고
'프로젝트 > WAVED' 카테고리의 다른 글
[프로젝트][WAVED] 지표 확인을 위해 구글 애널리틱스(GA) 도입하기 (0) | 2024.05.07 |
---|---|
[프로젝트][WAVED] next-pwa를 이용해서 PWA 앱으로 발전시키기 (0) | 2024.04.17 |
[프로젝트][WAVED] Next.js를 선택했다고 끝난게 아니다? 라우팅 방식과 렌더링 방식에 대한 고민들 (0) | 2024.03.05 |