본문 바로가기
iOS/트러블슈팅

페이지 단위 조회로 인증목록 응답성 개선하기

by hong7 2026. 4. 13.

두게더 iOS 프로젝트를 개발하면서, 사용자가 화면에 들어왔을 때 로딩이 오래 걸리거나 스크롤이 매끄럽지 않게 느껴지는 경험이 생각보다 UX에 큰 영향을 준다는 걸 자주 느꼈어요.

 

특히 인증목록처럼 데이터가 계속 쌓이는 화면은 더 그랬어요.

처음 진입했을 때 목록이 늦게 뜨거나, 한참 스크롤하다가 화면 흐름이 끊기면 사용자는 단순히 “조금 느리다”가 아니라 “앱이 답답하다”는 인상을 받기 쉬웠거든요.

 

두게더의 인증목록도 한동안은 그런 부담이 있었어요. 인증 데이터가 많아질수록 처음 화면에 진입할 때 한 번에 처리해야 하는 데이터 양이 커졌고, 그만큼 초기 응답성과 탐색 흐름에도 영향을 주기 시작했어요.

 

그래서 이번 글에서는 두게더 iOS에서 인증목록 조회 방식을 페이지 단위 조회로 바꾸면서, 왜 이 개선이 필요했는지, 그리고 어떻게 더 자연스러운 목록 탐색 경험을 만들려고 했는지 정리해보려고 해요.

문제의 발견

처음에는 인증목록도 단순한 리스트 화면이라고 생각했어요.

데이터를 받아와서 화면에 보여주면 되는 구조라고 보기 쉬웠죠.

 

그런데 실제로는 인증 데이터가 누적될수록 초기 진입에서 처리해야 하는 데이터 양이 점점 커졌어요.

 

이 구조에서는 사용자가 아직 보지도 않은 데이터까지 한 번에 가져오고, 가공하고, 화면에 올릴 준비를 해야 했어요.

문제는 이 부담이 그대로 사용자 경험으로 이어진다는 점이었어요.

  • 인증목록에 처음 들어왔을 때 화면이 바로 뜨지 않고 기다리는 시간이 길어질 수 있었어요.
  • 데이터 양이 많아질수록 목록을 그리는 부담도 함께 커졌어요.
  • 한 번에 너무 많은 데이터를 다루다 보니 탐색 흐름도 점점 무거워질 수 있었어요.

결국 이 화면에서 중요했던 건 “모든 데이터를 한 번에 다 가져오는 것”이 아니라,

사용자가 지금 필요한 만큼을 빠르게 보여주고, 이후 데이터는 자연스럽게 이어주는 것이었어요.

페이지 단위 조회 구조로 전환했어요

이 문제를 풀면서 가장 먼저 세운 기준은 단순했어요.

“사용자가 아직 보지 않은 데이터까지 처음부터 전부 가져올 필요는 없다.”

 

인증목록은 처음 한 화면이 빠르게 보여야 하고, 그다음은 사용자의 스크롤 흐름에 맞춰 자연스럽게 이어져야 했어요. 그래서 조회 방식을 전체 조회에서 페이지 단위 조회로 바꾸기로 했어요.

 

두게더에서는 인증목록 API에 현재 페이지 번호를 함께 넘기고, 응답에서는 다음 페이지가 남아 있는지 확인할 수 있는 hasNext 값을 받도록 구성했어요.

case let .getMyActivity(_, page):
    return [
        // 현재 몇 번째 페이지를 요청하는지 서버에 전달해요.
        .init(name: "page", value: page)
    ]
struct GetMyActivityResponse: Decodable {
    // 현재 페이지에 해당하는 인증 데이터예요.
    let certifications: [CertificationGroupedResponse]

    // 다음 페이지가 남아 있는지 알려주는 메타 정보예요.
    let pageInfo: PageInfoInGetMyActivityResponse
}

struct PageInfoInGetMyActivityResponse: Decodable {
    let recentPageNumber: Int

    // true면 아직 다음 페이지를 더 불러올 수 있어요.
    let hasNext: Bool
}

이렇게 바꾸고 나니 클라이언트는 “지금 어디까지 불러왔는지”, “다음 페이지가 더 있는지”를 기준으로 훨씬 단순하게 흐름을 관리할 수 있게 됐어요.

페이지 데이터는 누적해서 관리했어요

페이지 단위 조회에서 중요한 건 단순히 나눠서 받아오는 게 아니었어요.

사용자 입장에서는 목록이 끊기지 않고 자연스럽게 이어져야 했거든요.

 

그래서 두게더에서는 첫 페이지를 불러올 때는 목록을 새로 만들고, 다음 페이지를 불러올 때는 기존 목록 뒤에 이어 붙이는 방식으로 상태를 관리했어요.

func loadCertificationList(page: Int) async throws {
    // 이미 마지막 페이지라면 더 이상 다음 요청은 보내지 않아요.
    if page > 0 && certificationListViewDatas.value.isLastPage { return }

    try await fetchCertificationListViewDatas(page: page)
}

private func fetchCertificationListViewDatas(page: Int) async throws {
    let (_, certificationListViewDatas) = try await userUseCase.getCertificationListViewDatas(
        option: sortViewDatas.value.options[sortViewDatas.value.index],
        page: page
    )

    self.certificationListViewDatas.update {
        let newSections = certificationListViewDatas.sections

        // 첫 페이지면 새 목록으로 교체하고,
        // 이후 페이지면 기존 목록 뒤에 이어 붙여요.
        $0.sections = page == 0 ? newSections : $0.sections + newSections

        // 현재 몇 페이지까지 불러왔는지 저장해둬요.
        $0.currentPage = page

        // 다음 페이지가 남아 있는지 여부도 함께 관리해요.
        $0.isLastPage = certificationListViewDatas.isLastPage
    }
}

여기서 핵심은 append였어요.

 

새로운 데이터를 받아올 때마다 화면을 다시 처음부터 만드는 게 아니라, 사용자가 보고 있던 흐름 위에 다음 데이터를 자연스럽게 이어주는 구조로 바꾼 거예요.

하단 임계 지점에서 다음 페이지를 요청했어요

다음으로 고민한 건 “언제 다음 페이지를 요청할 것인가”였어요.

 

사용자가 완전히 목록의 끝에 도달한 뒤에야 요청을 시작하면, 체감상 흐름이 끊기는 느낌이 날 수 있어요. 그래서 두게더에서는 바닥에 닿기 직전, 조금 먼저 다음 페이지를 요청하도록 했어요.

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard !currentIsLastPage else {
        // 마지막 페이지라면 추가 요청을 막아요.
        isPagingRequestInProgress = false
        return
    }

    let offsetY = scrollView.contentOffset.y
    let contentHeight = scrollView.contentSize.height
    let frameHeight = scrollView.frame.size.height

    // 하단에 거의 도달했을 때 다음 페이지를 미리 요청해요.
    if offsetY > contentHeight - frameHeight - 100 {
        if !isPagingRequestInProgress {
            // 같은 구간에서 중복 호출되지 않도록 요청 중 상태를 먼저 켜요.
            isPagingRequestInProgress = true
            delegate?.didScrollToBottom()
        }
    } else {
        // 다시 위쪽으로 올라가면 다음 호출을 위해 상태를 해제해요.
        isPagingRequestInProgress = false
    }
}

이렇게 하니 사용자가 느끼는 흐름도 더 자연스러워졌어요.

“더 보려고 기다리는 느낌”보다 “스크롤에 맞춰 다음 데이터가 이어지는 느낌”에 가까워졌거든요.

중복 요청과 불필요한 재호출을 막았어요

무한 스크롤은 보기에는 단순하지만, 실제로는 쉽게 불안정해질 수 있어요.

스크롤 이벤트는 아주 자주 발생하기 때문에 제어 없이 연결하면 같은 페이지를 여러 번 요청하는 문제가 생기기 쉬웠거든요.

 

그래서 두게더에서는 isPagingRequestInProgress와 isLastPage를 함께 써서 흐름을 안정화했어요.

func didScrollToBottom() {
    // 현재 페이지 다음 번호를 계산해서 다음 페이지만 요청해요.
    loadCertificationListView(page: viewModel.certificationListViewDatas.value.currentPage + 1)
}

여기서 한 가지 더 중요했던 건, 사용자가 스크롤을 다시 위로 올렸을 때 이전 페이지를 다시 요청하지 않도록 흐름을 유지하는 것이었어요.

두게더의 인증목록 페이징은 아래로 내려가면서 다음 페이지를 추가로 붙이는 단방향 구조예요.

 

즉, 한 번 불러온 데이터는 sections에 계속 누적되고, 사용자가 위로 다시 올라갈 때는 이미 메모리에 있는 데이터를 그대로 보여줍니다.

 

스크롤을 위로 올렸을 때 실행되는 처리는 “이전 페이지 재요청”이 아니라, 다음 하단 진입 시 새로운 요청을 받을 수 있도록 요청 상태를 초기화하는 정도예요.

if offsetY > contentHeight - frameHeight - 100 {
    if !isPagingRequestInProgress {
        // 하단 근처에 도달했을 때만 다음 페이지를 요청해요.
        isPagingRequestInProgress = true
        delegate?.didScrollToBottom()
    }
} else {
    // 다시 위로 올라가면 요청 중 상태만 해제해요.
    // 이전 페이지를 다시 불러오는 동작은 여기서 하지 않아요.
    isPagingRequestInProgress = false
}

그리고 실제 다음 페이지 요청도 항상 현재 페이지 + 1 기준으로만 일어나도록 했어요.

func didScrollToBottom() {
    // 이미 불러온 페이지를 다시 요청하지 않고,
    // 현재 페이지 다음 번호만 요청해요.
    loadCertificationListView(page: viewModel.certificationListViewDatas.value.currentPage + 1)
}

즉, 사용자가 위로 스크롤할 때는 새 요청이 발생하는 구조가 아니라, 이미 쌓아둔 목록 안에서 다시 탐색하는 구조에 더 가깝게 설계했어요.

 

결국 페이징은 단순히 데이터를 나눠서 가져오는 게 아니라,

필요한 시점에, 중복 없이, 끊기지 않게 이어주는 게 더 중요하다는 걸 많이 느꼈어요.

결과

페이지 단위 조회를 적용한 뒤 인증목록 화면은 이전보다 훨씬 가벼운 흐름을 갖게 됐어요.

  • 초기 진입 시 한 번에 처리해야 하는 데이터 부담을 줄일 수 있었어요.
  • 인증 데이터가 많은 사용자도 첫 화면을 더 빠르게 확인할 수 있게 됐어요.
  • 사용자는 스크롤 흐름을 끊지 않고 자연스럽게 다음 목록을 탐색할 수 있게 됐어요.
  • 마지막 페이지 이후 불필요한 추가 요청을 줄여 보다 안정적인 무한 스크롤 구조를 만들 수 있었어요.

개인적으로는 이 작업을 하면서, 페이징은 단순한 성능 최적화라기보다 목록 탐색 경험을 다시 설계하는 작업에 더 가깝다고 느꼈어요.

마치며

인증목록 페이징 처리는 처음에는 단순히 데이터 양을 줄이는 작업처럼 보였어요.

 

하지만 실제로는 사용자가 화면에 진입했을 때 얼마나 빨리 내용을 볼 수 있는지, 그리고 스크롤하면서 얼마나 자연스럽게 탐색할 수 있는지를 함께 다루는 작업이었어요.

 

두게더에서는 이 개선을 통해 인증목록을 “처음부터 모든 데이터를 다 책임지는 화면”이 아니라, “사용자가 보는 흐름에 맞춰 필요한 만큼만 반응하는 화면”으로 바꾸려고 했어요.

 

그 결과 초기 진입 부담을 줄이고, 사용자가 더 자연스럽게 인증 기록을 탐색할 수 있는 흐름을 만들 수 있었어요.