React로 구현하는 최적화된 가로 스크롤 슬라이더
들어가기
가로 스크롤은 모바일 환경에서는 손가락 터치로 쉽게 조작할 수 있지만, 데스크탑 환경에서는 마우스로 가로 스크롤하는 데 어려움이 있습니다. 일부 마우스에서는 휠을 좌우로 움직이거나 Shift + 휠
조작으로 가로 스크롤이 가능하지만, 이 기능을 모르는 사용자도 많고, 모든 환경에서 지원되는 것도 아닙니다. 이러한 사용성 문제를 해결하기 위해, 좌우로 스크롤할 수 있는 Prev/Next 버튼을 추가하여 사용자 편의성을 높이고자 합니다.
구현 목표
모바일/태블릿(터치 스크린): 터치 스크롤 자연스럽게 지원
데스크탑/노트북(마우스): prev/next 버튼으로 슬라이드 기능 제공
슬라이드 동작 최적화
next 버튼: 현재 부분적으로 보이는 요소가 다음 슬라이드에서 맨 앞에 완전히 보이도록 이동
prev 버튼: 현재 부분적으로 보이는 요소가 이전 슬라이드에서 완전히 보이도록 && 슬라이드의 첫 요소가 잘리지 않도록 이동
버튼 표시 로직
next 버튼: 마지막 요소가 완전히 보이면 숨김
prev 버튼: 첫 요소가 완전히 보이면 숨김
가로 스크롤 슬라이드 최적화의 필요성
export const useHorizontalScroll = () => {
...
const scrollLeft = () => {
if (!scrollRef.current) return;
const { clientWidth } = scrollRef.current;
scrollRef.current.scrollBy({
left: -clientWidth,
behavior: 'smooth',
});
};
const scrollRight = () => {
if (!scrollRef.current) return;
const { clientWidth } = scrollRef.current;
scrollRef.current.scrollBy({
left: clientWidth,
behavior: 'smooth',
});
};
...
};

가로 스크롤 구현에서는 단순히 clientWidth
(컨테이너의 보이는 너비)만큼 스크롤하는 방식을 사용하려했습니다.
그러나 이 방식은 사용자 경험에 몇 가지 문제점을 발생시킵니다. 단순히 clientWidth
만큼 스크롤할 경우, 한 번 잘려서 보이기 시작한 요소는 다음 스크롤에서도 계속 잘려서 보이게 됩니다. 이는 사용자가 특정 요소의 전체 모습을 한 번에 보기 어렵게 만듭니다.
UX 최적화
이 문제를 해결하기 위한 슬라이드 동작 최적화는 아래의 원칙을 따르도록 했습니다.
모든 요소를 완전한 형태로 볼 수 있게 함: 사용자가 모든 콘텐츠를 온전히 볼 수 있도록 합니다.
방향에 따른 최적화: 왼쪽과 오른쪽 스크롤 각각에 특화된 최적화 로직을 적용합니다.
오른쪽으로 슬라이드 할 때 (Next 버튼)
오른쪽으로 스크롤할 때의 주요 문제점은 오른쪽 가장자리에 부분적으로 보이는 요소입니다.

1. 다음 스크롤때 완전히 보여야 하는 요소 찾기
for (const child of children) {
const itemLeft = child.offsetLeft;
const itemRight = itemLeft + child.offsetWidth;
const visibleRight = scrollLeft + clientWidth;
const target = itemRight > visibleRight
if (target) {
// ...
}
}
요소들을 앞에서부터 순회합니다.
visibleRight
는 현재 보이는 영역의 오른쪽 경계입니다.itemRight > visibleRight
: 요소의 오른쪽 경계가 현재 보이는 영역 밖(오른쪽)에 있음을 의미합니다.즉, 요소가 오른쪽 가장자리에 걸쳐서 일부만 보이고 있거나 경계가 겹쳐 다음 스크롤 때 보여줘야함을 의미합니다.
2. 스크롤 이동
if (target) {
container.scrollTo({
left: itemLeft,
behavior: 'smooth',
});
return;
}
다음 스크롤 때 완전히 보여야 하는 요소를 찾으면, 그 요소의 **왼쪽 경계(itemLeft
)**로 스크롤합니다.
이렇게 하면 이전에 부분적으로만 보이던 요소가 다음 화면에서 맨 왼쪽에 완전히 보이게 됩니다.
3. 기본 페이지 단위 스크롤
// 부분적으로 보이는 요소가 없다면 한 페이지 만큼 이동
container.scrollTo({
left: scrollLeft + clientWidth,
behavior: 'smooth',
});
해당 요소가 없으면, 컨테이너 너비(clientWidth
)만큼 오른쪽으로 스크롤합니다.
왼쪽으로 슬라이드 할 때 (Prev 버튼)
왼쪽으로 스크롤할 때의 주요 문제점은 왼쪽 가장자리에 부분적으로 보이는 요소입니다.

1. 다음 스크롤때 완전히 보여야 하는 요소 찾기
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
const itemLeft = child.offsetLeft;
const itemRight = itemLeft + child.offsetWidth;
const target = itemLeft < scrollLeft
if (target) {
// ...
}
}
요소들을 뒤에서부터 순회합니다.
itemLeft < scrollLeft
: 요소의 왼쪽 경계가 현재 보이는 영역 밖(왼쪽)에 있음을 의미합니다.즉, 요소가 왼쪽 가장자리에 걸쳐서 일부만 보이고 있거나 경계가 겹쳐 다음 스크롤 때 보여줘야함을 의미합니다.
2. 스크롤 이동
if (target) {
container.scrollTo({
left: itemRight - clientWidth,
behavior: 'smooth',
});
return;
}
itemRight - clientWidth
: 이 계산은 요소의 오른쪽 끝이 화면의 오른쪽에 위치하도록 스크롤 위치를 잡습니다. 이렇게 하면 현재 부분적으로 보이는 요소의 왼쪽(시작) 부분이 화면에 표시됩니다.
3. 기본 페이지 단위 스크롤
// 부분적으로 보이는 요소가 없다면 한 페이지 만큼 이동
container.scrollTo({
left: scrollLeft - clientWidth,
behavior: 'smooth',
});
해당 요소가 없으면, 컨테이너 너비(clientWidth
)만큼 왼쪽으로 스크롤합니다.
시각적 비교: UX 최적화 전과 후
최적화 전 (clientWidth 기반 스크롤)

요소들이 임의의 위치에서 잘릴 수 있음
한 번 부분적으로 보이기 시작한 요소는 계속해서 부분적으로만 보임
최적화 후 (요소 기반 스크롤)

모든 요소가 최소한 한 번은 완전한 형태로 화면에 표시됨
사용자는 더 직관적이고 예측 가능한 스크롤 경험을 얻을 수 있음
성능 최적화
위의 방식은 정확하게 작동하지만, 한 가지 주요 문제가 있습니다:
사용자가 버튼을 클릭할 때마다 모든 자식 요소를 반복해서 확인해야 합니다. 요소가 많을수록 비효율적이고, 반복적인 사용자 상호작용에서 성능 병목이 될 수 있습니다.
위 문제를 해결하기 위해 두 가지 최적화 접근법을 고려했습니다:
스크롤 단위 미리 계산: 초기에 스크롤 단위를 미리 계산한 후, 버튼 클릭 시 해당 단위만큼 이동
장점:
반복 계산 감소
단점:
방향 전환, 리사이징 등 동적 상황에서 예외 처리가 복잡해짐
현재 보이는 요소만 고려: 화면에 보이는 위치를 매번 갱신하여 모든 요소가 아닌, 현재 화면에 보이는 요소 중심으로 계산
장점:
필요한 요소만 계산하여 일부 성능 개선, 동적 콘텐츠에 더 유연하게 대응
단점:
여전히 일부 계산 필요,
현재 보이는 요소를 추적하는 과정에서 예상치 못한 예외 상황 발생 가능성
이진 탐색을 활용한 경계 요소 추출: 이진 탐색으로 현재 화면 경계에 있는 요소를 찾아 스크롤
장점:
O(log n) 시간 복잡도로 효율적
요소 수가 많을수록 성능 이점이 극대화됨
정확한 위치 계산 가능
단점:
여전히 일부 계산 필요
세 접근법을 비교한 결과, ~한 이유로 세 번째 방법인 이진 탐색이 더 효율적이라고 판단했습니다.
코드 전문
import { useEffect, useRef, useState } from 'react'; export const useHorizontalScroll = () => { const scrollRef = useRef<HTMLDivElement>(null); const elementsRef = useRef<{ left: number; right: number; width: number }[]>([]); const [hasLeftButton, setHasLeftButton] = useState(false); const [hasRightButton, setHasRightButton] = useState(false); const updateOffsets = () => { const container = scrollRef.current; if (!container) return; const children = Array.from(container.children) as HTMLElement[]; elementsRef.current = children.map((el) => ({ left: el.offsetLeft, right: el.offsetLeft + el.offsetWidth, width: el.offsetWidth, })); }; const findIndexByOffset = (offset: number) => { const elements = elementsRef.current; let start = 0; let end = elements.length - 1; while (start <= end) { const mid = Math.floor((start + end) / 2); const { left, right } = elements[mid]; if (offset < left) { end = mid - 1; } else if (offset > right) { start = mid + 1; } else { return mid; } } return start < elements.length ? start : elements.length - 1; }; const updateButtons = () => { const container = scrollRef.current; const elements = elementsRef.current; if (!container || elements.length === 0) return; const firstRect = elements[0]; const lastRect = elements[elements.length - 1]; const leftEdge = container.scrollLeft; const rightEdge = leftEdge + container.clientWidth; setHasLeftButton(firstRect.left < leftEdge); setHasRightButton(lastRect.right > rightEdge + 1); }; const scrollToIndex = (targetIndex: number, align: 'left' | 'right') => { const container = scrollRef.current; const elements = elementsRef.current; if (!container || !elements[targetIndex]) return; const { left, right } = elements[targetIndex]; const { clientWidth } = container; const scrollTo = align === 'left' ? left : right - clientWidth; container.scrollTo({ left: scrollTo, behavior: 'smooth' }); }; const scrollByPage = (direction: 'left' | 'right') => { const container = scrollRef.current; if (!container) return; const { scrollLeft, clientWidth } = container; const scrollTo = direction === 'right' ? scrollLeft + clientWidth : scrollLeft - clientWidth; container.scrollTo({ left: scrollTo, behavior: 'smooth' }); }; const scrollLeft = () => { const container = scrollRef.current; const elements = elementsRef.current; if (!container || elements.length === 0) return; const { scrollLeft: leftEdge, clientWidth } = container; const index = findIndexByOffset(leftEdge); const cur = elements[index]; const prev = elements[index - 1]; if (cur.width > clientWidth) { return scrollByPage('left'); } // gap 영역에 걸쳐 있는 경우: 이전 아이템으로 이동 if (prev && prev.right < leftEdge && leftEdge < cur.left) { return scrollToIndex(index - 1, 'right'); } // 현재 아이템이 정확히 경계에 있다면: 이전 아이템으로 이동 if (cur.left === leftEdge) { return scrollToIndex(index - 1, 'right'); } // 현재 아이템이 너무 커서 잘려있다면: 현재 아이템으로 이동 if (cur.left < leftEdge) { return scrollToIndex(index, 'right'); } // 나머지 경우 return scrollByPage('left'); }; const scrollRight = () => { const container = scrollRef.current; const elements = elementsRef.current; if (!container || elements.length === 0) return; const { scrollLeft, clientWidth } = container; const rightEdge = scrollLeft + clientWidth; const index = findIndexByOffset(rightEdge); const cur = elements[index]; const next = elements[index + 1]; if (cur.width > clientWidth) { return scrollByPage('right'); } // gap 영역에 걸쳐 있는 경우: 다음 아이템으로 이동 if (next && cur.right < rightEdge && rightEdge < next.left) { return scrollToIndex(index + 1, 'left'); } // 현재 아이템이 정확히 경계에 있다면: 다음 아이템으로 이동 if (cur.right === rightEdge) { return scrollToIndex(index + 1, 'left'); } // 현재 아이템이 잘려 있다면: 현재 아이템으로 이동 if (cur.right > rightEdge) { return scrollToIndex(index, 'left'); } // 나머지 경우 return scrollByPage('right'); }; const handleScroll = () => { const container = scrollRef.current; if (!container) return; updateButtons(); }; useEffect(() => { const container = scrollRef.current; if (!container) return; updateOffsets(); updateButtons(); container.addEventListener('scroll', handleScroll); const resizeObserver = new ResizeObserver(() => { updateOffsets(); updateButtons(); }); resizeObserver.observe(container); return () => { container.removeEventListener('scroll', handleScroll); resizeObserver.disconnect(); }; }, []); return { scrollLeft, scrollRight, hasLeftButton, hasRightButton, scrollRef, }; };
1. 기본 설정 및 상태 관리
export const useHorizontalScroll = () => {
// 스크롤 컨테이너에 대한 참조
const scrollRef = useRef<HTMLDivElement>(null);
// 각 자식 요소의 위치 정보를 저장할 참조
const elementsRef = useRef<{ left: number; right: number; width: number }[]>([]);
// 좌우 스크롤 버튼 표시 여부를 결정하는 상태
const [hasLeftButton, setHasLeftButton] = useState(false);
const [hasRightButton, setHasRightButton] = useState(false);
여기서:
scrollRef
는 수평 스크롤 컨테이너 요소에 대한 참조입니다.elementsRef
는 각 자식 요소의 위치 정보(왼쪽 경계, 오른쪽 경계, 너비)를 저장합니다.hasLeftButton
과hasRightButton
은 좌/우 스크롤 버튼 표시 여부를 결정하는 상태입니다.
2. 요소 위치 정보 업데이트
const updateOffsets = () => {
const container = scrollRef.current;
if (!container) return;
const children = Array.from(container.children) as HTMLElement[];
elementsRef.current = children.map((el) => ({
left: el.offsetLeft,
right: el.offsetLeft + el.offsetWidth,
width: el.offsetWidth,
}));
};
이 함수는:
컨테이너의 모든 자식 요소를 배열로 변환
각 요소의 왼쪽 경계(
offsetLeft
), 오른쪽 경계(offsetLeft + offsetWidth
), 너비(offsetWidth
)를 저장
3. 이진 탐색으로 요소 찾기
const findIndexByOffset = (offset: number) => {
const elements = elementsRef.current;
let start = 0;
let end = elements.length - 1;
while (start <= end) {
const mid = Math.floor((start + end) / 2);
const { left, right } = elements[mid];
if (offset < left) {
end = mid - 1;
} else if (offset > right) {
start = mid + 1;
} else {
return mid;
}
}
return start < elements.length ? start : elements.length - 1;
};
이 함수는:
주어진 오프셋(위치)에 해당하는 요소의 인덱스를 찾음
이진 탐색 알고리즘을 사용하여 O(log n) 시간 복잡도로 빠르게 찾음
오프셋이 요소의 왼쪽 경계(
left
)보다 작으면 왼쪽 반을 탐색오프셋이 요소의 오른쪽 경계(
right
)보다 크면 오른쪽 반을 탐색
4. 특정 인덱스로 스크롤
특정 요소로 스크롤하는 함수입니다:
const scrollToIndex = (targetIndex: number, align: 'left' | 'right') => {
const container = scrollRef.current;
const elements = elementsRef.current;
if (!container || !elements[targetIndex]) return;
const { left, right } = elements[targetIndex];
const { clientWidth } = container;
const scrollTo = align === 'left' ? left : right - clientWidth;
container.scrollTo({ left: scrollTo, behavior: 'smooth' });
};
이 함수는:
타겟 인덱스에 해당하는 요소의 위치 정보를 가져옴
align
매개변수에 따라 요소를 왼쪽 정렬 또는 오른쪽 정렬로 보여줌부드러운 스크롤 효과를 위해
behavior: 'smooth'
옵션 사용
5. 페이지 단위로 스크롤
컨테이너의 너비만큼 스크롤하는 함수입니다:
const scrollByPage = (direction: 'left' | 'right') => {
const container = scrollRef.current;
if (!container) return;
const { scrollLeft, clientWidth } = container;
const scrollTo = direction === 'right' ? scrollLeft + clientWidth : scrollLeft - clientWidth;
container.scrollTo({ left: scrollTo, behavior: 'smooth' });
};
이 함수는:
현재 스크롤 위치와 컨테이너 너비를 가져옴
방향에 따라 한 페이지(컨테이너 너비)만큼 스크롤
부드러운 스크롤 효과 적용
6. 왼쪽으로 스크롤 구현
좌측 버튼 클릭 시 호출되는 함수로, 여러 케이스를 처리합니다:
const scrollLeft = () => {
const container = scrollRef.current;
const elements = elementsRef.current;
if (!container || elements.length === 0) return;
const { scrollLeft: leftEdge, clientWidth } = container;
const index = findIndexByOffset(leftEdge);
const cur = elements[index];
const prev = elements[index - 1];
if (cur.width > clientWidth) {
return scrollByPage('left');
}
// gap 영역에 걸쳐 있는 경우: 이전 아이템으로 이동
if (prev && prev.right < leftEdge && leftEdge < cur.left) {
return scrollToIndex(index - 1, 'right');
}
// 현재 아이템이 정확히 경계에 있다면: 이전 아이템으로 이동
if (cur.left === leftEdge) {
return scrollToIndex(index - 1, 'right');
}
// 현재 아이템이 너무 커서 잘려있다면: 현재 아이템으로 이동
if (cur.left < leftEdge) {
return scrollToIndex(index, 'right');
}
// 나머지 경우
return scrollByPage('left');
};
이 함수는 다음 케이스를 처리합니다:
현재 요소가 화면보다 크면: 페이지 단위로 스크롤
요소 사이의 간격에 걸쳐 있는 경우: 이전 요소로 스크롤
현재 요소가 정확히 왼쪽 경계에 있는 경우: 이전 요소로 스크롤
현재 요소가 일부만 보이는 경우: 해당 요소를 완전히 보이게 스크롤
그 외 경우: 페이지 단위로 스크롤
7. 오른쪽으로 스크롤 구현
우측 버튼 클릭 시 호출되는 함수로, 비슷한 케이스를 처리합니다:
const scrollRight = () => {
const container = scrollRef.current;
const elements = elementsRef.current;
if (!container || elements.length === 0) return;
const { scrollLeft, clientWidth } = container;
const rightEdge = scrollLeft + clientWidth;
const index = findIndexByOffset(rightEdge);
const cur = elements[index];
const next = elements[index + 1];
if (cur.width > clientWidth) {
return scrollByPage('right');
}
// gap 영역에 걸쳐 있는 경우: 다음 아이템으로 이동
if (next && cur.right < rightEdge && rightEdge < next.left) {
return scrollToIndex(index + 1, 'left');
}
// 현재 아이템이 정확히 경계에 있다면: 다음 아이템으로 이동
if (cur.right === rightEdge) {
return scrollToIndex(index + 1, 'left');
}
// 현재 아이템이 잘려 있다면: 현재 아이템으로 이동
if (cur.right > rightEdge) {
return scrollToIndex(index, 'left');
}
// 나머지 경우
return scrollByPage('right');
};
이 함수는 scrollLeft
와 유사하지만 오른쪽 방향으로의 스크롤을 처리합니다:
현재 요소가 화면보다 크면: 페이지 단위로 스크롤
요소 사이의 간격에 걸쳐 있는 경우: 다음 요소로 스크롤
현재 요소가 정확히 오른쪽 경계에 있는 경우: 다음 요소로 스크롤
현재 요소가 일부만 보이는 경우: 해당 요소를 완전히 보이게 스크롤
그 외 경우: 페이지 단위로 스크롤
사용 예시
이 훅을 실제로 사용하는 예시 코드는 다음과 같습니다:
function ScrollableRow({ className, children }: ScrollableRowProps) {
const { scrollLeft, scrollRef, scrollRight, hasLeftButton, hasRightButton } = useHorizontalScroll();
return (
<div>
<div ref={scrollRef}>
{children}
</div>
{hasLeftButton && (
<button onClick={scrollLeft}>
...
</button>
)}
{hasRightButton && (
<button onClick={scrollRight}>
...
</button>
)}
</div>
);
}
결론: 최적화된 수평 스크롤의 기술적 가치와 UX 개선효과
최적화한 접근법은 다음과 같은 종합적인 이점을 제공합니다:
기술적 가치
향상된 성능과 효율성: O(log n) 시간 복잡도로 요소를 빠르게 찾아 스크롤함으로써, 특히 많은 요소가 있는 경우 성능이 크게 향상됩니다. (가로 스크롤의 경우 많은 요소를 두는 경우가 별로 없긴 할 것 같긴 하다…)
코드 유지보수성과 재사용성: 커스텀 훅으로 캡슐화되어 다양한 곳에 쉽게 재사용할 수 있습니다.
반응형 동작: 다양한 스크롤 시나리오와 요소 크기에 대응할 수 있는 유연한 구현입니다.
UX 개선 효과
콘텐츠 가시성 향상: 요소가 부분적으로 잘리는 현상을 방지하여 항상 온전한 형태로 콘텐츠를 표시합니다. 사용자는 항상 완전한 형태의 요소를 볼 수 있어 정보 인식이 용이합니다.
일관된 사용자 경험: 스크롤 동작이 예측 가능하고 일관되어 사용자가 쉽게 적응할 수 있습니다.
부드러운 전환 효과: 스무스 스크롤 기능(
behavior: 'smooth'
)을 통해 요소 간 전환이 부드럽게 이루어집니다접근성 개선: 터치가 되지 않는 마우스 환경에서도 편리한 가로 스크롤 버튼을 제공합니다
느낀점 & 배운점
수평 스크롤을 직접 구현하기 전에는 단순히 좌우로 스크롤 해주기만 하면 되므로 금방 끝날 거라 생각했습니다. 하지만 막상 구현을 시작해보니 요소가 한 번은 완전하게 보이게 처리, 요소 간 간격(gap) 등 생각보다 고려할 점이 많았습니다.
특히, 사용자 경험을 해치지 않으면서도 퍼포먼스를 유지하려면 단순한 로직 이상의 세심한 설계가 필요했습니다. 이 과정에서 요소의 위치 정보를 정확히 계산하는 방법, 이진 탐색을 활용한 최적화 등에 대해 배울 수 있었습니다. 결과적으로 단순해 보이는 UI 요소도 얼마나 많은 고민이 필요한지를 몸소 느낄 수 있었던 경험이었습니다.
Last updated