2023년, iOS 개발을 독학하면서 만든 나의 첫 개인 프로젝트는 스토리보드 기반 Todo 리스트 앱
이었다. 이 때는 iOS 앱을 어떻게 만드는지 자체가 관심사였고, 동작 하도록 하는 게 유일한 학습목표였다. 당연히 아키텍처에 대한 개념은 아예 없었다. 그 케케묵은 프로젝트를 꺼내 먼지를 털고 열어보니 view model
이 싱글톤으로 구현되어 있었다. 이 말도 안되는 코드를 MVC
아키텍처를 적용하여 리팩토링 하다보니 큰 틀부터 아주 자잘한 코드까지 많은 부분들을 수정하게 되었지만 그 중에서도 manager
객체를 controller
에 주입하는 과정에서 새로 공부하고, 깨닫게 된 내용들이 있어 포스팅으로 정리해보려 한다.
우선 ViewModel
의 존재가 무색하게도 그 객체가 갖고 있는 코드는 전부 Todo
를 관리하는 코드였기 때문에, 소름 돋는 shared
선언부터 얼른 삭제한 뒤 ViewModel
을 TodoManager
로 바꾸었다. (적절한 객체 역할 분리에 대한 생각 자체가 없었다는 게 실감났다 - 이게 뭔지도 사실 몰랐다) 그리고 이 manager
객체를 controller
들에 주입하는 방법을 고민하기 시작했다.
스토리보드가 없는 코드 베이스 프로젝트였다면 애초에 이 고민은 없었을 것이다. 생성자를 통하면 되기 때문이다.
class MainViewController: UIViewController {
private var todoManager: TodoManager
init(todoManager: TodoManager) {
self.todoManager = TodoManager
super.init(nibName: nil, bundle: nil)
}
// custom init을 정의하고 나면 컴파일 에러를 통해 이 코드를 넣게 된 경험, 다들 해봤을 것이다.
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// ... //
}
하지만 스토리보드 베이스 프로젝트에서는 여기서 required init?
만 호출되고 커스텀 init
은 호출되지 않기 때문에 의존성 주입에 실패한다.
스토리보드 기반으로 ViewController
를 생성하는 코드는 아래와 같다.
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let viewController = storyboard.instantiateViewController(withIdentifier: "MainViewController")
여기서 "Main"
은 스토리보드 파일 이름이다. Main.storyboard
파일에 그려놓은 MainViewController
라는 identifier
를 가진 화면을 불러오는 것이다.
identifier는 인스펙터에서 지정/확인 가능하다.
instantiateViewController
메소드를 통해 뷰 컨트롤러가 생성되는 과정에서 내부적으로 required init?(coder: NSCoder)
을 호출한다. required
생성자가 무조건적으로 호출되도록 되어있기 때문에, 스토리보드 시스템은 다른 커스텀 init
을 모르게 된다고 한다. 그래서 절대로 호출되지 않는다.
storyboard
는view controller
들을직렬화된 형태
로 메모리에 저장했다가NSCoder
를 통해 복원한다고 한다. 그렇기 때문에 이 생성자가 뷰 컨트롤러에게 필수적(required
)인 것이다.required
키워드는 서브 클래스가 반드시 구현해야 하는 생성자를 의미한다. 상위 클래스인UIViewController
가 이 생성자를 필수로 지정했기 때문에 뷰 컨트롤러에서custom init
을 정의하면required init?(coder:)
을 구현하라고 컴파일 에러가 난다.
코드 기반 프로젝트에서는instantiateViewController
를 통해 뷰 컨트롤러를 생성하지 않기 때문에required init
이 호출될 일이 없다. 만약 코드 기반 프로젝트에서 저 코드로(스토리보드를 통해) 뷰 컨트롤러를 생성하면 (이건 잘못된 생성 방법이기 때문에) 코드대로fatalError
가 발생하게 된다.
또한 스토리보드 기반 뷰 컨트롤러에서required init?(coder: NSCoder)
을 정의할 일이 있다면,fatalError
가 아닌super.init(coder: coder)
로 구현해야 한다.instantiateViewController
을 거칠 때 호출될 것이고, 정상적인 상황에서 런타임 에러를 발생시키고 싶진 않기 때문이다.required init?(coder: NSCoder) { super.init(coder: coder) }
여기에서 말하는 방법이 전부는 아니지만, 간단한 방법 몇가지를 정리해보겠다.
class MainViewController: UIViewController {
var todoManager: TodoManager?
// ... //
}
// 1. 스토리보드 선언
let storyboard = UIStoryboard(name: "Main", bundle: nil)
// 2. 뷰 컨트롤러 생성
let viewController = storyboard.instantiateViewController(withIdentifier: "MainViewController")
// 3. 매니저 생성
let todoManager: TodoManager = .init()
// 4. 뷰 컨트롤러에 매니저 주입
viewController.todoManager = todoManager
이 방법은 사실 화면 전환 시 다음 화면으로 전달해야 하는 값이 있을 때 사용하는 방법이다. 하지만 매니저 객체를 주입할 때 추천되는 방법은 아닌 거 같다. 옵셔널이라 사용할 때 불편하기 때문이다. 옵셔널 바인딩을 한두번만 하고 쓸 값이라면 괜찮겠지만 여러번 쓰인다면 매번 바인딩하기도 귀찮고 코드가 길어질 것이다. 그리고 값 할당을 까먹어도 컴파일 에러가 발생하지 않기 때문에 주입이 안 된 채 사용 시 런타임 에러가 발생한다는 점도 주의해야 한다.
class MainViewController: UIViewController {
private var todoManager: TodoManager!
// 매니저 주입 메소드
func inject(todoManger: TodoManager) {
self.todoManager = todoManager
}
override func viewDidLoad() {
super.viewDidLoad()
// 주입이 안됐을 경우 crash 유도
guard todoManager != nil else {
fatalError("TodoManager has not been injected")
}
}
}
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let viewController = storyboard.instantiateViewController(withIdentifier: "MainViewController")
let todoManager: TodoManager = .init()
// 컨트롤러 생성하고 주입 메소드 호출
viewController.inject(todoManager: todoManager)
여기서 fatalError
를 발생시키는 이유는 개발 단계에서 개발자가 의존성 주입을 빠뜨렸을 때 빨리 알아차릴 수 있게 하기 위함이다.
팩토리 메소드 : 객체 생성 로직을 가진 메소드. (객체의 구체적인 생성 방식을 캡슐화)
의존성 주입을 한 뒤 뷰 컨트롤러를 반환하는 메소드를 static
으로 정의하여 뷰 컨트롤러 생성 시 사용하는 방법이다. (왜 static
이냐하면, 인스턴스가 없는 상태에서도 호출이 가능하기 때문이다)
class MainViewController: UIViewController {
private var todoManager: TodoManager!
// 팩토리 메소드 : 스토리보드 선언 및 컨트롤러 생성, 주입 로직을 다 갖는다.
static func instantiate(with todoManager: TodoManager) -> MainViewController {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let viewController: MainViewController = storyboard.instantiateViewController(withIdentifier: "MainViewController")
viewController.todoManager = todoManager
return viewController
}
}
let todoManager: TodoManager = .init()
// 팩토리 메소드를 통해 뷰 컨트롤러 생성
let viewController = MainViewController.instantiate(with: todoManager)
이 경우 주입을 까먹을 일은 없겠지만, 팩토리 메소드 사용 자체를 까먹을 경우 문제가 발생할 것이기 때문에 02)
에서와 같이 viewDidLoad
에서 fatalError
를 발생시키는 등 보완이 필요하다.
나의 경우 화면이 4개고 모든 화면에서 매니저를 사용하기 때문에 최대한 컴파일 단계에서 주입을 유도하는 방법을 찾고 싶었다. 그래서 protocol
을 활용하게 되었다. 또한 똑같은 내용의 생성 로직을 4번 작성하고 싶지 않아 extension
을 활용하여 팩토리 메소드를 구현하였다.
protocol TodoManagerInjectable {
func inject(todoManager: TodoManager)
}
class MainViewController: UIViewController, TodoManagerInjectable {
private var todoManager: TodoManager!
func inject(todoManager: TodoManager) {
self.todoManager = todoManager
}
}
UIViewController
의 생성 로직이기 때문에 UIViewController
의 extension
에 구현하는 게 맞지 않나?하는 생각이 들 수도 있지만, 화면 생성의 책임은 UIStoryboard
쪽에 있고 컨트롤러의 책임은 화면을 구성하는 쪽에 가깝기 때문에 책임 분리를 명확히 하고 싶다면 UIStoryboard
에 구현하는 것이 바람직하다.
그전에 identifier
를 하드코딩 하고 싶지 않아 UIViewController extension
을 통해 클래스 이름 그대로를 String
값으로 반환하는 프로퍼티를 구현했다.
extension UIViewController {
static var identifier: String {
// 클래스 이름 그대로 identifier를 갖도록 구현
String(describing: self)
}
}
extension UIStoryboard {
func instantiateViewController<T: UIViewController & TodoManagerInjectable>(with todoManager: TodoManager) -> T {
// 1. identifier를 통해 view controller 생성
guard let viewController = instantiateViewController(withIdentifier: T.identifier) as? T else {
fatalError("Could not instantiate \(T.self) with identifier '\(T.identifier)'")
}
// 2. 의존성 주입
viewController.inject(todoManager: todoManager)
// 3. 반환
return viewController
}
}
여기서 중요한 점은 이 메소드를 호출할 때 뷰 컨트롤러의 타입을 선언해야 하는 것이다. (T
가 뭔지 알려줘야 함)
모든 뷰 컨트롤러를 생성할 때마다 let storyboard = UIStoryboard(name: "Main", bundle: nil)
를 선언하기 귀찮아서 타입 프로퍼티로 구조체에 넣어버린 뒤 사용하였다.
struct Storyboard {
static let main: UIStoryboard = .init(name: "Main", bundle: nil)
}
SceneDelegate
에서 root view controller
를 선언하는 코드class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
// manager 생성
let todoManager: TodoManager = .init()
// ViewController 타입을 명시하며 instantiate
let rootViewController: MainViewController = Storyboard.main.instantiateViewController(todoManager: todoManager)
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UINavigationController(rootViewController: rootViewController)
window.makeKeyAndVisible()
self.window = window
}
// ... //
}
MainViewController
에서 다음 화면으로 이동하는 코드class MainViewController: UIController, TodoManagerInjectable {
private var todoManager: TodoManager!
// ... //
// + New List 버튼 tap 시 AddNewListViewController로 이동
@IBAction func AddNeweListButtonTapped(_ sender: UIButton) {
let addNewListViewController: AddNewListViewController = Storyboard.main.instantiateViewController(todoManager: todoManager)
self.navigationController?.pushViewController(addNewListViewController, animated: true)
}
}
지금까지 정리한 ViewController
생성, 의존성 주입 그리고 navigation
로직까지 Coordinator
객체에 맡기는 방법이다. 하지만 이게 필수적이지 않고, 그 정도로 내 프로젝트 규모가 크지 않기 때문에 직접 구현하는 것은 나중을 기약하기로 했다.
정말 오랜만에 스토리보드 프로젝트를 열어보았는데 예기치 않게 여러가지 개념들을 알게 되었다. 지금까지 컴파일 에러를 따라 무심코 추가했던 required init
에 대해서 제대로 짚어 본 계기가 되었고 Coordinator
패턴에 대해서도 공부할 여지를 열게 되어 재밌고 좋았다.