지난 무한스크롤 구현 글에서...
2024.07.30 - [jQuery(+ajax +js)] - 무한 스크롤 구현: 엑셀 필터형 데이터 조회기
기술적 판단: 일반적인 페이징 방식에서는 무한스크롤을 선택한 이유가 페이지 이동 시마다 체크박스 상태를 관리하기 까다롭지만, 무한 스크롤은 단일 리스트 구조를 유지하므로 사용자 경험(UX)과 데이터 정규화 측면에서 더 유리하다고 판단했다.
라고 작성했었습니다.
하지만 지금와서 생각해보니 일반적인 페이지네이션 페이지가 무한스크롤에 비해 체크박스에 대해서도 더 관리가 쉬울 것 같다는 생각이 들었습니다.
하지만 무한스크롤을 사용한 김에 유지하고 리팩토링을 진행하겠습니다.

Scroll 이벤트의 단점
리팩토링을 위해 자료를 찾아보던 중 이제는 무한스크롤을 개발할 때 scroll 이벤트를 잘 사용하지 않는다는 것을 알게 되었습니다.
사용하지 않는 이유 : 성능
현재 잘 사용하지 않는 이유는 성능 문제 때문입니다.
기존의 제가 작성한 코드를 분석해보면 3가지 치명적인 단점이 있습니다.
// 컨테이너 요소의 스크롤 이벤트 핸들러 등록(사용)
$('.researcher-container').on('scroll', function() {
// 컨테이너 내부의 스크롤 위치와 높이를 사용하여 스크롤이 거의 맨 아래에 도달했는지 확인
if ($('.researcher-container').scrollTop() + $('.researcher-container').height() > $('.researcher-container')[0].scrollHeight - 100) {
scrollType = 'researcherScroll';
loadResearchers();
}
});
//기관명 컨테이너 요소임
$('.instit-container').on('scroll', function() {
// 컨테이너 내부의 스크롤 위치와 높이를 사용하여 스크롤이 거의 맨 아래에 도달했는지 확인
if ($('.instit-container').scrollTop() + $('.instit-container').height() > $('.instit-container')[0].scrollHeight - 100) {
scrollType = 'applicantNmscroll';
loadResearchers();
}
});
1. 과한 업무량으로 인한 이벤트 폭주
사용자가 마우스 휠을 한 번만 까닥해도 scroll 이벤트는 수십 번 발생합니다.
- 현재 코드의 문제: 사용자가 스크롤을 내리는 동안 if문 안의 계산식(scrollTop() + height() > scrollHeight - 100)이 초당 수십 번씩 실행됩니다.
- IO(Intersection Observer)의 장점: 브라우저가 알아서 감시하다가, 딱 바닥에 닿았을 때 단 한 번만 알려줍니다. CPU가 훨씬 편안해집니다.
2. 레이아웃 스래싱
코드에 있는 .scrollTop(), .height(), .scrollHeight는 모두 브라우저에게 지금 요소 크기랑 위치 계산해서 당장 보고해! 라고 명령하는 함수들입니다.
- 현재 코드의 문제: 스크롤이 일어날 때마다 브라우저는 화면을 다시 그리는 도중에 이 값들을 계산하기 위해 하던 일을 멈춰야 합니다. 이게 쌓이면 화면이 미세하게 버벅거리는 현상이 생깁니다.
- IO의 장점: IO는 이런 수치 계산을 자바스크립트 메인 스레드가 아닌 브라우저 내부에서 최적화된 방식으로 처리합니다.
3. "매직 넘버(-100)의 불안정성"
코드 끝에 있는 - 100은 바닥에 닿기 100px 전에 미리 불러와라 라는 의미입니다.
- 현재 코드의 문제: 화면 크기가 아주 작거나, 컨테이너 높이가 유동적일 때 이 '100px'이라는 기준은 가끔 무시되거나 오작동할 수 있습니다.
- IO의 장점: threshold: 0.5 (요소의 절반이 보일 때) 또는 rootMargin: '200px' (보이기 200px 전부터 감지) 같은 옵션을 통해 훨씬 정확하게 제어할 수 있습니다.
그래서 Intersection Observer(IO) 가 뭘까?
MDN 을 참고해보면
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
Intersection Observer란 우리가 지정한 특정 요소가 화면에 나타나거나 사라지는 순간을 감지하여 동작하게 만드는 웹 API입니다.
이름만 봐도 해당 API의 동작원리를 알 수 있습니다.
Intersection = 교차, 겹침
Observer = 관찰자, 감시자
Observer 는 소프트웨어 디자인 패턴 중 '옵저버 패턴'에서 온 용어입니다.
직접 매번 가서 확인하는 게 아니라, 어떤 사건이 일어날 때까지 가만히 지켜보고 있다가 보고하는 역할을 합니다.
비유해보자면
Scroll 방식은 사장이 문 앞에서 눈 안떼고 계속 쳐다보는것(성능저하) 이라 하면
Intersection Observer 방식 문에 센서를 달아두고 사람이 오면 알람이 울리게 하는 것입니다.
그래서 Intersection Observer 의 뜻을 합쳐서 보면
"두 영역이 교차(Intersection)하는지 가만히 지켜보는 녀석(Observer)"
입니다.
Intersection Observer 사용 방법
const options = {
root: document.querySelector("#scrollArea"),
rootMargin: "0px",
scrollMargin: "0px",
threshold: 1.0,
};
const observer = new IntersectionObserver(callback, options);
MDN 문서에 나와있는 샘플 코드는 Intersection Observer(IO)를 만들기 위한 가장 표준적인 설계도 입니다.
각 옵션의 역할입니다.
1. root
- 기준이 되는 화면(뷰포트)
- 보통은 브라우저 화면(null), 따로 지정하면 그 요소 기준으로 판단
2. rootMargin
- 기준 화면의 여백을 늘리거나 줄임
- "10px" → 위아래좌우 10px 확장
- 양수 = 더 빨리 감지 / 음수 = 더 늦게 감지
</div>
<div id="researcher-container"></div>
</div>
3. scrollMargin
- 스크롤 컨테이너(부모 스크롤 영역)의 여백 조정
- rootMargin과 거의 동일한 개념인데, 중첩 스크롤에 적용
4. threshold
- 요소가 얼마나 보여야 콜백 실행할지 기준
- 0 → 조금이라도 보이면 실행
- 0.5 → 50% 보일 때 실행
- [0, 0.5, 1] → 단계별로 실행
Intersection Observer 리팩토링 적용
기존에 컨테이너 요소 하나씩만 작성된 부분에 대해서
</div>
<div id="researcher-container"></div>
</div>
.
.
.
</div>
<div id="instit-container"></div
</div>
researcher-sentinel, instit-sentinel 요소도 추가해줍니다. (참고로 스크롤 두개 있음)
</div>
<div id="researcher-container"></div>
<div id="researcher-sentinel" style="height: 10px;"></div>
</div>
.
.
.
</div>
<div id="instit-container"></div>
<div id="instit-sentinel" style="height: 10px;"></div>
</div>
여기서 각 sentinel 감시 요원의 역할을 가지고 있습니다.
즉, 리스트의 가장 마지막에 위치하여 "여기까지 읽었으면 다음 내용을 가져와!"라는 표식 역할을 합니다.
아래 코드는 Intersection Observer 를 적용한 스크립트 입니다.
// 연구자 리스트용 Observer
const researcherObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !loading) {
scrollType = 'researcherScroll';
loadResearchers();
}
});
}, {
root: document.querySelector('.researcher-container'), // 연구자 전용 컨테이너
threshold: 0.1
});
// 기관 리스트용 Observer
const institObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !loading) {
scrollType = 'applicantNmscroll';
loadResearchers();
}
});
}, {
root: document.querySelector('.instit-container'), // 기관 전용 컨테이너
threshold: 0.1
});
// 감시 시작
researcherObserver.observe(document.getElementById('researcher-sentinel'));
institObserver.observe(document.getElementById('instit-sentinel'));
설정된 옵션은 root, threshold 입니다.
- root (기준 틀): { root: document.querySelector('.researcher-container') }로로 설정하여 전체 화면이 아니라 특정 박스 내부의 스크롤을 감시합니다.
- 참고로 root를 null로 해도 실행되긴 합니다. 이유는 감시 요원이 사용자 눈(화면)에 들어오는 순간이 박스 바닥에 닿는 순간과 거의 일치하기 때문입니다.
- 하지만 박스 안에서만 일어나는 정교한 스크롤을 제어할 때는 root에 해당 컨테이너를 직접 넣어주는 것이 안전합니다.
- threshold (민감도)
- 완전히 바닥에 닿기보다는 아주 살짝(10%)만 스쳐도 데이터를 불러오는 것이 무한 스크롤의 연속성을 살리기에 적합해보여 0.1로 설정했습니다.
- rootMargin scrollMargin 은 설정하지 않았으며 기본값 0px 가 적용됩니다.
if (entry.isIntersecting && !loading) {
scrollType = 'researcherScroll';
loadResearchers();
}
감시 요원이 컨테이너 안에 들어왔으면서(isIntersecting) 지금 이미 데이터를 가져오는 중이 아닐 때 (중복 호출 방지, !loading )
위의 두 조건이 맞으면 즉시 loadResearchers()를 실행해 서버에서 데이터를 받아옵니다.
[감시시작]
researcherObserver.observe(document.getElementById('researcher-sentinel'));
institObserver.observe(document.getElementById('instit-sentinel'));
앞에서 new IntersectionObserver(...)를 통해 감시관(Observer)을 고용하고 어떤 일을 할지(Callback) 교육까지 시켰다면, 이 부분은 실제로 업무를 시작하게 만드는 단계입니다.
- researcherObserver: 우리가 미리 만들어둔 '연구자용 감시관' 객체입니다.
- .observe(): "이제부터 감시를 시작해!"라는 실행 명령(메서드)입니다.
- document.getElementById('researcher-sentinel'): 감시할 구체적인 대상(타겟)입니다.(리스트 맨 밑에 심어둔 감시자 빈박스)
마지막으로
더 이상 불러올 데이터가 없을 때는 observer.unobserve(target)를 호출하여 감시를 종료합니다. 이는 불필요한 교차 감지 연산을 중단해 성능을 아낄 뿐만 아니라, 사용하지 않는 감시자를 제거함으로써 브라우저의 메모리 누수를 방지하고 자원을 효율적으로 관리하기 위함입니다.
코드는 loadResearchers 함수의 AJAX 성공 로직에 추가해줍니다.
success: function(data) {
var resultData = data.data;
if (data && resultData && resultData.length > 0) {
// ... 데이터 렌더링 로직 ...
} else {
// 데이터가 더 이상 없을 때(마지막 페이지)
if (scrollType == "researcherScroll") {
researcherObserver.unobserve(document.getElementById('researcher-sentinel'));
console.log("연구자: 모든 데이터를 읽어 감시를 종료합니다.");
} else if (scrollType == "applicantNmscroll") {
institObserver.unobserve(document.getElementById('instit-sentinel'));
console.log("기관: 모든 데이터를 읽어 감시를 종료합니다.");
}
loading = false;
}
}
여기서 이렇게 까지 했는데 문제가 생겼습니다.
데이터 소진 시 리소스 절약을 위해 unobserve()로 감시를 종료했는데, 이후 사용자가 새로운 키워드로 검색을 시도해도 무한 스크롤이 작동하지 않는 현상을 발견했습니다.

nobserve는 해당 요소에 대한 관찰을 영구적으로 중단하므로, 검색어 변경 등으로 리스트가 초기화될 때는 반드시 .observe()를 통해 관찰을 재개해야 함을 깨달았습니다.
이를 해결하기 위해 검색 이벤트 핸들러 내에 감시 대상(Sentinel)을 다시 등록하는 로직을 추가하여야 합니다.
이를 통해 API의 생명주기와 자원 관리의 중요성을 다시금 체감했습니다.
researcherObserver.observe(document.getElementById('researcher-sentinel'));
를 추가해줍니다.
//연구자 필터
$('#ResearcherSearch').on('keyup', function() {
.....
researcherObserver.observe(document.getElementById('researcher-sentinel'));
....
});
[해결]

결과 및 성능 측정 결과 비교

비교를 위해 처음에 올려뒀지만 리팩토링 전 scroll 이벤트를 사용한 gif 를 다시 올려보자면

데스크탑 환경에서는 체감이 적을 수 있으나, 저사양 모바일 기기나 네트워크 지연이 발생하는 환경에서는 Scroll 이벤트 방식보다 훨씬 안정적인 프레임 유지가 가능합니다.
성능 측정
[리팩토링 후 - IntersectionObserve API 사용 코드]

[리팩토링 - Scroll 이벤트 사용]

리팩토링 전 (Scroll 이벤트):
- 스크롤 발생 시마다 계산 로직이 실행되어 Scripting 시간이 128ms에 달함. (기록 시간 대비 높은 점유율)
리팩토링 후 (Intersection Observer):
- 브라우저 최적화 API를 활용하여 Scripting 시간을 84ms로 약 34% 절감.
결과적으로 단순한 API 교체만으로 자바스크립트 연산 시간(Scripting)을 약 34% 절감(128ms → 84ms)했습니다.
사용자 기기의 CPU 자원을 그만큼 아껴 더 쾌적한 브라우징 환경을 제공합니다.
느낀점
이전까지는 기능을 구현하고 화면에 데이터가 잘 나오게 하는 것에만 집중했습니다. 하지만 이번 리팩토링을 통해 사용자 눈에는 보이지 않지만 시스템 내부에서 돌아가는 성능의 중요성을 실감했습니다.
당연하게 사용했던 Scroll 이벤트가 브라우저에게 얼마나 큰 부담을 주는지 직접 수치로 확인하며, '동작하는 코드'와 '좋은 코드'의 차이를 배울 수 있었습니다.
앞으로는 기능을 구현하기에 앞서 시스템 자원을 얼마나 아끼고 효율적으로 사용할 수 있을지 한 번 더 고민하는 개발자가 되겠습니다.
단순 기능 구현을 넘어, 브라우저 렌더링 원리(Layout Thrashing)에 기반한 성능 최적화와 자원 관리를 고민한 무한 스크롤 리팩토링 기록입니다."
'리팩토링' 카테고리의 다른 글
| newLMS 교과정보 주차 설정(리팩토링 ) (0) | 2026.04.20 |
|---|