반응형

iOS 개발자가 많이 하는 실수 -  스크롤/레이아웃 업데이트를 메인 스레드에서 실행하지 않아 UI가 깨지는 문제

 

iOS에서 UI 관련 작업은 반드시 메인 스레드(Main Thread)에서 실행해야 합니다.
그런데 네트워크 콜백, 비동기 작업, 백그라운드 큐에서 돌아가는 코드 안에서
reloadData(), setNeedsLayout(), scrollToRow 등을 호출하면
다음과 같은 문제가 발생할 수 있습니다.

  • 간헐적인 크래시
  • 레이아웃 깨짐
  • 스크롤 튀는 현상
  • 경고 로그 출력 (main thread checker)

 

1. 문제 패턴: 백그라운드 큐에서 UI 업데이트

대표적인 안티 패턴:

DispatchQueue.global().async {
    // 데이터 로딩/가공
    let items = loadItems()

    // ❌ 백그라운드 큐에서 UI 건드림
    self.tableView.reloadData()
}

또는 네트워크 콜백에서 바로:

URLSession.shared.dataTask(with: url) { data, response, error in
    self.items = parse(data)
    self.tableView.reloadData()   // ❌ 백그라운드 스레드일 수 있음
}.resume()

이런 코드가 만들어내는 문제:

  • 테스트 때는 잘 동작하는 것처럼 보이지만
  • 특정 타이밍, 특정 기기/OS에서만 가끔 크래시
  • Auto Layout 경고, constraint 충돌
  • 스크롤 위치가 이상하게 튀거나, 셀이 깜빡임

2. UIKit은 스레드 세이프하지 않다

UIKit(UIView, UIViewController, UITableView, UICollectionView 등)은

스레드 세이프(Thread-safe)가 아니도록 설계되었습니다.

  • 모든 뷰 계층 구조 및 레이아웃 계산은 메인 스레드에서 이루어진다는 전제
  • 백그라운드 스레드에서 동일 뷰 객체를 만지면 내부 일관성이 깨질 수 있음
  • Apple 공식 문서에서도 “UI 관련 작업은 항상 메인 스레드에서 수행하라”고 명시

즉,

View, Constraint, Layout, Scroll 관련 변경 = 메인 스레드 필수


3. 올바른 패턴: 데이터 처리 = 백그라운드, UI 갱신 = 메인


3-1. 기본 패턴 예시

DispatchQueue.global().async {
    let items = loadItems()          // 백그라운드에서 데이터 가공

    DispatchQueue.main.async {       // 메인 스레드에서 UI 업데이트
        self.items = items
        self.tableView.reloadData()
    }
}

또는 네트워크 콜백 내부에서:

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

    DispatchQueue.main.async {
        self.items = items
        self.tableView.reloadData()
    }
}.resume()

핵심:

  • CPU-heavy 작업, 파싱, 필터링은 백그라운드 큐
  • 그 결과를 UI에 반영하는 모든 작업은 DispatchQueue.main.async

3-2. 레이아웃/컨스트레인트 변경

DispatchQueue.global().async {
    let targetHeight: CGFloat = 200

    DispatchQueue.main.async {
        self.heightConstraint.constant = targetHeight
        UIView.animate(withDuration: 0.25) {
            self.view.layoutIfNeeded()
        }
    }
}
  • constraint 변경, setNeedsLayout, layoutIfNeeded, setNeedsUpdateConstraints 등은
    모두 메인 스레드에서 수행해야 한다.

3-3. 스크롤 관련 API

DispatchQueue.main.async {
    self.tableView.scrollToRow(at: indexPath, at: .middle, animated: true)
}

또는:

DispatchQueue.main.async {
    self.collectionView.setContentOffset(offset, animated: true)
}

4. Swift Concurrency(async/await) 환경에서의 주의점


4-1. 단순 Task 내에서의 UI 접근

Task {
    let items = await loadItems()
    self.tableView.reloadData()   // ❌ 메인 스레드 보장 X
}

이 코드는 Task가 어떤 Executor에서 실행되는지 보장하지 않습니다.

해결책 1: MainActor.run 사용

Task {
    let items = try await loadItems()

    await MainActor.run {
        self.items = items
        self.tableView.reloadData()
    }
}

해결책 2: Task 자체를 MainActor에서 실행

Task { @MainActor in
    let items = try await loadItems()
    self.items = items
    self.tableView.reloadData()
}

4-2. ViewController를 @MainActor로 선언

@MainActor
class MyViewController: UIViewController {
    func updateUI(with items: [Item]) {
        self.items = items
        tableView.reloadData()
    }
}

이렇게 선언하면:

  • 해당 타입의 모든 인스턴스 메서드가 메인 스레드에서 실행될 것이 보장
  • UI 업데이트 실수가 줄어듦

다만, heavy 작업을 이 타입 안에서 직접 수행하면

메인 스레드 블로킹 가능성이 있으므로,

무거운 작업은 별도 비동기 함수로 분리하는 것이 좋습니다.


5. 흔한 변형 실수들


5-1. performBatchUpdates를 백그라운드에서 호출

DispatchQueue.global().async {
    self.dataSource.apply(snapshot, animatingDifferences: true)  // ❌
}

Diffable Data Source, performBatchUpdates, insert/delete/move 등은

모두 메인 스레드에서 실행해야 합니다.


5-2. reloadData 직후 스크롤/셀 접근

DispatchQueue.main.async {
    self.tableView.reloadData()
    self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}

이 자체는 메인 스레드라 괜찮지만,

경우에 따라 레이아웃이 아직 적용되지 않은 상태일 수 있습니다.

이럴 땐:

DispatchQueue.main.async {
    self.tableView.reloadData()
    self.tableView.layoutIfNeeded()
    self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}

처럼 레이아웃을 강제로 반영해주는 걸 고려할 수 있습니다.


6. Main Thread Checker가 알려주는 경고

Xcode의 Main Thread Checker가 활성화된 상태에서

백그라운드 스레드에서 UI를 만지면 다음과 같은 경고가 뜹니다.

Main Thread Checker: UI API called on a background thread

이 경고를 무시하지 말고,

해당 호출을 반드시 DispatchQueue.main.async 또는 @MainActor로 감싸야 합니다.


7. 실무용 체크리스트

스크롤/레이아웃 관련 코드를 작성할 때마다 다음을 점검합니다.

  1. 이 코드가 현재 어떤 스레드에서 실행되고 있는가? 
    • URLSession 콜백? Global Queue? Task?
  2. UI 관련 작업(뷰 변경, constraint 변경, 스크롤, reload 등)이 포함되어 있는가?
  3. 포함되어 있다면:
    • DispatchQueue.main.async 또는
    • @MainActor, MainActor.run
      으로 감싸고 있는가?
  4. Diffable Data Source / performBatchUpdates / reloadData 같은

    무거운 UI 연산은 모두 메인 스레드에서만 실행되고 있는가?

8. 요약

  • UIKit는 스레드 세이프하지 않으며,
    모든 UI 변경은 메인 스레드에서만 안전하다.
  • 특히 다음 API들은 반드시 메인 스레드에서 호출해야 한다:
    • reloadData, performBatchUpdates
    • setNeedsLayout, layoutIfNeeded, constraint 변경
    • scrollToRow, setContentOffset
  • 백그라운드에서 데이터 처리를 하더라도,
    UI 갱신 부분만큼은 반드시 메인큐로 옮겨야 한다.

핵심 문장:

데이터는 백그라운드에서, UI는 반드시 메인 스레드에서.

반응형
Posted by 까칠코더
,