스무스 스크롤의 동작 원리

DAM·2026년 5월 21일

[Project]

목록 보기
9/10
post-thumbnail

스무스 스크롤은 어떻게 동작할까

들어가며

인터랙션 중심의 프로모션 웹사이트를 작업하면서 스무스 스크롤을 다뤘습니다. 휠을 굴리면 화면이 관성을 받은 것처럼 부드럽게 미끄러지며 따라오는 효과입니다.

작업하면서 자연스럽게 궁금해졌습니다.
스무스 스크롤은 내부에서 어떻게 동작하길래 이렇게 부드럽게 느껴질까? 또 어떤 사이트는 왜 스크롤이 버벅이거나 끊길까?

이 글에서는 그 원리를 정리해보려 합니다.
lerp와 requestAnimationFrame으로 부드러운 움직임이 만들어지는 방식부터, 그 움직임을 60fps로 안정적으로 유지하기 위한 렌더링 최적화까지 함께 다룹니다.
브라우저가 화면을 그리는 기본 흐름도 같이 살펴보려고 합니다.

스무스 스크롤은 기본 스크롤과 무엇이 다를까

브라우저 기본 스크롤은 즉각적으로 동작합니다. 휠을 굴린 만큼 화면이 바로 이동하고, 그 위치에서 멈춥니다. 정보 전달이 중심인 사이트라면 이런 방식이 가장 자연스럽습니다.

하지만 분위기와 몰입감이 중요한 프로모션 사이트에서는 조금 다른 움직임이 필요할 때가 있습니다.

  • 휠을 굴리면 화면이 목표 위치까지 부드럽게 따라온다 (관성)
  • 빠르게 스크롤해도 끊기지 않고 미끄러지듯 이동한다
  • 스크롤 위치에 맞춰 등장 애니메이션이 자연스럽게 나타난다

기본 스크롤만으로는 이런 관성과 감속을 표현하기 어렵습니다.
스무스 스크롤의 핵심 아이디어는 단순합니다.

"최종적으로 이동해야 하는 위치(target)"와
"현재 화면에 보이는 위치(current)"를 분리하는 것

그리고 currenttarget을 조금씩 따라가게 만들면, 화면은 자연스럽게 감속하며 움직입니다.
즉, 목표 위치와 실제 표시 위치를 분리하는 순간부터 스무스 스크롤이 시작됩니다.

lerp — 부드러움을 만드는 계산

이 두 위치를 자연스럽게 이어주는 데 사용하는 것이 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 입니다.

  • 값이 클수록 빠르게 따라붙고
  • 값이 작을수록 더 묵직하게 미끄러집니다

즉, 이 값 하나가 스크롤의 무게감과 성격을 결정합니다.

requestAnimationFrame — 브라우저와 타이밍 맞추기

그렇다면 이 계산을 "매 프레임" 어떻게 반복할까요?

가능한 방법 중 하나는 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와 렌더링 파이프라인

이제 "부드럽다"를 넘어, 왜 어떤 스크롤은 끊기고 어떤 스크롤은 매끄러운지를 볼 차례입니다.

60fps는 1초에 60프레임이라는 뜻이고, 거꾸로 계산하면 프레임 하나를 약 16.67ms 안에 처리해야 한다는 의미입니다. 이 시간을 넘기면 프레임이 제때 그려지지 못하고 드롭(drop)됩니다. 우리가 흔히 느끼는 "버벅임"이 바로 이것입니다.

브라우저는 화면을 그릴 때 대략 다음 순서로 동작합니다.

JavaScript → Style → Layout → Paint → Composite

특히 중요한 건 뒤쪽 세 단계입니다.

  • Layout — 요소의 크기와 위치를 계산하는 단계. 가장 비용이 큽니다.
  • Paint — 정해진 자리에 색·텍스트·그림자를 실제 픽셀로 그리는 단계.
  • Composite — 이미 그려진 레이어를 합쳐 최종 화면을 만드는 단계. GPU가 처리하며 가장 가볍습니다.

핵심은, 어떤 CSS 속성을 변경하느냐에 따라 다시 실행되는 단계가 달라진다는 점입니다.

변경 속성다시 실행되는 단계
width, height, top, left, marginLayout → Paint → Composite (전부)
color, backgroundPaint → Composite
transform, opacityComposite만

top이나 margin처럼 위치·크기를 바꾸면 브라우저는 Layout부터 전부 다시 수행해야 합니다.
특히 이 Layout 재계산을 리플로우(reflow)라고 부르는데, 가장 무거운 작업입니다. 스크롤할 때마다 이런 작업이 발생하면 16.67ms 예산을 금방 초과하게 되고, 결국 프레임 드롭이 발생합니다.

왜 transform을 사용하는가

그래서 스무스 스크롤에서는 위치 이동에 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는 브라우저에게 "이 요소는 곧 자주 움직일 예정"이라고 미리 알려주는 속성입니다. 덕분에 브라우저가 레이어를 미리 준비할 수 있습니다. 다만 레이어를 많이 만들수록 메모리 사용량도 증가하기 때문에, 실제로 자주 움직이는 요소에만 제한적으로 사용하는 것이 좋습니다.

스크롤 등장 애니메이션과 Intersection Observer

스크롤 위치에 따라 콘텐츠가 하나씩 나타나는 등장 애니메이션에도 같은 원칙이 적용됩니다.

가장 단순한 구현은 scroll 이벤트마다 각 요소의 위치를 계산해서 화면 진입 여부를 확인하는 방식입니다.
하지만 여기에는 문제가 있습니다.

  • scroll 이벤트는 휠을 굴리는 동안 초당 수십~수백 번 발생하고
  • 그때마다 요소 위치를 계산하는 일은 Layout을 유발한니다

요소가 많아질수록 이 성능 비용이 빠르게 커집니다.

이 문제를 해결하기 위해 사용하는 것이 Intersection Observer 입니다. 브라우저에게 "이 요소가 화면(viewport)에 들어오면 알려줘"라고 맡기는 방식입니다.
개발자가 매 프레임 직접 위치를 계산하는 대신, 브라우저가 요소의 화면 진입 여부를 감지하고 필요한 순간에만 콜백을 실행합니다.

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add("show");
    }
  });
});

observer.observe(element);

이렇게 하면 스크롤 중 계산을 반복하지 않아도 되고, 요소가 실제로 화면에 들어온 순간에만 애니메이션을 실행할 수 있습니다.

등장 효과 자체는 보통 opacitytransform(예: translateY)으로만 처리합니다. 이 두 속성은 앞에서 설명한 것처럼 Composite 단계만 수행하므로,

  • Layout 재계산이 발생하지 않고
  • Paint 비용도 최소화되며
  • GPU 레벨에서 빠르게 처리됩니다

그래서 많은 요소가 동시에 등장해도 부드러운 애니메이션을 유지할 수 있습니다.

여러 요소를 순차적으로 등장시키는 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 단계에서 처리하는 구조

정말 60fps가 나오고 있을까

"transform을 썼으니 빠르겠지"는 추측일 뿐입니다. 실제로 60fps가 나오는지는 Chrome DevTools로 확인할 수 있습니다.

Performance 패널 — 스크롤 동작을 녹화하면 상단에 FPS 그래프가 나옵니다. 그래프에서 세 가지를 함께 봅니다.

  • 초록색 막대 — 길고 일정하면 60fps가 유지되는 구간입니다.
  • 빨간색 막대 — 그 구간에서 프레임이 드롭된 것입니다.
  • 보라색 Layout 막대 — 매 프레임 올라오지 않아야 transform이 의도대로 리플로우를 피하고 있다는 뜻입니다.

Frame Rendering Stats — 스크롤하면서 실시간으로 확인하고 싶다면 Rendering 탭에서 이 옵션을 켭니다. 화면 우상단에 현재 FPS가 표시됩니다.

모션이 많은 페이지일수록 이 측정이 중요합니다. "라이브러리를 썼으니 빠르다"가 아니라, "Layout을 유발하는 작업이 없는 걸 Performance 패널로 확인했다"고 말할 수 있어야 신뢰할 수 있는 결과입니다.

직접 구현할까, 라이브러리를 쓸까

스무스 스크롤은 지금까지 살펴본 원리대로 직접 구현할 수도 있고, Lenis·Locomotive Scroll·GSAP ScrollSmoother 같은 라이브러리를 쓸 수도 있습니다.

직접 구현하면 동작을 100% 제어할 수 있고, 무엇보다 원리를 이해하게 됩니다. 대신 직접 챙겨야 할 것이 늘어납니다.

  • 휠·터치패드·모바일 터치 등 입력 기기마다 다른 동작
  • 키보드 탭 이동 시 포커스 위치 보정
  • prefers-reduced-motion(애니메이션 최소화를 원하는 사용자 설정) 같은 접근성 대응

라이브러리를 쓰면 이런 엣지 케이스와 접근성 처리가 이미 검증돼 있습니다. 그래서 프로덕션에서는 라이브러리가 합리적인 선택인 경우가 많습니다. 다만 내부 원리를 이해하지 못한 채 사용하면, 스크롤이 끊기는 문제가 생겼을 때 원인을 찾기 어렵습니다.

직접 구현라이브러리
제어 범위100% 제어 가능라이브러리가 정한 범위
엣지 케이스·접근성직접 처리검증된 처리 제공
원리 이해자연히 깊어짐모르고도 사용 가능
문제 디버깅원인 추적이 쉬움내부를 모르면 어려움

어느 쪽을 택하든, 내부에서 lerp·rAF·transform이 어떻게 맞물려 동작하는지를 이해하면 문제를 디버깅하고 성능을 측정할 수 있습니다. 라이브러리를 쓰더라도 이 원리를 이해해야 하는 이유입니다.

마치며

스무스 스크롤의 결국 두 가지로 정리됩니다.

  • 부드러움 — lerp를 매 프레임 반복해 목표 위치를 따라잡는 데서 나온다.
  • 60fps 유지top이 아니라 transform으로 화면을 움직여 매 프레임 리플로우를 피한다.

부드러운 움직임과 높은 성능은 분리된 문제가 아닙니다.
아무리 자연스러운 애니메이션이더라도 렌더링 비용이 과하면 프레임이 끊기고, 그 순간 오히려 더 어색하게 느껴집니다.

그래서 스무스 스크롤은 단순히 "부드럽게 움직이는 기술"이 아니라

브라우저 렌더링 구조를 이해하고,
제한된 프레임 예산 안에서 움직임을 설계하는 작업

에 가깝다고 느꼈습니다.

profile
🌐 DOM 위에서 살아남기

0개의 댓글