logo

DowanKim

2. 마우스 휠을 살짝 내리든 세개 내리든, 다음 섹션으로 부드럽게 넘어가는 시스템으로 어바웃페이지를 구현해주세요.

2025년 8월 3일

졸업논문 대체 웹사이트

About 페이지 커스텀 스크롤 구현

목표

image.png

디자이너가 제공해준 피그마 파일에, 이러한 설명이 있었습니다.

즉 커스텀 휠 로직을 만들어서, 어바웃 페이지에서 사용자가 각 화면의 정보를 자세하게 볼 수 있도록 제어하는 시스템을 만들어야 합니다.

휠을 조금만 움직여도 다음 섹션으로 넘어가도록, 섹션 단위 스크롤을 구현해야합니다.


전체적인 구현 흐름은 다음과 같았습니다.

전체 흐름 요약

1. CSS로 섹션 높이를 일정하게 유지 → 인덱스 계산 단순화

2. scroll-snap-type으로 최종 정렬 보정 → 이동 후 정확한 위치 확보

3. preventDefault()로 기본 스크롤 차단 → 커스텀 로직 제어

4. 쿨다운 메커니즘으로 중복 입력 방지 → 섹션 건너뛰기 방지

5. 섹션 높이 기반 인덱스 계산 → 정확한 목표 위치 계산

6. scrollTo({ behavior: "smooth" })로 부드러운 이동 → 사용자 경험 개선

7. 터치 이벤트 처리로 모바일 지원 → 모든 디바이스에서 동일한 경험

아래 글은, 구현 과정에서 겪었던 문제를 해결한 과정들입니다.


1단계: CSS 기반 스크롤 컨테이너 구조 설계

각 섹션 높이를 동일하게 유지해야 인덱스 계산이 정확해질것입니다.. 헤더가 고정되어 있어 헤더 높이를 제외한 높이를 사용해야 합니다.

calc(100vh - 6.25vw) 사용

const SnapWrapper = styled.div` /* 스크롤 컨테이너 스타일 정의 */ height: calc(100vh - 6.25vw); /* 헤더 높이(6.25vw)를 제외한 가시 영역 높이 */ overflow-y: auto; /* 세로 스크롤 허용 */ overflow-x: hidden; /* 가로 스크롤 숨김 */ scroll-snap-type: y proximity; /* 근접 시 섹션 스냅 */ scroll-behavior: smooth; /* 코드로 스크롤 시 부드럽게 */ overscroll-behavior: contain; /* 상하 경계에서 바운스/버블 방지 */ `;
  • height: calc(100vh - 6.25vw): 헤더 높이를 제외한 일관된 높이로, sectionHeight = el.clientHeight 계산을 단순화합니다.
  • overflow-y: auto: 스크롤 가능한 컨테이너로 만듭니다.
  • overflow-x: hidden: 가로 스크롤을 막아 레이아웃 안정성을 확보합니다.

문제: JavaScript로 이동한 후 정확한 정렬 보정

scrollTo()로 이동해도 브라우저/디바이스에 따라 미세한 오차가 생길 수 있습니다.

해결: scroll-snap-type: y proximity 사용

scroll-snap-type: y proximity; /* 근접 시 섹션 스냅 */
  • proximity: 근접 시 자동 스냅되어 최종 정렬을 보정합니다.
  • JavaScript로 이동 후 브라우저가 섹션 시작점에 맞춰 정렬합니다.

문제: 스크롤이 컨테이너 밖으로 전파되는 현상

컨테이너 끝에서 스크롤 시 상위 요소로 전파되어 의도치 않은 스크롤이 발생하는 케이스가 있었습니다.

해결: overscroll-behavior: contain 사용

overscroll-behavior: contain; /* 상하 경계에서 바운스/버블 방지 */
  • 컨테이너 경계에서 스크롤 전파를 막아, 상위 요소로 스크롤이 번지지 않습니다.

문제: 섹션 높이를 정확히 맞춰야 인덱스 계산이 정확함

섹션 높이가 일정하지 않으면 currentIndex = Math.round(current / sectionHeight) 계산이 부정확해집니다.

해결: 모든 섹션에 동일한 높이 적용

const Section = styled.div` /* 기본 섹션 스타일 정의 */ height: calc(100vh - 6.25vw); /* 헤더 제외 높이를 가득 채움 */ scroll-snap-align: start; /* 섹션 시작 지점에서 스냅 */ position: relative; /* 절대배치 자식의 기준 박스 */ overflow: hidden; /* 섹션 밖으로 넘치는 요소 클리핑 */ isolation: isolate; /* 레이어 격리로 z-index 간섭 최소화 */ display: flex; flex-direction: column; align-items: center; justify-content: center; `;
  • height: calc(100vh - 6.25vw): 컨테이너와 동일한 높이로, sectionHeight = el.clientHeight로 모든 섹션 높이를 한 번에 계산할 수 있습니다.
  • scroll-snap-align: start: 각 섹션 시작점에서 스냅되도록 합니다.

2단계: 상태 관리 설계

문제: 이벤트 핸들러에서 최신 state를 즉시 참조해야 함

setState는 비동기라 이벤트 핸들러에서 animating을 읽으면 이전 값일 수 있습니다.

해결: useStateuseRef 같이 사용

const [animating, setAnimating] = useState(false); // 부드러운 스크롤 중인지 상태 const animatingRef = useRef(false); // setState 비동기 보완용 현재값 참조 useEffect(() => { // animating 값이 바뀔 때마다 ref 동기화 animatingRef.current = animating; // 최신 animating 값을 ref에 반영 }, [animating]);
  • animatingRef.current는 동기적으로 갱신되어, 이벤트 핸들러에서 즉시 최신 값을 참조할 수 있습니다.

문제: 연속 입력으로 인한 섹션 건너뛰기 방지

휠을 빠르게 여러 번 돌리면 여러 섹션을 건너뛰는 문제가 발생했습니다.

해결: 쿨다운 메커니즘 도입

const lastScrollAtRef = useRef(0); // 마지막 스크롤 처리 시각 저장 const COOLDOWN_MS = 1000; // 스크롤 입력 쿨다운 시간(ms)
  • lastScrollAtRef: 마지막 처리 시각을 저장합니다.
  • COOLDOWN_MS = 1000: 1초 내 중복 입력을 무시합니다.

3단계: 휠 이벤트 핸들러 구현

문제: 기본 스크롤 동작을 막아야 함

브라우저 기본 스크롤이 섹션 단위 이동을 방해합니다.

해결: e.preventDefault()로 기본 동작 차단

const onWheel = (e: WheelEvent) => { // 휠 이벤트 핸들러(캡처) e.preventDefault(); // 기본 스크롤 동작 차단
  • 기본 스크롤을 막고 커스텀 로직으로 제어합니다.

문제: 애니메이션 중 또는 쿨다운 중 중복 처리 방지

애니메이션 중이거나 쿨다운 중에는 입력을 무시해야 합니다.

해결: 이중 체크 로직

const now = Date.now(); // 현재 시각(ms) if (animatingRef.current || now - lastScrollAtRef.current < COOLDOWN_MS) return; // 애니메이션 중이거나 쿨다운 중이면 무시
  • animatingRef.current: 애니메이션 진행 여부를 즉시 확인합니다.
  • now - lastScrollAtRef.current < COOLDOWN_MS: 쿨다운 여부를 확인합니다.

문제: 현재 섹션 인덱스를 정확히 계산해야 함

스크롤 위치만으로는 현재 섹션을 알 수 없습니다.

해결: 섹션 높이로 나누어 인덱스 계산

const sectionHeight = el.clientHeight; // 헤더 제외 영역의 높이(섹션 높이 가정) const current = el.scrollTop; // 현재 스크롤 위치 const direction = e.deltaY > 0 ? 1 : -1; // 아래(+1)/위(-1) 판별 const currentIndex = Math.round(current / sectionHeight); // 현재 섹션 인덱스 추정
  • sectionHeight = el.clientHeight: 섹션 높이를 컨테이너 높이로 계산합니다.
  • currentIndex = Math.round(current / sectionHeight): 반올림으로 현재 섹션 인덱스를 구합니다.

문제: 마지막 섹션을 넘어가거나 첫 섹션 이전으로 가는 것 방지

인덱스가 범위를 벗어나면 스크롤이 이상하게 동작합니다.

해결: Math.minMath.max로 범위 제한

const maxIndex = Math.ceil(el.scrollHeight / sectionHeight) - 1; // 마지막 섹션 인덱스 const targetIndex = Math.min( Math.max(currentIndex + direction, 0), // 0 미만 방지 maxIndex // 마지막 초과 방지 ); // 이동 대상 섹션 인덱스

문제: 부드러운 스크롤 애니메이션 제공

디자이너가 의도한 것은, 부드럽게 이동하는 것입니다. 현재는 그냥 빠르게 이동됩니다.

해결: scrollTobehavior: "smooth" 사용

const target = targetIndex * sectionHeight; // 대상 섹션의 스크롤 상단 좌표 setAnimating(true); // 애니메이션 시작 표시 animatingRef.current = true; // ref에도 반영 lastScrollAtRef.current = now; // 마지막 처리 시각 갱신 el.scrollTo({ top: target, behavior: "smooth" }); // 부드럽게 스크롤 이동
  • behavior: "smooth": 브라우저 네이티브 부드러운 스크롤을 사용합니다.

문제: 쿨다운 타이밍 정확히 관리

setTimeout으로 쿨다운을 관리해야 합니다.

해결: setTimeout으로 쿨다운 종료 처리

window.setTimeout(() => { // 쿨다운이 끝나면 입력 재허용 setAnimating(false); // 상태 종료 animatingRef.current = false; // ref 종료 }, COOLDOWN_MS);
  • 1초 후 상태와 ref를 모두 false로 설정해 다음 입력을 허용합니다.

4단계: 모바일 터치 이벤트 지원

문제: 모바일에서도 동일한 섹션 단위 스크롤 제공

데스크톱 휠만으로는 모바일을 지원할 수 없습니다.

해결: 터치 이벤트 처리

// 터치 이벤트 핸들러 (모바일 스와이프 지원) let touchStartY = 0; let touchStartTime = 0; let isScrolling = false; const onTouchStart = (e: TouchEvent) => { touchStartY = e.touches[0].clientY; touchStartTime = Date.now(); isScrolling = false; };
  • touchStartY: 시작 Y 좌표 저장.
  • touchStartTime: 시작 시각 저장.

문제: 터치 이동 중 기본 스크롤 차단

터치 이동 중 기본 스크롤이 발생하면 섹션 단위 이동이 깨집니다.

해결: touchmove에서 preventDefault() 호출

const onTouchMove = (e: TouchEvent) => { // 터치 이동 중에는 기본 스크롤 동작을 차단 e.preventDefault(); isScrolling = true; };
  • 기본 스크롤을 막고, 스크롤 시작 여부를 표시합니다.

문제: 의도치 않은 작은 움직임으로 섹션 이동 방지

현재는 살짝 건드리기만 해도 바로 섹션이동이 됩니다.

해결: 최소 거리와 최대 시간 조건 적용

const onTouchEnd = (e: TouchEvent) => { const touchEndY = e.changedTouches[0].clientY; const touchEndTime = Date.now(); const deltaY = touchStartY - touchEndY; const deltaTime = touchEndTime - touchStartTime; // 스와이프 거리와 시간 체크 (최소 50px, 최대 500ms) if (Math.abs(deltaY) < 50 || deltaTime > 500) return;
  • Math.abs(deltaY) < 50: 최소 50px 이상 이동해야 함.
  • deltaTime > 500: 500ms 이내에 끝나야 함.

문제: 터치 이벤트에서도 섹션 단위 이동 로직 재사용

휠과 터치에서 동일한 로직을 사용해야 합니다.

해결: 동일한 인덱스 계산 및 스크롤 로직 재사용

const sectionHeight = el.clientHeight; const current = el.scrollTop; const direction = deltaY > 0 ? 1 : -1; // 위로 스와이프(+1), 아래로 스와이프(-1) const currentIndex = Math.round(current / sectionHeight); const maxIndex = Math.ceil(el.scrollHeight / sectionHeight) - 1; const targetIndex = Math.min( Math.max(currentIndex + direction, 0), maxIndex ); const target = targetIndex * sectionHeight; setAnimating(true); animatingRef.current = true; lastScrollAtRef.current = now; el.scrollTo({ top: target, behavior: "smooth" }); window.setTimeout(() => { setAnimating(false); animatingRef.current = false; }, COOLDOWN_MS);
  • 휠 이벤트와 동일한 로직을 재사용합니다.

5단계: 이벤트 리스너 등록 및 정리

문제: preventDefault() 사용을 위해 passive: false 필요

passive: truepreventDefault()가 동작하지 않습니다.

해결: 필요한 이벤트에 passive: false 설정

el.addEventListener("wheel", onWheel, { passive: false }); el.addEventListener("touchstart", onTouchStart, { passive: true }); el.addEventListener("touchmove", onTouchMove, { passive: false }); el.addEventListener("touchend", onTouchEnd, { passive: false });
  • wheel, touchmove, touchendpassive: false로 설정해 preventDefault()가 동작하도록 합니다.
  • touchstartpassive: true로 설정해 성능을 유지합니다.

문제: 메모리 누수 방지

컴포넌트 언마운트 시 이벤트 리스너를 제거해야 합니다.

해결: cleanup 함수에서 모든 리스너 제거

return () => { el.removeEventListener("wheel", onWheel as EventListener); el.removeEventListener("touchstart", onTouchStart as EventListener); el.removeEventListener("touchmove", onTouchMove as EventListener); el.removeEventListener("touchend", onTouchEnd as EventListener); };
  • 언마운트 시 모든 리스너를 제거합니다.

이렇게 케이스들을 하나하나 분석하고 구현하며, 어바웃페이지의 스크롤 시스템을 구현할 수 있었습니다.