Turbo Stream과 더보기 버튼 스크롤 문제 해결하기
Claude와 나눴던 삽질내용을 정리한 포스트입니다.
문제 상황
Nestjs와 Hotwire Turbo를 사용해서 무한 스크롤 기능을 구현하던 중, 예상치 못한 스크롤 문제가 발생했습니다.
더보기 버튼을 클릭해서 새로운 콘텐츠를 로드할 때마다 페이지가 맨 아래로 자동 스크롤되는 현상이었습니다. 사용자는 새로 추가된 콘텐츠를 보고 싶은데, 페이지가 확 내려가버려서 불편한 UX를 제공하고 있었죠.
코드 구조
// Stimulus Controller
onMore(event) {
const button = event.currentTarget;
const cursor = button.dataset?.cursor;
if (!cursor) {
window.alert('데이터를 불러오는데 실패하였습니다.');
return;
}
// 버튼 UI 변경 (로딩 상태)
button.innerText = '';
button.classList.add('btn-disabled');
const span = document.createElement('span');
span.classList.add('loading', 'loading-spinner');
button.appendChild(span);
// Turbo Stream 요청
const formData = new FormData(this.queryFormTarget);
formData.append('cursor', cursor);
const params = new URLSearchParams(formData);
fetch(`/?${params.toString()}`, {
method: 'GET',
headers: {
Accept: 'text/vnd.turbo-stream.html',
},
})
.then((response) => response.text())
.then((stream) => Turbo.renderStreamMessage(stream));
}
서버에서 반환하는 Turbo Stream
<turbo-stream action="append" target="feedPostsList">
<template>
@each((post, index) in postsData.items)
<li class="border-t border-base-300 sm:hidden">
</li>
@!component('pages/feed/posts/_list_item', { post })
@end
</template>
</turbo-stream>
@if(postsData?.hasMore)
<turbo-stream action="replace" target="moreButton">
<template>
<div class="w-full flex justify-center">
<button id="moreButton" class="btn btn-primary"
data-cursor="{{ postsData?.nextCursor }}"
data-action="feed-query#onMore">더보기</button>
</div>
</template>
</turbo-stream>
@else
<turbo-stream action="remove" target="moreButton">
</turbo-stream>
@end
이상한 패턴 발견
더 이상한 건 이런 패턴이었습니다:
- 더보기 버튼이 있을 때: 스크롤이 맨 아래로 확 내려감
- 더보기 버튼이 사라질 때 (마지막 페이지): 스크롤이 정상적으로 유지됨
해결 과정
시도 1: JavaScript로 스크롤 위치 제어
// 버튼 위치 저장 후 복원
const buttonRect = button.getBoundingClientRect();
const scrollOffset = window.scrollY + buttonRect.top;
// Turbo Stream 렌더링 후
setTimeout(() => {
window.scrollTo({
top: scrollOffset,
behavior: 'instant'
});
}, 50);
결과: 효과 없음
시도 2: Turbo.navigator.currentVisit.scrolled 사용
// application.js
window.shouldPreserveScroll = false;
document.addEventListener("submit", function(event) {
if (event.target.hasAttribute("data-turbo-preserve-scroll")) {
window.shouldPreserveScroll = true;
}
});
addEventListener("turbo:render", () => {
if (window.shouldPreserveScroll) {
Turbo.navigator.currentVisit.scrolled = true;
window.shouldPreserveScroll = false;
}
});
<button data-turbo-preserve-scroll="true">더보기</button>
결과: 이 방법으로 해결됨!
하지만 진짜 원인은...
위의 모든 코드를 제거해도 문제가 사라졌습니다. 진짜 원인은 button.disabled 속성의 누락이었죠.
진짜 원인: button.disabled 속성의 중요성
문제가 되었던 코드
// button.disabled 설정 없이 UI만 변경
button.innerText = '';
button.classList.add('btn-disabled');
올바른 코드
button.disabled = true; // ✅ 핵심!
button.innerText = '';
button.classList.add('btn-disabled');
disabled 속성을 사용하지 않았을 때의 문제
Focus 동작의 문제점
button.disabled = true를 설정하지 않으면:
- 버튼이 실제로 비활성화되지 않음: 시각적으로만 비활성화된 것처럼 보임
- 버튼이 여전히 focusable한 상태: 브라우저가 이 버튼을 active element로 추적
- 브라우저의 자동 스크롤 발생: 포커스된 요소가 화면에 보이도록 자동 조정
브라우저의 Focus 자동 스크롤 동작
브라우저는 포커스된 요소가 화면에 보이도록 자동으로 스크롤하는 기본 동작이 있습니다:
// 브라우저가 내부적으로 하는 일
element.focus(); // 자동으로 scrollIntoView() 호출
문제 발생 시나리오
- 사용자가 더보기 버튼 클릭
- 버튼이 focus된 상태 (하지만 실제로는 비활성화되지 않음)
- Turbo Stream
append액션으로 새 콘텐츠 추가 - 더보기 버튼이 페이지 하단으로 밀려남
- 브라우저가 포커스된 버튼을 보이려고 자동 스크롤
- 결과: 페이지가 맨 아래로 확 내려감
disabled 속성을 올바르게 사용했을 때
button.disabled = true; // 핵심 해결책
disabled 속성은:
- 버튼을 실제로 비활성화
- 버튼에서 focus를 제거 (disabled 요소는 focus될 수 없음)
- 브라우저의 자동 스크롤 추적을 차단
더보기 버튼 유무에 따른 차이점
더보기 버튼이 있을 때
- 버튼이 focus된 상태로 DOM 하단에 위치
- 새 콘텐츠 append → 버튼이 더 아래로 이동
- 브라우저가 포커스된 버튼을 보이려고 스크롤
더보기 버튼이 사라질 때 (마지막 페이지)
turbo-stream action="remove"로 버튼 제거- 포커스된 요소가 사라짐
- 포커스 추적할 대상이 없어서 스크롤 발생 안 함
교훈
1. disabled 속성의 진짜 역할
button.disabled = true는 단순한 버튼 비활성화가 아닙니다:
- 시각적 비활성화: 사용자에게 버튼이 비활성화되었음을 알림
- 기능적 비활성화: 클릭 이벤트가 발생하지 않음
- Focus 관리: 버튼에서 focus를 제거하고, focusable하지 않게 만듦
- 스크롤 방지: 브라우저의 자동 focus 스크롤을 차단
2. 브라우저의 기본 동작 이해
- 브라우저는 접근성을 위해 포커스된 요소를 자동으로 화면에 보이려고 함
- DOM 변경 시 active element의 위치 변화가 스크롤에 영향을 줄 수 있음
disabled상태가 아닌 요소는 여전히 브라우저의 focus 추적 대상
3. 문제 해결 접근법
복잡한 해결책을 찾기 전에:
- 기본 HTML 속성 확인 (disabled, hidden, focus 등)
- 브라우저 개발자 도구로 실제 DOM 상태 확인
- 단순한 원인부터 의심
결론
가장 단순한 해결책이 종종 가장 효과적입니다.
Turbo Stream과 관련된 스크롤 문제를 겪고 있다면, 먼저 기본적인 DOM 상태 관리가 올바르게 되어있는지 확인해보세요. 특히 로딩 상태의 버튼이나 폼 요소들이 제대로 비활성화되어 있는지 말이죠.
복잡한 Turbo 이벤트 핸들링이나 커스텀 스크롤 로직을 구현하기 전에, HTML의 기본 속성들부터 점검하는 것이 시간을 절약하는 지름길일 수 있습니다.
핵심 포인트: button.disabled = true는 단순한 버튼 비활성화가 아니라, 브라우저의 focus 관리와 자동 스크롤 동작을 제어하는 중요한 역할을 합니다.