우선 이번에 만들어볼 실습은 이런녀석이다
그래서 필요한 코드는 크게 두가지로 나눌 수 있다
첫번째 : api를 호출해서 데이터를 넘겨주는 녀석
두번째 : tableView가들어있는 ViewController
그런데 조금 다른점이 있다면 이번에는 첫번째
녀석과 두번째
녀석을 모듈화를 해볼거다
모듈화란?
간단히 말하면 우리가 매번 import
해서 쓰는거처럼 특정한 함수들의 모음을 따로 빼놓고 쓰고싶은곳에서 import
를 해서 쓰는거다. 이럴때의 장점이라고 하면 사실 다른건 다 안와닿는데 한가지 와닿았던점은 우리가 매번 빌드를 시키면 모듈화가 안되어있으면 빌드할때마다 모든 코드를 다 빌드시켜야하지만 모듈화는 처음에만 빌드를 하면 이후에는 변경된 부분만 빌드를한다고 한다. 그러니까 프로젝트가 클수록 모듈화를 안하면 빌드할때마다 시간이 오래걸리게 된다...(물론 이런 특성때문에 클린빌드를할때는 평소보다 느리다고한다) 또다른 효과로는 코드안정성이 있는데 모듈간 결합도가 낮아진다 의존성을 낮추면 좋다 이런건데 뭐 사실 이런건 잘모르겠다. 근데 애초에 의존성을 낮추기 위한 방식인듯싶다 의존성을 낮췄을때 좋은 점은 저번 글에서 적었으니 그냥 이런 장점들이 있는 방식이다 정도만 알고 넘어가면 좋을거같다
우선 내가 만든 모듈은 총 두개 APIKit
과 CustomViewKit
이다 APIKit
은 api를 호출할 수 있는 싱글톤 객체를 가지고 있고 public으로 선언되어있어 다른 모듈에서 사용할 수 있다. CustomViewKit
는 CoursesViewController를 가지고있어서 Main모듈에서 버튼을 누르면 이 ViewController를 호출해서 띄워준다. 모듈화는 뭐 tuist
를 다들 사용한다고 하는데 나는 그냥 xcode에서 framework를 만들어줬다
이런식으로 사용하면 된다
우선 ApiCaller를 보면
import Foundation
public class APICaller {
public static let shared = APICaller()
private init() {}
public func fetchCourseNames(completion: @escaping(Data) -> Void) {
guard let url = URL(string: "https://iosacademy.io/api/v1/courses/index.php") else { return }
let task = URLSession.shared.dataTask(with: url) { data, _, error in
guard let data, error == nil else { return }
completion(data)
}
task.resume()
}
}
그리고 CoursesViewController를 보면
import UIKit
public protocol DataFetchable {
func fetchCourseNames(completion: @escaping(Data) -> Void)
}
struct Course: Codable {
let name: String
}
public class CoursesViewController: UIViewController {
let dataFetchable: DataFetchable
private let tableView: UITableView = {
let table = UITableView()
table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
return table
}()
public init(dataFetchable: DataFetchable) {
self.dataFetchable = dataFetchable
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var courses: [Course] = []
public override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
tableView.delegate = self
tableView.dataSource = self
view.backgroundColor = .systemBackground
dataFetchable.fetchCourseNames { [weak self] data in
guard let model = try? JSONDecoder().decode([Course].self, from: data) else { fatalError("decode오류") }
self?.courses = model
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
tableView.frame = view.bounds
}
}
이제부터 설명을 좀 해보자면 애초에 CoursesViewController
에서 의존성주입
을 위해 추상화객체(프로토콜)
에 의존한 객체를 내부에서 만들고 그걸 외부에서 넣어주는 방식으로 구현을 했다
import UIKit
import APIKit
import CustomViewKit
extension APICaller: DataFetchable {}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// https://iosacademy.io/api/v1/courses/index.php
let button = UIButton(frame: .init(x: 0, y: 0, width: 250, height: 50))
view.addSubview(button)
button.backgroundColor = .systemBlue
button.setTitle("Tap Me", for: .normal)
button.center = view.center
button.setTitleColor(.white, for: .normal)
button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
}
@objc func didTapButton() {
let vc = CoursesViewController(dataFetchable: APICaller.shared)
present(vc, animated: true)
}
}
근데 여기서 좀 거슬리는 녀석이 있는데 바로
extension APICaller: DataFetchable {}
이녀석이다 이녀석은 무슨녀석이냐면 어짜피 apiCaller에서도 저 함수가 그대로 있기때문에 extension으로 protocol을 채택해줘도 된다 즉, 애초에 저 apicaller를 설계할때 함수이름을 똑같이 해서 설계해야
let vc = CoursesViewController(dataFetchable: APICaller.shared)
저기 input에다가 apiCaller의 객체를 넣어줄수 있게된다
어쨋든 이렇게 하면 내부변수를 외부에서 객체를 생성해서 주입해주고 있고 각각의 객체가 구체적인 타입이 아니라 추상적인 것(프로토콜)에 의존하고 있기 때문에 DI
라고 할 수 있게 된다.
근데 이제 여기서 좀 문제가 발생했던 부분이 CoursesViewController
에서
dataFetchable.fetchCourseNames { [weak self] data in
guard let model = try? JSONDecoder().decode([Course].self, from: data) else { fatalError("decode오류") }
self?.courses = model
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
이부분이다... 사실 우리가 매번 api를 호출하면 우리가 원하는 구조체의 타입을 클로저에 담아서 보내주고 그걸 viewcontroller
에서는 사용만 하면되는데 여기서는 viewcontroller
에서 decode
까지 해줘하는 이부분이 너무나 어색해보였다
그런데 이렇게 할수밖에없었던이유(거기에는 나의 작은 오해(?)가 있기는 했다)
애초에 APICaller
에서 Course
라는 데이터타입으로 decoding을 하려면 import CustomViewKit
를 해야하는데 그러면 애초에 두 모듈이 서로 의존하게 되는거고 그러면 모듈화를 하는 의미가 없지 않을까??? 라는 생각이들었다.
그래서 생각한 첫번째 방식이 그러면애초에 APICaller에서 넘길수 있는건 Data밖에 없겠네...?
왜냐면 얘는 어떤데이터 형식일지를 애초에 알수가 없으니까...? 였다 그래서 저렇게 했다가 ViewController에서 decoding을 해야하는 방식이 탄생하게 되었다
여기서 좀 조언을 구하고 싶어서 sopt iOS팟짱님과 함께 wwdc 스터디를 함께하고 계시는 민재님에게 help를 요청해서 토론을 해봤다
우선 민재님이 이 이야기를 듣고 몇가지 레포를 확인해본결과
+ 팟짱님에게 받은 영상
을 확인해보니 모듈사이에 import를 하는 경우가 많아서 이 행위자체가 이상한거 같지는 않다는 의견과 애초에 import를 한다고 의존성이 생기지는 않을거같다라는 의견이 있었다. 근데 이것도 맞는말인게 의존성이라는게 애초에 객체를 주입해줘야 생기는건데 이건뭐 객체를 주입하는 행위가 아니니까 맞는말이었다.
그래서 첫번째 해결책은 그냥 APICaller
에 import CustomViewKit
를 해주는것
두번째 해결책은 민재님과 디스코드에서 코드를 분석하다가 갑자기 어...?이거 generic쓰면 되는거아닌가요?
라는 생각이 들어서 시작한 리팩터링이다. 이렇게 하면 애초에 주입하는 시점에 타입을 결정해줄수있어서 import CustomViewKit
를 해줄 필요가 없었다.
우선 APICaller쪽 구현부를 살펴보면
public class APICaller {
public static let shared = APICaller()
private init() {}
public func fetchCourseNames<T: Codable>(returnType: [T].Type, completion: @escaping([T]) -> Void) {
guard let url = URL(string: "https://iosacademy.io/api/v1/courses/index.php") else { return }
let task = URLSession.shared.dataTask(with: url) { data, _, error in
guard let data, error == nil else { return }
guard let model = try? JSONDecoder().decode(returnType.self, from: data) else { return }
completion(model)
}
task.resume()
}
}
자 여기서 어제 민재님 덕분에 알게된부분이있다 애초에 이쪽엔 함수가 처음에 이런모양이 아니라
public func fetchCourseNames<T: Codable>(completion: @escaping([T]) -> Void)
이런 모양이었다 하지만 이렇게 하면 문제가 생긴다.
애초에 함수에서 generic
을 사용하면 함수의 input에 T를 구체화할수있게 어떤 타입의 value
가 들어가야하는데 분명히 completion
이라는 input parameter로 T가 들어가는데 오류가뜨는거다... 그래서 왜 이럴까 계속 보고있는데 민재님이 타입을 추정할수있는 input을 넣어줘야할거같다고 하셔서 처음에는 사실 이해를 못했는데 설명을 들어보니 @escaping
키워드는 애초에 함수가 실행되는 시점이 아니라 특정 task가 끝난후에 호출되는 클로저인데 그 함수들이 실행되는 시점에 T를 추정할수있는 input이 없는거다 그래서 completion호출시점이 아니라 함수의 실행시점에 T를 구체화 할 수 있는 parameter
가 필요했던거다 그래서 저렇게 넣어주고
dataFetchable.fetchCourseNames(returnType: [Course].self) { models in
self.courses = models
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
여기서 returnType자체를 넣어주면 그 타입으로 가져오게 되고 그 Course
라는 타입자체를 이쪽 모듈에서 들고있으니 APICaller
에서 import
를 하지 않아줘도 되는거였다...
사실 어떤방법이 좋은줄은 모르겠다. 근데 두가지 방법다 괜찮은 방법일수도 있겠다라는 생각이들었다