Swift Concurrency 마스터하기 - 실무 확장을 위한 구조화 전략 3가지
이번 편에서는 지난편에 했던 북마크/스크랩 기능이 포함된 앱 UI를 이어가보죠.
목표는 다음과 같습니다:
- 비동기 리스트 로딩과 북마크 토글을 동시에 안정적으로 처리
- 상태 꼬임 없이 스크롤 중 추가 로딩, 북마크 상태 반영
- 성공/에러 상태, 이전 데이터 유지까지 고려한 설계
🎯 실무 확장을 위한 구조화 전략 3가지
이번 편에서는 아래 3가지를 집중적으로 소개해요:
- 컴포넌트화: UI/로직을 재사용 가능한 단위로 분리하기
- 상태 관리 추상화: 공통 비동기 흐름을 하나의 타입으로 묶기
- 멀티탭 구조 대응: 북마크 상태를 여러 화면에서 일관되게 유지하기
1️⃣ 컴포넌트화: 북마크 버튼을 독립적인 재사용 단위로 만들기
북마크 토글 기능이 여러 리스트나 상세 화면에서 반복될 때, 동일한 로직을 계속 복붙하게 되면 유지보수가 어려워져요. 그래서 이를 독립적인 뷰 컴포넌트 + 전용 로직으로 분리해봅니다.
📦 BookmarkButtonView
struct BookmarkButtonView: View {
@Binding var isBookmarked: Bool
let toggleAction: () async -> Void
var body: some View {
Button(action: {
Task { await toggleAction() }
}) {
Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark")
.foregroundColor(isBookmarked ? .blue : .gray)
}
}
}
✅ 사용 예시
BookmarkButtonView(
isBookmarked: $article.isBookmarked,
toggleAction: {
await viewModel.toggleBookmark(for: article.id)
}
)
이렇게 하면 북마크 UI를 리스트/상세/추천뷰 등 어디에서든 일관되게 사용할 수 있어요. 또한 UI 테스트에서도 이 버튼만 독립적으로 검증 가능해집니다.
2️⃣ 상태 관리 추상화: 공통 비동기 상태를 하나의 모델로 만들기
북마크, 좋아요, 팔로우 등 여러 곳에 등장하는 비동기 토글 패턴은 대부분 다음 흐름을 공유합니다:
- 현재 상태 표시
- 토글 시 optimistic update
- 실패 시 복구
이 흐름을 반복해서 작성하지 않고, Generic 비동기 상태 관리 모델로 추상화할 수 있어요.
🧩 AsyncToggle<T> 구조체
struct AsyncToggle<T: Equatable>: Equatable {
var value: T
var isProcessing: Bool = false
var error: Error? = nil
mutating func toggle(with newValue: T) {
value = newValue
isProcessing = true
error = nil
}
mutating func complete(success newValue: T) {
value = newValue
isProcessing = false
error = nil
}
mutating func fail(originalValue: T, error: Error) {
value = originalValue
isProcessing = false
self.error = error
}
}
이걸 활용하면 북마크 뿐만 아니라 좋아요, 팔로우 등 다양한 상태를 안정적으로 관리할 수 있어요.
ViewModel 예시:
@Published var bookmark = AsyncToggle<Bool>(value: false)
func toggleBookmark() async {
let original = bookmark.value
bookmark.toggle(with: !original)
do {
try await BookmarkAPI.toggle(id: articleId)
bookmark.complete(success: !original)
} catch {
bookmark.fail(originalValue: original, error: error)
}
}
3️⃣ 멀티탭 구조 대응: 앱 전역 상태 공유하기
앱이 여러 탭으로 나뉘고, 북마크 상태가 전역에서 공유되어야 할 때는 ViewModel만으로는 부족해요. 이럴 땐 전역 상태 컨테이너를 정의해서 전체 앱이 같은 데이터를 바라보도록 해야 합니다.
🌐 AppState 예시
@MainActor
final class AppState: ObservableObject {
@Published var bookmarks: Set<Int> = []
func isBookmarked(id: Int) -> Bool {
bookmarks.contains(id)
}
func toggleBookmark(id: Int) async {
if bookmarks.contains(id) {
bookmarks.remove(id)
} else {
bookmarks.insert(id)
}
try? await BookmarkAPI.toggle(id: id)
}
}
SceneDelegate, AppDelegate, 또는 SwiftUI App에서 DI:
@main
struct MyApp: App {
@StateObject var appState = AppState()
var body: some Scene {
WindowGroup {
TabView {
HomeView()
BookmarksView()
}
.environmentObject(appState)
}
}
}
이제 어떤 탭에서 북마크를 추가/삭제해도 다른 탭에서 즉시 반영됩니다. 이건 유저 입장에서 굉장히 자연스러운 UX를 만들어줘요.
❓ FAQ: 실무 확장 구조 관련 자주 묻는 질문들
Q. 북마크 상태를 @Binding으로 넘기면 항상 안전한가요?
A. 값이 바뀌는 순서를 잘 통제하고 있다면 대부분 안전하지만, 복수의 비동기 토글이 동시에 일어나는 경우 충돌이 날 수 있어요. 이럴 땐 Binding보다는 전역 상태 공유 모델이 더 안전합니다.
Q. 북마크 상태가 바뀌었을 때, 리스트에 어떻게 반영되나요?
A. 리스트가 해당 데이터를 참조하고 있다면 @Published 변화로 인해 자동 반영됩니다. 단, 리스트의 참조가 끊겼다면 수동으로 업데이트 해줘야 할 수도 있어요.
Q. 전역 상태를 쓰면 모든 뷰가 무겁지 않나요?
A. @EnvironmentObject는 내부적으로 diffing을 통해 필요한 뷰만 업데이트합니다. 단, 너무 많은 상태를 하나의 전역 객체에 넣으면 유지보수가 어려워지니 feature 단위로 나누는 것이 좋습니다.
Q. 전역 상태에서도 Task.cancel() 같은 처리는 가능할까요?
A. 가능합니다. 상태 객체 내에서 Task를 저장하고, 필요 시 취소하도록 설계할 수 있어요. 다만 이를 관리하려면 구조를 좀 더 정교하게 만들어야 합니다.
✅ 요약: 실무 확장을 위한 3단 전략
| 전략 |
목적 |
핵심 이점 |
| 컴포넌트화 |
UI 단위 재사용 |
유지보수 쉬움, 테스트 가능 |
| 상태 추상화 |
비동기 토글 통일 |
로직 중복 제거, 일관성 확보 |
| 전역 상태화 |
멀티 탭 대응 |
전체 뷰 간 데이터 공유 |
💬 마무리하며
이번 편은 마치 번외편이지만, 실제로 앱을 만들 때 가장 필요한 고급 구조화 전략들이었습니다.
- 북마크/스크랩 같은 단일 기능도 여러 뷰/상태에서 엮이면 복잡해지기 쉬움
- 그럴수록 "분리"와 "공통화"가 해답이 됨
다음 편에서는 이 구조를 테스트 가능한 형태로 감싸서, 의존성 주입 + 단위 테스트로 이어가는 것도 좋겠어요.
혹시 지금까지 구조 중에서 더 다듬고 싶은 부분이 있거나, 적용하고 싶은 다른 기능이 있다면 말해줘. 함께 정리해보자! 💡
이전글:
[프로그래밍] - Swift Concurrency, 북마크/스크랩 기능이 있는 복합 UI 아키텍처 설계 Part.1