클라이언트 개발에서는 왜 MVVM이 인기가 좋을까? 1편: MVVM이 나오게된 역사

Uno·2024년 11월 25일
0

MVVM

목록 보기
1/1

서론

모바일 채용공고에 "MVVM에 대한 이해" 와 같은 요구사항이 상당히 많이 있습니다. 게임개발에서도 점점 MVVM에 대한 관심이 생겨나고 있습니다. 과거에 MVVM을 배울 때, 이런 질문을 속으로 했습니다.

MVC로도 충분한데? 왜 MVVM을 써야하는 걸가?

그리고 이것에 대한 스스로 답을 내기 위해서 "Spring Framework에서 MVC는 MVVM으로 나아가지 않았을까?" 부터 "Next MVVM은 무엇일까" 까지 고민한 내용을 이글에 작성해봅니다.

서버에서의 MVC

서버 개발에서 MVC 패턴이 오랫동안 성공적으로 사용되어 온 이유를 Spring Framework를 통해 살펴보겠습니다.

1. 상태가 없는 경우

대부분의 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 사이클이 명확하게 구분되어 있어 상태 관리가 필요하지 않습니다.

2. 상태가 있는 경우

물론 서버에서도 상태를 관리해야 하는 경우가 있습니다:

@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

서버와 마찬가지로 클라이언트에서도 기본적으로 MVC Pattern을 사용했었습니다. 대표적으로 UIKit Framework에 정의된 클래스 중 하나인 UIViewController 이름을 보면 알 수 있습니다.

MVC란?

뻔한 MVC 내용입니다. 아시는 분은 SKIP

클라이언트에서 MVC 패턴은 다음과 같은 책임을 Controller에 부여합니다:
1. 상태 정의: UI에 필요한 모든 데이터 상태 관리
2. 행위 정의: 사용자 입력에 대한 처리 로직
3. UI 업데이트: 상태 변경에 따른 화면 갱신
4. 모델 업데이트: 비즈니스 로직 수행과 데이터 변경

아래 내용은 강한 주관적인 의견
위 4 가지 말고 다른 역할은 없습니다. 만약 있다면, 그 코드는 적절하지 않은 클래스에 속해있을 확률이 매우 높습니다. 이유는 Controller의 경계선을 정한건 저니까 제맘입니다.
이렇게 말하면 장난처럼 들릴 수 있습니다만, 저는 특정 객체를 분리하는 것은 "어떠한 기준으로 경계선을 정하느냐" 가 전부라고 믿습니다. 그래서 이 외 코드는 허용하지 않아야 한다고 생각합니다.

MVC 장점 & 단점

MVC 패턴의 장점과 단점은 명확합니다.
1. 전체적인 구조를 보자마자 알 수 있을정도로 단순하다.
2. 그런데 너무 많은 종류의 목적을 가진 코드가 한 곳에 있어서, 세부사항을 볼 때, 힘들다.

이걸 정리하면, "Easy to learn, Hard to master" 라고 표현하고 싶네요.히오스+ 블리자드 게임 특징

장점

  • 단순함
  • 이해하기 쉬움
  • 빠른 개발

단점

  • Massive View Controller
  • 테스트 어려움
  • 상태 관리의 복잡성

이러한 장단점때문에, 클라이언트를 처음 학습하는 단계에서는 "MVC Pattern" 으로 시작합니다. 그리고 추후에 "MVVM Pattern"으로 주로 개발하게 되죠.

각 플랫폼별 MVC 구현과 한계

Flutter / SwiftUI 는 선언형 프레임워크이다 보니, MVC를 구성하는 것이 억지스럽다고 생각했습니다. 그래서 절차형으로 구성된 UIKit Framework를 예시로 보겠습니다.

UIKit Framework로 느껴보는 Massive Controller

먼저 데이터를 표현하는 계층인 Model입니다.

// Model
struct User {
   var username: String
   var password: String
}
  • username, password 는 데이터의 형태를 모델링한 것입니다.
  • 이곳에 Validation을 포함하기도하고 Controller에 포함하기도 합니다. 이부분은 주관적인 영역입니다만, 데이터 모델은 행위가 없어야한다고 생각하면 Controller에 정의합니다.
  • 그 반대라면 Model에 정의합니다.

다음은 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 업데이트 로직
   }
}
  • ViewController에 보면, 이전에 말했던 4 가지 목적으로 코드가 정의되어 있습니다: 상태 정의, 행위 정의, UI 업데이트 그리고 Model 업데이트
  • 지금은 코드가 짧지만, UI 2개 밖에 없는 화면이 과연 얼마다 될까요. 그리고 LoginViewController는 하나지면 View가 안에 여러개 있다면?
  • 최약의 경우는, 다른 화면에서 업데이트한 상태를 가져와서 LoginView에 업데이트 해야하는 경우 입니다.

예를 들어, 사용자가 회원가입 화면에서 프로필을 생성하고 다시 로그인 화면으로 돌아왔을 때, 방금 생성한 계정 정보를 로그인 화면에 반영해야 하는 상황을 보여드리겠습니다:

// 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는 더욱 비대해지고, 상태 관리는 더욱 복잡해집니다.

클라이언트 MVC의 문제해결 방안: Presentation Model / ViewModel

지금까지 내용을 정리해보겠습니다.
1. 서버의 MVC는 단순하다: Request-Response 사이클이 명확하고 대부분 상태를 관리할 필요가 없으며, 필요한 경우에도 외부 시스템(Redis, DB)에 위임합니다.
2. 클라이언트의 MVC는 복잡하다: Controller가 상태 관리, UI 업데이트, 사용자 입력 처리, 비즈니스 로직 등 너무 많은 책임을 가지고 있어 "Massive"해집니다.
3. 화면 간 상태 공유가 MVC의 치명적 약점: 여러 화면에서 동일한 상태를 다루어야 할 때, 전역 상태(UserDefaults)나 이벤트(NotificationCenter) 같은 임시방편적 해결책을 사용하게 되어 코드의 복잡도가 급증합니다.

MVC의 문제는 너무 많은 역할을 가지고 있기 때문입니다. 특히 클라이언트에서요. 여기서 "너무 많은 역할" 은 총 4개를 뜻합니다.
그 중에서 "상태 정의"와 "행동 정의" 만 가지고 있게되면 어떻게 될까요? UI 업데이트와 모델 업데이트를 빼버리구요.

1. 마틴 파울러의 Presentation Model

마틴파울러의 글

파울러는 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();  // 상태 변경 알림
    }
  }
}
  • 지역변수를 정의하고, 그것을 업데이트하는 코드를 작성하고, 특정 로직에 대해서 관련된 상태 값을 업데이트하고 있습니다.
  • 여기서는 특정 "View" 를 참조하는 코드가 없습니다. (참조하는 코드 == 메모리 주소가 넘어온 적이 없다.)
  • 그냥 상태만 값을 변경할 뿐이죠. 아래 작성될 코드에 있겠지만, 그 모델의 값 변경을 바라보고 있다가 View에서 업데이트를 합니다.
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'),
        ),
      ],
    );
  }
}
  • View는 어쩔 수 없이 Widget을 사용해야하므로 Flutter Framework를 사용하여 작성하겠습니다.
  • _model.canLogin 이 부분이 모델의 값을 "구독" 하고 있는 것 입니다. UI Binding이죠.
  • 이 덕분에, 값이 변경되면, 알아서 업데이트가 됩니다. 물론 UI Re-rendering을 위해서 Flutter Framework의 상세 지식인 setState 호출은 필요합니다. 이 부분은 사용하는 Framework의 개별적인 특징입니다.
  • 구조를 살펴보면, View(=Widget)은 ViewModel을 초기화하고 있습니다. 그에 비해 PresentationModel은 어떤 View인지 상관할 필요가 없습니다.
  • final _model = LoginPresentationModel(); 이것을 보면 알 수 있습니다. View는 어떤 Model인지 알고 있습니다. 그것에 비해 Model은 LoginScreen인지 다른 Screen인지 알 필요가 없습니다.
  • 이 상태를 View와 ViewModel(PresentationModel)의 관계가 느슨해졌다고 표현합니다.
  • View는 알지만 ViewModel은 View를 모른다.(모른다. = 상관없다. = 다른View가 되어도 된다.)

2. 마이크로소프트의 MVVM

마이크로소프트는 WPF(Windows Presentation Foundation)에서 MVVM을 도입했습니다. MVVM은 Presentation Model에서 영감을 받았지만, WPF의 데이터 바인딩 기능을 적극 활용하도록 설계되었습니다.

즉, Framework레벨에서 데이터 바인딩을 지원하게 된 것입니다. 옵저버 패턴을 직접 구현하거나 이럴 필요 없이 말이죠.

Presentation Model과의 차이점
PresentationModel과 ViewModel을 굳이 차이를 나눌 필요는 없다고 봅니다. 정리차원에서 차이점을 적어봅니다.

PresentationModel은 아이디어에 가깝다고 생각하면 편합니다.
ViewModel은 PresentationModel을 프로젝트에 도입하기 위해 구체화된 형태로 생각하면 편합니다.

  1. 데이터 바인딩 구현

    • Presentation Model: 직접 Observer 패턴 구현 필요
    • MVVM: Framework에서 데이터 바인딩 지원
  2. 의존성 방향

    • Presentation Model: View가 Model을 직접 참조하고 구독
    • MVVM: Framework가 중간에서 View와 ViewModel을 연결
  3. Command 패턴

    • Presentation Model: 일반 메서드로 구현
    • MVVM: Framework가 제공하는 Command 객체 사용 (실행 조건, 실행 로직 캡슐화)
  4. 설계 철학

    • Presentation Model: 플랫폼 독립적 설계 지향
    • MVVM: Framework의 특성을 최대한 활용하는 설계 지향

MVVM의 핵심

MVVM에서 중요한 것은 다음 내용입니다. MS의 MVVM 내용입니다

뷰는 뷰 모델을 "알고", 뷰 모델은 모델을 "알고" 있지만 모델은 뷰 모델을 인식하지 못하고 뷰 모델은 뷰를 인식하지 못합니다. 따라서 뷰 모델은 뷰를 모델에서 분리하고 모델이 뷰와 독립적으로 진화할 수 있도록 합니다.

  • View는 ViewModel을 직접 초기화하므로 "알고" 있습니다. final viewModel = ViewModel() 이 코드가 View에 있다는 뜻입니다.
  • ViewModel은 어떤 View인지 인식하지 못합니다. 그 덕분에 ViewModel은 View에 종속되지 않습니다.

길게 이야기했습니다만, 간단히 말하면
MVC에서는 View와 Controller가 상호 의존하므로, 결합도가 매우 강합니다. 이것을 분리하기 위한 것이 MVVM 도입입니다.

정리 및 요약

MVVM에 대해 알아보는 1편은 여기까지 작성하려고 합니다. 여기까지의 내용을 요약하고 마무리 하겠습니다.

  • 서버는 Request-Response 구조로 인해 상태 관리가 단순하고, 필요시 Redis나 DB같은 외부 시스템에 위임할 수 있어 MVC 패턴만으로 충분했습니다.
  • 반면 클라이언트는 지속적인 화면 상태 관리, 여러 화면 간의 상태 공유, 복잡한 UI 업데이트가 필요했고, 이로 인해 MVC의 Controller가 너무 비대해지는 문제가 발생했습니다.
  • 이 문제를 해결하기 위해 마틴 파울러가 Presentation Model을 제안했는데, 이는 View의 상태와 행동을 별도 계층으로 분리하는 것이 핵심이었습니다.
  • 이후 마이크로소프트가 이 개념을 발전시켜 WPF에서 MVVM을 도입했는데, Framework 레벨에서 데이터 바인딩을 지원하여 더 실용적인 구현이 가능해졌습니다.

이것도 길다. 한줄요약

서버와 달리 클라이언트는 복잡한 "상태관리"가 필요하고, 여러 View에서 동일한 상태를 공유해야 했다. 이를 MVC로 구현하면 Controller가 비대해지고 상태 추적이 어려워져서, 상태와 행동을 View에서 분리한 MVVM이 대안으로 등장했다.

profile
iOS & Flutter

0개의 댓글