본문 바로가기

iOS/UIKit

🧊 [iOS] ImageViewer.swift 라이브러리 앱 프리징 이슈 해결

이미지 뷰어 라이브러리를 사용하던 중 특정 상황에서 앱이 완전히 멈춰버리는 문제가 발생했습니다. 문제의 원인을 추적하고 해결한 과정을 공유합니다.
이 문제는 ImageViewer.swift의 내부 동작 원리를 정확히 이해해야 해결할 수 있었습니다.

🚨 문제 상황

재현 시나리오

피드 앱에서 다음과 같은 상황이 발생했습니다:

  1. 사용자 A가 이미지가 포함된 피드를 게시
  2. 사용자 B가 해당 피드를 확인 중
  3. 사용자 A가 피드를 삭제 (이미지도 함께 삭제됨)
  4. 사용자 B가 이미지를 클릭하여 전체화면으로 보려고 시도
  5. 앱이 완전히 멈춤 (Freezing 발생)

작동 영상

정상작동

 

피드 삭제

 

 

문제 상황

기본 구현

ImageViewer.swift 라이브러리를 사용하여 이미지 캐러셀 뷰를 구현했습니다:

cell.imageView.setupImageViewer(
    urls: [URL], 
    initialIndex: Int, 
    options: [ImageViewerOption], 
    placeholder: UIImage?, 
    from: UIViewController?, 
    imageLoader: (any ImageLoader)?
)

🔍 원인 분석

라이브러리 내부 코드 분석

문제의 원인을 찾기 위해 라이브러리 내부를 살펴봤습니다. URLSessionImageLoader의 구현을 확인해보니 놀라운 사실을 발견했습니다:

public struct URLSessionImageLoader: ImageLoader {
    public func loadImage(_ url: URL, placeholder: UIImage?, imageView: UIImageView, completion: @escaping (UIImage?) -> Void) {
        // 🚨 문제의 코드
        DispatchQueue.global(qos: .background).async {
            guard let data = try? Data(contentsOf: url), let image = UIImage(data: data) else {
                completion(nil)
                return
            }
            // ...
        }
    }
}

문제점 발견:

  • 이름은 URLSessionImageLoader인데 실제로는 Data(contentsOf: url) 사용

ImageView.swift

ImageViewer.swift

Apple 공식 문서 확인

Apple Developer Documentation에 따르면:

네트워크 기반 URL 통신을 통해 이미지를 다운받는 과정에서는 Data(contentsOf:) 대신 URLSession 사용을 권장합니다.
Apple 공식 문서에서도 이 메서드는 동기적으로 실행되어 스레드를 차단하므로 비차단 파일 관련 API 사용을 권장하고 있습니다.

💡 해결 방법

1차 시도: URLSession으로 교체

올바른 비동기 방식으로 구현해봤습니다:

URLSession.shared.dataTask(with: url) { data, response, error in
    DispatchQueue.main.async {
        if let error = error {
            print("에러 발생: \(error)")
            imageView.image = nil
            completion(nil)
            return
        }

        if let data, let image = UIImage(data: data) {
            imageView.image = image
            completion(image)
            return
        }

        imageView.image = nil
        completion(nil)
    }
}
.resume()

결과: 여전히 무한 루프 상태

2차 시도: nil 처리 문제 발견

테스트를 반복하면서 중요한 패턴을 발견했습니다:

// 🚨 이 순간 앱이 멈춤
imageView.image = nil

// ✅ 이렇게 하면 정상 작동
imageView.image = UIImage()
  • UIImage()를 할당했을 때 작동 영상

imageView.image = UIImage()

 

imageView.image = nil을 할당하는 순간 메모리에서 해제되면서 후속 작업이 진행되지 않는 현상을 확인했습니다.

🎯 근본 원인 파악

라이브러리 내부 구조 분석

ImageViewer.swift는 내부적으로 UIPageViewController를 상속받아 이미지 캐러셀을 구현합니다. 핵심 문제는 다음 코드에 있었습니다:

// 🚨 문제의 코드 (라이브러리 내부)
self?.observation = transitionVC.targetView?.observe(\.image, options: [.new, .initial]) { img, change in
    if img.image != nil {
        transitionVC.targetView?.alpha = 1.0
        dummyImageView.removeFromSuperview()
        completed(finished) // 👈 이미지가 nil이면 completion이 호출되지 않음!
    }
}

핵심 문제:

  • 이미지가 nil인 경우 completed 콜백이 호출되지 않음
  • 화면 전환 애니메이션이 완료되지 않아 UI가 정지됨
  • Auto Layout 문제가 아닌 비동기 작업 완료 처리 문제였음

GitHub에서 해결책 발견

동일한 문제에 대한 PR #149를 발견했습니다:

// ✅ 수정된 코드
let observation = transitionVC.targetView?.observe(\.image, options: [.new, .initial]) { img, change in
    if img.image != nil {
        DispatchQueue.main.async {
            transitionVC.targetView?.alpha = 1.0
            dummyImageView.removeFromSuperview()
        }
    }
}
transitionVC.imageObservation = observation
completed(finished) // 👈 항상 completion 호출

개선점:

  • 이미지 유무와 관계없이 completed 콜백 호출
  • 이미지가 있을 때만 UI 업데이트 수행

🛠️ 최종 해결방안

커스텀 ImageLoader 구현

PR이 아직 머지되지 않은 상황이므로, 자체적으로 해결책을 구현했습니다:

extension FeedTableViewCell: ImageLoader {
    func loadImage(_ url: URL, placeholder: UIImage?, imageView: UIImageView, completion: @escaping (UIImage?) -> Void) {
        let options: KingfisherOptionsInfo = [
            .transition(.fade(1.5)),
            .backgroundDecode
        ]

        imageView.kf.setImage(with: url, options: options) { result in
            switch result {
            case .success(let success):
                imageView.image = success.image
                completion(success.image)
            case .failure:
                // 🎯 핵심: nil 대신 placeholder 사용
                imageView.image = placeholder
                completion(nil)
            }
        }
    }
}

선택한 해결 전략

  1. Kingfisher 활용: 이미 사용 중인 이미지 캐싱 라이브러리 활용
  2. Placeholder 제공: UX 관점에서 사용자에게 명확한 피드백 제공
  3. 캐시 효과: 첫 번째 이미지는 이미 캐시되어 있어 빠른 로딩 가능

✅ 결과 확인

결론

📊 결과 및 개선사항

Before vs After

개선 전:

  • 삭제된 이미지 접근 시 앱 프리징
  • 사용자가 강제로 앱을 종료해야 함
  • 네트워크 기반 이미지 다운로드에 안전성이 떨어지는 네트워크 통신으로 이미지를 다운로드

개선 후:

  • 안정적인 에러 처리
  • Placeholder 이미지로 명확한 사용자 피드백
  • 올바른 비동기 네트워크 통신으로 안전한 방식의 이미지 다운로드

📚 핵심 학습 포인트

  1. 라이브러리 내부 구조 이해의 중요성: 문제의 근본 원인 파악 필요
  2. 에러 상황에서의 UX 고려: nil 처리보다 적절한 fallback 제공

결론

표면적으로는 간단해 보이는 프리징 현상이었지만, 실제로는 라이브러리의 비동기 작업 처리 로직과 관련된 복합적인 문제였습니다. 문제 해결 과정에서 다음을 배울 수 있었습니다:

  • 서드파티 라이브러리 사용 시 내부 동작 원리 파악의 중요성
  • 에러 상황에서의 적절한 사용자 경험 설계

이런 경험을 통해 더 안정적이고 사용자 친화적인 앱을 만들 수 있게 되었습니다.