Swift Concurrency 완벽 가이드 — async/await, Task, Actor와 URLSession 네트워크 통신

iPhone 앱을 개발하다 보면 서버에서 데이터를 가져와 화면에 표시해야 하는 경우가 많습니다.

예를 들어 날씨 앱에서는 기상 정보를 조회하고, 뉴스 앱에서는 최신 기사를 가져오며, 생물도감 앱에서는 서버에서 동물이나 식물의 상세 정보를 다운로드해야 합니다.

이러한 네트워크 작업은 시간이 걸릴 수 있기 때문에 메인 스레드에서 동기적으로 처리해서는 안 됩니다.

Android에서는 Kotlin Coroutine과 Retrofit2를 이용해 비동기 네트워크 통신을 구현하는 경우가 많습니다.

iOS에서는 Swift의 async/await, Task, Actor, URLSession을 이용해 비슷한 기능을 구현할 수 있습니다.

이번 글에서는 Swift Concurrency의 기본 개념부터 실제 REST API 연동까지 단계별로 설명하겠습니다.

1. 이번 글에서 구현할 기능

이번 예제에서는 공개 테스트 API를 이용해 게시글 목록을 조회하는 iPhone 앱을 만들겠습니다.

주요 기능은 다음과 같습니다.

  • URLSession을 이용한 HTTP GET 요청

  • JSON 데이터를 Swift 모델로 변환

  • async/await 비동기 처리

  • Task를 이용한 작업 실행

  • @MainActor 기반 UI 상태 관리

  • MVVM 및 Repository 패턴

  • 로딩 상태 표시

  • 네트워크 오류 처리

  • 요청 취소

  • 재시도 기능

  • Actor를 이용한 공유 데이터 보호

  • URLSession 테스트

개발 환경

  • Xcode

  • Swift

  • SwiftUI

  • iOS 17 이상

  • Swift Concurrency

  • URLSession

  • MVVM

예제 API는 JSONPlaceholder의 게시글 조회 엔드포인트를 사용합니다.

https://jsonplaceholder.typicode.com/posts

JSONPlaceholder는 학습과 테스트를 위한 공개 REST API입니다. 실제 서비스용 백엔드와는 구분해서 사용해야 합니다.


Swift Concurrency 이해하기

2. 비동기 처리가 필요한 이유

네트워크 요청에는 시간이 필요합니다.

서버 응답이 빠르면 수십 밀리초 만에 완료될 수도 있지만, 네트워크 환경이 좋지 않으면 수 초 이상 걸릴 수 있습니다.

이러한 작업을 메인 스레드에서 동기적으로 기다리면 화면이 멈출 수 있습니다.

예를 들어 다음과 같은 코드는 피해야 합니다.

let data = try Data(
    contentsOf: URL(
        string: "https://example.com/data.json"
    )!
)

이 코드를 메인 스레드에서 실행하면 다운로드가 완료될 때까지 화면이 응답하지 않을 수 있습니다.

네트워크 요청에는 URLSession의 비동기 API를 사용하는 것이 좋습니다.


3. Kotlin Coroutine과 Swift Concurrency 비교

Android 개발자에게 익숙한 개념과 비교해 보겠습니다.

Kotlin / AndroidSwift / iOS
suspend funasync func
suspend 함수 호출await
CoroutineScope.launchTask
Deferred / asyncasync let / TaskGroup
CancellationExceptionCancellationError
Dispatchers.MainMainActor
Mutex 등 동기화Actor 등
Retrofit2URLSession 또는 별도 라이브러리
Gson / kotlinx.serializationCodable
StateFlowObservation / AsyncSequence 등

이 표는 개념적인 비교입니다.

Swift의 Task와 Kotlin의 Job, Swift Actor와 Kotlin Mutex는 내부 동작이나 책임이 완전히 동일하지 않습니다.


async와 await

4. async 함수 선언

Swift에서 비동기 함수는 async 키워드로 선언합니다.

func loadMessage() async -> String {

    return "Hello Swift Concurrency"
}

비동기 함수를 호출할 때는 일반적으로 await를 사용합니다.

let message = await loadMessage()

여기서 중요한 점은 await가 스레드를 반드시 차단한다는 의미가 아니라는 것입니다.

await는 비동기 작업의 완료를 기다리는 동안 현재 작업이 일시 중단될 수 있는 지점을 나타냅니다.


5. async throws

네트워크 요청은 실패할 수 있습니다.

따라서 비동기 함수에서 오류를 전달하려면 async throws를 사용할 수 있습니다.

func fetchData() async throws -> Data {

    let url = URL(
        string: "https://example.com/data.json"
    )!

    let (data, _) = try await URLSession.shared.data(
        from: url
    )

    return data
}

호출할 때는 다음과 같이 작성합니다.

do {

    let data = try await fetchData()

    print(data.count)

} catch {

    print("오류: \(error)")
}

try는 오류가 발생할 수 있음을, await는 비동기 작업을 기다릴 수 있음을 나타냅니다.


URLSession 사용하기

6. URLSession이란?

URLSession은 Apple 플랫폼에서 HTTP 및 HTTPS 네트워크 통신을 처리하는 프레임워크입니다.

대표적인 기능은 다음과 같습니다.

  • HTTP GET 요청

  • HTTP POST 요청

  • 요청 헤더 설정

  • 파일 다운로드

  • 파일 업로드

  • 응답 데이터 처리

  • 요청 취소

  • 세션 구성

Android의 OkHttp와 비슷한 역할을 수행하는 시스템 네트워킹 API라고 이해할 수 있습니다.


7. 간단한 GET 요청

import Foundation

func fetchPosts() async throws -> Data {

    let url = URL(
        string: "https://jsonplaceholder.typicode.com/posts"
    )!

    let (data, response) = try await URLSession.shared.data(
        from: url
    )

    guard let httpResponse = response as? HTTPURLResponse else {
        throw URLError(.badServerResponse)
    }

    guard (200...299).contains(
        httpResponse.statusCode
    ) else {
        throw URLError(.badServerResponse)
    }

    return data
}

URLSession은 HTTP 상태 코드가 404 또는 500이라고 해서 반드시 오류를 던지는 것은 아닙니다.

따라서 HTTP 응답 코드를 직접 확인해야 합니다.


Codable과 JSON 파싱

8. API 응답 구조 확인

JSONPlaceholder의 게시글 데이터는 다음과 같은 형태입니다.

{
  "userId": 1,
  "id": 1,
  "title": "example title",
  "body": "example content"
}

이 JSON을 Swift 모델로 변환하겠습니다.


9. Post 모델 정의

Post.swift

import Foundation

struct Post: Identifiable, Codable, Sendable {

    let userId: Int
    let id: Int
    let title: String
    let body: String
}

여기서는 세 가지 프로토콜을 사용합니다.

Identifiable은 SwiftUI 목록에서 항목을 식별하는 데 사용합니다.

Codable은 JSON 인코딩과 디코딩을 지원합니다.

Sendable은 동시성 경계를 넘어 안전하게 전달할 수 있는 값 타입임을 나타냅니다.

이번 모델은 불변 프로퍼티와 기본 값 타입으로 구성되어 있으므로 Sendable을 적용하기 적합합니다.


10. JSONDecoder 사용

let decoder = JSONDecoder()

let posts = try decoder.decode(
    [Post].self,
    from: data
)

이렇게 하면 JSON 배열을 [Post]로 변환할 수 있습니다.

Android의 Gson과 비슷한 역할을 수행합니다.


API 오류 모델 만들기

11. 네트워크 오류 구분

실제 앱에서는 모든 오류를 하나의 메시지로 처리하기보다 원인별로 구분하는 것이 좋습니다.

APIError.swift

import Foundation

enum APIError: LocalizedError {

    case invalidResponse
    case httpError(Int)
    case decodingFailed
    case networkUnavailable

    var errorDescription: String? {

        switch self {

        case .invalidResponse:
            return "서버 응답이 올바르지 않습니다."

        case .httpError(let statusCode):
            return "서버 오류가 발생했습니다. (\(statusCode))"

        case .decodingFailed:
            return "데이터를 읽는 중 오류가 발생했습니다."

        case .networkUnavailable:
            return "네트워크 연결을 확인해 주세요."
        }
    }
}

오류를 구분하면 사용자에게 적절한 메시지를 표시하거나 재시도 정책을 다르게 적용할 수 있습니다.


APIClient 구현

12. URLSession 의존성 분리

테스트하기 쉬운 네트워크 구조를 만들기 위해 URLSession을 주입받는 APIClient를 작성하겠습니다.

APIClient.swift

import Foundation

struct APIClient {

    let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func fetchPosts() async throws -> [Post] {

        guard let url = URL(
            string: "https://jsonplaceholder.typicode.com/posts"
        ) else {
            throw URLError(.badURL)
        }

        var request = URLRequest(url: url)

        request.httpMethod = "GET"
        request.timeoutInterval = 15

        let data: Data
        let response: URLResponse

        do {

            (data, response) = try await session.data(
                for: request
            )

        } catch is CancellationError {
            throw CancellationError()

        } catch let error as URLError {

            if error.code == .cancelled {
                throw CancellationError()
            }

            if error.code == .notConnectedToInternet {
                throw APIError.networkUnavailable
            }

            throw error
        }

        guard let httpResponse = response as? HTTPURLResponse else {
            throw APIError.invalidResponse
        }

        guard (200...299).contains(
            httpResponse.statusCode
        ) else {
            throw APIError.httpError(
                httpResponse.statusCode
            )
        }

        do {

            return try JSONDecoder().decode(
                [Post].self,
                from: data
            )

        } catch {

            throw APIError.decodingFailed
        }
    }
}

이 구조에서는 URLSession.shared를 기본으로 사용하지만 테스트에서는 별도로 구성한 URLSession을 전달할 수 있습니다.


Repository 패턴 적용

13. PostRepository

네트워크 통신 코드를 ViewModel에서 직접 호출하지 않도록 Repository를 만들겠습니다.

PostRepository.swift

import Foundation

protocol PostRepositoryProtocol {

    func getPosts() async throws -> [Post]
}

struct PostRepository: PostRepositoryProtocol {

    private let apiClient: APIClient

    init(
        apiClient: APIClient = APIClient()
    ) {
        self.apiClient = apiClient
    }

    func getPosts() async throws -> [Post] {

        return try await apiClient.fetchPosts()
    }
}

Repository를 사용하면 API 호출 방식이 변경되어도 ViewModel의 변경을 줄일 수 있습니다.

또한 테스트에서 가짜 Repository를 주입하기 쉬워집니다.


SwiftUI MVVM 구현

14. UI 상태 설계

네트워크 화면에서는 최소한 다음 상태를 구분하는 것이 좋습니다.

  • 로딩 중

  • 데이터 조회 성공

  • 데이터 없음

  • 오류 발생

이를 위해 ViewModel에서 상태를 관리하겠습니다.


15. PostViewModel 구현

PostViewModel.swift

import Foundation
import Observation

@MainActor
@Observable
final class PostViewModel {

    var posts: [Post] = []
    var isLoading = false
    var errorMessage: String?

    private let repository: any PostRepositoryProtocol

    init(
        repository: any PostRepositoryProtocol = PostRepository()
    ) {
        self.repository = repository
    }

    func loadPosts() async {

        guard !isLoading else {
            return
        }

        isLoading = true
        errorMessage = nil

        defer {
            isLoading = false
        }

        do {

            let result = try await repository.getPosts()

            try Task.checkCancellation()

            posts = result

        } catch is CancellationError {

            // 화면이 사라지는 등으로 요청이 취소된 경우
            // 사용자에게 일반 오류로 표시하지 않습니다.

        } catch {

            errorMessage = error.localizedDescription
        }
    }
}

@MainActor를 사용해 ViewModel의 UI 상태를 메인 액터에서 관리합니다.

Task.checkCancellation()은 요청이 끝난 뒤 작업이 취소되었는지 확인하는 데 사용합니다.


SwiftUI 목록 화면

16. PostListView 구현

PostListView.swift

import SwiftUI

struct PostListView: View {

    @State private var viewModel = PostViewModel()

    var body: some View {

        NavigationStack {

            Group {

                if viewModel.isLoading &&
                   viewModel.posts.isEmpty {

                    ProgressView("게시글 불러오는 중...")

                } else if let message = viewModel.errorMessage,
                          viewModel.posts.isEmpty {

                    ContentUnavailableView {

                        Label(
                            "데이터를 불러오지 못했습니다",
                            systemImage: "wifi.exclamationmark"
                        )

                    } description: {

                        Text(message)

                    } actions: {

                        Button("다시 시도") {
                            Task {
                                await viewModel.loadPosts()
                            }
                        }
                    }

                } else if viewModel.posts.isEmpty {

                    ContentUnavailableView(
                        "게시글이 없습니다",
                        systemImage: "tray"
                    )

                } else {

                    List(viewModel.posts) { post in

                        NavigationLink {

                            PostDetailView(post: post)

                        } label: {

                            VStack(
                                alignment: .leading,
                                spacing: 8
                            ) {

                                Text(post.title)
                                    .font(.headline)

                                Text(post.body)
                                    .font(.subheadline)
                                    .foregroundStyle(.secondary)
                                    .lineLimit(2)
                            }
                            .padding(.vertical, 4)
                        }
                    }
                    .refreshable {
                        await viewModel.loadPosts()
                    }
                }
            }
            .navigationTitle("게시글")
            .task {
                await viewModel.loadPosts()
            }
        }
    }
}

이 화면에서는 로딩 상태, 오류 상태, 빈 데이터 상태, 정상 목록 상태를 구분해 표시합니다.

refreshable을 사용하면 목록을 아래로 당겨 데이터를 새로고침할 수 있습니다.


상세 화면

17. PostDetailView

PostDetailView.swift

import SwiftUI

struct PostDetailView: View {

    let post: Post

    var body: some View {

        ScrollView {

            VStack(
                alignment: .leading,
                spacing: 20
            ) {

                Text(post.title)
                    .font(.title2)
                    .fontWeight(.bold)

                Text(post.body)
                    .font(.body)

                Divider()

                Text("게시글 ID: \(post.id)")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            .frame(
                maxWidth: .infinity,
                alignment: .leading
            )
            .padding()
        }
        .navigationTitle("게시글 상세")
        .navigationBarTitleDisplayMode(.inline)
    }
}

목록에서 게시글을 선택하면 상세 화면으로 이동합니다.


앱 진입점 연결

18. ContentView 설정

이번 예제를 실행하려면 앱 시작 화면을 변경합니다.

import SwiftUI

@main
struct NetworkSampleApp: App {

    var body: some Scene {

        WindowGroup {
            PostListView()
        }
    }
}

기존 프로젝트에 적용한다면 새로운 @main 앱 구조체를 추가하지 말고 기존 앱 진입점의 첫 화면을 변경해야 합니다.

이제 시뮬레이터에서 실행하면 JSONPlaceholder의 게시글 목록을 조회할 수 있습니다.


Task 이해하기

19. Task란?

Swift의 Task는 비동기 작업을 실행하고 관리하는 단위입니다.

예를 들어 일반적인 버튼 이벤트에서 비동기 함수를 호출하려면 다음과 같이 작성할 수 있습니다.

Button("새로고침") {

    Task {
        await viewModel.loadPosts()
    }
}

Task는 비동기 작업을 시작하지만, 아무 곳에서나 생성하면 작업의 소유와 취소 시점이 불분명해질 수 있습니다.

화면과 연관된 데이터 로딩에는 SwiftUI의 .task를 우선 고려하는 것이 좋습니다.


20. .task와 Task 차이

구분.taskTask
사용 위치SwiftUI View Modifier비동기 작업 생성
화면 생명주기 연계기본적으로 연계직접 관리 필요
대표 용도화면 데이터 로딩버튼 이벤트, 별도 작업
취소View 생명주기에 따라 자동 취소 가능Task 핸들로 직접 취소 가능

SwiftUI의 .task는 View가 사라질 때 연결된 작업을 취소할 수 있습니다.

단, 취소는 협력적으로 동작하므로 내부 코드가 취소를 적절히 처리해야 합니다.


Task 취소 처리

21. 취소 가능한 작업

다음과 같이 Task를 저장하면 직접 취소할 수 있습니다.

var loadingTask: Task<Void, Never>?

loadingTask = Task {
    await viewModel.loadPosts()
}

작업을 취소할 때는 다음과 같이 호출합니다.

loadingTask?.cancel()

취소 요청을 했다고 해서 모든 작업이 즉시 종료되는 것은 아닙니다.

비동기 함수가 취소를 확인하거나 취소 가능한 API를 사용해야 합니다.


Actor 이해하기

22. Actor란?

여러 비동기 작업이 동일한 데이터를 동시에 수정하면 데이터 경쟁 문제가 발생할 수 있습니다.

Swift의 Actor는 격리된 상태를 관리하도록 설계된 참조 타입입니다.

예를 들어 여러 작업이 공유하는 메모리 캐시를 Actor로 관리할 수 있습니다.


23. Actor 기반 캐시 구현

PostCache.swift

import Foundation

actor PostCache {

    private var cachedPosts: [Post] = []

    func getPosts() -> [Post] {
        cachedPosts
    }

    func savePosts(_ posts: [Post]) {
        cachedPosts = posts
    }

    func clear() {
        cachedPosts.removeAll()
    }
}

다른 Actor 또는 격리 영역에서 접근할 때는 일반적으로 await를 사용합니다.

let cache = PostCache()

await cache.savePosts(posts)

let cached = await cache.getPosts()

Actor는 공유 상태를 보호하는 데 도움이 되지만, 모든 비동기 작업을 자동으로 병렬 실행하거나 모든 논리적 경쟁 상태를 해결해 주는 것은 아닙니다.


MainActor 이해하기

24. UI 상태는 어디에서 변경해야 할까?

SwiftUI에서 화면과 연결된 상태를 변경할 때는 액터 격리를 고려해야 합니다.

이번 예제의 ViewModel에는 다음과 같이 선언했습니다.

@MainActor
@Observable
final class PostViewModel {
    // ...
}

이렇게 하면 ViewModel의 상태가 메인 액터에 격리됩니다.

Android에서 Dispatchers.Main을 사용하는 것과 비슷한 목적이지만 Swift에서는 액터 격리와 컴파일러의 동시성 검사가 함께 작동합니다.


병렬 작업 실행

25. async let

서로 독립적인 두 비동기 작업을 병렬로 실행하려면 async let을 사용할 수 있습니다.

async let first = fetchFirstData()
async let second = fetchSecondData()

let (firstResult, secondResult) =
    try await (first, second)

이 방식은 두 작업 사이에 의존성이 없을 때 유용합니다.

예를 들어 사용자 프로필과 공지사항을 서로 다른 API에서 가져오는 경우에 사용할 수 있습니다.

다만 서버 요청 수가 증가하므로 무조건 병렬화하는 것이 좋은 것은 아닙니다.


TaskGroup 활용

26. 여러 작업을 동시에 실행하기

실행할 작업의 개수가 동적으로 결정되는 경우에는 TaskGroup을 사용할 수 있습니다.

func fetchAll(
    ids: [Int]
) async throws -> [Post] {

    try await withThrowingTaskGroup(
        of: Post.self
    ) { group in

        for id in ids {

            group.addTask {
                try await fetchPost(id: id)
            }
        }

        var result: [Post] = []

        for try await post in group {
            result.append(post)
        }

        return result
    }
}

이 예제의 fetchPost(id:)는 별도로 구현한 비동기 API 함수라고 가정합니다.

TaskGroup에서 결과가 반환되는 순서는 요청을 추가한 순서와 다를 수 있습니다.

따라서 결과 순서가 중요하다면 ID나 원래 인덱스를 함께 관리해야 합니다.

또한 요청이 많을 때는 동시 실행 개수를 제한하는 설계를 검토해야 합니다.


POST 요청 구현

27. URLRequest 사용

GET뿐 아니라 POST 요청도 구현할 수 있습니다.

func createPost(
    title: String,
    body: String
) async throws -> Post {

    let url = URL(
        string: "https://jsonplaceholder.typicode.com/posts"
    )!

    var request = URLRequest(url: url)

    request.httpMethod = "POST"

    request.setValue(
        "application/json",
        forHTTPHeaderField: "Content-Type"
    )

    let requestBody = [
        "title": title,
        "body": body,
        "userId": 1
    ] as [String: Any]

    request.httpBody = try JSONSerialization.data(
        withJSONObject: requestBody
    )

    let (data, response) = try await URLSession.shared.data(
        for: request
    )

    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw APIError.invalidResponse
    }

    return try JSONDecoder().decode(
        Post.self,
        from: data
    )
}

JSONPlaceholder의 POST API는 테스트용 응답을 반환하지만 실제 서버 데이터가 영구적으로 저장되는 것은 아닙니다.

실제 서비스에서는 인증, 입력 검증, 서버 오류, 중복 요청 처리 등을 추가해야 합니다.


네트워크 타임아웃

28. 요청 제한 시간 설정

네트워크 요청이 무한정 기다리도록 설계해서는 안 됩니다.

URLRequest에는 타임아웃을 설정할 수 있습니다.

var request = URLRequest(url: url)

request.timeoutInterval = 15

URLSessionConfiguration을 통해 세션 수준의 타임아웃 정책도 구성할 수 있습니다.

let configuration = URLSessionConfiguration.default

configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 60

let session = URLSession(
    configuration: configuration
)

요청 타임아웃과 전체 리소스 타임아웃은 목적이 다릅니다.

앱의 사용 환경에 따라 적절한 값을 설정해야 합니다.


재시도 전략

29. 모든 오류를 재시도하면 안 되는 이유

네트워크 오류가 발생했다고 해서 모든 요청을 반복하면 안 됩니다.

예를 들어 다음과 같이 구분할 수 있습니다.

오류재시도 검토
일시적인 네트워크 끊김가능
요청 타임아웃가능
HTTP 500 계열상황에 따라 가능
HTTP 401인증 처리 우선
HTTP 403권한 확인
HTTP 404일반적으로 무조건 재시도하지 않음
JSON 디코딩 실패응답 형식 확인
사용자 취소재시도하지 않음

특히 POST 요청은 중복 실행 시 데이터가 중복 생성될 수 있습니다.

따라서 멱등성 보장이 없는 요청을 자동 재시도할 때는 주의해야 합니다.


SwiftUI 네트워크 상태 관리

30. 로딩과 오류를 명확히 분리하기

실제 앱에서는 다음과 같은 상태 구조를 사용할 수 있습니다.

enum LoadingState<T> {

    case idle
    case loading
    case success(T)
    case failure(String)
}

이 방식은 여러 Boolean 변수를 조합하는 것보다 상태를 명확하게 표현할 수 있습니다.

다만 제네릭 상태 타입을 Observation과 함께 사용할 때는 데이터 타입과 UI 갱신 정책을 고려해야 합니다.


네트워크 테스트

31. URLProtocol을 이용한 테스트

실제 서버에 요청하지 않고도 URLSession의 동작을 테스트할 수 있습니다.

Apple 플랫폼에서는 URLProtocol을 이용해 네트워크 응답을 가로채는 테스트 구조를 만들 수 있습니다.

예를 들어 다음과 같은 테스트를 구성할 수 있습니다.

  • 정상 JSON 응답

  • HTTP 404 응답

  • HTTP 500 응답

  • 잘못된 JSON

  • 타임아웃

  • 네트워크 연결 실패

  • 요청 취소

테스트에서는 실제 외부 API에 의존하지 않는 것이 좋습니다.


API 보안

32. API Key 관리

실제 서버 API를 사용하는 경우 API Key가 필요할 수 있습니다.

하지만 iOS 앱에 포함된 API Key는 앱 분석을 통해 추출될 가능성이 있습니다.

따라서 앱 내부에 비밀 API Key를 저장하는 방식은 안전하지 않습니다.

서버에서 보호해야 하는 비밀 키는 백엔드에 보관하고, 앱은 백엔드를 통해 필요한 데이터를 요청하도록 설계하는 것이 좋습니다.

사용자 인증 토큰은 저장 목적과 보안 요구사항에 따라 Keychain 사용을 검토할 수 있습니다.


네트워크 성능 최적화

33. 불필요한 API 호출 줄이기

다음과 같은 방법을 고려할 수 있습니다.

  1. 화면 진입 시 중복 호출 방지

  2. 필요한 데이터만 요청

  3. 페이지네이션 적용

  4. 캐시 활용

  5. 이미지 다운로드 최적화

  6. 요청 취소 처리

  7. 적절한 타임아웃 설정

  8. 서버 오류 재시도 제한

  9. 백그라운드 작업 정책 확인

  10. 네트워크 상태에 따른 UI 제공

특히 목록 화면에서 사용자가 빠르게 이동할 때 불필요한 요청이 계속 발생하지 않도록 관리하는 것이 중요합니다.


실전 적용 사례

34. 생물도감 앱

생물도감 앱에서는 다음과 같은 구조를 사용할 수 있습니다.

SwiftUI View
     │
     ▼
ViewModel
     │
     ▼
Repository
     │
     ▼
APIClient
     │
     ▼
URLSession
     │
     ▼
Spring Boot API

서버에서는 생물 정보를 JSON으로 제공하고, iPhone 앱에서는 URLSession으로 조회할 수 있습니다.

조회한 데이터는 SwiftData에 캐시하는 방식으로 확장할 수도 있습니다.


35. Android와 iOS API 코드 비교

Android에서는 Retrofit2를 이용해 다음과 같이 작성할 수 있습니다.

interface PostApi {

    @GET("posts")
    suspend fun getPosts(): List<Post>
}

Swift에서는 URLSession을 이용해 다음과 같이 구현할 수 있습니다.

func getPosts() async throws -> [Post] {

    let (data, _) = try await URLSession.shared.data(
        from: URL(
            string: "https://jsonplaceholder.typicode.com/posts"
        )!
    )

    return try JSONDecoder().decode(
        [Post].self,
        from: data
    )
}

Retrofit은 인터페이스 선언을 기반으로 요청 코드를 생성하는 라이브러리인 반면, URLSession은 Apple의 기본 네트워킹 API입니다.

따라서 코드 작성 방식에는 차이가 있습니다.


테스트 체크리스트

36. 출시 전 확인 사항

  • async/await 이해

  • URLSession GET 요청

  • HTTP 상태 코드 검사

  • Codable JSON 파싱

  • API 오류 구분

  • Repository 구현

  • ViewModel 구현

  • @MainActor 상태 관리

  • 로딩 UI 표시

  • 오류 UI 표시

  • 빈 데이터 화면 표시

  • 새로고침 구현

  • 요청 취소

  • 타임아웃 설정

  • 재시도 정책

  • Actor 공유 상태 관리

  • POST 요청 테스트

  • 잘못된 JSON 처리

  • 실제 iPhone 네트워크 테스트

  • 비행기 모드 테스트

  • 서버 오류 테스트

  • 개인정보 및 인증 정보 보호


37. 마무리

Swift Concurrency는 iPhone 앱에서 비동기 작업을 구현하는 핵심 기능입니다.

특히 async/await, Task, Actor, MainActor를 이해하면 네트워크 통신뿐 아니라 파일 처리, 데이터베이스 작업, 이미지 다운로드 등 다양한 기능에 활용할 수 있습니다.

이번 글의 핵심 내용을 정리하면 다음과 같습니다.

  1. async/await로 비동기 함수를 작성할 수 있습니다.

  2. URLSession으로 HTTP 네트워크 통신을 구현할 수 있습니다.

  3. Codable로 JSON 데이터를 Swift 모델로 변환할 수 있습니다.

  4. HTTP 상태 코드는 별도로 검사해야 합니다.

  5. Task를 이용해 비동기 작업을 시작하고 취소할 수 있습니다.

  6. MainActor를 이용해 UI 상태의 액터 격리를 관리할 수 있습니다.

  7. Actor를 이용해 공유 상태를 보호할 수 있습니다.

  8. Repository 패턴으로 네트워크 로직을 분리할 수 있습니다.

  9. SwiftUI에서 로딩·오류·성공 상태를 구분할 수 있습니다.

  10. 실제 서비스에서는 타임아웃, 재시도, 캐시, 인증을 함께 설계해야 합니다.

Android의 Kotlin Coroutine과 Retrofit2를 사용해 본 개발자라면 Swift Concurrency도 비슷한 문제를 해결하기 위한 도구라는 점을 이해할 수 있을 것입니다.

다만 Swift는 Actor 격리와 Sendable 같은 언어 차원의 동시성 안전 기능을 제공하므로, 단순히 Kotlin 코드를 Swift 문법으로 변환하는 방식보다는 Swift의 동시성 모델에 맞게 설계하는 것이 중요합니다.

Swift 네트워크 개발의 핵심은 API 호출 자체보다 비동기 작업의 생명주기, 취소, 오류 처리, UI 상태를 안정적으로 관리하는 것입니다.


참고 자료

태그: iOS, Swift, SwiftUI, Swift Concurrency, async await, Task, Actor, MainActor, URLSession, REST API, JSON, Codable, MVVM, Repository, 네트워크 통신, iPhone 앱 개발