본 글은 2024.11.13에 마지막으로 수정되었습니다.
Devel Up 서비스에는 '위로 가기 버튼'이 있습니다. 아래 사진과 같은 버튼인데요, 글이 길어지는 경우 사용자 경험을 향상시키기 위해 스크롤을 최상단으로 끌어올릴 수 있게 해주는 버튼입니다.
이 버튼은 페이지 스크롤에 상관 없이 항상 position: fixed로 화면에 고정되어 있었습니다. 하지만 스크롤이 최상단에 있는 경우에도 버튼이 존재하는 것이 어색하다는 사용자 피드백이 있었습니다. 따라서 이 버튼을 스크롤이 특정 지점 아래에 있을 때만 보이도록 구현하기로 했습니다.
여기서 든 의문점이 있었습니다.
1. 사용자의 스크롤 위치를 항상 구하고 있어야 되는가?
2. 스크롤 위치를 항상 구하고 있어야 한다면, 이벤트의 콜백 함수를 계속 실행해야 하는가?
3. 콜백 함수를 통해 window의 y좌표를 계속 구하고 있어야 되는가?
4. 그렇다면 성능 저하가 발생하지 않는가?
성능 저하를 방지하기 위해, 스크롤 이벤트의 콜백 함수의 실행 횟수를 줄이기로 결정했습니다. 실행 횟수를 줄이는 방법에는 throttle 방식과 debounce 방식이 있는데요, 각각의 차이점이 존재했습니다.
Throttle 방식
throttling이라고도 불리는 throttle 방식은 일정 간격을 두고 함수가 실행되도록 하는 기능입니다. 실행 횟수에 제한을 두고, 마지막 함수가 호출된 뒤 일정 시간이 지나기 전에 다시 호출되지 않는다는 특징이 있습니다.
하지만 이 방식은 사용자가 스크롤하는 동안에도 화면의 높이를 계산해야 하므로 적절하지 않다고 판단했습니다.
구현하고자 하는 기능은 스크롤이 멈췄을 때 현재 위치가 최상단인지 여부만 확인하면 되므로, 스크롤 중에는 화면 계산이 필요하지 않습니다.
실험을 위해 throttle 함수를 작성하여 실행해 보았는데요,
// utils/throttle.ts
const throttle = (action: () => void, limit: number) => {
let waiting = false;
return function () {
if (!waiting) {
action();
waiting = true;
setTimeout(() => {
waiting = false;
}, limit);
}
};
};
// useScrollVisibility.ts
import { useEffect, useState } from 'react';
import { throttle } from '@/utils/throttle';
export function useScrollVisibility(threshold: number = 0) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleScroll = throttle(() => {
console.log(window.scrollY); // y좌표 출력
const scrollPosition = window.scrollY;
if (scrollPosition > threshold) {
setIsVisible(true);
} else {
setIsVisible(false);
}
}, 200);
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [threshold]);
return isVisible;
}
throttle을 적용한 handleScroll이라는 함수가 실행될 때마다 window의 스크롤 위치의 y좌표를 출력하도록 했습니다.
출력 결과는 아래와 같습니다. 스크롤이 진행되는 동안에도 함수가 실행되네요.
실험 중 버그를 발견하기도 했는데요, 빠르게 스크롤하는 경우 최상단에 스크롤이 위치해도 y좌표가 0이 되지 않는 것을 볼 수 있습니다.
이는 handleScroll 함수가 실행되는 타이밍의 차이 때문입니다.
스크롤이 최상단에 도착하기 전에 handleScroll 함수가 실행되어 window의 y좌표가 0보다 큰 값으로 출력됩니다. 따라서 스크롤이 최상단에 있지 않다고 판단하여 '위로가기' 버튼이 화면에 여전히 보입니다.
Debounce 방식
Debounce는 어떤 이벤트가 끝났을 때 실행하는 방식입니다. 일정 시간 내에 반복되어 발생하는 이벤트가 있다면, 가장 마지막 (혹은 가장 처음) 이벤트만을 실행시킵니다.
스크롤이 끝났을 때 y좌표를 계산한다는 점에서 '위로 가기' 버튼에 적절하다고 판단했습니다.
코드는 다음과 같습니다.
// utils/debounce.ts
export const debounce = (action: () => void, delay: number) => {
let timer: ReturnType<typeof setTimeout>;
return function () {
clearTimeout(timer);
timer = setTimeout(() => {
action();
}, delay);
};
};
기존의 timer는 clearTimeout을 통해 초기화하고, action이라는 콜백 함수를 전달받아 새로운 setTimeout을 걸어줍니다.
// useScrollVisibility.ts
import { useEffect, useState } from 'react';
import { debounce } from '@/utils/debounce';
export function useScrollVisibility(threshold: number = 0) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleScroll = debounce(() => {
console.log(window.scrollY); // y좌표 출력
const scrollPosition = window.scrollY;
if (scrollPosition > threshold) {
setIsVisible(true);
} else {
setIsVisible(false);
}
}, 200);
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [threshold]);
return isVisible;
}
y좌표를 출력해 보았을 때, 스크롤이 끝날 때 y좌표를 출력하는 것을 볼 수 있습니다.
빠르게 스크롤을 올려도 정확한 y좌표를 출력하네요.
함수 실행 횟수 비교
함수가 같은 조건에서 각각 몇 번 실행되는지 실험을 해봤습니다. 끝없는 궁금증...
handleScroll 내부에 scroll 출력문을 추가하고, 다음과 같은 조건에서 몇 번 출력되는지 비교해 보겠습니다.
조건
- 메인 페이지에서 진행
- 터치패드로 길게 스크롤 한 뒤, 스크롤이 완전히 끝나고 다음 스크롤 반복
- 페이지 하단에 닿을 때까지 반복
// useScrollVisibility.ts
import { useEffect, useState } from 'react';
import { debounce } from '@/utils/debounce';
export function useScrollVisibility(threshold: number = 0) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleScroll = debounce(() => {
console.log('scroll'); // scroll 출력
const scrollPosition = window.scrollY;
if (scrollPosition > threshold) {
setIsVisible(true);
} else {
setIsVisible(false);
}
}, 200);
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [threshold]);
return isVisible;
}
throttle, debounce 모두 적용하지 않았을 때 : 48회 출력
throttle 적용 시 : 11회 출력
debounce 적용 시 : 3회 출력
확실히 세 조건에서 debounce를 적용했을 때 스크롤 이벤트가 더 적게 실행되는 것을 볼 수 있습니다.
애니메이션 적용하기
이제 버튼이 등장할 때 일어나는 애니메이션을 적용해보려 하는데요, 다른 사이트에서는 어떻게 구현했는지 찾아보았습니다.
참고 : 인프런 (로드맵 메뉴)
먼저 인프런에 접속했을 때, 최상단에서 위로 가기 버튼이 없지만 스크롤을 아래로 내리면 애니메이션 없이 버튼이 등장합니다.
참고 : 네이버 뉴스
최상단에서는 버튼이 없고, 아래로 스크롤 시 애니메이션 없이 버튼이 생기는 것을 볼 수 있습니다.
참고 : 올리브영
올리브영에서는 모든 페이지에 위로가기 버튼이 존재했습니다.
최상단에서는 버튼이 없고 일정 높이로 스크롤을 내리면 버튼이 등장하는 형태입니다.
화면 녹화라 프레임이 끊겨 gif 이미지에서는 잘 보이지 않지만, 버튼이 등장할 때 opacity가 0에서 1로 변하면서 부드러운 애니메이션 효과가 있고 display 속성이 block으로 바뀝니다.
올리브영의 애니메이션이 제가 목표하던 바와 일치하므로 이곳을 참고하며 구현해 보겠습니다.
기존의 스타일 파일은 다음과 같았습니다.
import media from '@/styles/mediaQueries';
import styled from 'styled-components';
export const ScrollButton = styled.button`
// 기타 스타일 속성들 ..
position: fixed;
bottom: 4.5rem;
right: 10rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
transition: background-color 0.2s;
&:hover {
background-color: rgba(115, 131, 214, 0.5);
}
`;
이곳에서 버튼 등장 애니메이션을 위해 고려해야 할 부분은 display와 코드에는 없지만 opacity, visibility 속성이 있습니다.
먼저 display의 경우 DOM 트리와 CSSOM 트리를 결합한 렌더 트리에서 조작된다는 특징이 있는데요,
display: none은 렌더 트리에서 사라지고, display: block이 되면 다시 렌더 트리에 추가됩니다.
렌더 트리에 display: block이 적용된 노드가 추가되면서 reflow가 진행되고, 이어서 repaint도 진행되어 display 변경은 연산이 많이 드는 작업입니다.
따라서 브라우저 렌더링 과정 중 Layout과 Paint 과정이 다시 일어나야 하는 것이죠. 이렇게 되면 전체 레이아웃이 영향을 받을 수 있습니다. display를 조작하기보다는 다른 속성을 조작하는 것이 더 좋을 것 같습니다.
opacity의 경우 단순히 화면에서 보이는지 여부만 조작합니다. opacity가 0이면 화면에서 보이지 않고, 1이면 투명도가 100%로 보입니다. 화면에서 요소가 보이지 않아도 렌더 트리에는 해당 요소가 여전히 존재하므로, 레이아웃 재계산 과정이 필요하지 않아 reflow가 일어나지 않습니다. 따라서 자주 변경되는 값인 isVisible을 트리거로 opacity 속성을 변경하는 코드를 작성했습니다.
import media from '@/styles/mediaQueries';
import styled from 'styled-components';
interface ScrollButtonProps {
$isVisible: boolean;
}
export const ScrollButton = styled.button<ScrollButtonProps>`
// 기타 스타일 속성들 ..
position: fixed;
bottom: 4.5rem;
right: 10rem;
cursor: pointer;
pointer-events: ${({ $isVisible }) => ($isVisible ? 'auto' : 'none')};
opacity: ${({ $isVisible }) => ($isVisible ? 1 : 0)};
z-index: 1000;
transition:
opacity 0.2s ease-in,
background-color 0.2s ease;
&:hover {
background-color: rgba(115, 131, 214, 0.5);
}
`;
추가로, opacity는 단순히 화면에서 보이지 않도록 설정하는 속성이기 때문에 해당 요소에 이벤트가 등록되어 있다면 여전히 이벤트를 실행시킬 수 있는 상태가 됩니다. 따라서 이벤트를 발생시키지 않도록 pointer-events 속성을 추가했습니다.
마무리
'위로 가기' 버튼에 debounce와 스타일을 지정해 보았습니다. 아직 성능과 디자인 속성들에 개선해야 할 점은 많지만, 하나씩 차근차근 진행해보려 합니다.
오류가 있다면 편하게 댓글로 알려주세요!
읽어주셔서 감사합니다.
참고 자료
Using Debounce and Throttle to enhance your website
scroll event 최적화로 웹페이지 성능 개선하기
디바운스와 쓰로틀(Debounce & Throttle) - 최적화를 도와주는 기법
'우아한테크코스' 카테고리의 다른 글
사용자 피드백 반영하기 (수정 기능, 반응형 랜딩+헤더 구현) (0) | 2024.10.31 |
---|---|
우아한테크코스 최종 데모데이 회고🍀 (0) | 2024.10.28 |
웹 사이트의 성능을 높여보자 (2) (같은 건 매번 새로 요청하지 않기, 최소한의 변경만 일으키기) (0) | 2024.09.17 |
S3, AWS CloudFront(CDN) 캐시 설정의 차이점 (0) | 2024.09.17 |
웹 사이트의 성능을 높여보자 (1) (요청 크기 줄이기, 필요한 것만 요청하기) (0) | 2024.09.15 |