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

안정적인 로딩 UX 만들기

by hong7 2026. 4. 12.

두게더 iOS 프로젝트를 개발하며 “기능이 동작하는 것”만큼이나 “기다리는 동안 어떤 경험을 주는가”가 중요하다는 걸 자주 느꼈어요.

 

두게더는 투두 인증, 통계 조회, 프로필 조회, 이미지 업로드처럼 네트워크 요청이 생각보다 자주 일어나는 서비스예요. 그런데 한동안은 이 요청들이 화면 위에서 조용히 일어나고 있었어요. 요청이 빠르면 티가 안 났지만, 네트워크가 조금만 느려져도 사용자는 앱이 멈춘 것처럼 느끼기 쉬웠죠.

 

특히 여러 API가 한 화면에서 동시에 호출되거나, 업로드처럼 요청이 한 번 더 이어지는 흐름에서는 이 문제가 더 크게 드러났어요. 그래서 이번 글에서는 두게더 iOS에서 로딩 경험을 어떻게 개선했는지, 그리고 그 과정에서 어떤 고민을 했는지 정리해보려고 해요.

문제의 발견

처음에는 “로딩 화면 하나 추가하면 되는 것 아닌가?”라고 생각했어요.

 

하지만 실제 서비스에서는 그렇게 단순하지 않았어요.

 

두게더는 단일 요청만 처리하는 앱이 아니에요. 예를 들어 통계 화면처럼 한 화면 진입 시 여러 데이터를 병렬로 불러오는 경우가 있었고, 이미지 업로드처럼 API 호출 후 외부 업로드 요청이 한 번 더 이어지는 흐름도 있었어요. 이런 상황에서 로딩을 단순한 화면 장식처럼 붙이면 금방 문제가 생겼어요.

// 통계 화면에서는 한 번의 진입에 여러 데이터를 동시에 요청하고 있었어요.
// 즉, "요청 1개 = 로딩 1개"처럼 단순하게 보기 어려운 구조였죠.
async let activityResponse = userDataSource.getMyGroupActivity(groupId: groupId)
async let statsResponse = userDataSource.getMyCertificationStats(groupId: groupId)

// 두 요청이 모두 끝나야 실제 화면 데이터를 만들 수 있어요.
let (activity, stats) = try await (activityResponse, statsResponse)
func uploadImage(image: UIImage?) async throws -> String? {
    // 이미지 업로드 전체 흐름 동안 로딩을 유지해요.
    LoadingManager.shared.showLoading()
    defer { LoadingManager.shared.hideLoading() }

    // 1. 먼저 서버에서 presigned URL을 발급받고
    let request: PresignedUrlRequest = PresignedUrlRequest(
        dailyTodoId: 0,
        uploadFileTypes: [FileTypes.image.rawValue]
    )

    let response: PresignedUrlResponse = try await NetworkManager.shared.request(
        S3Router.presignedUrls(presignedUrlRequest: request)
    )

    // 2. 그 다음 발급받은 URL로 실제 업로드를 한 번 더 수행해요.
    guard let imageData = image?.pngData(),
          let presignedUrl = URL(string: response.presignedUrls[0]) else {
        return nil
    }

    return try await uploadImageToS3(imageData: imageData, presignedUrl: presignedUrl)
}

가장 먼저 느낀 문제는 사용자가 현재 상태를 이해하기 어렵다는 점이었어요.

  • 네트워크가 느릴 때 앱이 멈춘 것처럼 느껴졌어요.
  • 어떤 화면은 로딩이 보이고, 어떤 화면은 안 보여서 경험이 일관되지 않았어요.
  • 여러 요청이 겹치면 로딩이 중복되거나 먼저 사라질 수 있었어요.
  • 로딩 중에도 사용자가 다시 버튼을 누르거나 다른 인터랙션을 할 수 있어서 중복 액션 위험이 있었어요.

결국 필요한 건 “로딩 UI 하나”가 아니라, 서비스 전반에서 일관되게 동작하는 로딩 경험이었어요.

로딩을 어디서 관리해야 할까

이 문제를 풀면서 가장 먼저 고민한 건, 로딩을 화면마다 따로 관리할지, 아니면 앱 차원에서 공통으로 관리할지였어요.

 

처음에는 각 ViewController에서 필요할 때 로딩을 띄우는 방식도 생각할 수 있었어요. 하지만 두게더에서는 네트워크 요청이 화면 내부에서만 일어나지 않았어요. 공통 NetworkManager에서 처리되기도 하고, 이미지 업로드처럼 S3Manager 같은 다른 매니저 계층에서도 비동기 작업이 일어났죠.

 

이 구조에서 화면마다 로딩을 붙이기 시작하면 금방 이런 문제가 생겨요.

  • 어떤 요청은 로딩이 붙고 어떤 요청은 빠질 수 있어요.
  • 공통 처리가 되지 않아 화면마다 구현 방식이 달라져요.
  • 요청 흐름이 길어질수록 “누가 로딩을 닫아야 하는지” 책임이 흐려져요.

그래서 두게더에서는 로딩을 특정 화면의 상태가 아니라, 앱 전역의 비동기 작업 상태로 보기로 했어요. 이 기준으로 선택한 구조가 LoadingManager 싱글톤이었어요.

final class LoadingManager {
    // 앱 어디서든 동일한 로딩 매니저에 접근할 수 있도록 싱글톤으로 관리해요.
    static let shared = LoadingManager()

    // 실제 로딩 UI를 올릴 최상단 UIWindow예요.
    private var loadingWindow: UIWindow?

    // 현재 진행 중인 로딩 작업 수를 저장해요.
    // 단순 Bool이 아니라 count로 관리해서 여러 요청을 함께 처리할 수 있어요.
    private var loadingCount: Int = 0

    // 외부에서 새로운 인스턴스를 만들지 못하게 막아요.
    private init() { }
}

싱글톤은 편리하지만 단점도 분명해요. 전역 상태이기 때문에 의존성이 숨겨지기 쉽고, 무분별하게 커지면 테스트와 유지보수가 어려워질 수 있죠.

 

그래서 저도 이 부분을 꽤 고민했어요.

 

결론적으로는 “전역 객체를 쓴다”보다 “전역에서 관리해야만 하는 책임만 모은다”에 더 가깝게 설계하려고 했어요. 이 매니저는 앱의 여러 비동기 흐름에서 공통 로딩 오버레이를 보여주고 닫는 역할만 맡도록 제한했어요. 즉, 화면 상태 전반을 쥐는 거대한 싱글톤이 아니라, 책임이 아주 좁은 전역 로딩 매니저로 사용한 거죠.

단순한 on/off로는 부족했던 이유

로딩을 전역에서 관리하기로 정하고 나면, 다음 고민은 “그 상태를 어떻게 표현할 것인가”였어요.

가장 단순한 방법은 boolean이에요.

  • 요청 시작: isLoading = true
  • 요청 종료: isLoading = false

처음에는 이 방식도 충분해 보였어요.

 

그런데 실제 서비스 흐름에서는 요청이 하나만 존재하지 않았어요.

 

예를 들어 통계 화면에서는 서로 다른 API를 동시에 호출하고 있었어요.

// 활동 데이터와 통계 데이터를 병렬로 호출해요.
async let activityResponse = userDataSource.getMyGroupActivity(groupId: groupId)
async let statsResponse = userDataSource.getMyCertificationStats(groupId: groupId)

// 두 요청이 모두 완료되어야 다음 로직으로 넘어갈 수 있어요.
let (activity, stats) = try await (activityResponse, statsResponse)

이런 구조에서는 각 요청의 응답이 도착하는 순서도 매번 달라질 수 있어요.

 

어떤 요청은 먼저 끝나고, 어떤 요청은 더 오래 걸리죠.

 

처음에 로딩을 단순 on/off로 처리했을 때도 바로 이 부분이 문제였어요. 여러 요청이 동시에 실행되는 상황에서 각 요청이 끝날 때마다 로딩을 켜고 끄다 보니, 로딩 화면이 짧은 간격으로 켜졌다 꺼졌다 하면서 깜빡이는 현상이 생겼어요.

 

사용자 입장에서는 “지금 뭔가 처리 중이구나”보다, 화면이 불안정하게 흔들리는 느낌에 더 가까웠어요. 결국 로딩이 상태를 설명하기는커녕 오히려 UX를 해치는 경우가 생긴 거죠.

 

예를 들면 이런 식이에요.

  • A 요청 시작 -> 로딩 표시
  • B 요청 시작 -> 로딩 표시
  • A 요청 종료 -> 로딩 해제
  • B 요청은 아직 진행 중 -> 다시 로딩 표시 혹은 로딩 상태 불안정

즉, 실제 서비스에서는 “지금 로딩 중인가?”보다 “현재 몇 개의 작업이 아직 끝나지 않았는가?”가 더 정확한 상태였어요.

 

그래서 두게더에서는 로딩을 단순한 on/off 값이 아니라, 하나의 상태처럼 보고 loadingCount 방식으로 관리했어요.

func showLoading() {
    // 새로운 비동기 작업이 시작될 때마다 count를 1 증가시켜요.
    loadingCount += 1
    ...
}

func hideLoading() {
    // 작업이 끝나면 count를 1 감소시켜요.
    loadingCount -= 1

    // 모든 작업이 끝나 count가 0 이하가 되었을 때만
    // 실제 로딩 UI를 닫아요.
    if loadingCount <= 0 {
        ...
    }
}

요청이 시작될 때 count를 올리고, 종료될 때 count를 내리며, count가 0이 되었을 때만 실제 로딩을 닫는 구조예요.

 

이 방식으로 바꾸고 나서는 동시 호출이 있더라도 응답 순서나 각 요청의 소요 시간과 관계없이, 전체 작업이 끝날 때까지 로딩 상태를 안정적으로 유지할 수 있었어요. 결과적으로 로딩 화면이 불필요하게 깜빡이는 현상도 줄일 수 있었고요.

 

겉으로 보면 작은 구현 차이처럼 보이지만, 실제로는 “두게더의 비동기 요청 구조에 맞는 상태 모델이 무엇인가”를 고민한 결과였어요.

로딩은 왜 UIWindow에 올렸을까

로딩 상태를 전역으로 관리하는 것만으로는 아직 부족했어요.

 

이번에는 “어디에 띄울 것인가”가 문제였어요.

 

처음에는 특정 화면 위에 뷰를 얹는 방식도 생각할 수 있었어요. 하지만 서비스가 커질수록 이 방식은 점점 불안해졌어요.

  • push / present 전환 중에 로딩이 어색하게 보일 수 있어요.
  • modal, popup, overlay와 함께 있을 때 계층 충돌이 생길 수 있어요.
  • 어떤 화면에서는 잘 보이는데, 어떤 화면에서는 다른 뷰 뒤로 가려질 수 있어요.

무엇보다 두게더의 로딩은 단순한 시각적 표시를 넘어서, 로딩 중에는 다른 인터랙션을 막는 역할도 필요했어요. 요청이 처리되는 동안 버튼이 또 눌리거나 다른 액션이 겹치면 중복 요청이나 비정상 흐름이 생길 수 있었거든요.

 

그래서 로딩 UI는 “현재 화면 내부의 일부 뷰”가 아니라, 앱 최상단을 덮는 오버레이여야 했어요. 이 요구사항을 만족시키기 위해 UIWindow 기반 방식을 선택했어요.

let window = UIWindow(windowScene: windowScene)

// 로딩 전용 ViewController를 최상단 윈도우에 연결해요.
window.rootViewController = loadingViewController

// alert보다 더 높은 레벨에 올려서
// 어떤 화면 위에서도 항상 보이도록 보장해요.
window.windowLevel = .alert + 99

// 윈도우를 실제로 화면에 표시해요.
window.makeKeyAndVisible()

이 구조를 적용하니 현재 어떤 화면 위에 있든 일관되게 로딩을 띄울 수 있었고, dimmed background와 함께 자연스럽게 사용자 입력도 차단할 수 있었어요.

 

당장의 구현 편의보다 “서비스 전반에서 같은 동작을 보장할 수 있는가”를 기준으로 봤을 때, UIWindow가 가장 적합한 선택이었어요.

로딩도 서비스 경험의 일부라고 생각했어요

로딩 구조를 정리한 뒤에는 “어떤 UI를 보여줄 것인가”를 고민했어요.

 

사실 로딩은 가장 기능적인 UI 중 하나예요.

 

그냥 스피너 하나 넣어도 역할은 수행할 수 있죠. 하지만 사용자가 기다리는 몇 초는 생각보다 서비스 인상을 많이 남기는 순간이기도 해요. 그래서 이 시간을 그냥 비워두기보다, 두게더의 톤을 담은 경험으로 만들고 싶었어요.

이번 작업에서는 디자이너와 협업해 브랜드 캐릭터 기반의 애니메이션을 준비했고, 이를 앱에 자연스럽게 적용하기 위해 Lottie를 사용했어요.

// 디자이너가 전달한 로딩 애니메이션 파일을 불러와요.
private let animationView = LottieAnimationView(name: "dogetherLoading")

// 로딩이 끝날 때까지 반복 재생되도록 설정해요.
animationView.loopMode = .loop

Lottie를 사용한 이유는 분명했어요.

  • 디자이너가 만든 모션을 비교적 손실 없이 앱에 옮길 수 있어요.
  • 이미지 시퀀스보다 관리가 수월해요.
  • 단순한 스피너보다 브랜드 경험을 더 잘 전달할 수 있어요.

개인적으로는 이 과정이 특히 좋았어요.

 

개발이 단순히 기능을 붙이는 작업이 아니라, 디자이너의 의도를 실제 인터랙션으로 번역하는 과정이라는 걸 더 많이 느꼈거든요. 로딩처럼 짧은 순간에도 브랜드의 분위기를 담을 수 있다는 점이 재미있었어요.

공통 경로에 묶어 일관성을 만들었어요

구조를 정한 뒤에는 실제 요청 흐름에 공통으로 연결하는 작업이 필요했어요.

 

두게더에서는 NetworkManager의 공통 요청 함수에서 로딩을 감쌌어요.

func request<T: Decodable>(_ endpoint: NetworkEndpoint) async throws -> T {
    // 공통 네트워크 요청이 시작될 때 로딩을 켜요.
    LoadingManager.shared.showLoading()

    // 함수가 끝날 때 성공/실패와 관계없이 로딩을 끄도록 보장해요.
    defer { LoadingManager.shared.hideLoading() }

    let response: ServerResponse<T> = try await NetworkService.shared.request(endpoint)
    ...
}

이렇게 하니 대부분의 API 요청은 별도 화면 처리 없이도 공통 로딩 UX를 가질 수 있었어요. 여기에 더해 이미지 업로드나 초대 링크 생성처럼 네트워크 공통 경로 밖에 있던 작업도 같은 매니저를 사용하도록 연결해, 사용자 입장에서는 더 일관된 대기 경험을 제공할 수 있었어요.

 

즉, “각 화면이 잘 처리하는가”보다 “서비스 전체가 같은 원칙으로 동작하는가”에 더 가까운 개선이었어요.

결과

이 개선 이후 가장 크게 달라진 건, 사용자가 대기 상황을 덜 불안하게 느끼게 되었다는 점이었어요.

  • 네트워크 지연 상황에서도 앱이 정상적으로 동작 중임을 더 쉽게 전달할 수 있었어요.
  • 여러 요청이 겹치는 화면에서도 로딩이 먼저 사라지거나 중복 노출되는 문제가 줄었어요.
  • 현재 어떤 화면 위에 있든 일관된 로딩 경험을 제공할 수 있었어요.
  • 로딩 자체도 단순한 기능성 UI가 아니라, 브랜드 경험의 일부로 다룰 수 있게 되었어요.

무엇보다 좋았던 건, 이후 새로운 비동기 기능을 추가할 때도 로딩 처리 기준이 분명해졌다는 점이에요. 서비스 운영에서는 결국 “새로운 기능이 생겨도 같은 품질을 유지할 수 있는가”가 중요하다고 생각하는데, 이번 개선은 그런 기반을 만드는 작업이기도 했어요.

마치며

로딩 화면 개선은 겉으로 보면 작은 작업처럼 보일 수 있어요.

 

하지만 실제로는 “사용자에게 지금 무슨 일이 일어나고 있는지 어떻게 설명할 것인가”에 대한 문제였어요.

 

두게더에서는 이 문제를 단순히 스피너 하나 추가하는 방식으로 보지 않고, 전역 상태 관리, 동시 요청 처리, 화면 계층, 인터랙션 차단, 브랜드 경험까지 함께 고민하며 풀어가려고 했어요.

 

이번 작업의 핵심은 로딩 UI를 하나 추가한 것이 아니라, 서비스 전반의 대기 경험을 일관된 방식으로 정리한 데 있었어요. 그 결과 여러 요청이 동시에 일어나는 상황에서도 사용자에게 더 안정적인 흐름을 전달할 수 있었고, 앱이 멈춘 것처럼 느껴지던 순간도 많이 줄일 수 있었어요.

 

사용자는 기다림 자체보다, 왜 기다리는지 모를 때 더 불편함을 느낀다고 생각해요.

 

이번 로딩 개선은 그 당연한 사실을, 서비스 운영 속에서 다시 배우게 해준 작업이었어요.