J2ong 님의 블로그

인피니트 스크롤 구현 (Spring + React) 본문

개인 프로젝트

인피니트 스크롤 구현 (Spring + React)

J2ong 2025. 3. 16. 22:50

안녕하세요!

이번 게시글에서는  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가 제대로 동작하지 않는다면,

감지 대상 요소가 화면에 보이도록 스타일 속성을 올바르게 설정했는지 확인해보세요.

 


 

📌 핵심 정리

  1. 페이징을 활용한 트래픽 최적화
  2. Pageable을 활용한 효율적인 데이터 조회
  3. 클라이언트에서 page 값을 변경하여 추가 데이터 요청
  4. Intersection Observer를 활용한 무한 스크롤 기능
  5. 중복 데이터 제거 및 hasMore 상태를 활용한 최적화

 


 

 

이상으로 이번 포스팅을 마치겠습니다.

제 게시물이 다른 분들에게도 도움이 되면 좋겠네요. 다음 게시물에서 또 인사드릴게요.

행복한 하루 되세요!