[TIL/React] 2024/07/12
reference: 1) https://brunch.co.kr/@theopenproduct/58 (무한 스크롤에 대한 정의, 페이지네이션과의 비교를 중심으로) 2) https://tech.kakaoenterprise.com/149 (카카오 엔터프라이즈 포스팅) 3)

reference: 1) https://brunch.co.kr/@theopenproduct/58 (무한 스크롤에 대한 정의, 페이지네이션과의 비교를 중심으로) 2) https://tech.kakaoenterprise.com/149 (카카오 엔터프라이즈 포스팅) 3) https://www.bucketplace.com/post/2020-09-10-%EC%98%A4%EB%8A%98%EC%9D%98%EC%A7%91-%EB%82%B4-%EB%AC%B4%ED%95%9C%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B0%9C%EB%B0%9C%EA%B8%B0/ (오늘의 집 무한 스크롤 개발기) 4) https://medium.com/myrealtrip-product/%EC%83%81%ED%99%A9%EC%97%90-%EB%A7%9E%EB%8A%94-%EB%A1%9C%EB%94%A9-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-2018af51c197 (미디엄, 상황에 맞는 로딩 인디케이터 적용하기) 5) https://blog.hyeyoonjung.com/2019/01/09/intersectionobserver-tutorial/
✅ 무한 스크롤(Infinite Scrolling) 톺아보기
1. 무한 스크롤이란? 🚀
1-1. 무한 스크롤의 정의 ✍️
무한 스크롤이란, 스크롤이 페이지의 끝에 도달했을 때 자동으로 다음 데이터를 요청하여 받아오는 UX 방식을 의미한다. 별도의 페이지 이동 없이 데이터를 지속적으로 불러오기 때문에 직관적이며 편리하다는 장점을 갖는다. 유튜브, 페이스북, 인스타그램 등 많은 서비스들이 모바일과 웹에서 콘텐츠를 제시하는 방식으로서 '무한 스크롤' 기법을 활용하고 있다.
1-2. 무한 스크롤 VS 페이지네이션 ✍️
무한 스크롤과 페이지네이션은, 결국 콘텐츠 데이터를 사용자에게 보여주는 UX 방식이라는 점에서는 동일하다. 다만, 페이지네이션은 전체 콘텐츠를 페이지를 기준으로 적절한 분량으로 나누어 사용자에게 콘텐츠를 제시한다.
페이지네이션의 장점은, 일단 전체 페이지의 수(=전체 볼륨)를 사용자가 확인할 수 있기 때문에, 사용자가 콘텐츠에 대한 통제감을 느끼며 탐색을 진행할 수 있다. 또한 특정한 규칙에 따라 콘텐츠가 정렬되기에 콘텐츠의 정확한 인덱스를 파악할 수 있으며, 페이지마다 제시되는 콘텐츠의 양이 적절히 정해져 있기 때문에 빠른 로딩 속도를 제공할 수 있다.
1-3. 무한 스크롤을 프로젝트에 적용해야 하는 이유 ✍️
페이지네이션은 사용자가 처음부터 목적을 가지고 콘텐츠를 탐색할 때 유용하다. 특정한 기준에 따라서 콘텐츠가 정렬되어 있기 때문에 목적에 맞는 자료를 쉽게 찾을 수 있고, 언제든지 원하는 위치로 돌아갈 수 있기 때문이다.
하지만 이번에 진행할 MERN stack 프로젝트에서는, CRUD를 기반으로 콘텐츠를 제시하는 것이 주 목적이기 때문에 무한 스크롤 UX 기법을 연습해 보는 것이 더 적절할 것이라고 판단했다.
2. 이벤트 처리 방법 🚀
2-1. Window 객체의 scroll event ✍️
export interface PaginationResponse<T> {
contents: T[]; // 현재 페이지에 포함된 데이터 리스트
pageNumber: number; // 현재 페이지 번호
pageSize: number; // 페이지 크기
totalPages: number; // 전체 페이지 수
totalCount: number; // 전체 아이템 수
isLastPage: boolean; // 마지막 페이지 여부
isFirstPage: boolean; // 첫 페이지 여부
} Typescript로 작성된 인터페이스 정의이다. 위 인터페이스를 통해, 페이지네이션 된 API 응답을 처리할 수 있게 될 것이다.
다음은 모킹 API이다.
// 0부터 1023까지의 숫자를 포함하는 배열을 생성하고 각 요소를 User 객체로 변환
const users = Array.from(Array(1024).keys()).map(
(id): User => ({
id,
name: `denis${id}`,
})
);
// 핸들러 배열에는 REST API의 엔드포인트와 그에 대한 응답을 정의하는 함수가 포함됨
const handlers = [
// '/users' 경로에 대한 GET 요청을 처리하는 핸들러
rest.get('/users', async (req, res, ctx) => {
// 요청 URL에서 searchParams를 추출
const { searchParams } = req.url;
// 쿼리 파라미터에서 size와 page를 추출하여 숫자로 변환
const size = Number(searchParams.get('size'));
const page = Number(searchParams.get('page'));
// 전체 유저 수
const totalCount = users.length;
// 전체 페이지 수를 계산 (전체 유저 수를 페이지 크기로 나눈 후 반올림)
const totalPages = Math.round(totalCount / size);
// 응답 생성 및 반환
return res(
// 응답 상태 코드를 200으로 설정 (성공)
ctx.status(200),
// JSON 형태로 응답을 생성
ctx.json<PaginationResponse<User>>({
// 요청된 페이지에 해당하는 유저 리스트를 slice 메서드를 사용해 가져옴
contents: users.slice(page * size, (page + 1) * size),
// 요청된 페이지 번호
pageNumber: page,
// 각 페이지의 크기 (유저 수)
pageSize: size,
// 전체 페이지 수
totalPages,
// 전체 유저 수
totalCount,
// 현재 페이지가 마지막 페이지인지 여부를 나타냄
isLastPage: totalPages <= page,
// 현재 페이지가 첫 페이지인지 여부를 나타냄
isFirstPage: page === 0,
}),
// 응답을 500ms 지연시킴
ctx.delay(500)
);
}),
];
다음은 프론트엔드 React 코드다.
// 페이지 크기를 계산, 카드 크기(CARD_SIZE)와 뷰포트 너비에 따라 동적으로 설정
const PAGE_SIZE = 10 * Math.ceil(visualViewport.width / CARD_SIZE);
function UsersPage() {
// 페이지 상태를 관리하기 위한 useState 훅
const [page, setPage] = useState(0);
// 유저 데이터를 저장할 상태
const [users, setUsers] = useState<User[]>([]);
// 데이터 로딩 상태를 관리하기 위한 상태
const [isFetching, setFetching] = useState(false);
// 다음 페이지가 있는지 여부를 관리하기 위한 상태
const [hasNextPage, setNextPage] = useState(true);
// 유저 데이터를 비동기적으로 가져오는 함수
const fetchUsers = useCallback(async () => {
// axios를 사용하여 '/users' 경로에 GET 요청을 보냄, 쿼리 파라미터로 페이지와 크기를 전달
const { data } = await axios.get<PaginationResponse<User>>('/users', {
params: { page, size: PAGE_SIZE },
});
// 현재 유저 리스트에 새로운 데이터를 추가
setUsers(users.concat(data.contents));
// 다음 페이지 번호를 설정
setPage(data.pageNumber + 1);
// 다음 페이지가 있는지 여부를 설정
setNextPage(!data.isLastPage);
// 로딩 상태를 false로 설정
setFetching(false);
}, [page]);
// 컴포넌트가 마운트될 때와 스크롤 이벤트를 처리하기 위한 useEffect 훅
useEffect(() => {
const handleScroll = () => {
const { scrollTop, offsetHeight } = document.documentElement;
// 스크롤이 페이지 하단에 도달했을 때 로딩 상태를 true로 설정
if (window.innerHeight + scrollTop >= offsetHeight) {
setFetching(true);
}
};
// 초기 로딩 상태를 true로 설정
setFetching(true);
// 스크롤 이벤트 리스너 추가
window.addEventListener('scroll', handleScroll);
// 컴포넌트가 언마운트될 때 스크롤 이벤트 리스너 제거
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// 로딩 상태가 변경될 때와 다음 페이지가 있을 때 유저 데이터를 가져오는 useEffect 훅
useEffect(() => {
// 로딩 상태가 true이고 다음 페이지가 있을 때 유저 데이터를 가져옴
if (isFetching && hasNextPage) fetchUsers();
// 다음 페이지가 없을 때 로딩 상태를 false로 설정
else if (!hasNextPage) setFetching(false);
}, [isFetching]);
return (
<Container>
{/* 유저 데이터를 카드 형태로 렌더링 */}
{users.map((user) => (
<Card key={user.id} name={user.name} />
))}
{/* 로딩 상태일 때 로딩 컴포넌트를 렌더링 */}
{isFetching && <Loading />}
</Container>
);
}코드를 처음부터 다 이해할 필요는 없다. 아니, 사실 처음부터 다 이해할 수는 없다. 중요한 것은, 무한 스크롤이라는 건 '특정 페이지 하단에 도달' 했을 때 'API 요청'이 실행된다는 점이다. 그렇다면 'Window 객체의 scroll event'를 통해 '특정 페이지 하단에 도달'을 어떻게 구현했는지를 정확히 이해하는 것이 핵심이다.
useEffect(() => {
const handleScroll = () => {
const { scrollTop, offsetHeight } = document.documentElement
if (window.innerHeight + scrollTop >= offsetHeight) {
setFetching(true)
}
}
setFetching(true)
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
위 코드가 알짜배기라는 것이다. 그러면 innerHeight, scrollTop, offsetHeight라는 재료가 무엇을 의미하는지, 그리고 그 재료들로 요리된 ``window.innerHeight + scrollTop >= offsetHeight``가 무엇인지만 알면 되겠네.
innerHeight: 브라우저 창의 내부 뷰포트 높이 scrollTop: 현재 페이지의 스크롤 위치(=사용자가 페이지를 스크롤 하여 위로 올린 정도) offsetHeight: 페이지의 전체 높이
즉, ``브라우저 창의 내부 뷰포트 높이 + 현재 페이지의 스크롤 위치 >= 페이지의 전체 높이``를 의미한다.
2-2. Intersection Observer API ✍️

자, 그렇다면 Window 객체의 scroll을 쓰면 될 것이지, Intersection Observer API는 또 왜 쓰냐? 왜 나를 자꾸 힘들게 하는 것이냐?
기존 scroll 이벤트는 document에 스크롤 이벤트를 등록하고, 특정 지점을 관찰하여 엘리먼트가 위치에 도달했을 때 실행할 콜백 함수를 등록하는 방식으로 구현되어 있다. 하지만 scroll 이벤트는 단시간에 수백 번, 수천 번 호출될 수 있다. 동시에 스크롤 이벤트는 ``동기적`으로 실행되기 때문에 메인 스레드에 영향을 주게 된다. 게다가 특정 지점을 관찰하기 위해서는 getBoundingClientRect() 함수를 사용해야 하는데, 이 함수는 `리플로우(reflow) 현상``이 발생한다는 단점이 있다. 리플로우(reflow)란 브라우저가 웹 페이지의 일부 또는 전체를 다시 그려야 하는 경우 발생한다.
Intersection Observer API를 사용하면 위와 같은 문제를 해결할 수 있다. ``비동기적`으로 실행되기 때문에 메인 스레드에 영향을 주지 않으면서 변경 사항을 관찰할 수 있다. 또한 `IntersectionObserverEntry`의 속성을 활용하면 getBoundingClientRect()를 호출한 것과 같은 결과를 알 수 있기 때문에 따로 getBoundingClientRect() 함수를 호출할 필요가 없어 `리플로우 현상을 방지``할 수 있게 된다.
3. 성능 최적화(Debounce & Throttle) 🚀
그리고 무한 스크롤과 관련하여 알고 있어야 할 개념에 두 가지가 있다. Debounce와 Throttle이다. Debounce와 Throttle은 둘 다 함수의 호출을 제어(=지연)하여 성능 최적화나 이벤트 처리를 관리하는 기술이다.
각각의 용어가 어떤 함의를 갖고 있는지 가볍게 살펴보겠다.
3-1. Debounce ✍️
Debounce는 연이어 발생하는 이벤트에서 '마지막 이벤트'가 발생한 후 일정 시간이 지난 후에 해당 이벤트를 처리하는 기술이다. 주로 입력 필드에서 사용자의 입력을 처리하거나, 스크롤 이벤트 등에서 발생할 수 있는 연속적인 이벤트 처리를 제어하는 데 유용하다.
3-2. Throttle ✍️
Throttle은 연속적인 이벤트의 발생을 제어하여 '일정 시간 간격'으로 이벤트 핸들러가 실행되도록 하는 기술이다. 주로 스크롤 이벤트나 DOM 요소의 드래그 이벤트와 같이 빈번하게 발생하는 이벤트를 제한하는 데 사용된다.
3-3. 그래서 하고 싶은 말이 뭔데 ✍️
결국 Debounce와 Throttle은 모두 함수의 호출을 지연시키는 것이다. 준비되기 전까지 호들갑 떨지 말라는 거다. 그런데 이제 Debounce를 '간격'이라는 그릇에 담는 순간 Throttle이 되는 것이다.
4. UI/UX 고려사항(feat.로딩 인디케이터) 🚀
무한 스크롤을 공부하는 김에, 로딩 인디케이터에 관한 내용도 가볍게 살펴봤다. 마이리얼트립에서 사용하는 로딩 인디케이터를 기준으로 학습했다. 간단하게 스크린 샷을 아카이빙 하겠다.








5. 예제 코드 및 구현 화면 🚀
import React, { useState, useEffect, useRef, useCallback } from "react";
import axios from "axios";
import styled from "styled-components";
const AppContainer = styled.div`
text-align: center;
padding: 20px;
`;
const Title = styled.h1`
margin-bottom: 20px;
`;
const CardContainer = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
`;
const Card = styled.div`
border: 1px solid #ccc;
border-radius: 8px;
padding: 10px;
width: 150px;
text-align: left;
transition: transform 0.3s;
&:hover {
transform: translateY(-10px);
cursor: pointer;
}
`;
const CardImage = styled.img`
max-width: 100%;
border-radius: 4px;
`;
const CardText = styled.p`
margin: 10px 0 0;
font-size: 14px;
`;
const LoadingText = styled.p`
margin-top: 20px;
`;
const EndText = styled.p`
margin-top: 20px;
color: grey;
`;
const App = () => {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observer = useRef();
const fetchItems = async (page) => {
setLoading(true);
try {
const response = await axios.get(
`https://jsonplaceholder.typicode.com/photos?_page=${page}&_limit=10`
);
setItems((prevItems) => [...prevItems, ...response.data]);
setHasMore(response.data.length > 0);
} catch (error) {
console.error("Error fetching data:", error);
}
setLoading(false);
};
useEffect(() => {
fetchItems(page);
}, [page]);
const lastItemRef = useCallback(
(node) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
setPage((prevPage) => prevPage + 1);
}
});
if (node) observer.current.observe(node);
},
[loading, hasMore]
);
return (
<AppContainer>
<Title>Infinite Scroll Cards</Title>
<CardContainer>
{items.map((item, index) => (
<Card
key={item.id}
ref={items.length === index + 1 ? lastItemRef : null}
>
<CardImage src={item.thumbnailUrl} alt={item.title} />
<CardText>{item.title}</CardText>
</Card>
))}
</CardContainer>
{loading && <LoadingText>Loading...</LoadingText>}
{!hasMore && <EndText>No more items to load</EndText>}
</AppContainer>
);
};
export default App;

✅ 회고
가장 경쟁력 있는 상품은 '서사(narrative)'입니다.
성장과 좌절이 진실하게 누적된 나의 기록은 유일무이한 나만의 서사입니다.
시대예보(송길영) 中
More to read
AI&ML 기초
Reference: https://bettermesol.github.io/ml/2019/09/16/ai-ml-dl/AI: 기계가 사람처럼 생각하고 판단하게 만드는 가장 넓은 범주의 기술입니다.ML: 데이터를 학습하여 스스로 규칙을 찾아내는 AI의 한 분야로,
'AI Agent Economy'Novitas : AI Agent가 지갑을 가지는 세상
얼마 전, 미래에셋증권 리서치 리포트(올해는 이더리움이다: 에이전트 시대의 Near Automata)를 접하게 되었습니다. AI Agent를 인간과 함께할 경제 주체로 바라보는 시각에 적잖이 충격을 받았더랬죠.한 가지 짚고 넘어갈 부분이 있습니다. 우리가 흔히 'AI'
'ERC-8004'Novitas: AI 에이전트 경제 주체
Web 4.0을 한 문장으로 정의하면 Sovereign Transact입니다.AI가 인간의 허락 없이 지갑을 소유하고, 결제를 수행하며, 인프라를 통제하는 주권적 경제 주체가 되는 세계입니다. Web 3.0이 블록체인 기반의 탈중앙화를 실현했다면, Web 4.0은 그