mimizae 님의 블로그
스크롤 감지 Tab-Bar 구현하기 본문

이번 37기 DIVE SOPT 합동세미나에서 우리 팀, 모바일 웹 3조는기존 아이디어스 서비스의 UX 문제를 분석하고 리디자인하는 과제를 진행했다.
그 중에서도 나는 스크롤 감지 Tab-Bar 컴포넌트 구현을 맡았다! 챌린징한 요소이기에 구현 과정과 그 과정에서의 트러블 슈팅을 남긴다.
🤔 잠깐!! 합동세미나란?
합동세미나는 SOPT 내 서로 다른 파트(기획·디자인·개발 등)가 한 팀을 이루어 짧은 기간 동안 하나의 문제를 정의하고 해결해보는 협업 중심의 세미나이다.
파트 간 역할 분담과 커뮤니케이션을 통해 실제 서비스 제작 과정을 경험하며, 이후 진행되는 대규모 프로젝트를 대비하는 실전형 협업 활동이다!
컴포넌트 요구사항
스크롤 감지 Tab-Bar는 말 그대로 화면 스크롤 위치에 따라 활성화되는 탭을 자동으로 변경해주는 UI 컴포넌트이다.
사용자가 페이지를 스크롤하여 특정 섹션이 뷰포트 영역에 들어오는 순간, 해당 섹션과 연결된 탭이 자동으로 활성 상태로 변경된다.
예를 들어, 아래 이미지처럼 작품 정보 섹션이 화면에 보이는 순간, Tab-Bar에서 작품 정보 탭의 스타일이 활성 상태로 변하고, 만약 사용자가 후기 탭을 직접 클릭하면, 페이지가 매끄럽게 스크롤되며 후기 섹션 위치로 이동하는 방식으로 동작한다.
즉, 단순히 클릭으로 섹션을 이동할 수 있을 뿐 아니라, 스크롤과 탭 활성 상태가 양방향으로 연결되어 동작하는 인터랙티브한 탭바이다.
이 뿐만 아니라, 전체 화면을 스크롤할 때 Tab-Bar 또한 함께 아래로 이동하다가, 페이지 상단의 헤더 영역과 맞닿는 순간 상단에 고정(sticky) 되어 그때부터는 스크롤 감지 및 탭 전환 기능을 수행하는 고정 탭바 역할을 해야 한다.
즉, 처음에는 콘텐츠 흐름에 따라 자연스럽게 위치가 이동하지만, 사용자가 스크롤을 계속 내리면 Tab-Bar가 화면 상단에 고정되어 언제든지 탭을 전환하거나 섹션을 이동할 수 있도록 유지되는 형태인 것이다.

정리하자면, Tab-Bar의 요구사항은 다음과 같다.
- 초기에는 콘텐츠와 함께 스크롤되다가, 헤더 지점에 도달하면 화면 상단에 고정(sticky)되어야 한다.
- 작품 정보 / 후기 / 추천 각 섹션이 뷰포트에 진입하면, Tab-Bar에서 해당 탭이 자동으로 활성(active) 상태로 변경되어야 한다.
- Tab-Bar의 탭을 클릭하면 해당 섹션으로 부드럽게(smooth) 스크롤 이동해야 한다.
이 요구사항들을 검증하기 위해 임시로 작가 정보, 후기, 추천 컴포넌트들을 생성하여 페이지에 배치하고 스크롤 감지 및 탭 전환 기능을 테스트 했다!

임시 컴포넌트 배치 코드
import * as styles from "./test.css";
import { Header } from "./shared/components/header";
import { Content } from "./shared/components/content";
import Section1 from "./shared/components/section1";
import Section2 from "./shared/components/section2";
import Section3 from "./shared/components/section3";
function App() {
return (
<div className={styles.test}>
<Header />
{[...Array(20)].map((_, i) => (
<Content key={i} type="내용" />
))}
<Section1 />
<Section2 />
<Section3 />
</div>
);
}
export default App;
요구사항 구현
이제 Tab-Bar의 UI를 먼저 구현하여 요구사항 1(헤더 아래에서 고정되는 동작)을 만족시켜보자.
export const tabBar = style({
position: "sticky",
top: "5.6rem", // 헤더 높이만큼 띄워서 헤더 바로 아래에 붙도록
display: "flex",
backgroundColor: color.white[100],
borderBottom: `1px solid ${color.gray[200]}`,
zIndex: 10,
});
export const tab = recipe({
base: {
flex: 1,
textAlign: "center",
...typographyVars.body1,
padding: "1.2rem 1rem",
border: "none",
background: "none",
cursor: "pointer",
color: color.gray[300],
transition: "color 0.2s",
position: "relative",
marginBottom: "-1px", // tabBar의 border를 덮기 위해
borderBottom: `2px solid transparent`,
},
variants: {
active: {
true: {
color: color.black[100],
borderBottom: `2px solid ${color.black[100]}`,
},
},
},
});
Sticky 적용 포인트
- position: sticky는 일정 지점까지는 스크롤되다가 해당 위치를 기준으로 고정.
- top: 5.6rem은 헤더 높이 값으로, 헤더 바로 아래에서 고정되도록 설정.
- z-index: 10으로 다른 컴포넌트 위에 오도록 설정.
이렇게 해서 특정 위치, 즉 헤더 아래에 도달하기 전까지는 스크롤에 따라 움직이지만 도달 시 고정되도록 하는 헤더 아래에서 잠기듯 고정되는 UI를 구현했다!

이제 요구사항 2, 스크롤 시 섹션 자동 감지 & 활성 탭 전환 기능을 구현해보자.
요구사항에 따르면, 사용자가 화면을 스크롤할 때 현재 보이는 섹션에 맞추어 탭이 자동으로 활성화되어야 한다!
즉, 작품 정보 / 후기 / 추천 중 어떤 영역이 뷰포트에 들어왔는지를 정확하게 판단해야 한다.
여기서 핵심 키워드는 바로 감지이다.
특정 DOM의 위치를 추적해야 한다는 즉각적인 판단이 들었고, 각 섹션을 참조하기 위해 ref를 활용하는 방식으로 접근했다.
또한 스크롤 이벤트를 감지해야 하므로, 초기 구현 방식으로는 가장 직관적인 window.scroll 이벤트 + offsetTop 비교 방식을 선택했다.
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY + HEADER_HEIGHT + TAB_BAR_HEIGHT;
const productInfoSectionTop = productInfoRef.current?.offsetTop ?? 0;
const reviewSectionTop = reviewRef.current?.offsetTop ?? 0;
const recommendSectionTop = recommendRef.current?.offsetTop ?? 0;
if (scrollY >= recommendSectionTop) setActiveTab("recommend");
else if (scrollY >= reviewSectionTop) setActiveTab("review");
else if (scrollY >= productInfoSectionTop) setActiveTab("product-info");
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
코드 전문
function App() {
const productInfoRef = useRef<HTMLDivElement>(null);
const reviewRef = useRef<HTMLDivElement>(null);
const recommendRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState<
"product-info" | "review" | "recommend"
>("product-info");
// 스크롤 시 현재 섹션 감지
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY + HEADER_HEIGHT + TAB_BAR_HEIGHT;
const productInfoSectionTop = productInfoRef.current?.offsetTop ?? 0;
const reviewSectionTop = reviewRef.current?.offsetTop ?? 0;
const recommendSectionTop = recommendRef.current?.offsetTop ?? 0;
if (scrollY >= recommendSectionTop) {
setActiveTab("recommend");
} else if (scrollY >= reviewSectionTop) {
setActiveTab("review");
} else if (scrollY >= productInfoSectionTop) {
setActiveTab("product-info");
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// 탭 클릭 시 해당 섹션으로 스크롤
const handleTabClick = (tab: "product-info" | "review" | "recommend") => {
const sectionRefMap = {
"product-info": productInfoRef,
review: reviewRef,
recommend: recommendRef,
};
const targetSectionRef = sectionRefMap[tab];
if (targetSectionRef.current) {
const offsetTop =
targetSectionRef.current.offsetTop - HEADER_HEIGHT - TAB_BAR_HEIGHT;
window.scrollTo({
top: offsetTop,
behavior: "smooth",
});
}
};
동작 방식 설명
| 처리 단계 | 내용 |
| 1️⃣ ref 연결 | 각 섹션 요소를 productInfoRef, reviewRef, recommendRef로 참조한다. |
| 2️⃣ 위치 계산 | offsetTop 값을 통해 섹션의 페이지 내 절대 위치를 가져온다. |
| 3️⃣ 스크롤 위치 추적 | window.scrollY에 헤더, 탭바 높이를 더해 실제 기준점을 계산한다. |
| 4️⃣ 조건 비교 | 스크롤 위치와 각 섹션 위치를 비교하여 어떤 섹션이 보이는지 판단한다. |
| 5️⃣ 탭 활성화 | setActiveTab 실행 → UI와 동기화된다. |
이 방식은 구현이 매우 단순하고 직관적이며 별도의 API 사용 없이 브라우저 기본 속성을 활용하여 빠르게 기능 검증이 가능한 구조라는 장점이 있다.
실제로 첫 시도에서는 아주 자연스럽게 정상 동작했다. 스크롤에 따라 탭이 매끄럽게 활성화되는 모습을 확인할 수 있었고, 요구사항을 충족해나가고 있다는 확신을 얻을 수 있었다.
문제 상황
그러나 실제 사용자 환경에서 테스트를 진행해보니, 예상치 못한 문제가 발생했다…!
1. 탭 활성화 타이밍의 딜레이 문제
(위의 두 번째 GIF를 확인하면 더 명확하게 보인다. 🥲)
현재 구현 방식에서는 window의 scroll 이벤트가 발생할 때마다 각 섹션의 offsetTop 값을 계산하여 어떤 섹션이 화면에 보이는지를 실시간으로 판별하도록 구성되어 있다.
문제는 탭을 클릭하여 스크롤이 자동으로 이동하는 상황에서 나타났다.
스크롤 애니메이션이 진행되는 동안 activeTab 상태 업데이트가 미묘하게 늦게 반영되면서, 활성화 탭이 자연스럽게 전환되지 않거나 잠시 동안 이전 탭이 유지되는 플리커(flicker) 현상이 발생했다.
사용자 입장에서는 탭 이동의 시각적 흐름이 매끄럽지 않아 UX 측면에서 체감되는 불편이 상당했다.
2. Scroll Event로 인한 성능 부하 가능성
스크롤 이벤트는 화면을 움직이는 동안 수백에서 수천 번까지 반복적으로 호출된다.
그런데 현재 로직에서는 이벤트가 발생할 때마다
- 각 섹션의 offset 계산
- 활성 탭 조건 비교
- 상태 업데이트 검사
를 매번 수행하고 있다.
이와 같은 구조는 불필요한 연산을 지속적으로 발생시키므로 결국 성능적인 낭비가 필연적으로 발생할 수밖에 없다.
정리하자면…
기능 자체는 정상 동작하지만, UX / 성능 / 안정성 모든 측면에서 아쉬움이 있는 구현 방식이었다고 판단했다.
따라서, 보다 최적화된 방식의 개선이 필요하다는 결론에 도달했다.
해결!
const HEADER_HEIGHT = 56;
const TAB_BAR_HEIGHT = 46;
function App() {
const productInfoRef = useRef<HTMLDivElement>(null);
const reviewRef = useRef<HTMLDivElement>(null);
const recommendRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState<
"product-info" | "review" | "recommend"
>("product-info");
// IntersectionObserver로 현재 섹션 감지
useEffect(() => {
const observerOptions = {
// 섹션이 화면의 중앙 즈음에 올 때
rootMargin: `-${window.innerHeight / 2}px 0px -${window.innerHeight / 2}px 0px`,
threshold: 0,
};
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const sectionId = entry.target.getAttribute("data-section") as
| "product-info"
| "review"
| "recommend";
if (sectionId) {
setActiveTab(sectionId);
}
}
});
};
const sectionObserver = new IntersectionObserver(
observerCallback,
observerOptions
);
// 각 섹션 관찰 시작
if (productInfoRef.current) sectionObserver.observe(productInfoRef.current);
if (reviewRef.current) sectionObserver.observe(reviewRef.current);
if (recommendRef.current) sectionObserver.observe(recommendRef.current);
return () => {
sectionObserver.disconnect();
};
}, []);
// ... 동일
};
IntersectionObserver의 도입
고민 끝에 선택한 해결책은 브라우저 네이티브 API인 IntersectionObserver였다.
이 방법은 각 섹션 요소가 화면의 특정 영역에 들어오거나 나갈 때에만 콜백을 호출하도록 동작하기 때문에, 불필요한 이벤트 호출을 대폭 줄일 수 있다.
구체적으로 이 접근 방식의 장점은 다음과 같다.
- 스크롤 중 이벤트가 난사되지 않는다. 즉, 연속적인 scroll 이벤트를 기반으로 한 과도한 처리가 발생하지 않는다.
- 각 프레임마다 위치를 반복 계산하지 않으므로 불필요한 계산 반복이 사라진다.
- 섹션이 뷰포트의 관심 영역에 진입하는 정확한 시점에 콜백이 실행되므로 활성화 타이밍의 정밀도가 향상된다.
무엇보다 중요한 점은, 사용자가 실제로 보고 있다고 느끼는 순간에 탭이 전환되도록 구현할 수 있다는 사실이다!
이를 위해 IntersectionObserver의 rootMargin을 화면의 중앙 기준으로 설정하여, 사용자의 시각적 경험과 UI 동작이 일치하도록 세심하게 조정했다.
기존 scroll event 방식과 IntersectionObserver 도입의 차이점을 표로 정리해보았다.
| 개선 항목 | scroll event 방식 | IntersectionObserver 방식 |
| 탭 활성화 타이밍 | 딜레이 약간 있음 | 실시간 자연스러움 |
| 성능 부하 | 이벤트 난사 | 필요할 때만 실행 |
| 코드 안정성 | 데이터 변경에 취약 | 화면 기반 감지로 안정적 |
| 스크롤 UX | 탭 클릭 시 깜빡임 | 심리스 전환 |
사용 예시
IntersectionObserver 기반 감지 로직을 구성한 뒤, 이를 보다 재사용 가능하고 깔끔한 구조로 관리하기 위해 커스텀 훅 형태로 분리했다.
아래와 같이 useScrollableTabs 훅은 각 섹션을 참조할 ref와 활성화된 탭 상태, 그리고 탭 클릭 시 동작 함수 등을 외부로 반환하도록 했다!
import { useEffect, useRef, useState } from "react";
const HEADER_HEIGHT = 56;
const TAB_BAR_HEIGHT = 46;
export function useScrollableTabs() {
const productInfoRef = useRef<HTMLDivElement>(null);
const reviewRef = useRef<HTMLDivElement>(null);
const recommendRef = useRef<HTMLDivElement>(null);
...
return {
productInfoRef,
reviewRef,
recommendRef,
activeTab,
handleTabClick,
};
}
이제 해당 훅을 사용하는 컴포넌트에서는 아래와 같이 반환된 값을 구조 분해하여 간단하고 직관적으로 적용할 수 있다. 🤩
function App() {
const { productInfoRef, reviewRef, recommendRef, activeTab, handleTabClick } =
useScrollableTabs();
return (
<div className={styles.test}>
<Header />
{/* 내용들 - 탭바 위에 있을 캐러셀 부분 ㅎㅎ */}
{[...Array(20)].map((_, i) => (
<Content key={i} type="내용" />
))}
{/* 탭바 - sticky로 헤더에 닿으면!! 고정 */}
<TabBar activeTab={activeTab} onTabClick={handleTabClick} />
{/* 작품 정보, 후기, 추천 섹션들 - 탭바 아래 */}
<div ref={productInfoRef} data-section="product-info">
<Section1 />
</div>
<div ref={reviewRef} data-section="review">
<Section2 />
</div>
<div ref={recommendRef} data-section="recommend">
<Section3 />
</div>
</div>
);
}
export default App;
이와 같은 방식으로 적용하면,
- 각 섹션의 ref 관리
- 활성 탭 상태 제어
- 탭 클릭 시 스크롤 이동 처리
등의 로직이 모두 커스텀 훅 내부로 캡슐화되며, 이를 사용하는 컴포넌트는 탭 기능을 매우 간결하게 사용할 수 있는 구조로 정리된다.
즉! 선언적으로 읽기 쉽고, 유지보수 또한 수월한 형태로 개선되었다. 👍🏻
회고!
이 컴포넌트를 구현하면서 느낀 점은...
1. 단순히 동작한다고 해서 UX가 완성되는 것은 아니다
처음 방식은 충분히 작동했지만, 미묘한 딜레이 하나가 전체 인상을 좌우했다.
진짜 좋은 UI는 보이지 않는 디테일을 챙기는 과정에서 나온다는 것을 배웠다!
2. 브라우저가 제공하는 최적화 도구를 적극 활용해야 한다
React state만으로 해결하려 하지 말고, IntersectionObserver처럼 플랫폼이 제공하는 기능을 활용하는 것이 훨씬 효율적일 수 있음을 깨달았다.
마지막으로, 실제로 작업했던 PR 링크를 남기겠다.
'FE' 카테고리의 다른 글
| Tanstack Query의 데이터 최신화 (0) | 2025.12.14 |
|---|---|
| 낙관적 업데이트 적용하기 (0) | 2025.12.05 |
| 실시간 알림 구현하기 (0) | 2025.11.16 |
| Virtual DOM이란? (0) | 2025.11.07 |
| SEO에 대한 고찰 (2) | 2025.10.16 |