Ver3.5 ๐ Swift ๋คํธ์ํน ์์ ๊ฐ์ด๋: URLSession๋ถํฐ Alamofire๊น์ง API ํต์ ์ ๋ชจ๋ ๊ฒ

๐ Swift ๋คํธ์ํน ์์ ๊ฐ์ด๋: URLSession๋ถํฐ Alamofire๊น์ง API ํต์ ์ ๋ชจ๋ ๊ฒ
iOS ์ฑ ๊ฐ๋ฐ์๋ผ๋ฉด ๋ฐ๋์ ์์์ผ ํ ๋คํธ์ํฌ ํต์ ์ ํต์ฌ ๊ฐ๋ ๊ณผ ์ค์ ์ฝ๋๋ฅผ ์น๊ตฌ์ฒ๋ผ ์ฝ๊ฒ ํ์ด๋๋ฆฝ๋๋ค ๐
๐ค ์ ๋คํธ์ํน์ ์์์ผ ํ ๊น?
์ผ, ์์งํ ๋งํด๋ณผ๊ฒ. ์์ฆ ์ฑ ์ค์ ์ธํฐ๋ท ํต์ ์ ํ๋ ์ฑ์ด ์์ด? ๐คฃ
๋ ์จ ์ฑ์ ์๋ฒ์์ ๋ ์จ ๋ฐ์ดํฐ ๋ฐ์์ค๊ณ , ์ผํ ์ฑ์ ์ํ ๋ชฉ๋ก ๋ถ๋ฌ์ค๊ณ , SNS๋ ํผ๋ ์
๋ฐ์ดํธํ๊ณ ...
ํ๋ iOS ์ฑ์ 90% ์ด์์ ์ด๋ค ํํ๋ก๋ ๋คํธ์ํฌ ํต์ ์ ์ฌ์ฉํด.
๊ทธ๋์ Swift๋ก iOS ๊ฐ๋ฐ์ ํ๋ค๋ฉด ๋คํธ์ํน์ ์ ํ์ด ์๋๋ผ ํ์ ์คํฌ์ด์ผ.
์ค๋์ Apple์ด ๊ธฐ๋ณธ์ผ๋ก ์ ๊ณตํ๋ URLSession๋ถํฐ ์์ํด์,
๊ฐ๋ฐ์๋ค์ด ์ฌ๋ํ๋ ์๋ํํฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ Alamofire๊น์ง ์ ๋๋ก ํํค์ณ๋ณผ ๊ฑฐ์ผ!
๊ฒ๋จน์ง ๋ง. ์ฐจ๊ทผ์ฐจ๊ทผ ๊ฐ์ด ๊ฐ๋ณด์๊ณ ๐
๐ก HTTP์ REST API ๊ธฐ์ด โ ๋จผ์ ์ด๊ฒ๋ถํฐ!
URLSession์ด๊ณ Alamofire๊ณ ๊ฐ์, ๊ทธ ์ ์ HTTP์ REST API๊ฐ ๋ญ์ง ์์์ผ ํด.
์ด๋ฏธ ์๋ค๊ณ ? ๊ทธ๋๋ ํ ๋ฒ๋ง ๊ฐ์ด ๋ณต์ตํ์. ๊ธฐ์ด๊ฐ ํํํด์ผ ๋์ค์ ์ ํ๋ค๋ฆฌ๊ฑฐ๋ .
๐ HTTP ๋ฉ์๋์ ์ข ๋ฅ
์๋ฒ๋ ๋ํํ ๋ ์ฐ๋ฆฌ๊ฐ ์ฐ๋ "๋์ฌ"๋ค์ด์ผ:
| ๋ฉ์๋ | ์ญํ | ์์ |
|---|---|---|
| GET | ๋ฐ์ดํฐ ์กฐํ | ๊ฒ์๊ธ ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ |
| POST | ๋ฐ์ดํฐ ์์ฑ | ์ ๊ฒ์๊ธ ์์ฑ |
| PUT | ๋ฐ์ดํฐ ์ ์ฒด ์์ | ๊ฒ์๊ธ ์ ์ฒด ์ ๋ฐ์ดํธ |
| PATCH | ๋ฐ์ดํฐ ์ผ๋ถ ์์ | ๊ฒ์๊ธ ์ ๋ชฉ๋ง ๋ณ๊ฒฝ |
| DELETE | ๋ฐ์ดํฐ ์ญ์ | ๊ฒ์๊ธ ์ญ์ |
๐ HTTP ์ํ ์ฝ๋
์๋ฒ๊ฐ "๋ ์ง๊ธ ์ด๋?" ๋ผ๊ณ ์๋ ค์ฃผ๋ ์ซ์ ์ฝ๋์ผ:
| ์ฝ๋ | ์๋ฏธ | ์ํฉ |
|---|---|---|
| 200 | OK | ์์ฒญ ์ฑ๊ณต! ๐ |
| 201 | Created | ์์ฑ ์ฑ๊ณต! |
| 400 | Bad Request | ์์ฒญ์ด ์๋ชป๋จ ๐ |
| 401 | Unauthorized | ์ธ์ฆ ํ์ |
| 403 | Forbidden | ์ ๊ทผ ๊ถํ ์์ |
| 404 | Not Found | ์๋ ๋ฆฌ์์ค ์์ฒญ |
| 500 | Internal Server Error | ์๋ฒ ๋ด๋ถ ์ค๋ฅ ๐ฑ |
๐ JSON โ ๋ฐ์ดํฐ ๊ตํ์ ํ์ค์ด
์๋ฒ๋ ์ฑ์ด ๋ฐ์ดํฐ๋ฅผ ์ฃผ๊ณ ๋ฐ์ ๋ ๊ฐ์ฅ ๋ง์ด ์ฐ๋ ํ์์ด JSON(JavaScript Object Notation)์ด์ผ.
Swift์์๋ Codable ํ๋กํ ์ฝ๋ก JSON์ ์ฝ๊ฒ ๋ค๋ฃฐ ์ ์์ด.
{
"id": 1,
"name": "๊น๊ฐ๋ฐ",
"email": "dev@example.com",
"skills": ["Swift", "iOS", "Xcode"],
"isActive": true
}
๐ URLSession โ Apple์ด ๋ง๋ ๋คํธ์ํน์ ๊ธฐ๋ณธ๊ธฐ
URLSession์ Apple์ด iOS SDK์ ๊ธฐ๋ณธ์ผ๋ก ํฌํจ์ํจ ๋คํธ์ํน ํ๋ ์์ํฌ์ผ.
์ธ๋ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์์ด๋ HTTP ํต์ ์ ์๋ฒฝํ๊ฒ ์ฒ๋ฆฌํ ์ ์์ด.
์ฒ์์ ์ข ๋ณต์กํด ๋ณด์ผ ์ ์๋๋ฐ, ๊ตฌ์กฐ๋ฅผ ์ดํดํ๋ฉด ์๊ฐ๋ณด๋ค ๊น๋ํด!
๐ URLSession ๊ธฐ๋ณธ GET ์์ฒญ
๊ฐ์ฅ ๊ธฐ๋ณธ์ ์ธ GET ์์ฒญ๋ถํฐ ์์ํด๋ณด์!
import Foundation
// 1. URL ์์ฑ
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1") else {
print("์๋ชป๋ URL์ด์์!")
return
}
// 2. URLSession ๋ฐ์ดํฐ ํ์คํฌ ์์ฑ ๋ฐ ์คํ
let task = URLSession.shared.dataTask(with: url) { data, response, error in
// 3. ์๋ฌ ์ฒ๋ฆฌ
if let error = error {
print("์๋ฌ ๋ฐ์: \(error.localizedDescription)")
return
}
// 4. HTTP ์๋ต ์ํ ์ฝ๋ ํ์ธ
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
print("์๋ฒ ์๋ต ์ค๋ฅ!")
return
}
// 5. ๋ฐ์ดํฐ ์ฒ๋ฆฌ
guard let data = data else {
print("๋ฐ์ดํฐ๊ฐ ์์ด์!")
return
}
// 6. JSON ํ์ฑ
do {
let json = try JSONSerialization.jsonObject(with: data, options: [])
print("๋ฐ์ ๋ฐ์ดํฐ: \(json)")
} catch {
print("JSON ํ์ฑ ์คํจ: \(error)")
}
}
// 7. ํ์คํฌ ์์ (์ด๊ฑฐ ๋น ๋จ๋ฆฌ๋ฉด ์๋ฌด๊ฒ๋ ์ ๋จ! ์ฃผ์!)
task.resume()
task.resume()์ ํธ์ถํด์ผ ์ค์ ๋ก ์์ฒญ์ด ์์๋ผ. ์ด๊ฑฐ ๋น ๋จ๋ฆฌ๊ณ "์ ์ ๋์ง?" ํ๋ ์ด๋ณด์๋ค์ด ์์ฒญ ๋ง์ ๐
๐๏ธ Codable๋ก JSON ํ์ฑํ๊ธฐ
JSONSerialization๋ ์ข์ง๋ง, Swift์ Codable ํ๋กํ ์ฝ์ ์ฐ๋ฉด ํจ์ฌ ์ฐ์ํ๊ฒ JSON์ ๋ค๋ฃฐ ์ ์์ด.
ํ์
์์ ์ฑ๋ ๋ณด์ฅ๋๊ณ , ์ฝ๋๋ ํจ์ฌ ๊น๋ํด์ง๊ฑฐ๋ !
// ์๋ฒ์์ ๋ฐ์ ๋ฐ์ดํฐ ๊ตฌ์กฐ ์ ์
struct Post: Codable {
let id: Int
let userId: Int
let title: String
let body: String
}
struct User: Codable {
let id: Int
let name: String
let email: String
let username: String
}
// ๋ฐฐ์ด๋ก ๋ฐ์ ๋
// [Post] ์ด๋ฐ ์์ผ๋ก ์ฌ์ฉ
func fetchPost(id: Int, completion: @escaping (Result<Post, Error>) -> Void) {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/\(id)") else {
completion(.failure(NetworkError.invalidURL))
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
completion(.failure(NetworkError.invalidResponse))
return
}
guard let data = data else {
completion(.failure(NetworkError.noData))
return
}
do {
let decoder = JSONDecoder()
// ์๋ฒ๊ฐ snake_case๋ฅผ ์ด๋ค๋ฉด ์ด ์ค์ ์ถ๊ฐ!
decoder.keyDecodingStrategy = .convertFromSnakeCase
let post = try decoder.decode(Post.self, from: data)
completion(.success(post))
} catch {
completion(.failure(error))
}
}.resume()
}
// ์ปค์คํ
์๋ฌ ํ์
enum NetworkError: Error {
case invalidURL
case invalidResponse
case noData
case decodingError
}
Result<Success, Failure> ํ์
์ ์ฐ๋ฉด ์ฑ๊ณต/์คํจ ์ผ์ด์ค๋ฅผ ๋ช
ํํ๊ฒ ์ฒ๋ฆฌํ ์ ์์ด. ์ฝ๋๊ฐ ํจ์ฌ ์ฝ๊ธฐ ์ข์์ง๊ฑฐ๋ !
๐ค POST ์์ฒญ โ ๋ฐ์ดํฐ ๋ณด๋ด๊ธฐ
GET์ ๋ฐ์ดํฐ๋ฅผ ๋ฐ๋ ๊ฑฐ๊ณ , POST๋ ๋ฐ์ดํฐ๋ฅผ ์๋ฒ๋ก ๋ณด๋ด๋ ๊ฑฐ์ผ. ์กฐ๊ธ ๋ ์ค์ ์ด ํ์ํด!
func createPost(title: String, body: String, userId: Int,
completion: @escaping (Result<Post, Error>) -> Void) {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
completion(.failure(NetworkError.invalidURL))
return
}
// URLRequest ์ค์
var request = URLRequest(url: url)
request.httpMethod = "POST" // ๋ฉ์๋ ์ค์
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
// ๋ณด๋ผ ๋ฐ์ดํฐ ๊ตฌ์กฐ์ฒด
struct NewPost: Encodable {
let title: String
let body: String
let userId: Int
}
let newPost = NewPost(title: title, body: body, userId: userId)
do {
let encoder = JSONEncoder()
request.httpBody = try encoder.encode(newPost)
} catch {
completion(.failure(error))
return
}
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NetworkError.noData))
return
}
do {
let post = try JSONDecoder().decode(Post.self, from: data)
completion(.success(post))
} catch {
completion(.failure(error))
}
}.resume()
}
โก async/await โ ํ๋์ ์ธ ๋น๋๊ธฐ ์ฒ๋ฆฌ
Swift 5.5๋ถํฐ ๋์
๋ async/await๋ ๋น๋๊ธฐ ์ฝ๋๋ฅผ ๋๊ธฐ ์ฝ๋์ฒ๋ผ ์ฝ๊ธฐ ์ฝ๊ฒ ๋ง๋ค์ด์ค.
์ฝ๋ฐฑ ์ง์ฅ(callback hell)์์ ํด๋ฐฉ๋๋ ๋๋์ด๋๊น? ๐
iOS 15+ ์ด์์ ํ๊ฒ์ผ๋ก ํ๋ค๋ฉด ์ ๊ทน ํ์ฉํด๋ด!
fetchUser { user in
fetchPosts(user) { posts in
fetchComments(posts) { comments in
// ์ฝ๋ฐฑ ์ง์ฅ ๐ฑ
}
}
}
let user = await fetchUser()
let posts = await fetchPosts(user)
let comments = await fetchComments(posts)
// ํจ์ฌ ๊น๋! ๐
// async ํจ์ ์ ์
func fetchPost(id: Int) async throws -> Post {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/\(id)") else {
throw NetworkError.invalidURL
}
// iOS 15+์์ URLSession์ด async/await๋ฅผ ๋ค์ดํฐ๋ธ ์ง์!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
let post = try JSONDecoder().decode(Post.self, from: data)
return post
}
// ์ฌ์ฉํ๋ ๊ณณ (SwiftUI View๋ ViewModel์์)
Task {
do {
let post = try await fetchPost(id: 1)
print("์ ๋ชฉ: \(post.title)")
} catch {
print("์๋ฌ: \(error)")
}
}
// POST ์์ฒญ๋ async/await๋ก!
func createPost(newPost: NewPost) 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.httpBody = try JSONEncoder().encode(newPost)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(Post.self, from: data)
}
URLSession.shared.data(from:) async ๋ฒ์ ์ iOS 15.0+๋ถํฐ ์ฌ์ฉ ๊ฐ๋ฅํด. iOS 14 ์ดํ๋ฅผ ์ง์ํด์ผ ํ๋ค๋ฉด withCheckedThrowingContinuation์ผ๋ก ๋ํํ๊ฑฐ๋ ์ฝ๋ฐฑ ๋ฐฉ์์ ์จ์ผ ํด.
๐ withCheckedThrowingContinuation โ ๊ตฌ๋ฒ์ ํธํ
iOS 14 ์ดํ๋ ์ง์ํด์ผ ํ๋ค๋ฉด ์ด ๋ฐฉ๋ฒ์ผ๋ก ๊ธฐ์กด ์ฝ๋ฐฑ์ async/await๋ก ๋ณํํ ์ ์์ด:
func fetchPostCompat(id: Int) async throws -> Post {
return try await withCheckedThrowingContinuation { continuation in
// ๊ธฐ์กด ์ฝ๋ฐฑ ๋ฐฉ์ ํจ์ ํธ์ถ
fetchPost(id: id) { result in
switch result {
case .success(let post):
continuation.resume(returning: post)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
๐๏ธ ๋คํธ์ํฌ ๋ ์ด์ด ์ํคํ ์ฒ ์ค๊ณ
์ค์ ํ๋ก์ ํธ์์๋ ๋คํธ์ํฌ ์ฝ๋๋ฅผ ์ฌ๊ธฐ์ ๊ธฐ ํฉ๋ฟ๋ฆฌ๋ฉด ์ ๋ผ.
์ ์ค๊ณ๋ ๋คํธ์ํฌ ๋ ์ด์ด๋ฅผ ๋ง๋ค์ด์ผ ์ ์ง๋ณด์๋ ์ฝ๊ณ , ํ
์คํธ๋ ๊ฐ๋ฅํด์ง๊ฑฐ๋ !
์ค๋ฌด์์ ๋ง์ด ์ฐ๋ ํจํด์ ๋ณด์ฌ์ค๊ฒ.
// MARK: - Endpoint ํ๋กํ ์ฝ
protocol Endpoint {
var baseURL: String { get }
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String]? { get }
var parameters: [String: Any]? { get }
var body: Encodable? { get }
}
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
// MARK: - ๊ธฐ๋ณธ๊ฐ ์ ๊ณต (Extension)
extension Endpoint {
var baseURL: String { return "https://api.example.com" }
var headers: [String: String]? { return ["Content-Type": "application/json"] }
var parameters: [String: Any]? { return nil }
var body: Encodable? { return nil }
}
// MARK: - ์ค์ API ์๋ํฌ์ธํธ ์ ์
enum PostEndpoint: Endpoint {
case getPost(id: Int)
case getPosts
case createPost(title: String, body: String, userId: Int)
case deletePost(id: Int)
var path: String {
switch self {
case .getPost(let id): return "/posts/\(id)"
case .getPosts: return "/posts"
case .createPost: return "/posts"
case .deletePost(let id): return "/posts/\(id)"
}
}
var method: HTTPMethod {
switch self {
case .getPost, .getPosts: return .get
case .createPost: return .post
case .deletePost: return .delete
}
}
var body: Encodable? {
switch self {
case .createPost(let title, let body, let userId):
return ["title": title, "body": body, "userId": userId]
default:
return nil
}
}
}
// MARK: - NetworkManager
class NetworkManager {
static let shared = NetworkManager()
private let session: URLSession
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
self.session = URLSession(configuration: config)
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
// URL ๊ตฌ์ฑ
guard let url = URL(string: endpoint.baseURL + endpoint.path) else {
throw NetworkError.invalidURL
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = endpoint.method.rawValue
// ํค๋ ์ค์
endpoint.headers?.forEach { key, value in
urlRequest.setValue(value, forHTTPHeaderField: key)
}
// ๋ฐ๋ ์ค์
if let body = endpoint.body {
urlRequest.httpBody = try JSONEncoder().encode(body)
}
// ์์ฒญ ์คํ
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
// ์ํ ์ฝ๋ ์ฒ๋ฆฌ
switch httpResponse.statusCode {
case 200...299:
break
case 401:
throw NetworkError.unauthorized
case 404:
throw NetworkError.notFound
case 500...599:
throw NetworkError.serverError(httpResponse.statusCode)
default:
throw NetworkError.unknown(httpResponse.statusCode)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
// MARK: - ์ฌ์ฉ ์์
class PostViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var isLoading = false
@Published var errorMessage: String?
func loadPosts() async {
isLoading = true
defer { isLoading = false }
do {
posts = try await NetworkManager.shared.request(PostEndpoint.getPosts)
} catch {
errorMessage = error.localizedDescription
}
}
}
๐ฅ Alamofire โ ๋คํธ์ํน์ ๋ ์ฝ๊ฒ!
์, ์ด์ ๋ง์ iOS ๊ฐ๋ฐ์๋ค์ด ์ฌ๋ํ๋ Alamofire๋ฅผ ์์๋ณผ ์๊ฐ์ด์ผ! ๐
Alamofire๋ URLSession์ ๊ธฐ๋ฐ์ผ๋ก ๋ง๋ค์ด์ง ์๋ํํฐ ๋คํธ์ํน ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ผ.
GitHub ์คํ๊ฐ 4๋ง ๊ฐ ์ด์์ธ ์์ฒญ๋ ์ธ๊ธฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ง!
URLSession๋ณด๋ค ํจ์ฌ ๊ฐ๊ฒฐํ ์ฝ๋๋ก ๊ฐ์ ์์
์ ํ ์ ์์ด.
ํนํ ๋ณต์กํ ์์ฒญ, ํ์ผ ์
๋ก๋, ์ธ์ฆ ์ฒ๋ฆฌ ๋ฑ์์ ๋น์ ๋ฐํด.
๐ฆ Alamofire ์ค์น
Swift Package Manager(SPM)๋ก ์ค์นํ๋ ๊ฒ ๊ฐ์ฅ ์ฌ์:
// Package.swift์ ์ถ๊ฐํ๋ ๊ฒฝ์ฐ
dependencies: [
.package(
url: "https://github.com/Alamofire/Alamofire.git",
.upToNextMajor(from: "5.9.0")
)
]
// Xcode์์๋:
// File โ Add Package Dependencies โ
// https://github.com/Alamofire/Alamofire.git ์
๋ ฅ
pod 'Alamofire', '~> 5.9' ์ถ๊ฐ ํ pod install!
๐ Alamofire ๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ
URLSession๊ณผ ๋น๊ตํด๋ณด๋ฉด ์ผ๋ง๋ ์ฝ๋๊ฐ ์ค์ด๋๋์ง ๋ฐ๋ก ๋๊ปด์ง ๊ฑฐ์ผ!
import Alamofire
// ๊ธฐ๋ณธ GET ์์ฒญ - ์ด๊ฒ ์ ๋ถ์ผ! ๐ฒ
AF.request("https://jsonplaceholder.typicode.com/posts/1")
.responseDecodable(of: Post.self) { response in
switch response.result {
case .success(let post):
print("์ ๋ชฉ: \(post.title)")
case .failure(let error):
print("์๋ฌ: \(error)")
}
}
// ๋ฐฐ์ด ๋ฐ๊ธฐ
AF.request("https://jsonplaceholder.typicode.com/posts")
.responseDecodable(of: [Post].self) { response in
switch response.result {
case .success(let posts):
print("๊ฒ์๊ธ ์: \(posts.count)")
case .failure(let error):
print("์๋ฌ: \(error)")
}
}
// POST ์์ฒญ
let parameters: [String: Any] = [
"title": "์ ๊ฒ์๊ธ",
"body": "๋ด์ฉ์
๋๋ค",
"userId": 1
]
AF.request(
"https://jsonplaceholder.typicode.com/posts",
method: .post,
parameters: parameters,
encoding: JSONEncoding.default,
headers: ["Content-Type": "application/json"]
)
.responseDecodable(of: Post.self) { response in
switch response.result {
case .success(let post):
print("์์ฑ๋ ๊ฒ์๊ธ ID: \(post.id)")
case .failure(let error):
print("์๋ฌ: \(error)")
}
}
// Encodable ๊ตฌ์กฐ์ฒด ์ง์ ์ฌ์ฉ (๋ ํ์
์์ !)
struct CreatePostRequest: Encodable {
let title: String
let body: String
let userId: Int
}
let newPost = CreatePostRequest(title: "์ ๋ชฉ", body: "๋ด์ฉ", userId: 1)
AF.request(
"https://jsonplaceholder.typicode.com/posts",
method: .post,
parameters: newPost,
encoder: JSONParameterEncoder.default
)
.responseDecodable(of: Post.self) { response in
// ์ฒ๋ฆฌ...
}
โก Alamofire + async/await
Alamofire 5.5+๋ถํฐ async/await๋ฅผ ์๋ฒฝํ๊ฒ ์ง์ํด!
// async/await ๋ฐฉ์
func fetchPost(id: Int) async throws -> Post {
return try await AF.request(
"https://jsonplaceholder.typicode.com/posts/\(id)"
)
.serializingDecodable(Post.self)
.value
}
// ๋ฐฐ์ด ๋ฐ๊ธฐ
func fetchAllPosts() async throws -> [Post] {
return try await AF.request(
"https://jsonplaceholder.typicode.com/posts"
)
.serializingDecodable([Post].self)
.value
}
// ์ฌ์ฉ
Task {
do {
let post = try await fetchPost(id: 1)
print(post.title)
} catch {
print(error)
}
}
๐ก๏ธ Alamofire ๊ณ ๊ธ ๊ธฐ๋ฅ
Alamofire๊ฐ URLSession๋ณด๋ค ์ง์ง ๋น๋๋ ๋ถ๋ถ์ด ์ฌ๊ธฐ์ผ.
์ธํฐ์
ํฐ, ์ธ์ฆ ์ฒ๋ฆฌ, ์ฌ์๋ ๋ก์ง ๊ฐ์ ๋ณต์กํ ๊ธฐ๋ฅ๋ค์ ์์ฒญ ์ฝ๊ฒ ๊ตฌํํ ์ ์๊ฑฐ๋ !
๐ RequestInterceptor โ ์ธ์ฆ ํ ํฐ ์๋ ์ฒ๋ฆฌ
๋ชจ๋ ์์ฒญ์ Authorization ํค๋๋ฅผ ์๋์ผ๋ก ๋ถ์ด๊ฑฐ๋,
ํ ํฐ์ด ๋ง๋ฃ๋์ ๋ ์๋์ผ๋ก ๊ฐฑ์ ํ๋ ๋ก์ง์ ๊ตฌํํ ์ ์์ด.
import Alamofire
// MARK: - ์ธ์ฆ ์ธํฐ์
ํฐ
class AuthInterceptor: RequestInterceptor {
private let tokenManager: TokenManager
init(tokenManager: TokenManager = .shared) {
self.tokenManager = tokenManager
}
// ์์ฒญ ์ ์ ์คํ - ํค๋ ์ถ๊ฐ
func adapt(_ urlRequest: URLRequest,
for session: Session,
completion: @escaping (Result<URLRequest, Error>) -> Void) {
var request = urlRequest
// ์ก์ธ์ค ํ ํฐ์ด ์์ผ๋ฉด ํค๋์ ์ถ๊ฐ
if let token = tokenManager.accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
completion(.success(request))
}
// ์์ฒญ ์คํจ ์ ์ฌ์๋ ๋ก์ง
func retry(_ request: Request,
for session: Session,
dueTo error: Error,
completion: @escaping (RetryResult) -> Void) {
guard let response = request.task?.response as? HTTPURLResponse,
response.statusCode == 401 else {
// 401์ด ์๋๋ฉด ์ฌ์๋ ์ ํจ
completion(.doNotRetry)
return
}
// ํ ํฐ ๊ฐฑ์ ์๋
tokenManager.refreshToken { [weak self] success in
if success {
// ํ ํฐ ๊ฐฑ์ ์ฑ๊ณต โ ์์ฒญ ์ฌ์๋
completion(.retry)
} else {
// ํ ํฐ ๊ฐฑ์ ์คํจ โ ๋ก๊ทธ์์ ์ฒ๋ฆฌ
self?.handleLogout()
completion(.doNotRetry)
}
}
}
private func handleLogout() {
// ๋ก๊ทธ์์ ์ฒ๋ฆฌ ๋ก์ง
NotificationCenter.default.post(name: .userDidLogout, object: nil)
}
}
// MARK: - ํ ํฐ ๋งค๋์ (๊ฐ๋จ ์์)
class TokenManager {
static let shared = TokenManager()
var accessToken: String? {
return UserDefaults.standard.string(forKey: "accessToken")
}
func refreshToken(completion: @escaping (Bool) -> Void) {
// ๋ฆฌํ๋ ์ ํ ํฐ์ผ๋ก ์ ์ก์ธ์ค ํ ํฐ ์์ฒญ
// ์ค์ ๊ตฌํ์์๋ ์๋ฒ API ํธ์ถ
completion(true)
}
}
// MARK: - ์ธํฐ์
ํฐ๊ฐ ์ ์ฉ๋ Session ์์ฑ
let authenticatedSession = Session(interceptor: AuthInterceptor())
// ์ฌ์ฉ
authenticatedSession.request("https://api.example.com/profile")
.responseDecodable(of: UserProfile.self) { response in
// ์๋์ผ๋ก ํ ํฐ์ด ๋ถ์ด์ ์์ฒญ๋จ!
}
๐ ํ์ผ ์ ๋ก๋
์ด๋ฏธ์ง๋ ํ์ผ ์ ๋ก๋๋ Alamofire๋ก ์์ฒญ ์ฝ๊ฒ ํ ์ ์์ด!
// ์ด๋ฏธ์ง ์
๋ก๋ (multipart/form-data)
func uploadImage(_ image: UIImage, completion: @escaping (Result<String, Error>) -> Void) {
guard let imageData = image.jpegData(compressionQuality: 0.8) else {
completion(.failure(NetworkError.noData))
return
}
AF.upload(
multipartFormData: { formData in
// ์ด๋ฏธ์ง ๋ฐ์ดํฐ ์ถ๊ฐ
formData.append(
imageData,
withName: "image",
fileName: "photo.jpg",
mimeType: "image/jpeg"
)
// ์ถ๊ฐ ํ๋ผ๋ฏธํฐ
formData.append(
"profile".data(using: .utf8)!,
withName: "type"
)
},
to: "https://api.example.com/upload"
)
.uploadProgress { progress in
// ์
๋ก๋ ์งํ๋ฅ (0.0 ~ 1.0)
print("์
๋ก๋ ์งํ๋ฅ : \(Int(progress.fractionCompleted * 100))%")
}
.responseDecodable(of: UploadResponse.self) { response in
switch response.result {
case .success(let uploadResponse):
completion(.success(uploadResponse.imageURL))
case .failure(let error):
completion(.failure(error))
}
}
}
struct UploadResponse: Decodable {
let imageURL: String
let success: Bool
}
๐ฅ ํ์ผ ๋ค์ด๋ก๋
func downloadFile(from url: String) {
let destination: DownloadRequest.Destination = { _, _ in
let documentsURL = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
)[0]
let fileURL = documentsURL.appendingPathComponent("downloaded_file.pdf")
return (fileURL, [.removePreviousFile, .createIntermediateDirectories])
}
AF.download(url, to: destination)
.downloadProgress { progress in
print("๋ค์ด๋ก๋ ์งํ๋ฅ : \(Int(progress.fractionCompleted * 100))%")
}
.response { response in
if let error = response.error {
print("๋ค์ด๋ก๋ ์คํจ: \(error)")
} else if let fileURL = response.fileURL {
print("๋ค์ด๋ก๋ ์๋ฃ! ์ ์ฅ ์์น: \(fileURL)")
}
}
}
๐ EventMonitor โ ์์ฒญ/์๋ต ๋ก๊น
๋๋ฒ๊น ํ ๋ ๋ชจ๋ ๋คํธ์ํฌ ์์ฒญ์ ๋ก๊ทธ๋ก ์ฐ๊ณ ์ถ๋ค๋ฉด EventMonitor๋ฅผ ์จ๋ด!
class NetworkLogger: EventMonitor {
let queue = DispatchQueue(label: "com.app.networkLogger")
func requestDidFinish(_ request: Request) {
print("\n๐ ===== ๋คํธ์ํฌ ์์ฒญ =====")
print("๐ URL: \(request.request?.url?.absoluteString ?? "์์")")
print("๐ Method: \(request.request?.httpMethod ?? "์์")")
if let headers = request.request?.allHTTPHeaderFields {
print("๐จ Headers: \(headers)")
}
if let body = request.request?.httpBody,
let bodyString = String(data: body, encoding: .utf8) {
print("๐ฆ Body: \(bodyString)")
}
}
func request<Value>(_ request: DataRequest,
didParseResponse response: DataResponse<Value, AFError>) {
print("\n๐ฌ ===== ๋คํธ์ํฌ ์๋ต =====")
print("๐ Status: \(response.response?.statusCode ?? 0)")
if let data = response.data,
let jsonString = String(data: data, encoding: .utf8) {
print("๐ Response: \(jsonString.prefix(500))") // ์ฒ์ 500์๋ง
}
print("===========================\n")
}
}
// ๋ก๊ฑฐ๊ฐ ์ ์ฉ๋ Session
let loggingSession = Session(eventMonitors: [NetworkLogger()])
๐๏ธ Alamofire ๊ธฐ๋ฐ ์ค์ ๋คํธ์ํฌ ๋ ์ด์ด
์, ์ด์ ์ง์ง ์ค๋ฌด์์ ์ธ ์ ์๋ ์์ค์ ๋คํธ์ํฌ ๋ ์ด์ด๋ฅผ ๋ง๋ค์ด๋ณผ๊ฒ.
ํ๋กํ ์ฝ ์งํฅ ์ค๊ณ + Alamofire + async/await์ ์กฐํฉ์ด์ผ!
import Alamofire
import Foundation
// MARK: - API ๋ผ์ฐํฐ (Router Pattern)
enum APIRouter: URLRequestConvertible {
// ์๋ํฌ์ธํธ ์ผ์ด์ค๋ค
case login(email: String, password: String)
case getPosts
case getPost(id: Int)
case createPost(title: String, body: String)
case updatePost(id: Int, title: String, body: String)
case deletePost(id: Int)
case uploadImage(data: Data)
// ๊ธฐ๋ณธ URL
static let baseURL = "https://api.example.com/v1"
// HTTP ๋ฉ์๋
var method: HTTPMethod {
switch self {
case .login, .createPost, .uploadImage:
return .post
case .getPosts, .getPost:
return .get
case .updatePost:
return .put
case .deletePost:
return .delete
}
}
// ๊ฒฝ๋ก
var path: String {
switch self {
case .login:
return "/auth/login"
case .getPosts:
return "/posts"
case .getPost(let id):
return "/posts/\(id)"
case .createPost:
return "/posts"
case .updatePost(let id, _, _):
return "/posts/\(id)"
case .deletePost(let id):
return "/posts/\(id)"
case .uploadImage:
return "/media/upload"
}
}
// ํ๋ผ๋ฏธํฐ
var parameters: Parameters? {
switch self {
case .login(let email, let password):
return ["email": email, "password": password]
case .createPost(let title, let body):
return ["title": title, "body": body]
case .updatePost(_, let title, let body):
return ["title": title, "body": body]
default:
return nil
}
}
// URLRequest ์์ฑ
func asURLRequest() throws -> URLRequest {
let url = try APIRouter.baseURL.asURL().appendingPathComponent(path)
var request = URLRequest(url: url)
request.method = method
request.timeoutInterval = 30
// ๊ณตํต ํค๋
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("iOS", forHTTPHeaderField: "X-Platform")
// ํ๋ผ๋ฏธํฐ ์ธ์ฝ๋ฉ
switch method {
case .get:
return try URLEncoding.default.encode(request, with: parameters)
default:
return try JSONEncoding.default.encode(request, with: parameters)
}
}
}
// MARK: - API ์๋น์ค ํ๋กํ ์ฝ
protocol APIServiceProtocol {
func login(email: String, password: String) async throws -> LoginResponse
func getPosts() async throws -> [Post]
func getPost(id: Int) async throws -> Post
func createPost(title: String, body: String) async throws -> Post
func deletePost(id: Int) async throws
}
// MARK: - API ์๋น์ค ๊ตฌํ
class APIService: APIServiceProtocol {
static let shared = APIService()
private let session: Session
private init() {
let interceptor = AuthInterceptor()
let logger = NetworkLogger()
session = Session(
interceptor: interceptor,
eventMonitors: [logger]
)
}
// ์ ๋ค๋ฆญ ์์ฒญ ๋ฉ์๋
private func request<T: Decodable>(_ router: APIRouter) async throws -> T {
return try await session.request(router)
.validate(statusCode: 200...299)
.serializingDecodable(T.self)
.value
}
// ์๋ต ์๋ ์์ฒญ (DELETE ๋ฑ)
private func requestVoid(_ router: APIRouter) async throws {
let response = await session.request(router)
.validate(statusCode: 200...299)
.serializingData()
.response
if let error = response.error {
throw error
}
}
// MARK: - ์ค์ API ๋ฉ์๋๋ค
func login(email: String, password: String) async throws -> LoginResponse {
return try await request(.login(email: email, password: password))
}
func getPosts() async throws -> [Post] {
return try await request(.getPosts)
}
func getPost(id: Int) async throws -> Post {
return try await request(.getPost(id: id))
}
func createPost(title: String, body: String) async throws -> Post {
return try await request(.createPost(title: title, body: body))
}
func deletePost(id: Int) async throws {
try await requestVoid(.deletePost(id: id))
}
}
// MARK: - ์๋ต ๋ชจ๋ธ๋ค
struct LoginResponse: Decodable {
let accessToken: String
let refreshToken: String
let user: User
}
// MARK: - ViewModel์์ ์ฌ์ฉ
@MainActor
class PostListViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let apiService: APIServiceProtocol
init(apiService: APIServiceProtocol = APIService.shared) {
self.apiService = apiService
}
func loadPosts() async {
isLoading = true
errorMessage = nil
do {
posts = try await apiService.getPosts()
} catch let error as AFError {
handleAFError(error)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
private func handleAFError(_ error: AFError) {
switch error {
case .responseValidationFailed(let reason):
switch reason {
case .unacceptableStatusCode(let code):
errorMessage = "์๋ฒ ์ค๋ฅ (์ฝ๋: \(code))"
default:
errorMessage = "์๋ต ๊ฒ์ฆ ์คํจ"
}
case .sessionTaskFailed(let error):
if (error as NSError).code == NSURLErrorNotConnectedToInternet {
errorMessage = "์ธํฐ๋ท ์ฐ๊ฒฐ์ ํ์ธํด์ฃผ์ธ์"
} else {
errorMessage = "๋คํธ์ํฌ ์ค๋ฅ: \(error.localizedDescription)"
}
default:
errorMessage = error.localizedDescription
}
}
}
๐งช ๋คํธ์ํฌ ์ฝ๋ ํ ์คํธํ๊ธฐ
์ค๋ฌด์์ ์ง์ง ์ค์ํ ๋ถ๋ถ์ด์ผ. ๋คํธ์ํฌ ์ฝ๋๋ฅผ ํ
์คํธํ๋ ค๋ฉด Mock ๊ฐ์ฒด๋ฅผ ํ์ฉํด์ผ ํด.
์ค์ ์๋ฒ์ ์์กดํ์ง ์๊ณ ํ
์คํธํ ์ ์์ด์ ๋น ๋ฅด๊ณ ์์ ์ ์ด๊ฑฐ๋ !
import XCTest
@testable import MyApp
// MARK: - Mock API ์๋น์ค
class MockAPIService: APIServiceProtocol {
// ํ
์คํธ์ฉ ๋๋ฏธ ๋ฐ์ดํฐ
var mockPosts: [Post] = [
Post(id: 1, userId: 1, title: "ํ
์คํธ ๊ฒ์๊ธ", body: "๋ด์ฉ"),
Post(id: 2, userId: 1, title: "๋ ๋ฒ์งธ ๊ฒ์๊ธ", body: "๋ด์ฉ2")
]
var shouldThrowError = false
var mockError: Error = NetworkError.serverError(500)
func login(email: String, password: String) async throws -> LoginResponse {
if shouldThrowError { throw mockError }
return LoginResponse(
accessToken: "mock_token",
refreshToken: "mock_refresh",
user: User(id: 1, name: "ํ
์คํธ์ ์ ", email: email, username: "test")
)
}
func getPosts() async throws -> [Post] {
if shouldThrowError { throw mockError }
return mockPosts
}
func getPost(id: Int) async throws -> Post {
if shouldThrowError { throw mockError }
guard let post = mockPosts.first(where: { $0.id == id }) else {
throw NetworkError.notFound
}
return post
}
func createPost(title: String, body: String) async throws -> Post {
if shouldThrowError { throw mockError }
let newPost = Post(id: mockPosts.count + 1, userId: 1, title: title, body: body)
mockPosts.append(newPost)
return newPost
}
func deletePost(id: Int) async throws {
if shouldThrowError { throw mockError }
mockPosts.removeAll { $0.id == id }
}
}
// MARK: - ViewModel ํ
์คํธ
class PostListViewModelTests: XCTestCase {
var viewModel: PostListViewModel!
var mockService: MockAPIService!
override func setUp() {
super.setUp()
mockService = MockAPIService()
viewModel = PostListViewModel(apiService: mockService)
}
// ๊ฒ์๊ธ ๋ก๋ ์ฑ๊ณต ํ
์คํธ
func testLoadPostsSuccess() async {
await viewModel.loadPosts()
XCTAssertEqual(viewModel.posts.count, 2)
XCTAssertNil(viewModel.errorMessage)
XCTAssertFalse(viewModel.isLoading)
}
// ๊ฒ์๊ธ ๋ก๋ ์คํจ ํ
์คํธ
func testLoadPostsFailure() async {
mockService.shouldThrowError = true
await viewModel.loadPosts()
XCTAssertTrue(viewModel.posts.isEmpty)
XCTAssertNotNil(viewModel.errorMessage)
XCTAssertFalse(viewModel.isLoading)
}
}
APIServiceProtocol์ ๋ง๋ ์ด์ ๊ฐ ๋ฐ๋ก ์ด๊ฑฐ์ผ. ํ๋กํ ์ฝ์ด ์์ผ๋ฉด Mock ๊ฐ์ฒด๋ฅผ ๋ง๋ค์ด์ ์ค์ ์๋ฒ ์์ด๋ ํ
์คํธํ ์ ์์ด. ์์กด์ฑ ์ฃผ์
(DI)์ ํต์ฌ์ด์ง!
๐ ์ค์ ์๋๋ฆฌ์ค: ํ์ด์ง๋ค์ด์ ๊ตฌํ
์ค์ ์ฑ์์ ์์ฃผ ์ฐ์ด๋ ๋ฌดํ ์คํฌ๋กค(ํ์ด์ง๋ค์ด์
)์ ๊ตฌํํด๋ณผ๊ฒ.
์ด๊ฑด ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ์ฝํ
์ธ ๋ชฉ๋ก์ ๋ถ๋ฌ์ฌ ๋ ๊ผญ ํ์ํ ๊ธฐ๋ฅ์ด์ผ!
@MainActor
class PaginatedPostViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var isLoading = false
@Published var isLoadingMore = false
@Published var hasMorePages = true
@Published var errorMessage: String?
private var currentPage = 1
private let pageSize = 20
private var isFetching = false
// ์ฒ์ ๋ก๋
func loadInitial() async {
guard !isFetching else { return }
currentPage = 1
posts = []
hasMorePages = true
await fetchPosts(page: currentPage)
}
// ๋ค์ ํ์ด์ง ๋ก๋ (๋ฌดํ ์คํฌ๋กค)
func loadMore() async {
guard !isFetching, hasMorePages else { return }
currentPage += 1
await fetchPosts(page: currentPage)
}
private func fetchPosts(page: Int) async {
isFetching = true
if page == 1 {
isLoading = true
} else {
isLoadingMore = true
}
defer {
isFetching = false
isLoading = false
isLoadingMore = false
}
do {
let newPosts = try await fetchPostsFromAPI(page: page, size: pageSize)
if page == 1 {
posts = newPosts
} else {
posts.append(contentsOf: newPosts)
}
// ๋ฐ์ ๋ฐ์ดํฐ๊ฐ pageSize๋ณด๋ค ์ ์ผ๋ฉด ๋ง์ง๋ง ํ์ด์ง
hasMorePages = newPosts.count == pageSize
} catch {
errorMessage = error.localizedDescription
if page > 1 {
currentPage -= 1 // ์คํจ ์ ํ์ด์ง ๋กค๋ฐฑ
}
}
}
private func fetchPostsFromAPI(page: Int, size: Int) async throws -> [Post] {
// Alamofire๋ก ํ์ด์ง๋ค์ด์
API ํธ์ถ
let parameters: Parameters = [
"page": page,
"size": size
]
return try await AF.request(
"https://api.example.com/posts",
parameters: parameters
)
.serializingDecodable([Post].self)
.value
}
}
// SwiftUI์์ ์ฌ์ฉ
struct PostListView: View {
@StateObject private var viewModel = PaginatedPostViewModel()
var body: some View {
List {
ForEach(viewModel.posts) { post in
PostRow(post: post)
.onAppear {
// ๋ง์ง๋ง ์์ดํ
์ด ๋ณด์ด๋ฉด ๋ค์ ํ์ด์ง ๋ก๋
if post.id == viewModel.posts.last?.id {
Task { await viewModel.loadMore() }
}
}
}
if viewModel.isLoadingMore {
ProgressView()
.frame(maxWidth: .infinity)
}
}
.task {
await viewModel.loadInitial()
}
}
}
๐ ๋ณด์ ๊ณ ๋ ค์ฌํญ
๋คํธ์ํน ์ฝ๋๋ฅผ ์งค ๋ ๋ณด์๋ ์ ๋ ๋น ๋จ๋ฆฌ๋ฉด ์ ๋ผ!
ํนํ iOS ์ฑ์์ ์ค์ํ ๋ณด์ ํฌ์ธํธ๋ค์ ์์๋ณด์.
๐ ๋ฏผ๊ฐํ ์ ๋ณด ๊ด๋ฆฌ
API ํค, ์ํฌ๋ฆฟ ํค ๊ฐ์ ๋ฏผ๊ฐํ ์ ๋ณด๋ ์ ๋ ์ฝ๋์ ํ๋์ฝ๋ฉํ๋ฉด ์ ๋ผ!
Keychain์ ์ฌ์ฉํ๊ฑฐ๋, ์๋ฒ์์ ๋์ ์ผ๋ก ๋ฐ์์ค๋ ๋ฐฉ์์ ์จ์ผ ํด.
import Security
class KeychainManager {
static let shared = KeychainManager()
func save(key: String, value: String) {
let data = value.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary) // ๊ธฐ์กด ๊ฐ ์ญ์
SecItemAdd(query as CFDictionary, nil)
}
func load(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
guard let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
// ์ฌ์ฉ
KeychainManager.shared.save(key: "accessToken", value: "your_token_here")
let token = KeychainManager.shared.load(key: "accessToken")
๐ก๏ธ SSL Pinning
์ค๊ฐ์ ๊ณต๊ฒฉ(MITM)์ ๋ฐฉ์งํ๊ธฐ ์ํด SSL Pinning์ ์ ์ฉํ ์ ์์ด.
Alamofire์์๋ ServerTrustManager๋ก ์ฝ๊ฒ ๊ตฌํ ๊ฐ๋ฅํด!
// SSL Pinning ์ค์
let evaluators: [String: ServerTrustEvaluating] = [
"api.example.com": PinnedCertificatesTrustEvaluator(
certificates: Bundle.main.af.certificates
)
]
let serverTrustManager = ServerTrustManager(evaluators: evaluators)
let secureSession = Session(
serverTrustManager: serverTrustManager
)
// ์ด์ ์ด session์ผ๋ก ์์ฒญํ๋ฉด ์ธ์ฆ์ ๊ฒ์ฆ์ด ์๋์ผ๋ก ๋จ!
๐ ATS (App Transport Security)
iOS๋ ๊ธฐ๋ณธ์ ์ผ๋ก HTTPS๋ง ํ์ฉํด (ATS ์ ์ฑ
).
HTTP๋ฅผ ์จ์ผ ํ๋ค๋ฉด Info.plist์ ์์ธ๋ฅผ ์ถ๊ฐํด์ผ ํ๋๋ฐ,
๊ฐ๋ฅํ๋ฉด ๋ชจ๋ ํต์ ์ HTTPS๋ฅผ ์ฐ๋ ๊ฒ ๋ง์!
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>api.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<!-- ์ ์ฒด HTTP ํ์ฉ์ ์ ๋ ํ์ง ๋ง! (๋ณด์ ์ํ) -->
<!-- NSAllowsArbitraryLoads: true โ ์ด๊ฑด ์ฑ์คํ ์ด ์ฌ์ฌ์์๋ ๋ฌธ์ ๋จ -->
โก ์ฑ๋ฅ ์ต์ ํ ํ
๐ ์์ฒญ ์ทจ์ (Task Cancellation)
class SearchViewModel: ObservableObject {
@Published var results: [Post] = []
private var searchTask: Task<Void, Never>?
func search(query: String) {
// ์ด์ ๊ฒ์ ์ทจ์
searchTask?.cancel()
searchTask = Task {
// ๋๋ฐ์ด์ฑ (300ms ๋๊ธฐ)
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
do {
let posts = try await searchPosts(query: query)
guard !Task.isCancelled else { return }
await MainActor.run {
self.results = posts
}
} catch is CancellationError {
// ์ทจ์๋ ์๋ฌ๊ฐ ์๋
} catch {
print("๊ฒ์ ์๋ฌ: \(error)")
}
}
}
private func searchPosts(query: String) async throws -> [Post] {
return try await AF.request(
"https://api.example.com/posts/search",
parameters: ["q": query]
)
.serializingDecodable([Post].self)
.value
}
}
// Alamofire ์์ฒญ ์ทจ์
class DataFetcher {
private var currentRequest: DataRequest?
func fetchData() {
currentRequest = AF.request("https://api.example.com/data")
.responseDecodable(of: [Post].self) { response in
// ์ฒ๋ฆฌ
}
}
func cancelFetch() {
currentRequest?.cancel()
}
}
โก ๋ณ๋ ฌ ์์ฒญ (async let)
// ์์ฐจ ์คํ (๋๋ฆผ ๐ด)
func loadDataSequential() async throws {
let user = try await fetchUser(id: 1) // ๊ธฐ๋ค๋ฆผ...
let posts = try await fetchPosts(userId: 1) // ๋ ๊ธฐ๋ค๋ฆผ...
let comments = try await fetchComments() // ๋๋ ๊ธฐ๋ค๋ฆผ...
// ์ด ์๊ฐ = A + B + C
}
// ๋ณ๋ ฌ ์คํ (๋น ๋ฆ ๐)
func loadDataParallel() async throws {
async let user = fetchUser(id: 1)
async let posts = fetchPosts(userId: 1)
async let comments = fetchComments()
// ์ธ ์์ฒญ์ด ๋์์ ์คํ๋จ!
let (userData, postsData, commentsData) = try await (user, posts, comments)
// ์ด ์๊ฐ = max(A, B, C)
print("์ ์ : \(userData.name)")
print("๊ฒ์๊ธ: \(postsData.count)๊ฐ")
print("๋๊ธ: \(commentsData.count)๊ฐ")
}
// TaskGroup์ผ๋ก ๋์ ๋ณ๋ ฌ ์ฒ๋ฆฌ
func fetchMultiplePosts(ids: [Int]) async throws -> [Post] {
return try await withThrowingTaskGroup(of: Post.self) { group in
for id in ids {
group.addTask {
try await self.fetchPost(id: id)
}
}
var posts: [Post] = []
for try await post in group {
posts.append(post)
}
return posts.sorted { $0.id < $1.id }
}
}
๐พ URLCache ํ์ฉ
// ์ฑ ์์ ์ ์บ์ ์ค์ (AppDelegate ๋๋ App struct)
func setupURLCache() {
let memoryCapacity = 50 * 1024 * 1024 // 50MB ๋ฉ๋ชจ๋ฆฌ ์บ์
let diskCapacity = 200 * 1024 * 1024 // 200MB ๋์คํฌ ์บ์
URLCache.shared = URLCache(
memoryCapacity: memoryCapacity,
diskCapacity: diskCapacity
)
}
// ์บ์ ์ ์ฑ
์ค์
var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad // ์บ์ ์ฐ์ , ์์ผ๋ฉด ๋คํธ์ํฌ
// Alamofire์์ ์บ์ ํ์ฉ
let config = URLSessionConfiguration.default
config.urlCache = URLCache.shared
config.requestCachePolicy = .useProtocolCachePolicy
let cachedSession = Session(configuration: config)
๐ฏ URLSession vs Alamofire โ ์ธ์ ๋ญ ์จ์ผ ํด?
์ด๊ฒ ์ง์ง ๋ง์ด ๋ฐ๋ ์ง๋ฌธ์ด์ผ. "URLSession ์ธ๊น์, Alamofire ์ธ๊น์?"
์ ๋ต์ ์์ด. ์ํฉ์ ๋ฐ๋ผ ๋ค๋ฅด๊ฑฐ๋ . ๋ด ๊ธฐ์ค์ ๊ณต์ ํด์ค๊ฒ!
โ URLSession์ ์ ํํด์ผ ํ ๋
1. ์ธ๋ถ ์์กด์ฑ์ ์ต์ํํ๊ณ ์ถ์ ๋
๋ผ์ด๋ธ๋ฌ๋ฆฌ ๋ฒ์ ๊ด๋ฆฌ, ์
๋ฐ์ดํธ ์ด์ ๋ฑ์ ํผํ๊ณ ์ถ๋ค๋ฉด URLSession์ด ๋ต์ด์ผ.
2. ๊ฐ๋จํ ์ฑ์ด๋ SDK๋ฅผ ๋ง๋ค ๋
SDK๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ๋ง๋ ๋ค๋ฉด ์ธ๋ถ ์์กด์ฑ์ ์ถ๊ฐํ๋ฉด ์ ๋ผ.
3. ์ต์ Swift ๊ธฐ๋ฅ์ ์ต๋ํ ํ์ฉํ๊ณ ์ถ์ ๋
async/await, Actor ๋ฑ Swift ์ต์ ๊ธฐ๋ฅ๊ณผ ๊ฐ์ฅ ์์ฐ์ค๋ฝ๊ฒ ํตํฉ๋ผ.
4. ํ์ต ๋ชฉ์
๋คํธ์ํน์ ๊ธฐ๋ณธ ์๋ฆฌ๋ฅผ ์ดํดํ๋ ค๋ฉด URLSession๋ถํฐ ์์ํ๋ ๊ฒ ๋ง์!
๐ฅ Alamofire๋ฅผ ์ ํํด์ผ ํ ๋
1. ๋ณต์กํ ์ธ์ฆ ๋ก์ง์ด ํ์ํ ๋
ํ ํฐ ๊ฐฑ์ , ์ธํฐ์
ํฐ ๋ฑ ๋ณต์กํ ์ธ์ฆ ์ฒ๋ฆฌ๊ฐ ํ์ํ๋ค๋ฉด Alamofire๊ฐ ํจ์ฌ ํธํด.
2. ํ์ผ ์
๋ก๋/๋ค์ด๋ก๋๊ฐ ๋ง์ ๋
multipart ์
๋ก๋, ์งํ๋ฅ ์ถ์ ๋ฑ์ด ํ์ํ๋ค๋ฉด Alamofire๊ฐ ์๋์ ์ผ๋ก ํธ๋ฆฌํด.
3. ํ ํ๋ก์ ํธ์์ ์์ฐ์ฑ์ด ์ค์ํ ๋
ํ์๋ค์ด ์ต์ํ๊ณ , ๋น ๋ฅธ ๊ฐ๋ฐ์ด ํ์ํ๋ค๋ฉด Alamofire๊ฐ ์ข์ ์ ํ์ด์ผ.
4. ๋ ๊ฑฐ์ ์ฝ๋ ์ ์ง๋ณด์
์ด๋ฏธ Alamofire๋ฅผ ์ฐ๊ณ ์๋ ํ๋ก์ ํธ๋ผ๋ฉด ๊ณ์ ์ฐ๋ ๊ฒ ๋ง์.
๐ ์์ฃผ ๋ง๋๋ ์๋ฌ์ ํด๊ฒฐ๋ฒ
๊ฐ๋ฐํ๋ค ๋ณด๋ฉด ๋ฐ๋์ ๋ง๋๊ฒ ๋๋ ์๋ฌ๋ค์ด ์์ด.
๋ฏธ๋ฆฌ ์์๋๋ฉด ์ฝ์ง ์๊ฐ์ ์์ฒญ ์ค์ผ ์ ์์ด!
| ์๋ฌ | ์์ธ | ํด๊ฒฐ๋ฒ |
|---|---|---|
NSURLErrorNotConnectedToInternet | ์ธํฐ๋ท ์ฐ๊ฒฐ ์์ | Network ์ํ ๋ชจ๋ํฐ๋ง ์ถ๊ฐ |
NSURLErrorTimedOut | ์์ฒญ ํ์์์ | timeoutInterval ์กฐ์ |
DecodingError | JSON ํ์ฑ ์คํจ | ๋ชจ๋ธ ๊ตฌ์กฐ ํ์ธ, keyDecodingStrategy ์ค์ |
401 Unauthorized | ์ธ์ฆ ํ ํฐ ๋ง๋ฃ | ํ ํฐ ๊ฐฑ์ ๋ก์ง ์ถ๊ฐ |
ATS ์ค๋ฅ | HTTP ์์ฒญ ์ฐจ๋จ | HTTPS ์ฌ์ฉ ๋๋ ATS ์์ธ ์ค์ |
task.resume() ๋๋ฝ | ์์ฒญ ์์ ์ ๋จ | ๋ฐ๋์ .resume() ํธ์ถ |
| ๋ฉ์ธ ์ค๋ ๋ UI ์ ๋ฐ์ดํธ ๊ฒฝ๊ณ | ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋์์ UI ์ ๊ทผ | DispatchQueue.main.async ๋๋ @MainActor ์ฌ์ฉ |
import Network
class NetworkMonitor: ObservableObject {
static let shared = NetworkMonitor()
@Published var isConnected = true
@Published var connectionType: ConnectionType = .unknown
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
enum ConnectionType {
case wifi, cellular, ethernet, unknown
}
private init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected = path.status == .satisfied
if path.usesInterfaceType(.wifi) {
self?.connectionType = .wifi
} else if path.usesInterfaceType(.cellular) {
self?.connectionType = .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
self?.connectionType = .ethernet
} else {
self?.connectionType = .unknown
}
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}
// SwiftUI์์ ์ฌ์ฉ
struct ContentView: View {
@StateObject private var networkMonitor = NetworkMonitor.shared
var body: some View {
VStack {
if !networkMonitor.isConnected {
Text("โ ๏ธ ์ธํฐ๋ท ์ฐ๊ฒฐ์ด ์์ต๋๋ค")
.foregroundColor(.red)
.padding()
.background(Color.yellow.opacity(0.3))
.cornerRadius(8)
}
// ๋๋จธ์ง ์ฝํ
์ธ
}
}
}
๐ ์ ๋ฆฌ โ ์ค๋ ๋ฐฐ์ด ๊ฒ๋ค
์ค๋ ์ ๋ง ๋ง์ ๋ด์ฉ์ ๋ค๋ค์ด! ํ ๋ฒ์ ๋ค ์ธ์ฐ๋ ค๊ณ ํ์ง ๋ง๊ณ ,
์ค์ ๋ก ์ฝ๋๋ฅผ ์ณ๋ณด๋ฉด์ ์ตํ๋ ๊ฒ ๊ฐ์ฅ ์ค์ํด.
์ฒ์์ URLSession์ผ๋ก ๊ฐ๋จํ GET ์์ฒญ๋ถํฐ ์์ํด์,
์ ์ ๋ณต์กํ ๊ธฐ๋ฅ๋ค์ ์ถ๊ฐํด๋๊ฐ๋ ๋ฐฉ์์ผ๋ก ์ฐ์ตํด๋ด!
๊ทธ๋ฆฌ๊ณ ์ฌ๋ฅ๋ท์์ iOS ๊ฐ๋ฐ ๊ด๋ จ ์ฌ๋ฅ์ ์ฐพ์๋ณด๋ฉด,
์ค๋ฌด ๊ฒฝํ์ด ์๋ ๊ฐ๋ฐ์๋ค์๊ฒ ์ง์ ์ฝ๋ ๋ฆฌ๋ทฐ๋ ๋ฉํ ๋ง์ ๋ฐ์ ์๋ ์์ด.
ํผ์ ๊ณต๋ถํ๋ ๊ฒ๋ณด๋ค ํจ์ฌ ๋น ๋ฅด๊ฒ ์ฑ์ฅํ ์ ์๊ฑฐ๋ ! ๐ช
๐ฏ ํต์ฌ ํฌ์ธํธ ์์ฝ
URLSession Apple ๊ธฐ๋ณธ ์ ๊ณต, ์ธ๋ถ ์์กด์ฑ ์์
Codable JSON ํ์ฑ์ ํ์ค, ํ์
์์ ์ฑ ๋ณด์ฅ
async/await ํ๋์ ๋น๋๊ธฐ ์ฒ๋ฆฌ, iOS 15+
Alamofire ๋ณต์กํ ๋คํธ์ํน์ ๊ฐ๊ฒฐํ๊ฒ
Interceptor ์ธ์ฆ ํ ํฐ ์๋ ์ฒ๋ฆฌ
Protocol ํ
์คํธ ๊ฐ๋ฅํ ์ค๊ณ์ ํต์ฌ
Mock ์๋ฒ ์์ด ํ
์คํธ ๊ฐ๋ฅ
SSL Pinning ๋ณด์ ๊ฐํ
NWPathMonitor ๋คํธ์ํฌ ์ํ ๊ฐ์ง
async let ๋ณ๋ ฌ ์์ฒญ์ผ๋ก ์ฑ๋ฅ ํฅ์
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.