ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Pagination (feat. SwiftUI & MVVM)
    SwiftUI 2024. 11. 15. 18:56

    안녕하세요. 그린입니다 🍏

    이번 포스팅에서는 페이지네이션 기초에 대해 구현해보려 합니다 🙋🏻

    근데, SwiftUI와 MVVM을 곁들인.. 거기다 Concurrency도 곁들인~

     

    사실 개념적으로 어려운게 전혀 아니기에 바로 들어가보겠습니다!

     


    Pagination?

    페이지네이션은 데이터를 여러 페이지로 나눠서 다루는 기술이죠.

    예를 들어, 서버와 통신을해서 게시글을 보여줘야하는 데이터가 무수히 한 만개쯤 많다고 생각해볼께요.

    그랬을때 이 데이터를 모두 한번에 다 가져오고 또 보여준다는건 굉장한 성능 낭비일거에요.

    한 화면에 스크롤을 내리거나 다음 페이지로 넘어가지 않는 이상 10개정도만 보여줄 수 있을때 남너지 9990개는 아직 보여줄수도 없는데도 들고 있어야 하니까요 🥲

     

    그래서, 페이지네이션을 통해 데이터를 원하는 20개 정도 씩 나눠서 필요할때 다음 페이지에 대한 데이터를 이어 불러오는 방식이죠.

     

    페이지네이션은 사실 앱 개발에서 필수적인 부분입니다.

    커뮤니티 앱이 아니더라도 커머스나 여러 기타 도메인에서도 페이지네이션은 아주 빈번하게 사용됩니다.

     

    페이지네이션은 사용자 경험 즉, UX를 향상시켜줘요!

    앞서 말했듯 모든 데이터를 한번에 로드하지 않기에 페이지 로딩 속도가 향상되죠.

    그리고, 서버 측면에서도 부하가 감소합니다.

    전체가 아닌 필요한 데이터만 전송하기에 서버 리소스가 절약되고 데이터베이스 쿼리의 최적화가 가능해질 수 있죠.

     

    그럼 이런 페이지네이션을 한번 구현해볼까요?

     

    SwiftUI와 MVVM 그리고 Concurrency를 살짝 곁들여서 구조를 만들어보겠습니다 🙋🏻

     


    Pagination 구현하기

    가장 먼저 순서상 할일은 아무래도 데이터를 만들어봐야겠죠?

     

    모델링 부터 해보겠습니다!

     

    // MARK: - 모델
    struct Post {
      let title: String
      let description: String
    }
    
    struct PaginationPostData {
      let page: Int
      let isLast: Bool
      let posts: [Post]
    }
    
    extension PaginationPostData {
      static let stub1: PaginationPostData = .init(
        page: 1,
        isLast: false,
        posts: [
          .init(title: "1번 타이틀", description: "1번 설명"),
          .init(title: "2번 타이틀", description: "2번 설명"),
          .init(title: "3번 타이틀", description: "3번 설명"),
          .init(title: "4번 타이틀", description: "4번 설명"),
          .init(title: "5번 타이틀", description: "5번 설명"),
          .init(title: "6번 타이틀", description: "6번 설명"),
          .init(title: "7번 타이틀", description: "7번 설명"),
          .init(title: "8번 타이틀", description: "8번 설명"),
          .init(title: "9번 타이틀", description: "9번 설명"),
          .init(title: "10번 타이틀", description: "10번 설명")
        ]
      )
      
      static let stub2: PaginationPostData = .init(
        page: 2,
        isLast: true,
        posts: [
          .init(title: "11번 타이틀", description: "11번 설명"),
          .init(title: "12번 타이틀", description: "12번 설명"),
          .init(title: "13번 타이틀", description: "13번 설명"),
          .init(title: "14번 타이틀", description: "14번 설명"),
          .init(title: "15번 타이틀", description: "15번 설명"),
          .init(title: "16번 타이틀", description: "16번 설명"),
          .init(title: "17번 타이틀", description: "17번 설명")
        ]
      )
      
      static func getPosts(page: Int) -> PaginationPostData {
        if page == 1 {
          return PaginationPostData.stub1
        }
        return PaginationPostData.stub2
      }
    }

     

    단순하게 만들어봤어요.

    포스트들을 페이지네이션으로 뿌려줄거에요!

    그러기 위해서 먼저 포스트의 타이틀과 디스크립션 두개로 포스트 타입의 모델을 만듭니다.

    그러고, PaginationPostData라는 타입을 만들어 페이지 정보까지 포함되게 해요!

    우리는 현재 실제 네트워크 통신 작업이 없으니까 stub 데이터를 넣어봤어요.

    page는 현재 넘어온 데이터가 몇페이지인지를 나타내고, isLast는 이게 마지막 페이지의 데이터인지 나타내요.

    그리고 포스트들을 담아줍니다.

     

    getPosts는 실제로 네트워크 통신때는 필요없겠지만, 현재는 테스트 구현이기에 실제 통신의 로직처럼 소통할 수 있는 메서드를 만들었어요.

    페이지 값을 인수로 받아 그에 맞는 데이터를 넘겨줍니다.

     

    그럼 이제 뷰를 그리기전 뷰모델을 통해 로직을 작성해볼까요?

     

    // MARK: - 뷰모델
    class ContentViewModel: ObservableObject {
      @Published var posts: [Post] = []
      var postsCount: Int {
        posts.count
      }
      var currentPage: Int = 1
      var isLastPage: Bool = false
      var isLoading: Bool = false
      
      @MainActor
      func getPosts() async {
        guard !isLoading, !isLastPage else { return }
        
        isLoading = true
        
        let postData = PaginationPostData.getPosts(page: currentPage)
        
        if currentPage == 1 {
          self.posts = postData.posts
        } else {
          self.posts.append(contentsOf: postData.posts)
        }
        
        isLastPage = postData.isLast
        currentPage += 1
        
        isLoading = false
      }
    }

     

    posts 프로퍼티는 현재 들고있는 포스트 정보들을 가집니다.

    그리고 해당 데이터의 갯수를 가지는 연산 프로퍼티와 현재 페이지 정보 그리고 마지막인지 또 지금 데이터를 로드하고 있는지 변수들을 정의합니다.

     

    그리고, getPosts 메서드를 통해 실제 데이터를 로드하는 작업을 해주는것이죠.

    실제 상태값이 메인스레드에서 변경되고 반영되야하기에 MainActor를 붙여줍니다.

    물론, 필요한곳에서 await MainActor.run으로 감싸거나 클래스 전체를 MainActor로 선언할 수 있지만 현재 필요한 부분에서만 다뤄봅니다!

     

    해당 메서드를 호출하면 현재 데이터를 로드중인지와 마지막 페이지가 아닌지에 대한 값을 가지고 비교합니다.

    만약 로드중이거나 마지막 페이지였다면 아래 로직을 실행하면 안되기에 빠른 리턴을 시킵니다.

     

    그리고 조건을 통과했다면 이제 데이터 로드를 위해 로드중으로 상태를 변경합니다.

     

    let postData = PaginationPostData.getPosts(page: currentPage) 이 부분에서 실제로는 네트워크 통신을 하는건데, 현재는 아까 우리가 테스트용으로 만든 getPosts로 대체합니다!

     

    데이터를 로드해오고 만약 현재 페이지 넘버가 1이면, 즉, 처음 데이터를 불러오는거면 그대로 상태값을 바꿔주죠.

    그 다음부터는 append하여 데이터를 추가합니다.

     

    데이터가 성공적으로 로드되고 추가되었으면 이제 해당 데이터 정보에서 이게 마지막 페이지였는지에 대한 상태값을 변경해주고 현재 페이지 넘버를 하나 올려줍니다.

     

    마지막으로 데이터 로드 작업이 끝났으니 isLoading 상태값을 false로 변경해주면 되죠!

     

    생각보다 간단하죠~?

     

    이제 뷰 작업을 해볼까요?

     

    // MARK: - 뷰
    struct ContentView: View {
      @StateObject var viewModel: ContentViewModel = .init()
      
      var body: some View {
        VStack(spacing: 10) {
          Text("Post's count: \(viewModel.postsCount)")
            .font(.largeTitle)
          
          ScrollView {
            LazyVStack(spacing: 20) {
              ForEach(0..<(viewModel.posts.count), id: \.self) { index in
                HStack(spacing: 0) {
                  VStack(alignment: .leading, spacing: 10) {
                    Text(viewModel.posts[index].title)
                      .font(.headline)
                      .foregroundColor(.primary)
                      .padding()
                      .background(Color.gray.opacity(0.1))
                      .cornerRadius(10)
                    
                    Text(viewModel.posts[index].description)
                      .font(.body)
                      .foregroundColor(.secondary)
                    
                    Rectangle()
                      .fill(.gray)
                      .frame(height: 1)
                      .padding(.horizontal, 5)
                  }
                  
                  Spacer()
                }
                .onAppear {
                  guard index == viewModel.posts.count - 1 else {
                    return
                  }
                  
                  Task {
                    await viewModel.getPosts()
                  }
                }
              }
            }
          }
        }
        .padding()
        .onAppear {
          Task {
            await viewModel.getPosts()
          }
        }
      }
    }

     

    뷰는 크게 설명할건 없이 간단해요!

    현재 몇개의 데이터가 로드되었는지 맨 위 상단에 타이틀로 보여줍니다.

    실제로 페이지네이션이 일어나서 데이터가 추가되면 카운트값이 변하겠죠?

     

    그리고, 스크롤뷰로 감싸고 그 안에서 현재 보여지는 데이터만 효율적으로 보여줄 수 있게 LazyVStack을 사용합니다.

    만약 그냥 VStack을 사용한다면, 나타날때 그려지는게 아니라 모든 페이징이 한번에 이뤄집니다.

    즉, 초기 10개를 불러오고 스크롤을 하지 않았는데도 나타났기에 다음 페이징을 처리해서 사실상 페이지네이션 구현이 되지 않습니다 🥹

     

    그렇기에 꼭 LazyVStack을 활용해야 해요!!

     

    그리고 ForEach로 반복을 돌며 뷰를 그려주죠.

    여기서 ForEach로 반복을 돌때 현재 인덱스 정보가 필요해요.

    이 인덱스 정보로 해당 반복되어 그려지는 뷰가 나타날때 로직을 태울겁니다!

     

    만약 인덱스 정보가 현재 로드된 데이터의 갯수에서 -1 한 값과 같으면 마지막 요소라 판단되어 추가 데이터 로드 메서드를 호출하는것이죠.

    즉, 10개의 데이터가 초기 존재했으면 1~9번 데이터를 가진 뷰가 그려질땐 아무 역할을 안하고 마지막 10번 데이터를 가진 뷰가 그려질때 이후 11번부터의 데이터를 페이지네이션 로드하기 위해서 동작하는 그림이죠!

     

    이렇게 구성하고 이 전체 뷰가 초기에 그려질때 첫 로드가 필요하니 동일하게 getPosts를 호출하는 구성입니다 😃

     

    간단하죠 아주?

     

    그럼 동작 시켜볼까요?

     

     

    보시는것처럼 처음에는 stub1의 데이터만 들어와서 카운트도 10개였습니다.

    그러다 스크롤을 내려서 10번째 마지막 데이터가 나타나는 순간 다음 2페이지의 7개 데이터를 추가 로드해오죠!

     

    이런식으로 무한 스크롤에 따른 페이지네이션을 구현할 수 있습니다ㅎㅎ

     


    마무리

    생각보다 단순하지 않나요~?

    처음 개발을 시작할때 저는 이런 네트워크 통신을 통해 페이지네이션을 하는것에 두려움이 있었어요.

    괜히 개념적으로 어려울것 같고 어떻게 처리해야 하는지 막막했던 경험이 있습니다.

    근데, 쉽게 생각할수록 쉽게 구현되는것도 페이지네이션이더라구요!

    어렵지 않게 접해봤으면 좋겠습니다 😄

Designed by Tistory.