SwiftUI : @escaping과 함께 JSON Data 다운로드하기
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
함수 안에 인터넷을 사용하기 위한 코드 dataTask
의 completionHandler
을 사용하는데, 이 한줄의 코드로 인해 우리는 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
를 그냥 출력해보면 아무것도 출력되지 않습니다. 왜냐하면 Data
를 String
형식으로 변환시켜줘야 하죠.
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()
}
자, 여기까지 데이터를 확인 -> 오류 확인 -> 응답을 받고 -> 응답 상태를 확인 후 -> 데이터를 출력했습니다. 이제 해야 할 것은 getPosts
를 init
에 호출해주고 실제로 사용할 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
를 사용해야 합니다. 그리고 post
에 newPost
를 append
합니다.
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()
}
}
자, 요렇게 Model
과 ViewModel
의 함수까지 완성되었으니 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
에서 dataTask
는 url
을 전달하고, 데이터를 다시 가져옵니다. 그리고 만약 더 많은 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
를 보면 데이터에 데이터가 있고, error
가 nil
이 아니고, response
이 정확히 된다면 completionHandler
가 호출되도록 했습니다. 하지만 실패했다면 completionHandler
가 nil
이 되도록 해줘야겠죠? 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
아래에 호출해주는데 data
가 returnedData
면 newPost
를 실행하고 그게 아니라면 반환될 데이터가 존재하지 않는다는 문구를 출력해주도록 합니다.
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을 복사해주겠습니다. 그리고 이 주소로 수정해줍니다.
posts
의 JSON
구조를 보면 뭔가 달라진 것을 눈치채셨나요?
우리는 100개의 포스트 데이터를 사용할 것이기 때문에 PostModel
에 [ ] 배열을 사용해야 한다는 걸 알 수 있습니다.
밑줄 친 이 부분들을 수정해줘야 하는데, postModel
은 [PostModel]
로 수정, newPost
는 이제 여러 개의 post가 들어갈 것이기 때문에 newPosts
로 수정, newPost
를 append
하는 대신 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("반환될 데이터가 존재하지 않습니다.")
}
}
}
다시 시뮬레이터 실행 고고!
어? 배열이 이상하네요.. Body
의 VStack
부분을 수정해줘야겠어요.. 그리고 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 👇🏻