앱은 서버와 대화한다
현대 iOS 앱은 혼자 동작하지 않는 경우가 많습니다. 날씨 앱은 서버에서 현재 날씨를 가져오고, 쇼핑 앱은 상품 목록을 내려받고, 메신저 앱은 새 메시지를 주고받습니다. 사용자가 보는 화면 뒤에는 대부분 네트워크 통신이 있습니다.
네트워크 통신은 어렵게 느껴질 수 있지만, 기본 구조는 단순합니다.
앱이 서버에 요청을 보냅니다. 서버는 그 요청을 처리한 뒤 응답을 돌려줍니다. 앱은 응답 데이터를 Swift 모델로 바꾸고, 그 모델을 화면에 표시합니다.
이 흐름을 코드로 표현하면 다음 순서가 됩니다.
어떤 주소로 요청할지 정한다.
요청을 보낸다.
응답 상태가 성공인지 확인한다.
JSON 데이터를 Swift 타입으로 변환한다.
로딩, 성공, 실패 상태를 화면에 반영한다.
Apple의 URLSession은 iOS에서 네트워크 데이터 전송 작업을 다루는 기본 도구입니다.1 이 장에서는 URLSession과 async/await를 사용해 서버 데이터를 가져오고 SwiftUI 화면에 연결하는 방법을 배웁니다.

HTTP 요청과 응답 이해하기
앱과 서버가 대화할 때 가장 자주 사용하는 방식이 HTTP입니다. HTTP는 클라이언트와 서버가 데이터를 주고받기 위한 약속입니다.
여기서 클라이언트는 iPhone 앱이고, 서버는 데이터를 제공하는 외부 시스템입니다.
URL은 서버 자원의 주소다
URL은 데이터를 가져올 위치를 나타냅니다.
https://api.example.com/posts?page=1이 주소는 여러 부분으로 나눌 수 있습니다.
| 부분 | 예 | 의미 |
|---|---|---|
| scheme | https | 통신 방식 |
| host | api.example.com | 서버 주소 |
| path | /posts | 서버 안의 자원 위치 |
| query | page=1 | 추가 조건 |
실제 앱에서는 문자열을 직접 이어 붙이기보다 URLComponents를 사용하면 안전합니다.
var components = URLComponents()
components.scheme = "https"
components.host = "jsonplaceholder.typicode.com"
components.path = "/posts"
components.queryItems = [
URLQueryItem(name: "userId", value: "1")
]
let url = components.urlURLComponents를 쓰면 공백, 한글, 특수 문자처럼 URL에 그대로 넣기 어려운 값도 비교적 안전하게 처리할 수 있습니다.
HTTP 메서드는 요청의 의도를 나타낸다
같은 주소에 요청하더라도 의도는 다를 수 있습니다. 글 목록을 가져올 수도 있고, 새 글을 만들 수도 있습니다. 이 의도를 나타내는 값이 HTTP 메서드입니다.
| 메서드 | 주 용도 | 예 |
|---|---|---|
GET | 데이터 조회 | 게시글 목록 가져오기 |
POST | 새 데이터 생성 | 새 게시글 작성하기 |
PUT | 전체 수정 | 프로필 전체 변경하기 |
PATCH | 일부 수정 | 닉네임만 변경하기 |
DELETE | 삭제 | 게시글 삭제하기 |
처음에는 GET 요청부터 익히면 됩니다. 대부분의 목록 화면, 상세 화면은 서버에서 데이터를 조회하는 GET 요청으로 시작합니다.
상태 코드는 서버 응답의 결과다
서버는 응답과 함께 상태 코드를 보냅니다. 상태 코드는 요청이 성공했는지, 실패했다면 어떤 종류의 실패인지 알려줍니다.
| 범위 | 의미 | 예 |
|---|---|---|
| 200번대 | 성공 | 200 OK, 201 Created |
| 300번대 | 다른 위치로 이동 | 301 Moved Permanently |
| 400번대 | 클라이언트 요청 문제 | 400 Bad Request, 401 Unauthorized, 404 Not Found |
| 500번대 | 서버 문제 | 500 Internal Server Error |
네트워크 코드는 단순히 데이터를 받는 데서 끝나면 안 됩니다. 반드시 상태 코드를 확인해야 합니다. 서버가 404나 500을 보냈는데도 앱이 성공으로 처리하면, 화면에는 이상한 데이터가 표시되거나 디코딩 오류가 발생할 수 있습니다.
JSON과 Swift 모델 연결하기
서버는 보통 JSON 형식으로 데이터를 내려줍니다. JSON은 키와 값으로 이루어진 가벼운 데이터 형식입니다.
예를 들어 게시글 데이터는 다음처럼 생겼습니다.
{
"userId": 1,
"id": 1,
"title": "Hello",
"body": "First post"
}Swift에서는 이 JSON을 구조체로 표현합니다.
struct Post: Identifiable, Decodable {
let userId: Int
let id: Int
let title: String
let body: String
}여기서 Decodable은 JSON 데이터를 Swift 타입으로 바꿀 수 있다는 뜻입니다. Identifiable은 SwiftUI의 List에서 각 항목을 구분하기 위해 사용합니다.
JSON의 키 이름과 Swift 프로퍼티 이름이 같으면 JSONDecoder가 자동으로 값을 연결합니다.
let posts = try JSONDecoder().decode([Post].self, from: data)[Post].self는 "Post 배열로 해석하라"는 뜻입니다. 서버 응답이 배열이면 [Post], 단일 객체이면 Post를 사용합니다.
URLSession으로 데이터 가져오기
가장 단순한 GET 요청부터 작성해 보겠습니다. Swift의 async/await를 사용하면 비동기 네트워크 코드를 동기 코드처럼 위에서 아래로 읽을 수 있습니다. URLSession의 async API는 try await URLSession.shared.data(from:) 같은 형태로 데이터를 가져올 수 있습니다.2
import Foundation
struct PostAPI {
func fetchPosts() async throws -> [Post] {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
throw NetworkError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.badStatusCode(httpResponse.statusCode)
}
guard !data.isEmpty else {
throw NetworkError.emptyData
}
do {
return try JSONDecoder().decode([Post].self, from: data)
} catch {
throw NetworkError.decodingFailed
}
}
}이 코드에서 중요한 지점은 네 가지입니다.
첫째, URL이 올바르지 않으면 요청을 시작하지 않습니다.
둘째, try await로 네트워크 요청을 기다립니다. 네트워크 요청은 시간이 걸릴 수 있으므로 앱은 그동안 멈추지 않고 다른 일을 할 수 있습니다.
셋째, 응답을 HTTPURLResponse로 바꾼 뒤 상태 코드를 확인합니다.
넷째, 성공 응답이라도 데이터가 비어 있거나 JSON 구조가 예상과 다르면 오류로 처리합니다.
URLRequest가 필요한 경우
data(from:)은 단순한 GET 요청에 적합합니다. 하지만 실제 앱에서는 요청 헤더, HTTP 메서드, 요청 본문을 설정해야 하는 경우가 많습니다. 이때는 URLRequest를 사용합니다.
func fetchPostsWithRequest() async throws -> [Post] {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.badStatusCode(httpResponse.statusCode)
}
return try JSONDecoder().decode([Post].self, from: data)
}새 데이터를 서버에 보내는 POST 요청은 다음처럼 작성할 수 있습니다.
struct CreatePostRequest: Encodable {
let userId: Int
let title: String
let body: String
}
func createPost(_ post: CreatePostRequest) async throws -> Post {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(post)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.badStatusCode(httpResponse.statusCode)
}
return try JSONDecoder().decode(Post.self, from: data)
}GET은 주로 가져오기, POST는 주로 만들기입니다. 요청 본문이 필요한 경우에는 httpBody에 JSON으로 인코딩한 데이터를 넣습니다.
네트워크 오류를 앱에 맞게 정리하기
네트워크 오류는 여러 이유로 발생합니다.
인터넷 연결이 끊겼다.
서버 주소가 잘못되었다.
서버가 실패 상태 코드를 보냈다.
응답 데이터가 비어 있다.
JSON 구조가 앱의 모델과 맞지 않는다.
이 오류들을 모두 Error 하나로만 처리하면 화면에서 적절한 메시지를 보여 주기 어렵습니다. 앱에서 사용할 오류 타입을 따로 정의하면 좋습니다.
enum NetworkError: Error {
case invalidURL
case invalidResponse
case badStatusCode(Int)
case emptyData
case decodingFailed
case unknown(Error)
var userMessage: String {
switch self {
case .invalidURL:
return "요청 주소가 올바르지 않습니다."
case .invalidResponse:
return "서버 응답을 확인할 수 없습니다."
case .badStatusCode(let code):
if code == 401 || code == 403 {
return "로그인이 필요하거나 접근 권한이 없습니다."
} else if code == 404 {
return "요청한 데이터를 찾을 수 없습니다."
} else if (500..<600).contains(code) {
return "서버에 문제가 발생했습니다. 잠시 후 다시 시도해 주세요."
} else {
return "요청을 처리하지 못했습니다."
}
case .emptyData:
return "표시할 데이터가 없습니다."
case .decodingFailed:
return "데이터 형식이 앱과 맞지 않습니다."
case .unknown:
return "네트워크 오류가 발생했습니다."
}
}
}사용자에게는 짧고 이해하기 쉬운 메시지를 보여 주는 것이 좋습니다. 반대로 개발자가 확인해야 할 자세한 오류 정보는 콘솔 로그나 분석 도구에 남기는 편이 안전합니다.
예를 들어 "The data couldn't be read because it isn't in the correct format." 같은 메시지를 사용자에게 그대로 보여 주면 도움이 되지 않습니다. 사용자는 무엇을 해야 할지 알 수 없습니다. 대신 "데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."처럼 행동 가능한 문장으로 바꿔야 합니다.
ViewModel에서 로딩 상태 관리하기
네트워크 요청은 화면 상태와 함께 다뤄야 합니다. 데이터만 가져오는 코드와 화면을 직접 연결하면 코드가 금방 복잡해집니다.
SwiftUI에서는 ViewModel을 두고 다음 상태를 관리하면 좋습니다.
로딩 중인지
데이터가 있는지
오류 메시지가 있는지
다시 시도할 수 있는지
import Foundation
@MainActor
final class PostListViewModel: ObservableObject {
@Published private(set) var posts: [Post] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
private let api = PostAPI()
func loadPosts() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer {
isLoading = false
}
do {
let fetchedPosts = try await api.fetchPosts()
posts = fetchedPosts
if fetchedPosts.isEmpty {
errorMessage = "표시할 게시글이 없습니다."
}
} catch let error as NetworkError {
errorMessage = error.userMessage
} catch {
errorMessage = "알 수 없는 오류가 발생했습니다."
}
}
}@MainActor는 이 ViewModel의 상태 변경이 메인 스레드에서 일어나도록 돕습니다. 화면 상태는 메인 스레드에서 바뀌어야 안전합니다.
defer는 함수가 끝날 때 실행됩니다. 성공하든 실패하든 isLoading을 false로 돌려놓을 수 있어 로딩 상태를 정리하기 좋습니다.
SwiftUI 화면에 API 데이터 표시하기
이제 ViewModel을 화면에 연결해 보겠습니다.
import SwiftUI
struct PostListView: View {
@StateObject private var viewModel = PostListViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.posts.isEmpty {
ProgressView("게시글을 불러오는 중...")
} else if let errorMessage = viewModel.errorMessage,
viewModel.posts.isEmpty {
VStack(spacing: 12) {
Text(errorMessage)
.multilineTextAlignment(.center)
Button("다시 시도") {
Task {
await viewModel.loadPosts()
}
}
}
.padding()
} else {
List(viewModel.posts) { post in
VStack(alignment: .leading, spacing: 8) {
Text(post.title)
.font(.headline)
Text(post.body)
.font(.body)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
.refreshable {
await viewModel.loadPosts()
}
}
}
.navigationTitle("게시글")
.task {
await viewModel.loadPosts()
}
}
}
}여기서 .task는 화면이 나타날 때 비동기 작업을 실행합니다. 버튼 안에서는 Task { }를 사용해 비동기 함수를 호출합니다.
중요한 점은 body 안에서 직접 네트워크 요청을 시작하지 않는 것입니다. body는 상태가 바뀔 때 여러 번 다시 계산될 수 있습니다. 네트워크 요청은 .task, 버튼 액션, ViewModel 메서드처럼 명확한 실행 지점에서 시작해야 합니다.
빈 응답과 빈 목록은 다르다
네트워크 화면에서 자주 헷갈리는 부분이 있습니다. "서버 응답 데이터가 비어 있다"와 "정상 응답이지만 목록이 비어 있다"는 다릅니다.
예를 들어 서버가 아예 빈 데이터를 보냈다면 디코딩할 JSON이 없으므로 오류로 처리할 수 있습니다.
guard !data.isEmpty else {
throw NetworkError.emptyData
}반면 서버가 정상적으로 빈 배열을 보냈다면 오류가 아닙니다.
[]이 경우에는 "아직 게시글이 없습니다." 같은 빈 상태 화면을 보여 주면 됩니다.
if posts.isEmpty {
Text("아직 게시글이 없습니다.")
}사용자 입장에서 두 상황은 다르게 느껴집니다. 네트워크 실패는 다시 시도 버튼이 필요하고, 빈 목록은 새 항목을 만들거나 기다리면 되는 상태입니다.
이미지 다운로드와 로딩 상태 표시하기
목록 화면에는 텍스트만 있는 경우보다 이미지가 함께 있는 경우가 많습니다. SwiftUI는 원격 이미지를 간단히 표시할 수 있는 AsyncImage를 제공합니다. AsyncImage는 URL에서 이미지를 비동기로 불러와 표시하는 View입니다.3
struct ThumbnailView: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ZStack {
Color.gray.opacity(0.15)
ProgressView()
}
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
ZStack {
Color.gray.opacity(0.15)
Image(systemName: "photo")
.foregroundStyle(.secondary)
}
@unknown default:
EmptyView()
}
}
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}AsyncImagePhase는 이미지 로딩 상태를 나타냅니다.
.empty: 아직 로딩 중이거나 시작 전.success: 이미지 로딩 성공.failure: 이미지 로딩 실패
이미지 로딩에서도 빈 화면을 그대로 두지 않는 것이 중요합니다. 사용자는 이미지가 늦게 뜨는 것인지, 실패한 것인지 알 수 있어야 합니다.
이미지 캐싱을 고려하기
목록을 스크롤할 때 같은 이미지를 계속 다시 다운로드하면 앱이 느려지고 데이터 사용량도 늘어납니다. 그래서 이미지 캐싱이 필요합니다. 캐싱은 한 번 받은 데이터를 임시 저장해 두었다가 다시 사용하는 방식입니다.
기본적인 HTTP 캐싱은 URLCache와 서버의 캐시 헤더 영향을 받습니다. SwiftUI의 원격 이미지 로딩에서도 캐시 동작을 이해하고, 앱 요구 사항에 따라 별도 이미지 캐시나 이미지 라이브러리를 선택할 수 있습니다.4
가벼운 앱에서는 먼저 URLCache 용량을 설정해 볼 수 있습니다.
@main
struct NetworkSampleApp: App {
init() {
URLCache.shared.memoryCapacity = 50 * 1024 * 1024
URLCache.shared.diskCapacity = 200 * 1024 * 1024
}
var body: some Scene {
WindowGroup {
PostListView()
}
}
}다만 모든 이미지가 항상 원하는 방식으로 캐시되는 것은 아닙니다. 서버가 캐시를 허용하지 않거나, 인증 헤더가 필요한 이미지라면 직접 이미지 로더를 만드는 편이 낫습니다.
아래는 메모리 캐시를 사용하는 간단한 예입니다.
import SwiftUI
import UIKit
final class ImageMemoryCache {
static let shared = NSCache<NSURL, UIImage>()
}
@MainActor
final class RemoteImageLoader: ObservableObject {
@Published var image: UIImage?
@Published var isLoading = false
@Published var didFail = false
func load(from url: URL) async {
if let cachedImage = ImageMemoryCache.shared.object(forKey: url as NSURL) {
image = cachedImage
return
}
isLoading = true
didFail = false
defer {
isLoading = false
}
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode),
let downloadedImage = UIImage(data: data) else {
didFail = true
return
}
ImageMemoryCache.shared.setObject(downloadedImage, forKey: url as NSURL)
image = downloadedImage
} catch {
didFail = true
}
}
}
struct CachedRemoteImage: View {
let url: URL?
@StateObject private var loader = RemoteImageLoader()
var body: some View {
ZStack {
if let image = loader.image {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else if loader.isLoading {
Color.gray.opacity(0.15)
ProgressView()
} else {
Color.gray.opacity(0.15)
Image(systemName: loader.didFail ? "exclamationmark.triangle" : "photo")
.foregroundStyle(.secondary)
}
}
.task(id: url) {
guard let url else { return }
await loader.load(from: url)
}
}
}이 코드는 같은 URL의 이미지를 메모리에 저장합니다. 화면에서 같은 이미지를 다시 요청하면 네트워크를 거치지 않고 캐시된 이미지를 사용합니다.
단, 메모리 캐시는 앱이 종료되면 사라질 수 있습니다. 오래 보관해야 하는 이미지는 디스크 캐시를 추가로 고려해야 합니다.
이미지가 있는 실제 목록 화면 구성하기
이제 이미지 URL을 가진 모델을 화면에 표시해 보겠습니다.
struct Product: Identifiable, Decodable {
let id: Int
let name: String
let price: Int
let imageURL: String
var thumbnailURL: URL? {
URL(string: imageURL)
}
}행 View는 이미지와 텍스트를 함께 배치합니다.
struct ProductRow: View {
let product: Product
var body: some View {
HStack(spacing: 12) {
CachedRemoteImage(url: product.thumbnailURL)
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 6) {
Text(product.name)
.font(.headline)
Text("₩(product.price)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}실제 API 화면은 다음 세 가지를 함께 만족해야 합니다.
첫째, 데이터 목록의 로딩 상태가 있어야 합니다.
둘째, 각 이미지에도 개별 로딩 상태가 있어야 합니다.
셋째, 데이터 요청 실패와 이미지 요청 실패를 분리해서 처리해야 합니다.
상품 목록 API가 실패하면 목록 전체에 오류 화면을 보여 주어야 합니다. 하지만 상품 이미지 하나가 실패했다고 해서 목록 전체를 실패 처리할 필요는 없습니다. 이미지 자리에는 기본 아이콘을 보여 주고, 상품 이름과 가격은 계속 표시하는 편이 사용자에게 더 낫습니다.
네트워크 코드를 작성할 때의 기준
네트워크 코드는 처음에는 간단해 보이지만 앱이 커질수록 반복이 많아집니다. 다음 기준을 지키면 유지보수하기 쉬워집니다.
1. View에서 URLSession을 직접 호출하지 않는다
View는 화면을 그리는 역할에 집중해야 합니다. 네트워크 요청은 API 객체나 Repository, ViewModel로 분리하는 것이 좋습니다.
나쁜 예는 다음과 같습니다.
struct BadView: View {
var body: some View {
Text("목록")
.onAppear {
// 여기서 URLSession 코드를 길게 작성하면 View가 복잡해집니다.
}
}
}View에는 "언제 불러올지"만 남기고, "어떻게 불러올지"는 다른 타입으로 분리합니다.
2. 상태 코드를 항상 확인한다
URLSession이 데이터를 반환했다고 해서 요청이 성공한 것은 아닙니다. 서버가 오류 메시지를 JSON으로 내려줘도 네트워크 자체는 성공처럼 보일 수 있습니다.
항상 HTTPURLResponse와 상태 코드를 확인해야 합니다.
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}3. 디코딩 실패를 서버 문제로만 보지 않는다
디코딩 실패는 여러 이유로 발생합니다.
서버 응답 구조가 바뀌었다.
모델의 타입이 맞지 않는다.
필수 값이 응답에서 빠졌다.
날짜 형식이 예상과 다르다.
예를 들어 서버가 price를 숫자가 아니라 문자열로 내려주면 Int로 디코딩할 수 없습니다.
{
"price": "12000"
}이런 가능성이 있다면 모델을 서버 응답에 맞게 수정하거나, 별도의 DTO를 두고 앱 내부 모델로 변환하는 방식을 사용할 수 있습니다. DTO는 서버 응답 전용 타입입니다. 앱 내부에서 쓰는 모델과 서버 응답 구조가 다를 때 중간 역할을 합니다.
4. 사용자 메시지는 짧게 쓴다
오류 메시지는 기술 설명이 아니라 안내문이어야 합니다.
나쁜 메시지:
Decoding failed with typeMismatch error.
좋은 메시지:
데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.
개발자에게 필요한 세부 정보는 로그로 남기고, 사용자에게는 다음 행동을 알려 주는 문장을 보여 주세요.
5. 재시도 흐름을 제공한다
모바일 네트워크는 불안정할 수 있습니다. 지하철, 엘리베이터, 이동 중 환경에서는 일시적으로 요청이 실패할 수 있습니다.
따라서 네트워크 오류 화면에는 "다시 시도" 버튼이 있는 것이 좋습니다.
Button("다시 시도") {
Task {
await viewModel.loadPosts()
}
}재시도 버튼 하나만 있어도 사용자는 앱을 종료하지 않고 문제를 해결할 수 있습니다.
작은 네트워크 계층 만들기
앱이 커지면 API가 여러 개 생깁니다. 매번 상태 코드 확인과 디코딩 코드를 반복하면 실수하기 쉽습니다. 공통 네트워크 클라이언트를 만들면 중복을 줄일 수 있습니다.
struct NetworkClient {
func get<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.badStatusCode(httpResponse.statusCode)
}
guard !data.isEmpty else {
throw NetworkError.emptyData
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkError.decodingFailed
}
}
}사용할 때는 원하는 타입을 넘깁니다.
struct PostAPI {
private let client = NetworkClient()
func fetchPosts() async throws -> [Post] {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
throw NetworkError.invalidURL
}
return try await client.get([Post].self, from: url)
}
}이렇게 하면 API가 늘어나도 공통 검증 로직을 한곳에서 관리할 수 있습니다.
이 장에서 기억할 것
네트워크 통신은 iOS 앱의 핵심 기능입니다. 하지만 구조는 단순합니다. URL을 만들고, 요청을 보내고, 상태 코드를 확인하고, JSON을 모델로 바꾸고, 화면 상태를 갱신합니다.
URLSession은 iOS의 기본 네트워크 도구입니다. async/await를 함께 사용하면 비동기 코드를 읽기 쉽게 작성할 수 있습니다.
좋은 네트워크 화면은 성공 상태만 다루지 않습니다. 로딩 중, 빈 목록, 오류, 재시도, 이미지 실패까지 함께 고려합니다. 사용자는 네트워크 상황을 통제할 수 없습니다. 앱이 대신 안전하게 처리해야 합니다.
다음 장에서는 이 흐름을 더 깊게 이해하기 위해 비동기 처리와 동시성을 다룹니다. 네트워크 요청이 왜 기다려야 하는 작업인지, 여러 요청을 동시에 실행할 때 어떤 점을 조심해야 하는지 살펴봅니다.
참고
1URLSession | Apple Developer Documentation
2Using URLSession's async/await-powered APIs
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
