모바일 채용공고에 "MVVM에 대한 이해" 와 같은 요구사항이 상당히 많이 있습니다. 게임개발에서도 점점 MVVM에 대한 관심이 생겨나고 있습니다. 과거에 MVVM을 배울 때, 이런 질문을 속으로 했습니다.
MVC로도 충분한데? 왜 MVVM을 써야하는 걸가?
그리고 이것에 대한 스스로 답을 내기 위해서 "Spring Framework에서 MVC는 MVVM으로 나아가지 않았을까?" 부터 "Next MVVM은 무엇일까" 까지 고민한 내용을 이글에 작성해봅니다.
서버 개발에서 MVC 패턴이 오랫동안 성공적으로 사용되어 온 이유를 Spring Framework를 통해 살펴보겠습니다.
대부분의 Spring Controller는 아래와 같은 형태를 가집니다:
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<User> createUser(@RequestBody UserCreateDto dto) {
User newUser = userService.create(dto);
return ResponseEntity.ok(newUser);
}
}
이 코드의 특징은:
Controller 메서드가 호출될 때마다 새로운 지역 변수들이 생성됨
메서드 실행이 완료되면 모든 지역 변수가 제거됨
다음 요청은 이전 요청과 완전히 독립적
즉, Request-Response 사이클이 명확하게 구분되어 있어 상태 관리가 필요하지 않습니다.
물론 서버에서도 상태를 관리해야 하는 경우가 있습니다:
@Controller
public class SessionController {
@GetMapping("/login")
public String login(HttpSession session, @RequestParam String username) {
// 세션에 사용자 정보 저장
session.setAttribute("user", username);
return "redirect:/home";
}
@GetMapping("/home")
public String home(HttpSession session, Model model) {
String username = (String) session.getAttribute("user");
if (username == null) {
return "redirect:/login";
}
model.addAttribute("username", username);
return "home";
}
}
하지만 이런 경우에도
세션 관리는 Spring Session이나 Redis와 같은 외부 저장소에 위임하거나
Controller는 단순히 세션 데이터를 읽고 쓰는 역할만 수행합니다.
실제 상태 저장과 관리는 Controller의 책임이 아닙니다.
이처럼 서버의 MVC에서는
Controller가 상태를 직접 관리할 필요가 없습니다.
상태 관리가 필요한 경우 외부 시스템으로 위임하는 경우가 대다수입니다.
Request-Response 사이클이 명확하게 구분되는 것이죠.
이러한 특성 때문에 서버에서는 MVC 패턴만으로도 충분한 관심사 분리가 가능하며, MVVM과 같은 추가적인 패턴이 필요하지 않게 되었습니다.
서버와 마찬가지로 클라이언트에서도 기본적으로 MVC Pattern을 사용했었습니다. 대표적으로 UIKit Framework에 정의된 클래스 중 하나인 UIViewController
이름을 보면 알 수 있습니다.
뻔한 MVC 내용입니다. 아시는 분은 SKIP
클라이언트에서 MVC 패턴은 다음과 같은 책임을 Controller에 부여합니다:
1. 상태 정의: UI에 필요한 모든 데이터 상태 관리
2. 행위 정의: 사용자 입력에 대한 처리 로직
3. UI 업데이트: 상태 변경에 따른 화면 갱신
4. 모델 업데이트: 비즈니스 로직 수행과 데이터 변경
아래 내용은 강한 주관적인 의견
위 4 가지 말고 다른 역할은 없습니다. 만약 있다면, 그 코드는 적절하지 않은 클래스에 속해있을 확률이 매우 높습니다. 이유는 Controller의 경계선을 정한건 저니까 제맘입니다.
이렇게 말하면 장난처럼 들릴 수 있습니다만, 저는 특정 객체를 분리하는 것은 "어떠한 기준으로 경계선을 정하느냐" 가 전부라고 믿습니다. 그래서 이 외 코드는 허용하지 않아야 한다고 생각합니다.
MVC 패턴의 장점과 단점은 명확합니다.
1. 전체적인 구조를 보자마자 알 수 있을정도로 단순하다.
2. 그런데 너무 많은 종류의 목적을 가진 코드가 한 곳에 있어서, 세부사항을 볼 때, 힘들다.
이걸 정리하면, "Easy to learn, Hard to master" 라고 표현하고 싶네요.히오스+ 블리자드 게임 특징
장점
단점
이러한 장단점때문에, 클라이언트를 처음 학습하는 단계에서는 "MVC Pattern" 으로 시작합니다. 그리고 추후에 "MVVM Pattern"으로 주로 개발하게 되죠.
Flutter / SwiftUI 는 선언형 프레임워크이다 보니, MVC를 구성하는 것이 억지스럽다고 생각했습니다. 그래서 절차형으로 구성된 UIKit Framework를 예시로 보겠습니다.
먼저 데이터를 표현하는 계층인 Model입니다.
// Model
struct User {
var username: String
var password: String
}
username
, password
는 데이터의 형태를 모델링한 것입니다. 다음은 Controller 입니다. 이름이 ViewController라서 혼동하기 쉽습니다만, UI는 .xib
, storyboard
에 정의되어 있고, @IBOutlet
에서 이 둘을 연결하고 있습니다.
참고로 IB는 Interface Builder로 인터페이스와 연결됨을 의미하는 단어 입니다.
// ViewController (Controller + View 관리)
class LoginViewController: UIViewController {
// UI 요소들
@IBOutlet private weak var usernameField: UITextField!
@IBOutlet private weak var loginButton: UIButton!
// 상태 관리
private var user = User(username: "", password: "")
private var isLoading = false {
didSet { updateLoadingState() }
}
// UI 이벤트 처리
@IBAction func usernameChanged(_ sender: UITextField) {
user.username = sender.text ?? ""
updateUI()
}
// 상태에 따른 UI 갱신
private func updateUI() {
loginButton.isEnabled = !user.username.isEmpty
// ... 더 많은 UI 업데이트 로직
}
}
예를 들어, 사용자가 회원가입 화면에서 프로필을 생성하고 다시 로그인 화면으로 돌아왔을 때, 방금 생성한 계정 정보를 로그인 화면에 반영해야 하는 상황을 보여드리겠습니다:
// SignUpViewController.swift
class SignUpViewController: UIViewController {
@IBOutlet private weak var usernameField: UITextField!
private var newUser: User?
@IBAction func createAccount(_ sender: Any) {
// 계정 생성 로직...
newUser = User(username: usernameField.text ?? "", password: "")
// UserDefaults에 저장
UserDefaults.standard.set(newUser?.username, forKey: "lastCreatedUsername")
// 또는 Notification 발송
NotificationCenter.default.post(
name: .didCreateNewAccount,
object: nil,
userInfo: ["username": newUser?.username ?? ""]
)
navigationController?.popViewController(animated: true)
}
}
// LoginViewController.swift
class LoginViewController: UIViewController {
@IBOutlet private weak var usernameField: UITextField!
private var user = User(username: "", password: "")
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 방법 1: UserDefaults 확인
if let lastUsername = UserDefaults.standard.string(forKey: "lastCreatedUsername") {
user.username = lastUsername
usernameField.text = lastUsername
updateUI()
}
// 방법 2: Notification 구독
NotificationCenter.default.addObserver(
self,
selector: #selector(handleNewAccount(_:)),
name: .didCreateNewAccount,
object: nil
)
}
@objc private func handleNewAccount(_ notification: Notification) {
if let username = notification.userInfo?["username"] as? String {
user.username = username
usernameField.text = username
updateUI()
}
}
}
이 코드의 문제점:
화면 간 상태 공유를 위해 전역 상태(UserDefaults) 또는 이벤트(NotificationCenter)에 의존
상태 업데이트 흐름을 추적하기 어려움
여러 화면에서 같은 상태를 다루면서 일관성 유지가 어려움
테스트가 어려움 (전역 상태 의존성)
이런 상황이 많아질수록 Controller는 더욱 비대해지고, 상태 관리는 더욱 복잡해집니다.
지금까지 내용을 정리해보겠습니다.
1. 서버의 MVC는 단순하다: Request-Response 사이클이 명확하고 대부분 상태를 관리할 필요가 없으며, 필요한 경우에도 외부 시스템(Redis, DB)에 위임합니다.
2. 클라이언트의 MVC는 복잡하다: Controller가 상태 관리, UI 업데이트, 사용자 입력 처리, 비즈니스 로직 등 너무 많은 책임을 가지고 있어 "Massive"해집니다.
3. 화면 간 상태 공유가 MVC의 치명적 약점: 여러 화면에서 동일한 상태를 다루어야 할 때, 전역 상태(UserDefaults)나 이벤트(NotificationCenter) 같은 임시방편적 해결책을 사용하게 되어 코드의 복잡도가 급증합니다.
MVC의 문제는 너무 많은 역할을 가지고 있기 때문입니다. 특히 클라이언트에서요. 여기서 "너무 많은 역할" 은 총 4개를 뜻합니다.
그 중에서 "상태 정의"와 "행동 정의" 만 가지고 있게되면 어떻게 될까요? UI 업데이트와 모델 업데이트를 빼버리구요.
파울러는 UI와 독립적으로 존재하는 뷰의 상태와 행동을 캡슐화하는 추상화 계층의 필요성을 이야기합니다.
이 말이 좀 어렵습니다: "뷰의 상태와 행동" + "캡슐화하는 추상화 계층"
뷰의 상태 는 지역변수를 정의하는 것을 말합니다.
행동 은 메서드를 말합니다.
캡슐화하는 추상화 계층 클래스 객체로 따로 만드는 것을 의미합니다. 여기서는 "Model의 실제 데이터를 View가 필요한 형태로 변화시켜주는 중간 계층" 을 의미합니다.
즉, 특정 목적에 따라서 클래스를 통해 계층을 구성하는 행위를 말합니다.
Represent the state and behavior of the presentation independently of the GUI controls used in the interface
Also Known as: Application Model, MVVM (Model-View-ViewModel)
먼저 PresentationModel을 Dart언어로 구현해보겠습니다.
(Flutter Framework 사용 안하고 작성)
// Presentation Model에 상태 변경 알림 추가
class LoginPresentationModel {
// 상태 변경 콜백 리스너
VoidCallback? _listener;
String _username = '';
bool _isLoading = false;
// getter는 동일
String get username => _username;
bool get isLoading => _isLoading;
bool get canLogin => _username.length >= 4;
// 리스너 설정
void addListener(VoidCallback listener) {
_listener = listener;
}
// 상태 변경 시 알림
void _notifyListener() {
_listener?.call();
}
void updateUsername(String newValue) {
_username = newValue;
_notifyListener(); // 상태 변경 알림
}
Future<void> login() async {
if (!canLogin) return;
_isLoading = true;
_notifyListener(); // 상태 변경 알림
try {
// 로그인 로직...
} finally {
_isLoading = false;
_notifyListener(); // 상태 변경 알림
}
}
}
class LoginScreen extends StatefulWidget {
_LoginScreenState createState() => _LoginScreenState();
}
// LoginScreen에서 상태 변경 감지 추가
class _LoginScreenState extends State<LoginScreen> {
final _model = LoginPresentationModel();
void initState() {
super.initState();
// 상태 변경 감지를 위한 리스너 등록
_model.addListener(() {
setState(() {}); // UI 갱신
});
}
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: _model.updateUsername,
),
ElevatedButton(
onPressed: _model.canLogin ? _model.login : null,
child: _model.isLoading
? CircularProgressIndicator()
: Text('Login'),
),
],
);
}
}
_model.canLogin
이 부분이 모델의 값을 "구독" 하고 있는 것 입니다. UI Binding이죠.setState
호출은 필요합니다. 이 부분은 사용하는 Framework의 개별적인 특징입니다. final _model = LoginPresentationModel();
이것을 보면 알 수 있습니다. View는 어떤 Model인지 알고 있습니다. 그것에 비해 Model은 LoginScreen인지 다른 Screen인지 알 필요가 없습니다.마이크로소프트는 WPF(Windows Presentation Foundation)에서 MVVM을 도입했습니다. MVVM은 Presentation Model에서 영감을 받았지만, WPF의 데이터 바인딩 기능을 적극 활용하도록 설계되었습니다.
즉, Framework레벨에서 데이터 바인딩을 지원하게 된 것입니다. 옵저버 패턴을 직접 구현하거나 이럴 필요 없이 말이죠.
Presentation Model과의 차이점
PresentationModel과 ViewModel을 굳이 차이를 나눌 필요는 없다고 봅니다. 정리차원에서 차이점을 적어봅니다.
PresentationModel은 아이디어에 가깝다고 생각하면 편합니다.
ViewModel은 PresentationModel을 프로젝트에 도입하기 위해 구체화된 형태로 생각하면 편합니다.
데이터 바인딩 구현
의존성 방향
Command 패턴
설계 철학
MVVM의 핵심
MVVM에서 중요한 것은 다음 내용입니다. MS의 MVVM 내용입니다
뷰는 뷰 모델을 "알고", 뷰 모델은 모델을 "알고" 있지만 모델은 뷰 모델을 인식하지 못하고 뷰 모델은 뷰를 인식하지 못합니다. 따라서 뷰 모델은 뷰를 모델에서 분리하고 모델이 뷰와 독립적으로 진화할 수 있도록 합니다.
final viewModel = ViewModel()
이 코드가 View에 있다는 뜻입니다.길게 이야기했습니다만, 간단히 말하면
MVC에서는 View와 Controller가 상호 의존하므로, 결합도가 매우 강합니다. 이것을 분리하기 위한 것이 MVVM 도입입니다.
MVVM에 대해 알아보는 1편은 여기까지 작성하려고 합니다. 여기까지의 내용을 요약하고 마무리 하겠습니다.
이것도 길다. 한줄요약
서버와 달리 클라이언트는 복잡한 "상태관리"가 필요하고, 여러 View에서 동일한 상태를 공유해야 했다. 이를 MVC로 구현하면 Controller가 비대해지고 상태 추적이 어려워져서, 상태와 행동을 View에서 분리한 MVVM이 대안으로 등장했다.