이미지 뷰어 라이브러리를 사용하던 중 특정 상황에서 앱이 완전히 멈춰버리는 문제가 발생했습니다. 문제의 원인을 추적하고 해결한 과정을 공유합니다.
이 문제는 ImageViewer.swift의 내부 동작 원리를 정확히 이해해야 해결할 수 있었습니다.
🚨 문제 상황
재현 시나리오
피드 앱에서 다음과 같은 상황이 발생했습니다:
- 사용자 A가 이미지가 포함된 피드를 게시
- 사용자 B가 해당 피드를 확인 중
- 사용자 A가 피드를 삭제 (이미지도 함께 삭제됨)
- 사용자 B가 이미지를 클릭하여 전체화면으로 보려고 시도
- 앱이 완전히 멈춤 (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
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 = 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)
}
}
}
}
선택한 해결 전략
- Kingfisher 활용: 이미 사용 중인 이미지 캐싱 라이브러리 활용
- Placeholder 제공: UX 관점에서 사용자에게 명확한 피드백 제공
- 캐시 효과: 첫 번째 이미지는 이미 캐시되어 있어 빠른 로딩 가능
✅ 결과 확인
📊 결과 및 개선사항
Before vs After
개선 전:
- 삭제된 이미지 접근 시 앱 프리징
- 사용자가 강제로 앱을 종료해야 함
- 네트워크 기반 이미지 다운로드에 안전성이 떨어지는 네트워크 통신으로 이미지를 다운로드
개선 후:
- 안정적인 에러 처리
- Placeholder 이미지로 명확한 사용자 피드백
- 올바른 비동기 네트워크 통신으로 안전한 방식의 이미지 다운로드
📚 핵심 학습 포인트
- 라이브러리 내부 구조 이해의 중요성: 문제의 근본 원인 파악 필요
- 에러 상황에서의 UX 고려: nil 처리보다 적절한 fallback 제공
결론
표면적으로는 간단해 보이는 프리징 현상이었지만, 실제로는 라이브러리의 비동기 작업 처리 로직과 관련된 복합적인 문제였습니다. 문제 해결 과정에서 다음을 배울 수 있었습니다:
- 서드파티 라이브러리 사용 시 내부 동작 원리 파악의 중요성
- 에러 상황에서의 적절한 사용자 경험 설계
이런 경험을 통해 더 안정적이고 사용자 친화적인 앱을 만들 수 있게 되었습니다.