반응형

iOS 개발자가 많이 하는 실수 - URLSession / 네트워크 요청후 UI 작업시 메인 스레드 전환을 잊는 실수 

 

iOS에서는 UI 관련 작업은 반드시 메인 스레드(Main Thread)에서 수행해야 합니다.
하지만 초보자는 물론, 경험 많은 개발자도 가끔 네트워크 콜백에서 바로 UI를 건드리다가
예측하기 어려운 버그를 만들곤 합니다.

이 문서는 다음을 다룹니다.

  • 왜 메인 스레드 전환이 필요한지
  • 어떤 코드에서 자주 실수하는지
  • 실무에서 사용하는 안전한 패턴

1. 문제 패턴: URLSession 콜백에서 바로 UI 업데이트

대표적인 코드:

URLSession.shared.dataTask(with: url) { data, response, error in
    if let data = data {
        self.imageView.image = UIImage(data: data)   // ❌ 백그라운드 스레드에서 UI 접근
        self.activityIndicator.stopAnimating()
    }
}.resume()

위 코드는 대부분의 경우에도 “잘 동작하는 것처럼” 보일 수 있지만,

이는 우연일 뿐이며 다음과 같은 문제가 있습니다.

  • UIKit는 메인 스레드에서만 안전하게 동작하도록 설계
  • 백그라운드 스레드에서 UI를 건드리면:
    • 경고가 뜨지 않을 수도 있고
    • 특정 기기/OS 버전에서만 이상 동작
    • 간헐적인 크래시나 UI 깨짐 발생

2. 왜 메인 스레드에서만 UI 작업을 해야 하는가?

iOS의 대부분 UI 프레임워크(UIKit, AppKit 등)는

스레드 세이프(Thread-safe)가 아니기 때문입니다.

  • layout, drawing, constraint, 애니메이션 등 많은 로직이
    메인 스레드를 기준으로 움직이도록 구현되어 있음
  • 동시에 여러 스레드에서 View 상태를 변경하면 내부 일관성이 깨질 수 있음

그래서 Apple 공식 문서에서도:

모든 UI 업데이트는 메인 스레드에서 수행하라

고 강하게 권장합니다.


3. 올바른 패턴: 콜백 내부에서 Main Thread로 전환


3-1. URLSession + DispatchQueue.main.async

URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
    guard let self else { return }

    guard let data = data else { return }
    let image = UIImage(data: data)

    DispatchQueue.main.async {
        self.imageView.image = image
        self.activityIndicator.stopAnimating()
    }
}.resume()

핵심:

  • 네트워크 응답 처리(파싱, 디코딩)는 백그라운드에서 수행
  • UI 업데이트는 DispatchQueue.main.async 블록 안에서 수행

3-2. async/await 기반 코드에서의 메인 스레드 보장

Swift Concurrency를 사용하는 경우:

func loadImage() async {
    do {
        let (data, _) = try await URLSession.shared.data(from: url)
        let image = UIImage(data: data)

        await MainActor.run {
            self.imageView.image = image
            self.activityIndicator.stopAnimating()
        }
    } catch {
        print(error)
    }
}

또는 ViewController/클래스를 @MainActor로 선언해서

해당 타입의 메서드가 항상 메인 스레드에서 실행되도록 만드는 방법도 있습니다.

@MainActor
class MyViewController: UIViewController {
    func updateUI(with image: UIImage) {
        imageView.image = image
    }
}

이 경우 updateUI는 항상 메인 스레드에서 실행됩니다.


4. 흔한 변형 실수들


4-1. 파싱 + UI를 한 블록에서 처리

URLSession.shared.dataTask(with: url) { data, response, error in
    guard let data = data else { return }

    // JSON 파싱
    let user = try? JSONDecoder().decode(User.self, from: data)

    // UI 업데이트
    self.nameLabel.text = user?.name   // ❌ 여전히 background thread
}.resume()

해결책:

URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
    guard let self,
          let data = data,
          let user = try? JSONDecoder().decode(User.self, from: data) else { return }

    DispatchQueue.main.async {
        self.nameLabel.text = user.name
    }
}.resume()

4-2. Completion Handler 설계에서 메인 스레드를 고려하지 않음

라이브러리/헬퍼를 만들면서:

func fetchUser(completion: @escaping (User?) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data else {
            completion(nil)      // ❌ 어떤 스레드에서 호출될지 보장X
            return
        }

        let user = try? JSONDecoder().decode(User.self, from: data)
        completion(user)         // ❌ 마찬가지
    }.resume()
}

이제 이 함수를 사용하는 쪽은:

fetchUser { user in
    self.nameLabel.text = user?.name   // ❌ 여기서도 background일 수 있음
}

문제: completion이 어떤 스레드에서 실행되는지 API 설계상 명시되어 있지 않음.

해결책: API에서 Main Thread 보장하기

func fetchUser(completion: @escaping (User?) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        var user: User? = nil
        if let data = data {
            user = try? JSONDecoder().decode(User.self, from: data)
        }

        DispatchQueue.main.async {
            completion(user)   // ✔️ 항상 메인 스레드에서 호출
        }
    }.resume()
}

이제 사용하는 쪽에서는 안심하고 UI를 업데이트할 수 있습니다.


5. Swift Concurrency에서의 흔한 착각

func loadUser() async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

Task {
    let user = try await loadUser()
    nameLabel.text = user.name  // ❌ 여기서도 메인 스레드 보장이 안 될 수 있음
}

Task {}는 기본적으로 현재 Actor/Thread와 무관하게 실행될 수 있으므로,

UI 코드가 들어간다면 다음 두 가지 방법 중 하나를 사용해야 합니다.

방법 1: MainActor.run

Task {
    let user = try await loadUser()
    await MainActor.run {
        self.nameLabel.text = user.name
    }
}

방법 2: 호출 측 Task 자체를 MainActor로 실행

Task { @MainActor in
    let user = try await loadUser()
    self.nameLabel.text = user.name
}

6. 실무용 체크리스트

네트워크 코드 작성 시 다음을 항상 확인해야 합니다.

  1. 이 콜백은 어떤 스레드에서 실행되는가?
    • URLSession completion은 background에서 실행될 수 있음
  2. 콜백 안에서 UI를 직접 만지고 있지 않은가?
  3. 공용 헬퍼/라이브러리 함수의 completion은
    • 반드시 메인 스레드에서 호출되도록 보장하는가?
  4. async/await 코드에서:
    • UI 변경 시 @MainActor 또는 MainActor.run을 사용하고 있는가?

7. 요약

  • 네트워크/백그라운드 작업의 completion은 메인 스레드를 보장하지 않는다.
  • UI 업데이트는 항상 메인 스레드에서 해야 한다.
  • 가장 실무적인 패턴:
    • URLSession: DispatchQueue.main.async { ... }로 UI 업데이트
    • async/await: await MainActor.run { ... } 또는 Task { @MainActor in ... }
  • 헬퍼 함수/라이브러리의 completion 설계 시
    “이 completion은 어떤 스레드에서 실행되는지”를 반드시 문서화·보장해야 한다.

핵심 문장:

네트워크 콜백에서 UI를 건드릴 때는, 항상 한 번 더 ‘지금 메인 스레드인가?'를 떠올려라.

반응형
Posted by 까칠코더
,