2. 마우스 휠을 살짝 내리든 세개 내리든, 다음 섹션으로 부드럽게 넘어가는 시스템으로 어바웃페이지를 구현해주세요.
2025년 8월 3일
About 페이지 커스텀 스크롤 구현
목표

디자이너가 제공해준 피그마 파일에, 이러한 설명이 있었습니다.
즉 커스텀 휠 로직을 만들어서, 어바웃 페이지에서 사용자가 각 화면의 정보를 자세하게 볼 수 있도록 제어하는 시스템을 만들어야 합니다.
휠을 조금만 움직여도 다음 섹션으로 넘어가도록, 섹션 단위 스크롤을 구현해야합니다.
전체적인 구현 흐름은 다음과 같았습니다.
전체 흐름 요약
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을 읽으면 이전 값일 수 있습니다.
해결: useState와 useRef 같이 사용
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.min과 Math.max로 범위 제한
const maxIndex = Math.ceil(el.scrollHeight / sectionHeight) - 1; // 마지막 섹션 인덱스 const targetIndex = Math.min( Math.max(currentIndex + direction, 0), // 0 미만 방지 maxIndex // 마지막 초과 방지 ); // 이동 대상 섹션 인덱스
문제: 부드러운 스크롤 애니메이션 제공
디자이너가 의도한 것은, 부드럽게 이동하는 것입니다. 현재는 그냥 빠르게 이동됩니다.
해결: scrollTo의 behavior: "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: true면 preventDefault()가 동작하지 않습니다.
해결: 필요한 이벤트에 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,touchend는passive: false로 설정해preventDefault()가 동작하도록 합니다.touchstart는passive: 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); };
- 언마운트 시 모든 리스너를 제거합니다.
이렇게 케이스들을 하나하나 분석하고 구현하며, 어바웃페이지의 스크롤 시스템을 구현할 수 있었습니다.