챌린지 2-2 · 6 Topics

🌐 인터넷에서 데이터 가져오기

서버에서 실시간 데이터를 받아와야 한다

async / await URLSession Codable · JSONDecoder Task · .task modifier 로딩 / 에러 상태 @MainActor
시작 전에 던져볼 질문
01
네트워크 요청이 왜 비동기(async)여야 하는가?
검색 → why network requests must be async iOS main thread
02
Swift의 async/await는 콜백(completion handler)과 어떻게 다른가?
검색 → Swift async await vs completion handler differences
03
API 응답이 늦을 때 사용자에게 어떻게 피드백을 줘야 하는가?
검색 → SwiftUI loading state ProgressView skeleton
Topic 01

async / await 기초

비동기 코드를 동기 코드처럼 읽히게 만드는 Swift Concurrency
Swift
// ❌ 콜백 방식 — 중첩이 깊어진다 (Callback Hell)
func fetchUser(completion: @escaping (User?) -> Void) {
    URLSession.shared.dataTask(with: url) { data, _, _ in
        guard let data = data else { completion(nil); return }
        completion(try? JSONDecoder().decode(User.self, from: data))
    }.resume()
}

// ✅ async/await 방식 — 위에서 아래로 읽힌다
func fetchUser() async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

// await: "여기서 기다려, 결과 오면 계속 진행해"
// async: "이 함수는 비동기 문맥에서만 호출 가능"
// throws: "에러가 발생할 수 있다, try로 처리해야 한다"

// 호출 방법
Task {
    do {
        let user = try await fetchUser()
        print(user.name)
    } catch {
        print("에러: \(error)")
    }
}
생각해보기
  • await를 만나면 현재 스레드가 멈추는가, 아니면 다른 작업을 처리하러 가는가?
  • async throws 함수를 호출할 때 try await 순서가 왜 이 순서인가?
  • SwiftUI View의 .task { }onAppear { Task { } }의 차이는?
await의 의미
현재 실행을 일시 중단하고 결과를 기다림. 스레드를 블록하지 않고 다른 작업이 그 스레드를 사용할 수 있다
Task { }
동기 컨텍스트에서 비동기 코드를 시작하는 컨테이너. 취소, 우선순위 설정 가능
.task modifier
View가 나타날 때 Task를 시작, 사라질 때 자동 취소. onAppear + Task의 개선된 버전
Topic 02

URLSession — HTTP 요청

GET 요청으로 API에서 데이터를 가져오는 기본 패턴
Swift
// API 응답 모델
struct Post: Codable, Identifiable {
    let id: Int
    let title: String
    let body: String
    let userId: Int
}

// 네트워크 함수
func fetchPosts() async throws -> [Post] {
    guard let url = URL(
        string: "https://jsonplaceholder.typicode.com/posts"
    ) else {
        throw URLError(.badURL)
    }

    // data(from:)은 iOS 15+에서 async/await 지원
    let (data, response) = try await URLSession.shared
        .data(from: url)

    // HTTP 상태 코드 확인
    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }

    let decoder = JSONDecoder()
    // API 키가 snake_case면 자동 변환
    // decoder.keyDecodingStrategy = .convertFromSnakeCase
    return try decoder.decode([Post].self, from: data)
}

// POST 요청 (데이터 전송)
func createPost(_ post: Post) async throws -> Post {
    guard let url = URL(
        string: "https://jsonplaceholder.typicode.com/posts"
    ) else { throw URLError(.badURL) }

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json",
        forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(post)

    let (data, _) = try await URLSession.shared
        .data(for: request)
    return try JSONDecoder().decode(Post.self, from: data)
}
생각해보기
  • HTTP 상태 코드 200과 404와 500은 각각 무엇을 의미하는가?
  • URLSession.shared와 직접 만든 URLSession의 차이는? 언제 직접 만들어야 하는가?
  • API 인증 토큰은 어디에 어떻게 포함해야 하는가? (Header vs Query parameter)
URLSession.shared.data(from:)
GET 요청의 가장 간단한 형태. (Data, URLResponse) 튜플 반환
URLRequest
HTTP Method, Header, Body를 포함한 요청 객체. POST/PUT/PATCH/DELETE에 사용
keyDecodingStrategy
.convertFromSnakeCase: user_nameuserName 자동 변환. CodingKeys 없이도 매핑 가능
Topic 03

로딩 / 성공 / 에러 상태 처리

네트워크 요청의 3가지 상태를 View에서 표현하는 방법
Swift
// enum으로 상태 표현
enum LoadingState {
    case idle
    case loading
    case success(T)
    case failure(Error)
}

struct PostListView: View {
    @State private var state: LoadingState<[Post]> = .idle

    var body: some View {
        Group {
            switch state {
            case .idle:
                Color.clear.onAppear { load() }
            case .loading:
                ProgressView("불러오는 중...")
            case .success(let posts):
                List(posts) { post in
                    VStack(alignment: .leading, spacing: 4) {
                        Text(post.title)
                            .font(.headline)
                        Text(post.body)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                            .lineLimit(2)
                    }
                }
            case .failure(let error):
                VStack(spacing: 16) {
                    Image(systemName: "wifi.slash")
                        .font(.system(size: 40))
                        .foregroundStyle(.secondary)
                    Text(error.localizedDescription)
                        .multilineTextAlignment(.center)
                    Button("다시 시도") { load() }
                        .buttonStyle(.borderedProminent)
                }
                .padding()
            }
        }
        .navigationTitle("포스트")
    }

    func load() {
        state = .loading
        Task {
            do {
                let posts = try await fetchPosts()
                state = .success(posts)
            } catch {
                state = .failure(error)
            }
        }
    }
}
생각해보기
  • LoadingState를 enum으로 표현하면 @State var isLoading: Bool + @State var error: Error?로 나누는 것보다 어떤 점이 좋은가?
  • .task { }로 데이터를 불러올 때 View가 사라지면 무슨 일이 일어나는가?
  • 같은 화면에서 여러 API를 동시에 호출하려면 어떻게 하는가? (async let)
enum 상태 모델링
가능한 상태를 명시적으로 표현. 누락된 상태 처리를 컴파일러가 잡아준다 (switch의 exhaustive 체크)
async let — 병렬 요청
async let a = fetchA(); async let b = fetchB(); let (ra, rb) = try await (a, b) — 두 요청을 동시에 실행
ProgressView
로딩 표시 컴포넌트. ProgressView("텍스트") 스피너, ProgressView(value: 0.5) 프로그레스 바
Topic 04

@MainActor — UI는 메인 스레드에서

백그라운드에서 받은 데이터를 안전하게 UI에 반영하는 방법
Swift
// ViewModel에 @MainActor를 붙이면
// 모든 프로퍼티 변경이 메인 스레드에서 실행됨
@MainActor
class NewsViewModel: ObservableObject {
    @Published var articles: [Article] = []
    @Published var isLoading = false
    @Published var errorMessage: String?

    func fetchArticles() async {
        isLoading = true
        errorMessage = nil

        do {
            // 네트워크는 백그라운드에서 실행
            articles = try await NewsService.fetch()
        } catch {
            // @MainActor이므로 UI 업데이트가 안전
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }
}

struct NewsView: View {
    @StateObject private var vm = NewsViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if vm.isLoading {
                    ProgressView()
                } else {
                    List(vm.articles) { article in
                        Text(article.title)
                    }
                }
            }
            .navigationTitle("뉴스")
        }
        .task { await vm.fetchArticles() }
    }
}
생각해보기
  • UI 업데이트를 메인 스레드에서 해야 하는 이유는 무엇인가?
  • @MainActor 없이 백그라운드 스레드에서 @Published를 바꾸면 어떤 경고/오류가 발생하는가?
  • @StateObject@ObservedObject 중 ViewModel을 생성하는 View에서는 어느 것을 써야 하는가?
@MainActor
클래스나 함수가 항상 메인 스레드에서 실행됨을 보장. UIKit/SwiftUI 업데이트는 메인 스레드 필수
Actor
데이터 경쟁 조건(race condition)을 방지하는 Swift Concurrency 기본 타입. MainActor는 특수한 글로벌 액터
Topic 05

네트워크 에러 처리

예측 가능한 에러를 타입으로 정의하고 사용자에게 알리는 방법
Swift
// 커스텀 에러 타입
enum NetworkError: LocalizedError {
    case invalidURL
    case badResponse(statusCode: Int)
    case decodingFailed
    case noInternet

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "잘못된 URL입니다."
        case .badResponse(let code):
            return "서버 오류 (\(code)). 잠시 후 다시 시도해주세요."
        case .decodingFailed:
            return "데이터를 읽을 수 없습니다."
        case .noInternet:
            return "인터넷 연결을 확인해주세요."
        }
    }
}

// 에러 타입 활용
func fetchData(from urlString: String) async throws -> Data {
    guard let url = URL(string: urlString) else {
        throw NetworkError.invalidURL
    }

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

    guard let http = response as? HTTPURLResponse else {
        throw NetworkError.badResponse(statusCode: 0)
    }

    guard (200...299).contains(http.statusCode) else {
        throw NetworkError.badResponse(
            statusCode: http.statusCode)
    }

    return data
}

// 에러 분기 처리
do {
    let data = try await fetchData(from: "https://...")
    let posts = try JSONDecoder().decode([Post].self, from: data)
} catch let error as NetworkError {
    print(error.errorDescription ?? "")
} catch is DecodingError {
    print("JSON 파싱 실패")
} catch {
    print("알 수 없는 오류: \(error)")
}
생각해보기
  • LocalizedError 프로토콜을 채택하면 어떤 이점이 있는가?
  • catch let error as NetworkErrorcatch의 실행 순서는? 더 구체적인 catch를 먼저 써야 하는가?
  • 네트워크 실패 시 자동으로 재시도하는 로직은 어떻게 구현하는가?
LocalizedError
errorDescription을 구현하면 error.localizedDescription이 사용자 친화적 메시지를 반환
에러 계층 분기
구체적인 에러 타입을 먼저 catch. 마지막 catch error는 모든 에러를 처리하는 폴백
Topic 06

실전 네트워크 레이어 패턴

재사용 가능한 API 클라이언트 만들기
Swift
// 재사용 가능한 제네릭 네트워크 함수
struct APIClient {
    static let shared = APIClient()
    private let baseURL = "https://jsonplaceholder.typicode.com"
    private let decoder: JSONDecoder = {
        let d = JSONDecoder()
        d.keyDecodingStrategy = .convertFromSnakeCase
        return d
    }()

    func fetch(_ path: String) async throws -> T {
        guard let url = URL(string: baseURL + path) else {
            throw NetworkError.invalidURL
        }
        let (data, response) = try await URLSession.shared
            .data(from: url)
        guard let http = response as? HTTPURLResponse,
              (200...299).contains(http.statusCode) else {
            throw NetworkError.badResponse(statusCode: 0)
        }
        do {
            return try decoder.decode(T.self, from: data)
        } catch {
            throw NetworkError.decodingFailed
        }
    }
}

// 사용 — 타입 추론으로 깔끔하게
let posts: [Post] = try await APIClient.shared.fetch("/posts")
let user: User = try await APIClient.shared.fetch("/users/1")
생각해보기
  • fetch<T: Decodable>에서 T는 호출 시점에 어떻게 결정되는가?
  • 싱글톤 APIClient.shared의 장점과 테스트에서의 단점은?
  • 인증이 필요한 API에서 Bearer 토큰을 모든 요청에 자동으로 추가하려면?
제네릭 fetch 함수
반환 타입을 호출 시점에 결정. URL, 헤더, 에러 처리가 한 곳에 집중되어 중복 제거
싱글톤 패턴
앱 전체에서 하나의 인스턴스 공유. 설정을 한 번만 정의. 테스트 시 모킹이 어려운 단점
async/await
func fetch() async throws
  let data = try await
    URLSession...
  return try decode(...)
위에서 아래로 읽힌다
URLSession
GET /posts → 200 OK
첫 번째 포스트 제목
sunt aut facere...
두 번째 포스트 제목
qui est esse...
Loading States
.loading
.success
✓ 데이터 로드 완료
.failure
⚠ 다시 시도
@MainActor
📰
뉴스 제목
요약 내용...
뉴스 제목 2
요약 내용...
@MainActor → UI 안전
에러 처리
📡
인터넷 연결을
확인해주세요
NetworkError.noInternet
다시 시도
API Client
let posts: [Post] =
  try await
  APIClient
    .shared
    .fetch("/posts")
Homework

챌린지 2-2를 마쳤다면

필수
JSONPlaceholder API로 포스트 목록 앱
https://jsonplaceholder.typicode.com/posts 에서 데이터 fetch. 로딩/에러/성공 상태를 모두 표시. 항목 탭 시 상세 화면으로 이동
필수
@MainActor ViewModel로 리팩터링
위 앱의 네트워크 로직을 @MainActor ObservableObject ViewModel로 분리. View는 ViewModel의 상태만 바라보도록 구성
도전
검색 기능 + 캐싱
제목으로 포스트를 필터링하는 검색 기능 추가. 한번 불러온 데이터는 다시 요청하지 않도록 메모리 캐시 구현
완료 체크리스트
async/await로 URLSession 요청을 작성할 수 있다
로딩/성공/실패 상태를 enum으로 표현하고 View에서 분기할 수 있다
@MainActor ViewModel로 네트워크 코드를 분리할 수 있다
커스텀 에러 타입을 만들고 do-catch로 분기 처리할 수 있다
← 이전 챌린지 2-1 데이터를 앱에 저장하기
코멘트
후원하기
콘텐츠가 도움이 됐다면
커피 한 잔의 후원을 부탁드려요 :)
카카오페이 QR
이현호
카카오페이
카카오톡 QR
카카오톡 오픈채팅 💬
학습 질문 · 코드 리뷰 · 링크로 참여