Swift Structured Concurrency 면접 질문: async/await, TaskGroup, Actors

Swift Structured Concurrency 기술 면접 질문: async/await, TaskGroup, actors 및 iOS 2026 동시성 패턴

async/await, TaskGroup, actors와 함께하는 Swift Structured Concurrency 면접 질문

Swift 5.5에서 도입된 Structured Concurrency는 iOS의 비동기 프로그래밍을 혁신했습니다. 채용 담당자들은 이제 기술 면접에서 async/await, TaskGroup, actors에 대한 숙련도를 테스트합니다. 면접에서 두각을 나타내기 위한 필수 질문과 기대되는 답변을 소개합니다.

면접에서 평가되는 핵심 역량

채용 담당자들은 세 가지 역량을 평가합니다: 기본 개념 이해 (async/await, Task), 동시성 패턴 숙련도 (TaskGroup, actor 격리), 일반적인 오류 (data races, deadlocks) 진단 능력입니다.

async/await와 DispatchQueue의 차이점은 무엇입니까?

예상 답변: async/await는 가독성 있는 순차 코드로 구조화된 동시성을 제공하는 반면, DispatchQueue는 콜백을 사용하며 소위 "콜백 헬"을 초래할 수 있습니다. Swift는 async/await로 스레드를 자동으로 관리합니다.

NetworkService.swiftswift
// Comparison: async/await vs DispatchQueue

// ❌ Old style: DispatchQueue with callbacks
func fetchUserOld(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
    DispatchQueue.global().async {
        // Simulated network call
        let result = self.performNetworkRequest(id: id)
        DispatchQueue.main.async {
            completion(result)
        }
    }
}

// ✅ New style: async/await more readable
func fetchUser(id: Int) async throws -> User {
    // Swift runtime handles threads automatically
    // No need to manually switch between queues
    return try await performNetworkRequest(id: id)
}

핵심 포인트: async/await는 콜백 피라미드를 제거하고, 스레드 오류를 줄이며 (DispatchQueue.main.async가 필요하지 않음), Swift 런타임이 사용 가능한 CPU 코어 간 실행을 최적화할 수 있게 합니다.

성능 이점

Swift 런타임은 과도한 스레드 생성을 방지하는 최적화된 스레드 풀을 사용합니다. 각 .async가 새 스레드를 생성할 수 있는 DispatchQueue와 달리, async/await는 기존 스레드를 지능적으로 재사용합니다.

여러 비동기 작업을 병렬로 어떻게 관리합니까?

예상 답변: 단순한 2-3개의 작업에는 async let을, 결과 수집과 함께 동적인 수의 병렬 작업에는 TaskGroup을 사용합니다.

DataFetcher.swiftswift
// Parallel async operations strategies

struct DataFetcher {
    // Strategy 1: async let for fixed tasks (2-4 operations)
    func loadDashboard() async throws -> Dashboard {
        // Launch 3 requests in parallel
        async let user = fetchUser()
        async let posts = fetchPosts()
        async let notifications = fetchNotifications()

        // Wait for results (parallel, not sequential)
        let (userData, postsData, notificationsData) = try await (user, posts, notifications)

        return Dashboard(user: userData, posts: postsData, notifications: notificationsData)
    }

    // Strategy 2: TaskGroup for dynamic number of tasks
    func downloadImages(urls: [URL]) async throws -> [UIImage] {
        // TaskGroup allows managing N tasks with result collection
        try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
            // Launch one task per URL
            for (index, url) in urls.enumerated() {
                group.addTask {
                    let (data, _) = try await URLSession.shared.data(from: url)
                    guard let image = UIImage(data: data) else {
                        throw ImageError.invalidData
                    }
                    return (index, image) // Return index to preserve order
                }
            }

            // Collect results in order
            var images = [UIImage?](repeating: nil, count: urls.count)
            for try await (index, image) in group {
                images[index] = image
            }

            return images.compactMap { $0 }
        }
    }
}

일반적인 실수: 호출을 병렬화하는 async let 대신 await를 순차적으로 사용하는 것입니다. let user = await fetchUser(); let posts = await fetchPosts()는 순차적으로 실행되지만 (느림), async let은 둘을 동시에 시작합니다.

Actor란 무엇이며 왜 사용합니까?

예상 답변: Actor는 순차 접근을 보장하여 변경 가능한 상태를 data races로부터 보호하는 타입입니다. 동시 접근을 보호하기 위해 수동 잠금 (NSLock, DispatchQueue)을 대체합니다.

CacheManager.swiftswift
// Actor for thread-safe state management

// ❌ Classic class: data race risk
class UnsafeCache {
    private var cache: [String: Data] = [:] // Not thread-safe!

    func store(_ data: Data, for key: String) {
        cache[key] = data // ⚠️ Race condition with concurrent access
    }
}

// ✅ Actor: automatic protection against races
actor SafeCache {
    private var cache: [String: Data] = [:]

    // Sequential access guaranteed by actor isolation
    func store(_ data: Data, for key: String) {
        cache[key] = data // ✅ Thread-safe automatically
    }

    func retrieve(for key: String) -> Data? {
        return cache[key] // ✅ Protected read
    }

    // Internal synchronous method (no await needed)
    nonisolated func clearAll() async {
        // nonisolated allows calling from any context
        await self.clear()
    }

    private func clear() {
        cache.removeAll()
    }
}

// Usage: await required to access actor
let cache = SafeCache()
await cache.store(data, for: "user_123") // Await mandatory
let cachedData = await cache.retrieve(for: "user_123")

핵심 포인트: Actor는 한 번에 하나의 스레드만 그 상태에 접근하도록 보장합니다. 컴파일러는 외부 호출에 대해 await 사용을 강제하여 잠재적인 일시 중지 지점을 명확하게 만듭니다.

MainActor의 함정

@MainActor는 UI 작업을 위한 전역 actor입니다. 클래스를 @MainActor로 표시하면 모든 메서드가 메인 스레드에서 실행되도록 강제됩니다. 인터페이스를 멈출 수 있는 차단 호출에 주의하십시오.

iOS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

TaskGroup에서 오류를 어떻게 처리합니까?

예상 답변: withThrowingTaskGroup은 만난 첫 번째 오류를 전파하고 나머지 작업을 자동으로 취소합니다. 모든 오류를 수집하려면 TaskGroup에서 Result를 사용하십시오.

BatchProcessor.swiftswift
// Error handling strategies in TaskGroup

struct BatchProcessor {
    // Strategy 1: First error propagation (fail-fast)
    func processItemsFastFail(items: [Item]) async throws -> [ProcessedItem] {
        try await withThrowingTaskGroup(of: ProcessedItem.self) { group in
            for item in items {
                group.addTask {
                    // If one task throws, group cancels others
                    try await self.process(item)
                }
            }

            // Collect results until first error
            var results: [ProcessedItem] = []
            for try await result in group {
                results.append(result)
            }
            return results
        }
        // ⚠️ If one task fails, others are cancelled
    }

    // Strategy 2: Collect all errors (resilience)
    func processItemsResilient(items: [Item]) async -> ([ProcessedItem], [Error]) {
        await withTaskGroup(of: Result<ProcessedItem, Error>.self) { group in
            for item in items {
                group.addTask {
                    // Wrap in Result to capture errors
                    do {
                        let result = try await self.process(item)
                        return .success(result)
                    } catch {
                        return .failure(error)
                    }
                }
            }

            // Separate successes/failures
            var successes: [ProcessedItem] = []
            var errors: [Error] = []

            for await result in group {
                switch result {
                case .success(let item):
                    successes.append(item)
                case .failure(let error):
                    errors.append(error)
                }
            }

            return (successes, errors)
        }
    }

    private func process(_ item: Item) async throws -> ProcessedItem {
        // Processing with possible error
        try await Task.sleep(nanoseconds: 100_000_000)
        return ProcessedItem(from: item)
    }
}

핵심 포인트: withThrowingTaskGroup은 첫 번째 오류에서 중지되며 (원자적 작업에 유용), withTaskGroup + Result는 오류에도 불구하고 계속 진행할 수 있게 합니다 (배치 처리에 유용).

Task, Task.detached, async let의 차이점은 무엇입니까?

예상 답변: Task는 부모 컨텍스트를 상속받고 (우선순위, actor 격리), Task.detached는 상속 없이 독립적인 작업을 생성하며, async let은 범위 끝에서 자동으로 대기되는 자식 작업을 생성합니다.

TaskLifecycle.swiftswift
// Understanding Task creation patterns

@MainActor
class ViewModel {
    var isLoading = false

    // Scenario 1: Task inherits context (@MainActor here)
    func loadDataWithTask() {
        Task {
            // ✅ Inherits @MainActor from parent
            // No need for await MainActor.run
            self.isLoading = true
            let data = try await fetchData()
            self.isLoading = false // ✅ Always on MainActor
        }
    }

    // Scenario 2: Task.detached creates independent task
    func loadDataDetached() {
        Task.detached {
            // ⚠️ Does NOT inherit @MainActor
            let data = try await self.fetchData()

            // ❌ Error: isLoading not directly accessible
            // await MainActor.run {
            //     self.isLoading = false
            // }
        }
    }

    // Scenario 3: async let creates structured child task
    func loadMultipleData() async throws {
        // async let tasks are bound to current scope
        async let users = fetchUsers()
        async let posts = fetchPosts()

        // ⚠️ If leaving function before await, compilation error
        let (usersData, postsData) = try await (users, posts)

        // async let tasks automatically cancelled
        // if exiting scope (e.g., throw before await)
    }

    private func fetchData() async throws -> Data {
        try await URLSession.shared.data(from: URL(string: "https://api.example.com")!).0
    }

    private func fetchUsers() async throws -> [User] { [] }
    private func fetchPosts() async throws -> [Post] { [] }
}

사용 사례:

  • Task: 현재 컨텍스트에 연결된 작업 (예: ViewModel에서의 UI 업데이트)
  • Task.detached: 독립적인 백그라운드 작업 (예: 로그, analytics)
  • async let: 현재 범위에서 결과가 필요한 병렬 작업

비동기 작업에 타임아웃을 어떻게 구현합니까?

예상 답변: withThrowingTaskGroup을 사용하여 메인 작업과 타임아웃 작업 간의 경쟁에서 Task.sleep을 사용하거나 withTimeout 유틸리티를 만드십시오.

AsyncTimeout.swiftswift
// Timeout implementation for async operations

enum TimeoutError: Error {
    case timedOut
}

// Generic utility to add timeout
func withTimeout<T>(
    seconds: TimeInterval,
    operation: @escaping () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        // Task 1: main operation
        group.addTask {
            try await operation()
        }

        // Task 2: timeout
        group.addTask {
            try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
            throw TimeoutError.timedOut
        }

        // First task to finish wins
        guard let result = try await group.next() else {
            throw TimeoutError.timedOut
        }

        // Cancel losing task (important for cleanup)
        group.cancelAll()

        return result
    }
}

// Usage example
struct NetworkService {
    func fetchUserWithTimeout(id: Int) async throws -> User {
        // 5-second timeout on network call
        try await withTimeout(seconds: 5) {
            try await self.fetchUser(id: id)
        }
    }

    private func fetchUser(id: Int) async throws -> User {
        let url = URL(string: "https://api.example.com/users/\(id)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }
}

현대적 대안: iOS 16부터 HTTP 호출 전용으로 URLSessionConfiguration을 통해 구성된 timeoutIntervalURLSession을 사용하십시오.

명시적 취소

group.cancelAll()은 리소스를 해제하는 데 매우 중요합니다. 이것이 없으면 패배한 작업은 자연 완료까지 백그라운드에서 계속 실행되어 CPU와 메모리를 낭비합니다.

여러 작업 간에 변경 가능한 상태를 어떻게 안전하게 공유합니까?

예상 답변: 공유 상태에는 actor를 사용하거나, 값의 스트림을 통해 작업 간에 통신하기 위해 AsyncStream을 사용합니다.

SharedStateManager.swiftswift
// Safe state sharing between concurrent tasks

// Approach 1: Actor for shared state with sequential access
actor DownloadManager {
    private var activeDownloads: [String: Task<Data, Error>] = [:]
    private var cache: [String: Data] = [:]

    // Start download or return existing task
    func download(url: String) async throws -> Data {
        // Check cache first
        if let cachedData = cache[url] {
            return cachedData
        }

        // Check if download already in progress
        if let existingTask = activeDownloads[url] {
            return try await existingTask.value
        }

        // Create new download task
        let task = Task<Data, Error> {
            let data = try await self.performDownload(url: url)

            // Update cache (thread-safe via actor)
            await self.completeDownload(url: url, data: data)

            return data
        }

        activeDownloads[url] = task
        return try await task.value
    }

    private func performDownload(url: String) async throws -> Data {
        let urlObject = URL(string: url)!
        let (data, _) = try await URLSession.shared.data(from: urlObject)
        return data
    }

    private func completeDownload(url: String, data: Data) {
        cache[url] = data
        activeDownloads.removeValue(forKey: url)
    }
}

// Approach 2: AsyncStream for inter-task communication
struct EventStream {
    private let continuation: AsyncStream<Event>.Continuation
    let stream: AsyncStream<Event>

    init() {
        var continuation: AsyncStream<Event>.Continuation!
        stream = AsyncStream { cont in
            continuation = cont
        }
        self.continuation = continuation
    }

    func emit(_ event: Event) {
        continuation.yield(event)
    }

    func finish() {
        continuation.finish()
    }
}

// Example: shared progress monitoring
func processItemsWithProgress(items: [Item]) async {
    let eventStream = EventStream()

    // Task 1: Process items
    Task {
        for item in items {
            await processItem(item)
            eventStream.emit(.itemProcessed(item.id))
        }
        eventStream.finish()
    }

    // Task 2: Update UI with progress
    Task { @MainActor in
        for await event in eventStream.stream {
            switch event {
            case .itemProcessed(let id):
                print("Item \(id) processed")
            }
        }
    }
}

enum Event {
    case itemProcessed(String)
}

아키텍처 선택: 비즈니스 로직이 있는 중앙 집중식 상태에는 Actor를, 분리된 컴포넌트 간의 이벤트 기반 통신에는 AsyncStream을 사용합니다.

iOS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

Task 취소란 무엇이며 어떻게 처리합니까?

예상 답변: Task 취소를 통해 진행 중인 비동기 작업을 취소할 수 있습니다. 작업은 주기적으로 Task.isCancelled를 확인하거나 오류를 던지는 Task.checkCancellation()을 사용해야 합니다.

CancellableOperations.swiftswift
// Implementing proper task cancellation

struct ImageProcessor {
    // Cancellable processing with explicit checks
    func processImages(_ images: [UIImage]) async throws -> [ProcessedImage] {
        var results: [ProcessedImage] = []

        for (index, image) in images.enumerated() {
            // Check 1: Boolean check (continue or skip)
            if Task.isCancelled {
                print("Cancelled after \(index) images")
                break // Graceful stop
            }

            let processed = try await processImage(image)
            results.append(processed)

            // Check 2: Automatic throw if cancelled
            try Task.checkCancellation()
        }

        return results
    }

    private func processImage(_ image: UIImage) async throws -> ProcessedImage {
        // Simulate long processing
        for _ in 0..<10 {
            try await Task.sleep(nanoseconds: 100_000_000)

            // ✅ Check cancellation in long loops
            try Task.checkCancellation()
        }

        return ProcessedImage(from: image)
    }
}

// SwiftUI: Automatic cancellation when view disappears
struct ImageGalleryView: View {
    @State private var images: [ProcessedImage] = []

    var body: some View {
        ScrollView {
            // Display images
        }
        .task {
            // ✅ Task cancelled automatically when view disappears
            let processor = ImageProcessor()
            do {
                images = try await processor.processImages(sourceImages)
            } catch is CancellationError {
                print("Processing cancelled")
            }
        }
    }
}

// Manual cancellation of stored task
class DownloadViewModel {
    private var downloadTask: Task<Void, Never>?

    func startDownload() {
        downloadTask = Task {
            do {
                try await performLongDownload()
            } catch is CancellationError {
                print("Download cancelled by user")
            }
        }
    }

    func cancelDownload() {
        // Explicit cancellation of stored task
        downloadTask?.cancel()
        downloadTask = nil
    }

    private func performLongDownload() async throws {
        try Task.checkCancellation()
        // Download logic
    }
}

핵심 포인트:

  • Task.isCancelled: 비차단 확인 (bool 반환)
  • Task.checkCancellation(): 취소된 경우 CancellationError 던짐
  • SwiftUI 수정자 .task { }: 뷰가 사라지면 자동 취소
협력적 취소

Swift는 협력적 취소 모델을 사용합니다: 작업은 강제로 종료되지 않습니다. 취소에 반응하려면 코드가 능동적으로 Task.isCancelled 또는 checkCancellation()을 확인해야 합니다. 이러한 확인이 없으면 작업은 무기한 계속됩니다.

SwiftUI 앱에서 MainActor를 어떻게 올바르게 사용합니까?

예상 답변: 모든 UI 상태 업데이트가 메인 스레드에서 발생하도록 보장하기 위해 ViewModel에 @MainActor로 주석을 답니다. 특정 작업만 UI에 닿는 경우 개별 함수에 @MainActor를 사용하십시오.

MainActorPatterns.swiftswift
// Proper MainActor usage in SwiftUI architecture

// Pattern 1: Entire ViewModel @MainActor
@MainActor
class UserViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let repository: UserRepository

    init(repository: UserRepository) {
        self.repository = repository
    }

    // ✅ All methods implicitly @MainActor
    func loadUser(id: Int) async {
        isLoading = true // No need for await or MainActor.run
        errorMessage = nil

        do {
            // Network call done on background thread by runtime
            user = try await repository.fetchUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false // Always on MainActor
    }

    // Synchronous method also on MainActor
    func clearUser() {
        user = nil
        errorMessage = nil
    }
}

// Pattern 2: Selective methods with @MainActor
class DataSyncService {
    // ❌ Not @MainActor on class (no UI here)

    func syncData() async throws {
        // Background processing
        let data = try await fetchRemoteData()
        let processed = processData(data)

        // ✅ Switch to MainActor only for UI
        await updateUI(with: processed)
    }

    @MainActor
    private func updateUI(with data: ProcessedData) {
        // Update observable property
        NotificationCenter.default.post(
            name: .dataDidSync,
            object: data
        )
    }

    // Background work (not @MainActor)
    private func fetchRemoteData() async throws -> Data {
        // Network call
        Data()
    }

    private func processData(_ data: Data) -> ProcessedData {
        // CPU-intensive processing in background
        ProcessedData()
    }
}

// Pattern 3: Closure annotation
class ImageLoader {
    func loadImage(url: URL, completion: @MainActor @escaping (UIImage?) -> Void) async {
        let image = try? await downloadImage(from: url)

        // ✅ Completion guaranteed on MainActor
        await completion(image)
    }

    private func downloadImage(from url: URL) async throws -> UIImage {
        let (data, _) = try await URLSession.shared.data(from: url)
        return UIImage(data: data) ?? UIImage()
    }
}

일반적인 실수: 특정 메서드만 UI에 닿을 때 전체 클래스를 @MainActor로 표시하는 것입니다. 이는 백그라운드에 있어야 할 무거운 작업을 포함하여 모든 코드를 메인 스레드에서 실행되도록 강제합니다.

Sendable로 data races를 어떻게 처리합니까?

예상 답변: Sendable 프로토콜은 타입이 data race 위험 없이 작업 간에 공유될 수 있음을 보장합니다. 값 타입 (struct, enum)은 자동으로 Sendable이며, 클래스는 불변 또는 보호된 속성으로 final이어야 합니다.

SendableCompliance.swiftswift
// Making types safe for concurrent access

// ✅ Struct: automatically Sendable (value type)
struct UserData: Sendable {
    let id: Int
    let name: String
    let email: String
}

// ✅ Enum: automatically Sendable
enum LoadingState: Sendable {
    case idle
    case loading
    case loaded(UserData)
    case failed(Error) // ⚠️ Error must also be Sendable
}

// ❌ Class with mutable state: not Sendable by default
class UnsafeCounter {
    var count = 0 // Mutable, unprotected

    func increment() {
        count += 1 // Data race possible
    }
}

// ✅ Immutable class: explicit Sendable
final class SafeConfig: @unchecked Sendable {
    let apiKey: String
    let timeout: TimeInterval

    init(apiKey: String, timeout: TimeInterval) {
        self.apiKey = apiKey
        self.timeout = timeout
    }
}

// ✅ Class with actor-protected state
actor SafeCounter: Sendable {
    private var count = 0 // Protected by actor isolation

    func increment() {
        count += 1 // Thread-safe automatically
    }

    func getValue() -> Int {
        return count
    }
}

// ✅ Class with manually protected state
final class ThreadSafeCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: Data] = [:]

    func store(_ data: Data, for key: String) {
        lock.lock()
        defer { lock.unlock() }
        storage[key] = data
    }

    func retrieve(for key: String) -> Data? {
        lock.lock()
        defer { lock.unlock() }
        return storage[key]
    }
}

// Usage: compiler checks Sendable
func processInBackground(data: UserData) { // ✅ UserData is Sendable
    Task.detached {
        // No warning: UserData is Sendable value type
        print("Processing user: \(data.name)")
    }
}

func processUnsafe(counter: UnsafeCounter) {
    Task.detached {
        // ⚠️ Warning: UnsafeCounter is not Sendable
        // counter.increment()
    }
}

Sendable 규칙:

  • Sendable 속성을 가진 Struct/Enum: 자동으로 Sendable
  • 클래스: final + 불변이어야 하거나, 수동 보호 (lock, actor)와 함께 @unchecked Sendable을 사용해야 합니다
  • 클로저: Sendable 타입만 캡처하는 경우 자동으로 Sendable
@unchecked Sendable

@unchecked Sendable은 컴파일러 검사를 비활성화합니다. 스레드 안전성이 수동으로 보장될 때만 사용하십시오 (lock, 직렬 큐). data races를 피하는 것은 개발자의 책임입니다.

iOS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

결론

Swift Structured Concurrency를 마스터하는 것은 2026년 iOS 면접에 필수적이 되었습니다. 채용 담당자들은 세 가지 수준을 테스트합니다: 개념 이해 (async/await vs callback), 패턴 숙련도 (TaskGroup, actor 격리), 디버깅 (취소, Sendable).

준비 체크리스트:

  • ✅ 구체적인 예제로 async/await vs DispatchQueue 설명하기
  • ✅ 병렬 작업을 위한 TaskGroup 사용 시연하기
  • ✅ 변경 가능한 상태를 보호하기 위한 스레드 안전 actor 구현하기
  • ✅ 동시 컨텍스트에서 오류 처리 (Result, throwing)
  • ✅ 사용 사례로 Task, Task.detached, async let 구분하기
  • ✅ 비동기 작업에 타임아웃 구현하기
  • ✅ SwiftUI 아키텍처에서 MainActor를 올바르게 사용하기
  • ✅ Sendable 이해하고 data races 피하기

최고의 후보들은 이론과 실습을 결합합니다: "왜" (data races 피하기, 가독성 개선)와 "어떻게" (오류 처리가 있는 기능 코드)를 설명합니다. 이러한 패턴을 강화하기 위해 실제 프로젝트에서 연습하십시오.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#swift
#concurrency
#async-await
#actors
#interview

공유

관련 기사