์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿš€ Swift JSON ํŒŒ์‹ฑ๊ณผ Codable ํ”„๋กœํ† ์ฝœ๋กœ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ์™„๋ฒฝ ์ •๋ณตํ•˜๊ธฐ

๐Ÿš€ Swift JSON ํŒŒ์‹ฑ๊ณผ Codable ํ”„๋กœํ† ์ฝœ๋กœ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ์™„๋ฒฝ ์ •๋ณตํ•˜๊ธฐ

API ์‘๋‹ต์„ Swift ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ๋งˆ๋ฒ• ๊ฐ™์€ ์—ฌ์ • โœจ

๐ŸŽฏ ์™œ JSON ํŒŒ์‹ฑ์ด ์ค‘์š”ํ• ๊นŒ?

์•ˆ๋…•! ์˜ค๋Š˜์€ Swift ๊ฐœ๋ฐœ์ž๋ผ๋ฉด ๋ฐ˜๋“œ์‹œ ์•Œ์•„์•ผ ํ•  JSON ํŒŒ์‹ฑ๊ณผ Codable ํ”„๋กœํ† ์ฝœ์— ๋Œ€ํ•ด ์ด์•ผ๊ธฐํ•ด๋ณผ๊ฒŒ. ๐Ÿ˜Š

์š”์ฆ˜ ์•ฑ ๊ฐœ๋ฐœํ•˜๋ฉด์„œ ์„œ๋ฒ„์™€ ํ†ต์‹  ์•ˆ ํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๊ฑฐ์˜ ์—†์ž–์•„? ๋‚ ์”จ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋“ , ์‚ฌ์šฉ์ž ํ”„๋กœํ•„์„ ๋ถˆ๋Ÿฌ์˜ค๋“ , ์‡ผํ•‘๋ชฐ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ณด์—ฌ์ฃผ๋“ ... ๋ชจ๋“  ๊ฒŒ ์„œ๋ฒ„์™€์˜ ๋ฐ์ดํ„ฐ ์ฃผ๊ณ ๋ฐ›๊ธฐ๋กœ ์‹œ์ž‘๋˜์ง€. ๊ทธ๋ฆฌ๊ณ  ๊ทธ ๋ฐ์ดํ„ฐ ํ˜•์‹์˜ 90% ์ด์ƒ์ด ๋ฐ”๋กœ JSON(JavaScript Object Notation)์ด์•ผ! ๐Ÿ“ฆ

์˜ˆ์ „์—๋Š” JSON ํŒŒ์‹ฑ์ด ์ •๋ง ๊ณจ์น˜ ์•„ํ”ˆ ์ž‘์—…์ด์—ˆ์–ด. JSONSerialization์„ ์‚ฌ์šฉํ•ด์„œ ๋”•์…”๋„ˆ๋ฆฌ๋กœ ๋ณ€ํ™˜ํ•˜๊ณ , ํƒ€์ž… ์บ์ŠคํŒ…ํ•˜๊ณ , ์˜ต์…”๋„ ์ฒด์ด๋‹ ์ง€์˜ฅ์— ๋น ์ง€๊ณ ... ์ƒ๊ฐ๋งŒ ํ•ด๋„ ๋”์ฐํ•˜์ง€? ๐Ÿ˜ฑ

ํ•˜์ง€๋งŒ Swift 4๋ถ€ํ„ฐ ๋“ฑ์žฅํ•œ Codable ํ”„๋กœํ† ์ฝœ์€ ์ด ๋ชจ๋“  ๊ฑธ ํ˜๋ช…์ ์œผ๋กœ ๋ฐ”๊ฟ”๋†จ์–ด. ๋งˆ์น˜ ๋งˆ๋ฒ•์ฒ˜๋Ÿผ JSON ๋ฐ์ดํ„ฐ๊ฐ€ Swift ๊ฐ์ฒด๋กœ ๋ณ€์‹ ํ•˜๊ฑฐ๋“ !

JSON Data { "name": "Swift", "version": 5.9, "isAwesome": true } Swift Object struct Language { let name: String let version: Double let isAwesome: Bool } Codable Magic!

๐Ÿ“š Codable ํ”„๋กœํ† ์ฝœ์˜ ์ •์ฒด

์ž, ๋ณธ๊ฒฉ์ ์œผ๋กœ Codable์ด ๋ญ”์ง€ ํŒŒํ—ค์ณ๋ณด์ž! ๐Ÿ”

Codable์€ ์‚ฌ์‹ค ๋‘ ๊ฐœ์˜ ํ”„๋กœํ† ์ฝœ์„ ํ•ฉ์ณ๋†“์€ ํƒ€์ž… ๋ณ„์นญ(typealias)์ด์•ผ:

typealias Codable = Decodable & Encodable


์ฆ‰, Decodable๊ณผ Encodable ๋‘ ํ”„๋กœํ† ์ฝœ์„ ๋™์‹œ์— ์ฑ„ํƒํ•œ ๊ฑฐ์ง€. ๊ฐ๊ฐ์˜ ์—ญํ• ์„ ๋ณด๋ฉด:

ํ”„๋กœํ† ์ฝœ ์—ญํ•  ์‚ฌ์šฉ ์‹œ๋‚˜๋ฆฌ์˜ค
Decodable ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ โ†’ Swift ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ (๋””์ฝ”๋”ฉ) ์„œ๋ฒ„์—์„œ ๋ฐ›์€ JSON์„ ์•ฑ์—์„œ ์‚ฌ์šฉ
Encodable Swift ๊ฐ์ฒด โ†’ ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ๋กœ ๋ณ€ํ™˜ (์ธ์ฝ”๋”ฉ) ์•ฑ ๋ฐ์ดํ„ฐ๋ฅผ JSON์œผ๋กœ ๋งŒ๋“ค์–ด ์„œ๋ฒ„์— ์ „์†ก
Codable ์–‘๋ฐฉํ–ฅ ๋ณ€ํ™˜ ๋ชจ๋‘ ๊ฐ€๋Šฅ ๋ฐ์ดํ„ฐ๋ฅผ ์ฃผ๊ณ ๋ฐ›๋Š” ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ


๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ ์–‘๋ฐฉํ–ฅ ๋ณ€ํ™˜์ด ํ•„์š”ํ•˜๋‹ˆ๊นŒ ๊ทธ๋ƒฅ Codable์„ ์‚ฌ์šฉํ•˜๋ฉด ๋ผ. ๊ฐ„๋‹จํ•˜์ง€? ๐Ÿ˜Ž

๐ŸŽจ ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•: ์ฒซ ๋ฒˆ์งธ Codable ๊ตฌ์กฐ์ฒด

์ด์ œ ์‹ค์ „์œผ๋กœ ๋“ค์–ด๊ฐ€๋ณผ๊นŒ? ๊ฐ€์žฅ ๊ธฐ๋ณธ์ ์ธ ์˜ˆ์ œ๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ด๋ณด์ž! ๐Ÿš€

๋‚ ์”จ ์•ฑ์„ ๋งŒ๋“ ๋‹ค๊ณ  ๊ฐ€์ •ํ•ด๋ณผ๊ฒŒ. ์„œ๋ฒ„์—์„œ ์ด๋Ÿฐ JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•˜์–ด:

{
  "city": "Seoul",
  "temperature": 23.5,
  "condition": "Sunny",
  "humidity": 65
}


์ด ๋ฐ์ดํ„ฐ๋ฅผ Swift์—์„œ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์ด๋ ‡๊ฒŒ ๊ตฌ์กฐ์ฒด๋ฅผ ๋งŒ๋“ค๋ฉด ๋ผ:

struct Weather: Codable {
    let city: String
    let temperature: Double
    let condition: String
    let humidity: Int
}


๋! ์ •๋ง ์ด๊ฒŒ ๋์ด์•ผ! ๐Ÿ˜ฒ

Codable ํ”„๋กœํ† ์ฝœ๋งŒ ์ฑ„ํƒํ•˜๋ฉด Swift ์ปดํŒŒ์ผ๋Ÿฌ๊ฐ€ ์ž๋™์œผ๋กœ ์ธ์ฝ”๋”ฉ/๋””์ฝ”๋”ฉ ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•ด์ค˜. ํ”„๋กœํผํ‹ฐ ์ด๋ฆ„์ด JSON ํ‚ค์™€ ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋ฉด ์ถ”๊ฐ€ ์ž‘์—…์ด ์ „ํ˜€ ํ•„์š” ์—†์–ด.

์ด์ œ ์‹ค์ œ๋กœ JSON์„ ํŒŒ์‹ฑํ•ด๋ณด์ž:

let jsonString = """
{
  "city": "Seoul",
  "temperature": 23.5,
  "condition": "Sunny",
  "humidity": 65
}
"""

// JSON ๋ฌธ์ž์—ด์„ Data๋กœ ๋ณ€ํ™˜
let jsonData = jsonString.data(using: .utf8)!

// JSONDecoder๋กœ ๋””์ฝ”๋”ฉ
let decoder = JSONDecoder()
do {
    let weather = try decoder.decode(Weather.self, from: jsonData)
    print("๋„์‹œ: \(weather.city)")
    print("์˜จ๋„: \(weather.temperature)ยฐC")
    print("๋‚ ์”จ: \(weather.condition)")
    print("์Šต๋„: \(weather.humidity)%")
} catch {
    print("๋””์ฝ”๋”ฉ ์‹คํŒจ: \(error)")
}
๐Ÿ’ก Pro Tip: JSONDecoder๋Š” ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฐ์ฒด์•ผ. ์—ฌ๋Ÿฌ ๋ฒˆ ๋””์ฝ”๋”ฉํ•  ๊ฑฐ๋ผ๋ฉด ํ•œ ๋ฒˆ ๋งŒ๋“ค์–ด์„œ ๊ณ„์† ์‚ฌ์šฉํ•˜๋Š” ๊ฒŒ ํšจ์œจ์ ์ด์ง€!

๐Ÿ”‘ CodingKeys: ํ”„๋กœํผํ‹ฐ ์ด๋ฆ„ ๋งคํ•‘ํ•˜๊ธฐ

์‹ค์ œ ๊ฐœ๋ฐœํ•˜๋‹ค ๋ณด๋ฉด JSON ํ‚ค ์ด๋ฆ„๊ณผ Swift ํ”„๋กœํผํ‹ฐ ์ด๋ฆ„์ด ๋‹ค๋ฅธ ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์•„. ํŠนํžˆ ์„œ๋ฒ„ ๊ฐœ๋ฐœ์ž๋“ค์€ snake_case๋ฅผ ์„ ํ˜ธํ•˜๋Š”๋ฐ, Swift์—์„œ๋Š” camelCase๋ฅผ ์‚ฌ์šฉํ•˜์ž–์•„? ๐Ÿ๐Ÿซ

์˜ˆ๋ฅผ ๋“ค์–ด ์ด๋Ÿฐ JSON์ด ์žˆ๋‹ค๊ณ  ํ•ด๋ณด์ž:

{
  "user_name": "๊น€์ฝ”๋”ฉ",
  "user_age": 28,
  "is_premium_member": true,
  "account_created_at": "2024-01-15"
}


์ด๊ฑธ Swift ์Šคํƒ€์ผ๋กœ ๋ฐ”๊พธ๋ ค๋ฉด CodingKeys ์—ด๊ฑฐํ˜•์„ ์‚ฌ์šฉํ•ด:

struct User: Codable {
    let userName: String
    let userAge: Int
    let isPremiumMember: Bool
    let accountCreatedAt: String
    
    enum CodingKeys: String, CodingKey {
        case userName = "user_name"
        case userAge = "user_age"
        case isPremiumMember = "is_premium_member"
        case accountCreatedAt = "account_created_at"
    }
}


CodingKeys๋Š” String๊ณผ CodingKey ํ”„๋กœํ† ์ฝœ์„ ์ฑ„ํƒํ•œ ์—ด๊ฑฐํ˜•์ด์•ผ. ๊ฐ case์˜ ์ด๋ฆ„์€ Swift ํ”„๋กœํผํ‹ฐ ์ด๋ฆ„๊ณผ ์ผ์น˜ํ•ด์•ผ ํ•˜๊ณ , raw value๋กœ JSON ํ‚ค ์ด๋ฆ„์„ ์ง€์ •ํ•˜๋ฉด ๋ผ.

๐Ÿ’ก ์ž๋™ ๋ณ€ํ™˜ ๊ธฐ๋Šฅ: Swift 5.1๋ถ€ํ„ฐ๋Š” JSONDecoder์˜ keyDecodingStrategy๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ž๋™์œผ๋กœ snake_case๋ฅผ camelCase๋กœ ๋ณ€ํ™˜ํ•  ์ˆ˜ ์žˆ์–ด!

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

// ์ด์ œ CodingKeys ์—†์ด๋„ ์ž๋™ ๋ณ€ํ™˜!
struct User: Codable {
    let userName: String
    let userAge: Int
    let isPremiumMember: Bool
    let accountCreatedAt: String
}

๐ŸŽญ ์ค‘์ฒฉ๋œ JSON ๊ตฌ์กฐ ๋‹ค๋ฃจ๊ธฐ

์‹ค์ œ API ์‘๋‹ต์€ ๋Œ€๋ถ€๋ถ„ ๋ณต์žกํ•œ ์ค‘์ฒฉ ๊ตฌ์กฐ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์–ด. ์˜ˆ๋ฅผ ๋“ค์–ด ์‚ฌ์šฉ์ž ์ •๋ณด์— ์ฃผ์†Œ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋‹ค๋ฉด:

{
  "name": "์ด๊ฐœ๋ฐœ",
  "email": "dev@example.com",
  "address": {
    "street": "ํ…Œํ—ค๋ž€๋กœ 123",
    "city": "์„œ์šธ",
    "zipCode": "06234"
  },
  "phoneNumbers": [
    "010-1234-5678",
    "02-987-6543"
  ]
}


์ด๋Ÿฐ ๊ตฌ์กฐ๋Š” ์ค‘์ฒฉ๋œ ๊ตฌ์กฐ์ฒด๋กœ ํ‘œํ˜„ํ•˜๋ฉด ๋ผ:

struct UserProfile: Codable {
    let name: String
    let email: String
    let address: Address
    let phoneNumbers: [String]
    
    struct Address: Codable {
        let street: String
        let city: String
        let zipCode: String
    }
}


๋ณด๋‹ค์‹œํ”ผ Address๋ฅผ ๋ณ„๋„์˜ ๊ตฌ์กฐ์ฒด๋กœ ๋งŒ๋“ค๊ณ , ๊ทธ๊ฒƒ๋„ Codable์„ ์ฑ„ํƒํ•˜๋ฉด ๋ผ. ๋ฐฐ์—ด([String])๋„ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์ง€! ๐ŸŽ‰

๋” ๋ณต์žกํ•œ ์˜ˆ์ œ๋ฅผ ๋ณผ๊นŒ? ์˜ํ™” ์ •๋ณด API ์‘๋‹ต์ด๋ผ๊ณ  ํ•ด๋ณด์ž:

{
  "title": "์ธ์…‰์…˜",
  "releaseYear": 2010,
  "director": {
    "name": "ํฌ๋ฆฌ์Šคํ† ํผ ๋†€๋ž€",
    "birthYear": 1970
  },
  "cast": [
    {
      "name": "๋ ˆ์˜ค๋‚˜๋ฅด๋„ ๋””์นดํ”„๋ฆฌ์˜ค",
      "role": "์ฝ”๋ธŒ"
    },
    {
      "name": "๋งˆ๋ฆฌ์˜จ ์ฝ”ํ‹ฐ์•ผ๋ฅด",
      "role": "๋ง"
    }
  ],
  "ratings": {
    "imdb": 8.8,
    "rottenTomatoes": 87
  }
}


์ด๊ฑธ Swift ๊ตฌ์กฐ์ฒด๋กœ ํ‘œํ˜„ํ•˜๋ฉด:

struct Movie: Codable {
    let title: String
    let releaseYear: Int
    let director: Director
    let cast: [Actor]
    let ratings: Ratings
    
    struct Director: Codable {
        let name: String
        let birthYear: Int
    }
    
    struct Actor: Codable {
        let name: String
        let role: String
    }
    
    struct Ratings: Codable {
        let imdb: Double
        let rottenTomatoes: Int
    }
}


์ค‘์ฒฉ ๊ตฌ์กฐ์ฒด๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ฝ”๋“œ๊ฐ€ ํ›จ์”ฌ ๊น”๋”ํ•˜๊ณ  ํƒ€์ž… ์•ˆ์ „์„ฑ๋„ ๋ณด์žฅ๋ผ. ๊ฐ ๊ตฌ์กฐ์ฒด๊ฐ€ ์ž์‹ ์˜ ์—ญํ• ์— ์ง‘์ค‘ํ•  ์ˆ˜ ์žˆ์–ด์„œ ์œ ์ง€๋ณด์ˆ˜๋„ ์‰ฝ์ง€! ๐Ÿ‘

์ค‘์ฒฉ JSON ๊ตฌ์กฐ ์‹œ๊ฐํ™” Movie Director name: String birthYear: Int Ratings imdb: Double rottenTomatoes: Int Cast: [Actor] Actor 1 name: String role: String Actor 2 name: String role: String

๐Ÿ› ๏ธ ์ปค์Šคํ…€ ๋””์ฝ”๋”ฉ: init(from:) ๊ตฌํ˜„ํ•˜๊ธฐ

๋•Œ๋กœ๋Š” ์ž๋™ ๋””์ฝ”๋”ฉ๋งŒ์œผ๋กœ๋Š” ๋ถ€์กฑํ•œ ๊ฒฝ์šฐ๊ฐ€ ์žˆ์–ด. ์˜ˆ๋ฅผ ๋“ค์–ด:

โ€ข JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ€ํ™˜ํ•˜๊ฑฐ๋‚˜ ๊ฐ€๊ณตํ•ด์•ผ ํ•  ๋•Œ
โ€ข ๊ธฐ๋ณธ๊ฐ’์„ ์„ค์ •ํ•ด์•ผ ํ•  ๋•Œ
โ€ข ์—ฌ๋Ÿฌ JSON ํ‚ค๋ฅผ ํ•˜๋‚˜์˜ ํ”„๋กœํผํ‹ฐ๋กœ ํ•ฉ์ณ์•ผ ํ•  ๋•Œ
โ€ข ๋‚ ์งœ ํ˜•์‹์„ ๋ณ€ํ™˜ํ•ด์•ผ ํ•  ๋•Œ

์ด๋Ÿด ๋•Œ๋Š” init(from decoder: Decoder) ๋ฉ”์„œ๋“œ๋ฅผ ์ง์ ‘ ๊ตฌํ˜„ํ•˜๋ฉด ๋ผ! ๐ŸŽฏ

์˜ˆ๋ฅผ ๋“ค์–ด, ์„œ๋ฒ„์—์„œ ์˜จ๋„๋ฅผ ํ™”์”จ๋กœ ๋ณด๋‚ด๋Š”๋ฐ ์šฐ๋ฆฌ๋Š” ์„ญ์”จ๋กœ ์ €์žฅํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด:

struct Temperature: Codable {
    let celsius: Double
    let location: String
    
    enum CodingKeys: String, CodingKey {
        case fahrenheit = "temperature"
        case location
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        // ํ™”์”จ๋ฅผ ์„ญ์”จ๋กœ ๋ณ€ํ™˜
        let fahrenheit = try container.decode(Double.self, forKey: .fahrenheit)
        self.celsius = (fahrenheit - 32) * 5 / 9
        
        // location์€ ๊ทธ๋Œ€๋กœ
        self.location = try container.decode(String.self, forKey: .location)
    }
}


๋” ๋ณต์žกํ•œ ์˜ˆ์ œ๋ฅผ ๋ณผ๊นŒ? ์‚ฌ์šฉ์ž์˜ ์ „์ฒด ์ด๋ฆ„์ด firstName๊ณผ lastName์œผ๋กœ ๋‚˜๋‰˜์–ด ์žˆ๋Š”๋ฐ, ์šฐ๋ฆฌ๋Š” fullName ํ•˜๋‚˜๋กœ ์ €์žฅํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด:

struct Person: Codable {
    let fullName: String
    let age: Int
    let email: String
    
    enum CodingKeys: String, CodingKey {
        case firstName
        case lastName
        case age
        case email
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        // firstName๊ณผ lastName์„ ํ•ฉ์ณ์„œ fullName ์ƒ์„ฑ
        let firstName = try container.decode(String.self, forKey: .firstName)
        let lastName = try container.decode(String.self, forKey: .lastName)
        self.fullName = "\(firstName) \(lastName)"
        
        // ๋‚˜๋จธ์ง€๋Š” ๊ทธ๋Œ€๋กœ ๋””์ฝ”๋”ฉ
        self.age = try container.decode(Int.self, forKey: .age)
        self.email = try container.decode(String.self, forKey: .email)
    }
}
๐Ÿ” Container์˜ ์ข…๋ฅ˜ ๋””์ฝ”๋”ฉํ•  ๋•Œ ์‚ฌ์šฉํ•˜๋Š” ์ปจํ…Œ์ด๋„ˆ๋Š” ์„ธ ๊ฐ€์ง€ ์ข…๋ฅ˜๊ฐ€ ์žˆ์–ด:

1. KeyedDecodingContainer: ๋”•์…”๋„ˆ๋ฆฌ์ฒ˜๋Ÿผ ํ‚ค-๊ฐ’ ์Œ์œผ๋กœ ์ ‘๊ทผ
2. UnkeyedDecodingContainer: ๋ฐฐ์—ด์ฒ˜๋Ÿผ ์ˆœ์„œ๋Œ€๋กœ ์ ‘๊ทผ
3. SingleValueDecodingContainer: ๋‹จ์ผ ๊ฐ’ ๋””์ฝ”๋”ฉ

๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ KeyedDecodingContainer๋ฅผ ์‚ฌ์šฉํ•˜๊ฒŒ ๋  ๊ฑฐ์•ผ!

๐Ÿ“… ๋‚ ์งœ ์ฒ˜๋ฆฌํ•˜๊ธฐ

๋‚ ์งœ ์ฒ˜๋ฆฌ๋Š” JSON ํŒŒ์‹ฑ์—์„œ ๊ฐ€์žฅ ๊ณจ์น˜ ์•„ํ”ˆ ๋ถ€๋ถ„ ์ค‘ ํ•˜๋‚˜์•ผ. ์„œ๋ฒ„๋งˆ๋‹ค ๋‚ ์งœ ํ˜•์‹์ด ๋‹ค๋ฅด๊ฑฐ๋“ ! ๐Ÿ˜…

์ผ๋ฐ˜์ ์ธ ๋‚ ์งœ ํ˜•์‹๋“ค:

// ISO 8601 ํ˜•์‹ (๊ฐ€์žฅ ํ‘œ์ค€์ )
"2024-01-15T14:30:00Z"

// ํƒ€์ž„์Šคํƒฌํ”„ (Unix timestamp)
1705329000

// ์ปค์Šคํ…€ ํ˜•์‹
"15/01/2024"
"2024๋…„ 1์›” 15์ผ"


JSONDecoder๋Š” ๋‚ ์งœ ๋””์ฝ”๋”ฉ ์ „๋žต์„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์–ด:

let decoder = JSONDecoder()

// 1. ISO 8601 ํ˜•์‹ (๊ธฐ๋ณธ ๊ถŒ์žฅ)
decoder.dateDecodingStrategy = .iso8601

// 2. ํƒ€์ž„์Šคํƒฌํ”„ (์ดˆ ๋‹จ์œ„)
decoder.dateDecodingStrategy = .secondsSince1970

// 3. ํƒ€์ž„์Šคํƒฌํ”„ (๋ฐ€๋ฆฌ์ดˆ ๋‹จ์œ„)
decoder.dateDecodingStrategy = .millisecondsSince1970

// 4. ์ปค์Šคํ…€ DateFormatter ์‚ฌ์šฉ
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.locale = Locale(identifier: "ko_KR")
dateFormatter.timeZone = TimeZone(identifier: "Asia/Seoul")
decoder.dateDecodingStrategy = .formatted(dateFormatter)

// 5. ์™„์ „ ์ปค์Šคํ…€ ๋””์ฝ”๋”ฉ
decoder.dateDecodingStrategy = .custom { decoder in
    let container = try decoder.singleValueContainer()
    let dateString = try container.decode(String.self)
    
    // ์—ฌ๋Ÿฌ ํ˜•์‹ ์‹œ๋„
    let formatters = [
        "yyyy-MM-dd",
        "yyyy-MM-dd'T'HH:mm:ss",
        "dd/MM/yyyy"
    ].map { format -> DateFormatter in
        let formatter = DateFormatter()
        formatter.dateFormat = format
        return formatter
    }
    
    for formatter in formatters {
        if let date = formatter.date(from: dateString) {
            return date
        }
    }
    
    throw DecodingError.dataCorruptedError(
        in: container,
        debugDescription: "๋‚ ์งœ ํ˜•์‹์„ ํŒŒ์‹ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: \(dateString)"
    )
}


์‹ค์ œ ์‚ฌ์šฉ ์˜ˆ์ œ:

struct Event: Codable {
    let title: String
    let startDate: Date
    let endDate: Date
    let createdAt: Date
}

let jsonString = """
{
  "title": "Swift ์ปจํผ๋Ÿฐ์Šค",
  "startDate": "2024-03-15T09:00:00Z",
  "endDate": "2024-03-15T18:00:00Z",
  "createdAt": "2024-01-10T12:30:00Z"
}
"""

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

let jsonData = jsonString.data(using: .utf8)!
let event = try decoder.decode(Event.self, from: jsonData)

// ๋‚ ์งœ ํฌ๋งทํŒ…ํ•ด์„œ ์ถœ๋ ฅ
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .long
displayFormatter.timeStyle = .short
displayFormatter.locale = Locale(identifier: "ko_KR")

print("์ด๋ฒคํŠธ: \(event.title)")
print("์‹œ์ž‘: \(displayFormatter.string(from: event.startDate))")
print("์ข…๋ฃŒ: \(displayFormatter.string(from: event.endDate))")
๐Ÿ’ก ๋‚ ์งœ ์ฒ˜๋ฆฌ Best Practice: โ€ข ๊ฐ€๋Šฅํ•˜๋ฉด ์„œ๋ฒ„์™€ ISO 8601 ํ˜•์‹์œผ๋กœ ํ†ต์ผํ•˜๋Š” ๊ฒŒ ์ข‹์•„
โ€ข ํƒ€์ž„์กด ์ฒ˜๋ฆฌ๋ฅผ ํ•ญ์ƒ ๊ณ ๋ คํ•ด์•ผ ํ•ด (UTC vs ๋กœ์ปฌ ์‹œ๊ฐ„)
โ€ข ๋‚ ์งœ ๋น„๊ต๋‚˜ ๊ณ„์‚ฐ์ด ํ•„์š”ํ•˜๋ฉด Date ํƒ€์ž…์œผ๋กœ ์ €์žฅํ•˜๊ณ , ํ‘œ์‹œํ•  ๋•Œ๋งŒ DateFormatter ์‚ฌ์šฉ
โ€ข ์—ฌ๋Ÿฌ ๋‚ ์งœ ํ˜•์‹์„ ์ง€์›ํ•ด์•ผ ํ•œ๋‹ค๋ฉด ์ปค์Šคํ…€ ๋””์ฝ”๋”ฉ ์ „๋žต ์‚ฌ์šฉ

๐ŸŽช ์˜ต์…”๋„๊ณผ ๊ธฐ๋ณธ๊ฐ’ ์ฒ˜๋ฆฌ

์‹ค์ œ API๋ฅผ ๋‹ค๋ฃจ๋‹ค ๋ณด๋ฉด ๊ฐ’์ด ์—†์„ ์ˆ˜๋„ ์žˆ๋Š” ํ•„๋“œ๋“ค์ด ๋งŽ์•„. ์ด๋Ÿด ๋•Œ ์˜ต์…”๋„ ์ฒ˜๋ฆฌ๊ฐ€ ์ค‘์š”ํ•˜์ง€! ๐ŸŽญ

JSON์—์„œ ๊ฐ’์ด ์—†๋Š” ๊ฒฝ์šฐ๋Š” ๋‘ ๊ฐ€์ง€์•ผ:

1. ํ‚ค ์ž์ฒด๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ
2. ํ‚ค๋Š” ์žˆ์ง€๋งŒ ๊ฐ’์ด null์ธ ๊ฒฝ์šฐ

// ์ผ€์ด์Šค 1: ํ‚ค๊ฐ€ ์—†์Œ
{
  "name": "ํ™๊ธธ๋™",
  "age": 30
  // email ํ‚ค ์ž์ฒด๊ฐ€ ์—†์Œ
}

// ์ผ€์ด์Šค 2: ๊ฐ’์ด null
{
  "name": "ํ™๊ธธ๋™",
  "age": 30,
  "email": null
}


Swift์—์„œ๋Š” ์ด๋ ‡๊ฒŒ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด:

struct User: Codable {
    let name: String
    let age: Int
    let email: String?        // ์˜ต์…”๋„๋กœ ์„ ์–ธ
    let phoneNumber: String?
    
    // ๊ธฐ๋ณธ๊ฐ’์„ ๊ฐ€์ง„ ํ”„๋กœํผํ‹ฐ
    var isActive: Bool = true
    var membershipLevel: String = "basic"
}


ํ•˜์ง€๋งŒ ๊ธฐ๋ณธ๊ฐ’์„ ์„ค์ •ํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด ์ปค์Šคํ…€ ๋””์ฝ”๋”ฉ์ด ํ•„์š”ํ•ด:

struct UserProfile: Codable {
    let username: String
    let bio: String
    let followerCount: Int
    let isVerified: Bool
    
    enum CodingKeys: String, CodingKey {
        case username
        case bio
        case followerCount = "follower_count"
        case isVerified = "is_verified"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        username = try container.decode(String.self, forKey: .username)
        
        // bio๊ฐ€ ์—†์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ
        bio = try container.decodeIfPresent(String.self, forKey: .bio) ?? "์†Œ๊ฐœ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."
        
        // followerCount๊ฐ€ ์—†์œผ๋ฉด 0
        followerCount = try container.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
        
        // isVerified๊ฐ€ ์—†์œผ๋ฉด false
        isVerified = try container.decodeIfPresent(Bool.self, forKey: .isVerified) ?? false
    }
}


decodeIfPresent ๋ฉ”์„œ๋“œ๋Š” ํ‚ค๊ฐ€ ์—†๊ฑฐ๋‚˜ ๊ฐ’์ด null์ผ ๋•Œ nil์„ ๋ฐ˜ํ™˜ํ•ด. ๊ทธ๋ž˜์„œ nil-coalescing ์—ฐ์‚ฐ์ž(??)์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด ๊ธฐ๋ณธ๊ฐ’์„ ์‰ฝ๊ฒŒ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์ง€! ๐Ÿ‘

๋” ํŽธ๋ฆฌํ•œ ๋ฐฉ๋ฒ•์œผ๋กœ Property Wrapper๋ฅผ ๋งŒ๋“ค ์ˆ˜๋„ ์žˆ์–ด:

@propertyWrapper
struct DefaultValue<T: Codable>: Codable {
    var wrappedValue: T
    
    init(wrappedValue: T) {
        self.wrappedValue = wrappedValue
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = (try? container.decode(T.self)) ?? wrappedValue
    }
}

// ์‚ฌ์šฉ ์˜ˆ์ œ
struct Settings: Codable {
    @DefaultValue(wrappedValue: true)
    var notificationsEnabled: Bool
    
    @DefaultValue(wrappedValue: "light")
    var theme: String
    
    @DefaultValue(wrappedValue: 100)
    var maxItems: Int
}

๐ŸŒŠ ๋ฐฐ์—ด๊ณผ ๋”•์…”๋„ˆ๋ฆฌ ์ฒ˜๋ฆฌ

๋ฐฐ์—ด๊ณผ ๋”•์…”๋„ˆ๋ฆฌ๋Š” JSON์—์„œ ์•„์ฃผ ํ”ํ•˜๊ฒŒ ์‚ฌ์šฉ๋˜๋Š” ๊ตฌ์กฐ์•ผ. Codable์€ ์ด๊ฒƒ๋“ค๋„ ์™„๋ฒฝํ•˜๊ฒŒ ์ง€์›ํ•ด! ๐ŸŽ‰

๐Ÿ“ฆ ๋ฐฐ์—ด ์ฒ˜๋ฆฌ

// JSON ๋ฐฐ์—ด
{
  "products": [
    {
      "id": 1,
      "name": "๋งฅ๋ถ ํ”„๋กœ",
      "price": 2500000
    },
    {
      "id": 2,
      "name": "์•„์ดํŒจ๋“œ",
      "price": 1200000
    }
  ]
}

// Swift ๊ตฌ์กฐ์ฒด
struct ProductList: Codable {
    let products: [Product]
}

struct Product: Codable {
    let id: Int
    let name: String
    let price: Int
}


๋งŒ์•ฝ ์ตœ์ƒ์œ„๊ฐ€ ๋ฐฐ์—ด์ด๋ผ๋ฉด:

// JSON (๋ฐฐ์—ด์ด ์ตœ์ƒ์œ„)
[
  {
    "id": 1,
    "name": "๋งฅ๋ถ ํ”„๋กœ"
  },
  {
    "id": 2,
    "name": "์•„์ดํŒจ๋“œ"
  }
]

// ๋””์ฝ”๋”ฉ
let products = try decoder.decode([Product].self, from: jsonData)

๐Ÿ—บ๏ธ ๋”•์…”๋„ˆ๋ฆฌ ์ฒ˜๋ฆฌ

// JSON ๋”•์…”๋„ˆ๋ฆฌ
{
  "prices": {
    "USD": 1000,
    "KRW": 1300000,
    "JPY": 150000
  }
}

// Swift ๊ตฌ์กฐ์ฒด
struct PriceInfo: Codable {
    let prices: [String: Int]
}

// ์‚ฌ์šฉ
let priceInfo = try decoder.decode(PriceInfo.self, from: jsonData)
print("์›ํ™” ๊ฐ€๊ฒฉ: \(priceInfo.prices["KRW"] ?? 0)์›")


๋” ๋ณต์žกํ•œ ๋”•์…”๋„ˆ๋ฆฌ ๊ตฌ์กฐ:

// JSON
{
  "users": {
    "user123": {
      "name": "๊น€๊ฐœ๋ฐœ",
      "level": 5
    },
    "user456": {
      "name": "์ด์ฝ”๋”ฉ",
      "level": 3
    }
  }
}

// Swift
struct UserDatabase: Codable {
    let users: [String: UserInfo]
}

struct UserInfo: Codable {
    let name: String
    let level: Int
}

// ์‚ฌ์šฉ
let database = try decoder.decode(UserDatabase.self, from: jsonData)
if let user = database.users["user123"] {
    print("\(user.name)๋‹˜์˜ ๋ ˆ๋ฒจ: \(user.level)")
}
๐ŸŽฏ ๋ฐฐ์—ด/๋”•์…”๋„ˆ๋ฆฌ ์ฒ˜๋ฆฌ ํŒ โ€ข ๋นˆ ๋ฐฐ์—ด์ด ์˜ฌ ์ˆ˜ ์žˆ๋‹ค๋ฉด ์˜ต์…”๋„๋ณด๋‹ค๋Š” ๊ธฐ๋ณธ๊ฐ’ [] ์‚ฌ์šฉ
โ€ข ๋”•์…”๋„ˆ๋ฆฌ ํ‚ค๋Š” String์ด๋‚˜ Int ๊ฐ™์€ Hashable ํƒ€์ž…๋งŒ ๊ฐ€๋Šฅ
โ€ข ์ค‘์ฒฉ๋œ ๋ฐฐ์—ด([[String]])๋„ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌ๋ผ
โ€ข ์„ฑ๋Šฅ์ด ์ค‘์š”ํ•˜๋‹ค๋ฉด ํฐ ๋ฐฐ์—ด์€ lazy ์ฒ˜๋ฆฌ ๊ณ ๋ ค
๋ฐฐ์—ด vs ๋”•์…”๋„ˆ๋ฆฌ Array [Product] Index 0 Product(id: 1, name: "๋งฅ๋ถ") Index 1 Product(id: 2, name: "์•„์ดํŒจ๋“œ") Index 2 Product(id: 3, name: "์—์–ดํŒŸ") [String: UserInfo] Key: "user123" UserInfo(name: "๊น€๊ฐœ๋ฐœ", level: 5) Key: "user456" UserInfo(name: "์ด์ฝ”๋”ฉ", level: 3) Key: "user789" UserInfo(name: "๋ฐ•๊ฐœ๋ฐœ", level: 7) ์ˆœ์„œ ๋ณด์žฅ, ์ธ๋ฑ์Šค ์ ‘๊ทผ ํ‚ค๋กœ ๋น ๋ฅธ ๊ฒ€์ƒ‰, ์ˆœ์„œ ์—†์Œ

โš ๏ธ ์—๋Ÿฌ ์ฒ˜๋ฆฌ์™€ ๋””๋ฒ„๊น…

JSON ํŒŒ์‹ฑ์€ ์‹คํŒจํ•  ์ˆ˜ ์žˆ๋Š” ์ž‘์—…์ด์•ผ. ๋„คํŠธ์›Œํฌ ๋ฌธ์ œ, ์ž˜๋ชป๋œ ๋ฐ์ดํ„ฐ ํ˜•์‹, ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ๊ตฌ์กฐ ๋“ฑ... ์—๋Ÿฌ ์ฒ˜๋ฆฌ๊ฐ€ ์ •๋ง ์ค‘์š”ํ•˜์ง€! ๐Ÿšจ

๐Ÿ” ์ผ๋ฐ˜์ ์ธ ๋””์ฝ”๋”ฉ ์—๋Ÿฌ๋“ค

do {
    let user = try decoder.decode(User.self, from: jsonData)
    print("์„ฑ๊ณต: \(user)")
} catch DecodingError.keyNotFound(let key, let context) {
    print("ํ‚ค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ: \(key.stringValue)")
    print("๊ฒฝ๋กœ: \(context.codingPath)")
    print("์„ค๋ช…: \(context.debugDescription)")
    
} catch DecodingError.typeMismatch(let type, let context) {
    print("ํƒ€์ž… ๋ถˆ์ผ์น˜: ์˜ˆ์ƒ ํƒ€์ž… \(type)")
    print("๊ฒฝ๋กœ: \(context.codingPath)")
    print("์„ค๋ช…: \(context.debugDescription)")
    
} catch DecodingError.valueNotFound(let type, let context) {
    print("๊ฐ’์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ: \(type)")
    print("๊ฒฝ๋กœ: \(context.codingPath)")
    
} catch DecodingError.dataCorrupted(let context) {
    print("๋ฐ์ดํ„ฐ ์†์ƒ")
    print("๊ฒฝ๋กœ: \(context.codingPath)")
    print("์„ค๋ช…: \(context.debugDescription)")
    
} catch {
    print("์•Œ ์ˆ˜ ์—†๋Š” ์—๋Ÿฌ: \(error)")
}


๋” ์‚ฌ์šฉ์ž ์นœํ™”์ ์ธ ์—๋Ÿฌ ์ฒ˜๋ฆฌ:

enum APIError: Error, LocalizedError {
    case decodingFailed(String)
    case networkError(String)
    case invalidResponse
    
    var errorDescription: String? {
        switch self {
        case .decodingFailed(let message):
            return "๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ์‹คํŒจ: \(message)"
        case .networkError(let message):
            return "๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜: \(message)"
        case .invalidResponse:
            return "์ž˜๋ชป๋œ ์„œ๋ฒ„ ์‘๋‹ต"
        }
    }
}

func fetchUser(id: Int) async throws -> User {
    guard let url = URL(string: "https://api.example.com/users/\(id)") else {
        throw APIError.invalidResponse
    }
    
    do {
        let (data, response) = try await URLSession.shared.data(from: url)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw APIError.networkError("์ƒํƒœ ์ฝ”๋“œ ์˜ค๋ฅ˜")
        }
        
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        
        return try decoder.decode(User.self, from: data)
        
    } catch let error as DecodingError {
        // ๋””์ฝ”๋”ฉ ์—๋Ÿฌ๋ฅผ ๋” ์ฝ๊ธฐ ์‰ฝ๊ฒŒ ๋ณ€ํ™˜
        let message = parseDecodingError(error)
        throw APIError.decodingFailed(message)
    } catch {
        throw APIError.networkError(error.localizedDescription)
    }
}

func parseDecodingError(_ error: DecodingError) -> String {
    switch error {
    case .keyNotFound(let key, _):
        return "ํ•„์ˆ˜ ํ•„๋“œ '\(key.stringValue)'๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค"
    case .typeMismatch(let type, let context):
        let path = context.codingPath.map { $0.stringValue }.joined(separator: ".")
        return "'\(path)' ํ•„๋“œ์˜ ํƒ€์ž…์ด \(type)๊ณผ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค"
    case .valueNotFound(let type, let context):
        let path = context.codingPath.map { $0.stringValue }.joined(separator: ".")
        return "'\(path)' ํ•„๋“œ์— \(type) ๊ฐ’์ด ์—†์Šต๋‹ˆ๋‹ค"
    case .dataCorrupted(let context):
        return "๋ฐ์ดํ„ฐ ํ˜•์‹ ์˜ค๋ฅ˜: \(context.debugDescription)"
    @unknown default:
        return "์•Œ ์ˆ˜ ์—†๋Š” ๋””์ฝ”๋”ฉ ์˜ค๋ฅ˜"
    }
}
โš ๏ธ ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ ์ฃผ์˜์‚ฌํ•ญ โ€ข ์ ˆ๋Œ€ ์‚ฌ์šฉ์ž์—๊ฒŒ raw ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด์—ฌ์ฃผ์ง€ ๋งˆ! ๋ณด์•ˆ ์œ„ํ—˜์ด ์žˆ์–ด
โ€ข ์—๋Ÿฌ ๋กœ๊น…์€ ์„œ๋ฒ„์— ์ „์†กํ•˜๊ฑฐ๋‚˜ Crashlytics ๊ฐ™์€ ๋„๊ตฌ ์‚ฌ์šฉ
โ€ข ๋ฏผ๊ฐํ•œ ์ •๋ณด(ํ† ํฐ, ๋น„๋ฐ€๋ฒˆํ˜ธ ๋“ฑ)๊ฐ€ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€์— ํฌํ•จ๋˜์ง€ ์•Š๋„๋ก ์ฃผ์˜
โ€ข ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ์ƒ์„ธํ•œ ์—๋Ÿฌ, ํ”„๋กœ๋•์…˜์—์„œ๋Š” ์ผ๋ฐ˜์ ์ธ ๋ฉ”์‹œ์ง€

๐Ÿ› ๋””๋ฒ„๊น… ํŒ

// 1. JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋จผ์ € ์ถœ๋ ฅํ•ด์„œ ํ™•์ธ
if let jsonString = String(data: jsonData, encoding: .utf8) {
    print("๋ฐ›์€ JSON:")
    print(jsonString)
}

// 2. Pretty print๋กœ ๋ณด๊ธฐ ์ข‹๊ฒŒ
func prettyPrintJSON(_ data: Data) {
    guard let object = try? JSONSerialization.jsonObject(with: data),
          let prettyData = try? JSONSerialization.data(
            withJSONObject: object,
            options: .prettyPrinted
          ),
          let prettyString = String(data: prettyData, encoding: .utf8) else {
        print("JSON ํŒŒ์‹ฑ ์‹คํŒจ")
        return
    }
    print(prettyString)
}

// 3. ๋ถ€๋ถ„์ ์œผ๋กœ ๋””์ฝ”๋”ฉํ•ด์„œ ๋ฌธ์ œ ์ฐพ๊ธฐ
struct PartialUser: Codable {
    let id: Int?
    let name: String?
    let email: String?
    // ๋ชจ๋“  ํ•„๋“œ๋ฅผ ์˜ต์…”๋„๋กœ ๋งŒ๋“ค์–ด์„œ ์–ด๋””์„œ ์‹คํŒจํ•˜๋Š”์ง€ ํ™•์ธ
}

// 4. ์ปค์Šคํ…€ ๋””๋ฒ„๊ทธ ์ •๋ณด ์ถ”๊ฐ€
extension DecodingError {
    var detailedDescription: String {
        switch self {
        case .keyNotFound(let key, let context):
            return """
            ํ‚ค ์—†์Œ: \(key.stringValue)
            ๊ฒฝ๋กœ: \(context.codingPath.map { $0.stringValue }.joined(separator: " โ†’ "))
            ์„ค๋ช…: \(context.debugDescription)
            """
        case .typeMismatch(_, let context):
            return """
            ํƒ€์ž… ๋ถˆ์ผ์น˜
            ๊ฒฝ๋กœ: \(context.codingPath.map { $0.stringValue }.joined(separator: " โ†’ "))
            ์„ค๋ช…: \(context.debugDescription)
            """
        case .valueNotFound(_, let context):
            return """
            ๊ฐ’ ์—†์Œ
            ๊ฒฝ๋กœ: \(context.codingPath.map { $0.stringValue }.joined(separator: " โ†’ "))
            ์„ค๋ช…: \(context.debugDescription)
            """
        case .dataCorrupted(let context):
            return """
            ๋ฐ์ดํ„ฐ ์†์ƒ
            ๊ฒฝ๋กœ: \(context.codingPath.map { $0.stringValue }.joined(separator: " โ†’ "))
            ์„ค๋ช…: \(context.debugDescription)
            """
        @unknown default:
            return "์•Œ ์ˆ˜ ์—†๋Š” ์—๋Ÿฌ"
        }
    }
}

๐Ÿš€ ์‹ค์ „ ์˜ˆ์ œ: ์™„์ „ํ•œ API ํ†ต์‹ 

์ด์ œ ๋ฐฐ์šด ๊ฑธ ๋ชจ๋‘ ํ™œ์šฉํ•ด์„œ ์‹ค์ œ API ํ†ต์‹  ์ฝ”๋“œ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž! GitHub API๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์˜ˆ์ œ์•ผ. ๐Ÿ˜Ž

import Foundation

// MARK: - ๋ชจ๋ธ ์ •์˜
struct GitHubUser: Codable {
    let login: String
    let id: Int
    let avatarUrl: String
    let name: String?
    let bio: String?
    let publicRepos: Int
    let followers: Int
    let following: Int
    let createdAt: Date
    let updatedAt: Date
    
    enum CodingKeys: String, CodingKey {
        case login, id, name, bio, followers, following
        case avatarUrl = "avatar_url"
        case publicRepos = "public_repos"
        case createdAt = "created_at"
        case updatedAt = "updated_at"
    }
}

struct Repository: Codable {
    let id: Int
    let name: String
    let fullName: String
    let description: String?
    let stargazersCount: Int
    let forksCount: Int
    let language: String?
    let createdAt: Date
    let updatedAt: Date
    
    enum CodingKeys: String, CodingKey {
        case id, name, description, language
        case fullName = "full_name"
        case stargazersCount = "stargazers_count"
        case forksCount = "forks_count"
        case createdAt = "created_at"
        case updatedAt = "updated_at"
    }
}

// MARK: - API ์„œ๋น„์Šค
class GitHubService {
    private let baseURL = "https://api.github.com"
    private let decoder: JSONDecoder
    
    init() {
        decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
    }
    
    func fetchUser(username: String) async throws -> GitHubUser {
        let urlString = "\(baseURL)/users/\(username)"
        guard let url = URL(string: urlString) else {
            throw URLError(.badURL)
        }
        
        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)
        }
        
        do {
            return try decoder.decode(GitHubUser.self, from: data)
        } catch {
            print("๋””์ฝ”๋”ฉ ์—๋Ÿฌ: \(error)")
            if let decodingError = error as? DecodingError {
                print(decodingError.detailedDescription)
            }
            throw error
        }
    }
    
    func fetchRepositories(username: String) async throws -> [Repository] {
        let urlString = "\(baseURL)/users/\(username)/repos"
        guard let url = URL(string: urlString) else {
            throw URLError(.badURL)
        }
        
        let (data, response) = try await URLSession.shared.data(from: url)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw URLError(.badServerResponse)
        }
        
        return try decoder.decode([Repository].self, from: data)
    }
}

// MARK: - ์‚ฌ์šฉ ์˜ˆ์ œ
Task {
    let service = GitHubService()
    
    do {
        // ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
        let user = try await service.fetchUser(username: "apple")
        print("์‚ฌ์šฉ์ž: \(user.login)")
        print("์ด๋ฆ„: \(user.name ?? "์—†์Œ")")
        print("ํŒ”๋กœ์›Œ: \(user.followers)๋ช…")
        print("๊ณต๊ฐœ ์ €์žฅ์†Œ: \(user.publicRepos)๊ฐœ")
        
        // ์ €์žฅ์†Œ ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
        let repos = try await service.fetchRepositories(username: "apple")
        print("\n์ €์žฅ์†Œ ๋ชฉ๋ก:")
        for repo in repos.prefix(5) {
            print("- \(repo.name)")
            print("  โญ๏ธ \(repo.stargazersCount) stars")
            if let language = repo.language {
                print("  ๐Ÿ’ป \(language)")
            }
        }
        
    } catch {
        print("์—๋Ÿฌ ๋ฐœ์ƒ: \(error.localizedDescription)")
    }
}


SwiftUI์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋Š” ์˜ˆ์ œ:

import SwiftUI

class GitHubViewModel: ObservableObject {
    @Published var user: GitHubUser?
    @Published var repositories: [Repository] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let service = GitHubService()
    
    @MainActor
    func loadUser(username: String) async {
        isLoading = true
        errorMessage = nil
        
        do {
            user = try await service.fetchUser(username: username)
            repositories = try await service.fetchRepositories(username: username)
        } catch {
            errorMessage = "๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: \(error.localizedDescription)"
        }
        
        isLoading = false
    }
}

struct GitHubProfileView: View {
    @StateObject private var viewModel = GitHubViewModel()
    @State private var username = "apple"
    
    var body: some View {
        NavigationView {
            VStack {
                if viewModel.isLoading {
                    ProgressView("๋กœ๋”ฉ ์ค‘...")
                } else if let user = viewModel.user {
                    ScrollView {
                        VStack(alignment: .leading, spacing: 20) {
                            // ์‚ฌ์šฉ์ž ์ •๋ณด
                            HStack {
                                AsyncImage(url: URL(string: user.avatarUrl)) { image in
                                    image.resizable()
                                } placeholder: {
                                    Color.gray
                                }
                                .frame(width: 80, height: 80)
                                .clipShape(Circle())
                                
                                VStack(alignment: .leading) {
                                    Text(user.name ?? user.login)
                                        .font(.title)
                                    Text("@\(user.login)")
                                        .foregroundColor(.secondary)
                                }
                            }
                            
                            if let bio = user.bio {
                                Text(bio)
                                    .font(.body)
                            }
                            
                            HStack(spacing: 20) {
                                StatView(title: "ํŒ”๋กœ์›Œ", value: "\(user.followers)")
                                StatView(title: "ํŒ”๋กœ์ž‰", value: "\(user.following)")
                                StatView(title: "์ €์žฅ์†Œ", value: "\(user.publicRepos)")
                            }
                            
                            // ์ €์žฅ์†Œ ๋ชฉ๋ก
                            Text("์ €์žฅ์†Œ")
                                .font(.headline)
                            
                            ForEach(viewModel.repositories.prefix(10), id: \.id) { repo in
                                RepositoryRow(repository: repo)
                            }
                        }
                        .padding()
                    }
                } else if let error = viewModel.errorMessage {
                    Text(error)
                        .foregroundColor(.red)
                }
            }
            .navigationTitle("GitHub Profile")
            .task {
                await viewModel.loadUser(username: username)
            }
        }
    }
}

struct StatView: View {
    let title: String
    let value: String
    
    var body: some View {
        VStack {
            Text(value)
                .font(.title2)
                .fontWeight(.bold)
            Text(title)
                .font(.caption)
                .foregroundColor(.secondary)
        }
    }
}

struct RepositoryRow: View {
    let repository: Repository
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(repository.name)
                .font(.headline)
            
            if let description = repository.description {
                Text(description)
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    .lineLimit(2)
            }
            
            HStack {
                if let language = repository.language {
                    Label(language, systemImage: "chevron.left.forwardslash.chevron.right")
                        .font(.caption)
                }
                
                Label("\(repository.stargazersCount)", systemImage: "star.fill")
                    .font(.caption)
                
                Label("\(repository.forksCount)", systemImage: "tuningfork")
                    .font(.caption)
            }
            .foregroundColor(.secondary)
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(10)
    }
}

๐Ÿ’Ž ๊ณ ๊ธ‰ ํ…Œํฌ๋‹‰๊ณผ ์ตœ์ ํ™”

์ด์ œ ์ข€ ๋” ๊ณ ๊ธ‰ ๊ธฐ์ˆ ๋“ค์„ ์•Œ์•„๋ณด์ž! ์‹ค๋ฌด์—์„œ ์œ ์šฉํ•œ ํŒ๋“ค์ด์•ผ. ๐ŸŽ“

1๏ธโƒฃ ์ œ๋„ค๋ฆญ์„ ํ™œ์šฉํ•œ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ API ํด๋ผ์ด์–ธํŠธ

protocol APIRequest {
    associatedtype Response: Codable
    
    var path: String { get }
    var method: String { get }
    var queryItems: [URLQueryItem]? { get }
    var body: Data? { get }
}

extension APIRequest {
    var method: String { "GET" }
    var queryItems: [URLQueryItem]? { nil }
    var body: Data? { nil }
}

class APIClient {
    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder
    
    init(baseURL: URL, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session
        
        self.decoder = JSONDecoder()
        self.decoder.keyDecodingStrategy = .convertFromSnakeCase
        self.decoder.dateDecodingStrategy = .iso8601
    }
    
    func send<T: APIRequest>(_ request: T) async throws -> T.Response {
        var components = URLComponents(
            url: baseURL.appendingPathComponent(request.path),
            resolvingAgainstBaseURL: true
        )
        components?.queryItems = request.queryItems
        
        guard let url = components?.url else {
            throw URLError(.badURL)
        }
        
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.method
        urlRequest.httpBody = request.body
        urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        let (data, response) = try await session.data(for: urlRequest)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw URLError(.badServerResponse)
        }
        
        return try decoder.decode(T.Response.self, from: data)
    }
}

// ์‚ฌ์šฉ ์˜ˆ์ œ
struct GetUserRequest: APIRequest {
    typealias Response = GitHubUser
    
    let username: String
    
    var path: String {
        "/users/\(username)"
    }
}

struct SearchRepositoriesRequest: APIRequest {
    typealias Response = SearchResult
    
    let query: String
    let page: Int
    
    var path: String { "/search/repositories" }
    
    var queryItems: [URLQueryItem]? {
        [
            URLQueryItem(name: "q", value: query),
            URLQueryItem(name: "page", value: "\(page)")
        ]
    }
}

struct SearchResult: Codable {
    let totalCount: Int
    let items: [Repository]
}

// ์‹ค์ œ ์‚ฌ์šฉ
let client = APIClient(baseURL: URL(string: "https://api.github.com")!)

Task {
    let user = try await client.send(GetUserRequest(username: "apple"))
    print(user.name ?? "")
    
    let searchResult = try await client.send(
        SearchRepositoriesRequest(query: "swift", page: 1)
    )
    print("๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ: \(searchResult.totalCount)๊ฐœ")
}

2๏ธโƒฃ ์บ์‹ฑ ์ „๋žต

actor DataCache<Key: Hashable, Value> {
    private var cache: [Key: CacheEntry<Value>] = [:]
    private let expirationInterval: TimeInterval
    
    struct CacheEntry<T> {
        let value: T
        let expirationDate: Date
    }
    
    init(expirationInterval: TimeInterval = 300) { // 5๋ถ„ ๊ธฐ๋ณธ๊ฐ’
        self.expirationInterval = expirationInterval
    }
    
    func get(_ key: Key) -> Value? {
        guard let entry = cache[key] else { return nil }
        
        if Date() > entry.expirationDate {
            cache.removeValue(forKey: key)
            return nil
        }
        
        return entry.value
    }
    
    func set(_ value: Value, forKey key: Key) {
        let expirationDate = Date().addingTimeInterval(expirationInterval)
        cache[key] = CacheEntry(value: value, expirationDate: expirationDate)
    }
    
    func clear() {
        cache.removeAll()
    }
}

class CachedAPIClient {
    private let client: APIClient
    private let cache = DataCache<String, Any>()
    
    init(client: APIClient) {
        self.client = client
    }
    
    func send<T: APIRequest>(_ request: T, useCache: Bool = true) async throws -> T.Response {
        let cacheKey = "\(request.path)_\(request.method)"
        
        if useCache, let cached = await cache.get(cacheKey) as? T.Response {
            print("์บ์‹œ์—์„œ ๋ฐ˜ํ™˜")
            return cached
        }
        
        let response = try await client.send(request)
        await cache.set(response, forKey: cacheKey)
        
        return response
    }
}

3๏ธโƒฃ ๋Œ€์šฉ๋Ÿ‰ JSON ์ŠคํŠธ๋ฆฌ๋ฐ

// ํฐ JSON ํŒŒ์ผ์„ ํ•œ ๋ฒˆ์— ๋ฉ”๋ชจ๋ฆฌ์— ์˜ฌ๋ฆฌ์ง€ ์•Š๊ณ  ์ŠคํŠธ๋ฆฌ๋ฐ์œผ๋กœ ์ฒ˜๋ฆฌ
class JSONStreamDecoder<T: Codable> {
    private let decoder = JSONDecoder()
    
    func decode(from url: URL, batchSize: Int = 100, 
                handler: @escaping ([T]) -> Void) throws {
        guard let inputStream = InputStream(url: url) else {
            throw NSError(domain: "StreamError", code: -1)
        }
        
        inputStream.open()
        defer { inputStream.close() }
        
        var buffer = [UInt8](repeating: 0, count: 4096)
        var jsonString = ""
        var batch: [T] = []
        
        while inputStream.hasBytesAvailable {
            let bytesRead = inputStream.read(&buffer, maxLength: buffer.count)
            if bytesRead > 0 {
                if let chunk = String(bytes: buffer[..<bytesRead], encoding: .utf8) {
                    jsonString += chunk
                    
                    // JSON ๊ฐ์ฒด ํŒŒ์‹ฑ ์‹œ๋„
                    if let data = jsonString.data(using: .utf8),
                       let item = try? decoder.decode(T.self, from: data) {
                        batch.append(item)
                        jsonString = ""
                        
                        if batch.count >= batchSize {
                            handler(batch)
                            batch.removeAll()
                        }
                    }
                }
            }
        }
        
        if !batch.isEmpty {
            handler(batch)
        }
    }
}
๐Ÿ’ก ์„ฑ๋Šฅ ์ตœ์ ํ™” ํŒ โ€ข JSONDecoder๋Š” ์žฌ์‚ฌ์šฉํ•˜์„ธ์š” - ๋งค๋ฒˆ ์ƒˆ๋กœ ๋งŒ๋“ค์ง€ ๋งˆ์„ธ์š”
โ€ข ํฐ ๋ฐฐ์—ด์€ lazy ์ฒ˜๋ฆฌ๋ฅผ ๊ณ ๋ คํ•˜์„ธ์š”
โ€ข ๋ถˆํ•„์š”ํ•œ ํ•„๋“œ๋Š” ๋””์ฝ”๋”ฉํ•˜์ง€ ๋งˆ์„ธ์š” (CodingKeys๋กœ ์ œ์™ธ)
โ€ข ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค๋ ˆ๋“œ์—์„œ ๋””์ฝ”๋”ฉํ•˜์„ธ์š”
โ€ข ์บ์‹ฑ์„ ์ ๊ทน ํ™œ์šฉํ•˜์„ธ์š”
โ€ข ๋„คํŠธ์›Œํฌ ์š”์ฒญ์€ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌํ•˜์„ธ์š”

๐ŸŽจ ์‹ค๋ฌด ํŒจํ„ด๊ณผ ์•„ํ‚คํ…์ฒ˜

์‹ค์ œ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ๊น”๋”ํ•œ ์•„ํ‚คํ…์ฒ˜๊ฐ€ ์ค‘์š”ํ•ด. ์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ ํ”„๋กœ๊ทธ๋ž˜๋ฐ ์žฌ๋Šฅ์„ ๊ณต์œ ํ•  ๋•Œ๋„ ์ด๋Ÿฐ ๊ตฌ์กฐํ™”๋œ ์ฝ”๋“œ๊ฐ€ ํฐ ๋„์›€์ด ๋˜์ง€! ๐Ÿ—๏ธ

๐Ÿ“ฆ Repository ํŒจํ„ด

// ๋ฐ์ดํ„ฐ ์†Œ์Šค ํ”„๋กœํ† ์ฝœ
protocol UserDataSource {
    func fetchUser(id: Int) async throws -> User
    func updateUser(_ user: User) async throws
}

// ์›๊ฒฉ ๋ฐ์ดํ„ฐ ์†Œ์Šค (API)
class RemoteUserDataSource: UserDataSource {
    private let apiClient: APIClient
    
    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }
    
    func fetchUser(id: Int) async throws -> User {
        struct GetUserRequest: APIRequest {
            typealias Response = User
            let id: Int
            var path: String { "/users/\(id)" }
        }
        
        return try await apiClient.send(GetUserRequest(id: id))
    }
    
    func updateUser(_ user: User) async throws {
        // PUT ์š”์ฒญ ๊ตฌํ˜„
    }
}

// ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์†Œ์Šค (์บ์‹œ/DB)
class LocalUserDataSource: UserDataSource {
    private let cache: DataCache<Int, User>
    
    init() {
        self.cache = DataCache()
    }
    
    func fetchUser(id: Int) async throws -> User {
        guard let user = await cache.get(id) else {
            throw NSError(domain: "CacheError", code: 404)
        }
        return user
    }
    
    func updateUser(_ user: User) async throws {
        await cache.set(user, forKey: user.id)
    }
}

// Repository (๋ฐ์ดํ„ฐ ์†Œ์Šค ์กฐํ•ฉ)
class UserRepository {
    private let remoteDataSource: RemoteUserDataSource
    private let localDataSource: LocalUserDataSource
    
    init(remoteDataSource: RemoteUserDataSource,
         localDataSource: LocalUserDataSource) {
        self.remoteDataSource = remoteDataSource
        self.localDataSource = localDataSource
    }
    
    func getUser(id: Int, forceRefresh: Bool = false) async throws -> User {
        // ์บ์‹œ ์šฐ์„  ์ „๋žต
        if !forceRefresh {
            if let cachedUser = try? await localDataSource.fetchUser(id: id) {
                return cachedUser
            }
        }
        
        // ์›๊ฒฉ์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ
        let user = try await remoteDataSource.fetchUser(id: id)
        
        // ๋กœ์ปฌ์— ์ €์žฅ
        try? await localDataSource.updateUser(user)
        
        return user
    }
    
    func updateUser(_ user: User) async throws {
        // ์›๊ฒฉ ์—…๋ฐ์ดํŠธ
        try await remoteDataSource.updateUser(user)
        
        // ๋กœ์ปฌ ์บ์‹œ ์—…๋ฐ์ดํŠธ
        try await localDataSource.updateUser(user)
    }
}

๐ŸŽฏ Result ํƒ€์ž… ํ™œ์šฉ

extension APIClient {
    func sendSafe<T: APIRequest>(_ request: T) async -> Result<T.Response, Error> {
        do {
            let response = try await send(request)
            return .success(response)
        } catch {
            return .failure(error)
        }
    }
}

// ์‚ฌ์šฉ ์˜ˆ์ œ
class UserViewModel: ObservableObject {
    @Published var user: User?
    @Published var errorMessage: String?
    @Published var isLoading = false
    
    private let repository: UserRepository
    
    init(repository: UserRepository) {
        self.repository = repository
    }
    
    @MainActor
    func loadUser(id: Int) async {
        isLoading = true
        errorMessage = nil
        
        do {
            user = try await repository.getUser(id: id)
        } catch {
            errorMessage = handleError(error)
        }
        
        isLoading = false
    }
    
    private func handleError(_ error: Error) -> String {
        if let urlError = error as? URLError {
            switch urlError.code {
            case .notConnectedToInternet:
                return "์ธํ„ฐ๋„ท ์—ฐ๊ฒฐ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”"
            case .timedOut:
                return "์š”์ฒญ ์‹œ๊ฐ„์ด ์ดˆ๊ณผ๋˜์—ˆ์Šต๋‹ˆ๋‹ค"
            default:
                return "๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค"
            }
        } else if let decodingError = error as? DecodingError {
            return "๋ฐ์ดํ„ฐ ํ˜•์‹ ์˜ค๋ฅ˜"
        } else {
            return "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค"
        }
    }
}

๐Ÿ”„ Combine๊ณผ ํ†ตํ•ฉ

import Combine

extension APIClient {
    func sendPublisher<T: APIRequest>(_ request: T) -> AnyPublisher<T.Response, Error> {
        Future { promise in
            Task {
                do {
                    let response = try await self.send(request)
                    promise(.success(response))
                } catch {
                    promise(.failure(error))
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

class CombineUserViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    
    private let apiClient: APIClient
    private var cancellables = Set<AnyCancellable>()
    
    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }
    
    func loadUser(id: Int) {
        isLoading = true
        
        apiClient.sendPublisher(GetUserRequest(id: id))
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    print("์—๋Ÿฌ: \(error)")
                }
            } receiveValue: { [weak self] user in
                self?.user = user
            }
            .store(in: &cancellables)
    }
}

๐Ÿงช ํ…Œ์ŠคํŠธํ•˜๊ธฐ

JSON ํŒŒ์‹ฑ ์ฝ”๋“œ๋„ ํ…Œ์ŠคํŠธ๊ฐ€ ํ•„์š”ํ•ด! ํŠนํžˆ ๋‹ค์–‘ํ•œ ์—ฃ์ง€ ์ผ€์ด์Šค๋ฅผ ๋‹ค๋ค„์•ผ ํ•˜๋‹ˆ๊นŒ. ๐Ÿ”ฌ

import XCTest

class UserDecodingTests: XCTestCase {
    var decoder: JSONDecoder!
    
    override func setUp() {
        super.setUp()
        decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
    }
    
    func testValidUserDecoding() throws {
        let json = """
        {
            "id": 1,
            "username": "testuser",
            "email": "test@example.com",
            "created_at": "2024-01-01T00:00:00Z"
        }
        """
        
        let data = json.data(using: .utf8)!
        let user = try decoder.decode(User.self, from: data)
        
        XCTAssertEqual(user.id, 1)
        XCTAssertEqual(user.username, "testuser")
        XCTAssertEqual(user.email, "test@example.com")
    }
    
    func testMissingOptionalField() throws {
        let json = """
        {
            "id": 1,
            "username": "testuser",
            "email": "test@example.com"
        }
        """
        
        let data = json.data(using: .utf8)!
        let user = try decoder.decode(User.self, from: data)
        
        XCTAssertNil(user.bio)
        XCTAssertNil(user.profileImage)
    }
    
    func testInvalidDateFormat() {
        let json = """
        {
            "id": 1,
            "username": "testuser",
            "email": "test@example.com",
            "created_at": "invalid-date"
        }
        """
        
        let data = json.data(using: .utf8)!
        
        XCTAssertThrowsError(try decoder.decode(User.self, from: data)) { error in
            XCTAssertTrue(error is DecodingError)
        }
    }
    
    func testTypeMismatch() {
        let json = """
        {
            "id": "not-a-number",
            "username": "testuser",
            "email": "test@example.com"
        }
        """
        
        let data = json.data(using: .utf8)!
        
        XCTAssertThrowsError(try decoder.decode(User.self, from: data)) { error in
            guard case DecodingError.typeMismatch = error else {
                XCTFail("์˜ˆ์ƒํ•œ ์—๋Ÿฌ ํƒ€์ž…์ด ์•„๋‹™๋‹ˆ๋‹ค")
                return
            }
        }
    }
    
    func testArrayDecoding() throws {
        let json = """
        [
            {"id": 1, "username": "user1", "email": "user1@example.com"},
            {"id": 2, "username": "user2", "email": "user2@example.com"}
        ]
        """
        
        let data = json.data(using: .utf8)!
        let users = try decoder.decode([User].self, from: data)
        
        XCTAssertEqual(users.count, 2)
        XCTAssertEqual(users[0].username, "user1")
        XCTAssertEqual(users[1].username, "user2")
    }
    
    func testNestedStructureDecoding() throws {
        let json = """
        {
            "user": {
                "id": 1,
                "username": "testuser"
            },
            "posts": [
                {"id": 1, "title": "์ฒซ ๋ฒˆ์งธ ๊ธ€"},
                {"id": 2, "title": "๋‘ ๋ฒˆ์งธ ๊ธ€"}
            ]
        }
        """
        
        struct UserWithPosts: Codable {
            let user: User
            let posts: [Post]
        }
        
        struct Post: Codable {
            let id: Int
            let title: String
        }
        
        let data = json.data(using: .utf8)!
        let result = try decoder.decode(UserWithPosts.self, from: data)
        
        XCTAssertEqual(result.user.username, "testuser")
        XCTAssertEqual(result.posts.count, 2)
    }
}

// Mock API Client for testing
class MockAPIClient: APIClient {
    var mockResponse: Any?
    var mockError: Error?
    
    override func send<T: APIRequest>(_ request: T) async throws -> T.Response {
        if let error = mockError {
            throw error
        }
        
        guard let response = mockResponse as? T.Response else {
            throw URLError(.badServerResponse)
        }
        
        return response
    }
}

class UserRepositoryTests: XCTestCase {
    var repository: UserRepository!
    var mockClient: MockAPIClient!
    
    override func setUp() {
        super.setUp()
        mockClient = MockAPIClient(baseURL: URL(string: "https://api.example.com")!)
        let remoteDataSource = RemoteUserDataSource(apiClient: mockClient)
        let localDataSource = LocalUserDataSource()
        repository = UserRepository(
            remoteDataSource: remoteDataSource,
            localDataSource: localDataSource
        )
    }
    
    func testGetUserSuccess() async throws {
        let expectedUser = User(
            id: 1,
            username: "testuser",
            email: "test@example.com"
        )
        mockClient.mockResponse = expectedUser
        
        let user = try await repository.getUser(id: 1)
        
        XCTAssertEqual(user.id, expectedUser.id)
        XCTAssertEqual(user.username, expectedUser.username)
    }
    
    func testGetUserNetworkError() async {
        mockClient.mockError = URLError(.notConnectedToInternet)
        
        do {
            _ = try await repository.getUser(id: 1)
            XCTFail("์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค")
        } catch {
            XCTAssertTrue(error is URLError)
        }
    }
}
ํ…Œ์ŠคํŠธ ํ”ผ๋ผ๋ฏธ๋“œ Unit Tests ๋””์ฝ”๋”ฉ, ๋ชจ๋ธ ๊ฒ€์ฆ Integration Tests API ํ†ต์‹ , Repository E2E Tests ์ „์ฒด ํ”Œ๋กœ์šฐ ๋น ๋ฆ„, ๋งŽ์ด ๋А๋ฆผ, ์ ๊ฒŒ

๐ŸŽ“ Best Practices ์ •๋ฆฌ

์ง€๊ธˆ๊นŒ์ง€ ๋ฐฐ์šด ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ์‹ค๋ฌด์—์„œ ์ง€์ผœ์•ผ ํ•  ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค๋ฅผ ์ •๋ฆฌํ•ด๋ณผ๊ฒŒ! โœจ

1. ๋ชจ๋ธ ์„ค๊ณ„ ์›์น™ โœ… ๊ตฌ์กฐ์ฒด๋Š” ์ž‘๊ณ  ์ง‘์ค‘๋œ ์ฑ…์ž„์„ ๊ฐ€์ ธ์•ผ ํ•ด
โœ… ์˜ต์…”๋„์€ ์ •๋ง ํ•„์š”ํ•œ ๊ฒฝ์šฐ์—๋งŒ ์‚ฌ์šฉ
โœ… ๊ธฐ๋ณธ๊ฐ’์ด ์žˆ๋‹ค๋ฉด ๋ช…์‹œ์ ์œผ๋กœ ์„ค์ •
โœ… ์ค‘์ฒฉ ๊ตฌ์กฐ๋Š” 3๋‹จ๊ณ„๋ฅผ ๋„˜์ง€ ์•Š๋„๋ก
โœ… ๋‚ ์งœ๋Š” Date ํƒ€์ž…์œผ๋กœ, ๋ฌธ์ž์—ด ์ €์žฅ ์ง€์–‘

2. ๋„ค์ด๋ฐ ์ปจ๋ฒค์…˜

โœ… Swift ์Šคํƒ€์ผ ๊ฐ€์ด๋“œ ์ค€์ˆ˜ (camelCase)
โœ… ์•ฝ์–ด๋ณด๋‹ค๋Š” ๋ช…ํ™•ํ•œ ์ด๋ฆ„ ์‚ฌ์šฉ
โœ… Bool ํƒ€์ž…์€ is, has, should ์ ‘๋‘์‚ฌ
โœ… ๋ฐฐ์—ด์€ ๋ณต์ˆ˜ํ˜• (users, products)
โœ… CodingKeys๋Š” ์„œ๋ฒ„ API ๋ช…์„ธ์™€ ์ผ์น˜

3. ์—๋Ÿฌ ์ฒ˜๋ฆฌ

โœ… ๋ชจ๋“  ๋””์ฝ”๋”ฉ์€ try-catch๋กœ ๊ฐ์‹ธ๊ธฐ
โœ… ์‚ฌ์šฉ์ž ์นœํ™”์ ์ธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์ œ๊ณต
โœ… ๋กœ๊น…์€ ๊ฐœ๋ฐœ/ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ ๋ถ„๋ฆฌ
โœ… ๋„คํŠธ์›Œํฌ ์—๋Ÿฌ์™€ ํŒŒ์‹ฑ ์—๋Ÿฌ ๊ตฌ๋ถ„
โœ… ์žฌ์‹œ๋„ ๋กœ์ง ๊ตฌํ˜„ (exponential backoff)

4. ์„ฑ๋Šฅ ์ตœ์ ํ™”

โœ… JSONDecoder ์žฌ์‚ฌ์šฉ
โœ… ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค๋ ˆ๋“œ์—์„œ ๋””์ฝ”๋”ฉ
โœ… ํฐ ๋ฐ์ดํ„ฐ๋Š” ํŽ˜์ด์ง€๋„ค์ด์…˜
โœ… ์บ์‹ฑ ์ „๋žต ์ˆ˜๋ฆฝ
โœ… ๋ถˆํ•„์š”ํ•œ ํ•„๋“œ ๋””์ฝ”๋”ฉ ์ œ์™ธ

5. ๋ณด์•ˆ

โœ… ๋ฏผ๊ฐํ•œ ์ •๋ณด๋Š” ๋กœ๊ทธ์— ๋‚จ๊ธฐ์ง€ ์•Š๊ธฐ
โœ… HTTPS ์‚ฌ์šฉ ๊ฐ•์ œ
โœ… ์ธ์ฆ ํ† ํฐ์€ Keychain์— ์ €์žฅ
โœ… Certificate Pinning ๊ณ ๋ ค
โœ… API ํ‚ค๋Š” ์ฝ”๋“œ์— ํ•˜๋“œ์ฝ”๋”ฉ ๊ธˆ์ง€

๐Ÿš€ ์‹ค์ „ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ์˜ˆ์ œ

๋งˆ์ง€๋ง‰์œผ๋กœ ์‹ค์ œ ํ”„๋กœ์ ํŠธ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์™„์ „ํ•œ ๊ตฌ์กฐ๋ฅผ ๋ณด์—ฌ์ค„๊ฒŒ! ๐Ÿ“

MyApp/
โ”œโ”€โ”€ Models/
โ”‚   โ”œโ”€โ”€ User.swift
โ”‚   โ”œโ”€โ”€ Product.swift
โ”‚   โ””โ”€โ”€ Order.swift
โ”‚
โ”œโ”€โ”€ Networking/
โ”‚   โ”œโ”€โ”€ APIClient.swift
โ”‚   โ”œโ”€โ”€ APIRequest.swift
โ”‚   โ”œโ”€โ”€ APIError.swift
โ”‚   โ””โ”€โ”€ Endpoints/
โ”‚       โ”œโ”€โ”€ UserEndpoint.swift
โ”‚       โ””โ”€โ”€ ProductEndpoint.swift
โ”‚
โ”œโ”€โ”€ Repositories/
โ”‚   โ”œโ”€โ”€ UserRepository.swift
โ”‚   โ””โ”€โ”€ ProductRepository.swift
โ”‚
โ”œโ”€โ”€ DataSources/
โ”‚   โ”œโ”€โ”€ Remote/
โ”‚   โ”‚   โ”œโ”€โ”€ RemoteUserDataSource.swift
โ”‚   โ”‚   โ””โ”€โ”€ RemoteProductDataSource.swift
โ”‚   โ””โ”€โ”€ Local/
โ”‚       โ”œโ”€โ”€ LocalUserDataSource.swift
โ”‚       โ””โ”€โ”€ LocalProductDataSource.swift
โ”‚
โ”œโ”€โ”€ ViewModels/
โ”‚   โ”œโ”€โ”€ UserViewModel.swift
โ”‚   โ””โ”€โ”€ ProductViewModel.swift
โ”‚
โ”œโ”€โ”€ Views/
โ”‚   โ”œโ”€โ”€ UserProfileView.swift
โ”‚   โ””โ”€โ”€ ProductListView.swift
โ”‚
โ””โ”€โ”€ Utilities/
    โ”œโ”€โ”€ DataCache.swift
    โ”œโ”€โ”€ NetworkMonitor.swift
    โ””โ”€โ”€ Extensions/
        โ”œโ”€โ”€ Codable+Extensions.swift
        โ””โ”€โ”€ JSONDecoder+Extensions.swift


๊ฐ ํŒŒ์ผ์˜ ์—ญํ• :

// Models/User.swift
struct User: Codable, Identifiable {
    let id: Int
    let username: String
    let email: String
    let profileImageUrl: String?
    let createdAt: Date
    
    enum CodingKeys: String, CodingKey {
        case id, username, email
        case profileImageUrl = "profile_image_url"
        case createdAt = "created_at"
    }
}

// Networking/APIClient.swift
class APIClient {
    static let shared = APIClient()
    
    private let baseURL = URL(string: "https://api.example.com")!
    private let session: URLSession
    private let decoder: JSONDecoder
    
    private init() {
        let configuration = URLSessionConfiguration.default
        configuration.timeoutIntervalForRequest = 30
        configuration.waitsForConnectivity = true
        
        self.session = URLSession(configuration: configuration)
        
        self.decoder = JSONDecoder()
        self.decoder.keyDecodingStrategy = .convertFromSnakeCase
        self.decoder.dateDecodingStrategy = .iso8601
    }
    
    func send<T: APIRequest>(_ request: T) async throws -> T.Response {
        // ๊ตฌํ˜„...
    }
}

// Networking/Endpoints/UserEndpoint.swift
enum UserEndpoint {
    case getUser(id: Int)
    case updateUser(User)
    case searchUsers(query: String)
}

extension UserEndpoint: APIRequest {
    typealias Response = User
    
    var path: String {
        switch self {
        case .getUser(let id):
            return "/users/\(id)"
        case .updateUser:
            return "/users"
        case .searchUsers:
            return "/users/search"
        }
    }
    
    var method: String {
        switch self {
        case .getUser, .searchUsers:
            return "GET"
        case .updateUser:
            return "PUT"
        }
    }
    
    var queryItems: [URLQueryItem]? {
        switch self {
        case .searchUsers(let query):
            return [URLQueryItem(name: "q", value: query)]
        default:
            return nil
        }
    }
}

// Repositories/UserRepository.swift
class UserRepository {
    private let remoteDataSource: RemoteUserDataSource
    private let localDataSource: LocalUserDataSource
    
    init(remoteDataSource: RemoteUserDataSource = .init(),
         localDataSource: LocalUserDataSource = .init()) {
        self.remoteDataSource = remoteDataSource
        self.localDataSource = localDataSource
    }
    
    func getUser(id: Int, forceRefresh: Bool = false) async throws -> User {
        if !forceRefresh, let cached = try? await localDataSource.getUser(id: id) {
            return cached
        }
        
        let user = try await remoteDataSource.getUser(id: id)
        try? await localDataSource.saveUser(user)
        
        return user
    }
}

// ViewModels/UserViewModel.swift
@MainActor
class UserViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let repository: UserRepository
    
    init(repository: UserRepository = UserRepository()) {
        self.repository = repository
    }
    
    func loadUser(id: Int) async {
        isLoading = true
        errorMessage = nil
        
        do {
            user = try await repository.getUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
}

// Utilities/Extensions/JSONDecoder+Extensions.swift
extension JSONDecoder {
    static var `default`: JSONDecoder {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        return decoder
    }
    
    static var flexible: JSONDecoder {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .custom { decoder in
            let container = try decoder.singleValueContainer()
            let dateString = try container.decode(String.self)
            
            // ์—ฌ๋Ÿฌ ๋‚ ์งœ ํ˜•์‹ ์‹œ๋„
            let formatters = [
                ISO8601DateFormatter(),
                // ์ถ”๊ฐ€ ํฌ๋งทํ„ฐ๋“ค...
            ]
            
            for formatter in formatters {
                if let date = formatter.date(from: dateString) {
                    return date
                }
            }
            
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "๋‚ ์งœ ํ˜•์‹์„ ํŒŒ์‹ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"
            )
        }
        return decoder
    }
}

๐Ÿ’ก ๋งˆ๋ฌด๋ฆฌํ•˜๋ฉฐ

์™€! ์—ฌ๊ธฐ๊นŒ์ง€ ์ •๋ง ๊ธด ์—ฌ์ •์ด์—ˆ์–ด! ๐ŸŽ‰

Swift์˜ Codable ํ”„๋กœํ† ์ฝœ์€ ์ •๋ง ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ์•ผ. JSON ํŒŒ์‹ฑ์ด ์ด๋ ‡๊ฒŒ ์‰ฌ์›Œ์งˆ ์ค„ ๋ˆ„๊ฐ€ ์•Œ์•˜๊ฒ ์–ด? ์˜ˆ์ „์—๋Š” ์ˆ˜์‹ญ ์ค„์˜ ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด์•ผ ํ–ˆ๋Š”๋ฐ, ์ด์ œ๋Š” ๋‹จ ํ•œ ์ค„๋กœ ํ•ด๊ฒฐ๋˜์ž–์•„!

ํ•ต์‹ฌ ํฌ์ธํŠธ๋ฅผ ๋‹ค์‹œ ์ •๋ฆฌํ•˜๋ฉด:

๐ŸŽฏ Codable = Decodable + Encodable ์–‘๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜์„ ์œ„ํ•œ ํ”„๋กœํ† ์ฝœ

๐Ÿ”‘ CodingKeys๋กœ ์œ ์—ฐํ•œ ๋งคํ•‘
JSON ํ‚ค์™€ Swift ํ”„๋กœํผํ‹ฐ ์ด๋ฆ„์ด ๋‹ฌ๋ผ๋„ OK

๐ŸŽจ ์ปค์Šคํ…€ ๋””์ฝ”๋”ฉ์œผ๋กœ ๋ฌดํ•œํ•œ ๊ฐ€๋Šฅ์„ฑ
init(from:)์„ ๊ตฌํ˜„ํ•˜๋ฉด ์›ํ•˜๋Š” ๋Œ€๋กœ ๋ณ€ํ™˜ ๊ฐ€๋Šฅ

๐Ÿ“… ๋‚ ์งœ ์ฒ˜๋ฆฌ๋Š” ์‹ ์ค‘ํ•˜๊ฒŒ
ISO 8601 ํ˜•์‹ ๊ถŒ์žฅ, ํ•„์š”์‹œ ์ปค์Šคํ…€ ์ „๋žต ์‚ฌ์šฉ

โš ๏ธ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋Š” ํ•„์ˆ˜
์‚ฌ์šฉ์ž ์นœํ™”์ ์ธ ๋ฉ”์‹œ์ง€์™€ ์ ์ ˆํ•œ ๋กœ๊น…

๐Ÿš€ ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด ํ™œ์šฉ
Repository, MVVM ๋“ฑ์œผ๋กœ ๊น”๋”ํ•œ ์ฝ”๋“œ ๊ตฌ์กฐ

๐Ÿงช ํ…Œ์ŠคํŠธ๋Š” ์„ ํƒ์ด ์•„๋‹Œ ํ•„์ˆ˜
๋‹ค์–‘ํ•œ ์—ฃ์ง€ ์ผ€์ด์Šค ๊ฒ€์ฆ


์ด์ œ ์—ฌ๋Ÿฌ๋ถ„์€ Swift์—์„œ JSON์„ ์ž์œ ์ž์žฌ๋กœ ๋‹ค๋ฃฐ ์ˆ˜ ์žˆ์–ด! ๐ŸŽŠ

์‹ค์ œ ํ”„๋กœ์ ํŠธ์— ์ ์šฉํ•˜๋ฉด์„œ ๋” ๋งŽ์€ ๊ฒฝํ—˜์„ ์Œ“์•„๋ณด๊ธธ ๋ฐ”๋ผ. ๊ทธ๋ฆฌ๊ณ  ๋ง‰ํžˆ๋Š” ๋ถ€๋ถ„์ด ์žˆ๋‹ค๋ฉด ์žฌ๋Šฅ๋„ท์—์„œ Swift ์ „๋ฌธ๊ฐ€๋“ค์˜ ๋„์›€์„ ๋ฐ›์„ ์ˆ˜๋„ ์žˆ์–ด. ํ”„๋กœ๊ทธ๋ž˜๋ฐ์€ ํ˜ผ์ž ํ•˜๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ ํ•จ๊ป˜ ์„ฑ์žฅํ•˜๋Š” ๊ฑฐ๋‹ˆ๊นŒ! ๐Ÿ’ช

์žฌ๋Šฅ๋„ท์—๋Š” ๋‹ค์–‘ํ•œ ํ”„๋กœ๊ทธ๋ž˜๋ฐ ์žฌ๋Šฅ์„ ๊ฐ€์ง„ ๋ถ„๋“ค์ด ๋งŽ์œผ๋‹ˆ, ์—ฌ๋Ÿฌ๋ถ„๋„ ๋ฐฐ์šด ์ง€์‹์„ ๊ณต์œ ํ•˜๊ฑฐ๋‚˜ ๋” ๊นŠ์€ ๋‚ด์šฉ์„ ๋ฐฐ์šธ ์ˆ˜ ์žˆ์„ ๊ฑฐ์•ผ.

Happy Coding! ์ฆ๊ฑฐ์šด ์ฝ”๋”ฉ ๋˜๊ธธ! ๐Ÿš€โœจ

๐ŸŒŸ ์ด ๊ธ€์ด ๋„์›€์ด ๋˜์—ˆ๋‹ค๋ฉด ์žฌ๋Šฅ๋„ท์—์„œ ๋” ๋งŽ์€ ์ง€์‹์„ ๊ณต์œ ํ•ด๋ณด์„ธ์š”! ๐ŸŒŸ

๋Œ“๊ธ€ ์ž‘์„ฑ

์ด ๊ธ€์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ƒ๊ฐ์„ ๋“ค๋ ค์ฃผ์„ธ์š”

๋Œ“๊ธ€ 0
๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.