인피니트 스크롤 구현 (Spring + React)
안녕하세요!
이번 게시글에서는 Spring + React 프로젝트의 인피니트 스크롤을 활용한 페이징 구현 방법을 공유하려 합니다.
📌 트래픽 최적화에 대한 고민
웹 서비스를 개발할 때, 특히 SNS와 같은 데이터 중심 애플리케이션에서는 트래픽 최적화가 중요한 문제입니다.
저 역시 TalkHub를 개발하면서 트래픽 감소 및 성능 최적화에 대한 고민을 많이 했습니다.
제가 개발한 프로젝트에서 가장 많은 트래픽이 발생할 가능성이 있는 기능은 다음과 같습니다.
- 게시글 리스트 조회 (피드 로딩)
- 메시지 리스트 조회 (채팅 내역 불러오기)
초기에는 한 번에 모든 데이터를 불러오는 방식을 고려했지만, 데이터가 많아질수록 서버 부하가 커지고 사용자 경험(UX)도 저하될 위험이 있었습니다.
따라서, 페이징(Pagination) 기법을 활용해 트래픽을 최적화하는 방향으로 설계를 진행했습니다.
📌 인피니트 스크롤을 활용한 페이징 구현
기존의 제가 사용한 방식은 페이지 번호를 기준으로 일정 개수의 데이터를 불러오는 페이징 방식입니다.
하지만, 인스타그램이나 페이스북처럼 스크롤을 내릴 때 추가 데이터를 불러오는 방식을 적용하고 싶었습니다.
이를 위해 인피니트 스크롤(Infinite Scroll) 기법을 사용했습니다.
기존 방식
- 새로운 페이지 요청 시, 기존 데이터를 덮어씀
- 페이지 이동이 필요함
인피니트 스크롤
- 스크롤이 페이지 하단에 도달하면 새로운 데이터를 불러와 기존 리스트에 추가
- 기존 데이터를 유지하면서 새로운 데이터를 "이어붙이는" 방식
📌 무한 스크롤 페이징 기능 구현 (Backend)
백엔드에서의 코드는 기존의 페이징 방식과 동일합니다.
Controller
@GetMapping("/latest-list")
public ResponseEntity<? super GetLatestBoardListResponseDto> getLatestBoardList(
@RequestParam(value = "page", defaultValue = "0") int page, // 페이지 번호
@RequestParam(value = "size", defaultValue = "5") int size, // 가져올 리스트 갯수
@AuthenticationPrincipal String email
) {
ResponseEntity<? super GetLatestBoardListResponseDto> response = boardService.getLatestBoardList(email, page, size);
return response;
}
- 클라이언트에서 GET /latest-list?page=0&size=5 요청을 보내면, page 값과 size 값을 받아서 특정 페이지의 게시글을 가져옵니다.
Service
@Override
@Transactional
public ResponseEntity<? super GetLatestBoardListResponseDto> getLatestBoardList(String userEmail, int page, int size) {
try {
Pageable pageable = PageRequest.of(page, size);
Page<BoardListViewEntity> boardListViewEntities = boardListViewRepository.findFeedOrderByPriorityWithPaging(userEmail, pageable);
return GetLatestBoardListResponseDto.success(boardListViewEntities);
} catch (Exception exception) {
exception.printStackTrace();
return ResponseDto.databaseError();
}
}
- PageRequest.of(page, size)를 사용하여 페이징을 적용합니다.
- boardListViewRepository.findFeedOrderByPriorityWithPaging(userEmail, pageable)을 호출하여 페이지 단위로 데이터를 조회합니다.
ResponseDto
@Getter
public class GetLatestBoardListResponseDto extends ResponseDto {
private List<BoardListItem> latestList;
private GetLatestBoardListResponseDto(Page<BoardListViewEntity> boardListViewEntities) {
super(ResponseCode.SUCCESS, ResponseMessage.SUCCESS);
this.latestList = boardListViewEntities.stream()
.map(BoardListItem::new)
.collect(Collectors.toList());
}
public static ResponseEntity<GetLatestBoardListResponseDto> success(Page<BoardListViewEntity> boardListViewEntities) {
GetLatestBoardListResponseDto result = new GetLatestBoardListResponseDto(boardListViewEntities);
return ResponseEntity.status(HttpStatus.OK).body(result);
}
}
- latestList는 BoardListItem 객체 리스트이며, 이를 stream()을 이용해 변환합니다.
- Page<BoardListViewEntity> 데이터를 받아 GetLatestBoardListResponseDto로 변환 후 응답합니다.
API 요청
GET http://{서버 URL}/latest-list?page=0&size=5
- page=0 → 첫 번째 페이지
- size=5 → 한 번에 5개의 게시글 로드
- 클라이언트는 page 값을 증가시켜서 page=1, page=2... 형식으로 API 요청을 보냅니다.
📌 무한 스크롤 페이징 기능 구현 (Frontend)
최신 게시글 목록 요청 (Axios)
export const getLatestBoardListRequest = async (page: number, size: number, accessToken: string) => {
const result = await axios.get(GET_LATEST_BOARD_LIST_URL(), {
...authorization(accessToken),
params: {
page: page,
size: size,
}
})
.then(response => {
const responseBody: GetLatestBoardListResponseDto = response.data;
return responseBody;
})
.catch(error => {
if (!error.response) return null;
const responseBody: ResponseDto = error.response.data;
return responseBody;
})
return result;
}
상태 정의
// state: BoardListItem 상태
const [boardList, setBoardList] = useState<BoardListItem[]>([]);
// state: 페이지 번호
const [page, setPage] = useState(0);
// state: 데이터가 더 있는지 여부
const [hasMore, setHasMore] = useState(true);
// state: IntersectionObserver가 감지할 target ref
const targetRef = useRef<HTMLDivElement | null>(null);
// state: IntersectionObserver의 인스턴스를 저장하는 ref
const observerRef = useRef<IntersectionObserver | null>(null);
응답 처리 함수
// function: get latest board response 처리 함수
const getLatestBoardListResponse = (responseBody: GetLatestBoardListResponseDto | ResponseDto | null) => {
if (!responseBody) return;
const { code } = responseBody;
if (code === 'DBE') alert('데이터베이스 오류입니다.');
if (code !== 'SU') return;
const { latestList } = responseBody as GetLatestBoardListResponseDto;
if (latestList.length === 0) {
setHasMore(false);
} else {
setBoardList((prevList) => {
const newList = [...prevList, ...latestList];
return newList.filter((item, index, self) =>
index === self.findIndex((t) => t.boardNumber === item.boardNumber)
);
});
}
};
- getLatestBoardListResponse: 서버 응답을 처리하는 함수.
- 데이터가 없으면 hasMore를 false로 설정하여 추가 요청을 중단.
- 기존 boardList에 새 데이터를 추가하면서 boardNumber 기준으로 중복을 제거.
데이터 불러오기 함수
// function: 데이터 불러오기
const fetchMoreData = useCallback(async () => {
if (!hasMore) return;
const responseBody = await getLatestBoardListRequest(page + 1, 5, cookies.accessToken);
getLatestBoardListResponse(responseBody);
setPage((prev) => prev + 1);
}, [hasMore, page, cookies.accessToken]);
- page + 1을 이용해 다음 페이지 데이터를 요청하고, 상태를 업데이트.
- hasMore가 false이면 추가 요청을 하지 않음.
처음 가져올 데이터
// effect: 처음 데이터 로드
useEffect(() => {
getLatestBoardListRequest(page, 5, cookies.accessToken).then(getLatestBoardListResponse);
}, [cookies.accessToken, page]);
- useEffect를 사용하여 초기 데이터를 불러옴.
- page가 변경될 때마다 새로운 데이터를 가져옴.
Intersection Observer로 무한 스크롤 함수
// effect: IntersectionObserver 감지 설정
useEffect(() => {
// Create the observer instance
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchMoreData();
}
},
{ threshold: 0.5 } // 50%가 화면에 보일 때 트리거
);
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [fetchMoreData]);
- IntersectionObserver를 생성하여 targetRef가 화면에 50% 이상 보이면 fetchMoreData 호출.
- observerRef.current.disconnect()를 통해 컴포넌트가 언마운트될 때 감지 중지.
관찰자 연결/해제 처리 함수
// effect: 대상 요소 또는 관찰자가 변경될 때 관찰자 연결/연결 해제
useEffect(() => {
// When target element becomes available or changes
if (targetRef.current && observerRef.current) {
observerRef.current.observe(targetRef.current);
}
return () => {
if (targetRef.current && observerRef.current) {
observerRef.current.unobserve(targetRef.current);
}
};
}, [targetRef.current, observerRef.current]);
- targetRef.current가 변경될 때마다 IntersectionObserver가 해당 요소를 감지하도록 설정.
- observerRef.current.unobserve(targetRef.current)을 이용해 이전 요소 감지를 해제.
랜더링
<div className='main-contents-box'>
{boardList.length === 0 ? (
<div className='main-current-contents-nothing'>{'게시물이 없습니다.'}</div>
) : (
<div className='main-current-contents'>
{boardList.map((boardListItem) => (
<BoardItem key={boardListItem.boardNumber} boardListItem={boardListItem} />
))}
{hasMore &&
<div ref={targetRef} className='loading-indicator'>
<img className='loading-gif' src={loadingGif} alt="로딩 중" />
</div>
}
{!hasMore && <div className='no-more-items'>{'더 이상 불러올 게시물이 없습니다.'}</div>}
</div>
)}
</div>
주의할 점
IntersectionObserver를 사용할 때, 감지하려는 요소가 화면에 제대로 보이지 않으면 감지가 되지 않는 문제가 발생할 수 있습니다.
특히, targetRef를 감지하려면 해당 요소가 화면 내에 제대로 표시되어야 하는데, 이를 위해서는 그 요소를 감싸는 부모 요소의 스타일도 중요합니다.
저 같은 경우 targetRef를 감싸는 main-current-contents의 높이를 설정하지 않아서 한참 동안 문제를 해결하지 못했었습니다. main-current-contents의 높이를 100%로 지정해주었더니 IntersectionObserver가 targetRef 요소를 제대로 감지할 수 있었습니다.
IntersectionObserver가 제대로 동작하지 않는다면,
감지 대상 요소가 화면에 보이도록 스타일 속성을 올바르게 설정했는지 확인해보세요.
📌 핵심 정리
- 페이징을 활용한 트래픽 최적화
- Pageable을 활용한 효율적인 데이터 조회
- 클라이언트에서 page 값을 변경하여 추가 데이터 요청
- Intersection Observer를 활용한 무한 스크롤 기능
- 중복 데이터 제거 및 hasMore 상태를 활용한 최적화
이상으로 이번 포스팅을 마치겠습니다.
제 게시물이 다른 분들에게도 도움이 되면 좋겠네요. 다음 게시물에서 또 인사드릴게요.
행복한 하루 되세요!