기존 프로젝트에서, 고정된 헤더가 스크롤을 내릴 때 사용자에게 불편함을 주지 않고 자연스럽게 콘텐츠와 어우러지도록 하기 위해, 헤더에 시각적인 변화를 주는 방식으로 구현을 했했다.

스크롤이 발생하면 헤더에 Shadow가 생기고, Opacity가 조정되며, 헤더 높이가 72px에서 56px로 살짝 낮아지도록 설정했다.
export const headerContainer = recipe({
base: style({
height: '4.5rem', // 72px
padding: '0.5rem 0',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
animation: `${fadeIn} 0.5s ease-out`,
borderBottom: '1px solid transparent',
}),
variants: {
isScrolled: {
true: {
height: '3.5rem', // 56px
backgroundColor: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.05)',
height: '3.5rem',
padding: '0.25rem 0',
},
},
},
});문제 발생
그런데 특정상황에서 헤더의 스타일이 계속해서 변경되서 깜빡이는 현상이 발생했다.
import { useEffect, useState } from 'react';
export const useScrollDetection = (threshold: number) => {
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
const currentScrollY = window.scrollY;
if (!isScrolled && currentScrollY > threshold) {
setIsScrolled(true);
} else if (isScrolled && currentScrollY < threshold) {
setIsScrolled(false);
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [threshold, isScrolled]);
return isScrolled;
};위와 같이 스크롤 상태를 통해 감지하고 있었는데
특정 스크롤 위치에 도달하면 isScrolled라는 상태 값이 계속해서 변하게 되었다.
이 상태 값에 따라 스크롤 여부를 판단하고, 그에 맞춰 헤더의 스타일을 적용했기 때문에, 헤더가 계속해서 스타일이 변경되는 문제가 발생했다.

이렇게 되면 스크롤 이벤트가 계속 발생하게 되어 성능에 좋지 않았고, 유저에게 불편함 없이 제공하려고 만든 헤더의 스타일이 오히려 불편함을 초래하는 결과를 낳았다.
"왜 특정 구간에서만 문제가 발생할까?" 🤔
어떤 구간에서 반복적으로 깜빡이는지 확인해보니, 스크롤 위치가 50px ~ 72px 사이일 때 문제가 계속 발생하고 있었다.
콘솔로 찍어보니

즉, 내가 설정한 threshold: 50px과 헤더의 높이 72px 사이에서 상태가 계속 변경되고 있었다.
문제가 발생하는 특정 구간은 다음과 같았다.
✅ 0px ~ 50px → 정상 (헤더 높이 72px 유지)
⚠️ 51px ~ 72px → 문제 발생 구간
✅ 72px 이상 → 정상 (헤더 높이 56px 유지)
문제의 원인
스크롤 위치가 51px에 도달하면 isScrolled가 true가 되어 헤더의 높이가 72px → 56px로 줄어들게 된다.
그러나 이 과정에서 예상치 못한 문제가 발생했다.
- 헤더가 작아지면서 페이지의 전체 높이도 줄어든다.
- 페이지 높이가 변하자 브라우저가 자동으로 스크롤 위치를 재조정한다.
- 이 조정으로 인해 스크롤 위치가 다시
50px아래로 내려간다. - 그러면 헤더가 다시
72px로 커지고, 다시 페이지 높이가 늘어나면서 또 스크롤 위치가 변경된다.
이 과정이 반복되면서 헤더의 크기가 계속 깜빡이는 현상이 발생
스크롤 위치가 72px 이상으로 충분히 내려간 경우에는,
이미 페이지가 충분히 스크롤된 상태이므로 헤더 크기 변화가 threshold 기준점에 영향을 주지 않는다.
그러나 51px ~ 72px에서 스크롤이 멈출 경우, 헤더 크기가 변하면서 페이지 높이가 달라지고, 그로 인해 브라우저가 스크롤 위치를 자동 조정하면서 무한 반복 현상이 발생하는 것이다.
이 문제를 해결하려면 상태 변화가 특정 임계값(threshold) 근처에서 불필요하게 반복되지 않도록, 일정한 여유(버퍼)를 두는 방식이 필요하다.
즉, 상태가 즉시 변화하지 않고, 일정 범위를 벗어났을 때만 변화가 반영되는 방식이다.
이 원리는 수학적으로 히스테리시스(Hysteresis)라 불리며, 시스템의 입력 값이 변할 때 출력을 즉시 따라가지 않고, 특정 범위를 넘어서야만 상태 변화가 발생하는 방식이다.
이처럼 상태 변화에 여유를 두는 히스테리시스 패턴을 적용하면, 스크롤이 특정 구간을 확실히 벗어났을 때만 상태가 변경되어, 헤더 깜빡임을 방지하고 자연스러운 전환을 유도할 수 있다.
실제로, MUI(Material-UI)의 useScrollTrigger 훅에도 히스테리시스 옵션이 제공된다. disableHysteresis 옵션을 통해 히스테리시스를 활성화하거나 비활성화할 수 있으며, 이를 통해 스크롤 방향에 따라 상태 변화를 조절할 수 있다.
문제 해결하기
히스테리시스 패턴은 상태 변화를 즉각적으로 수행하지 않고, 특정 구간을 넘어설 때만 상태를 변경하는 방식이다.
50px을 기준으로 바로isScrolled값을 변경하는 것이 아니라,- 스크롤이 70px 이상이면
isScrolled: true(헤더 축소) - 스크롤이 30px 이하이면
isScrolled: false(헤더 원래 크기)
const currentScrollY = window.scrollY;
if (!isScrolled && currentScrollY > threshold + hysteresis) {
setIsScrolled(true); // 스크롤이 기준을 넘으면 헤더 축소
} else if (isScrolled && currentScrollY < threshold - hysteresis) {
setIsScrolled(false); // 스크롤이 기준 이하로 내려가면 헤더 원래 크기로
}여유값(버퍼)을 두는 이유는 작은 임계값 변화가 의도치 않은 동작을 일으킬 수 있기 때문이다.
상태 변화의 기준을 명확하게 설정하고 불필요한 변화를 방지하면, 50px ~ 72px 사이에서 헤더가 깜빡이는 현상을 방지할 수 있다.
이 방식은 사용자 경험을 확실히 개선한다. UI가 더 안정적이고 자연스럽게 동작하며, 스크롤 기반 애니메이션처럼 계속해서 변하는 값에 대해서도 효율적으로 상태를 관리할 수 있다.
예를 들어, 스크롤이 70px을 넘으면 헤더가 축소되고, 30px 보다 작을 때는 원래 크기로 돌아간다.
그 사이(30px ~ 70px)에서는 상태 변화가 일어나지 않기 때문에, 사용자가 의도한 대로 스크롤에만 반응하게 된다.
성능 개선하기
히스테리시스 패턴으로 불필요한 상태 변화는 막았지만, 여전히 개선이 필요한 부분이 있다.
예를 들어, 스크롤이 70px을 넘어선 상태에서는 isScrolled가 이미 true로 설정되어 있는데도, 계속해서 스크롤 이벤트가 발생할 때마다 불필요하게 조건을 확인하게 된다. 30px 이하에서도 마찬가지다.
이러한 상황에서는 requestAnimationFrame을 활용하여 성능을 개선할 수 있다.
requestAnimationFrame은 브라우저의 화면 리프레시 주기에 맞춰 함수를 실행하여 애니메이션이나 스크롤 이벤트를 부드럽게 처리할 수 있도록 돕는다. 약 16.6ms 간격으로 호출되어 불필요한 렌더링을 방지하고 성능을 최적화할 수 있다.
requestAnimationFrame에 대한 자세한 이해는 다음글을 참고하길 바란다.
비교해보자
스크롤 성능을 테스트하기 위해, 버튼을 클릭하면 71px과 29px을 (상태가 변해서 렌더링 되는 지점)을 반복하게 해놓았다. setInterval을 사용하여 16ms 간격으로 스크롤을 처리하고, 10초가 지나면 자동으로 멈추도록 설정하였다.

const handleAutoScroll = () => {
const startTime = performance.now();
let isScrolling = true;
const THRESHOLD = 50;
const HYSTERESIS = 20;
let toggle = true;
// 초기 위치: 71px
window.scrollTo(0, THRESHOLD + HYSTERESIS + 1);
const interval = setInterval(() => {
if (!isScrolling) {
clearInterval(interval);
return;
}
// 71px ↔ 29px 반복
window.scrollTo(0, toggle ?
THRESHOLD + HYSTERESIS + 1 :
THRESHOLD - HYSTERESIS - 1
);
toggle = !toggle;
// 10초 경과 체크
if (performance.now() - startTime >= 10000) {
isScrolling = false;
clearInterval(interval);
}
}, 16);
};
<Button onClick={handleAutoScroll} marginY='10'>
스크롤 성능 측정 버튼
</Button>
버튼을 누르고 성능 탭에서 측정을 해보니
requestAnimationFrame을 사용하지 않은 경우 (기존의 코드)
import { useEffect, useState } from 'react';
const hysteresis = 20;
export const useScrollDetection = (threshold: number) => {
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
const currentScrollY = window.scrollY;
if (!isScrolled && currentScrollY > threshold + hysteresis) {
setIsScrolled(true);
} else if (isScrolled && currentScrollY < threshold - hysteresis) {
setIsScrolled(false);
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [threshold, isScrolled]);
return isScrolled;
};
isScrolled상태가 변경될 때마다 컴포넌트 리렌더링- 히스테리시스로 상태 변화는 제한했지만, 이벤트 핸들러 자체는 매우 빈번하게 실행
와 같은 결과가 발생을 했다.
requestAnimationFrame 적용 후 성능 측정
import { useRef } from "react";
import { useEffect, useState } from 'react';
const hysteresis = 20;
export const useScrollDetection = (threshold: number) => {
const [isScrolled, setIsScrolled] = useState(false);
const ticking = useRef(false); // requestAnimationFrame 중복 호출 방지
useEffect(() => {
const handleScroll = () => {
if (!ticking.current) {
ticking.current = true;
requestAnimationFrame(() => {
const currentScrollY = window.scrollY;
if (!isScrolled && currentScrollY > threshold + hysteresis) {
setIsScrolled(true);
} else if (isScrolled && currentScrollY < threshold - hysteresis) {
setIsScrolled(false);
}
ticking.current = false;
});
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [threshold, isScrolled]);
return isScrolled;
};
렌더링 성능 향상
- 렌더링 시간이 1,136ms → 668ms로 크게 감소
RAF를 통해 브라우저 렌더링 주기와 동기화되어 더 효율적인 렌더링
페인팅 최적화
- 페인팅 시간도 750ms → 658ms로 감소
- 불필요한 화면 업데이트가 줄어든 효과
전반적으로는 렌더링과 페인팅 성능이 개선되어 사용자 경험이 향상된 것을 확인할 수 있다.
여기서 ticking 플래그를 적용한 것을 확인할 수 있는데 ticking ref는 requestAnimationFrame(RAF)의 중복 예약을 방지하는 중요한 역할을 한다.
requestAnimationFrame에서 ticking에 대한 자세한 동작은 아래 글을 참조하였다.
이 과정을 코드로 보면
const ticking = useRef(false); // 초기값 false
const handleScroll = () => {
// 1️⃣ RAF가 예약되지 않은 상태일 때만 실행
if (!ticking.current) {
// 2️⃣ 플래그를 true로 설정해 추가 예약 방지
ticking.current = true;
requestAnimationFrame(() => {
// 4️⃣ RAF 콜백 내부 로직 실행
const currentScrollY = window.scrollY;
if (!isScrolled && currentScrollY > threshold + hysteresis) {
setIsScrolled(true);
} else if (isScrolled && currentScrollY < threshold - hysteresis) {
setIsScrolled(false);
}
// 5️⃣ 로직 실행 후 플래그를 false로 변경
ticking.current = false;
});
}
// 3️⃣ ticking이 true인 경우 추가 RAF 예약 없이 종료
};
- 스크롤 이벤트 발생 →
handleScroll함수 호출ticking.current가false일 때만requestAnimationFrame을 예약requestAnimationFrame실행 시ticking.current는false로 설정
- 스크롤 이벤트가 계속 발생 →
ticking.current가true인 동안 추가적인requestAnimationFrame예약은 이루어지지 않음 - 한 번의
requestAnimationFrame실행이 끝난 후ticking.current가 다시false로 변경되어, 다음 스크롤 이벤트 발생 시 새로운requestAnimationFrame예약이 가능해짐.
이렇게 함으로써
불필요한 rAF 예약 방지: 중복 예약을 방지하여 성능 저하를 막는다.
렌더링 주기와 동기화: requestAnimationFrame이 브라우저의 렌더링 주기에 맞춰 실행되므로, 성능이 최적화된다.
메모리와 CPU 사용량 최적화: 반복적인 렌더링을 줄여 CPU와 메모리 사용량을 절감한다.
성능 개선 효과
히스테리시스 적용
- 불필요한 상태 변경 없이 안정적인 전환
- 깜빡임 현상 해결로 자연스러운 UI
- 임계값 주변에서의 반복 렌더링 방지
RAF 최적화
- 브라우저 렌더링 주기에 맞춘 효율적인 처리
- 스크롤 이벤트 최적화로 성능 개선
- 부드러운 애니메이션 효과 구현
스크롤 발생 시 RAF로 다음 프레임을 대기하고, 히스테리시스 조건을 확인한 후 필요한 경우에만 상태를 업데이트한다.
이를 통해 불필요한 연산을 줄이고 부드러운 사용자 경험을 제공할 수 있게 되었다.
마치며
스크롤에 반응하는 헤더 구현 과정에서 발생한 문제를 해결하면서 성능 최적화에 대해 다뤘다.
히스테리시스 패턴을 통해 불필요한 상태 변화를 줄였고, requestAnimationFrame을 활용해 렌더링 성능을 개선했다. 이 과정에서 작은 UI 변화가 예상치 못한 문제를 일으킬 수 있다는 점을 다시 한 번 깨달았다.
이 경험을 통해, 사용자 경험을 개선하는 데 성능 최적화가 중요한 요소라는 것을 확인할 수 있었다.
앞으로도 더 나은 사용자 경험을 제공하기 위해 성능 최적화에 대한 고민과 연구를 지속해야겠다는 생각이 든다.
참고자료
