먹고 기도하고 코딩하라

[UIKit] Data(contentsOf:) 대신 URLSession으로 이미지 불러오기 본문

앱/Swift

[UIKit] Data(contentsOf:) 대신 URLSession으로 이미지 불러오기

사과먹는사람 2023. 7. 23. 11:48
728x90
728x90

 

Data(contentsOf:)로 웹상의 이미지를 불러오는 것을 지양해야 하는 이유

회사 코드에 이런 게 있었다.

Data(contentsOf:)로 웹 이미지를 가져와서 UIImage(data:)로 이미지 변환한 다음, 이미지뷰에 띄우는 코드다.

var urlString: String = "https://cdn.pixabay.com/photo/2016/04/17/10/38/doberman-1334497_960_720.jpg"
var imageData: Data = try! Data(contentsOf: URL(string: urlString)!)
imageView.image = UIImage(data: imageData)

이 코드는 겉보기엔 별 문제가 없어보이지만, 실제로 이 코드가 동작하는 시점에서는 보라색 경고 문구가 뜬다.

Synchronous URL loading of https://cdn.pixabay.com/photo/2016/04/17/10/38/doberman-1334497_960_720.jpg should not occur on this application's main thread as it may lead to UI unresponsiveness. Please switch to an asynchronous networking API such as URLSession.

위 URL의 동기 로딩은 메인 스레드에서 동작하면 안 되는데, UI 반응성에 영향을 주기 때문이다. 아주 큰 이미지를 받고 있거나 네트워크 환경이 좋지 않은 경우 이 작업을 처리하는 데 오랜 시간이 걸릴 수 있는데, 이미 이 작업이 UI 스레드를 점유하고 있어서 그 동안 사용자는 UI 조작을 할 수 없다.

그래서 위와 같은 작업을 할 때는 URLSession 같은 비동기 통신 API를 사용하기를 권장하고 있다. 한마디로 네트워크 환경과 불러오려는 데이터의 크기에 따라 UI 동작이 얼마나 블락되는지를 두고 도박하지 말고, 안전하게 백그라운드 스레드에서 작업하라는 뜻이다. 앱이 죽지는 않지만, 경고 문구처럼 유저와 UI 상호작용에 있어서 영향을 미칠 수 있는 부분이다. 이미지가 있어야만 다음 작업을 할 수 있는 경우가 아니라면, 동기식으로 작업할 이유는 딱히 없다.

 

 

 

고쳐보기

이 경고 문구를 없애고 싶어서 스택오버플로우를 좀 뒤져본 결과, 웹상의 이미지를 불러와 이미지뷰에 넣는 메소드를 extension으로 빼서 사용할 수 있겠다는 생각이 들었다.

 

(1) Data(contentsOf:) 이미지 로딩

예제 프로젝트를 만든다. ViewController에 UIImageView를 생성하고, imageView 아웃렛 변수로 연결한다.

ViewController 코드는 다음과 같다.

Data(contentsOf:)로 이미지를 불러오는 원래 방식부터 시작한다.

class ViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        loadImage()
    }
    
    func loadImage() {
        var urlString: String = "https://cdn.pixabay.com/photo/2016/04/17/10/38/doberman-1334497_960_720.jpg"
        var imageData: Data = try! Data(contentsOf: URL(string: urlString)!)
        imageView.image = UIImage(data: imageData)
    }
}

 빌드하면 바로 강아지 사진이 뜨면서 동시에 보라색 경고 문구가 xcode에 뜨는 것도 함께 볼 수 있다.

 

이 문제를 해결하는 방법으로 Nuke, Kingfisher 같은 이미지 로딩 API를 사용하는 방법이 있지만 이 포스팅에서는 논외로 하고, 써드파티 없이 URLSession을 써서 고쳐본다.

 

(2) URLSession으로 고치기

먼저 URL 객체를 생성하고, Data로 불러온 다음 UIImage로 만드는 작업을 URLSession으로 해보자. 다음과 같이 쓸 수 있다.

 

class ViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        loadAsyncImage()
    }

    func loadAsyncImage() {
        URLSession.shared.dataTask(with: URL(string: "https://cdn.pixabay.com/photo/2016/04/17/10/38/doberman-1334497_960_720.jpg")!) { [weak self] data, response, error in
            guard let self,
                  let data = data,
                  response != nil,
                  error == nil else { return }
            DispatchQueue.main.async {
                self.imageView.image = UIImage(data: data) ?? UIImage()
            }
        }.resume()
    }
}

주의할 점은 imageView.image에 UIImage를 넣어주는 문장은 반드시 DispatchQueue.main.async 안에 넣어줘야 한다는 것이다. URLSession dataTask의 컴플리션 핸들러는 메인 스레드에서 동작한다는 보장이 없다. 이미지뷰에 이미지를 넣는 건 UI 관련 작업을 하는 메인 스레드에서 실행되어야 하기 때문에 main.async 안에 넣어줘야 앱이 죽지 않는다.

self.imageView.image = UIImage(data: data) ?? UIImage()
// UIImageView.image must be used from main thread only
// 죽을 수 있다. 아마 높은 확률로..

이렇게 실행하면 아무 경고 문구 없이 잘 실행되는 것을 볼 수 있다.

현재까지 이점은

  • 경고 문구를 없앨 수 있다. (즉, 비동기적으로 이미지 로딩을 하게 된다)

 

(3) String.extension으로 빼서 뷰컨에 종속되지 않도록 하기

한 단계 더 나아가보자. 이 함수는 현재 ViewController 안에서만 쓸 수 있는 인스턴스 메소드다. 비동기로 이미지를 받아서 이미지뷰에 넣어주는 건 한두 번 하는 일이 아니고, 분명 다른 뷰컨이나 뷰에서도 쓸 일이 있을 것이다. 그럴 때마다 이 함수를 복붙해 넣으면 비효율적이며 기능을 변경해야 할 때 하나하나 고치기가 고되니까 extension으로 빼본다.

일단 어느 뷰, 뷰컨에서든지 비동기 이미지 로딩을 해야 할 때는 불러올 이미지에 대한 URL 정보를 가진 상태일 것이다. 그러므로 String에 extension을 만드는 게 좋을 것이다.

여기서는 단순히 확장 메소드에 imageView를 넘겨서, 그 imageView에 이미지를 바로 꽂아보도록 하겠다. 

class ViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        "https://cdn.pixabay.com/photo/2016/04/17/10/38/doberman-1334497_960_720.jpg".loadAsyncImage(imageView)
    }
}

extension String {
    func loadAsyncImage(_ imageView: UIImageView) {
        URLSession.shared.dataTask(with: URL(string: self)!) { data, response, error in
            guard let data = data,
                  response != nil,
                  error == nil else { return }
            DispatchQueue.main.async {
                imageView.image = UIImage(data: data) ?? UIImage()
            }
        }.resume()
    }
}

역시 경고 문구 없이 잘 동작하는 모습이다.

dataTask 내부에서 guard let self와 [weak self]로 캡쳐해주는 문장이 사라졌는데, UIViewController는 클래스 타입이지만, String은 구조체 타입이다. ARC로 관리하는 대상이 아니기 때문에 weak하게 캡쳐하는 것이 불가능해서 제거한 문장이다.

추가된 이점은

  • URL String이 있다면 어떤 뷰나 뷰컨에서도 쉽게 사용할 수 있다.

 

(4) 이미지뷰에 넣는 것 말고 다른 작업도 할 수 있도록 하기

그런데 만약 버튼의 이미지를 비동기 로딩해야 한다면 어떻게 해야 할까? 로딩하고 난 다음에 필터를 걸어줘야 한다면? 그 때는 loadAsyncImage를 사용하기 힘들 것이다.

이미지를 로딩하는 것까지만 loadAsyncImage가 하고, 그 결과로 나오는 이미지를 받아서 무엇을 할지는 사용하는 곳에서 결정하도록 변경하는 건 어떨까?

이미 충분히 괜찮은 메소드이지만, 클로저를 이용해서 이미지를 함수 바깥으로 보내는 작업을 해보자.

매개변수로 받던 imageView를 지우고 그 자리에 UIImageView를 함수 밖으로 보내는 escaping 클로저를 넣는다.

 

extension String {
    func loadAsyncImage(_ completion: @escaping (UIImage?) -> ()) {
        URLSession.shared.dataTask(with: URL(string: self)!) { data, response, error in
            guard let data = data,
                  response != nil,
                  error == nil else { return }
            DispatchQueue.main.async {
                completion(UIImage(data: data))
            }
        }.resume()
    }
}

클로저 사용에 익숙하지 않다면 조금 헷갈릴 수 있지만, 어쨌든 dataTask 결과로 이미지를 받은 것까지는 똑같다. 그 이미지를 컴플리션 메소드에 매개변수로 넘겨서 이미지를 메소드 바깥으로 탈출시킨다는 점이 다른 것이다.

이 이미지는 이제 이렇게 쓸 수 있다. 

class ViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        "https://cdn.pixabay.com/photo/2016/04/17/10/38/doberman-1334497_960_720.jpg".loadAsyncImage { [weak self] image in
            self?.imageView.image = image ?? UIImage()
        }
    }
}

trailing closure로 completion 클로저를 써준다. 어렵지 않다. 이미지를 받으면, 그 이미지를 imageView에 넣겠다는 것이다.

이제 버튼을 추가해서 버튼에도 배경 이미지로 넣어보자.

class ViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var button: UIButton!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        "https://cdn.pixabay.com/photo/2016/04/17/10/38/doberman-1334497_960_720.jpg".loadAsyncImage { [weak self] image in
            self?.imageView.image = image ?? UIImage()
        }
        
        "https://cdn.pixabay.com/photo/2016/04/17/10/38/doberman-1334497_960_720.jpg".loadAsyncImage { [weak self] image in
            self?.button.setBackgroundImage(image ?? UIImage(), for: .normal)
        }
    }
}

위의 도베르만 사진이 버튼이다. 탭할 수 있다.

마지막으로 loadAsyncImage에서 강제로 URL 객체를 언랩핑하는 것을 안전하게 고쳐보자.

if 문을 쓰는 방법과 guard 문을 쓰는 방법이 있는데, URL 객체를 생성할 수 없다면 바로 exit하는 것이 의도에 더 맞으며 코드 뎁스도 줄일 수 있으니 guard 문을 써서 고쳐보겠다.

extension String {
    func loadAsyncImage(_ completion: @escaping (UIImage?) -> ()) {
        guard let url: URL = URL(string: self) else { return }
        URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data,
                  response != nil,
                  error == nil else { return }
            DispatchQueue.main.async {
                completion(UIImage(data: data))
            }
        }.resume()
    }
}

 

 

References

 

 

728x90
반응형
Comments