오랜만에 글 쓰네요 날씨가 따듯해지면서 오늘은 좋은 카페에 와서 글을 써요 오늘의 카페 추천은
합정에 있는 콤파일입니다.
어두컴컴하고 아주 제 취향이더라고요. (주말 2시간 컷은 너무하긴하네요)
암튼 오늘은 오랜만에 swift에 대해 재밌는 Q&A 영상 올라와서 정리해보려고요:)
Apple 엔지니어들이 직접 나와서 Swift Concurrency 관련 질문들에 답해주는 영상인데, 실제로 헷갈렸던 것들이 꽤 많이 해소됐어요.
Swift 6로 넘어오면서 data race 검사가 빡세지면서 경고 폭탄을 맞은 분들도 많을 것 같아서, 제가 정리한 내용 같이 공유해봐요 :)
isolation 이란?
Swift Concurrency에서 가장 먼저 이해해야 할 개념이 있어요. 바로 isolation입니다.
isolation(격리 도메인)은 한 마디로 "이 코드가 어느 스레드에서 실행되어야 하는가" 를 나타내는 개념이에요.
@MainActor는 "이 타입/함수는 반드시 메인 스레드에서만 써라"라는 선언입니다.
nonisolated는 정반대예요. "나는 특정 isolation에 묶이지 않는다. 어디서든 써도 된다."라는 선언입니다.
@MainActor
class ViewModel {
var title = "Hello" // 메인 스레드에서만 접근 가능
nonisolated func greet() -> String {
return "안녕하세요" // isolation 없음 — 어디서든 호출 가능
// return self.title // ❌ 컴파일 에러! MainActor 상태에 접근 불가
}
}
nonisolated로 마킹된 함수는 어느 isolation domain에서든 호출할 수 있습니다.
대신 MainActor 상태 같은 격리된 데이터엔 직접 접근 못합니다. 컴파일러가 막아줘요.
Foundation의 대부분 API가 nonisolated인 이유가 이거예요 — 앱에서 메인 스레드든 백그라운드든 어디서나 쓸 수 있도록요.
async 함수에서 달라진 동작
여기서 한 가지 중요한 변화가 있어요. 아래 코드, heavyWork()는 메인 스레드에서 실행될까요 백그라운드에서 실행될까요?
@MainActor
class ViewModel {
func onButtonTap() {
Task {
await loadData()
}
}
nonisolated func loadData() async {
let result = heavyWork() // 어느 스레드?
print(result)
}
}
정답은 Swift 버전에 따라 다릅니다 🫢
Swift 5(approachable concurrency 꺼져있을 때)에서는 nonisolated async 함수를 호출하면 무조건 concurrent thread pool(백그라운드)로 hop 했습니다
아무 이유 없이 context switching이 발생하고 예상치 못한 스레드에서 실행되는 문제가 생겼어요.
Apple도 올바른 trade-off가 아니라는 걸 인정했습니다.
그래서!!
새 Xcode 기본값(approachable concurrency 켜져있을 때)에서는 nonisolated async 함수는 호출한 곳의 isolation을 유지합니다.
"isolation 없음 = 호출한 곳을 따라간다."
그럼 백그라운드에서 돌리고 싶으면 @concurrent를 명시적으로 붙이면 됩니다.
@MainActor
class ViewModel {
// ❌ 새 동작에서는 MainActor에서 실행됨
nonisolated func loadData() async {
let result = heavyWork()
}
// ✅ 무조건 백그라운드(concurrent thread pool)에서 실행 보장
@concurrent func loadData() async {
let result = heavyWork()
}
}
@concurrent는 옛날 nonisolated async의 동작을 명시적으로 표현한 것입니다.
"나는 항상 백그라운드에서 실행되어야 한다"는 의도를 코드에 드러내는 거예요.
새 Xcode 프로젝트는 approachable concurrency가 기본으로 켜져 있어요.
백그라운드 실행을 원한다면 @concurrent를 명시적으로 써줘야 해요.
Task 만들기 — 뭘 어떻게 쓰면 될까?
isolation 개념을 알고 나면, 이제 Task를 만드는 세 가지 방법이 왜 다른지 바로 이해돼요.
Task { }, Task.detached, Task { @concurrent in }
아래 세 코드, 실행 스레드가 각각 어디일까요?
// A
@MainActor
func onButtonTap() {
Task {
await doWork()
}
}
// B
@MainActor
func onButtonTap() {
Task.detached {
await doWork()
}
}
// C
@MainActor
func onButtonTap() {
Task { @concurrent in
await doWork()
}
}
정답은 A: 메인 스레드, B: 백그라운드, C: 백그라운드입니다.
핵심 차이는 주변 context를 얼마나 상속받느냐예요.
priority도 여기서 등장하는데, Task가 얼마나 빨리 실행되어야 하는지를 나타내는 값으로 .userInitiated, .background 같은 게 있어요. 명시하지 않으면 Task를 만든 곳의 priority를 이어받습니다.
Task { } 는 현재 isolation과 priority를 그대로 가져갑니다. 대부분의 상황에서 이걸 쓰면 됩니다.
Task.detached { } 는 isolation도 priority도 아무것도 상속받지 않습니다. 로그 저장, 분석 이벤트 전송처럼 현재 작업과 완전히 무관하게 독립적으로 실행되어야 할 때만 씁니다.
Task { @concurrent in } 는 isolation만 백그라운드로 고정하고, isolation을 제외한 나머지 context(priority 등)는 부모에서 그대로 가져옵니다. "백그라운드에서 실행하되, 완전히 독립적이진 않아도 될 때" 씁니다.
Task { @MainActor in } vs await MainActor.run { } — 취향 차이인가?
저는 둘 다 사실 똑같은 건 줄 알고 "취향 차이?" 라고 생각했어요. 근데 완전히 다릅니다.
아래 두 코드에서 heavyProcessing(data)는 어느 스레드에서 실행될까요?
// 1번
Task { @MainActor in
let data = await fetchData()
let processed = heavyProcessing(data) // 어느 스레드?
self.items = processed
}
// 2번
Task {
let data = await fetchData()
let processed = heavyProcessing(data) // 어느 스레드?
await MainActor.run {
self.items = processed
}
}
1번은 메인 스레드, 2번은 백그라운드입니다.

1번은 @MainActor in이 Task 전체에 붙어있기 때문에 fetchData() await 이후 resume될 때도, heavyProcessing(data)도 전부 메인 스레드에서 실행됩니다. 무거운 작업이 메인 스레드를 점령해버려요. UI가 버벅일 수 있습니다.
2번은 heavyProcessing(data)는 백그라운드에서 돌아갑니다. 마지막 UI 업데이트만 MainActor.run으로 메인 스레드에서 처리합니다. 메인 스레드 부담이 훨씬 적습니다.
@MainActor 뷰모델에서 heavy 작업 빼내기
이제 isolation과 Task 종류를 알았으니 실전 케이스를 봐볼게요.
뷰모델 전체를 @MainActor로 마킹하는 건 흔한 패턴인데, 그 안에 무거운 작업이 있다면 문제가 생깁니다.
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func loadItems() async {
let data = await fetchFromNetwork()
let processed = heavyParsing(data) // 😱 메인 스레드에서 실행됨
self.items = processed
}
}
@MainActor 클래스 안의 모든 메서드는 기본적으로 메인 스레드에서 실행됩니다.
heavyParsing이 오래 걸리면 UI가 그대로 멈춰요.
클래스 전체를 바꿀 필요 없습니다.
그 메서드 하나에만 @concurrent를 붙이면 됩니다.
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func loadItems() async {
let processed = await parseItems() // 백그라운드에서 실행
self.items = processed // ✅ 메인 스레드로 돌아와서 업데이트
}
@concurrent
func parseItems() async -> [Item] {
let data = await fetchFromNetwork()
return heavyParsing(data) // ✅ 백그라운드에서 실행
// self.items = ... // ❌ 컴파일 에러! MainActor 상태 접근 불가
}
}
@concurrent를 붙이면 컴파일러가 자동으로 해당 메서드에서 @MainActor 상태에 직접 접근하는 걸 막아줍니다.
데이터 경쟁을 컴파일 타임에 잡아주는 거예요.
그럼 무조건 @concurrent로 빼야 할까? 아닙니다. Apple 엔지니어들이 강조한 말이 있어요.
"먼저 profile 하세요. 문제가 있을 때 최적화하세요."
선제적으로 모든 걸 백그라운드로 빼려 하면 오히려 코드가 복잡해지고 불필요한 context switching만 늘어납니다.
권장 흐름은 이렇습니다.
- 일단 @MainActor에서 실행
- UI 버벅임 느껴지면 Instruments로 프로파일링
- 메인 스레드 점령하는 코드 확인
- 그때 @concurrent로 빼기
새 앱 타겟은?
Xcode 26부터 새 앱 타겟은 기본으로 main actor isolation이 켜집니다.
SwiftUI 뷰가 이미 @MainActor이고 뷰모델도 대부분 UI 상태를 다루기 때문에 앱 타겟에서는 매우 자연스러운 선택입니다.
단, 라이브러리는 다릅니다. Foundation처럼 누가 어디서 쓸지 모르는 코드는 nonisolated가 기본이어야 해요.
Task 생명주기 — 취소, 에러, 메모리
Task를 어떻게 만드는지 알았다면, 이번엔 만들고 나서 자주 빠지는 함정들을 정리해볼게요.
취소는 자동으로 되는 게 아닙니다
@MainActor
class ViewModel: ObservableObject {
private var task: Task<Void, Never>?
func load() {
task = Task {
await loadData()
}
}
func cancel() {
task?.cancel()
task = nil
}
deinit {
task?.cancel() // 안 하면 ViewModel 사라져도 Task는 계속 실행됨
}
}
취소하려면 handle을 프로퍼티에 저장해야 합니다. 저장 안 하면 취소할 방법이 없어요.
cancel() 호출하면 즉시 멈출까요?
task = Task {
await doSomething() // (1)
heavySyncWork() // (2) — cancel 호출됨
await doSomethingElse() // (3)
}
task?.cancel()
아닙니다. Swift의 Task 취소는 cooperative(협력적)입니다.
.cancel()을 호출하면 "취소 요청" 플래그만 세울 뿐, 실행 중인 코드를 강제로 중단하지 않아요.

- (1) doSomething() — 내부에서 취소 감지 → CancellationError throw → 중단
- (2) heavySyncWork() — 동기 코드라 취소 체크 없음 → 끝까지 실행됨
- (3) doSomethingElse() — (2)가 끝난 후에야 취소 감지
동기 코드가 강제 중단되지 않는 건 의도된 동작입니다. 오래 걸리는 동기 코드 안에서 직접 취소를 체크하고 싶으면:
@concurrent
func heavySyncWork() async throws {
for item in largeArray {
try Task.checkCancellation() // 취소됐으면 CancellationError throw
process(item)
}
}
weak self — 써야 할까요?
// 방식 A — weak capture
task = Task { [weak self] in
let data = await fetchData()
self?.items = data
}
// 방식 B — strong capture
task = Task {
let data = await fetchData()
self.items = data
}
정답은 상황에 따라 다릅니다.
방식 B(strong capture) 는 Task가 완료될 때까지 self를 살려두고 싶을 때. 예: 네트워크 요청 결과를 반드시 저장해야 할 때.
방식 A(weak capture) 는 View처럼 Task보다 먼저 사라져도 되는 경우. 단, self가 nil이 되면 이후 작업이 조용히 스킵됩니다.
특히 무한 루프 Task + self에 handle 저장 조합은 retain cycle 위험이 있습니다.
무한 루프 Task라면 weak self 또는 명시적 cancel 로직 필수입니다.
Task가 에러를 조용히 삼켜버린다?
Task {
try await riskyOperation() // 에러 발생해도 아무 일 없음
}
Task는 내부에서 throw된 에러를 처리하지 않으면 조용히 무시합니다. 앱이 이상하게 동작하는데 원인을 못 찾는 케이스 중 하나예요. Swift 6.4부터는 이걸 경고로 잡아줍니다.
// 방법 1: task body 안에서 직접 처리
Task {
do {
try await riskyOperation()
} catch {
self.errorMessage = error.localizedDescription
}
}
// 방법 2: handle로 나중에 받기
let task = Task {
try await riskyOperation()
}
do {
try await task.value
} catch {
print(error)
}
버튼 탭 → async 요청, 어디서 Task 만드는게 좋을까?
그랬던적 있죠? 버튼 누르면 로그인 API 쏘거나 해야할때 궁금했던게 있어요. button에서 비동기로 전달하는 것과 뷰모델에서 비동기로 전달하는것과 동작은 같지 않을까?
// ❌ View에서 직접
Button("로드") {
Task {
let data = try await network.fetch()
self.items = data
}
}
// ✅ Model에서 Task 관리
Button("로드") {
viewModel.load()
}
@MainActor
class ViewModel {
func load() {
Task {
let data = try await network.fetch()
self.items = data
}
}
}
하지만 async 코드는 Model로 이동시키는 게 좋습니다.
View는 동기 메서드만 호출. 왜 그렇게 해야할까요? 이것도 테스트를 생각하면 이해가 쉽습니다.
=> View 반응성 유지 + Model만 독립적으로 테스트 하기 위해!!!
수천 개 파일 처리 — Task 하나씩 만들면?
파일이 수백 개 수준이면 Task 하나씩 만들어도 충분할 수 있습니다.
이론으로 최적화하지 말고, 실제로 느리다고 느껴질 때 profile 먼저 해보는게 맞다구 하네요:)
profile 결과 Task 생성 오버헤드 + 스케줄링 부담이 문제라면 그때 withTaskGroup으로 묶어야 겠어요
// 일단 이렇게 해보고 — 생각보다 괜찮을 수 있어요
for file in files {
Task {
await process(file)
}
}
// 느리다는 게 확인됐을 때
await withTaskGroup(of: Void.self) { group in
for file in files {
group.addTask {
await process(file)
}
}
}
Actor — 언제, 얼마나 써야 할까?
Actor는 자신의 상태를 한 번에 하나의 작업만 접근할 수 있도록 보호하는 타입입니다.
쉽게 말하면 자기만의 isolation domain을 가진 타입이에요.
actor NetworkCache {
private var cache: [String: Data] = [:]
func store(_ data: Data, for key: String) {
cache[key] = data
}
func get(_ key: String) -> Data? {
cache[key]
}
}
// 외부에서 접근할 때는 await 필요
let cache = NetworkCache()
await cache.store(data, for: "key")
여러 Task가 동시에 cache에 접근해도 Actor가 직렬화해줍니다. data race 없음.!!!!
Actor 많이 만들수록 좋을까?
아닙니다. Actor 하나 = isolation domain 하나 = 복잡도 하나 추가입니다.
Actor끼리 데이터를 주고받으려면 전부 await이 필요해요. Actor가 많아질수록 코드 전체가 async 투성이가 됩니다.
// Actor 많으면 이런 코드가 됨
let user = await userActor.getUser()
let cached = await cacheActor.get(user.id)
await analyticsActor.log(user.id)
await networkActor.fetch(cached)
Actor가 적합한 경우는 공유 가변 상태가 명확히 존재하고, 여러 곳에서 동시에 접근할 때입니다.
예: 네트워크 캐시, DB 접근.
반대로 대부분의 앱 코드는 @MainActor 하나로 충분합니다.
모든 UI 상태가 메인 스레드에서 관리되면 Actor 없이도 data race가 없으니까요:)
"이게 정말 여러 isolation domain에서 동시에 접근해야 하나?" => ㅇㅇ 액터 사용 아니면 X
Task 좀 더 파헤치기.
.task modifier는 언제 취소될까?
struct MyView: View {
var body: some View {
List(items) { item in
Text(item.title)
}
.task {
await viewModel.load()
}
}
}
.task modifier가 만든 Task는 view가 사라질 때 자동으로 취소됩니다. onDisappear와 같은 타이밍이에요.
state 변경 시에는 취소되지 않아요.
state 변경은 view를 파괴하지 않고 body만 재평가합니다. Task는 그대로 살아있어요.
단, 이런 경우는 취소됩니다.
struct MyView: View {
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
} else {
Text("메인")
.task {
await load() // showDetail = true 되면 취소됨
}
}
}
}
}
if 분기가 바뀌면서 해당 branch의 view가 파괴될 때 취소됩니다.
더 세밀하게 제어하려면?
.task modifier는 handle을 노출하지 않습니다. 직접 소유하면 됩니다.
@MainActor
class ViewModel: ObservableObject {
private var task: Task<Void, Never>?
func startLoad() {
task = Task {
await load()
}
}
func cancelLoad() {
task?.cancel()
task = nil
}
}
struct MyView: View {
@ObservedObject var vm: ViewModel
var body: some View {
ContentView()
.onAppear { vm.startLoad() }
.onDisappear { vm.cancelLoad() }
}
}
Actor 데이터를 SwiftUI에 노출하려면?
actor DataStore {
var items: [Item] = []
}
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
private let store = DataStore()
func load() async {
let data = await store.items
self.items = data // ✅ MainActor에서 UI 업데이트
}
}
Actor → SwiftUI 사이에는 동기화 레이어가 불가피합니다.
Actor 상태를 SwiftUI가 직접 observe 할 수 없으니까요. ViewModel이 그 다리 역할을 합니다.
이때 동기화가 복잡하다고 느낄때 하.. actor를 진짜 써야해? 라고 의문점을 가져야해요!!
UI 상태를 다루는 모델이라면 custom actor 대신 @MainActor 클래스가 훨씬 자연스럽습니다.
Sendable 경고 어떻게 해결하나?
여기까지 왔으면 Sendable도 어렵지 않아요.
isolation domain 사이를 넘나들 때 안전한지를 컴파일러가 체크하는 건데, 그게 바로 Sendable이에요.
Sendable은 다른 isolation domain으로 안전하게 전달할 수 있는 타입입니다.
// ✅ Sendable — value type은 대부분 자동
struct User: Sendable {
let name: String
let age: Int
}
// ❌ non-Sendable — 가변 상태 가진 class
class UserSession {
var token: String = "" // 여러 스레드에서 동시 접근 위험
}
NSObject 상속 클래스에서 Sendable 경고
// 1. 직접 thread-safe하게 보호하고 있을 때 — @unchecked Sendable
final class UserSession: NSObject, @unchecked Sendable {
private let lock = NSLock()
private var _token: String = ""
var token: String {
lock.withLock { _token }
}
}
// 2. 한 번만 전달하면 될 때 — sending
func process(_ session: sending UserSession) async {
// session을 이 isolation domain으로 넘겨받음
// 원래 isolation에서는 더 이상 접근 불가
}
// 3. 전체 객체 말고 필요한 값만 추출
let token = session.token // String은 Sendable
Task {
await process(token) // ✅ String만 전달
}
Generic Codable 함수에서 Swift 6 에러
// ⚠️ Swift 6에서 에러 발생
func fetch<T: Codable>(_ type: T.Type) async -> [T] {
let data = await network.request()
return try! JSONDecoder().decode([T].self, from: data)
}
// ✅ Sendable 추가
func fetch<T: Codable & Sendable>(_ type: T.Type) async -> [T] {
let data = await network.request()
return try! JSONDecoder().decode([T].self, from: data)
}
// ✅ 또는 반환값에 sending
func fetch<T: Codable>(_ type: T.Type) async -> sending [T] {
let data = await network.request()
return try! JSONDecoder().decode([T].self, from: data)
}
Swift 6 마이그레이션 — 어디서부터 시작하지?
개념을 다 잡았으니 마지막으로 실제로 마이그레이션할 때 어떻게 접근하면 좋은지 정리해볼게요.
가장 큰 실수: 한 번에 다 바꾸려는 것
Swift 6 마이그레이션에서 많은 분들이 "한 번에 완전한 Swift concurrency 모델로 리팩터링"을 시도합니다.
그러다 경고 폭탄에 지쳐서 포기하죠.
두 단계를 분리하세요.
- 현대화: API를 async/await으로 바꾸기
- 안전화: Swift 6 언어 모드 활성화, data race 경고 해결
순서는 상관없습니다. 동시에 하려 하면 힘들어요.
추천 순서
1단계: approachable concurrency 먼저 활성화
Xcode Build Settings → Swift Compiler → Upcoming Features에서 활성화. 새 async 함수 기본 동작(hopping 없음)을 먼저 적용합니다.
2단계: 경고 카테고리별로 나눠서 처리
한 번에 전체 활성화 대신 SwiftStrictConcurrency = minimal → targeted → complete 순으로. static variables → actor isolation → sendable 순으로 카테고리별 처리. 하나 해결하면 연쇄적으로 많은 경고가 사라집니다.
3단계: 새로 작성하는 코드에 우선 적용
오래된 안정적인 코드는 나중에. 새로 쓰는 코드부터 Swift concurrency로 작성하는 게 가장 효과적입니다.
GCD/OperationQueue → Swift Concurrency
// ❌ 기존 GCD
DispatchQueue.global().async {
let data = self.heavyWork()
DispatchQueue.main.async {
self.items = data
}
}
// ✅ Swift Concurrency
Task {
let data = await heavyWork()
await MainActor.run {
self.items = data
}
}
// ❌ 기존 OperationQueue
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 4
for item in items {
queue.addOperation { process(item) }
}
// ✅ Swift Concurrency
await withTaskGroup(of: Void.self) { group in
for item in items {
group.addTask { await process(item) }
}
}
App termination — async shutdown 처리
// ❌ 절대 하면 안 됨 — deadlock 위험
func applicationWillTerminate(_ application: UIApplication) {
let semaphore = DispatchSemaphore(value: 0)
Task {
await cleanup()
semaphore.signal()
}
semaphore.wait() // 💀 concurrent thread pool이 꽉 차면 아무것도 진행 못함
}
// ✅ 가능한 한 동기로 처리
func applicationWillTerminate(_ application: UIApplication) {
saveStateSynchronously()
flushLogsSynchronously()
}
termination callback은 동기입니다. 그 안에서 처리해야 할 코드를 최대한 동기로 만드는 게 근본 해결책이에요.
이 내용은 Apple Developer의 Q&A: Swift concurrency | Meet with Apple 세션을 기반으로 정리했습니다.
틀린 내용이나 추가했으면 하는 내용 있으면 댓글로 알려주세요 :)
https://www.youtube.com/watch?v=E95agtPgaa0&list=LL&index=1&t=2415s
'iOS' 카테고리의 다른 글
| 푸시 알람을 어떻게 설계할까? (2) | 2025.12.21 |
|---|---|
| iOS 이미지 포맷과 압축 방식 - 사진은 HEIC인데 스크린샷은 왜 PNG일까? (0) | 2025.12.17 |
| UIKit / SwiftUI에서는 무슨 디자인 패턴을 사용해야할까? (0) | 2025.10.17 |
| URLSession 한 줄 뒤에 숨겨진 것들: DNS부터 전자기파까지 (2) | 2025.10.11 |
| HTTP 캐싱(Etag & max-age) 그리고 iOS에서는? (0) | 2025.10.01 |