Pull-to-Refresh: 기본, RefreshControl 및 UIRefreshControl

저자: IT Sectr 게시일: 2026-02-27 읽는 시간: 8 분
Pull-to-Refresh는 사용자가 손가락으로 목록을 아래로 당겨 새 데이터 로드를 시작하는 모바일 인터페이스 패턴입니다. 이 제스처에는 시각적 표시기(회전하는 스피너 또는 애니메이션 아이콘)가 수반되며 로드가 완료되면 사라집니다. Apple HIG UX 분석에 따르면, Pull-to-Refresh는 Tweetie(2008)에서 도입되고 Apple과 Google에 의해 표준화된 이후 뉴스 피드, 소셜 네트워크 및 이메일 클라이언트에서 콘텐츠 업데이트의 표준 메커니즘이 되었습니다.

핵심 포인트

  • Pull-to-Refresh는 시각적 로딩 표시기와 함께 데이터를 새로고침하기 위해 목록을 아래로 당기는 제스처입니다.
  • iOS에서는 UIRefreshControl(iOS 6+)을 사용하며, refreshControl 속성을 통해 UITableViewController 또는 UIScrollView에 추가됩니다.
  • Android에서는 SwipeRefreshLayout(Support Library)을 사용합니다 — RecyclerView 또는 NestedScrollView용 ViewGroup 래퍼입니다.
  • 두 API 모두 리스너를 통한 색상, 표시기 및 콜백 사용자 지정을 지원합니다(iOS: UIRefreshControl.target-action, Android: setOnRefreshListener).
  • 목록이 최상단 위치에 없으면 Pull-to-Refresh가 자동으로 차단됩니다 — 스크롤과의 충돌은 아키텍처적으로 배제됩니다.

Pull-to-Refresh란?

Pull-to-Refresh는 사용자가 목록이나 스크롤 가능한 영역을 아래로 당겨(pull down) 콘텐츠를 새로고침하는 사용자 인터페이스 패턴입니다. 시각적으로 제스처에는 화면 상단에 나타나는 로딩 표시기(스피너)가 수반되며 데이터를 수신하면 사라집니다. 이 패턴은 iPhone용 Tweetie 앱(2008)에 의해 대중화되었으며 이후 Apple(iOS 6 — UIRefreshControl)과 Google(Android Support Library — SwipeRefreshLayout)에 의해 표준화되었습니다.

기술적 관점에서 Pull-to-Refresh는 패닝(손가락 변위 추적)과 임계값 도달 시 트리거의 조합입니다. 사용자는 저항(저항성 오버스크롤)을 극복하며 목록을 아래로 당기고, 임계값(iOS에서 ~80px, Android에서 ~64dp)을 초과하면 표시기 애니메이션과 비동기 로딩이 시작됩니다. 사용자가 임계값 전에 손가락을 떼면 목록은 새로고침 없이 원래 위치로 돌아갑니다.

Material Design 가이드라인에 따르면, Pull-to-Refresh는 탐색이나 탭 전환에 사용해서는 안 됩니다 — 유일한 목적은 데이터 새로고침입니다. IT Sectr에서는 데이터의 신선도가 사용자 경험에 중요한 뉴스 피드, 주문 피드 및 채팅에서 Pull-to-Refresh를 사용합니다.

iOS의 Pull-to-Refresh: UIRefreshControl

UIRefreshControl은 Pull-to-Refresh용 표준 iOS 컨트롤로, iOS 6부터 사용 가능합니다. UIRefreshControl은 refreshControl 속성(iOS 10+)을 통해 UITableViewController에 추가되거나 이전 버전에서는 테이블의 하위 뷰로 추가됩니다. 사용자 지정 가능한 색상(tintColor), title 속성 및 레이블이 있는 속성 문자열(예: "업데이트 중...")이 포함된 내장 스피너가 있습니다.

UIRefreshControl은 target-action 메커니즘을 통해 작동합니다: 제스처가 활성화되면 지정된 메서드(예: refresh(_:))가 호출됩니다. 메서드 내에서 비동기 데이터 로딩이 수행됩니다. 완료 후 endRefreshing()이 호출되어 애니메이션과 함께 표시기를 숨깁니다. UIRefreshControl은 자동으로 제스처 감도를 관리합니다 — 테이블이 최상단 위치에 있을 때만 트리거됩니다(contentOffset.y <= 0).

tintColor 속성은 스피너 색상을 설정합니다. attributedTitle을 사용하면 완료 후 "2분 전 업데이트됨"과 같은 텍스트를 표시할 수 있습니다. iOS 10부터 UIRefreshControl은 UIActivityIndicatorView 또는 영구 사용자 지정 뷰를 통한 사용자 지정 애니메이션을 지원합니다. IT Sectr에서는 브랜드에 맞게 tintColor를 구성하고 attributedTitle을 통해 마지막 업데이트 시간을 표시합니다 — 이를 통해 사용자의 데이터 신뢰도가 높아집니다.

Android의 Pull-to-Refresh: SwipeRefreshLayout

SwipeRefreshLayout은 Android Support Library(androidx.swiperefreshlayout)의 ViewGroup으로, 스크롤 가능한 콘텐츠(RecyclerView, NestedScrollView, ListView)를 래핑하고 Pull-to-Refresh 기능을 추가합니다. UIRefreshControl(컨트롤이지 컨테이너가 아님)과 달리 SwipeRefreshLayout은 자식의 터치 이벤트를 가로채고 임계값을 초과하면 새로고침 표시기를 트리거하는 컨테이너입니다.

SwipeRefreshLayout은 setColorSchemeColors()를 통한 색상 사용자 지정이 가능한 원형 Material Design 진행 표시기를 사용합니다. setOnRefreshListener 메서드는 onRefresh() 콜백을 설정하며, 그 안에서 비동기 로딩이 수행됩니다. 완료 후 setRefreshing(false)가 호출되어 표시기를 숨깁니다. 중요: setRefreshing(true)는 onRefresh()를 다시 호출합니다 — 따라서 프로그래밍 방식으로 새로고침을 시작하려면 플래그 또는 post 메서드를 사용하세요.

setProgressBackgroundColorSchemeResource 속성은 표시기의 배경을 변경합니다. setSize(SwipeRefreshLayout.LARGE)는 스피너 크기를 설정합니다. XML 레이아웃에서 SwipeRefreshLayout은 RecyclerView를 래핑합니다: swipe_refresh_layout → recycler_view. Google I/O 2024에 따르면, SwipeRefreshLayout은 콘텐츠 피드가 있는 Android 앱의 85%에서 사용됩니다. IT Sectr에서는 비동기적으로 로드된 목록이 있는 모든 화면을 SwipeRefreshLayout으로 래핑합니다 — 이를 통해 모든 Android 버전에서 일관된 UX를 제공합니다.

Material Pull-to-Refresh (Android 12+)

Android 12(Material You)부터 Google은 material-1.6.0+ 라이브러리(Compose의 경우 androidx.compose.material3.pulltorefresh)의 새로운 Material Pull-to-Refresh 사용을 권장합니다. 새 API는 스프링 애니메이션 지원과 배경화면 기반 적응형 색상을 갖춘 애니메이션 표시기를 사용합니다. SwipeRefreshLayout은 Android 12 미만 버전에서도 호환됩니다.

모범 사례 및 일반적인 실수

Pull-to-Refresh는 구현하기 쉬운 패턴이지만 UX를 저하시키는 몇 가지 일반적인 실수가 있습니다. 이를 살펴보고 방지 방법을 알아보겠습니다.

  • 이중 새로고침 — 사용자가 로딩이 완료되기 전에 목록을 여러 번 당길 수 있습니다. 해결책: 시작 시 isRefreshing 플래그를 설정하고 onRefresh()에서 확인합니다. iOS에서는 완료 후에만 endRefreshing()이 호출됩니다. UIRefreshControl의 제스처 차단은 내장되어 있습니다.
  • 피드백 부족 — 로딩 표시기는 사용자가 임계값을 초과한 후에만 나타나야 합니다. 터치 시 즉시 표시기를 표시하지 마세요 — 사용자를 혼란스럽게 합니다. iOS와 Android는 이를 자동으로 처리합니다.
  • 새로고침 시간 무시 — 데이터가 200ms 안에 새로고침되는 경우, 사용자가 업데이트를 인지할 수 있도록 표시기를 최소 500ms 동안 표시해야 합니다. UIRefreshControl에는 최소 애니메이션 시간이 있습니다. Android에서는 최소 표시 시간을 위해 Handler.postDelayed를 사용하세요.
  • 키보드 충돌 — 키보드가 열려 있는 상태에서 Pull-to-Refresh가 실수로 트리거될 수 있습니다. iOS에서는 view.endEditing(true), Android에서는 InputMethodManager.hideSoftInputFromWindow()를 사용하여 제스처 시작 시 키보드를 숨깁니다.
  • 새로고침 외의 용도로 사용 — 탐색(탭 전환, 뒤로 가기)에 Pull-to-Refresh를 사용하지 마세요. 이는 두 플랫폼의 HIG를 위반하고 사용자를 혼란스럽게 합니다.

IT Sectr에서는 테스트 서버 로그에서 중복 요청을 발견한 후 모든 프로젝트에 isRefreshing 확인을 추가했습니다 — 손가락이 빠른 사용자가 연속으로 최대 3번까지 새로고침을 트리거하고 있었습니다.

Swift 및 Kotlin 코드 예제

예제 1: iOS의 UIRefreshControl (Swift)

사용자 지정 스피너 색상 및 속성 제목으로 UITableViewController에 Pull-to-Refresh를 추가합니다. 데이터 로딩 후 표시기가 숨겨집니다.

swift
import UIKit

class FeedTableViewController: UITableViewController {

    private var items: [String] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.refreshControl = UIRefreshControl()
        refreshControl?.tintColor = .systemBlue
        refreshControl?.attributedTitle = NSAttributedString(
            string: "당겨서 새로고침"
        )
        refreshControl?.addTarget(
            self,
            action: #selector(refreshData),
            for: .valueChanged
        )
    }

    @objc private func refreshData() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
            self.items = FeedService().fetchLatest()
            self.tableView.reloadData()
            self.refreshControl?.endRefreshing()
        }
    }
}

tableView.refreshControl 속성(iOS 10+)이 UIRefreshControl을 설정합니다. .valueChanged 이벤트가 있는 addTarget은 제스처가 활성화될 때 발생합니다. endRefreshing()은 필수입니다 — 없으면 표시기가 무한히 회전합니다. 비동기 로딩은 DispatchQueue.main.asyncAfter로 시뮬레이션됩니다 — 실제 프로젝트에서는 URLSession 또는 async/await를 사용하세요.

예제 2: Android의 SwipeRefreshLayout (Kotlin)

사용자 지정 표시기 색상으로 SwipeRefreshLayout에 RecyclerView를 래핑합니다. onRefresh가 로딩을 시작하고 완료 후 표시기를 숨깁니다.

kotlin
class FeedFragment : Fragment() {

    private var _binding: FragmentFeedBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentFeedBinding.inflate(inflater, container, false)

        binding.swipeRefreshLayout.setColorSchemeColors(
            resources.getColor(R.color.brand_blue, null),
            resources.getColor(R.color.brand_green, null)
        )
        binding.swipeRefreshLayout.setOnRefreshListener {
            loadData()
        }
        return binding.root
    }

    private fun loadData() {
        viewModelScope.launch {
            try {
                val result = repository.getLatestFeed()
                adapter.submitList(result)
            } finally {
                binding.swipeRefreshLayout.isRefreshing = false
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

setColorSchemeColors는 회전하는 Material Design 표시기의 색상을 설정합니다. isRefreshing = false는 finally 블록에서 호출되어 로딩 오류 시에도 표시기를 숨깁니다. ViewModelScope.launch는 프래그먼트 수명 주기 내에서 코루틴을 실행합니다 — 프래그먼트가 소멸되면 코루틴이 자동으로 취소되어 메모리 누수를 방지합니다.

예제 3: SwiftUI .refreshable (iOS 15+)

최신 SwiftUI는 .refreshable 수정자를 제공하여 List 또는 ScrollView에 자동으로 Pull-to-Refresh를 추가합니다.

swift
import SwiftUI

struct FeedView: View {

    @State private var items: [String] = []

    var body: some View {
        List(items, id: \.self) { item in
            Text(item)
        }
        .refreshable {
            items = await FeedService().fetchLatestAsync()
        }
    }
}

.refreshable 수정자는 Pull-to-Refresh 시 실행되는 async 클로저를 받습니다. SwiftUI는 자동으로 새로고침 표시기를 표시하고 숨기며, 상태 경쟁을 관리하고(현재 새로고침이 완료될 때까지 새로고침을 시작하지 않음), 플랫폼에 맞게 애니메이션을 조정합니다. iOS 15+의 경우 SwiftUI에서 Pull-to-Refresh를 구현하는 권장 방법입니다.

자주 묻는 질문

SwiftUI에서 Pull-to-Refresh가 작동하나요?

네, SwiftUI는 List 또는 ScrollView용 .refreshable 수정자를 제공하며 iOS 15부터 사용 가능합니다. 클로저 내에서 비동기 데이터 로딩 코드가 실행됩니다. SwiftUI는 자동으로 새로고침 표시기를 관리하고 현재 로딩이 완료될 때까지 반복 트리거를 차단합니다 — 이는 새 프로젝트의 표준 권장 방식입니다.

이중 새로고침을 방지하려면?

isRefreshing 플래그를 사용하세요: 로딩 시작 시 true로 설정하고 완료 후 false로 설정합니다. iOS에서는 endRefreshing()이 호출될 때까지 UIRefreshControl이 자동으로 반복 호출을 차단합니다. Android에서는 onRefresh() 시작 시 SwipeRefreshLayout.isRefreshing을 확인합니다: true이면 return합니다. 이렇게 하면 제스처당 하나의 요청이 보장됩니다.

Pull-to-Refresh가 목록 스크롤과 충돌하나요?

UIRefreshControl과 SwipeRefreshLayout은 목록이 최상단 위치에 있을 때만 트리거됩니다(contentOffset == 0). 아키텍처가 충돌을 제거합니다: 목록이 1px이라도 스크롤된 동안에는 Pull-to-Refresh 제스처가 활성화되지 않습니다. 충돌이 발생하면 Android의 nestedScrollingEnabled 또는 터치를 가로채는 사용자 지정 GestureRecognizer의 존재를 확인하세요.

요약

  • Pull-to-Refresh는 Apple과 Google이 모든 모바일 플랫폼에서 표준화한 아래로 당기는 제스처를 통한 데이터 새로고침 패턴입니다.
  • iOS의 UIRefreshControl — target-action, tintColor, attributedTitle 및 필수 endRefreshing()이 있는 컨트롤입니다.
  • Android의 SwipeRefreshLayout — setOnRefreshListener, setColorSchemeColors 및 isRefreshing이 있는 ViewGroup 컨테이너입니다.
  • Material Pull-to-Refresh (Android 12+) — 스프링 애니메이션이 있는 새로운 API로, 새 프로젝트에 권장됩니다.
  • SwiftUI .refreshable — async 클로저가 있는 선언적 수정자로, iOS 15부터 사용 가능합니다.
  • isRefreshing 플래그는 이중 새로고침을 방지합니다 — 두 플랫폼 모두에서 필수입니다.
  • Pull-to-Refresh는 탐색용이 아닙니다 — Material Design 및 Apple HIG에 따른 콘텐츠 새로고침 전용입니다.

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기