스크롤 락은 어디에 쓰는 건가요?
스크롤 락은 다음과 같이 구현됩니다.
스크롤락은 탐색 대상을 정한 뒤 탐색 대상은 스크롤 여부를 검사하여 스크롤을 허용할지 말지를 결정합니다.
탐색 대상 이외는 모두 스크롤 락 처리를 진행합니다.
보통 아래와 같이 하면 drawer 내부의 스크롤은 탐색을 진행하고 루트 컨테이너의 스크롤은 락처리가 진행됩니다.
<body>
<RemoveScroll enabled={isOpen && lockScroll}>
<DrawerOverlay>
...
</DrawerOverlay>
</RemoveScroll>
</body>
흐름을 간단하게 요약하면 다음과 같습니다.
- 스크롤에 대해 판단 함수 존재 => 속성 검사 (overflow: auto, scroll)+ scrollHeight / clientHeight 너비/높이 검사 (탐색 대상을 위한 로직)
- 이벤트에 대해서 리액트단에서 이벤트를 큐에 넣는 로직 존재 이때 판단 함수를 통해 결과 저장해놓음
리스너 1: React가 등록 (passive: true)
→ onWheelCapture로 scrollWheel 실행
→ 판단만 하고 큐에 기록
→ preventDefault() 안 함 (해도 안 먹히니까) → passive true 리스너안에서는 브라우저가 무시 - 리스너 2: 직접 등록 (passive: false)
→ document.addEventListener('wheel', shouldPrevent, { passive: false }) - dom에서 이벤트를 꺼내서 큐에 저장된 판단 결과를 통해 스크롤 막는 로직 존재 (e.preventDefault)
→ passive: false라 브라우저가 존중
=> 이때 리액트 캡처 핸들러와 document 네이티브 리스너의 같은 이벤트를 필터하는 로직을 통해 대상 지정 - 이때 락밖에서 발생한 것은 자동으로 차단하는 형태 (위의 판단 로직은 락 안을 위한 로직)
- 스크롤 숨김은 react-remove-scroll-bar라는 별도 패키지에 위임
dom 이벤트 전파 단계
- 캡처 단계: document → body → div → button (위에서 아래로)
- 타겟 단계: button (이벤트 발생 지점)
- 버블 단계: button → div → body → document (아래에서 위로)
=> React에서 onWheelCapture는 캡처 단계, onWheel은 버블 단계입니다.
이때 리액트에서는 성능 최적화를 위해서 스크롤 관련 이벤트를 기본적으로 passive true로 처리하기 때문에
e.preventDefault로 막히지 않게 됩니다.
따라서 다음과 같이 네이티브 이벤트 리스너를 붙여야 작동합니다.
useEffect(() => {
const el = ref.current;
if (!el) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
// 커스텀 로직
};
el.addEventListener('wheel', handler, { passive: false });
return () => el.removeEventListener('wheel', handler);
}, []);
handleScroll.ts
이 함수는 스크롤 가능 여부에 대해 판단 검사를 하는 로직이 주로 들어 있습니다.
여기서 정의된 함수를 통해 스크롤 가능 여부를 큐에 넣는 형태로 진행합니다.
5. handleScroll.ts — 스크롤 가능 여부 판단 검사 함수 + overscroll 방지
import { Axis } from './types';
// textarea는 항상 내부에 스크롤을 포함
const alwaysContainsScroll = (node: Element): boolean =>
node.tagName === 'TEXTAREA';
// 특정 방향(overflowX/Y)으로 스크롤 가능한 엘리먼트인지 확인
// 단일 노드 검사하여 스크롤 가능하면 true 반환
const elementCanBeScrolled = (node: Element, overflow: 'overflowX' | 'overflowY'): boolean => {
if (!(node instanceof Element)) return false;
const styles = window.getComputedStyle(node);
return (
// overflow: hidden이면 스크롤 불가
styles[overflow] !== 'hidden' &&
// overflow: visible이면 textarea가 아닌 한 스크롤 불가
!(styles.overflowY === styles.overflowX && !alwaysContainsScroll(node) && styles[overflow] === 'visible')
);
};
...
// 이벤트 target에서 body까지 올라가며 해당 방향으로 스크롤 가능한 요소가 있는지 확인
export const locationCouldBeScrolled = (axis: Axis, node: HTMLElement): boolean => {
const ownerDocument = node.ownerDocument;
let current = node;
do {
// Shadow DOM 내부면 host로 탈출 -> 리액트에서는 웹컴포넌트를 안쓰기 때문에 무시
// <!-- 웹 컴포넌트 --> 예시
//<my-button>클릭</my-button> (일반 html이 아니라 속성 확인 불가능)
if (typeof ShadowRoot !== 'undefined' && current instanceof ShadowRoot) {
current = current.host as HTMLElement;
}
// 축별 단일 노드 검사 스크롤 가능하면 true 반환
const isScrollable = elementCouldBeScrolled(axis, current);
if (isScrollable) {
const [, scrollHeight, clientHeight] = getScrollVariables(axis, current);
// 실제로 콘텐츠가 넘쳐서 스크롤할 게 있는가? 확인
// 예를 들어 overflow: auto인데 콘텐츠가 짧으면 스크롤바가 안 생기니까 스크롤 불가
if (scrollHeight > clientHeight) {
return true;
}
}
current = current.parentNode as any;
} while (current && current !== ownerDocument.body);
return false;
};
...
/**
* 스크롤이 끝에 도달했는지 판단하여 overscroll(배경 스크롤 전파) 방지
*
* @returns true면 스크롤 차단해야 함
*/
export const handleScroll = (
axis: Axis,
endTarget: HTMLElement, // Lock 컨테이너
event: any,
sourceDelta: number, // 스크롤 이동량
noOverscroll: boolean
) => {
const directionFactor = getDirectionFactor(axis, window.getComputedStyle(endTarget).direction);
const delta = directionFactor * sourceDelta;
let target: HTMLElement | null = event.target as any;
const targetInLock = endTarget.contains(target);
let shouldCancelScroll = false;
const isDeltaPositive = delta > 0;
let availableScroll = 0; // 아래쪽(또는 오른쪽)으로 남은 스크롤 여유
let availableScrollTop = 0; // 위쪽(또는 왼쪽)으로 남은 스크롤 여유
// target에서 Lock 컨테이너까지 DOM 트리를 올라가며 스크롤 여유량 누적
do {
if (!target) break;
const [position, scroll, capacity] = getScrollVariables(axis, target);
const elementScroll = scroll - capacity - directionFactor * position;
if (position || elementScroll) {
if (elementCouldBeScrolled(axis, target)) {
availableScroll += elementScroll; // 남은 스크롤 공간
availableScrollTop += position; // 이미 스크롤된 양
}
}
const parent = target.parentNode;
// Shadow DOM이면 host로 올라감
target = (
parent && parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE
? (parent as ShadowRoot).host
: parent
) as HTMLElement;
} while (
// Lock 바깥 콘텐츠(portal)면 body까지 순회
(!targetInLock && target !== document.body) ||
// Lock 안 콘텐츠면 Lock 컨테이너까지만 순회
(targetInLock && (endTarget.contains(target) || endTarget === target))
);
// 아래로 스크롤하는데 남은 여유가 1px 미만 → 차단
if (
isDeltaPositive &&
((noOverscroll && Math.abs(availableScroll) < 1) || (!noOverscroll && delta > availableScroll))
) {
shouldCancelScroll = true;
}
// 위로 스크롤하는데 남은 여유가 1px 미만 → 차단
else if (
!isDeltaPositive &&
((noOverscroll && Math.abs(availableScrollTop) < 1) || (!noOverscroll && -delta > availableScrollTop))
) {
shouldCancelScroll = true;
}
return shouldCancelScroll;
};
이제 실제 그 과정을 코드를 통해 확인해 보겠습니다.
1. 이 함수를 통해 락 컨테이너에 대한 종합적인 판단이 이루어짐
// ==============================
// shouldCancelEvent — "이 스크롤 이벤트를 차단해야 하는가?" 판단
// ==============================
// Lock 내부에서 발생한 스크롤에 대해:
// - 내부에 스크롤 가능한 요소가 있으면 → 허용 (false)
// - 없으면 → 차단 (true)
// - 있더라도 스크롤 끝에 도달했으면 → 차단 (배경 전파 방지)
// 이때 이벤트 발생 시마다 독립적으로 동작
const shouldCancelEvent = React.useCallback((event: WheelEvent | TouchEvent, parent: HTMLElement) => {
// ---- 핀치줌 감지 ----
// 2-finger 터치 또는 ctrl+휠 = 핀치줌
// allowPinchZoom prop이 true면 허용(false 반환), 아니면 차단(true 반환)
if (('touches' in event && event.touches.length === 2) || (event.type === 'wheel' && event.ctrlKey)) {
return !lastProps.current.allowPinchZoom;
}
// ---- 이동량(delta) 계산 ----
const touch = getTouchXY(event);
const touchStart = touchStartRef.current;
// 휠: deltaX/Y 직접 사용
// 터치: 시작점과 현재점의 차이로 계산
const deltaX = 'deltaX' in event ? event.deltaX : touchStart[0] - touch[0];
const deltaY = 'deltaY' in event ? event.deltaY : touchStart[1] - touch[1];
let currentAxis: Axis | undefined;
const target: HTMLElement = event.target as any;
// 가로/세로 중 이동량이 큰 쪽이 메인 스크롤 방향
const moveDirection: Axis = Math.abs(deltaX) > Math.abs(deltaY) ? 'h' : 'v';
// ---- 예외 1: Range input ----
// <input type="range"> 슬라이더를 터치로 수평 드래그하는 건 스크롤이 아님 → 허용
if ('touches' in event && moveDirection === 'h' && (target as HTMLInputElement).type === 'range') {
return false;
}
// ---- 예외 2: iOS 텍스트 선택 드래그 ----
// 텍스트를 선택하면서 드래그하는 건 스크롤이 아님 → 허용
const selection = window.getSelection();
const anchorNode = selection && selection.anchorNode;
const isTouchingSelection = anchorNode ? anchorNode === target || anchorNode.contains(target) : false;
if (isTouchingSelection) {
return false;
}
// ---- 스크롤 가능 여부 판단 ----
// target에서 body까지 올라가며 해당 방향으로 스크롤 가능한 요소가 하나라도 있는지 확인
let canBeScrolledInMainDirection = locationCouldBeScrolled(moveDirection, target);
// 스크롤 가능한 요소가 아예 없으면 → 차단
if (!canBeScrolledInMainDirection) {
return true;
}
2. 여기서 내부 락 컨테이너 대상에 대한 이벤트들의 판단을 기록하는 형태
// ==============================
// 1단계: React 캡처 이벤트 핸들러들
// ==============================
// UI.tsx의 컨테이너 div에 onWheelCapture, onTouchMoveCapture로 붙음
// "차단해야 하는지" 판단만 하고 큐에 기록 — 여기서는 preventDefault() 안 함
// 터치 시작 — 시작 좌표 저장 + 축 초기화
const scrollTouchStart = React.useCallback((event: any) => {
touchStartRef.current = getTouchXY(event);
activeAxis.current = undefined; // 새 터치 시작이면 축 리셋
}, []);
// 휠 이벤트 — 판단 후 큐에 기록
const scrollWheel = React.useCallback((event: WheelEvent) => {
shouldCancel(event.type, getDeltaXY(event), event.target, shouldCancelEvent(event, props.lockRef.current as any));
}, []);
// 터치 이벤트 — 판단 후 큐에 기록
const scrollTouchMove = React.useCallback((event: TouchEvent<HTMLDivElement>) => {
shouldCancel(event.type, getTouchXY(event), event.target, shouldCancelEvent(event, props.lockRef.current as any));
}, []);
3. 여기서 이제 내부 락 컨테이너 대상에 대한 스크롤 락이 실제로 진행하게 됨
=> react 캡처 핸들러와 document 네이티브 리스너에 같은 이벤트가 있으므로 그것을 찾는 과정을 거치게 됨
// ==============================
// shouldPrevent — 2단계: document 네이티브 리스너
// ==============================
// 여기서 실제 event.preventDefault()를 호출하여 브라우저 스크롤을 차단
// 1단계 큐에서 판단 결과를 읽어옴
const shouldPrevent = React.useCallback((_event: Event) => {
const event: WheelEvent | TouchEvent = _event as any;
// ---- 중첩 Lock 체크 ----
// lockStack의 마지막(가장 최근에 마운트된 Lock)만 활성
// 예: 모달 위에 또 모달 → 아래 모달의 Lock은 무시
if (!lockStack.length || lockStack[lockStack.length - 1] !== Style) {
return;
}
const delta = 'deltaY' in event ? getDeltaXY(event) : getTouchXY(event);
// ---- 1단계 큐에서 매칭되는 판단 결과 찾기 ----
// 같은 이벤트 타입 + 같은 target(또는 Shadow DOM host) + 같은 delta면 매칭
// 여기서 같은 이벤트 타입 + target + delta가 매칭되는 항목을 찾음
// react 캡처 핸들러와 document 네이티브 리스너에 같은 이벤트가 있으므로 그것을 찾는 과정
const sourceEvent = shouldPreventQueue.current.filter(
(e) => e.name === event.type && (e.target === event.target || event.target === e.shadowParent) && deltaCompare(e.delta, delta)
)[0];
// ---- Case 1: Lock 내부 이벤트 (큐에서 찾음) ----
// 1단계에서 "차단해야 한다"고 판단했으면 → 실제 차단
if (sourceEvent && sourceEvent.should) {
if (event.cancelable) {
event.preventDefault(); // ★ 여기서 실제로 브라우저 스크롤을 막음
}
return;
}
// ---- Case 2: Lock 외부 이벤트 (큐에 없음) ----
// Lock 컨테이너 바깥에서 발생한 이벤트
if (!sourceEvent) {
// shard: Lock 외부지만 스크롤을 허용하기로 한 요소들
// 예: 모달 + 사이드패널을 동시에 열어둘 때, 사이드패널을 shard로 지정
const shardNodes = (lastProps.current.shards || [])
.map(extractRef)
.filter(Boolean)
.filter((node) => node.contains(event.target as any));
const shouldStop =
shardNodes.length > 0
? shouldCancelEvent(event, shardNodes[0]) // shard 내부면 shouldCancelEvent로 재판단
: !lastProps.current.noIsolation; // shard 아니면: noIsolation=false(기본)이면 차단, true이면 허용
if (shouldStop) {
if (event.cancelable) {
event.preventDefault();
}
}
}
}, []);
아래를 보면 그냥 e.preventDefault로 막으면 안막히기 때문에 실제로 네이티브 리스너에 등록한 것을 확인할 수 있다.
// capture 옵션이 없으면 capture는 기본적으로 false로 동작하게 됩니다.
React.useEffect(() => {
// Lock 스택에 자신 추가 — 가장 마지막 = 활성 Lock
lockStack.push(Style);
// UI 컴포넌트(UI.tsx)에 1단계 캡처 핸들러 주입
// UI.tsx의 컨테이너 div에 onWheelCapture={scrollWheel} 등으로 붙음
props.setCallbacks({
onScrollCapture: scrollWheel,
onWheelCapture: scrollWheel,
onTouchMoveCapture: scrollTouchMove,
});
// document에 2단계 네이티브 리스너 등록
// passive: false 필수 — true면 preventDefault() 호출해도 브라우저가 무시함
document.addEventListener('wheel', shouldPrevent, nonPassive);
document.addEventListener('touchmove', shouldPrevent, nonPassive);
document.addEventListener('touchstart', scrollTouchStart, nonPassive);
return () => {
// Lock 스택에서 자신 제거
lockStack = lockStack.filter((inst) => inst !== Style);
// 리스너 해제
document.removeEventListener('wheel', shouldPrevent, nonPassive as any);
document.removeEventListener('touchmove', shouldPrevent, nonPassive as any);
document.removeEventListener('touchstart', scrollTouchStart, nonPassive as any);
};
}, []);
const { removeScrollBar, inert } = props;
return (
<React.Fragment>
{inert ? <Style styles={generateStyle(id)} /> : null}
{removeScrollBar ? <RemoveScrollBar noRelative={props.noRelative} gapMode={props.gapMode} /> : null}
</React.Fragment>
);
}
또한 여기서 주요하게 살펴볼 특징은 순서의 보장입니다.
다음과 같이 순서가 진행되게 되게 됩니다.
- 캡처 단계: document → body → div(Lock 컨테이너) → target
- 타겟 단계: target
- 버블 단계: target → div(Lock 컨테이너) → body → document
이때 이벤트를 큐에 기록하는 것이 버블 단계에 일어나고 이벤트를 막는 것이 캡처 단계에 일어나게 되면 올바른 동작이 이루어지지 않습니다.
따라서 여기 코드에서 볼 수 있듯이 onWheelCapture를 통해 이벤트를 캡처 단계에서 기록하고 captuer 가 기본 false로 동작하는 것을 통해 버블 단계에서 이벤트를 막는 것을 확인할 수 있습니다.
스크롤 제거는 다른 라이브러리 위임
// ==============================
// constants.ts — CSS 클래스명 + 변수명 상수
// ==============================
// position: fixed 요소의 right 보정용 클래스
export const zeroRightClassName = 'right-scroll-bar-position';
// width: 100% 요소의 margin 보정용 클래스
export const fullWidthClassName = 'width-before-scroll-bar';
// 스크롤바 숨김 대상 클래스
export const noScrollbarsClassName = 'with-scroll-bars-hidden';
// 스크롤바 너비를 외부에서 참조할 수 있는 CSS 변수
export const removedBarSizeVariable = '--removed-body-scroll-bar-size';
// ==============================
// utils.ts — 스크롤바 너비 계산
// ==============================
export type GapMode = 'padding' | 'margin';
export interface GapOffset {
left: number;
top: number;
right: number;
gap: number; // 스크롤바 너비
}
// body의 현재 padding 또는 margin 값을 파싱
const getOffset = (gapMode: GapMode): number[] => {
const cs = window.getComputedStyle(document.body);
const left = cs[gapMode === 'padding' ? 'paddingLeft' : 'marginLeft'];
const top = cs[gapMode === 'padding' ? 'paddingTop' : 'marginTop'];
const right = cs[gapMode === 'padding' ? 'paddingRight' : 'marginRight'];
return [parse(left), parse(top), parse(right)];
};
export const getGapWidth = (gapMode: GapMode = 'margin'): GapOffset => {
if (typeof window === 'undefined') return zeroGap;
const offsets = getOffset(gapMode);
const documentWidth = document.documentElement.clientWidth; // 스크롤바 미포함
const windowWidth = window.innerWidth; // 스크롤바 포함
return {
left: offsets[0],
top: offsets[1],
right: offsets[2],
// ★ 핵심: 스크롤바 너비 = 전체 창 너비 - 콘텐츠 영역 너비
gap: Math.max(0, windowWidth - documentWidth + offsets[2] - offsets[0]),
};
};
// ==============================
// component.tsx — 메인 컴포넌트
// ==============================
// body에 data-scroll-locked 속성으로 중첩 Lock 카운터 관리
export const lockAttribute = 'data-scroll-locked';
// ---- 스타일 생성 함수 ----
// 스크롤바를 숨기고, 사라진 스크롤바 너비만큼 보정하는 CSS
const getStyles = ({ left, top, right, gap }: GapOffset, allowRelative: boolean, gapMode: GapMode, important: string) => `
/* 스크롤바 숨김 대상에 overflow: hidden + 우측 패딩 보정 */
.${noScrollbarsClassName} {
overflow: hidden ${important};
padding-right: ${gap}px ${important};
}
/* body 자체에 overflow: hidden 적용 */
body[${lockAttribute}] {
overflow: hidden ${important};
overscroll-behavior: contain; /* 부모로 스크롤 전파 차단 (체이닝 방지) */
${gapMode === 'margin'
? `margin-right: ${gap}px ${important};` /* margin 모드: margin-right로 보정 */
: `padding-right: ${gap}px ${important};`} /* padding 모드: padding-right로 보정 */
}
/* position: fixed 요소 보정 — right 값을 스크롤바 너비만큼 밀어줌 */
.${zeroRightClassName} {
right: ${gap}px ${important};
}
/* width: 100% 요소 보정 — margin-right로 스크롤바 너비만큼 줄여줌 */
.${fullWidthClassName} {
margin-right: ${gap}px ${important};
}
/* 중첩된 보정 클래스는 이중 보정 방지 (0으로 리셋) */
.${zeroRightClassName} .${zeroRightClassName} { right: 0 ${important}; }
.${fullWidthClassName} .${fullWidthClassName} { margin-right: 0 ${important}; }
/* 외부에서 스크롤바 너비를 참조할 수 있는 CSS 변수 */
body[${lockAttribute}] {
${removedBarSizeVariable}: ${gap}px;
}
`;
// ---- 중첩 Lock 카운터 ----
// 마운트 시 +1, 언마운트 시 -1 → 0이 되면 속성 제거
export const useLockAttribute = () => {
React.useEffect(() => {
document.body.setAttribute(lockAttribute, (getCurrentUseCounter() + 1).toString());
return () => {
const newCounter = getCurrentUseCounter() - 1;
if (newCounter <= 0) {
document.body.removeAttribute(lockAttribute); // 모든 Lock 해제 → 속성 제거
} else {
document.body.setAttribute(lockAttribute, newCounter.toString());
}
};
}, []);
};
// ---- 메인 컴포넌트 ----
export const RemoveScrollBar: React.FC<BodyScroll> = ({ noRelative, noImportant, gapMode = 'margin' }) => {
useLockAttribute(); // 카운터 관리
// 스크롤바 너비 측정 — 마운트 시 1회
const gap = React.useMemo(() => getGapWidth(gapMode), [gapMode]);
// Style은 react-style-singleton — 여러 인스턴스가 있어도 첫 번째 스타일만 적용
// 즉 처음 측정한 스크롤바 너비가 유지됨 (숨긴 후 재측정하면 0이 되니까)
return <Style styles={getStyles(gap, !noRelative, gapMode, !noImportant ? '!important' : '')} />;
};
핵심 포인트:
1. gap 계산: window.innerWidth - clientWidth로 스크롤바 너비 측정
2. overflow: hidden: body에 적용하여 스크롤 자체를 숨김
3. 밀림 보정: 스크롤바가 사라지면서 생기는 레이아웃 밀림을 margin-right 또는 padding-right로 보정
4. 싱글톤 스타일: 첫 번째 인스턴스가 측정한 gap만 사용 (숨긴 뒤 재측정하면 0이 되니까)
5. 카운터: data-scroll-locked 속성으로 중첩 Lock 관리
그래서 갑자기 이건 왜 한건데요?
스크롤 락과 관련된 유틸이 각 도메인마다 구현체가 달라 흩어져 있었고
이거를 통일하려던 중 이미 적용된 라이브러리가 있어 괜찮은 라이브러리인지 살펴보고자 코드를 까보았습니다.
그래서 현재 drawer 컴포넌트에 적용한 상태 입니다.
'코딩 정보 > React' 카테고리의 다른 글
| 선언형으로 에러 관리하기 (0) | 2026.07.11 |
|---|---|
| [React] 리액트 피버와 함께 랜더링 이해하기 (8) | 2025.08.12 |
| [React] useOptimistic을 통해 낙관적 업데이트를 구현해보자 (7) | 2025.08.04 |
| [React] useLayoutEffect 와 useEffect (2) | 2025.08.02 |
| [React] stompjs, sockjs를 통해 소켓 통신 하기 (0) | 2025.04.19 |