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. 실무용 체크리스트
네트워크 코드 작성 시 다음을 항상 확인해야 합니다.
- 이 콜백은 어떤 스레드에서 실행되는가?
- URLSession completion은 background에서 실행될 수 있음
- 콜백 안에서 UI를 직접 만지고 있지 않은가?
- 공용 헬퍼/라이브러리 함수의 completion은
- 반드시 메인 스레드에서 호출되도록 보장하는가?
- 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를 건드릴 때는, 항상 한 번 더 ‘지금 메인 스레드인가?'를 떠올려라.
'Dev Study > iOS' 카테고리의 다른 글
| iOS 개발자가 많이 하는 실수 - Auto Layout 제약 조건 충돌 경고를 무시하고 방치하는 실수 (0) | 2025.12.04 |
|---|---|
| iOS 개발자가 많이 하는 실수 - 스크롤/레이아웃 업데이트를 메인 스레드에서 실행하지 않아 UI가 깨지는 문제 (0) | 2025.12.04 |
| iOS 개발자가 많이 하는 실수 - JSONDecoder에서 Date 포맷/전략을 설정하지 않아 날짜 디코딩이 실패하는 실수 (0) | 2025.12.04 |
| iOS 개발자가 많이 하는 실수 - 네트워크 에러 및 HTTP 상태 코드(4xx/5xx)를 무시하는 실수 (0) | 2025.12.04 |
| iOS 개발자가 많이 하는 실수 - NotificationCenter addObserver 후 removeObserver 누락 문제 (0) | 2025.12.04 |
| iOS 개발자가 많이 하는 실수 - Timer invalidate() 누락으로 인한 메모리 누수·이상 동작 문제 (0) | 2025.12.04 |
| iOS 개발자가 많이 하는 실수 - 클로저에서 self를 강하게(strong) 캡처해 메모리 누수가 발생 (0) | 2025.12.04 |
| iOS 개발자가 많이 하는 실수 - Codable decode 실패 시 에러 원인 확인 없이 try? 사용 (0) | 2025.12.04 |

