Neoself의 기술 블로그

UIKit + MVC 아키텍처 패턴 사용기 본문

개발지식 정리/Swift

UIKit + MVC 아키텍처 패턴 사용기

Neoself 2024. 10. 24. 18:56

최근에 UIKit + MVC 아키텍처 패턴으로 작은 프로젝트를 만들게 되었습니다. 이를 만들어보면서 고민했던 아키텍처 구성 과정을 적어보았습니다.

MVC는 뭐죠??

MVC 패턴의 사전적 의미를 먼저 보겠습니다.

디자인 패턴중 하나로, Model, View, Controller의 약자입니다. 사용자가 Controller를 조작하면, Controller는 Model 레이어를 통해서 데이터를 가져오고, 그 정보를 바탕으로 View를 업데이트하는 과정을 통해 앱이 동작하는 구조라고 이해하면 됩니다.

 

Model: 애플리케이션의 정보, 데이터를 처리하는 레이어

데이터베이스 혹은 Core Data 프레임워크를 활용한 데이터 처리 CRUD 메서드와 같이 정보들의 가공을 책임지는 컴포넌트가 이에 해당됩니다.

View: 앱 사용자가 상호작용할 수 있는 컴포넌트입니다. 데이터의 입력과 출력을 담당합니다.

Controller: 뷰로부터 전달받은 이벤트들을 처리하는 등, 데이터와 사용자 인터페이스 요소를 잇는 다리 역할을 합니다. 


 

제가 최근 진행했던 미니 프로젝트의 코드를 예시로 설명드리겠습니다.

먼저 Model Layer입니다.

Model Layer

// Data Model
class JGDD_MO: NSManagedObject {
    @NSManaged public var greeting: String?
    @NSManaged public var id: UUID?
    @NSManaged public var image: String?
    @NSManaged public var mbti: String?
    @NSManaged public var name: String?
}

// Business Logic
class ProfileManager {
    static let shared = ProfileManager()
    private let context: NSManagedObjectContext
    
    func createProfile(...) throws -> JGDD_MO
    func fetchProfiles() throws -> [JGDD_MO]
    func updateProfile(...) throws
    func deleteProfile(...) throws
}

 

 

해당 프로젝트의 경우, 오프라인에서도 정보를 볼 수 있어야 했기 때문에 Core Data 프레임워크를 통해 관리하고, 저장하도록 구성했는데요. 때문에 데이터를 담는 객체인 JGDD_MO(JGDD는 전기도둑이라는 저희 팀의 약자입니다...)를 정의해준 후에, Core Data를 활용한 데이터 처리(CRUD) 메서드들을 ProfileManager라는 싱글톤 클래스에 선언하였습니다. 이처럼 앱 내부에서 관리되는 데이터 처리 메서드와 데이터 객체구조가 Model layer에 속한다고 볼 수 있습니다.

 

View Layer

class CreateMemberView: UIView {
    private let profileImageButton = ProfileImageButton()
    private let nameTextField = CustomTextField()
    private let mbtiButton = MbtiButton()
    
    private func setupUI() { ... }
    private func setConstraints() { ... }
}

다음은 UIKit 기반의 View 레이어입니다. 확실히 SwiftUI에 비해서는 각 UI컴포넌트들의 속성 설정으로 인해 코드량이 많았었는데요. 때문에 자주 사용되는 TextField, MbtiButton 컴포넌트들을 아래와 같이 별도의 클래스로 분리해준 후에, 인스턴스 프로퍼티로 선언 및 하여 코드의 가독성과 Boilerplate를 줄이고자 했습니다.

import UIKit

final class MbtiButton: UIButton {
    private var _isTapped: Bool = false
    var delegate: MbtiButtonDelegate?
    
    var isTapped: Bool {
        get {
            return _isTapped
        }
        set {
            _isTapped = newValue
            self.backgroundColor = _isTapped ? .gray : .clear
        }
    }
    
    init(_ title: String) {
        super.init(frame: .zero)
        
        setup(title)
        setAddTarget()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setup(_ title: String) {
        self.setTitle(title, for: .normal)
        self.backgroundColor = .clear
        self.layer.cornerRadius = 5
        self.layer.borderWidth = 1
        self.layer.borderColor = #colorLiteral(red: 0.2, green: 0.2, blue: 0.2, alpha: 1)
        self.setTitleColor(.black, for: .normal)
    }
    
    private func setAddTarget() {
        self.addTarget(self, action: #selector(mbtiButtonTapped), for: .touchUpInside)
    }
    
    @objc private func mbtiButtonTapped() {
        delegate?.mbtiButtonTapped(self)
    }
}

 

 

View Controller

ViewController 레이어에서는 앞서 데이터 모델 레이어로 설명드린 ProfileManager를 접근해 영구 저장소로부터 데이터를 가져오는 로직과 View의 UI 속성들을 명시적으로 변경해 업데이트해주는 등, 비즈니스 로직을 담고자 했습니다.


class MainViewController: UIViewController {
    ...
    private func loadProfiles() {
        do {
            let profiles = try ProfileManager.shared.fetchProfiles()
            updateUI(isEmpty: profiles.isEmpty)
            tableView.reloadData()
        } catch {
            showError(message: "프로필을 불러오는데 실패했습니다")
        }
    }

    private func updateUI(isEmpty: Bool) {
        if isEmpty {
            placeholderLabel.isHidden = false
            tableView.isHidden = true
        } else {
            placeholderLabel.isHidden = true
            tableView.isHidden = false
        }
    }   
}

 

하지만, 상황에 따라 사용자와의 상호작용이 많이 필요하지 않다는 점, 즉 정적인 데이터만을 띄우는 뷰에서는 View와 ViewController가 꼭 분리될 필요는 없습니다. 

import UIKit

final class ProfileDetailViewController: UIViewController {
    private let profile: JGDD_MO
    
    private let profileImageView: UIImageView = {...}()
    private let nameLabel: TitleLabel = {...}()
    private let nameValueLabel: UILabel = {...}()
    ...
    
    init(profile: JGDD_MO) {
        self.profile = profile
        super.init(nibName: nil, bundle: nil)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        configureProfile() 
    }
    
    // MARK: - UI Setup
    private func setupUI() {
        view.backgroundColor = .white
        title = "프로필 상세"
        
        let stackView = CustomStackView(spacing: 20)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.alignment = .center  // 이미지를 중앙 정렬하기 위해
        ...
        view.addSubview(stackView)
        
        NSLayoutConstraint.activate([
            ...
        ])
    }
    
    // 불러온 String 타입의 이미지이름으로 렌더 이미지 결정짓는 메서드
    private func configureProfile() {
        if let imageName = profile.image {
            profileImageView.image = UIImage(named: imageName)
        } else {
            profileImageView.image = UIImage(systemName: "person.circle.fill")
        }
        
        nameValueLabel.text = profile.name
        mbtiValueLabel.text = profile.mbti
        greetingValueLabel.text = profile.greeting
    }
}

 

ProfileDetailView의 경우, 데이터 모델 레이어로부터 불러온 데이터를 단순히 화면에 표시하는 로직밖에 없었으며, 별도로 화면을 업데이트해야할 필요가 없었기에, setupUI함수 외에는 뷰와 유기적으로 연결되는 메서드가 없었습니다. 이러한 이유로 인해 상황에 따라 View 분리 처리를 유동적으로 진행하였습니다. 만약에 View와 ViewController를 분리해야할 경우에는 다음과 같이 서로를 약하게 참조하여 뷰 업데이트 및 사용자 이벤트 수신이 가능토록 처리가 필요합니다.

// 뷰 레이어
final class CreateMemberView: UIView {
    private weak var viewController: CreateMemberViewController?
    ...
    // configure함수를 통해 전달받은 뷰 컨트롤러를 약하게 참조
    // 약하게 참조하기 때문에, 두 객체 중 하나가 메모리에서 해제되면, 순환참조되지 않고 같이 해제됨
    func configure(with viewController: CreateMemberViewController) {
        self.viewController = viewController
    }
    
    ...
    @objc private func completeButtonTapped() {
        ...
        // 뷰 컨트롤러 내부 함수 직접 호출
        viewController?.createMember(image: image, name: name, greeting: greeting, mbti: mbti)
    }
}

// 뷰 컨트롤러 레이어
final class CreateMemberViewController: UIViewController {
    
    private let createMemberView = CreateMemberView() // 뷰 인스턴스 생성
    weak var delegate: CreateMemberDelegate?
    
    override func loadView() {
        view = createMemberView
    }
    
    override func viewDidLoad() {
        createMemberView.configure(with: self)  
        // 인스턴스 내부 configure 함수통해 자신(View Controller)을 뷰 레이어로 전달
    }
    ...
}

 

사실 말로만 들었을 때는, 이해가 잘 되지 않았었는데요. MVC를 직접 구현하고 UIKit를 조합해보면서, 보다 명확하게 생각이 정리될 수 있었습니다.

 

다음에는 MVC 패턴을 구성하며, 굉장히 머리를 싸맸던 Delegate 개념을 다뤄보도록 하겠습니다.

 

감사합니다.