궁금한 내용을 검색해보세요!
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
서근 개발노트
티스토리에 팔로잉
SWIFTUI/Others

SwiftUI : @escaping과 함께 JSON Data 다운로드하기

서근
QUOTE THE DAY

-
Written by SeogunSEOGUN

반응형

Escaping클로저를 사용한 JSON Data 다운로드

이번에는 @escaping클로저와 함께 JSON Data를 인터넷에서 다운로드해오는 방법에 대해 알아볼까 합니다. 

 

웹데이터를 앱에서 사용할 수 있는 데이터로 변환하고 그 API를 화면에 띄워주는것이 목표입니다. 

 

우선 새로운 SwiftUI 파일을 생성하고 이름은 DownloadWithEscaping이라고 정해주겠습니다. 그런 다음 ViewModel class를 생성해줘야겠죠? 

import SwiftUI


class DownloadWithEscapingViewModel: ObservableObject {
    
    init() {
        getPosts()
    }
    
    func getPosts() {
        
    }
}
struct DownloadWithEscaping: View {
    @StateObject var vm = DownloadWithEscapingViewModel()
    var body: some View {
        Text("Hello, World!")
    }
}

이제 getPosts함수 안에 인터넷을 사용하기 위한 코드 dataTaskcompletionHandler을 사용하는데, 이 한줄의 코드로 인해 우리는 url에 있는 모든 데이터를 다운로드할 수 있습니다. 다운로드를 받고 돌아오면 data, reponse, error 중 하나를 실행하게 됩니다.

class DownloadWithEscapingViewModel: ObservableObject {
    
    init() {
        getPosts()
    }
    
    func getPosts() {
        
        //URL을 만들때는 옵셔널 이기 때문에 guard let 사용 
        guard let url = URL(string: "") else { return }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            
            //some code
        }
    }
}

url변수를 생성할때 URL옵셔널 상태이기 때문에 guard 문을 사용해줘야 합니다. 그리고 dataTask에서 먼저 data, response, error를 확인하고, 그다음 이 작업의 맨 아래에 .resume()을 추가해서 실제로 호출할 때까지 시작되지 않도록 설정해줍니다.

 

그렇다면 data, response, error에 맞는 코드를 작성해줘야겠네요!

 

  • 첫 번째, data의 존재 유무 확인
  • 두 번째, error확인
  • 세 번째, http 응답을 받음 (response)

 

크게 보면 대략적으로 이렇네요. 천천히 코드를 작성해볼께요.

 

먼저, 데이터가 있는지 확인해야 합니다.

            //1. 데이터 확인
            guard let data = data else {
                print("데이터가 존재하지 않습니다.")
                return
            }

그리고 error가 있는지 확인합니다. print("오류 : \(error)")라고 코드를 넣고 에러 메시지에 Fix를 누르면 자동으로 고쳐집니다.

            //2. 오류 확인
            guard error == nil else {
                print("오류 : \(String(describing: error))")
                return
            }

http응답을 받습니다.

            //3. http응답을 받음
            guard let response = response as? HTTPURLResponse else {
                print("잘못된 응답입니다.")
                return
            }

이제 응답을 받았으니 응답 상태를 한번 확인해줘야 합니다. 이 응답의 상태가 성공을 했는지, 아니면 실패를 했는지를 알 수 있어야 하기 때문이죠. 인터넷 상태를 체크하는 방법에는 mozilla 사이트를 참고하면 됩니다.

성공적인 응답은 statusCode 200~299 사이네요! 그렇다면 아래와 같이 코드를 작성해 줄 수 있습니다.

            //4. 응답 상태
            //Successful response = 200 ~ 299
            guard response.statusCode >= 200 && response.statusCode < 300 else {
                print("Status Code는 2xx이 되야 합니다. 현재 Status Code는 \(response.statusCode) 입니다.")
                return
            }

이제 이 응답이 성공적으로 이뤄졌다면 콘솔 창에 데이터를 성공적으로 다운로드했다고 출력해줘야 합니다. 그리고 data를 출력해야겠죠. 하지만 data를 그냥 출력해보면 아무것도 출력되지 않습니다. 왜냐하면 DataString형식으로 변환시켜줘야 하죠.

    func getPosts() {
        
        guard let url = URL(string: "") else { return }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            //some code
            
            //1. 데이터 확인
            guard let data = data else {
                print("데이터가 존재하지 않습니다.")
                return
            }
            
            //2. 오류 확인
            guard error == nil else {
                print("오류 : \(String(describing: error))")
                return
            }
            
            //3. http응답을 받음
            guard let response = response as? HTTPURLResponse else {
                print("잘못된 응답입니다.")
                return
            }
            
            //4. 응답 상태
            //Successful response = 200 ~ 299
            guard response.statusCode >= 200 && response.statusCode < 300 else {
                print("Status Code는 2xx이 되야 합니다. 현재 Status Code는 \(response.statusCode) 입니다.")
                return
            }
            print("데이터를 성공적으로 다운로드 했습니다!")
            print(data)
            //data를 문자열로 변환해줘야 합니다.
            let jsonString = String(data: data, encoding: .utf8)
            print(jsonString)
        }
        .resume()
    }

자, 여기까지 데이터를 확인 -> 오류 확인 -> 응답을 받고 -> 응답 상태를 확인 후 -> 데이터를 출력했습니다. 이제 해야 할 것은 getPostsinit에 호출해주고 실제로 사용할 Json Data url를 가져와줘야겠네요! 이번 게시글은 API연습을 위한 것이기 때문에 Fack API를 가져와서 사용해줄까 합니다. 

 

Fack API를 무료로 제공해주는 아주 좋은 사이트가 있습니다. JSONPlaceholder이라는 사이트인데, 사이트 아래에 Resources 섹션을 보면 아래와 같은 API를 확인할 수 있습니다. 

일단 간단하게 GET의 /posts/1 을 사용해볼게요. 

posts/1을 눌러서 JSON을 확인해보면, 

JSON배열이 userID, id, title, body이네요! 일단 이렇게만 인지해두고 아래와 같이 url을 추가해주고 시뮬레이터를 실행해서 콘솔을 화 긴 해봅니다!

    func getPosts() {
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1") else { return }

292의 bytes와 JSON에 있는 데이터 구조를 성공적으로 가져오게 되었네요! 

 

이제 우리가 해야 할 것은 이 데이터 구조와 동일한 Model을 만드는 것입니다. 그리고 postModel디코딩하고 인코딩하기를 원하기 때문에 codable 추가해주겠습니다.

//MARK: MODEL
//get post 1의 Json 데이터와 동일한 모델 생성
// postModel을 디코딩하고 인코딩을 원하기 때문에 codable 추가

struct PostModel: Identifiable, Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

한 가지 팁(?)을 드리고 싶은데, JSON Data 구조를 struct Model로 가져올 때 정확하지 않고 한 개 라도 잘못 입력한다면 컴파일 오류가 생길 것입니다. 그렇다면 어떻게 올바르고 쉽게 가져올 수 있을까요? 

 

바로 사이트를 이용하는 것이죠! 이번에 추천할 사이트는 바로 Quicktype 라는 사이트인데요 아래와 같이 사용할 수 있습니다.

{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}

JSON을 복사해서 Quicktype에 붙여 넣어줍니다.

이런 식으로 어떻게 struct 구조로 변환되는지 쉽게 알 수 있습니다. 뭐... 이번에는 필요하지 않으니 넘어갈게요.

 

자, 이렇게 Post Model을 생성했으니, 디코딩해주겠습니다. ViewModel에서 @Published를 사용해 배열을 만들어 주겠습니다.

//MARK: VIEWMODEL
class DownloadWithEscapingViewModel: ObservableObject {
    
    @Published var posts: [PostModel] = []

@Publishe post에서 시작해서 data를 프린트하면, 새로운 post를 생성하도록 해줘야 합니다. 바로 디코딩해줄게요.

let newPost = try? JSONDecoder().decode(PostModel.self, from: data)

여기서 newPost옵셔널 이기 때문에 guard를 사용해야 합니다. 그리고 postnewPostappend 합니다. 

            print("데이터를 성공적으로 다운로드 했습니다!")
            print(data)
            //data를 문자열로 변환해줘야 합니다.
            let jsonString = String(data: data, encoding: .utf8)
            print(jsonString)

            //newPost는 옵셔널 이기 때문에 guard
            guard let newPost = try? JSONDecoder().decode(PostModel.self, from: data) else { return }
            self.posts.append(newPost)
        }

지난 Thread 게시글을 보면 인터넷에서 다운로드하는 데이터는 background Thread에서 실행되고 UI를 업데이트하는 곳은 Main Thread에서 실행된다고 했었죠? 여기서 인터넷에서 다운로드하는 곳은 바로 dataTask 부분이 되며 실제로 background Thread로 이동됩니다. 이는 코딩할 때 아주 효율적이죠.

 

dataTask는 앱을 업데이트하지 않기 때문에 background Thread에서 다운로드 하지만, 다운로드를 성공하면 UI를 업데이트하고 싶습니다.

 

background에서 다운로드하고 게시글을 추가하면 UI를 업데이트하는 것이므로 append 부분은 Main Thread가 되겠군요! 어렵지 않아요!

 

그럼 append부분을 다음과 같이 DispatchQueue를 통해 Main Thread로 이동해 줄 수 있습니다.

 DispatchQueue.main.async { self.posts.append(newPost) }

마지막으로 self강한참조 이기 때문에 약한참조로 바꿔줄게요.

import SwiftUI

//MARK: MODEL
struct PostModel: Identifiable, Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

//MARK: VIEWMODEL
class DownloadWithEscapingViewModel: ObservableObject {
    
    @Published var posts: [PostModel] = []
    
    init() {
        getPosts()
    }
    
    func getPosts() {
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1") else { return }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            //some code
            
            //1. 데이터 확인
            guard let data = data else {
                print("데이터가 존재하지 않습니다.")
                return
            }
            
            //2. 오류 확인
            guard error == nil else {
                print("오류 : \(String(describing: error))")
                return
            }
            
            //3. http응답을 받음
            guard let response = response as? HTTPURLResponse else {
                print("잘못된 응답입니다.")
                return
            }
            
            //4. 응답 상태
            //Successful response = 200 ~ 299
            guard response.statusCode >= 200 && response.statusCode < 300 else {
                print("Status Code는 2xx이 되야 합니다. 현재 Status Code는 \(response.statusCode) 입니다.")
                return
            }
            print("데이터를 성공적으로 다운로드 했습니다!")
            print(data)
            //data를 문자열로 변환해줘야 합니다.
            let jsonString = String(data: data, encoding: .utf8)
            print(jsonString)
            //newPost는 옵셔널 이기 때문에 guard
            guard let newPost = try? JSONDecoder().decode(PostModel.self, from: data) else { return }
            DispatchQueue.main.async { [weak self] in
                self?.posts.append(newPost)
                
            }
        }
        .resume()
    }
}

자, 요렇게 ModelViewModel의 함수까지 완성되었으니 Body에서 간단하게 View를 만들어봅시다!

//MARK: BODY
struct DownloadWithEscaping: View {
    @StateObject var vm = DownloadWithEscapingViewModel()
    var body: some View {
        ScrollView {
            ForEach(vm.posts) { post in
                VStack(spacing: 10) {
                    Text(post.title)
                        .font(Font.title.bold())
                    Text(post.body)
                        .foregroundColor(Color(UIColor.systemGray2))
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
            }
        }
    }
}

타란! 성공적으로 API를 앱으로 가져왔습니다. 자 이렇게 데이터를 가져왔으니 필요 없는 부분들은 삭제하고 코드를 짧게 정리해주고 싶네요

    func getPosts() {
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1") else { return }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            
            guard
                let data = data,
                error == nil,
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 300 else {
                print("데이터 다운로드에 실패했습니다.")
                return
            }
            
            //newPost는 옵셔널 이기 때문에 guard
            guard let newPost = try? JSONDecoder().decode(PostModel.self, from: data) else { return }
            DispatchQueue.main.async { [weak self] in
            self?.posts.append(newPost)
                
            }
        }
        .resume()
    }

 한 가지 더 하고 싶은 것은 URLSession에서 dataTaskurl을 전달하고, 데이터를 다시 가져옵니다. 그리고 만약 더 많은 url 주소가 있다면 우리는 이 다운로드 작업을 계속할 것입니다. 그리고 guard함수를 몇 번이고 계속 다시 작성하고 다시 호출하겠죠.. 그렇기 때문에 데이터를 생성하는 것이 더 효율적입니다. 우리가 사용할 수 있는 일반적인 single function 말이죠! 

 

downloadData함수를 사용할 때 @escaping클로저를 사용해야 하는데 이유는 비동기적인 것을 다운로드할 때 앱으로 돌아가는 시간이 있는데 실제로 조금의 시간이 걸립니다. 그럴 때 @escaping클로저를 사용한다고 했었죠? 

    func downloadData(fromURL url: URL, complectionHandler: @escaping (_ data: Data?) -> ()) {
        
    }

    func getPosts() {
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1") else { return }
        //newPost는 옵셔널 이기 때문에 guard
        guard let newPost = try? JSONDecoder().decode(PostModel.self, from: data) else { return }
        DispatchQueue.main.async { [weak self] in
        self?.posts.append(newPost)
            
        }

    }
    func downloadData(fromURL url: URL, complectionHandler: @escaping (_ data: Data?) -> ()) {
        URLSession.shared.dataTask(with: url) { data, response, error in
            
            guard
                let data = data,
                error == nil,
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 300 else {
                print("데이터 다운로드에 실패했습니다.")
                return
            }
        }
        .resume()
        
    }
}

 

데이터를 얻고 guard statement를 지나서 completionHandler를 호출하고 이곳에 data를 전달해주겠습니다. 그리고 guard statement를 보면 데이터에 데이터가 있고, errornil이 아니고, response이 정확히 된다면 completionHandler가 호출되도록 했습니다. 하지만 실패했다면 completionHandlernil이 되도록 해줘야겠죠? else구 안에 바로 추가해줘야겠네요!

 

이제 guard문에 오류가 없다면 성공적으로 completionHandler가 실행될 것입니다.

    func downloadData(fromURL url: URL, complectionHandler: @escaping (_ data: Data?) -> ()) {
        URLSession.shared.dataTask(with: url) { data, response, error in
            
            guard
                let data = data,
                error == nil,
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 300 else {
                print("데이터 다운로드에 실패했습니다.")
                complectionHandler(nil)
                return
            }
          complectionHandler(data)
        }
        .resume()
        
    }

자! 거의 다 왔습니다! downloadData함수를 잘 만들어줬으니 이제 getPosts로 돌아가서 이 함수를 호출해줘야 합니다. guard let url 아래에 호출해주는데 datareturnedDatanewPost를 실행하고 그게 아니라면 반환될 데이터가 존재하지 않는다는 문구를 출력해주도록 합니다.

    func getPosts() {
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1") else { return }
        
        downloadData(fromURL: url) { returnedData in
            if let data = returnedData {
                
            } else {
                print("반환될 데이터가 존재하지 않습니다.")
            }
        }

//MARK: VIEWMODEL
class DownloadWithEscapingViewModel: ObservableObject {
    
    @Published var posts: [PostModel] = []
    
    init() {
        getPosts()
    }
    
    func getPosts() {
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1") else { return }
        
        downloadData(fromURL: url) { returnedData in
            if let data = returnedData {
                //newPost는 옵셔널 이기 때문에 guard
                guard let newPost = try? JSONDecoder().decode(PostModel.self, from: data) else { return }
                DispatchQueue.main.async { [weak self] in
                    self?.posts.append(newPost)
                    
                }
            } else {
                print("반환될 데이터가 존재하지 않습니다.")
            }
        }
    }
    
    func downloadData(fromURL url: URL, complectionHandler: @escaping (_ data: Data?) -> ()) {
        URLSession.shared.dataTask(with: url) { data, response, error in
            
            guard
                let data = data,
                error == nil,
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 300 else {
                    print("데이터 다운로드에 실패했습니다.")
                    complectionHandler(nil)
                    return
                }
            complectionHandler(data)
        }
        .resume()
        
    }
}

ViewModel의 함수가 완성됐고 이제 시뮬레이터를 실행해서 확인해보면 정상적으로 데이터를 다운해왔고 화면에 나타나게 됐죠? 

 

그러면 이제 다시 JSONPlaceholder로 돌아가서 Resources 섹션의 100개의 post가 있는 /posts 탭으로 들어가 주고 url을 복사해주겠습니다. 그리고 이 주소로 수정해줍니다.

 

postsJSON구조를 보면 뭔가 달라진 것을 눈치채셨나요?

우리는 100개의 포스트 데이터를 사용할 것이기 때문에 PostModel에  [ ] 배열을 사용해야 한다는 걸 알 수 있습니다.

밑줄 친 이 부분들을 수정해줘야 하는데, postModel[PostModel]로 수정, newPost는 이제 여러 개의 post가 들어갈 것이기 때문에 newPosts로 수정, newPostappend 하는 대신 self?.posts = newPosts로 수정해주면 될 거 같네요.

    func getPosts() {
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return }
        
        downloadData(fromURL: url) { returnedData in
            if let data = returnedData {
                //newPost는 옵셔널 이기 때문에 guard
                guard let newPosts = try? JSONDecoder().decode([PostModel].self, from: data) else { return }
                DispatchQueue.main.async { [weak self] in
                    self?.posts = newPosts
                    
                }
            } else {
                print("반환될 데이터가 존재하지 않습니다.")
            }
        }
    }

다시 시뮬레이터 실행 고고!

어? 배열이 이상하네요.. BodyVStack 부분을 수정해줘야겠어요.. 그리고 ScrollView대신 List를 써볼게요.

//MARK: BODY
struct DownloadWithEscaping: View {
    @StateObject var vm = DownloadWithEscapingViewModel()
    var body: some View {
        NavigationView {
            List {
                ForEach(vm.posts) { post in
                    VStack(alignment: .leading, spacing: 10) {
                        Text(post.title)
                            .font(Font.title.bold())
                        Text(post.body)
                            .foregroundColor(Color(UIColor.systemGray2))
                    }
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding()
                }
            }
            .navigationBarTitle("Fack API DATA")
            .listStyle(PlainListStyle())
        }
    }
}

타란~ 와.... 제가 API를 사용해보다니... 점점 지식이 늘어나서 행복하네요 😭 

정리

    func downloadData(fromURL url: URL, complectionHandler: @escaping (_ data: Data?) -> ()) {
        URLSession.shared.dataTask(with: url) { data, response, error in
            
            guard
                let data = data,
                error == nil,
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 300 else {
                    print("데이터 다운로드에 실패했습니다.")
                    complectionHandler(nil)
                    return
                }
            complectionHandler(data)
        }
        .resume()

우리는 downloadData 함수를 만들어서 모든 URL에서 데이터를 다운로드할 수 있게 만들었고 URLSession.shared.dataTask를 사용하고 있습니다. 이 dataTask의 큰 장점은 자동으로 background Thread로 간다는 점이죠. background Thread로 이동하고 -> data, response, error검사한 후에 -> completionHandler를 호출했습니다. 그리고 우리는 이 함수에서 @escaping 클로저를 사용하고 있죠. 왜냐면 이 함수는 비동기식 이므로 이 dataTask를 호출하는데 약간의 시간이 걸릴 수 있기 때문에 @escaping클로저를 통해 일부 데이터가 반환됩니다. 

        downloadData(fromURL: url) { returnedData in
            if let data = returnedData {
                //newPost는 옵셔널 이기 때문에 guard
                guard let newPosts = try? JSONDecoder().decode([PostModel].self, from: data) else { return }
                DispatchQueue.main.async { [weak self] in
                    self?.posts = newPosts
                }
            } else {
                print("반환될 데이터가 존재하지 않습니다.")
            }

그리고 Data(_ data: Data?)를 반환해줬고 해당 데이터를 codable프로토콜을 사용하여 PostModel의 배열로 변환했습니다.

@Published var posts: [PostModel] = []

또 중요한 것은 newPosts를 추가할 때는 UI를 업데이트하는 것이기 때문에 Main Thread에서 작업될 수 있도록 해줬고, 강한참조를 피하기 위해 약한참조로 바꿔줬습니다.

                DispatchQueue.main.async { [weak self] in
                    self?.posts = newPosts
                }

자 이렇게 끝이 났습니다!!!!!! 아직 저에게는 복잡하고 어렵지만 API를 앱 데이터로 변환해서 화면에 구성하는 게 너무 재미있었습니다. API에 대해 더 공부하고 제가 배운 자료를 계속 공유해드리고 싶네요 :)

전체 코드

<hide/>

import SwiftUI

//MARK: MODEL
struct PostModel: Identifiable, Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

//MARK: VIEWMODEL
class DownloadWithEscapingViewModel: ObservableObject {
    
    @Published var posts: [PostModel] = []
    
    init() {
        getPosts()
    }
    
    
    func getPosts() {
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return }
        
        downloadData(fromURL: url) { returnedData in
            if let data = returnedData {
                //newPost는 옵셔널 이기 때문에 guard
                guard let newPosts = try? JSONDecoder().decode([PostModel].self, from: data) else { return }
                DispatchQueue.main.async { [weak self] in
                    self?.posts = newPosts
                }
            } else {
                print("반환될 데이터가 존재하지 않습니다.")
            }
        }
    }
    
    func downloadData(fromURL url: URL, complectionHandler: @escaping (_ data: Data?) -> ()) {
        URLSession.shared.dataTask(with: url) { data, response, error in
            
            guard
                let data = data,
                error == nil,
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 300 else {
                    print("데이터 다운로드에 실패했습니다.")
                    complectionHandler(nil)
                    return
                }
            complectionHandler(data)
        }
        .resume()
        
    }
}

//MARK: BODY
struct DownloadWithEscaping: View {
    @StateObject var vm = DownloadWithEscapingViewModel()
    var body: some View {
        NavigationView {
            List {
                ForEach(vm.posts) { post in
                    VStack(alignment: .leading, spacing: 10) {
                        Text(post.title)
                            .font(Font.title.bold())
                        Text(post.body)
                            .foregroundColor(Color(UIColor.systemGray2))
                    }
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding()
                }
            }
            .navigationBarTitle("Fack API DATA")
            .listStyle(PlainListStyle())
        }
    }
}


//MARK: PREVIEWS
struct DownloadWithEscaping_Previews: PreviewProvider {
    static var previews: some View {
        DownloadWithEscaping()
    }
}

 

 

읽어주셔서 감사합니다🤟

 

본 게시글의 전체 코드 GitHub 👇🏻

 

Seogun95/DownloadWithEscaping

SwifUI_DownloadWithEscaping(JSON). Contribute to Seogun95/DownloadWithEscaping development by creating an account on GitHub.

github.com


잘못된 내용이 있으면 언제든 피드백 부탁드립니다.


서근


위처럼 이미지 와 함께 댓글을 작성할 수 있습니다.