์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐ŸŽจ iOS ๋””์ž์ธ ํŒจํ„ด๊ณผ MVC, MVVM, Coordinator ์•„ํ‚คํ…์ฒ˜ ์™„๋ฒฝ ๊ฐ€์ด๋“œ

๐ŸŽจ iOS ๋””์ž์ธ ํŒจํ„ด๊ณผ MVC, MVVM, Coordinator ์•„ํ‚คํ…์ฒ˜ ์™„๋ฒฝ ๊ฐ€์ด๋“œ

์นœ๊ตฌ์ฒ˜๋Ÿผ ์‰ฝ๊ฒŒ ๋ฐฐ์šฐ๋Š” iOS ์•ฑ ์„ค๊ณ„์˜ ๋ชจ๋“  ๊ฒƒ

์•ˆ๋…•! ๐Ÿ‘‹ iOS ๊ฐœ๋ฐœ์„ ์‹œ์ž‘ํ•˜๋ฉด์„œ ๊ฐ€์žฅ ๋งŽ์ด ๋“ฃ๋Š” ์งˆ๋ฌธ ์ค‘ ํ•˜๋‚˜๊ฐ€ ๋ฐ”๋กœ "์–ด๋–ค ์•„ํ‚คํ…์ฒ˜๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•˜๋‚˜์š”?"์ผ ๊ฑฐ์•ผ. MVC, MVVM, Coordinator... ์ด๋Ÿฐ ์šฉ์–ด๋“ค์ด ์ฒ˜์Œ์—” ์ •๋ง ์–ด๋ ต๊ฒŒ ๋А๊ปด์ง€์ง€? ํ•˜์ง€๋งŒ ๊ฑฑ์ • ๋งˆ! ์˜ค๋Š˜์€ ์นœ๊ตฌ์—๊ฒŒ ์„ค๋ช…ํ•˜๋“ฏ์ด ์‰ฝ๊ณ  ์žฌ๋ฏธ์žˆ๊ฒŒ, ๊ทธ๋Ÿฌ๋ฉด์„œ๋„ ์‹ค๋ฌด์—์„œ ๋ฐ”๋กœ ์“ธ ์ˆ˜ ์žˆ๋Š” ์ •ํ™•ํ•œ ๋‚ด์šฉ์œผ๋กœ iOS ๋””์ž์ธ ํŒจํ„ด๊ณผ ์•„ํ‚คํ…์ฒ˜์— ๋Œ€ํ•ด ์•Œ์•„๋ณผ ๊ฑฐ์•ผ. ๐Ÿš€

MVC MVVM Coordinator Model View Controller iOS ์•„ํ‚คํ…์ฒ˜์˜ ์„ธ๊ณ„

๐Ÿ—๏ธ ์™œ ์•„ํ‚คํ…์ฒ˜๊ฐ€ ์ค‘์š”ํ• ๊นŒ?

์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๋‹ค ๋ณด๋ฉด ์ฒ˜์Œ์—” ๊ฐ„๋‹จํ–ˆ๋˜ ํ”„๋กœ์ ํŠธ๊ฐ€ ์ ์  ๋ณต์žกํ•ด์ง€๋Š” ๊ฑธ ๊ฒฝํ—˜ํ•˜๊ฒŒ ๋ผ. ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์–ด๋””๋ฅผ ์ˆ˜์ •ํ•ด์•ผ ํ• ์ง€ ๋ง‰๋ง‰ํ•˜๊ณ , ๋ฒ„๊ทธ๋ฅผ ๊ณ ์น˜๋ ค๋‹ค ๋‹ค๋ฅธ ๊ณณ์—์„œ ์ƒˆ๋กœ์šด ๋ฒ„๊ทธ๊ฐ€ ์ƒ๊ธฐ๊ธฐ๋„ ํ•˜์ง€. ๐Ÿ˜ฐ

์ด๋Ÿฐ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๋“ฑ์žฅํ•œ ๊ฒŒ ๋ฐ”๋กœ ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด์ด์•ผ. ์•„ํ‚คํ…์ฒ˜๋Š” ๊ฑด๋ฌผ์„ ์ง€์„ ๋•Œ ์„ค๊ณ„๋„๊ฐ€ ํ•„์š”ํ•œ ๊ฒƒ์ฒ˜๋Ÿผ, ์•ฑ์„ ๋งŒ๋“ค ๋•Œ๋„ ์ฒด๊ณ„์ ์ธ ๊ตฌ์กฐ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋Š” ์ฒ ํ•™์—์„œ ์ถœ๋ฐœํ–ˆ์–ด.

๐ŸŽฏ ์ข‹์€ ์•„ํ‚คํ…์ฒ˜์˜ ์กฐ๊ฑด

1. ์œ ์ง€๋ณด์ˆ˜์„ฑ - ์ฝ”๋“œ๋ฅผ ์‰ฝ๊ฒŒ ์ˆ˜์ •ํ•˜๊ณ  ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ด
2. ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ - ๊ฐ ๋ถ€๋ถ„์„ ๋…๋ฆฝ์ ์œผ๋กœ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ด
3. ํ™•์žฅ์„ฑ - ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•˜๊ธฐ ์‰ฌ์›Œ์•ผ ํ•ด
4. ๊ฐ€๋…์„ฑ - ๋‹ค๋ฅธ ๊ฐœ๋ฐœ์ž๊ฐ€ ๋ด๋„ ์ดํ•ดํ•˜๊ธฐ ์‰ฌ์›Œ์•ผ ํ•ด
5. ์žฌ์‚ฌ์šฉ์„ฑ - ์ฝ”๋“œ๋ฅผ ๋‹ค๋ฅธ ๊ณณ์—์„œ๋„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ด

๐Ÿ“ฑ MVC: Apple์ด ์ œ์•ˆํ•œ ๊ธฐ๋ณธ ํŒจํ„ด

MVC๋Š” Model-View-Controller์˜ ์•ฝ์ž๋กœ, Apple์ด iOS ๊ฐœ๋ฐœ์˜ ๊ธฐ๋ณธ ํŒจํ„ด์œผ๋กœ ์ œ์•ˆํ•œ ์•„ํ‚คํ…์ฒ˜์•ผ. ๊ฐ€์žฅ ์˜ค๋ž˜๋˜๊ณ  ๋„๋ฆฌ ์•Œ๋ ค์ง„ ํŒจํ„ด์ด์ง€๋งŒ, iOS์—์„œ์˜ MVC๋Š” ๋‹ค๋ฅธ ํ”Œ๋žซํผ์˜ MVC์™€๋Š” ์กฐ๊ธˆ ๋‹ค๋ฅธ ํŠน์ง•์„ ๊ฐ€์ง€๊ณ  ์žˆ์–ด. ๐ŸŽ

๐Ÿ” MVC์˜ ์„ธ ๊ฐ€์ง€ ๊ตฌ์„ฑ ์š”์†Œ

Model (๋ชจ๋ธ)
์•ฑ์˜ ๋ฐ์ดํ„ฐ์™€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๋‹ด๋‹นํ•ด. ์‚ฌ์šฉ์ž ์ •๋ณด, ๊ฒŒ์‹œ๊ธ€ ๋ฐ์ดํ„ฐ, ๋„คํŠธ์›Œํฌ ์š”์ฒญ ๊ฒฐ๊ณผ ๋“ฑ ์•ฑ์—์„œ ๋‹ค๋ฃจ๋Š” ๋ชจ๋“  ๋ฐ์ดํ„ฐ๊ฐ€ ์—ฌ๊ธฐ์— ์†ํ•ด. Model์€ View๋‚˜ Controller์— ๋Œ€ํ•ด ์ „ํ˜€ ์•Œ์ง€ ๋ชปํ•˜๊ณ , ์ˆœ์ˆ˜ํ•˜๊ฒŒ ๋ฐ์ดํ„ฐ๋งŒ ๊ด€๋ฆฌํ•ด.

View (๋ทฐ)
์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์—ฌ์ง€๋Š” UI๋ฅผ ๋‹ด๋‹นํ•ด. UILabel, UIButton, UITableView ๊ฐ™์€ ๋ชจ๋“  ์‹œ๊ฐ์  ์š”์†Œ๋“ค์ด ์—ฌ๊ธฐ์— ํ•ด๋‹น๋ผ. View๋Š” ์‚ฌ์šฉ์ž์˜ ์ž…๋ ฅ์„ ๋ฐ›๊ณ  ํ™”๋ฉด์— ์ •๋ณด๋ฅผ ํ‘œ์‹œํ•˜์ง€๋งŒ, ๋ฐ์ดํ„ฐ๋ฅผ ์ง์ ‘ ์ฒ˜๋ฆฌํ•˜์ง€๋Š” ์•Š์•„.

Controller (์ปจํŠธ๋กค๋Ÿฌ)
Model๊ณผ View ์‚ฌ์ด์˜ ์ค‘์žฌ์ž ์—ญํ• ์„ ํ•ด. UIViewController๊ฐ€ ๋ฐ”๋กœ ์ด ์—ญํ• ์„ ๋‹ด๋‹นํ•˜์ง€. ์‚ฌ์šฉ์ž์˜ ์ž…๋ ฅ์„ ๋ฐ›์•„ Model์„ ์—…๋ฐ์ดํŠธํ•˜๊ณ , Model์˜ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ View์— ๋ฐ˜์˜ํ•˜๋Š” ๋ชจ๋“  ๋กœ์ง์ด ์—ฌ๊ธฐ์— ๋“ค์–ด๊ฐ€.

๐Ÿ’ก ์‹ค์ „ ์˜ˆ์ œ: ๊ฐ„๋‹จํ•œ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ํ™”๋ฉด

์‚ฌ์šฉ์ž์˜ ์ด๋ฆ„๊ณผ ์ด๋ฉ”์ผ์„ ๋ณด์—ฌ์ฃผ๋Š” ๊ฐ„๋‹จํ•œ ํ™”๋ฉด์„ MVC๋กœ ๊ตฌํ˜„ํ•ด๋ณด์ž!

Model - User.swift
struct User {
    let name: String
    let email: String
    let profileImageURL: String?
    
    // ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง
    func isValidEmail() -> Bool {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegex)
        return emailPredicate.evaluate(with: email)
    }
}
View - ProfileView.swift
class ProfileView: UIView {
    let nameLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 24, weight: .bold)
        label.textColor = .black
        return label
    }()
    
    let emailLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 16)
        label.textColor = .gray
        return label
    }()
    
    let profileImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.layer.cornerRadius = 50
        return imageView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupUI() {
        addSubview(profileImageView)
        addSubview(nameLabel)
        addSubview(emailLabel)
        
        // Auto Layout ์„ค์ • (๊ฐ„๋žตํ™”)
        profileImageView.translatesAutoresizingMaskIntoConstraints = false
        nameLabel.translatesAutoresizingMaskIntoConstraints = false
        emailLabel.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            profileImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
            profileImageView.topAnchor.constraint(equalTo: topAnchor, constant: 40),
            profileImageView.widthAnchor.constraint(equalToConstant: 100),
            profileImageView.heightAnchor.constraint(equalToConstant: 100),
            
            nameLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
            nameLabel.topAnchor.constraint(equalTo: profileImageView.bottomAnchor, constant: 20),
            
            emailLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
            emailLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 8)
        ])
    }
}
Controller - ProfileViewController.swift
class ProfileViewController: UIViewController {
    private let profileView = ProfileView()
    private var user: User?
    
    override func loadView() {
        view = profileView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        loadUserData()
    }
    
    private func loadUserData() {
        // ์‹ค์ œ๋กœ๋Š” ๋„คํŠธ์›Œํฌ๋‚˜ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฐ€์ ธ์˜ด
        user = User(
            name: "๊น€๊ฐœ๋ฐœ",
            email: "dev@example.com",
            profileImageURL: nil
        )
        
        updateUI()
    }
    
    private func updateUI() {
        guard let user = user else { return }
        
        profileView.nameLabel.text = user.name
        profileView.emailLabel.text = user.email
        
        if let imageURL = user.profileImageURL {
            // ์ด๋ฏธ์ง€ ๋กœ๋”ฉ ๋กœ์ง
            loadProfileImage(from: imageURL)
        } else {
            profileView.profileImageView.image = UIImage(systemName: "person.circle.fill")
        }
        
        // ์ด๋ฉ”์ผ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
        if !user.isValidEmail() {
            showInvalidEmailAlert()
        }
    }
    
    private func loadProfileImage(from urlString: String) {
        // ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ ๋ฐ ์บ์‹ฑ ๋กœ์ง
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let data = data, let image = UIImage(data: data) else { return }
            
            DispatchQueue.main.async {
                self?.profileView.profileImageView.image = image
            }
        }.resume()
    }
    
    private func showInvalidEmailAlert() {
        let alert = UIAlertController(
            title: "์•Œ๋ฆผ",
            message: "์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฉ”์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค.",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default))
        present(alert, animated: true)
    }
}

โš ๏ธ MVC์˜ ๋ฌธ์ œ์ : Massive View Controller

์œ„ ์˜ˆ์ œ๋ฅผ ๋ณด๋ฉด ์•Œ ์ˆ˜ ์žˆ๋“ฏ์ด, Controller์— ๋„ˆ๋ฌด ๋งŽ์€ ์ฑ…์ž„์ด ์ง‘์ค‘๋˜๋Š” ๊ฒฝํ–ฅ์ด ์žˆ์–ด. View์˜ ์ƒ๋ช…์ฃผ๊ธฐ ๊ด€๋ฆฌ, ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ, UI ์—…๋ฐ์ดํŠธ, ๋„คํŠธ์›Œํฌ ํ†ต์‹ , ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋“ฑ ๋ชจ๋“  ๊ฒŒ ViewController์— ๋“ค์–ด๊ฐ€๊ฒŒ ๋ผ. ๐Ÿ˜“

์‹ค์ œ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ViewController๊ฐ€ ์ˆ˜๋ฐฑ, ์ˆ˜์ฒœ ์ค„์ด ๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ํ”ํ•ด. ์ด๋ฅผ ๊ฐœ๋ฐœ์ž๋“ค์€ ๋†๋‹ด ์‚ผ์•„ "Massive View Controller"๋ผ๊ณ  ๋ถ€๋ฅด์ง€. ์ด๋Ÿฐ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๋“ฑ์žฅํ•œ ๊ฒŒ ๋ฐ”๋กœ MVVM์ด์•ผ!

๐ŸŽฏ MVVM: ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ์˜ ๋งˆ๋ฒ•

MVVM์€ Model-View-ViewModel์˜ ์•ฝ์ž๋กœ, Microsoft์—์„œ ์ œ์•ˆํ•œ ํŒจํ„ด์ด์•ผ. MVC์˜ ๋ฌธ์ œ์ ์„ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๋“ฑ์žฅํ–ˆ๋Š”๋ฐ, ํ•ต์‹ฌ์€ ViewModel์ด๋ผ๋Š” ์ƒˆ๋กœ์šด ๋ ˆ์ด์–ด๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฑฐ์•ผ. ๐ŸŒŸ

Model ๋ฐ์ดํ„ฐ + ๋กœ์ง ViewModel ํ”„๋ ˆ์  ํ…Œ์ด์…˜ ๋กœ์ง View UI ํ‘œ์‹œ ์—…๋ฐ์ดํŠธ ๋ฐ”์ธ๋”ฉ ์‚ฌ์šฉ์ž ์•ก์…˜ ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ์œผ๋กœ ์ž๋™ ๋™๊ธฐํ™”!

๐Ÿ” MVVM์˜ ๊ตฌ์„ฑ ์š”์†Œ

Model
MVC์™€ ๋™์ผํ•˜๊ฒŒ ๋ฐ์ดํ„ฐ์™€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๋‹ด๋‹นํ•ด.

View
UI๋ฅผ ํ‘œ์‹œํ•˜๊ณ  ์‚ฌ์šฉ์ž ์ž…๋ ฅ์„ ๋ฐ›์•„. ํ•˜์ง€๋งŒ MVC์™€ ๋‹ฌ๋ฆฌ ViewController๋„ View ๋ ˆ์ด์–ด์— ํฌํ•จ๋ผ. View๋Š” ViewModel์„ ๊ด€์ฐฐ(observe)ํ•˜๋ฉด์„œ ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ์„ ์ž๋™์œผ๋กœ ๋ฐ˜์˜ํ•ด.

ViewModel
์ด๊ฒŒ ํ•ต์‹ฌ์ด์•ผ! ViewModel์€ View๋ฅผ ์œ„ํ•œ Model์ด๋ผ๊ณ  ์ƒ๊ฐํ•˜๋ฉด ๋ผ. View์— ํ‘œ์‹œํ•  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€๊ณตํ•˜๊ณ , View์˜ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋ฉฐ, ์‚ฌ์šฉ์ž ์•ก์…˜์„ ์ฒ˜๋ฆฌํ•ด. ์ค‘์š”ํ•œ ๊ฑด ViewModel์€ UIKit์„ importํ•˜์ง€ ์•Š์•„์„œ View์— ๋Œ€ํ•ด ์ „ํ˜€ ๋ชจ๋ฅธ๋‹ค๋Š” ๊ฑฐ์•ผ. ์ด๊ฒŒ ํ…Œ์ŠคํŠธ๋ฅผ ์‰ฝ๊ฒŒ ๋งŒ๋“ค์–ด์ฃผ์ง€! ๐ŸŽ‰

๐Ÿ’ก ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ์ด๋ž€?

๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ์€ ViewModel์˜ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ณ€๊ฒฝ๋˜๋ฉด ์ž๋™์œผ๋กœ View๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜๋Š” ๋ฉ”์ปค๋‹ˆ์ฆ˜์ด์•ผ. Swift์—์„œ๋Š” ์—ฌ๋Ÿฌ ๋ฐฉ๋ฒ•์œผ๋กœ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด:

1. Closure ๊ธฐ๋ฐ˜ ๋ฐ”์ธ๋”ฉ - ๊ฐ€์žฅ ๊ฐ„๋‹จํ•˜๊ณ  ์ง๊ด€์ 
2. KVO (Key-Value Observing) - Objective-C ์‹œ์ ˆ๋ถ€ํ„ฐ ์žˆ๋˜ ๋ฐฉ๋ฒ•
3. Combine ํ”„๋ ˆ์ž„์›Œํฌ - Apple์˜ ๊ณต์‹ ๋ฐ˜์‘ํ˜• ํ”„๋กœ๊ทธ๋ž˜๋ฐ ํ”„๋ ˆ์ž„์›Œํฌ
4. RxSwift - ๊ฐ€์žฅ ์ธ๊ธฐ ์žˆ๋Š” ์„œ๋“œํŒŒํ‹ฐ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ

๊ฐ™์€ ํ”„๋กœํ•„ ํ™”๋ฉด์„ MVVM์œผ๋กœ ๋‹ค์‹œ ๊ตฌํ˜„ํ•ด๋ณด์ž! ์—ฌ๊ธฐ์„œ๋Š” ๊ฐ„๋‹จํ•œ Closure ๊ธฐ๋ฐ˜ ๋ฐ”์ธ๋”ฉ์„ ์‚ฌ์šฉํ• ๊ฒŒ. ๐Ÿ˜Š

Model - User.swift (๋™์ผ)
struct User {
    let name: String
    let email: String
    let profileImageURL: String?
    
    func isValidEmail() -> Bool {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegex)
        return emailPredicate.evaluate(with: email)
    }
}
ViewModel - ProfileViewModel.swift
class ProfileViewModel {
    // Observable ํŒจํ„ด์„ ์œ„ํ•œ ๊ฐ„๋‹จํ•œ ๋ฐ”์ธ๋”ฉ ํด๋ž˜์Šค
    class Observable<T> {
        var value: T {
            didSet {
                listener?(value)
            }
        }
        
        private var listener: ((T) -> Void)?
        
        init(_ value: T) {
            self.value = value
        }
        
        func bind(_ closure: @escaping (T) -> Void) {
            closure(value)
            listener = closure
        }
    }
    
    // View์— ๋ฐ”์ธ๋”ฉ๋  ๋ฐ์ดํ„ฐ
    let userName = Observable<String>("")
    let userEmail = Observable<String>("")
    let profileImage = Observable<UIImage?>(nil)
    let isEmailValid = Observable<Bool>(true)
    let isLoading = Observable<Bool>(false)
    let errorMessage = Observable<String?>(nil)
    
    private var user: User?
    
    // ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ ๋กœ๋“œ
    func loadUserData() {
        isLoading.value = true
        
        // ์‹ค์ œ๋กœ๋Š” ๋„คํŠธ์›Œํฌ๋‚˜ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ฐ€์ ธ์˜ด
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
            self?.user = User(
                name: "๊น€๊ฐœ๋ฐœ",
                email: "dev@example.com",
                profileImageURL: nil
            )
            
            self?.updateViewData()
            self?.isLoading.value = false
        }
    }
    
    private func updateViewData() {
        guard let user = user else { return }
        
        userName.value = user.name
        userEmail.value = user.email
        isEmailValid.value = user.isValidEmail()
        
        if let imageURL = user.profileImageURL {
            loadProfileImage(from: imageURL)
        } else {
            profileImage.value = UIImage(systemName: "person.circle.fill")
        }
    }
    
    private func loadProfileImage(from urlString: String) {
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            if let error = error {
                DispatchQueue.main.async {
                    self?.errorMessage.value = "์ด๋ฏธ์ง€ ๋กœ๋”ฉ ์‹คํŒจ: \(error.localizedDescription)"
                }
                return
            }
            
            guard let data = data, let image = UIImage(data: data) else { return }
            
            DispatchQueue.main.async {
                self?.profileImage.value = image
            }
        }.resume()
    }
    
    // ์‚ฌ์šฉ์ž ์•ก์…˜ ์ฒ˜๋ฆฌ
    func didTapRefreshButton() {
        loadUserData()
    }
    
    func didTapEditButton() {
        // ํŽธ์ง‘ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜๋Š” ๋กœ์ง
        // Coordinator ํŒจํ„ด๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด ๋” ๊น”๋”ํ•ด์ ธ!
    }
}
View - ProfileViewController.swift
class ProfileViewController: UIViewController {
    private let profileView = ProfileView()
    private let viewModel = ProfileViewModel()
    
    private let activityIndicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView(style: .large)
        indicator.hidesWhenStopped = true
        return indicator
    }()
    
    override func loadView() {
        view = profileView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        setupActivityIndicator()
        bindViewModel()
        viewModel.loadUserData()
    }
    
    private func setupActivityIndicator() {
        view.addSubview(activityIndicator)
        activityIndicator.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    private func bindViewModel() {
        // ์ด๋ฆ„ ๋ฐ”์ธ๋”ฉ
        viewModel.userName.bind { [weak self] name in
            self?.profileView.nameLabel.text = name
        }
        
        // ์ด๋ฉ”์ผ ๋ฐ”์ธ๋”ฉ
        viewModel.userEmail.bind { [weak self] email in
            self?.profileView.emailLabel.text = email
        }
        
        // ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ๋ฐ”์ธ๋”ฉ
        viewModel.profileImage.bind { [weak self] image in
            self?.profileView.profileImageView.image = image
        }
        
        // ์ด๋ฉ”์ผ ์œ ํšจ์„ฑ ๋ฐ”์ธ๋”ฉ
        viewModel.isEmailValid.bind { [weak self] isValid in
            if !isValid {
                self?.showInvalidEmailAlert()
            }
        }
        
        // ๋กœ๋”ฉ ์ƒํƒœ ๋ฐ”์ธ๋”ฉ
        viewModel.isLoading.bind { [weak self] isLoading in
            if isLoading {
                self?.activityIndicator.startAnimating()
            } else {
                self?.activityIndicator.stopAnimating()
            }
        }
        
        // ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋ฐ”์ธ๋”ฉ
        viewModel.errorMessage.bind { [weak self] message in
            guard let message = message else { return }
            self?.showErrorAlert(message: message)
        }
    }
    
    private func showInvalidEmailAlert() {
        let alert = UIAlertController(
            title: "์•Œ๋ฆผ",
            message: "์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฉ”์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค.",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default))
        present(alert, animated: true)
    }
    
    private func showErrorAlert(message: String) {
        let alert = UIAlertController(
            title: "์˜ค๋ฅ˜",
            message: message,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default))
        present(alert, animated: true)
    }
}

๐ŸŽจ MVC์˜ ํŠน์ง•

โœ… ๊ฐ„๋‹จํ•˜๊ณ  ์ง๊ด€์ 
โœ… Apple์˜ ๊ธฐ๋ณธ ํŒจํ„ด
โœ… ์ž‘์€ ํ”„๋กœ์ ํŠธ์— ์ ํ•ฉ
โŒ Controller๊ฐ€ ๋น„๋Œ€ํ•ด์ง
โŒ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์–ด๋ ค์›€
โŒ View์™€ ๊ฐ•ํ•˜๊ฒŒ ๊ฒฐํ•ฉ

๐Ÿš€ MVVM์˜ ํŠน์ง•

โœ… ๊ด€์‹ฌ์‚ฌ์˜ ๋ช…ํ™•ํ•œ ๋ถ„๋ฆฌ
โœ… ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์‰ฌ์›€
โœ… ์žฌ์‚ฌ์šฉ์„ฑ ๋†’์Œ
โœ… ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ์œผ๋กœ ์ž๋™ ๋™๊ธฐํ™”
โŒ ์ดˆ๊ธฐ ์„ค์ •์ด ๋ณต์žก
โŒ ์ž‘์€ ํ”„๋กœ์ ํŠธ์—” ๊ณผํ•  ์ˆ˜ ์žˆ์Œ

๐Ÿ’ก ์‹ค๋ฌด ํŒ: Combine์œผ๋กœ ๋” ๊ฐ•๋ ฅํ•˜๊ฒŒ!

iOS 13๋ถ€ํ„ฐ Apple์ด ๊ณต์‹์ ์œผ๋กœ ์ œ๊ณตํ•˜๋Š” Combine ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋” ๊ฐ•๋ ฅํ•œ ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด. ์œ„ ์˜ˆ์ œ์˜ Observable ํด๋ž˜์Šค ๋Œ€์‹  @Published ํ”„๋กœํผํ‹ฐ ๋ž˜ํผ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํ›จ์”ฌ ๊ฐ„๊ฒฐํ•ด์ ธ!

Combine์„ ์‚ฌ์šฉํ•œ ViewModel
import Combine

class ProfileViewModel: ObservableObject {
    @Published var userName: String = ""
    @Published var userEmail: String = ""
    @Published var profileImage: UIImage?
    @Published var isEmailValid: Bool = true
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?
    
    private var user: User?
    private var cancellables = Set<AnyCancellable>()
    
    func loadUserData() {
        isLoading = true
        
        // ์‹ค์ œ ๋„คํŠธ์›Œํฌ ์š”์ฒญ ์‹œ๋ฎฌ๋ ˆ์ด์…˜
        Just(User(name: "๊น€๊ฐœ๋ฐœ", email: "dev@example.com", profileImageURL: nil))
            .delay(for: 1.0, scheduler: DispatchQueue.main)
            .sink { [weak self] user in
                self?.user = user
                self?.updateViewData()
                self?.isLoading = false
            }
            .store(in: &cancellables)
    }
    
    private func updateViewData() {
        guard let user = user else { return }
        
        userName = user.name
        userEmail = user.email
        isEmailValid = user.isValidEmail()
        profileImage = UIImage(systemName: "person.circle.fill")
    }
}
Combine์„ ์‚ฌ์šฉํ•œ ViewController
import Combine

class ProfileViewController: UIViewController {
    private let profileView = ProfileView()
    private let viewModel = ProfileViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        bindViewModel()
        viewModel.loadUserData()
    }
    
    private func bindViewModel() {
        // ํ›จ์”ฌ ๊ฐ„๊ฒฐํ•œ ๋ฐ”์ธ๋”ฉ!
        viewModel.$userName
            .assign(to: \.text, on: profileView.nameLabel)
            .store(in: &cancellables)
        
        viewModel.$userEmail
            .assign(to: \.text, on: profileView.emailLabel)
            .store(in: &cancellables)
        
        viewModel.$profileImage
            .assign(to: \.image, on: profileView.profileImageView)
            .store(in: &cancellables)
        
        viewModel.$isEmailValid
            .filter { !$0 }
            .sink { [weak self] _ in
                self?.showInvalidEmailAlert()
            }
            .store(in: &cancellables)
        
        viewModel.$isLoading
            .sink { [weak self] isLoading in
                // ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ ์ฒ˜๋ฆฌ
            }
            .store(in: &cancellables)
    }
}

๋ณด๋‹ค์‹œํ”ผ Combine์„ ์‚ฌ์šฉํ•˜๋ฉด ์ฝ”๋“œ๊ฐ€ ํ›จ์”ฌ ๊ฐ„๊ฒฐํ•ด์ง€๊ณ  ์„ ์–ธ์ ์œผ๋กœ ๋ณ€ํ•ด. @Published ํ”„๋กœํผํ‹ฐ๋Š” ๊ฐ’์ด ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ์ž๋™์œผ๋กœ ๊ตฌ๋…์ž๋“ค์—๊ฒŒ ์•Œ๋ ค์ฃผ๊ณ , assign์ด๋‚˜ sink ๊ฐ™์€ ์˜คํผ๋ ˆ์ดํ„ฐ๋กœ ์‰ฝ๊ฒŒ ๋ฐ”์ธ๋”ฉํ•  ์ˆ˜ ์žˆ์–ด. ๐ŸŽฏ

๐Ÿงญ Coordinator: ํ™”๋ฉด ์ „ํ™˜์˜ ๋‹ฌ์ธ

์ž, ์ด์ œ MVC์™€ MVVM์„ ๋ฐฐ์› ์œผ๋‹ˆ ์™„๋ฒฝํ•œ ๊ฑฐ ์•„๋‹ˆ์•ผ? ๋ผ๊ณ  ์ƒ๊ฐํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์•„์ง ํ•˜๋‚˜ ๋” ๋‚จ์•˜์–ด! ๋ฐ”๋กœ ํ™”๋ฉด ์ „ํ™˜(Navigation) ๋ฌธ์ œ์•ผ. ๐Ÿšฆ

์ผ๋ฐ˜์ ์œผ๋กœ ViewController๋‚˜ ViewModel์—์„œ ๋‹ค๋ฅธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๊ฒŒ ๋˜๋Š”๋ฐ, ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ์—ฌ๋Ÿฌ ๋ฌธ์ œ๊ฐ€ ์ƒ๊ฒจ:

โŒ ๊ธฐ์กด ๋ฐฉ์‹์˜ ๋ฌธ์ œ์ 

1. ๊ฐ•ํ•œ ๊ฒฐํ•ฉ
ViewController๊ฐ€ ๋‹ค์Œ์— ๋ณด์—ฌ์ค„ ViewController๋ฅผ ์ง์ ‘ ์•Œ์•„์•ผ ํ•ด. ์ด๋Ÿฌ๋ฉด ์žฌ์‚ฌ์šฉํ•˜๊ธฐ ์–ด๋ ค์›Œ์ ธ.

2. ํ…Œ์ŠคํŠธ ์–ด๋ ค์›€
ํ™”๋ฉด ์ „ํ™˜ ๋กœ์ง์ด ViewController์— ์„ž์—ฌ ์žˆ์–ด์„œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๋ณต์žกํ•ด์ ธ.

3. ๋ณต์žกํ•œ ํ”Œ๋กœ์šฐ ๊ด€๋ฆฌ
๋กœ๊ทธ์ธ โ†’ ๋ฉ”์ธ โ†’ ์ƒ์„ธ โ†’ ํŽธ์ง‘ ๊ฐ™์€ ๋ณต์žกํ•œ ํ”Œ๋กœ์šฐ๋ฅผ ๊ด€๋ฆฌํ•˜๊ธฐ ์–ด๋ ค์›Œ.

4. Deep Link ์ฒ˜๋ฆฌ ๊ณค๋ž€
์™ธ๋ถ€์—์„œ ํŠน์ • ํ™”๋ฉด์œผ๋กœ ๋ฐ”๋กœ ์ด๋™ํ•˜๋Š” Deep Link๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ๊นŒ๋‹ค๋กœ์›Œ.

์ด๋Ÿฐ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๋“ฑ์žฅํ•œ ๊ฒŒ ๋ฐ”๋กœ Coordinator ํŒจํ„ด์ด์•ผ! Coordinator๋Š” ์•ฑ์˜ ํ™”๋ฉด ์ „ํ™˜ ๋กœ์ง์„ ์ „๋‹ดํ•˜๋Š” ๊ฐ์ฒด๋กœ, Soroush Khanlou๊ฐ€ 2015๋…„์— ์ œ์•ˆํ•œ ํŒจํ„ด์ด์•ผ. ๐ŸŽญ

App Coordinator ์ „์ฒด ํ”Œ๋กœ์šฐ ๊ด€๋ฆฌ Login Coordinator ๋กœ๊ทธ์ธ ํ”Œ๋กœ์šฐ Main Coordinator ๋ฉ”์ธ ํ”Œ๋กœ์šฐ Profile Coordinator ํ”„๋กœํ•„ ํ”Œ๋กœ์šฐ LoginVC SignUpVC HomeVC DetailVC EditVC ๊ณ„์ธต์  ํ™”๋ฉด ์ „ํ™˜ ๊ด€๋ฆฌ

๐ŸŽฏ Coordinator ํŒจํ„ด์˜ ํ•ต์‹ฌ ๊ฐœ๋…

Coordinator
ํ™”๋ฉด ์ „ํ™˜์„ ๋‹ด๋‹นํ•˜๋Š” ๊ฐ์ฒด์•ผ. ๊ฐ Coordinator๋Š” ํŠน์ • ํ”Œ๋กœ์šฐ(์˜ˆ: ๋กœ๊ทธ์ธ, ๋ฉ”์ธ, ์„ค์ • ๋“ฑ)๋ฅผ ๊ด€๋ฆฌํ•˜๊ณ , ํ•„์š”ํ•œ ViewController๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ํ‘œ์‹œํ•ด.

๊ณ„์ธต ๊ตฌ์กฐ
Coordinator๋Š” ๊ณ„์ธต ๊ตฌ์กฐ๋ฅผ ๊ฐ€์ ธ. AppCoordinator๊ฐ€ ์ตœ์ƒ์œ„์— ์žˆ๊ณ , ๊ทธ ์•„๋ž˜์— LoginCoordinator, MainCoordinator ๊ฐ™์€ ์ž์‹ Coordinator๋“ค์ด ์žˆ์–ด. ๋ถ€๋ชจ Coordinator๋Š” ์ž์‹ Coordinator์˜ ์ƒ๋ช…์ฃผ๊ธฐ๋ฅผ ๊ด€๋ฆฌํ•ด.

Delegate ํŒจํ„ด
Coordinator๋Š” Delegate ํŒจํ„ด์„ ํ†ตํ•ด ๋ถ€๋ชจ์—๊ฒŒ ํ”Œ๋กœ์šฐ ์™„๋ฃŒ๋ฅผ ์•Œ๋ ค. ์˜ˆ๋ฅผ ๋“ค์–ด LoginCoordinator๋Š” ๋กœ๊ทธ์ธ์ด ์™„๋ฃŒ๋˜๋ฉด ๋ถ€๋ชจ์ธ AppCoordinator์—๊ฒŒ ์•Œ๋ ค์ฃผ๊ณ , AppCoordinator๋Š” MainCoordinator๋ฅผ ์‹œ์ž‘ํ•ด.

์ด์ œ ์‹ค์ œ ์ฝ”๋“œ๋กœ Coordinator ํŒจํ„ด์„ ๊ตฌํ˜„ํ•ด๋ณด์ž! ๋กœ๊ทธ์ธ๋ถ€ํ„ฐ ๋ฉ”์ธ ํ™”๋ฉด๊นŒ์ง€์˜ ํ”Œ๋กœ์šฐ๋ฅผ ๋งŒ๋“ค์–ด๋ณผ๊ฒŒ. ๐Ÿ˜Š

Coordinator Protocol
protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }
    
    func start()
}

extension Coordinator {
    func addChildCoordinator(_ coordinator: Coordinator) {
        childCoordinators.append(coordinator)
    }
    
    func removeChildCoordinator(_ coordinator: Coordinator) {
        childCoordinators = childCoordinators.filter { $0 !== coordinator }
    }
}
AppCoordinator.swift
class AppCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    
    private let window: UIWindow
    
    init(window: UIWindow) {
        self.window = window
        self.navigationController = UINavigationController()
        self.navigationController.setNavigationBarHidden(true, animated: false)
    }
    
    func start() {
        window.rootViewController = navigationController
        window.makeKeyAndVisible()
        
        // ๋กœ๊ทธ์ธ ์ƒํƒœ ํ™•์ธ
        if isUserLoggedIn() {
            showMainFlow()
        } else {
            showLoginFlow()
        }
    }
    
    private func isUserLoggedIn() -> Bool {
        // ์‹ค์ œ๋กœ๋Š” UserDefaults๋‚˜ Keychain์—์„œ ํ™•์ธ
        return UserDefaults.standard.bool(forKey: "isLoggedIn")
    }
    
    private func showLoginFlow() {
        let loginCoordinator = LoginCoordinator(navigationController: navigationController)
        loginCoordinator.delegate = self
        addChildCoordinator(loginCoordinator)
        loginCoordinator.start()
    }
    
    private func showMainFlow() {
        let mainCoordinator = MainCoordinator(navigationController: navigationController)
        mainCoordinator.delegate = self
        addChildCoordinator(mainCoordinator)
        mainCoordinator.start()
    }
}

extension AppCoordinator: LoginCoordinatorDelegate {
    func loginCoordinatorDidFinish(_ coordinator: LoginCoordinator) {
        removeChildCoordinator(coordinator)
        showMainFlow()
    }
}

extension AppCoordinator: MainCoordinatorDelegate {
    func mainCoordinatorDidLogout(_ coordinator: MainCoordinator) {
        removeChildCoordinator(coordinator)
        showLoginFlow()
    }
}
LoginCoordinator.swift
protocol LoginCoordinatorDelegate: AnyObject {
    func loginCoordinatorDidFinish(_ coordinator: LoginCoordinator)
}

class LoginCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    weak var delegate: LoginCoordinatorDelegate?
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        let viewModel = LoginViewModel()
        viewModel.coordinator = self
        
        let loginVC = LoginViewController(viewModel: viewModel)
        navigationController.setViewControllers([loginVC], animated: true)
    }
    
    func showSignUp() {
        let viewModel = SignUpViewModel()
        viewModel.coordinator = self
        
        let signUpVC = SignUpViewController(viewModel: viewModel)
        navigationController.pushViewController(signUpVC, animated: true)
    }
    
    func showForgotPassword() {
        let viewModel = ForgotPasswordViewModel()
        viewModel.coordinator = self
        
        let forgotPasswordVC = ForgotPasswordViewController(viewModel: viewModel)
        navigationController.pushViewController(forgotPasswordVC, animated: true)
    }
    
    func didFinishLogin() {
        UserDefaults.standard.set(true, forKey: "isLoggedIn")
        delegate?.loginCoordinatorDidFinish(self)
    }
}

// LoginViewModel.swift
class LoginViewModel {
    weak var coordinator: LoginCoordinator?
    
    @Published var email: String = ""
    @Published var password: String = ""
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?
    
    func didTapLoginButton() {
        isLoading = true
        
        // ์‹ค์ œ ๋กœ๊ทธ์ธ API ํ˜ธ์ถœ
        AuthService.shared.login(email: email, password: password) { [weak self] result in
            self?.isLoading = false
            
            switch result {
            case .success:
                self?.coordinator?.didFinishLogin()
            case .failure(let error):
                self?.errorMessage = error.localizedDescription
            }
        }
    }
    
    func didTapSignUpButton() {
        coordinator?.showSignUp()
    }
    
    func didTapForgotPasswordButton() {
        coordinator?.showForgotPassword()
    }
}

// LoginViewController.swift
class LoginViewController: UIViewController {
    private let viewModel: LoginViewModel
    private var cancellables = Set<AnyCancellable>()
    
    // UI ์š”์†Œ๋“ค...
    private let emailTextField = UITextField()
    private let passwordTextField = UITextField()
    private let loginButton = UIButton()
    private let signUpButton = UIButton()
    
    init(viewModel: LoginViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        bindViewModel()
        setupActions()
    }
    
    private func setupActions() {
        loginButton.addTarget(self, action: #selector(didTapLogin), for: .touchUpInside)
        signUpButton.addTarget(self, action: #selector(didTapSignUp), for: .touchUpInside)
    }
    
    @objc private func didTapLogin() {
        viewModel.didTapLoginButton()
    }
    
    @objc private func didTapSignUp() {
        viewModel.didTapSignUpButton()
    }
    
    private func bindViewModel() {
        viewModel.$isLoading
            .sink { [weak self] isLoading in
                // ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ ์ฒ˜๋ฆฌ
            }
            .store(in: &cancellables)
        
        viewModel.$errorMessage
            .compactMap { $0 }
            .sink { [weak self] message in
                self?.showErrorAlert(message: message)
            }
            .store(in: &cancellables)
    }
    
    private func setupUI() {
        // UI ์„ค์ •...
    }
    
    private func showErrorAlert(message: String) {
        let alert = UIAlertController(title: "์˜ค๋ฅ˜", message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default))
        present(alert, animated: true)
    }
}
MainCoordinator.swift
protocol MainCoordinatorDelegate: AnyObject {
    func mainCoordinatorDidLogout(_ coordinator: MainCoordinator)
}

class MainCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    weak var delegate: MainCoordinatorDelegate?
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        let viewModel = HomeViewModel()
        viewModel.coordinator = self
        
        let homeVC = HomeViewController(viewModel: viewModel)
        navigationController.setViewControllers([homeVC], animated: true)
    }
    
    func showProfile() {
        let profileCoordinator = ProfileCoordinator(navigationController: navigationController)
        profileCoordinator.parentCoordinator = self
        addChildCoordinator(profileCoordinator)
        profileCoordinator.start()
    }
    
    func showDetail(itemId: String) {
        let viewModel = DetailViewModel(itemId: itemId)
        viewModel.coordinator = self
        
        let detailVC = DetailViewController(viewModel: viewModel)
        navigationController.pushViewController(detailVC, animated: true)
    }
    
    func showSettings() {
        let settingsCoordinator = SettingsCoordinator(navigationController: navigationController)
        settingsCoordinator.delegate = self
        addChildCoordinator(settingsCoordinator)
        settingsCoordinator.start()
    }
    
    func childDidFinish(_ child: Coordinator) {
        removeChildCoordinator(child)
    }
}

extension MainCoordinator: SettingsCoordinatorDelegate {
    func settingsCoordinatorDidLogout(_ coordinator: SettingsCoordinator) {
        removeChildCoordinator(coordinator)
        UserDefaults.standard.set(false, forKey: "isLoggedIn")
        delegate?.mainCoordinatorDidLogout(self)
    }
}
SceneDelegate.swift์—์„œ ์‚ฌ์šฉ
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    var appCoordinator: AppCoordinator?
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        let window = UIWindow(windowScene: windowScene)
        self.window = window
        
        appCoordinator = AppCoordinator(window: window)
        appCoordinator?.start()
    }
}

๐Ÿ’ก Coordinator ํŒจํ„ด์˜ ์žฅ์ 

1. ๊ด€์‹ฌ์‚ฌ์˜ ๋ถ„๋ฆฌ
ViewController๋Š” ํ™”๋ฉด ํ‘œ์‹œ์—๋งŒ ์ง‘์ค‘ํ•˜๊ณ , ํ™”๋ฉด ์ „ํ™˜ ๋กœ์ง์€ Coordinator๊ฐ€ ๋‹ด๋‹นํ•ด.

2. ์žฌ์‚ฌ์šฉ์„ฑ
๊ฐ™์€ ViewController๋ฅผ ๋‹ค๋ฅธ ํ”Œ๋กœ์šฐ์—์„œ๋„ ์‰ฝ๊ฒŒ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด.

3. ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ
ํ™”๋ฉด ์ „ํ™˜ ๋กœ์ง์„ ๋…๋ฆฝ์ ์œผ๋กœ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์–ด.

4. Deep Link ์ฒ˜๋ฆฌ
์™ธ๋ถ€์—์„œ ํŠน์ • ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜๋Š” ๋กœ์ง์„ Coordinator์—์„œ ์ค‘์•™ ์ง‘์ค‘์‹์œผ๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด.

5. ํ”Œ๋กœ์šฐ ์‹œ๊ฐํ™”
์•ฑ์˜ ์ „์ฒด ํ”Œ๋กœ์šฐ๋ฅผ Coordinator ๊ณ„์ธต ๊ตฌ์กฐ๋กœ ๋ช…ํ™•ํ•˜๊ฒŒ ํŒŒ์•…ํ•  ์ˆ˜ ์žˆ์–ด.

๐ŸŽจ MVVM-C: ์ตœ๊ฐ•์˜ ์กฐํ•ฉ

์ง€๊ธˆ๊นŒ์ง€ MVC, MVVM, Coordinator๋ฅผ ๊ฐ๊ฐ ๋ฐฐ์› ๋Š”๋ฐ, ์‹ค๋ฌด์—์„œ๋Š” ์ด๋“ค์„ ์กฐํ•ฉํ•ด์„œ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์•„. ํŠนํžˆ MVVM + Coordinator ์กฐํ•ฉ์€ iOS ๊ฐœ๋ฐœ์—์„œ ๊ฐ€์žฅ ์ธ๊ธฐ ์žˆ๋Š” ์•„ํ‚คํ…์ฒ˜ ์ค‘ ํ•˜๋‚˜์•ผ! ์ด๋ฅผ ์ค„์—ฌ์„œ MVVM-C๋ผ๊ณ  ๋ถ€๋ฅด์ง€. ๐ŸŒŸ

Model ๋ฐ์ดํ„ฐ ViewModel ๋กœ์ง View UI Coordinator ํ™”๋ฉด์ „ํ™˜ MVVM-C ์•„ํ‚คํ…์ฒ˜ ์™„๋ฒฝํ•œ ๊ด€์‹ฌ์‚ฌ์˜ ๋ถ„๋ฆฌ์™€ ํ™•์žฅ์„ฑ

๐ŸŽฏ MVVM-C์˜ ๊ตฌ์กฐ

Model - ๋ฐ์ดํ„ฐ์™€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง
View - UI ํ‘œ์‹œ (ViewController ํฌํ•จ)
ViewModel - ํ”„๋ ˆ์  ํ…Œ์ด์…˜ ๋กœ์ง, View๋ฅผ ์œ„ํ•œ ๋ฐ์ดํ„ฐ ๊ฐ€๊ณต
Coordinator - ํ™”๋ฉด ์ „ํ™˜ ๋กœ์ง, ํ”Œ๋กœ์šฐ ๊ด€๋ฆฌ

๊ฐ ๋ ˆ์ด์–ด๊ฐ€ ๋ช…ํ™•ํ•˜๊ฒŒ ๋ถ„๋ฆฌ๋˜์–ด ์žˆ์–ด์„œ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์‰ฝ๊ณ , ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ํŽธํ•˜๋ฉฐ, ํŒ€ ํ˜‘์—…์—๋„ ์œ ๋ฆฌํ•ด. ์˜ˆ๋ฅผ ๋“ค์–ด ํ•œ ๋ช…์€ ViewModel์„ ์ž‘์„ฑํ•˜๊ณ , ๋‹ค๋ฅธ ํ•œ ๋ช…์€ View๋ฅผ ์ž‘์„ฑํ•˜๊ณ , ๋˜ ๋‹ค๋ฅธ ํ•œ ๋ช…์€ Coordinator๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์–ด! ๐Ÿ‘ฅ

์‹ค์ œ ํ”„๋กœ์ ํŠธ์—์„œ MVVM-C๋ฅผ ์–ด๋–ป๊ฒŒ ๊ตฌ์„ฑํ•˜๋Š”์ง€ ํด๋” ๊ตฌ์กฐ๋ฅผ ๋ณด์—ฌ์ค„๊ฒŒ:

ํ”„๋กœ์ ํŠธ ํด๋” ๊ตฌ์กฐ
MyApp/
โ”œโ”€โ”€ Application/
โ”‚   โ”œโ”€โ”€ AppDelegate.swift
โ”‚   โ””โ”€โ”€ SceneDelegate.swift
โ”‚
โ”œโ”€โ”€ Coordinators/
โ”‚   โ”œโ”€โ”€ Protocols/
โ”‚   โ”‚   โ””โ”€โ”€ Coordinator.swift
โ”‚   โ”œโ”€โ”€ AppCoordinator.swift
โ”‚   โ”œโ”€โ”€ LoginCoordinator.swift
โ”‚   โ”œโ”€โ”€ MainCoordinator.swift
โ”‚   โ””โ”€โ”€ ProfileCoordinator.swift
โ”‚
โ”œโ”€โ”€ Scenes/
โ”‚   โ”œโ”€โ”€ Login/
โ”‚   โ”‚   โ”œโ”€โ”€ LoginViewController.swift
โ”‚   โ”‚   โ”œโ”€โ”€ LoginViewModel.swift
โ”‚   โ”‚   โ””โ”€โ”€ LoginView.swift
โ”‚   โ”‚
โ”‚   โ”œโ”€โ”€ Home/
โ”‚   โ”‚   โ”œโ”€โ”€ HomeViewController.swift
โ”‚   โ”‚   โ”œโ”€โ”€ HomeViewModel.swift
โ”‚   โ”‚   โ””โ”€โ”€ HomeView.swift
โ”‚   โ”‚
โ”‚   โ””โ”€โ”€ Profile/
โ”‚       โ”œโ”€โ”€ ProfileViewController.swift
โ”‚       โ”œโ”€โ”€ ProfileViewModel.swift
โ”‚       โ””โ”€โ”€ ProfileView.swift
โ”‚
โ”œโ”€โ”€ Models/
โ”‚   โ”œโ”€โ”€ User.swift
โ”‚   โ”œโ”€โ”€ Post.swift
โ”‚   โ””โ”€โ”€ Comment.swift
โ”‚
โ”œโ”€โ”€ Services/
โ”‚   โ”œโ”€โ”€ NetworkService.swift
โ”‚   โ”œโ”€โ”€ AuthService.swift
โ”‚   โ””โ”€โ”€ StorageService.swift
โ”‚
โ”œโ”€โ”€ Utilities/
โ”‚   โ”œโ”€โ”€ Extensions/
โ”‚   โ”‚   โ”œโ”€โ”€ UIView+Extensions.swift
โ”‚   โ”‚   โ””โ”€โ”€ String+Extensions.swift
โ”‚   โ””โ”€โ”€ Helpers/
โ”‚       โ”œโ”€โ”€ DateHelper.swift
โ”‚       โ””โ”€โ”€ ValidationHelper.swift
โ”‚
โ””โ”€โ”€ Resources/
    โ”œโ”€โ”€ Assets.xcassets
    โ””โ”€โ”€ Fonts/

๐Ÿ’ก ์‹ค๋ฌด ํŒ: ์˜์กด์„ฑ ์ฃผ์ž…(Dependency Injection)

MVVM-C๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ๋Š” ์˜์กด์„ฑ ์ฃผ์ž…์„ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด ๋” ์ข‹์•„. Coordinator๊ฐ€ ViewController๋ฅผ ์ƒ์„ฑํ•  ๋•Œ ํ•„์š”ํ•œ ViewModel๊ณผ ์˜์กด์„ฑ๋“ค์„ ์ฃผ์ž…ํ•ด์ฃผ๋Š” ๊ฑฐ์•ผ. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๊ฐ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋…๋ฆฝ์ ์ด ๋˜์–ด ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์‰ฌ์›Œ์ ธ!

์˜์กด์„ฑ ์ฃผ์ž… ์˜ˆ์ œ
// Service Protocol
protocol UserServiceProtocol {
    func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void)
    func updateUser(_ user: User, completion: @escaping (Result<Void, Error>) -> Void)
}

// ์‹ค์ œ ๊ตฌํ˜„
class UserService: UserServiceProtocol {
    func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
        // ๋„คํŠธ์›Œํฌ ์š”์ฒญ
    }
    
    func updateUser(_ user: User, completion: @escaping (Result<Void, Error>) -> Void) {
        // ๋„คํŠธ์›Œํฌ ์š”์ฒญ
    }
}

// Mock ๊ตฌํ˜„ (ํ…Œ์ŠคํŠธ์šฉ)
class MockUserService: UserServiceProtocol {
    var mockUser: User?
    var shouldFail: Bool = false
    
    func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
        if shouldFail {
            completion(.failure(NSError(domain: "test", code: -1)))
        } else if let user = mockUser {
            completion(.success(user))
        }
    }
    
    func updateUser(_ user: User, completion: @escaping (Result<Void, Error>) -> Void) {
        if shouldFail {
            completion(.failure(NSError(domain: "test", code: -1)))
        } else {
            completion(.success(()))
        }
    }
}

// ViewModel with Dependency Injection
class ProfileViewModel {
    weak var coordinator: ProfileCoordinator?
    private let userService: UserServiceProtocol
    
    @Published var user: User?
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?
    
    // ์˜์กด์„ฑ ์ฃผ์ž…
    init(userService: UserServiceProtocol) {
        self.userService = userService
    }
    
    func loadUser(id: String) {
        isLoading = true
        
        userService.fetchUser(id: id) { [weak self] result in
            self?.isLoading = false
            
            switch result {
            case .success(let user):
                self?.user = user
            case .failure(let error):
                self?.errorMessage = error.localizedDescription
            }
        }
    }
}

// Coordinator์—์„œ ์˜์กด์„ฑ ์ฃผ์ž…
class ProfileCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    
    private let userService: UserServiceProtocol
    
    init(navigationController: UINavigationController, userService: UserServiceProtocol) {
        self.navigationController = navigationController
        self.userService = userService
    }
    
    func start() {
        let viewModel = ProfileViewModel(userService: userService)
        viewModel.coordinator = self
        
        let profileVC = ProfileViewController(viewModel: viewModel)
        navigationController.pushViewController(profileVC, animated: true)
    }
}

// ํ…Œ์ŠคํŠธ ์ฝ”๋“œ
class ProfileViewModelTests: XCTestCase {
    var sut: ProfileViewModel!
    var mockUserService: MockUserService!
    
    override func setUp() {
        super.setUp()
        mockUserService = MockUserService()
        sut = ProfileViewModel(userService: mockUserService)
    }
    
    func testLoadUserSuccess() {
        // Given
        let expectedUser = User(name: "ํ…Œ์ŠคํŠธ", email: "test@example.com", profileImageURL: nil)
        mockUserService.mockUser = expectedUser
        
        let expectation = XCTestExpectation(description: "User loaded")
        
        var cancellables = Set<AnyCancellable>()
        sut.$user
            .dropFirst()
            .sink { user in
                XCTAssertEqual(user?.name, expectedUser.name)
                expectation.fulfill()
            }
            .store(in: &cancellables)
        
        // When
        sut.loadUser(id: "123")
        
        // Then
        wait(for: [expectation], timeout: 1.0)
    }
    
    func testLoadUserFailure() {
        // Given
        mockUserService.shouldFail = true
        
        let expectation = XCTestExpectation(description: "Error occurred")
        
        var cancellables = Set<AnyCancellable>()
        sut.$errorMessage
            .dropFirst()
            .sink { message in
                XCTAssertNotNil(message)
                expectation.fulfill()
            }
            .store(in: &cancellables)
        
        // When
        sut.loadUser(id: "123")
        
        // Then
        wait(for: [expectation], timeout: 1.0)
    }
}

๐Ÿ”„ ๋‹ค๋ฅธ ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด๋“ค

MVC, MVVM, Coordinator ์™ธ์—๋„ iOS์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋‹ค์–‘ํ•œ ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด๋“ค์ด ์žˆ์–ด. ๊ฐ๊ฐ์˜ ํŠน์ง•์„ ๊ฐ„๋‹จํžˆ ์•Œ์•„๋ณด์ž! ๐ŸŽญ

๐Ÿ›๏ธ VIPER (View-Interactor-Presenter-Entity-Router)

๊ฐ€์žฅ ๋ณต์žกํ•˜์ง€๋งŒ ๊ฐ€์žฅ ๋ช…ํ™•ํ•˜๊ฒŒ ๊ด€์‹ฌ์‚ฌ๋ฅผ ๋ถ„๋ฆฌํ•œ ํŒจํ„ด์ด์•ผ. ๊ฐ ํ™”๋ฉด๋งˆ๋‹ค 5๊ฐœ์˜ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ํ•„์š”ํ•ด์„œ ์ดˆ๊ธฐ ์„ค์ •์ด ๋ณต์žกํ•˜์ง€๋งŒ, ๋Œ€๊ทœ๋ชจ ํ”„๋กœ์ ํŠธ์—์„œ ๋น›์„ ๋ฐœํ•ด.

View - UI ํ‘œ์‹œ
Interactor - ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง
Presenter - View์™€ Interactor ์‚ฌ์ด์˜ ์ค‘์žฌ์ž
Entity - ๋ฐ์ดํ„ฐ ๋ชจ๋ธ
Router - ํ™”๋ฉด ์ „ํ™˜ (Coordinator์™€ ๋น„์Šท)

์žฅ์ : ๋งค์šฐ ๋ช…ํ™•ํ•œ ์ฑ…์ž„ ๋ถ„๋ฆฌ, ํ…Œ์ŠคํŠธ ์šฉ์ด
๋‹จ์ : ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๊ฐ€ ๋งŽ์Œ, ์ž‘์€ ํ”„๋กœ์ ํŠธ์—” ๊ณผํ•จ

VIPER ์˜ˆ์ œ (๊ฐ„๋žตํ™”)
// Entity
struct User {
    let id: String
    let name: String
    let email: String
}

// Interactor
protocol ProfileInteractorProtocol {
    func fetchUser(id: String)
}

class ProfileInteractor: ProfileInteractorProtocol {
    weak var presenter: ProfilePresenterProtocol?
    private let userService: UserServiceProtocol
    
    init(userService: UserServiceProtocol) {
        self.userService = userService
    }
    
    func fetchUser(id: String) {
        userService.fetchUser(id: id) { [weak self] result in
            switch result {
            case .success(let user):
                self?.presenter?.didFetchUser(user)
            case .failure(let error):
                self?.presenter?.didFailToFetchUser(error)
            }
        }
    }
}

// Presenter
protocol ProfilePresenterProtocol: AnyObject {
    func viewDidLoad()
    func didFetchUser(_ user: User)
    func didFailToFetchUser(_ error: Error)
    func didTapEditButton()
}

class ProfilePresenter: ProfilePresenterProtocol {
    weak var view: ProfileViewProtocol?
    var interactor: ProfileInteractorProtocol?
    var router: ProfileRouterProtocol?
    
    private let userId: String
    
    init(userId: String) {
        self.userId = userId
    }
    
    func viewDidLoad() {
        view?.showLoading()
        interactor?.fetchUser(id: userId)
    }
    
    func didFetchUser(_ user: User) {
        view?.hideLoading()
        view?.displayUser(name: user.name, email: user.email)
    }
    
    func didFailToFetchUser(_ error: Error) {
        view?.hideLoading()
        view?.showError(message: error.localizedDescription)
    }
    
    func didTapEditButton() {
        router?.navigateToEditProfile()
    }
}

// View
protocol ProfileViewProtocol: AnyObject {
    func showLoading()
    func hideLoading()
    func displayUser(name: String, email: String)
    func showError(message: String)
}

class ProfileViewController: UIViewController, ProfileViewProtocol {
    var presenter: ProfilePresenterProtocol?
    
    // UI ์š”์†Œ๋“ค...
    
    override func viewDidLoad() {
        super.viewDidLoad()
        presenter?.viewDidLoad()
    }
    
    func showLoading() {
        // ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ ํ‘œ์‹œ
    }
    
    func hideLoading() {
        // ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ ์ˆจ๊น€
    }
    
    func displayUser(name: String, email: String) {
        // UI ์—…๋ฐ์ดํŠธ
    }
    
    func showError(message: String) {
        // ์—๋Ÿฌ ์•Œ๋ฆผ ํ‘œ์‹œ
    }
}

// Router
protocol ProfileRouterProtocol {
    func navigateToEditProfile()
}

class ProfileRouter: ProfileRouterProtocol {
    weak var viewController: UIViewController?
    
    func navigateToEditProfile() {
        // ํŽธ์ง‘ ํ™”๋ฉด์œผ๋กœ ์ด๋™
    }
}

// Module Builder
class ProfileModuleBuilder {
    static func build(userId: String) -> UIViewController {
        let view = ProfileViewController()
        let presenter = ProfilePresenter(userId: userId)
        let interactor = ProfileInteractor(userService: UserService())
        let router = ProfileRouter()
        
        view.presenter = presenter
        presenter.view = view
        presenter.interactor = interactor
        presenter.router = router
        interactor.presenter = presenter
        router.viewController = view
        
        return view
    }
}

๐ŸŽฏ RIBs (Router-Interactor-Builder)

Uber์—์„œ ๊ฐœ๋ฐœํ•œ ์•„ํ‚คํ…์ฒ˜๋กœ, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ์–ด. View๊ฐ€ ์„ ํƒ์ ์ด๋ผ๋Š” ๊ฒŒ ํŠน์ง•์ด์•ผ. ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๊ฐ€์ง„ ๋Œ€๊ทœ๋ชจ ์•ฑ์— ์ ํ•ฉํ•ด.

Router - ์ž์‹ RIB ๊ด€๋ฆฌ ๋ฐ ํ™”๋ฉด ์ „ํ™˜
Interactor - ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง
Builder - RIB ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ
View - UI (์„ ํƒ์ )
Presenter - View์™€ Interactor ์—ฐ๊ฒฐ (์„ ํƒ์ )

์žฅ์ : ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ค‘์‹ฌ ์„ค๊ณ„, ๋ชจ๋“ˆํ™” ์šฐ์ˆ˜
๋‹จ์ : ํ•™์Šต ๊ณก์„ ์ด ๊ฐ€ํŒŒ๋ฆ„, ์ดˆ๊ธฐ ์„ค์ • ๋ณต์žก

๐ŸŒŠ Redux/TCA (The Composable Architecture)

๋‹จ๋ฐฉํ–ฅ ๋ฐ์ดํ„ฐ ํ”Œ๋กœ์šฐ๋ฅผ ๊ฐ€์ง„ ์•„ํ‚คํ…์ฒ˜์•ผ. Point-Free์—์„œ ๊ฐœ๋ฐœํ•œ TCA๋Š” SwiftUI์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด ์ •๋ง ๊ฐ•๋ ฅํ•ด.

State - ์•ฑ์˜ ์ „์ฒด ์ƒํƒœ
Action - ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” ์ด๋ฒคํŠธ
Reducer - Action์„ ๋ฐ›์•„ State๋ฅผ ๋ณ€๊ฒฝ
Store - State๋ฅผ ์ €์žฅํ•˜๊ณ  ๊ด€๋ฆฌ

์žฅ์ : ์˜ˆ์ธก ๊ฐ€๋Šฅํ•œ ์ƒํƒœ ๊ด€๋ฆฌ, ์‹œ๊ฐ„ ์—ฌํ–‰ ๋””๋ฒ„๊น… ๊ฐ€๋Šฅ
๋‹จ์ : ๋Ÿฌ๋‹ ์ปค๋ธŒ, ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ

TCA ๊ฐ„๋‹จ ์˜ˆ์ œ
import ComposableArchitecture

// State
struct ProfileState: Equatable {
    var user: User?
    var isLoading: Bool = false
    var errorMessage: String?
}

// Action
enum ProfileAction: Equatable {
    case onAppear
    case userResponse(Result<User, Error>)
    case editButtonTapped
}

// Environment (์˜์กด์„ฑ)
struct ProfileEnvironment {
    var userService: UserServiceProtocol
    var mainQueue: AnySchedulerOf<DispatchQueue>
}

// Reducer
let profileReducer = Reducer<ProfileState, ProfileAction, ProfileEnvironment> { state, action, environment in
    switch action {
    case .onAppear:
        state.isLoading = true
        
        return environment.userService
            .fetchUser(id: "123")
            .receive(on: environment.mainQueue)
            .catchToEffect(ProfileAction.userResponse)
        
    case .userResponse(.success(let user)):
        state.isLoading = false
        state.user = user
        return .none
        
    case .userResponse(.failure(let error)):
        state.isLoading = false
        state.errorMessage = error.localizedDescription
        return .none
        
    case .editButtonTapped:
        // ํŽธ์ง‘ ํ™”๋ฉด์œผ๋กœ ์ด๋™
        return .none
    }
}

// SwiftUI View
struct ProfileView: View {
    let store: Store<ProfileState, ProfileAction>
    
    var body: some View {
        WithViewStore(store) { viewStore in
            VStack {
                if viewStore.isLoading {
                    ProgressView()
                } else if let user = viewStore.user {
                    Text(user.name)
                        .font(.title)
                    Text(user.email)
                        .foregroundColor(.gray)
                    
                    Button("ํŽธ์ง‘") {
                        viewStore.send(.editButtonTapped)
                    }
                }
            }
            .onAppear {
                viewStore.send(.onAppear)
            }
        }
    }
}

๐Ÿค” ์–ด๋–ค ์•„ํ‚คํ…์ฒ˜๋ฅผ ์„ ํƒํ•ด์•ผ ํ• ๊นŒ?

"๊ทธ๋ž˜์„œ ๋ญ˜ ์จ์•ผ ํ•˜๋‚˜์š”?" ์ด๊ฒŒ ๊ฐ€์žฅ ๋งŽ์ด ๋ฐ›๋Š” ์งˆ๋ฌธ์ด์•ผ. ์ •๋‹ต์€... ์ƒํ™ฉ์— ๋”ฐ๋ผ ๋‹ค๋ฅด๋‹ค! ๐Ÿ˜…

๊ฐ ์•„ํ‚คํ…์ฒ˜๋Š” ์žฅ๋‹จ์ ์ด ์žˆ๊ณ , ํ”„๋กœ์ ํŠธ์˜ ๊ทœ๋ชจ, ํŒ€์˜ ๊ฒฝํ—˜, ์š”๊ตฌ์‚ฌํ•ญ์— ๋”ฐ๋ผ ์ ํ•ฉํ•œ ํŒจํ„ด์ด ๋‹ฌ๋ผ์ ธ. ๋‹ค์Œ ๊ฐ€์ด๋“œ๋ฅผ ์ฐธ๊ณ ํ•ด๋ด!

๐ŸŒฑ ์ž‘์€ ํ”„๋กœ์ ํŠธ / ํ”„๋กœํ† ํƒ€์ž…

์ถ”์ฒœ: MVC

๋น ๋ฅด๊ฒŒ ๊ฐœ๋ฐœํ•˜๊ณ  ๊ฒ€์ฆํ•ด์•ผ ํ•  ๋•Œ๋Š” MVC๊ฐ€ ์ตœ๊ณ ์•ผ. Apple์˜ ๊ธฐ๋ณธ ํŒจํ„ด์ด๋ผ ๋Ÿฌ๋‹ ์ปค๋ธŒ๋„ ๋‚ฎ๊ณ , ๊ฐ„๋‹จํ•œ ์•ฑ์—์„œ๋Š” ์ถฉ๋ถ„ํžˆ ํšจ๊ณผ์ ์ด์•ผ.

์˜ˆ์‹œ: ๊ฐœ์ธ ํ”„๋กœ์ ํŠธ, MVP, ํ•ด์ปคํ†ค

๐Ÿข ์ค‘๊ฐ„ ๊ทœ๋ชจ ํ”„๋กœ์ ํŠธ

์ถ”์ฒœ: MVVM ๋˜๋Š” MVVM-C

์œ ์ง€๋ณด์ˆ˜์™€ ํ…Œ์ŠคํŠธ๊ฐ€ ์ค‘์š”ํ•ด์ง€๋Š” ๋‹จ๊ณ„์•ผ. MVVM์œผ๋กœ ์‹œ์ž‘ํ•ด์„œ ํ™”๋ฉด์ด ๋งŽ์•„์ง€๋ฉด Coordinator๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒŒ ์ข‹์•„.

์˜ˆ์‹œ: ์Šคํƒ€ํŠธ์—… ์•ฑ, ์ค‘์†Œ๊ธฐ์—… ํ”„๋กœ์ ํŠธ

๐Ÿ—๏ธ ๋Œ€๊ทœ๋ชจ ํ”„๋กœ์ ํŠธ

์ถ”์ฒœ: VIPER ๋˜๋Š” RIBs

๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง๊ณผ ๋งŽ์€ ํŒ€์›์ด ํ˜‘์—…ํ•˜๋Š” ๊ฒฝ์šฐ ๋ช…ํ™•ํ•œ ์ฑ…์ž„ ๋ถ„๋ฆฌ๊ฐ€ ํ•„์ˆ˜์•ผ. ์ดˆ๊ธฐ ํˆฌ์ž ๋น„์šฉ์ด ํฌ์ง€๋งŒ ์žฅ๊ธฐ์ ์œผ๋กœ ์ด๋“์ด์•ผ.

์˜ˆ์‹œ: ๋Œ€๊ธฐ์—… ์•ฑ, ๊ธˆ์œต ์•ฑ

๐Ÿ“Š ์•„ํ‚คํ…์ฒ˜ ์„ ํƒ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

1. ํ”„๋กœ์ ํŠธ ๊ทœ๋ชจ
ํ™”๋ฉด์ด 10๊ฐœ ๋ฏธ๋งŒ? โ†’ MVC
ํ™”๋ฉด์ด 10-30๊ฐœ? โ†’ MVVM(-C)
ํ™”๋ฉด์ด 30๊ฐœ ์ด์ƒ? โ†’ VIPER/RIBs

2. ํŒ€ ๊ทœ๋ชจ
ํ˜ผ์ž ๋˜๋Š” 2-3๋ช…? โ†’ MVC, MVVM
5๋ช… ์ด์ƒ? โ†’ MVVM-C, VIPER

3. ํ…Œ์ŠคํŠธ ์ค‘์š”๋„
ํ…Œ์ŠคํŠธ๊ฐ€ ์ค‘์š”ํ•˜๋‹ค๋ฉด โ†’ MVVM ์ด์ƒ
ํ…Œ์ŠคํŠธ๊ฐ€ ๋œ ์ค‘์š”ํ•˜๋‹ค๋ฉด โ†’ MVC๋„ OK

4. ๊ฐœ๋ฐœ ๊ธฐ๊ฐ„
๋น ๋ฅธ ์ถœ์‹œ๊ฐ€ ์ค‘์š”? โ†’ MVC, MVVM
์žฅ๊ธฐ ํ”„๋กœ์ ํŠธ? โ†’ MVVM-C, VIPER

5. ํŒ€์˜ ๊ฒฝํ—˜
์ฃผ๋‹ˆ์–ด ๊ฐœ๋ฐœ์ž ๋งŽ์Œ? โ†’ MVC, MVVM
์‹œ๋‹ˆ์–ด ๊ฐœ๋ฐœ์ž ๋งŽ์Œ? โ†’ ์ž์œ ๋กญ๊ฒŒ ์„ ํƒ

๐Ÿ’ก ์‹ค๋ฌด ์กฐ์–ธ

์ฒ˜์Œ๋ถ€ํ„ฐ ์™„๋ฒฝํ•œ ์•„ํ‚คํ…์ฒ˜๋ฅผ ์„ ํƒํ•˜๋ ค๊ณ  ํ•˜์ง€ ๋งˆ! ํ”„๋กœ์ ํŠธ๊ฐ€ ์„ฑ์žฅํ•˜๋ฉด์„œ ์•„ํ‚คํ…์ฒ˜๋„ ์ง„ํ™”์‹œํ‚ฌ ์ˆ˜ ์žˆ์–ด. MVC๋กœ ์‹œ์ž‘ํ•ด์„œ MVVM์œผ๋กœ, ๊ทธ๋ฆฌ๊ณ  ํ•„์š”ํ•˜๋ฉด Coordinator๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ์‹์œผ๋กœ ์ ์ง„์ ์œผ๋กœ ๊ฐœ์„ ํ•˜๋Š” ๊ฒŒ ํ˜„์‹ค์ ์ด์•ผ. ๐Ÿš€

๋˜ํ•œ ์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ ๊ฒฝํ—˜ ๋งŽ์€ iOS ๊ฐœ๋ฐœ์ž๋“ค์˜ ์กฐ์–ธ์„ ๊ตฌํ•˜๋Š” ๊ฒƒ๋„ ์ข‹์€ ๋ฐฉ๋ฒ•์ด์•ผ. ์‹ค๋ฌด ๊ฒฝํ—˜์„ ๊ฐ€์ง„ ๋ฉ˜ํ† ์˜ ํ”ผ๋“œ๋ฐฑ์€ ์ •๋ง ๊ฐ’์ง€๊ฑฐ๋“ !

๐Ÿ› ๏ธ ์‹ค์ „ ํŒ๊ณผ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค

์ด๋ก ์€ ์ถฉ๋ถ„ํžˆ ๋ฐฐ์› ์œผ๋‹ˆ, ์ด์ œ ์‹ค์ „์—์„œ ์œ ์šฉํ•œ ํŒ๋“ค์„ ์•Œ์•„๋ณด์ž! ๐ŸŽฏ

1๏ธโƒฃ ํด๋” ๊ตฌ์กฐ๋ฅผ ๋ช…ํ™•ํ•˜๊ฒŒ

์•„ํ‚คํ…์ฒ˜๋ฅผ ์„ ํƒํ–ˆ๋‹ค๋ฉด ๊ทธ์— ๋งž๋Š” ํด๋” ๊ตฌ์กฐ๋ฅผ ์ผ๊ด€๋˜๊ฒŒ ์œ ์ง€ํ•ด. ํŒ€์› ๋ชจ๋‘๊ฐ€ ์–ด๋””์— ๋ฌด์—‡์ด ์žˆ๋Š”์ง€ ์‰ฝ๊ฒŒ ์ฐพ์„ ์ˆ˜ ์žˆ์–ด์•ผ ํ•ด.

Feature ๊ธฐ๋ฐ˜ ๊ตฌ์กฐ (์ถ”์ฒœ)
๊ฐ ๊ธฐ๋Šฅ๋ณ„๋กœ ํด๋”๋ฅผ ๋งŒ๋“ค๊ณ , ๊ทธ ์•ˆ์— ๊ด€๋ จ๋œ ๋ชจ๋“  ํŒŒ์ผ์„ ๋„ฃ์–ด.
์˜ˆ: Login/ ํด๋” ์•ˆ์— LoginViewController, LoginViewModel, LoginCoordinator ๋“ฑ

Layer ๊ธฐ๋ฐ˜ ๊ตฌ์กฐ
๋ ˆ์ด์–ด๋ณ„๋กœ ํด๋”๋ฅผ ๋งŒ๋“ค์–ด.
์˜ˆ: ViewControllers/, ViewModels/, Coordinators/ ๋“ฑ

2๏ธโƒฃ Protocol์„ ์ ๊ทน ํ™œ์šฉ

Protocol์„ ์‚ฌ์šฉํ•˜๋ฉด ์˜์กด์„ฑ์„ ์—ญ์ „์‹œํ‚ฌ ์ˆ˜ ์žˆ์–ด. ์ด๋Š” ํ…Œ์ŠคํŠธ์™€ ๋ชจํ‚น์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค์–ด์ค˜.

์ข‹์€ ์˜ˆ:

Protocol ํ™œ์šฉ ์˜ˆ์ œ
// Protocol ์ •์˜
protocol UserRepositoryProtocol {
    func fetchUser(id: String) async throws -> User
    func saveUser(_ user: User) async throws
}

// ์‹ค์ œ ๊ตฌํ˜„
class UserRepository: UserRepositoryProtocol {
    private let networkService: NetworkServiceProtocol
    private let cacheService: CacheServiceProtocol
    
    init(networkService: NetworkServiceProtocol, cacheService: CacheServiceProtocol) {
        self.networkService = networkService
        self.cacheService = cacheService
    }
    
    func fetchUser(id: String) async throws -> User {
        // ์บ์‹œ ํ™•์ธ
        if let cachedUser = try? await cacheService.getUser(id: id) {
            return cachedUser
        }
        
        // ๋„คํŠธ์›Œํฌ ์š”์ฒญ
        let user = try await networkService.fetchUser(id: id)
        
        // ์บ์‹œ ์ €์žฅ
        try? await cacheService.saveUser(user)
        
        return user
    }
    
    func saveUser(_ user: User) async throws {
        try await networkService.updateUser(user)
        try await cacheService.saveUser(user)
    }
}

// Mock ๊ตฌํ˜„ (ํ…Œ์ŠคํŠธ์šฉ)
class MockUserRepository: UserRepositoryProtocol {
    var mockUser: User?
    var shouldThrowError = false
    
    func fetchUser(id: String) async throws -> User {
        if shouldThrowError {
            throw NSError(domain: "test", code: -1)
        }
        return mockUser ?? User(name: "Mock", email: "mock@test.com", profileImageURL: nil)
    }
    
    func saveUser(_ user: User) async throws {
        if shouldThrowError {
            throw NSError(domain: "test", code: -1)
        }
        mockUser = user
    }
}

// ViewModel์—์„œ ์‚ฌ์šฉ
class ProfileViewModel {
    private let userRepository: UserRepositoryProtocol
    
    init(userRepository: UserRepositoryProtocol) {
        self.userRepository = userRepository
    }
    
    func loadUser(id: String) async {
        do {
            let user = try await userRepository.fetchUser(id: id)
            // UI ์—…๋ฐ์ดํŠธ
        } catch {
            // ์—๋Ÿฌ ์ฒ˜๋ฆฌ
        }
    }
}

3๏ธโƒฃ async/await๋ฅผ ํ™œ์šฉํ•œ ํ˜„๋Œ€์ ์ธ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ

iOS 13+์—์„œ๋Š” Combine์„, iOS 15+์—์„œ๋Š” async/await๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด. async/await๋Š” ์ฝ”๋“œ๋ฅผ ๋” ์ฝ๊ธฐ ์‰ฝ๊ฒŒ ๋งŒ๋“ค์–ด์ค˜!

async/await ํ™œ์šฉ
// ๊ธฐ์กด Completion Handler ๋ฐฉ์‹
func fetchUserOld(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        
        guard let data = data else {
            completion(.failure(NetworkError.noData))
            return
        }
        
        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(.success(user))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

// async/await ๋ฐฉ์‹ (ํ›จ์”ฌ ๊น”๋”!)
func fetchUser(id: String) async throws -> User {
    let (data, response) = try await URLSession.shared.data(from: url)
    
    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw NetworkError.invalidResponse
    }
    
    let user = try JSONDecoder().decode(User.self, from: data)
    return user
}

// ViewModel์—์„œ ์‚ฌ์šฉ
class ProfileViewModel {
    @Published var user: User?
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    func loadUser(id: String) {
        Task {
            isLoading = true
            defer { isLoading = false }
            
            do {
                user = try await fetchUser(id: id)
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
    
    // ์—ฌ๋Ÿฌ ๋น„๋™๊ธฐ ์ž‘์—…์„ ์ˆœ์ฐจ์ ์œผ๋กœ
    func loadUserAndPosts(id: String) async throws {
        // ์ˆœ์ฐจ ์‹คํ–‰
        let user = try await fetchUser(id: id)
        let posts = try await fetchPosts(userId: user.id)
        
        // ๋˜๋Š” ๋ณ‘๋ ฌ ์‹คํ–‰
        async let userTask = fetchUser(id: id)
        async let postsTask = fetchPosts(userId: id)
        
        let (user, posts) = try await (userTask, postsTask)
        
        // UI ์—…๋ฐ์ดํŠธ
    }
}

4๏ธโƒฃ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋ฅผ ์ฒด๊ณ„์ ์œผ๋กœ

์•ฑ์—์„œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ์—๋Ÿฌ๋ฅผ ๋ช…ํ™•ํ•˜๊ฒŒ ์ •์˜ํ•˜๊ณ  ์ฒ˜๋ฆฌํ•ด. ์‚ฌ์šฉ์ž์—๊ฒŒ ์˜๋ฏธ ์žˆ๋Š” ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ๊ฒŒ ์ค‘์š”ํ•ด!

์ฒด๊ณ„์ ์ธ ์—๋Ÿฌ ์ฒ˜๋ฆฌ
// ์—๋Ÿฌ ํƒ€์ž… ์ •์˜
enum NetworkError: LocalizedError {
    case invalidURL
    case noData
    case decodingError
    case serverError(statusCode: Int)
    case unauthorized
    case networkUnavailable
    
    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "์ž˜๋ชป๋œ URL์ž…๋‹ˆ๋‹ค."
        case .noData:
            return "๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."
        case .decodingError:
            return "๋ฐ์ดํ„ฐ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."
        case .serverError(let statusCode):
            return "์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. (์ฝ”๋“œ: \(statusCode))"
        case .unauthorized:
            return "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."
        case .networkUnavailable:
            return "๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”."
        }
    }
    
    var recoverySuggestion: String? {
        switch self {
        case .networkUnavailable:
            return "Wi-Fi ๋˜๋Š” ๋ฐ์ดํ„ฐ ์—ฐ๊ฒฐ์„ ํ™•์ธํ•˜๊ณ  ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”."
        case .unauthorized:
            return "๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”."
        case .serverError:
            return "์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”."
        default:
            return "๋ฌธ์ œ๊ฐ€ ๊ณ„์†๋˜๋ฉด ๊ณ ๊ฐ์„ผํ„ฐ์— ๋ฌธ์˜ํ•ด์ฃผ์„ธ์š”."
        }
    }
}

// ViewModel์—์„œ ์—๋Ÿฌ ์ฒ˜๋ฆฌ
class ProfileViewModel {
    @Published var alertItem: AlertItem?
    
    struct AlertItem: Identifiable {
        let id = UUID()
        let title: String
        let message: String
        let primaryButton: String
        let secondaryButton: String?
        let primaryAction: (() -> Void)?
        let secondaryAction: (() -> Void)?
    }
    
    func loadUser(id: String) async {
        do {
            let user = try await userRepository.fetchUser(id: id)
            self.user = user
        } catch let error as NetworkError {
            handleNetworkError(error)
        } catch {
            handleUnknownError(error)
        }
    }
    
    private func handleNetworkError(_ error: NetworkError) {
        switch error {
        case .unauthorized:
            alertItem = AlertItem(
                title: "๋กœ๊ทธ์ธ ํ•„์š”",
                message: error.localizedDescription,
                primaryButton: "๋กœ๊ทธ์ธ",
                secondaryButton: "์ทจ์†Œ",
                primaryAction: { [weak self] in
                    self?.coordinator?.showLogin()
                },
                secondaryAction: nil
            )
            
        case .networkUnavailable:
            alertItem = AlertItem(
                title: "๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜",
                message: error.localizedDescription,
                primaryButton: "๋‹ค์‹œ ์‹œ๋„",
                secondaryButton: "์ทจ์†Œ",
                primaryAction: { [weak self] in
                    Task {
                        await self?.loadUser(id: id)
                    }
                },
                secondaryAction: nil
            )
            
        default:
            alertItem = AlertItem(
                title: "์˜ค๋ฅ˜",
                message: error.localizedDescription,
                primaryButton: "ํ™•์ธ",
                secondaryButton: nil,
                primaryAction: nil,
                secondaryAction: nil
            )
        }
    }
    
    private func handleUnknownError(_ error: Error) {
        alertItem = AlertItem(
            title: "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜",
            message: "์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.",
            primaryButton: "ํ™•์ธ",
            secondaryButton: nil,
            primaryAction: nil,
            secondaryAction: nil
        )
    }
}

5๏ธโƒฃ ๋กœ๊น…๊ณผ ๋””๋ฒ„๊น…์„ ์œ„ํ•œ ์ค€๋น„

๊ฐœ๋ฐœ ์ค‘์—๋Š” print๋กœ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ, ์‹ค์ œ ์•ฑ์—์„œ๋Š” ์ฒด๊ณ„์ ์ธ ๋กœ๊น… ์‹œ์Šคํ…œ์ด ํ•„์š”ํ•ด. ํŠนํžˆ ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ฒ„๊ทธ๋ฅผ ์ถ”์ ํ•˜๋ ค๋ฉด ํ•„์ˆ˜์•ผ!

๋กœ๊น… ์‹œ์Šคํ…œ
import os.log

enum LogCategory: String {
    case network = "๐ŸŒ Network"
    case database = "๐Ÿ’พ Database"
    case ui = "๐ŸŽจ UI"
    case business = "๐Ÿ’ผ Business"
    case error = "โŒ Error"
}

class Logger {
    static let shared = Logger()
    
    private let subsystem = Bundle.main.bundleIdentifier ?? "com.app"
    
    private init() {}
    
    func log(_ message: String, category: LogCategory = .business, type: OSLogType = .default) {
        let log = OSLog(subsystem: subsystem, category: category.rawValue)
        os_log("%{public}@", log: log, type: type, message)
        
        #if DEBUG
        print("\(category.rawValue) \(message)")
        #endif
    }
    
    func debug(_ message: String, category: LogCategory = .business) {
        log(message, category: category, type: .debug)
    }
    
    func info(_ message: String, category: LogCategory = .business) {
        log(message, category: category, type: .info)
    }
    
    func error(_ message: String, category: LogCategory = .error) {
        log(message, category: category, type: .error)
    }
    
    func fault(_ message: String, category: LogCategory = .error) {
        log(message, category: category, type: .fault)
    }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
class ProfileViewModel {
    func loadUser(id: String) async {
        Logger.shared.info("์‚ฌ์šฉ์ž ๋กœ๋”ฉ ์‹œ์ž‘: \(id)", category: .business)
        
        do {
            let user = try await userRepository.fetchUser(id: id)
            Logger.shared.info("์‚ฌ์šฉ์ž ๋กœ๋”ฉ ์„ฑ๊ณต: \(user.name)", category: .business)
            self.user = user
        } catch {
            Logger.shared.error("์‚ฌ์šฉ์ž ๋กœ๋”ฉ ์‹คํŒจ: \(error.localizedDescription)", category: .error)
            handleError(error)
        }
    }
}

6๏ธโƒฃ ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ ์ฃผ์˜

Coordinator ํŒจํ„ด์„ ์‚ฌ์šฉํ•  ๋•Œ ํŠนํžˆ ์ฃผ์˜ํ•ด์•ผ ํ•  ๊ฒŒ ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜์•ผ. ์ˆœํ™˜ ์ฐธ์กฐ๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด weak, unowned๋ฅผ ์ ์ ˆํžˆ ์‚ฌ์šฉํ•ด์•ผ ํ•ด!

๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ ์˜ˆ์ œ
// โŒ ๋‚˜์œ ์˜ˆ - ์ˆœํ™˜ ์ฐธ์กฐ ๋ฐœ์ƒ
class ProfileViewModel {
    var coordinator: ProfileCoordinator? // strong reference
    
    func didTapButton() {
        coordinator?.showDetail() // coordinator๊ฐ€ self๋ฅผ ๊ฐ•ํ•˜๊ฒŒ ์ฐธ์กฐํ•˜๋ฉด ์ˆœํ™˜ ์ฐธ์กฐ!
    }
}

// โœ… ์ข‹์€ ์˜ˆ - weak ์‚ฌ์šฉ
class ProfileViewModel {
    weak var coordinator: ProfileCoordinator? // weak reference
    
    func didTapButton() {
        coordinator?.showDetail()
    }
}

// Closure์—์„œ๋„ ์ฃผ์˜!
class ProfileViewModel {
    var onComplete: (() -> Void)?
    
    func loadData() {
        // โŒ ๋‚˜์œ ์˜ˆ
        networkService.fetch { data in
            self.processData(data) // self๋ฅผ ๊ฐ•ํ•˜๊ฒŒ ์บก์ฒ˜
            self.onComplete?()
        }
        
        // โœ… ์ข‹์€ ์˜ˆ
        networkService.fetch { [weak self] data in
            guard let self = self else { return }
            self.processData(data)
            self.onComplete?()
        }
    }
}

// Combine์—์„œ๋„ ์ฃผ์˜!
class ProfileViewModel {
    private var cancellables = Set<AnyCancellable>()
    
    func setupBindings() {
        // โŒ ๋‚˜์œ ์˜ˆ
        $userName
            .sink { name in
                self.updateUI(name) // self๋ฅผ ๊ฐ•ํ•˜๊ฒŒ ์บก์ฒ˜
            }
            .store(in: &cancellables)
        
        // โœ… ์ข‹์€ ์˜ˆ
        $userName
            .sink { [weak self] name in
                self?.updateUI(name)
            }
            .store(in: &cancellables)
    }
}

// Coordinator์—์„œ ์ž์‹ ๊ด€๋ฆฌ
class ParentCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    
    func startChildFlow() {
        let child = ChildCoordinator(navigationController: navigationController)
        child.delegate = self // child๊ฐ€ self๋ฅผ weak์œผ๋กœ ์ฐธ์กฐํ•ด์•ผ ํ•จ!
        addChildCoordinator(child)
        child.start()
    }
    
    func childDidFinish(_ child: Coordinator) {
        removeChildCoordinator(child) // ๋ฐ˜๋“œ์‹œ ์ œ๊ฑฐ!
    }
}

๐Ÿ“š ํ•™์Šต ๋กœ๋“œ๋งต๊ณผ ์ถ”๊ฐ€ ์ž๋ฃŒ

iOS ์•„ํ‚คํ…์ฒ˜๋ฅผ ๋งˆ์Šคํ„ฐํ•˜๋Š” ๊ฑด ํ•˜๋ฃจ์•„์นจ์— ๋˜๋Š” ๊ฒŒ ์•„๋‹ˆ์•ผ. ๋‹จ๊ณ„์ ์œผ๋กœ ํ•™์Šตํ•˜๊ณ  ์‹ค์ „์— ์ ์šฉํ•ด๋ณด๋Š” ๊ฒŒ ์ค‘์š”ํ•ด! ๐ŸŽ“

๐ŸŽฏ ํ•™์Šต ๋กœ๋“œ๋งต

1๋‹จ๊ณ„: ๊ธฐ์ดˆ ๋‹ค์ง€๊ธฐ (1-2๊ฐœ์›”)
โ€ข Swift ๊ธฐ๋ณธ ๋ฌธ๋ฒ• ์™„๋ฒฝํžˆ ์ตํžˆ๊ธฐ
โ€ข UIKit ๊ธฐ๋ณธ ์ปดํฌ๋„ŒํŠธ ๋‹ค๋ฃจ๊ธฐ
โ€ข MVC ํŒจํ„ด์œผ๋กœ ๊ฐ„๋‹จํ•œ ์•ฑ ๋งŒ๋“ค์–ด๋ณด๊ธฐ
โ€ข Delegate, Closure ํŒจํ„ด ์ดํ•ดํ•˜๊ธฐ

2๋‹จ๊ณ„: MVVM ์ตํžˆ๊ธฐ (2-3๊ฐœ์›”)
โ€ข ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ ๊ฐœ๋… ์ดํ•ด
โ€ข Closure ๊ธฐ๋ฐ˜ ๋ฐ”์ธ๋”ฉ ๊ตฌํ˜„
โ€ข Combine ํ”„๋ ˆ์ž„์›Œํฌ ํ•™์Šต
โ€ข ์‹ค์ „ ํ”„๋กœ์ ํŠธ์— MVVM ์ ์šฉ

3๋‹จ๊ณ„: Coordinator ์ถ”๊ฐ€ (1-2๊ฐœ์›”)
โ€ข ํ™”๋ฉด ์ „ํ™˜ ๋กœ์ง ๋ถ„๋ฆฌํ•˜๊ธฐ
โ€ข ๊ณ„์ธต์  Coordinator ๊ตฌ์กฐ ์ดํ•ด
โ€ข Deep Link ์ฒ˜๋ฆฌ ๊ตฌํ˜„
โ€ข MVVM-C๋กœ ์ค‘๊ฐ„ ๊ทœ๋ชจ ์•ฑ ๋งŒ๋“ค๊ธฐ

4๋‹จ๊ณ„: ๊ณ ๊ธ‰ ํŒจํ„ด ํƒ๊ตฌ (์ง€์†์ )
โ€ข VIPER, RIBs ํ•™์Šต
โ€ข TCA (The Composable Architecture) ํƒ๊ตฌ
โ€ข ์˜์กด์„ฑ ์ฃผ์ž… ํŒจํ„ด ๋งˆ์Šคํ„ฐ
โ€ข ๋Œ€๊ทœ๋ชจ ํ”„๋กœ์ ํŠธ ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„

๐Ÿ’ก ํ•™์Šต ํŒ

1. ์ž‘์€ ํ”„๋กœ์ ํŠธ๋ถ€ํ„ฐ ์‹œ์ž‘
์ฒ˜์Œ๋ถ€ํ„ฐ ๋ณต์žกํ•œ ์•„ํ‚คํ…์ฒ˜๋ฅผ ์ ์šฉํ•˜๋ ค๊ณ  ํ•˜์ง€ ๋งˆ. ๊ฐ„๋‹จํ•œ To-Do ์•ฑ, ๋‚ ์”จ ์•ฑ ๊ฐ™์€ ์ž‘์€ ํ”„๋กœ์ ํŠธ๋กœ ์‹œ์ž‘ํ•ด์„œ ์ ์ง„์ ์œผ๋กœ ๋ณต์žก๋„๋ฅผ ๋†’์—ฌ๊ฐ€.

2. ์˜คํ”ˆ์†Œ์Šค ์ฝ”๋“œ ์ฝ๊ธฐ
GitHub์—์„œ ์ž˜ ๋งŒ๋“ค์–ด์ง„ iOS ํ”„๋กœ์ ํŠธ๋ฅผ ์ฐพ์•„์„œ ์ฝ”๋“œ๋ฅผ ์ฝ์–ด๋ด. ์‹ค๋ฌด์—์„œ ์–ด๋–ป๊ฒŒ ์•„ํ‚คํ…์ฒ˜๋ฅผ ์ ์šฉํ•˜๋Š”์ง€ ๋ฐฐ์šธ ์ˆ˜ ์žˆ์–ด.

3. ๋ฆฌํŒฉํ† ๋ง ์—ฐ์Šต
๊ธฐ์กด์— MVC๋กœ ๋งŒ๋“  ํ”„๋กœ์ ํŠธ๋ฅผ MVVM์œผ๋กœ, MVVM์„ MVVM-C๋กœ ๋ฆฌํŒฉํ† ๋งํ•ด๋ด. ๊ฐ ํŒจํ„ด์˜ ์ฐจ์ด๋ฅผ ๋ชธ์œผ๋กœ ๋А๋‚„ ์ˆ˜ ์žˆ์–ด.

4. ์ปค๋ฎค๋‹ˆํ‹ฐ ํ™œ์šฉ
์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ ๊ฒฝํ—˜ ๋งŽ์€ ๊ฐœ๋ฐœ์ž๋“ค๊ณผ ์†Œํ†ตํ•˜๊ณ , ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ๋ฐ›์•„๋ด. ํ˜ผ์ž ๊ณต๋ถ€ํ•˜๋Š” ๊ฒƒ๋ณด๋‹ค ํ›จ์”ฌ ๋น ๋ฅด๊ฒŒ ์„ฑ์žฅํ•  ์ˆ˜ ์žˆ์–ด!

๐Ÿ“– ์ถ”์ฒœ ํ•™์Šต ์ž๋ฃŒ

๊ณต์‹ ๋ฌธ์„œ
โ€ข Apple Developer Documentation
โ€ข Swift.org
โ€ข WWDC ์„ธ์…˜ ๋น„๋””์˜ค

๋ธ”๋กœ๊ทธ & ์•„ํ‹ฐํด
โ€ข Ray Wenderlich (raywenderlich.com)
โ€ข Swift by Sundell (swiftbysundell.com)
โ€ข NSHipster (nshipster.com)

์˜คํ”ˆ์†Œ์Šค ํ”„๋กœ์ ํŠธ
โ€ข Kickstarter iOS App
โ€ข Firefox iOS
โ€ข WordPress iOS

์œ ํŠœ๋ธŒ ์ฑ„๋„
โ€ข Sean Allen
โ€ข Paul Hudson (Hacking with Swift)
โ€ข Stewart Lynch

๐ŸŽฌ ๋งˆ๋ฌด๋ฆฌํ•˜๋ฉฐ

์™€! ์—ฌ๊ธฐ๊นŒ์ง€ ์ •๋ง ๊ธด ์—ฌ์ •์ด์—ˆ์–ด. ๐ŸŽ‰ iOS ๋””์ž์ธ ํŒจํ„ด๊ณผ ์•„ํ‚คํ…์ฒ˜์— ๋Œ€ํ•ด ๋งŽ์€ ๊ฑธ ๋ฐฐ์› ์ง€?

ํ•ต์‹ฌ์„ ๋‹ค์‹œ ํ•œ๋ฒˆ ์ •๋ฆฌํ•ด๋ณด์ž:

โœจ ํ•ต์‹ฌ ์š”์•ฝ

MVC - Apple์˜ ๊ธฐ๋ณธ ํŒจํ„ด, ๊ฐ„๋‹จํ•˜์ง€๋งŒ Controller๊ฐ€ ๋น„๋Œ€ํ•ด์ง€๋Š” ๋ฌธ์ œ

MVVM - ViewModel์„ ํ†ตํ•œ ๊ด€์‹ฌ์‚ฌ ๋ถ„๋ฆฌ, ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ์œผ๋กœ ์ž๋™ ๋™๊ธฐํ™”, ํ…Œ์ŠคํŠธ ์šฉ์ด

Coordinator - ํ™”๋ฉด ์ „ํ™˜ ๋กœ์ง ๋ถ„๋ฆฌ, ์žฌ์‚ฌ์šฉ์„ฑ ํ–ฅ์ƒ, Deep Link ์ฒ˜๋ฆฌ ์šฉ์ด

MVVM-C - MVVM + Coordinator ์กฐํ•ฉ, ์‹ค๋ฌด์—์„œ ๊ฐ€์žฅ ์ธ๊ธฐ ์žˆ๋Š” ํŒจํ„ด

์„ ํƒ ๊ธฐ์ค€ - ํ”„๋กœ์ ํŠธ ๊ทœ๋ชจ, ํŒ€ ํฌ๊ธฐ, ์š”๊ตฌ์‚ฌํ•ญ์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ํŒจํ„ด ์„ ํƒ

์•„ํ‚คํ…์ฒ˜๋Š” ์€ํƒ„ํ™˜(Silver Bullet)์ด ์•„๋‹ˆ์•ผ. ์™„๋ฒฝํ•œ ์•„ํ‚คํ…์ฒ˜๋Š” ์—†๊ณ , ์ƒํ™ฉ์— ๋งž๋Š” ์ตœ์„ ์˜ ์„ ํƒ๋งŒ ์žˆ์„ ๋ฟ์ด์ง€. ์ฒ˜์Œ์—” ์–ด๋ ต๊ฒŒ ๋А๊ปด์ง€๊ฒ ์ง€๋งŒ, ๊ณ„์† ์—ฐ์Šตํ•˜๊ณ  ์‹ค์ „์— ์ ์šฉํ•˜๋‹ค ๋ณด๋ฉด ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋ชธ์— ์ต๊ฒŒ ๋  ๊ฑฐ์•ผ. ๐Ÿ’ช

๊ฐ€์žฅ ์ค‘์š”ํ•œ ๊ฑด ์ผ๊ด€์„ฑ์ด์•ผ. ์–ด๋–ค ํŒจํ„ด์„ ์„ ํƒํ•˜๋“  ํŒ€ ์ „์ฒด๊ฐ€ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๊ณ , ๋ช…ํ™•ํ•œ ๊ทœ์น™์„ ๋”ฐ๋ฅด๋Š” ๊ฒŒ ์ค‘์š”ํ•ด. ๊ทธ๋ž˜์•ผ ์ฝ”๋“œ๋ฅผ ์ฝ๊ณ  ์ดํ•ดํ•˜๊ธฐ ์‰ฌ์›Œ์ง€๊ณ , ์œ ์ง€๋ณด์ˆ˜๋„ ํŽธํ•ด์ ธ.

ํ˜น์‹œ ๋ง‰ํžˆ๋Š” ๋ถ€๋ถ„์ด ์žˆ๊ฑฐ๋‚˜ ๋” ๊นŠ์ด ์žˆ๋Š” ์กฐ์–ธ์ด ํ•„์š”ํ•˜๋‹ค๋ฉด, ์žฌ๋Šฅ๋„ท์—์„œ ๊ฒฝํ—˜ ๋งŽ์€ iOS ๊ฐœ๋ฐœ์ž๋“ค์„ ์ฐพ์•„๋ณด๋Š” ๊ฒƒ๋„ ์ข‹์€ ๋ฐฉ๋ฒ•์ด์•ผ. ์‹ค๋ฌด ๊ฒฝํ—˜์„ ๋ฐ”ํƒ•์œผ๋กœ ํ•œ ๋ฉ˜ํ† ๋ง์€ ์ฑ…์ด๋‚˜ ๊ฐ•์˜์—์„œ ๋ฐฐ์šธ ์ˆ˜ ์—†๋Š” ๊ท€์ค‘ํ•œ ์ธ์‚ฌ์ดํŠธ๋ฅผ ์ œ๊ณตํ•ด์ค„ ๊ฑฐ์•ผ! ๐ŸŒŸ

์ž, ์ด์ œ ๋‹น์‹ ์˜ ์ฐจ๋ก€์•ผ! ์˜ค๋Š˜ ๋ฐฐ์šด ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ๋ฉ‹์ง„ iOS ์•ฑ์„ ๋งŒ๋“ค์–ด๋ด. ์ฒ˜์Œ์—” ์„œํˆด๋Ÿฌ๋„ ๊ดœ์ฐฎ์•„. ๋ชจ๋“  ์ „๋ฌธ๊ฐ€๋„ ์ฒ˜์Œ์—” ์ดˆ๋ณด์˜€์œผ๋‹ˆ๊นŒ. ํ™”์ดํŒ…! ๐Ÿš€

๐ŸŽจ๐Ÿ“ฑ๐Ÿ’ป

Happy Coding! ์ฆ๊ฑฐ์šด iOS ๊ฐœ๋ฐœ ๋˜์„ธ์š”!

๋Œ“๊ธ€ ์ž‘์„ฑ

์ด ๊ธ€์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ƒ๊ฐ์„ ๋“ค๋ ค์ฃผ์„ธ์š”

๋Œ“๊ธ€ 0
๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.