[iOS] storyboard 기반 프로젝트에서의 manager 객체 의존성 주입

Emily·2025년 7월 17일
0

2023년, iOS 개발을 독학하면서 만든 나의 첫 개인 프로젝트는 스토리보드 기반 Todo 리스트 앱이었다. 이 때는 iOS 앱을 어떻게 만드는지 자체가 관심사였고, 동작 하도록 하는 게 유일한 학습목표였다. 당연히 아키텍처에 대한 개념은 아예 없었다. 그 케케묵은 프로젝트를 꺼내 먼지를 털고 열어보니 view model이 싱글톤으로 구현되어 있었다. 이 말도 안되는 코드를 MVC 아키텍처를 적용하여 리팩토링 하다보니 큰 틀부터 아주 자잘한 코드까지 많은 부분들을 수정하게 되었지만 그 중에서도 manager 객체를 controller에 주입하는 과정에서 새로 공부하고, 깨닫게 된 내용들이 있어 포스팅으로 정리해보려 한다.

우선 ViewModel의 존재가 무색하게도 그 객체가 갖고 있는 코드는 전부 Todo를 관리하는 코드였기 때문에, 소름 돋는 shared 선언부터 얼른 삭제한 뒤 ViewModelTodoManager로 바꾸었다. (적절한 객체 역할 분리에 대한 생각 자체가 없었다는 게 실감났다 - 이게 뭔지도 사실 몰랐다) 그리고 이 manager 객체를 controller들에 주입하는 방법을 고민하기 시작했다.

Custom init 사용의 한계

스토리보드가 없는 코드 베이스 프로젝트였다면 애초에 이 고민은 없었을 것이다. 생성자를 통하면 되기 때문이다.

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은 호출되지 않기 때문에 의존성 주입에 실패한다.

왜 required 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을 모르게 된다고 한다. 그래서 절대로 호출되지 않는다.

storyboardview 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)
}

init말고 주입 방법

여기에서 말하는 방법이 전부는 아니지만, 간단한 방법 몇가지를 정리해보겠다.

01) optional로 선언하고 view controller 생성 후 주입하기

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

이 방법은 사실 화면 전환 시 다음 화면으로 전달해야 하는 값이 있을 때 사용하는 방법이다. 하지만 매니저 객체를 주입할 때 추천되는 방법은 아닌 거 같다. 옵셔널이라 사용할 때 불편하기 때문이다. 옵셔널 바인딩을 한두번만 하고 쓸 값이라면 괜찮겠지만 여러번 쓰인다면 매번 바인딩하기도 귀찮고 코드가 길어질 것이다. 그리고 값 할당을 까먹어도 컴파일 에러가 발생하지 않기 때문에 주입이 안 된 채 사용 시 런타임 에러가 발생한다는 점도 주의해야 한다.

02) non-optional로 선언하고 주입 메소드 호출하기

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를 발생시키는 이유는 개발 단계에서 개발자가 의존성 주입을 빠뜨렸을 때 빨리 알아차릴 수 있게 하기 위함이다.

03) 팩토리 메소드 사용하기

팩토리 메소드 : 객체 생성 로직을 가진 메소드. (객체의 구체적인 생성 방식을 캡슐화)

의존성 주입을 한 뒤 뷰 컨트롤러를 반환하는 메소드를 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를 발생시키는 등 보완이 필요하다.

내가 사용한 방법 : protocol과 팩토리 메소드 사용하기

나의 경우 화면이 4개고 모든 화면에서 매니저를 사용하기 때문에 최대한 컴파일 단계에서 주입을 유도하는 방법을 찾고 싶었다. 그래서 protocol을 활용하게 되었다. 또한 똑같은 내용의 생성 로직을 4번 작성하고 싶지 않아 extension을 활용하여 팩토리 메소드를 구현하였다.

01) Injectable 프로토콜을 정의하고 모든 컨트롤러에 채택시키기

protocol TodoManagerInjectable {
    func inject(todoManager: TodoManager)
}
class MainViewController: UIViewController, TodoManagerInjectable {
	private var todoManager: TodoManager!
    
    func inject(todoManager: TodoManager) {
        self.todoManager = todoManager
    }
}

02) UIStoryboard의 extension으로 팩토리 메소드 구현하기

UIViewController의 생성 로직이기 때문에 UIViewControllerextension에 구현하는 게 맞지 않나?하는 생각이 들 수도 있지만, 화면 생성의 책임은 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가 뭔지 알려줘야 함)

03) 활용하기

모든 뷰 컨트롤러를 생성할 때마다 let storyboard = UIStoryboard(name: "Main", bundle: nil)를 선언하기 귀찮아서 타입 프로퍼티로 구조체에 넣어버린 뒤 사용하였다.

struct Storyboard {
	static let main: UIStoryboard = .init(name: "Main", bundle: nil)
}
  1. 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
    }
    
    // ... //
}
  1. 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)
    }
}

더 좋은 방법 : Coordinator

지금까지 정리한 ViewController 생성, 의존성 주입 그리고 navigation 로직까지 Coordinator 객체에 맡기는 방법이다. 하지만 이게 필수적이지 않고, 그 정도로 내 프로젝트 규모가 크지 않기 때문에 직접 구현하는 것은 나중을 기약하기로 했다.


정말 오랜만에 스토리보드 프로젝트를 열어보았는데 예기치 않게 여러가지 개념들을 알게 되었다. 지금까지 컴파일 에러를 따라 무심코 추가했던 required init에 대해서 제대로 짚어 본 계기가 되었고 Coordinator 패턴에 대해서도 공부할 여지를 열게 되어 재밌고 좋았다.

profile
iOS Junior Developer

0개의 댓글