Ver3.0 ๐จ iOS ๋์์ธ ํจํด๊ณผ MVC, MVVM, Coordinator ์ํคํ ์ฒ ์๋ฒฝ ๊ฐ์ด๋

๐จ iOS ๋์์ธ ํจํด๊ณผ MVC, MVVM, Coordinator ์ํคํ ์ฒ ์๋ฒฝ ๊ฐ์ด๋
์น๊ตฌ์ฒ๋ผ ์ฝ๊ฒ ๋ฐฐ์ฐ๋ iOS ์ฑ ์ค๊ณ์ ๋ชจ๋ ๊ฒ
์๋ ! ๐ iOS ๊ฐ๋ฐ์ ์์ํ๋ฉด์ ๊ฐ์ฅ ๋ง์ด ๋ฃ๋ ์ง๋ฌธ ์ค ํ๋๊ฐ ๋ฐ๋ก "์ด๋ค ์ํคํ ์ฒ๋ฅผ ์ฌ์ฉํด์ผ ํ๋์?"์ผ ๊ฑฐ์ผ. MVC, MVVM, Coordinator... ์ด๋ฐ ์ฉ์ด๋ค์ด ์ฒ์์ ์ ๋ง ์ด๋ ต๊ฒ ๋๊ปด์ง์ง? ํ์ง๋ง ๊ฑฑ์ ๋ง! ์ค๋์ ์น๊ตฌ์๊ฒ ์ค๋ช ํ๋ฏ์ด ์ฝ๊ณ ์ฌ๋ฏธ์๊ฒ, ๊ทธ๋ฌ๋ฉด์๋ ์ค๋ฌด์์ ๋ฐ๋ก ์ธ ์ ์๋ ์ ํํ ๋ด์ฉ์ผ๋ก 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๋ก ๊ตฌํํด๋ณด์!
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)
}
}
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)
])
}
}
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์ด๋ผ๋ ์๋ก์ด ๋ ์ด์ด๋ฅผ ์ถ๊ฐํ๋ ๊ฑฐ์ผ. ๐
๐ 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 ๊ธฐ๋ฐ ๋ฐ์ธ๋ฉ์ ์ฌ์ฉํ ๊ฒ. ๐
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)
}
}
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 ํจํด๊ณผ ํจ๊ป ์ฌ์ฉํ๋ฉด ๋ ๊น๋ํด์ ธ!
}
}
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 ํ๋กํผํฐ ๋ํผ๋ฅผ ์ฌ์ฉํ๋ฉด ํจ์ฌ ๊ฐ๊ฒฐํด์ ธ!
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")
}
}
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๋ ์ ์ ์ํ ํจํด์ด์ผ. ๐ญ
๐ฏ Coordinator ํจํด์ ํต์ฌ ๊ฐ๋
Coordinator
ํ๋ฉด ์ ํ์ ๋ด๋นํ๋ ๊ฐ์ฒด์ผ. ๊ฐ Coordinator๋ ํน์ ํ๋ก์ฐ(์: ๋ก๊ทธ์ธ, ๋ฉ์ธ, ์ค์ ๋ฑ)๋ฅผ ๊ด๋ฆฌํ๊ณ , ํ์ํ ViewController๋ฅผ ์์ฑํ๊ณ ํ์ํด.
๊ณ์ธต ๊ตฌ์กฐ
Coordinator๋ ๊ณ์ธต ๊ตฌ์กฐ๋ฅผ ๊ฐ์ ธ. AppCoordinator๊ฐ ์ต์์์ ์๊ณ , ๊ทธ ์๋์ LoginCoordinator, MainCoordinator ๊ฐ์ ์์ Coordinator๋ค์ด ์์ด. ๋ถ๋ชจ Coordinator๋ ์์ Coordinator์ ์๋ช
์ฃผ๊ธฐ๋ฅผ ๊ด๋ฆฌํด.
Delegate ํจํด
Coordinator๋ Delegate ํจํด์ ํตํด ๋ถ๋ชจ์๊ฒ ํ๋ก์ฐ ์๋ฃ๋ฅผ ์๋ ค. ์๋ฅผ ๋ค์ด LoginCoordinator๋ ๋ก๊ทธ์ธ์ด ์๋ฃ๋๋ฉด ๋ถ๋ชจ์ธ AppCoordinator์๊ฒ ์๋ ค์ฃผ๊ณ , AppCoordinator๋ MainCoordinator๋ฅผ ์์ํด.
์ด์ ์ค์ ์ฝ๋๋ก Coordinator ํจํด์ ๊ตฌํํด๋ณด์! ๋ก๊ทธ์ธ๋ถํฐ ๋ฉ์ธ ํ๋ฉด๊น์ง์ ํ๋ก์ฐ๋ฅผ ๋ง๋ค์ด๋ณผ๊ฒ. ๐
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 }
}
}
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()
}
}
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)
}
}
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)
}
}
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๋ผ๊ณ ๋ถ๋ฅด์ง. ๐
๐ฏ 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์ ๋น์ท)
์ฅ์ : ๋งค์ฐ ๋ช
ํํ ์ฑ
์ ๋ถ๋ฆฌ, ํ
์คํธ ์ฉ์ด
๋จ์ : ๋ณด์ผ๋ฌํ๋ ์ดํธ ์ฝ๋๊ฐ ๋ง์, ์์ ํ๋ก์ ํธ์ ๊ณผํจ
// 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๋ฅผ ์ ์ฅํ๊ณ ๊ด๋ฆฌ
์ฅ์ : ์์ธก ๊ฐ๋ฅํ ์ํ ๊ด๋ฆฌ, ์๊ฐ ์ฌํ ๋๋ฒ๊น
๊ฐ๋ฅ
๋จ์ : ๋ฌ๋ ์ปค๋ธ, ๋ณด์ผ๋ฌํ๋ ์ดํธ ์ฝ๋
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 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๋ ์ฝ๋๋ฅผ ๋ ์ฝ๊ธฐ ์ฝ๊ฒ ๋ง๋ค์ด์ค!
// ๊ธฐ์กด 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
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.