본문 바로가기

DEV

[iOS/Swift] JSON 파싱, @escaping, Result, Bundle, Completion Handler, Data(contentsof:) 등등 ..

반응형

스파르타 부트캠프 Ch 3. 앱 개발 입문 주차 과제로 책 시리즈 앱을 만드는 중이다.

책 데이터가 json 파일로 제공이 되어었고, json 파일에 있는 데이터를 가져오기 위한 코드도 제공이 되어있다.

아래가 주어진 코드이다.

import Foundation

class DataService {
    
    enum DataError: Error {
        case fileNotFound
        case parsingFailed
    }
    
    func loadBooks(completion: @escaping (Result<[Book], Error>) -> Void) {
        guard let path = Bundle.main.path(forResource: "data", ofType: "json") else {
            completion(.failure(DataError.fileNotFound))
            return
        }
        
        do {
            let data = try Data(contentsOf: URL(fileURLWithPath: path))
            let bookResponse = try JSONDecoder().decode(BookResponse.self, from: data)
            let books = bookResponse.data.map { $0.attributes }
            completion(.success(books))
        } catch {
            print("🚨 JSON 파싱 에러 : \(error)")
            completion(.failure(DataError.parsingFailed))
        }
    }
}

/* 사용부
private let dataService = DataService()

func loadBooks() {
    dataService.loadBooks { [weak self] result in
        guard let self = self else { return }
        
        switch result {
        case .success(let books):
            
        case .failure(let error):
            
        }
    }
}
*/

 

해당 코드를 정확히 이해하진 못해서 자세히 알아보고자 한다.

 

그전에 아래 data.json 파일에 맞게, 해당 모델을 만들어줘야 한다.

 

json 구조가 헷갈려서 찾아봤다.

 

1. JSON 데이터는 이름과 값의 쌍으로 이루어집니다.
2. JSON 데이터는 쉼표(,)로 나열됩니다.
3. 객체(object)는 중괄호({})로 둘러쌓아 표현합니다.

{
    "name": "식빵",
    "family": "웰시코기",
    "age": 1,
    "weight": 2.14
}


4. 배열(array)은 대괄호([])로 둘러쌓아 표현합니다.

"dog": [
    {"name": "식빵", "family": "웰시코기", "age": 1, "weight": 2.14},
    {"name": "콩콩", "family": "포메라니안", "age": 3, "weight": 2.5},
    {"name": "젤리", "family": "푸들", "age": 7, "weight": 3.1}
]

 

 

JSON 파일을 Encoding / Decoding 할 수 있도록 Codable 프로토콜을 채택하였다.

BookResponse는 json 파일의 {"data": [...]} 부분 

BookWrapper는 "attributes" 부분

Book은 "attributes" 안에 있는 Book 관련 정보들을 가지고 있다.

chapters는 Book 구조체 안에 [Chapter] 배열로 포함되어 있고, Chapter는 별도의 구조체로 정의했다.

이는 JSON "chapters": [{"title": "..."}, ...] 같은 배열을 매핑하기 위함이다.

enum CodingKeys로 변수명을 releaseDate로 쓰기 위해 JSON 키 이름 "release_date"와 매핑해주었다.

import Foundation

struct Book: Codable {
    let title: String
    let author: String
    let pages: Int
    let releaseDate: String
    let dedication: String
    let summary: String
    let wiki: String
    let chapters: [Chapter]
    
    enum CodingKeys: String, CodingKey {
        case title, author, pages, dedication, summary, wiki, chapters
        case releaseDate = "release_date"
    }
}

struct Chapter: Codable {
    let title: String
}

struct BookWrapper: Codable {
    let attributes: Book
}

struct BookResponse: Codable {
    let data: [BookWrapper]
}

 

DataError: Error

enum DataError: Error {
        case fileNotFound
        case parsingFailed
    }

 

먼저 Error 프로토콜을 준수하는 DataError라는 enum이 있다.

Error 프로토콜은 Swift에서 오류를 표현하기 위해 사용되는 프로토콜로 열거형을 통해서 표현하기에 적합하다고 한다.

현재 fileNotFound, parsingFailed로 두 가지 에러 케이스가 정의되었다.

 

 

loadBooks

func loadBooks(completion: @escaping (Result<[Book], Error>) -> Void)

 

completion: 해당 메서드의 매개변수로 클로저이다. 여기서는 Completion Handler 패턴을 사용하고 있다.


Completion Handler는 작업(특히 비동기 작업)이 완료된 후 호출되는 클로저로 주로 작업의 성공/실패 여부와 결과를 호출자에게 전달하는 데 사용된다고 한다.

 

여기서 completion은 loadBooks 메서드가 작업을 완료한 후 아래 클로저처럼 결과를 호출자에게 전달하는 역할을 한다.

dataService.loadBooks { result in
    switch result {
    case .success(let books):
        print("Books loaded: \(books)")
    case .failure(let error):
        print("Error: \(error)")
    }
}

 

여기서 result는 loadBooks가 completion을 호출할 때 전달하는 값이다. 

result가 .success인지, .failure인지에 따라 기능을 구현하면 된다.

 

@escaping: @escaping이 붙어있으면 Escaping Clousure(탈출 클로저)를 의미하며, 클로저가 메서드 실행이 끝난 후에도 사용될 수 있음을 나타낸다. 비동기 작업에서 필수적이다.

현재 과제에서는 프로젝트 내에서 json 파일을 직접 받아오기에 불필요할 것 같지만, json을 외부 네트워크에서 가져온다고 가정하면 필요할 것이다.

 

Result<[Book], Error>: Result는 Swift에서 제공하는 열거형으로 <성공(success), 실패(failure)> 두 가지 경우를 나타낸다.

현재 코드에서는 성공 시 .success([Book]), 실패 시 .failure(Error)로 클로저를 통해 호출하는 클로저에 전달된다.


 

그런데 계속 알아볼수록 머리가 어지럽고 복잡해져만갔다.

 

일단 성공을 했다고 가정했을 시 completion(.success(books))이 실행이 될텐데, 해당 코드가 실행되면 어떻게 되는지 헷갈렸다.

그리고, 외부에서 loadBooks를 호출 할 때 result는 정확한게 무엇인지 등, 헷갈리는게 많았다.

 

그래서 Grok한테 일일이 물어봤다.

 

그래도 이해가 잘 안됐다. 호출자가 제공한 completion 클로저가 정확히 뭔지 잘 몰라서 다시 물어봤다.

 

호출자가 제공하는 completion 클로저는 아래 코드에서 { [weak self] result in ... } 전체가 호출자가 제공한 completion 클로저라고 한다. 이 클로저는 loadBooks에 전달되어 completion이라는 이름으로 사용된다.

dataService.loadBooks { [weak self] result in
    guard let self = self else { return }
    switch result {
    case .success(let books):
        print("책 로드 성공: \(books)")
    case .failure(let error):
        print("에러: \(error)")
    }
}

 

이 코드에서는 [weak self]로 메모리 순한 참조를 방지하였다.

guard let self = self else { return } 로 self가 nil이 아닌지 확인한다.(약한 참조 때문에 필요)

result는 클로저가 받는 파라미터로 loadBooks가 전달하는 결과(Result<[Book], Error>)가 여기에 들어온다.

 

 

아래처럼 코드 흐름을 순서대로 설명을 해줬다.

 

현재 loadBooks()를 호출하는 코드는 후행 클로저Trailing Closure) 형태로 작성된 것이다. 

후행 클로저는 Swift에서 함수의 마지막 파라미터가 클로저일 때, 클로저를 함수 호출 괄호 밖으로 빼서 작성하는 방식이다.

후행 클로저로 축약을 하지 않았다면 아래와 같이 (completion: { [weak self] result in ... }) 형태로 된다.
(클로저 문법이 아직도 헷갈린다. 더 공부를 해야겠다...)

dataService.loadBooks(completion: { [weak self] result in
    guard let self = self else { return }
    switch result {
    case .success(let books):
        print("책 로드 성공: \(books)")
    case .failure(let error):
        print("에러: \(error)")
    }
})

 

즉, { [weak self] result in ... } 전체가 DataService의 loadBooks 메서드의 completion 파라미터로 전달되어 실행이 된다고 한다.

(함수를 파라미터로 받는 형태가 아직 익숙하게 느껴지지 않는다.)

 

그럼 result는 정확히 뭐지?

 

그렇다고 한다. 

파라미터인건 알겠는데, 왜 헷갈리는지 모르겠지만 그냥 헷갈린다. 피곤해서 그런가.

result가 어디서 설정되는지도 알려줬다.

loadBooks 메서드 내부에서 completion 호출 시 result 값이 설정된다고 한다.

 

정리해보자면 dataService.loadBooks() 처럼 외부에서 호출을 하면 dataService의 loadBooks 메서드가 실행되며 { [weak self] result in ... } 클로저가 completion 파라미터로 전달된다.

그 후 loadBooks가 json 파일을 읽고 파싱을 하고, completion이 호출되며 result가 클로저로 전달된다.

마지막으로, 이 result에 따라 switch 문을 통해 동작을 실행한다.

 

그런데 아직도 { [weak self] result in guard let self = self else { return } switch result { ... } } 전체가 loadBooks의 completion 파라미터로 전달된다는게 이해가 안된다.

나중에 튜터님에게 물어보든지 해야겠다.


다음 코드부터 알아보자

guard let path = Bundle.main.path(forResource: "data", ofType: "json") else {
    completion(.failure(DataError.fileNotFound))
    return
}

 

Bundle.main.path(forResource: ofType: )이 실패하면("data.json" 파일을 찾지 못하면) completion 클로저가 .failure(DataError.fileNotFound)와 함께 호출된다.

 

Bundle이 뭐지?

Bundle은 앱의 리소스와 실행 파일이 포함된 디렉토리라고 한다.

Xcode는 프로젝트를 빌드 시 실행 파일(앱 자체)과 함께 리소스(이미지, JSON 파일, 사운드 파일 등)를 번들(Bundle)이라는 폴더에 묶는다고 한다.

현대 data.json 파일은 Bundle 내에 있기 때문에 거기서 파일을 불러오고 있다.

 

Bundle.main.path(forResource:ofType:)

이 메서드로 번들 안에 있는 특정 파일의 파일 시스템 경로(문자열)을 반환해주고 있다.

forResource에 파일 이름을, ofType에 파일 확장자를 넣어준다.

파일 존재 시 파일의 전체 경로(String?)을 반환하고, 없으면 nil을 반환한다.

 

이 시점에서 DataError.fileNotFound는 Result 열거형의 .failure 케이스에 래핑되어 아래와 같이 호출자에게 전달된다.

 

do {
    let data = try Data(contentsOf: URL(fileURLWithPath: path))
    let bookResponse = try JSONDecoder().decode(BookResponse.self, from: data)
    let books = bookResponse.data.map { $0.attributes }
    completion(.success(books))
} catch {
    print("🚨 JSON 파싱 에러 : \(error)")
    completion(.failure(DataError.parsingFailed))
}

 

여기서는 do-try-catch 블록으로 에러 처리를 하고 있다.

 

Data(contentsOf:)

Data(contentsOf:)는 주어진 URL로부터 데이터를 데이터 객체로 초기화하는 작업이다.

현재 코드에서는 전에 Bundle.main.path(forResource: "data", ofType: "json")로  경로를 받아온 변수 path를 URL(fileURLWithPath: ) 에서 해당 파일 경로를 URL로 변환후 해당 메서드를 실행하였다.

Data(contentsOf:)메서드는 URL 타입을 요구하기 때문이다.

 

하지만 해당 메서드는 동기적인 작업이기에 네트워크 기반의 URL을 요청할때는 사용하면 안된다고 한다.

만약 네트워크가 느릴 경우 main 스레드가 차단되어 사용자와 가장 가까운 UI가 멈출수도 있다고 한다.

 

네트워크에서 이미지를 받을 경우에는URLSession dataTask(with:completionHandler:) 를 사용하는 것을 권장한다고 한다.

URLSession을 통해 이미지를 받아오면 Data(contentsOf:)와 달리, 오류 발생 시 진단을 할 수 있으며 스레드가 block 되는 것을 방지할 수 있다고 한다.

 

JSONDecoder().decode

이 메서드는 JSON 데이터를 decode 하는 메서드이다.

JSONDecoder()로 JSONDecoder의 인스턴스를 새로 생성.

.decode(_ : from: )으로 지정된 타입으로 디코딩.

_ : 이곳에 디코딩할 타입을 지정한다.

from: 디코딩할 원본 데이터를(Data 타입) 넣어주면 된다.

 

let bookResponse = try JSONDecoder().decode(BookResponse.self, from: data)

현재 코드에서는 위에서 만든 data 객체를 BookResponse라는 구조체로 파싱을 한다. (이에 따라 BookResponse라는 구조체를 만들면 되겠다.)

BookResponse.self 에서 .self는 타입 자체를 의미한다.

decode 메서드는 JSON 파싱 중 에러가 발생할 수 있기에 do-catch 블록안에서 try를 붙인다.

 

bookResponse.data.map { $0.attributes }
BookResponse 안에 있는 data 배열에서 각 항목의 attributes만 추출해서 [Book] 배열로 만든다.

성공 시 completion(.success(books)) 로 책 배열을 전달하고, 실패 시 catch 블록에서 에러를 출력하고 completion(.failure(DataError.parsingFailed)) 로 에러를 전달한다.

 

 

아래 블로그들을 참고하였습니다.

https://www.tcpschool.com/json/json_basic_structure

 

코딩교육 티씨피스쿨

4차산업혁명, 코딩교육, 소프트웨어교육, 코딩기초, SW코딩, 기초코딩부터 자바 파이썬 등

tcpschool.com

https://88yhtserof.tistory.com/52

 

[ iOS ] URL로 이미지를 받아 올 경우 Data(contentsOf:)를 사용하면 안 되는 이유

공부한 내용을 정리한 글입니다. 문제 네트워크 기반의 URL 이미지를 받아올 때 Data(contentsOf:)를 사용해도 될까? 1. Data(contentsOf:) NSData(constentsOf:)는 주어진 URL로부터 데이터를 데이터 객체로 초기

88yhtserof.tistory.com

 

반응형