[SwiftUI] HTTP Request로 가져온 서버 데이터를 MVVM 패턴에 따라 앱에 적용하기

Laav·2022년 3월 23일
2

SwiftUI

목록 보기
4/9
post-thumbnail

지난 포스팅에서, MVVM 패턴이 무엇인지와 어떻게 실제 앱에 적용되는지, 그리고 Model, View, View Model이 각각 어떤 역할을 하는지를 알아보았다.

그럼 이번에는, 실제 프로젝트에서 HTTP Request 중 GET 메소드를 통해 서버에서 데이터를 받아오고, 받아온 데이터를 MVVM 패턴에 맞추어 화면에 나타내보도록 하겠다.

MVVM 패턴에 대한 이해가 부족하다면, 아래 포스팅을 참고하길 바란다.

MVVM 패턴에 대해


💻 사전 설정

HTTP Request 중 GET 메소드를 사용해볼 것이다.

HTTP Request를 서버로 보내기 전에, 프로젝트에 몇 가지 설정을 해주었다.
참고로, 프로젝트 구조를 말할 때의 'Root'는 프로젝트의 루트폴더를 의미한다.

- 프로젝트 내 인터넷 사용 설정 (Root/Info.plist)

앱 내에서 인터넷을 사용하기 위한 설정이다.

  1. 프로젝트 폴더의 Info.plist로 들어가서 Information Property List에 추가 버튼(+)을 클릭해서 새로운 설정 아이템을 하나 만든다.
  2. 만든 설정 아이템의 이름을 "App Transport Security Settings"로 바꿔주고, 이 아이템에서 추가 버튼(+)을 클릭해 해당 아이템의 하위로 설정 아이템을 하나 더 만든다.
  3. 2번에서 만든 설정 아이템의 이름을 "Allow Arbitrary Loads" 로, Type은 "Boolean" 으로 바꿔주고 값을 "YES" 로 바꿔준다.


위와 같이 설정이 되면 프로젝트, 즉 개발중인 앱 내에서 인터넷을 사용할 수 있다.

- Root/Utils/Constants.swift

상수를 저장하기 위한 파일이다.

필자 같은 경우에는 통신할 서버의 IP 주소와 Port 번호를 저장했다.

import Foundation

struct Constants {
    static let IP_ADDRESS = "Your_IP_Address"
    static let PORT_NUM = "8080"
}

이후 IP 주소나 Port 번호에 변동이 생기더라도, Constants.swift 내에서 상수값만 변경해주면 된다.

- Root/Extensions/URLExtensions.swift

필자는 서버에서 게임 데이터를 받아올 것인데, 각 게임의 이름을 파라미터로 전달하면 해당 게임이 저장되어 있는 서버의 URL을 리턴하는 함수를 생성했다.

IP 주소와 Port 번호는 위에서 생성한 Constants.swift에서 참조하여 사용한다.

import Foundation

extension URL {
    
    static func forRandomGameByName(_ gameName: String) -> URL? {
        return URL(string: "http://\(Constants.IP_ADDRESS):\(Constants.PORT_NUM)/games/\(gameName)/random")
    }
    
    static func forGameByNameAndID(_ gameName: String, _ id: String) -> URL? {
        return URL(string: "http://\(Constants.IP_ADDRESS):\(Constants.PORT_NUM)/games/\(gameName)/\(id)")
    }
    
}

무작위로 하나의 게임 데이터를 받아오는 쿼리와 관련된 URL 하나와 게임 ID를 통해 특정 게임 데이터를 받아오는 쿼리와 관련된 URL 하나, 총 두 개가 포함되어 있다.


💻 Model 생성

- Root/Models/GameModel.swift

사전 설정이 끝났다면, 이제 M-V-VM 패턴 중 Model을 생성해줄 것이다.
Model을 생성할 때는 받아올 JSON 데이터의 포맷에 맞춰주면 된다.

필자가 받아올 데이터는 다음과 같이 되어 있다.

{
    "id": 0,
    "solution1": "Blah Blah",
    "solution2": "Blah Blah"
}

해당 데이터 포맷에 맞추어, 'BalanceGame' 이라는 구조체를 'Codable' 로 선언해주었다.

Codable로 선언함으로써, 서버에는 각각 'solution1'과 'solution2'로 저장되어 있지만 프로젝트 내에서는 'firstChoice', 'secondChoice' 라는 변수명으로 사용할 수 있다.
내가 사용할 변수명을 String 으로, 서버에서의 변수명을 CodingKey로 지정하여 사용해주면 되는데, 여기서 주의할 점은 CodingKey를 작성할 때 서버에서 사용하는 명칭과 대소문자를 포함하여 완벽하게 같아야 한다 는 점이다. 여기서 조금이라도 다르게 설정한다면 이후 HTTP REQUEST 에서 ERROR를 맞이하게 될 것이다.

import Foundation

struct BalanceGame: Codable {
    let id: Int
    let firstChoice: String
    let secondChoice: String
    
    private enum CodingKeys: String, CodingKey {
        case id = "id"
        case firstChoice = "solution1"
        case secondChoice = "solution2"
    }
    
}

💻 HTTP Request 생성

- Root/Services/HTTPClient.swift

Model을 생성해준 뒤, View Model을 생성하기 전에 서버에 보낼 HTTP Request 코드를 작성해보자.
처음 HTTP Request 코드를 봤을 때, 도대체 뭐가 뭔지 하나도 몰랐다...
한 번 차근차근 살펴보도록 하자 ㅎ

먼저, 코드는 다음과 같다.

import Foundation

// 1
enum NetworkError: Error {
    case badURL
    case noData
    case decodingError
}
// 2
class HTTPClient {
	// 3
	func getBalanceGame(completion: @escaping (Result<BalanceGame, NetworkError>) -> Void) {
        // 4
        guard let url = URL.forRandomGameByName("balance-game") else {
            return completion(.failure(.badURL))
        }
        // 5
        URLSession.shared.dataTask(with: url) { data, response, error in
            // 6
            guard let data = data, error == nil else {
                return completion(.failure(.noData))
            }
            // 7
            guard let balanceGame = try? JSONDecoder().decode(BalanceGame.self, from: data) else {
                return completion(.failure(.decodingError))
            }
            // 8
            completion(.success(balanceGame))
            
        }.resume()
        
    }
}

Task 별로 순서대로 살펴보자.

// 1
우선 Error를 enum 값으로 정의해준다. 이를 통해 추후 에러가 발생할 시 어떤 에러가 발생했는지 바로 알 수 있을 것이다.

// 2
그 후 'HTTPClient'라는 class 안에 'getBalanceGame' 이라는 함수를 만들어준다.

// 3
"@escaping" 이라는 문구는 비동기식 프로그래밍을 의미하며, Flutter의 Async/await 같은 개념이라고 보면 된다. 함수가 종료되면 Result에 성공시 BalanceGame, 즉 JSON 데이터가 파싱되어 들어간 Model을 반환할 것이며 실패시 에러를 반환할 것이라는 의미로 이해하면 된다.

// 4
"url"은 위에서 설정했던 URLExtension 에서 가져오며, 밸런스게임의 데이터를 받을 것이기 때문에 알맞은 인자를 전달하면 된다. 여기서 에러가 난다면 에러 중 badURL을 반환하면 될 것이다.

// 5, 6
다음으로 Foundation 헤더에서 제공하는 URLSession의 dataTask 메소드를 사용한다.
dataTask에 url을 인자로 전달하고, 데이터가 없다면 에러 중 noData를 반환하는 코드를 넣어준다.

// 7
이제 JSONDecoder를 사용하여 받아온 JSON 값을 파싱하고, 파싱된 데이터를 'BalanceGame', 즉 Model에 담아줄 것이다. dataTask에서 나온 data를 사용하며 여기서 에러가 발생할 시 에러 중 decodingError를 반환한다.

// 8
7번 Task까지 성공적으로 완료된다면, success를 디코딩 반환값과 함께 반환하면 끝이다!

이렇게 짜여진 HTTP Request(GET) 함수는 View Model에서 사용하게 될 것이다.

💻 View Model 생성

- Root/View_Models/GameViewModels/BalanceGameViewModel.swift

이제 MVVM 패턴의 하이라이트라고 볼 수 있는 View Model을 생성하고, 그 안에서 HTTP Request를 통해 Model의 값을 불러올 것이다.

MVVM 패턴에 대한 지난 포스팅에서 언급했던 ObservableObjectBalanceGameViewModel 이라는 View Model을 생성하고, balance라는 변수로 Model 객체를 nullable로 선언한다(처음에는 아무것도 들어가 있지 않으므로).

그 후 HTTPClient 객체를 선언하고, 위에서 작성한 getBalanceGame() 함수를 통해 데이터를 요청한다.

데이터를 받아오는 것이 실패했다면 에러를 출력할 것이고, 만약 데이터를 성공적으로 받아왔다면 파싱된 데이터를 Model 객체 내에 넣어주면 된다.

마지막으로 View Model 내에서 Model의 변수 값들을 받아오는 id, firstChoice, secondChoice 라는 친구들을 만들어준다.

코드는 다음과 같다.

import Foundation
import SwiftUI

class BalanceGameViewModel: ObservableObject {
    
    @Published var balance: BalanceGame?
    var httpClient = HTTPClient()
    
    init(balance: BalanceGame? = nil) {
        self.balance = balance
    }
    
    func getBalanceGameRandomly() {
        httpClient.getBalanceGame() { result in
            switch result {
                case .success(let game):
                    DispatchQueue.main.async {
                        self.balance = game
                    }
                
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
    }
    
    var id: Int {
        self.balance?.id ?? 0
    }
    
    var firstChoice: String {
        self.balance?.firstChoice ?? ""
    }
    
    var secondChoice: String {
        self.balance?.secondChoice ?? ""
    }
    
}

이제 View Model에서 서버에 요청하여 받아온, 그리고 Model에 저장된 값들을 View에서 사용하면 되는 것이다!

💻 View 생성

- Root/Screens/Game/BalanceGameScreen.swift(일부)

이제 View를 생성하고, View 내에서 View Model 객체를 다음과 같이 선언해준 뒤, init() 함수를 이용하여 View Model 객체에서 HTTP Request를 보낸다.

@ObservedObject private var balanceGameVM: BalanceGameViewModel
    
init() {
    self.balanceGameVM = BalanceGameViewModel()
    balanceGameVM.getBalanceGameRandomly()
}

이제 Model에는 서버에서 받아온 데이터가 들어갔을 것이고, ObservedObject로 선언된 View Model 안에도 View에서 필요한 데이터들을 불러왔을 것이다.

그렇다면, 이제 해당 데이터를 사용하여 화면에 띄워보도록 하자.

화면에서 어떻게 활용하는지는 물론 자유지만, 필자의 경우에는 아래와 같이 데이터를 View에 띄우게 되었다.

Text(self.balanceGameVM.firstChoice)
	.
    .
    .

View 내에서 선언한 View Model 객체 내의, 위에서 선언한 Model의 데이터를 받아온 변수 친구들을 자유롭게 사용하면 된다.


💻 끝: 프로젝트 구조

마지막으로, 전체 프로젝트 구조는 아래와 같이 되었다.

Screen이 M-V-VM 패턴에서 View 역할을 하고 있으며, 화면을 구성하는 View 폴더가 따로 존재하지만 MVVM 패턴과의 혼동을 피하기 위해 스크린샷에는 넣지 않았다.

📌 후기 & 마치며...

위에서도 잠시 언급했지만, HTTP Request 코드를 처음 봤을 때는 도저히 이해가 가지 않았었지만, 차근차근 한줄씩 공부해보니 생각보다 어렵지 않게 배울 수 있었다.

지금은 GET 메소드만 사용했지만, 추후 CRUD를 모두 할 수 있게 POST, UPDATE 등의 메소드를 사용해볼 것이고 기본적인 URLSession으로 공부해본 뒤 강력한 HTTP 통신 라이브러리인 Alamofire를 활용해볼 것이다.

알아갈수록, SwiftUI가 재밌어진다. 아직 많이 부족하지만, 이 글을 보는 개발자분들이 조금이나마 도움을 얻어갔으면 한다.

포스팅 내용 중 틀린 부분이나 부족한 부분이 있다면, 댓글 환영합니다 (_ _)

printf("Thank You!\n");
printf("Posted by Thirsty Developer\n");
profile
iOS 왕초보

0개의 댓글