
인터랙션 중심의 프로모션 웹사이트를 작업하면서 스무스 스크롤을 다뤘습니다. 휠을 굴리면 화면이 관성을 받은 것처럼 부드럽게 미끄러지며 따라오는 효과입니다.
작업하면서 자연스럽게 궁금해졌습니다.
스무스 스크롤은 내부에서 어떻게 동작하길래 이렇게 부드럽게 느껴질까? 또 어떤 사이트는 왜 스크롤이 버벅이거나 끊길까?
이 글에서는 그 원리를 정리해보려 합니다.
lerp와 requestAnimationFrame으로 부드러운 움직임이 만들어지는 방식부터, 그 움직임을 60fps로 안정적으로 유지하기 위한 렌더링 최적화까지 함께 다룹니다.
브라우저가 화면을 그리는 기본 흐름도 같이 살펴보려고 합니다.
브라우저 기본 스크롤은 즉각적으로 동작합니다. 휠을 굴린 만큼 화면이 바로 이동하고, 그 위치에서 멈춥니다. 정보 전달이 중심인 사이트라면 이런 방식이 가장 자연스럽습니다.
하지만 분위기와 몰입감이 중요한 프로모션 사이트에서는 조금 다른 움직임이 필요할 때가 있습니다.
기본 스크롤만으로는 이런 관성과 감속을 표현하기 어렵습니다.
스무스 스크롤의 핵심 아이디어는 단순합니다.
"최종적으로 이동해야 하는 위치(target)"와
"현재 화면에 보이는 위치(current)"를 분리하는 것
그리고 current가 target을 조금씩 따라가게 만들면, 화면은 자연스럽게 감속하며 움직입니다.
즉, 목표 위치와 실제 표시 위치를 분리하는 순간부터 스무스 스크롤이 시작됩니다.
이 두 위치를 자연스럽게 이어주는 데 사용하는 것이 lerp(linear interpolation, 선형 보간)입니다.
이름은 어렵지만 "A와 B 사이에서 몇 % 지점"을 구하는 단순한 계산입니다.
// factor: 0~1 사이의 비율. 0.5면 A와 B의 한가운데
const lerp = (start, end, factor) =>
start + (end - start) * factor;
예를 들어 lerp(0, 100, 0.1)은 0에서 100까지 거리 중 10% 지점인 10을 반환합니다.
스무스 스크롤에서는 이 계산을 매 프레임 반복합니다.
let current = 0; // 실제 화면 위치
let target = 0; // 목표 위치 (휠을 굴린 만큼 쌓인 값)
// 매 프레임:
current = lerp(current, target, 0.1);
target이 100이라면 current는 다음처럼 움직입니다.
0 → 10 → 19 → 27.1 → 34.4 → ... → 100 근처
매번 남은 거리의 10%씩만 이동하기 때문에 처음에는 빠르게 움직이고, 목표에 가까워질수록 천천히 감속합니다.
그래서 끝부분에서 자연스럽게 속도가 줄며 멈추는 감속 곡선이 나옵니다.
여기서 중요한 값이 factor 입니다.
즉, 이 값 하나가 스크롤의 무게감과 성격을 결정합니다.
그렇다면 이 계산을 "매 프레임" 어떻게 반복할까요?
가능한 방법 중 하나는 setInterval입니다.
setInterval(update, 16);
하지만 실제 애니메이션에서는 대부분 requestAnimationFrame(rAF)을 사용합니다. 브라우저의 화면 갱신 타이밍과 맞물려 동작하기 때문입니다.
화면은 보통 1초에 약 60번 새로 그려집니다.
이 한 장 한 장을 프레임(frame)이라고 부르고, 브라우저가 다음 프레임을 화면에 그리는 시점을 리페인트라고 합니다.
부드러운 애니메이션을 만들려면 이 리페인트 타이밍에 맞춰 위치를 갱신해야 합니다.
setInterval(fn, 16) | requestAnimationFrame(fn) | |
|---|---|---|
| 호출 시점 | 고정 시간 간격, 리페인트와 무관 | 브라우저 리페인트 직전 |
| 백그라운드 탭 | 멈추지 않고 계속 실행됨 (강하게 스로틀링) | 자동 일시정지 |
| 프레임 정확도 | 리페인트와 어긋나 끊김 발생 | 리페인트 주기에 정확히 맞춤 |
setInterval은 브라우저가 실제로 화면을 그리는 타이밍과 무관하게 정해진 간격으로만 실행됩니다. 그래서 화면 반영 시점이 어긋나면서, 계산한 위치가 제때 반영되지 못하고 프레임이 끊겨 보일 수 있습니다.
반면 requestAnimationFrame은
브라우저가 "이제 화면 그릴 테니까, 그 전에 업데이트해"
라는 타이밍에 콜백을 실행하므로, 계산 결과가 정확히 다음 프레임에 반영됩니다.
스무스 스크롤은 보통 이런 구조로 동작합니다.
function raf() {
current = lerp(current, target, 0.1); // 위치 한 칸 갱신
applyScroll(current); // 화면에 반영
requestAnimationFrame(raf); // 다음 프레임 다시 예약
}
raf();
requestAnimationFrame은 한 번 실행하고 끝나는 함수가 아니라, 콜백 안에서 자기 자신을 다시 호출하면서 프레임 루프를 만드는 구조입니다.
지금까지 내용을 정리하면 다음과 같습니다.
[ 네이티브 스크롤 ]
휠 입력 → 브라우저가 스크롤한 양을 target 에 저장
[ rAF 루프 (매 프레임) ]
current 를 target 방향으로 lerp 만큼 이동
[ 화면 반영 ]
current 값을 transform 으로 적용
[ 결과 ]
스크롤바는 평소대로 움직이지만,
콘텐츠는 current 값을 따라 한 박자 늦게 부드럽게 따라옴
여기서 중요한 포인트가 하나 있습니다.
콘텐츠를 직접 스크롤시키지 않고
transform이라는 CSS 속성으로 위치만 이동시킨다는 점
그리고 이 방식이 바로 다음 주제인 60fps 유지와 연결됩니다.
이제 "부드럽다"를 넘어, 왜 어떤 스크롤은 끊기고 어떤 스크롤은 매끄러운지를 볼 차례입니다.
60fps는 1초에 60프레임이라는 뜻이고, 거꾸로 계산하면 프레임 하나를 약 16.67ms 안에 처리해야 한다는 의미입니다. 이 시간을 넘기면 프레임이 제때 그려지지 못하고 드롭(drop)됩니다. 우리가 흔히 느끼는 "버벅임"이 바로 이것입니다.
브라우저는 화면을 그릴 때 대략 다음 순서로 동작합니다.
JavaScript → Style → Layout → Paint → Composite
특히 중요한 건 뒤쪽 세 단계입니다.
핵심은, 어떤 CSS 속성을 변경하느냐에 따라 다시 실행되는 단계가 달라진다는 점입니다.
| 변경 속성 | 다시 실행되는 단계 |
|---|---|
width, height, top, left, margin | Layout → Paint → Composite (전부) |
color, background | Paint → Composite |
transform, opacity | Composite만 |
top이나 margin처럼 위치·크기를 바꾸면 브라우저는 Layout부터 전부 다시 수행해야 합니다.
특히 이 Layout 재계산을 리플로우(reflow)라고 부르는데, 가장 무거운 작업입니다. 스크롤할 때마다 이런 작업이 발생하면 16.67ms 예산을 금방 초과하게 되고, 결국 프레임 드롭이 발생합니다.
그래서 스무스 스크롤에서는 위치 이동에 top 대신 transform: translateY()를 사용합니다.
function applyScroll(current) {
// ❌ top 변경 → Layout부터 다시(리플로우)
// wrapper.style.top = `${-current}px`;
// ✅ transform 변경 → Composite만
wrapper.style.transform = `translateY(${-current}px)`;
}
transform은 Layout과 Paint를 건너뛰고 Composite 단계만 수행합니다. 즉,
그래서 GPU가 빠르게 처리할 수 있고, 흔히 말하는 GPU 가속이 가능해집니다.
자주 움직이는 요소는 레이어를 미리 분리하도록 이런 힌트를 줄 수 있습니다.
.scroll-wrapper {
will-change: transform; /* 이 요소는 transform이 자주 바뀐다고 미리 알림 */
}
will-change는 브라우저에게 "이 요소는 곧 자주 움직일 예정"이라고 미리 알려주는 속성입니다. 덕분에 브라우저가 레이어를 미리 준비할 수 있습니다. 다만 레이어를 많이 만들수록 메모리 사용량도 증가하기 때문에, 실제로 자주 움직이는 요소에만 제한적으로 사용하는 것이 좋습니다.
스크롤 위치에 따라 콘텐츠가 하나씩 나타나는 등장 애니메이션에도 같은 원칙이 적용됩니다.
가장 단순한 구현은 scroll 이벤트마다 각 요소의 위치를 계산해서 화면 진입 여부를 확인하는 방식입니다.
하지만 여기에는 문제가 있습니다.
scroll 이벤트는 휠을 굴리는 동안 초당 수십~수백 번 발생하고요소가 많아질수록 이 성능 비용이 빠르게 커집니다.
이 문제를 해결하기 위해 사용하는 것이 Intersection Observer 입니다. 브라우저에게 "이 요소가 화면(viewport)에 들어오면 알려줘"라고 맡기는 방식입니다.
개발자가 매 프레임 직접 위치를 계산하는 대신, 브라우저가 요소의 화면 진입 여부를 감지하고 필요한 순간에만 콜백을 실행합니다.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("show");
}
});
});
observer.observe(element);
이렇게 하면 스크롤 중 계산을 반복하지 않아도 되고, 요소가 실제로 화면에 들어온 순간에만 애니메이션을 실행할 수 있습니다.
등장 효과 자체는 보통 opacity와 transform(예: translateY)으로만 처리합니다. 이 두 속성은 앞에서 설명한 것처럼 Composite 단계만 수행하므로,
그래서 많은 요소가 동시에 등장해도 부드러운 애니메이션을 유지할 수 있습니다.
여러 요소를 순차적으로 등장시키는 Stagger(스태거) 효과도 자주 쓰입니다. 이 경우에는 각 요소의 transition-delay를 조금씩 다르게 주면 됩니다.
.card:nth-child(1) { transition-delay: 0ms; }
.card:nth-child(2) { transition-delay: 100ms; }
.card:nth-child(3) { transition-delay: 200ms; }
그러면 요소들이 한꺼번에 나타나는 대신, 시간차를 두고 자연스럽게 이어서 등장합니다.
정리하면 역할은 이렇게 나뉩니다.
| 역할 | 담당 |
|---|---|
| 요소의 화면 진입 감지 | Intersection Observer |
| 실제 움직임과 등장 효과 | transform, `opacity |
| 부드러운 렌더링 처리 | Composite 단계 (GPU) |
감지는 브라우저에게 맡기고,
애니메이션은 Composite 단계에서 처리하는 구조
"transform을 썼으니 빠르겠지"는 추측일 뿐입니다. 실제로 60fps가 나오는지는 Chrome DevTools로 확인할 수 있습니다.
Performance 패널 — 스크롤 동작을 녹화하면 상단에 FPS 그래프가 나옵니다. 그래프에서 세 가지를 함께 봅니다.
transform이 의도대로 리플로우를 피하고 있다는 뜻입니다.Frame Rendering Stats — 스크롤하면서 실시간으로 확인하고 싶다면 Rendering 탭에서 이 옵션을 켭니다. 화면 우상단에 현재 FPS가 표시됩니다.
모션이 많은 페이지일수록 이 측정이 중요합니다. "라이브러리를 썼으니 빠르다"가 아니라, "Layout을 유발하는 작업이 없는 걸 Performance 패널로 확인했다"고 말할 수 있어야 신뢰할 수 있는 결과입니다.
스무스 스크롤은 지금까지 살펴본 원리대로 직접 구현할 수도 있고, Lenis·Locomotive Scroll·GSAP ScrollSmoother 같은 라이브러리를 쓸 수도 있습니다.
직접 구현하면 동작을 100% 제어할 수 있고, 무엇보다 원리를 이해하게 됩니다. 대신 직접 챙겨야 할 것이 늘어납니다.
prefers-reduced-motion(애니메이션 최소화를 원하는 사용자 설정) 같은 접근성 대응라이브러리를 쓰면 이런 엣지 케이스와 접근성 처리가 이미 검증돼 있습니다. 그래서 프로덕션에서는 라이브러리가 합리적인 선택인 경우가 많습니다. 다만 내부 원리를 이해하지 못한 채 사용하면, 스크롤이 끊기는 문제가 생겼을 때 원인을 찾기 어렵습니다.
| 직접 구현 | 라이브러리 | |
|---|---|---|
| 제어 범위 | 100% 제어 가능 | 라이브러리가 정한 범위 |
| 엣지 케이스·접근성 | 직접 처리 | 검증된 처리 제공 |
| 원리 이해 | 자연히 깊어짐 | 모르고도 사용 가능 |
| 문제 디버깅 | 원인 추적이 쉬움 | 내부를 모르면 어려움 |
어느 쪽을 택하든, 내부에서 lerp·rAF·transform이 어떻게 맞물려 동작하는지를 이해하면 문제를 디버깅하고 성능을 측정할 수 있습니다. 라이브러리를 쓰더라도 이 원리를 이해해야 하는 이유입니다.
스무스 스크롤의 결국 두 가지로 정리됩니다.
top이 아니라 transform으로 화면을 움직여 매 프레임 리플로우를 피한다.부드러운 움직임과 높은 성능은 분리된 문제가 아닙니다.
아무리 자연스러운 애니메이션이더라도 렌더링 비용이 과하면 프레임이 끊기고, 그 순간 오히려 더 어색하게 느껴집니다.
그래서 스무스 스크롤은 단순히 "부드럽게 움직이는 기술"이 아니라
브라우저 렌더링 구조를 이해하고,
제한된 프레임 예산 안에서 움직임을 설계하는 작업
에 가깝다고 느꼈습니다.