앱이 멈추는 이유부터 이해하기
사용자가 버튼을 눌렀는데 화면이 반응하지 않거나, 목록을 스크롤하는 중에 앱이 뚝뚝 끊긴다면 대부분 시간이 오래 걸리는 작업이 잘못된 위치에서 실행되고 있다는 뜻입니다. iOS 앱에서 화면 그리기, 터치 처리, 애니메이션, SwiftUI 상태 갱신은 주로 메인 스레드에서 이루어집니다. 메인 스레드는 사용자와 앱이 만나는 가장 중요한 통로입니다.
문제는 네트워크 요청, 큰 파일 읽기, 이미지 압축, 많은 데이터 정렬 같은 작업도 시간이 오래 걸린다는 점입니다. 이런 작업을 메인 스레드에서 실행하면 화면 갱신이 밀립니다. 사용자는 앱이 멈춘 것처럼 느낍니다.
그래서 성능 좋은 앱은 일을 나눕니다. 사용자 입력과 화면 갱신은 메인 스레드에서 처리하고, 오래 걸리는 작업은 비동기적으로 실행합니다. 비동기란 작업을 시작한 뒤 결과가 올 때까지 앱 전체를 멈추지 않는 방식입니다. 결과가 준비되면 그때 화면 상태만 갱신합니다.
핵심 흐름은 단순합니다.
화면이 나타나거나 사용자가 버튼을 누른다.
네트워크 요청이나 파일 작업을 비동기로 시작한다.
화면에는 로딩 상태를 보여 준다.
작업이 끝나면 성공 또는 실패 상태로 바꾼다.
상태 변경은 안전하게 메인 액터에서 수행한다.
여기서 메인 액터는 메인 스레드에서 실행되어야 하는 UI 관련 작업을 안전하게 묶어 주는 Swift Concurrency의 장치라고 이해하면 됩니다.
AI 생성 이미지
async/await로 비동기 코드를 읽기 쉽게 만들기
Swift의 async/await는 비동기 작업을 순서가 있는 코드처럼 읽게 해 줍니다. 이전 장에서 URLSession으로 API를 호출할 때도 try await를 사용했습니다. await는 "여기서 결과를 기다린다"는 표시이고, try는 실패할 수 있는 작업이라는 표시입니다.
예를 들어 서버에서 공지 목록을 가져오는 함수를 이렇게 만들 수 있습니다.
struct NoticeService {
func fetchNotices() async throws -> [Notice] {
let url = URL(string: "https://example.com/notices")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
return try JSONDecoder().decode([Notice].self, from: data)
}
}이 함수는 세 가지 이유로 실패할 수 있습니다. 인터넷 연결이 끊길 수 있고, 서버가 오류 상태 코드를 보낼 수 있고, JSON 구조가 앱의 모델과 맞지 않을 수 있습니다. 그래서 async throws로 선언합니다.
호출하는 쪽에서는 do-catch로 실패를 처리합니다.
do {
let notices = try await service.fetchNotices()
print("공지 개수: (notices.count)")
} catch {
print("공지 불러오기 실패: (error)")
}이 구조는 completion handler 방식보다 읽기 쉽습니다. 작업이 위에서 아래로 흐르고, 오류 처리가 한곳에 모입니다. Swift의 async/await는 중첩 콜백을 줄이고 비동기 호출 흐름을 더 명확하게 만드는 데 적합합니다.1
completion handler 코드에서 async/await로 옮기기
오래된 iOS 코드나 외부 라이브러리에서는 아직 completion handler를 많이 사용합니다. completion handler는 작업이 끝났을 때 실행할 클로저를 함수에 전달하는 방식입니다.
예전 방식은 보통 이런 모습입니다.
func fetchProfile(completion: @escaping (Result<UserProfile, Error>) -> Void) {
// 네트워크 요청 후 completion 호출
}호출하는 쪽은 다음처럼 작성합니다.
fetchProfile { result in
switch result {
case .success(let profile):
print(profile.name)
case .failure(let error):
print(error)
}
}간단한 경우에는 문제가 없습니다. 하지만 로그인 후 프로필을 가져오고, 다시 알림 설정을 가져오고, 다시 추천 목록을 가져와야 한다면 클로저가 계속 중첩됩니다. 오류 처리도 여러 곳에 흩어집니다.
새 코드에서는 가능하면 async 함수로 바꾸는 편이 좋습니다.
func fetchProfile() async throws -> UserProfile {
// 네트워크 요청 후 UserProfile 반환
}이미 completion 기반 함수가 있다면 withCheckedThrowingContinuation으로 async 함수처럼 감쌀 수 있습니다.
func fetchProfile() async throws -> UserProfile {
try await withCheckedThrowingContinuation { continuation in
fetchProfile { result in
continuation.resume(with: result)
}
}
}이 방식은 기존 코드를 한 번에 모두 고치기 어려울 때 유용합니다. 내부 구현은 당장 유지하되, 새로 작성하는 ViewModel이나 Service에서는 async/await 방식으로 호출할 수 있습니다. 다만 continuation은 반드시 한 번만 resume해야 합니다. 성공과 실패 중 하나로 정확히 끝나야 하며, 두 번 호출하면 문제가 생깁니다.
Task로 비동기 작업 시작하기
SwiftUI의 버튼 액션이나 일반 함수는 기본적으로 await를 직접 사용할 수 없습니다. 이런 동기 문맥에서 비동기 함수를 호출할 때 Task를 사용합니다.
예를 들어 버튼을 누르면 공지를 새로고침하는 ViewModel을 생각해 봅시다.
@MainActor
final class NoticeViewModel: ObservableObject {
@Published private(set) var state: LoadState<[Notice]> = .idle
private let service: NoticeService
init(service: NoticeService) {
self.service = service
}
func load() {
Task {
state = .loading
do {
let notices = try await service.fetchNotices()
state = .success(notices)
} catch {
state = .failure("공지 목록을 불러오지 못했습니다.")
}
}
}
}@MainActor를 ViewModel에 붙이면 이 객체의 UI 상태 변경은 메인 액터에서 실행됩니다. @Published 값은 SwiftUI 화면과 연결되므로 메인 액터에서 바꾸는 습관이 안전합니다. 특정 부분에서만 메인 액터로 이동해야 한다면 MainActor.run을 사용할 수도 있습니다.
func load() {
Task {
await MainActor.run {
state = .loading
}
do {
let notices = try await service.fetchNotices()
await MainActor.run {
state = .success(notices)
}
} catch {
await MainActor.run {
state = .failure("불러오기에 실패했습니다.")
}
}
}
}둘 중 하나를 선택하면 됩니다. ViewModel 전체가 화면 상태를 다루는 객체라면 @MainActor를 붙이는 방식이 단순합니다. 반대로 무거운 계산을 많이 포함하는 객체라면 필요한 UI 갱신 지점에서만 MainActor.run을 쓰는 편이 낫습니다.
로딩, 성공, 실패를 하나의 상태로 관리하기
비동기 작업에서는 상태가 쉽게 꼬입니다. isLoading, items, errorMessage를 각각 따로 두면 "로딩 중인데 오류 메시지도 있고, 데이터도 있는" 모순 상태가 생길 수 있습니다. 그래서 화면 상태를 하나의 타입으로 묶는 편이 안전합니다.
enum LoadState<Value> {
case idle
case loading
case success(Value)
case failure(String)
}SwiftUI 화면에서는 이 상태 하나만 보고 화면을 그립니다.
struct NoticeView: View {
@StateObject private var viewModel: NoticeViewModel
var body: some View {
content
.task {
viewModel.load()
}
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle, .loading:
ProgressView("불러오는 중")
case .success(let notices):
List(notices) { notice in
Text(notice.title)
}
case .failure(let message):
VStack(spacing: 12) {
Text(message)
Button("다시 시도") {
viewModel.load()
}
}
}
}
}이 구조의 장점은 화면이 현재 상태를 숨기지 않는다는 점입니다. 로딩 중이면 로딩 화면, 성공하면 목록, 실패하면 안내와 재시도 버튼이 보입니다. 사용자는 앱이 멈춘 것이 아니라 작업 중이라는 사실을 알 수 있습니다.
독립적인 작업은 병렬로 실행하기
비동기 작업이라고 해서 항상 하나씩 기다릴 필요는 없습니다. 서로 의존하지 않는 요청은 동시에 실행할 수 있습니다. 예를 들어 홈 화면에서 사용자 프로필과 추천 콘텐츠를 각각 가져온다고 해 봅시다. 추천 콘텐츠가 프로필 결과를 필요로 하지 않는다면 두 작업은 독립적입니다.
순차 실행은 이렇게 됩니다.
let profile = try await userService.fetchProfile()
let recommendations = try await contentService.fetchRecommendations()두 번째 요청은 첫 번째 요청이 끝난 뒤에야 시작됩니다. 반면 async let을 사용하면 두 작업을 거의 동시에 시작하고 나중에 결과를 모을 수 있습니다.
async let profile = userService.fetchProfile()
async let recommendations = contentService.fetchRecommendations()
let homeData = try await HomeData(
profile: profile,
recommendations: recommendations
)병렬 실행은 시간을 줄일 수 있지만 조심할 점도 있습니다. 여러 작업이 같은 배열이나 같은 상태 값을 동시에 수정하면 예측하기 어려운 문제가 생길 수 있습니다. 좋은 방식은 각 작업이 자기 결과만 반환하고, 최종 상태 변경은 한곳에서 하는 것입니다.
즉, 병렬 작업 안에서 UI 상태를 계속 바꾸기보다 결과를 모은 뒤 한 번에 반영합니다.
func loadHome() {
Task {
state = .loading
do {
async let profile = userService.fetchProfile()
async let posts = postService.fetchPosts()
let data = try await HomeData(profile: profile, posts: posts)
state = .success(data)
} catch {
state = .failure("홈 데이터를 불러오지 못했습니다.")
}
}
}작업 수가 고정되어 있으면 async let이 간단합니다. 이미지 여러 장처럼 작업 수가 실행 중에 결정된다면 TaskGroup을 사용할 수 있습니다. 하지만 처음에는 async let만 제대로 이해해도 충분합니다.
취소를 고려해야 진짜 부드러운 앱이 된다
비동기 작업은 시작만큼 취소도 중요합니다. 사용자가 검색어를 빠르게 바꾸는데 이전 검색 요청이 계속 살아 있다면 오래된 결과가 나중에 도착해 화면을 덮어쓸 수 있습니다. 사용자가 화면을 떠났는데 이미지 다운로드가 계속된다면 불필요한 네트워크와 메모리를 사용합니다.
SwiftUI의 .task는 View 생명주기와 연결됩니다. View가 사라지면 해당 작업이 취소될 수 있습니다. 그러나 ViewModel 안에서 직접 만든 Task는 필요에 따라 직접 보관하고 취소해야 합니다.
@MainActor
final class SearchViewModel: ObservableObject {
@Published private(set) var state: LoadState<[SearchResult]> = .idle
private let service: SearchService
private var searchTask: Task<Void, Never>?
init(service: SearchService) {
self.service = service
}
func search(keyword: String) {
searchTask?.cancel()
guard keyword.isEmpty == false else {
state = .idle
return
}
searchTask = Task {
state = .loading
do {
try await Task.sleep(nanoseconds: 300_000_000)
let results = try await service.search(keyword: keyword)
try Task.checkCancellation()
state = .success(results)
} catch is CancellationError {
// 사용자가 새 검색을 시작했거나 화면을 떠난 경우이므로 오류로 표시하지 않는다.
} catch {
state = .failure("검색에 실패했습니다.")
}
}
}
func cancel() {
searchTask?.cancel()
searchTask = nil
}
}여기서 Task.sleep은 입력이 잠시 멈출 때까지 기다리는 디바운스 역할을 합니다. 사용자가 한 글자씩 입력할 때마다 서버 요청을 보내지 않도록 막습니다. Task.checkCancellation()은 이미 취소된 작업이 오래된 결과를 화면에 반영하지 못하게 하는 안전장치입니다.
취소는 실패가 아닙니다. 사용자가 다른 화면으로 이동했거나 더 최신 요청을 시작했기 때문에 이전 작업을 멈춘 것뿐입니다. 그래서 CancellationError를 일반 오류 메시지로 보여 주지 않는 것이 자연스럽습니다.
비동기 코드를 작성할 때의 기준
비동기와 동시성을 처음 배우면 Task를 어디에나 붙이고 싶어집니다. 하지만 기준은 분명해야 합니다.
네트워크 요청, 파일 입출력, 이미지 처리, 큰 데이터 가공처럼 시간이 걸리는 작업은 비동기로 분리합니다. 화면 상태 변경은 메인 액터에서 합니다. 서로 의존하는 작업은 순서대로 await하고, 독립적인 작업은 async let으로 병렬 실행을 고려합니다. 사용자가 새 요청을 시작하거나 화면을 떠날 수 있는 흐름에서는 취소를 설계합니다.
무엇보다 중요한 것은 사용자가 기다리는 동안 앱이 무엇을 하고 있는지 보여 주는 것입니다. 로딩 표시가 있고, 실패했을 때 이유를 알려 주고, 다시 시도할 수 있다면 사용자는 앱을 신뢰합니다. 반대로 실제 작업이 빠르더라도 화면이 멈추거나 오래된 결과가 뒤늦게 나타나면 앱은 불안정해 보입니다.
좋은 iOS 앱은 단순히 기능을 실행하는 앱이 아닙니다. 시간이 걸리는 일을 올바른 곳에서 처리하고, 결과를 안전하게 화면에 반영하며, 필요 없는 작업은 제때 멈추는 앱입니다. Swift Concurrency는 이 흐름을 Swift답게 작성하기 위한 도구입니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
