GSAP + Lenis 부드러운 스크롤 배선의 정석 — 복붙 코드와 네 가지 함정
autoRaf 기본값, 초와 밀리초 단위, lagSmoothing(0), scrollerProxy까지. 공식 문서와 대조해 검증한 배선 코드입니다.
스크롤에 반응하는 사이트를 만들다 보면 거의 항상 같은 지점에서 막힙니다. 부드러운 스크롤을 붙였더니 스크롤 트리거가 한 박자 늦게 터지거나, 핀으로 고정한 섹션이 스크롤할 때마다 위아래로 튀거나, 심하면 스크롤이 아예 멈춘 것처럼 보입니다. 애니메이션 코드는 분명 예제 그대로인데 말이죠.
원인은 대부분 애니메이션 쪽이 아니라 그 아래 배선에 있습니다. Lenis는 브라우저의 스크롤 값을 자기가 다시 계산해서 굴리고, GSAP ScrollTrigger는 스크롤 위치를 기준으로 트리거 시점을 계산합니다. 이 둘이 같은 프레임 루프 위에서 돌지 않으면 서로 다른 시간을 보게 되고, 그 어긋남이 지연·저크·튐으로 나타납니다. 문제는 검색해서 나오는 배선 코드들이 서로 미묘하게 다르고, 그중 일부는 현재 버전 기준으로 틀렸다는 점입니다.
저는 모션을 무겁게 쓰는 레퍼런스 사이트(kingkong-ad)를 실측해 라이브러리 버전을 확인하고, 배선 코드를 한 줄씩 공식 문서와 대조하면서 정리했습니다. 결론부터 말하면 배선은 네 줄이면 끝나고, 함정도 네 개뿐입니다. 아래 버전 기준으로 정리했습니다.
실측 버전: GSAP 3.12.5 · Lenis 1.3.15 · SplitType 0.3.4 · AOS 2.3.4 · Swiper 9
배선이 하는 일은 네 가지뿐입니다
| 하는 일 | 코드 | 빼먹으면 |
|---|---|---|
| 스크롤값 갱신 알림 | lenis.on('scroll', ScrollTrigger.update) | 트리거가 한 박자 늦게 반응 |
| 프레임 루프 통합 | gsap.ticker.add(t => lenis.raf(t * 1000)) | rAF가 둘로 갈리거나 스크롤이 멈춤 |
| 랙 스무딩 차단 | gsap.ticker.lagSmoothing(0) | 프레임 드랍 시 핀·패럴랙스가 튐 |
| 로드 후 위치 재계산 | ScrollTrigger.refresh() | 폰트·이미지 로드 뒤 트리거 위치 어긋남 |
핵심은 “Lenis에게 별도의 rAF 루프를 주지 않고, GSAP의 티커 하나로만 굴린다”는 것입니다. 프레임 루프가 하나면 계산 시점이 하나고, 시점이 하나면 어긋날 일이 없습니다.
복붙용 배선 코드
아래 블록이 모든 스크롤 연출의 토대입니다. 이 위에 SplitType 텍스트 리빌이든, 핀 고정 섹션이든, 패럴랙스든 올리면 됩니다.
<link rel="stylesheet" href="https://unpkg.com/lenis@1.3.15/dist/lenis.css">
<script src="https://unpkg.com/lenis@1.3.15/dist/lenis.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script>
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ duration: 1.2, smoothWheel: true, syncTouch: false });
lenis.on('scroll', ScrollTrigger.update); // 스크롤마다 트리거 갱신
gsap.ticker.add((t) => lenis.raf(t * 1000)); // rAF를 GSAP 틱으로 통합(초 → ms)
gsap.ticker.lagSmoothing(0); // 랙 스무딩 OFF (핀 튐 방지)
window.addEventListener('load', () => ScrollTrigger.refresh());
</script>
lenis.css를 빼먹지 마세요. Lenis가 html 요소에 붙이는 클래스에 대한 height 규칙이 들어 있고, 이게 없으면 스크롤 컨테이너 높이 계산이 어긋납니다. CDN 대신 npm으로 쓴다면 import 'lenis/dist/lenis.css'에 해당하는 경로를 번들에 포함시켜야 합니다.
함정 1. autoRaf 기본값은 true가 아니라 false입니다
가장 많이 틀리는 지점입니다. 한국어·영어 블로그 상당수가 “Lenis는 autoRaf가 기본 true라서 autoRaf: false를 꼭 꺼야 한다”고 안내하는데, 1.3.15 기준으로 기본값은 false입니다. 즉 위 코드처럼 gsap.ticker로 직접 굴리는 것이 정상 경로이고, 별도로 끌 것이 없습니다.
반대로 “안전하게” 하겠다고 autoRaf: true를 명시하면 Lenis 내부 rAF와 GSAP 티커가 동시에 raf()를 호출합니다. 한 프레임에 두 번 진행되니 스크롤 속도가 대략 두 배로 뛰고 미세한 저크가 생깁니다. 둘 중 하나만 쓰면 되고, GSAP을 함께 쓰는 상황이라면 티커 쪽이 정답입니다.
// 잘못된 조합 — 이중 rAF
const lenis = new Lenis({ autoRaf: true });
gsap.ticker.add((t) => lenis.raf(t * 1000));
// 올바른 조합 — 티커 하나만
const lenis = new Lenis(); // autoRaf 기본 false
gsap.ticker.add((t) => lenis.raf(t * 1000));
함정 2. 초와 밀리초 — * 1000을 빼면 안 됩니다
gsap.ticker가 콜백에 넘겨주는 시간은 초 단위이고, lenis.raf()가 기대하는 값은 브라우저 rAF와 같은 밀리초입니다. 단위가 다르니 그대로 넘기면 Lenis 입장에서는 시간이 거의 흐르지 않는 것처럼 보이고, 결과적으로 스크롤이 멈춘 것처럼 동작합니다.
gsap.ticker.add((t) => lenis.raf(t * 1000)); // 필수
증상이 “스크롤이 안 먹는다”라서 Lenis 설정이나 CSS를 뒤지게 되는데, 실제 원인은 이 곱하기 하나인 경우가 많습니다.
함정 3. lagSmoothing(0)은 선택이 아닙니다
GSAP은 프레임이 크게 밀리면 애니메이션이 튀지 않도록 시간 간격을 보정하는 랙 스무딩을 기본으로 켭니다. 일반 트윈에는 도움이 되지만, 스크롤 위치와 1:1로 묶여 있어야 하는 핀 고정이나 패럴랙스에서는 이 보정이 오히려 어긋남을 만듭니다. 무거운 이미지가 디코딩되는 순간처럼 프레임이 드랍되면 핀 섹션이 눈에 띄게 튑니다.
gsap.ticker.lagSmoothing(0); // 스크롤 연동 연출에서는 꺼두는 것이 기본
함정 4. scrollerProxy를 쓰지 마세요
Locomotive Scroll 시절 자료를 참고하면 ScrollTrigger.scrollerProxy()로 스크롤러를 대신 등록하는 설정이 나옵니다. Lenis 1.x는 별도의 가상 컨테이너를 만드는 대신 window의 네이티브 스크롤을 그대로 사용하기 때문에, 여기에 프록시를 얹으면 정상 동작하던 계산이 오히려 깨집니다. Lenis에서는 lenis.on('scroll', ScrollTrigger.update) 한 줄이 프록시 역할을 대신합니다.
증상으로 원인 역추적하기
배선을 잘못했을 때 나타나는 증상은 꽤 특징적이라 역추적이 쉽습니다.
| 증상 | 의심할 곳 |
|---|---|
| 스크롤이 멈춘 듯 거의 안 움직임 | t * 1000 누락 |
| 스크롤 속도가 비정상적으로 빠르고 저크 | autoRaf: true + 티커 병행(이중 rAF) |
| 핀·패럴랙스가 간헐적으로 튐 | lagSmoothing(0) 미적용 |
| 트리거가 한 박자 늦게 터짐 | lenis.on('scroll', ScrollTrigger.update) 누락 |
| 페이지 로드 후 트리거 위치가 밀림 | ScrollTrigger.refresh() 미호출 |
| 스크롤 길이·높이가 이상함 | lenis.css 미포함 |
배선 위에 올리는 예: 핀 고정 + scrub 3단계
배선이 제대로 됐는지 확인하기 가장 좋은 연출이 핀입니다. 배선이 틀리면 바로 튀기 때문입니다.
<section class="quantum">
<div class="stage"><span>01</span><h2>도약 준비</h2></div>
<div class="stage"><span>02</span><h2>퀀텀 점프</h2></div>
<div class="stage"><span>03</span><h2>새로운 궤도</h2></div>
</section>
<style>
.quantum{position:relative;height:100vh;overflow:hidden;background:#0b0b0f;color:#fff}
.stage{position:absolute;inset:0;display:grid;place-content:center;will-change:transform,opacity}
</style>
<script>
const stages = gsap.utils.toArray('.stage');
gsap.set(stages, { autoAlpha: 0, yPercent: 8 });
gsap.set(stages[0], { autoAlpha: 1, yPercent: 0 });
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.quantum', start: 'top top', end: '+=300%',
pin: true, scrub: 1, // 핀 + 스크롤 연동(값 = 관성)
snap: { snapTo: [0, 0.5, 1], duration: 0.4 } // 스크롤 진행 0/50/100%에 스냅
}
});
tl.to(stages[0], { autoAlpha: 0, yPercent: -8 }).to(stages[1], { autoAlpha: 1, yPercent: 0 }, '<')
.to(stages[1], { autoAlpha: 0, yPercent: -8 }).to(stages[2], { autoAlpha: 1, yPercent: 0 }, '<');
window.addEventListener('load', () => ScrollTrigger.refresh());
</script>
여기서도 걸리기 쉬운 지점이 있습니다.
pin은 pin-spacer를 삽입해 문서 높이를 늘립니다.end: '+=300%'는 애니메이션 길이가 아니라 고정이 지속되는 스크롤 거리입니다.- 숨김 처리는
opacity: 0이 아니라 **autoAlpha**를 쓰세요.autoAlpha는visibility까지 함께 처리하기 때문에, 투명해진 패널이 클릭이나 스크린리더 포커스를 가로채는 문제를 막아 줍니다. snapTo의 값은 타임라인 시간이 아니라 스크롤 진행도(0~1) 입니다.- 같은 구간을 AOS 같은 다른 스크롤 라이브러리로 동시에 다루지 마세요. 트리거가 이중으로 걸립니다.
참고로 AOS 2.3.4에는 별도의 함정이 있습니다. 전역 옵션의 anchorPlacement와 mirror는 이 버전에서 동작하지 않고(소스 확인), anchorPlacement는 요소별 data-aos-anchor-placement 속성으로만 먹습니다. 그리고 aos.css가 대상 요소를 즉시 opacity: 0으로 잠그기 때문에, JS가 실패하거나 AOS.init()이 호출되지 않으면 콘텐츠가 영영 보이지 않습니다. 본문 핵심 텍스트나 SEO에 중요한 문구에는 남발하지 않는 편이 안전합니다.
점수: 8.8
좋은 점
- 배선이 네 줄로 끝납니다. 한 번 정리해 두면 프로젝트마다 그대로 복사해서 씁니다.
- Lenis가 window 네이티브 스크롤을 그대로 쓰기 때문에 앵커 링크, 브라우저 스크롤 복원, 개발자도구 스크롤 계산이 대체로 자연스럽게 동작합니다. 가상 스크롤 컨테이너를 만드는 방식보다 부작용이 적습니다.
- GSAP 티커 하나로 프레임 루프가 통일되므로, 나중에 SplitType·패럴랙스·마그네틱 호버 같은 연출을 얹어도 타이밍 문제가 추가로 생기지 않습니다.
- 마그네틱 호버 같은 인터랙션에 쓰는
gsap.quickTo(3.10 이상)까지 GSAP 코어만으로 해결돼서, 추가 플러그인 없이 꽤 멀리 갑니다.
아쉬운 점
- 함정이 전부 “조용히” 실패합니다.
* 1000을 빼도 콘솔에 에러 한 줄 뜨지 않고, 그냥 스크롤이 이상해질 뿐이라 초보자가 원인을 찾기 어렵습니다. - 검색으로 나오는 배선 예제의 품질 편차가 큽니다.
autoRaf기본값처럼 사실이 뒤집힌 안내가 상위에 노출돼 있어서, 결국 공식 문서를 직접 확인해야 합니다. - 라이브러리 두 개에 ScrollTrigger까지 얹으면 스크립트 무게가 붙습니다. 리빌 몇 개 하자고 도입할 스택은 아닙니다.
- 부드러운 스크롤 자체가 호불호가 갈립니다. 스크롤 위치를 정밀하게 잡고 싶은 사용자에게는 관성이 방해로 느껴질 수 있습니다.
- 모바일에서는
syncTouch설정에 따라 체감이 크게 달라져서, 실제 기기에서 확인하지 않으면 데스크톱에서만 좋은 사이트가 되기 쉽습니다.
이럴 때 쓰세요: 핀 고정·scrub 같은 스크롤 연동 연출이 페이지의 핵심 콘셉트인 브랜드/캠페인 사이트를 만들 때.
이 스택을 쓰지 말아야 할 때
카드가 순서대로 나타나는 정도의 리빌이 전부라면 이 스택은 과설계입니다. IntersectionObserver 몇 줄과 CSS 트랜지션이면 충분하고, 의존성도 늘지 않으며, JS가 실패해도 콘텐츠는 그대로 보입니다. 저도 경량 사이트에는 이 배선을 넣지 않습니다.
접근성도 함께 챙기는 것이 좋습니다. 관성 스크롤과 큰 폭의 모션은 어지럼증을 유발할 수 있으니, prefers-reduced-motion 미디어 쿼리가 켜진 사용자에게는 Lenis를 초기화하지 않거나 모션 폭을 줄이는 분기를 넣어 두세요.
검증 범위에 대해
이 글의 코드와 함정 네 가지는 실제 레퍼런스 사이트를 실측해 버전을 확인하고, 각 항목을 공식 문서와 대조해 정리한 것입니다. AOS의 전역 옵션 미동작은 해당 버전 소스를 확인했습니다.
다만 모든 브라우저·기기 조합에서 장기간 운영해 본 결과는 아닙니다. 특히 모바일 터치 동작(syncTouch)과 iOS 사파리의 스크롤 처리는 실제 기기에서 직접 확인하시길 권합니다. 라이브러리 버전이 올라가면 기본값이 바뀔 수 있으니, 도입 시점에 GSAP 공식 문서와 Lenis 저장소에서 해당 버전의 기본값을 한 번 더 확인하는 것이 안전합니다.