MVVM: 왜 ViewModel은 UI를 무시해야 하는가?

MVVM 아키텍처의 핵심 원리를 체계적으로 정리합니다. MVVM을 배우는 초보자들은 일반적으로 "결합을 해제하려면 어떻게 해야 할까?"라는 질문에 빠지기 쉬운 함정이 있습니다. 데이터 중심 접근법을 이해하면 MVVM의 본질을 파악할 수 있습니다.

MVVM의 핵심 원리: UI와 로직의 분리

WPF, Avalonia, MAUI 개발에서는 MVVM 패턴이 표준적인 구조로 자리 잡고 있습니다. 많은 개발자들이 궁금해하는 대표적 질문은 다음과 같습니다: "ViewModel에서 비즈니스 로직을 처리하는 것이 왜 Window.Show()나 버튼 상태 조절이 불가능한가요?"

이러한 제약은 소프트웨어 공학에서 최상의 목표인 분리성을 달성하기 위한 설계입니다.

  1. UI 참조 금지의 이유

ViewModel에 System.Windows.Controls 또는 Button과 같은 UI 컨트롤 타입을 직접 참조하는 경우, 다음 3가지 문제점이 발생합니다:

  1. 테스트 용이성 저하: ViewModel이 UI 요소를 직접 조작하게 되면 테스트 시 전체 UI 엔진을 실행해야 하며, 단위 테스트 수행이 어려워집니다. 이는 자동화 테스트의 효율성을 극도로 낮춥니다.

  2. 비즈니스 로직과 UI의 강한 결합: 예를 들어, ListBox를 DataGrid로 변경해야 할 때 ViewModel에 ListBox 관련 코드가 존재한다면, 비즈니스 로직 수정이 필요해집니다. 이는 개방-폐쇄 원칙(OCP)을 위반합니다.

  3. 다중 플랫폼 호환성 문제: Avalonia와 같은 현대 프레임워크는 크로스 플랫폼 지원을 제공합니다. Windows 특화 UI 로직이 포함된 ViewModel은 Linux나 Android로 전환 시 재구현이 필수적이 됩니다.

  4. UI 제어 방식의 혁신적 접근


MVVM에서는 명령형 UI 조작 대신 상태 변화를 통해 UI가 자동으로 반응하도록 설계합니다.

1. 데이터 동기화: 바인딩 기술

사례: 버튼 비활성화 및 로딩 애니메이션 제어

  • 전통적 방법: btnSave.IsEnabled = false;
  • MVVM 방식: ViewModel에서 bool IsBusy 속성을 정의하고, XAML에서 버튼의 IsEnabled 속성을 IsBusy 값에 바인딩하여 반전 처리
  • 원리: ViewModel이 IsBusy = true 값을 갱신하면, UI가 해당 값의 변화를 감지해 자동으로 비활성화 처리됨

2. 복잡한 UI 상호작용: 행동 추가(Behaviors)

사례: 스크롤 위치 조절 및 TextBox 포커스 시 전체 선택

  • 해결책: Microsoft.Xaml.Behaviors 라이브러리 사용
  • 원리: 특정 컨트롤에 대한 일반적인 '행동' 클래스를 작성하고, ViewModel의 속성 변화를 감지해 UI 레이어에서 API 호출 수행

3. 다이얼로그 및 네비게이션: 서비스 추상화

사례: 작업 완료 후 확인 다이얼로그 표시

  • 해결책: 의존성 주입(DI)을 통한 인터페이스화
  • 실현 로직:
  1. IDialogService 인터페이스 정의 (Show 메서드 포함)
  2. ViewModel 생성자에서 해당 인터페이스 주입
  3. ViewModel은 _dialogService.Show("저장 완료")만 호출
  4. Service 구현체는 Win32 창 또는 Material Design 다이얼로그 등 구체적인 UI 구현체 관리

4. 모듈 간 통신: 메시지 중개자(Messenger)

사례: A 창 버튼 클릭 시 B 창 새로고침

  • 해결책: CommunityToolkit.Mvvm의 Messenger 사용
  • 원리: ViewModel A에서 RefreshMessage 발행, View B 또는 ViewModel B가 해당 메시지 구독하여 반응 처리
  1. UI 코드 작성의 적절한 경계

많은 개발자가 MainWindow.xaml.cs 파일을 비워두어야 한다고 생각하지만, 이는 오해입니다.

원칙: 시각적 표현에 필요한 로직은 반드시 View 영역에 남겨야 합니다.

  • View 후단에서 작성 가능한 사례: 순수한 애니메이션 처리, 복잡한 마우스 트래킹, 다중 모니터 좌표 계산, 제3자 컨트롤 라이브러리 요구사항 충족
  • 판단 기준: "이 코드를 삭제해도 비즈니스 로직이 콘솔 앱에서 작동할 수 있는가?" 만약 가능하다면 이는 UI 로직으로 판단되어 View에 남겨야 합니다.
  1. 마무리: 사고방식의 전환

MVVM의 본질은 상태 기계 개념입니다.

  • View는 상태의 관찰자
  • ViewModel은 상태의 관리자
  • Model은 상태의 데이터 원천

ViewModel에서 txtUser.Text = "" 처럼 UI 직접 조작을 시도할 때는 멈추고, 어떤 속성을 정의해야 UI가 자동으로 갱신될 수 있을지 고민해야 합니다.

DI를 활용한 ViewModel에서 다이얼로그 트리거링 구현 예시

MVVM을 CommunityToolkit.Mvvm으로 구현할 때, ObservableObject와 DI 기법을 활용하여 우아한 다이얼로그 처리 방식을 구현합니다.

핵심 아이디어는: ViewModel이 요청을 제기하고, Service가 실행을 담당합니다.

1. 서비스 인터페이스 정의 (추상화 계층)

ViewModel 소속 프로젝트에서 인터페이스 정의

// 인터페이스는 ViewModel 소속 프로젝트에 정의
public interface IDialogServiceInterface
{
    // 기본 알림 창 표시
    void ShowNotification(string title, string message);
    
    // 확인 대화상자 표시 및 결과 반환
    bool RequestConfirmation(string title, string message);
}

2. UI 계층에서 서비스 구현 (구체적 구현)

UI 프로젝트 내부에서 실제 UI 연동 코드 작성

using System.Windows;

// 구현체는 UI 프로젝트 내부에 위치
public class WpfDialogServiceImpl : IDialogServiceInterface
{
    public void ShowNotification(string title, string message)
    {
        // 실제 UI 연동 코드
        MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Information);
    }

    public bool RequestConfirmation(string title, string message)
    {
        var result = MessageBox.Show(message, title, MessageBoxButton.YesNo, MessageBoxImage.Question);
        return result == MessageBoxResult.Yes;
    }
}

3. ViewModel에서 서비스 사용 (분리 계층)

CommunityToolkit.Mvvm의 특성 활용하여 생성자 주입

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class MainViewModelClass : ObservableObject
{
    // 서비스 인터페이스 선언
    private readonly IDialogServiceInterface _dialogService;

    [ObservableProperty]
    private string _userName = "게스트";

    // 생성자에서 서비스 주입 (DI 컨테이너와 연동)
    public MainViewModelClass(IDialogServiceInterface dialogService)
    {
        _dialogService = dialogService;
    }

    // RelayCommand 사용으로 비즈니스 로직 처리
    [RelayCommand]
    private void ExecuteSave()
    {
        // 1. 비즈니스 로직 처리 (데이터베이스 저장 등)
        // ... 가상 저장 성공 ...

        // 2. 다이얼로그 표시 시 인터페이스 메서드 호출
        _dialogService.ShowNotification("시스템 알림", $"사용자 {_userName} 데이터 저장 완료!");
    }

    [RelayCommand]
    private void Logout()
    {
        // 사용자 확인 요청
        bool isConfirm = _dialogService.RequestConfirmation("로그아웃 확인", "로그아웃하시겠습니까?");
        
        if (isConfirm)
        {
            UserName = "로그아웃";
        }
    }
}

4. 의존성 주입 구성 (시작 계층)

App.xaml.cs에서 서비스 등록 처리

public partial class ApplicationStartup : Application
{
    public ApplicationStartup()
    {
        Services = ConfigureServices();
    }

    public IServiceProvider Services { get; }

    private static IServiceProvider ConfigureServices()
    {
        var services = new ServiceCollection();

        // 서비스 등록: IDialogServiceInterface 요청 시 WpfDialogServiceImpl 제공
        services.AddSingleton<IDialogServiceInterface, WpfDialogServiceImpl>();

        // ViewModel 등록
        services.AddTransient<MainViewModelClass>();

        return services.BuildServiceProvider();
    }
}

왜 이런 설계가 우수한가?

  1. UI 독립성 확보: MainViewModelClass에는 System.Windows 관련 코드 전혀 없음
  2. 테스트 용이성: MockDialogServiceImpl을 작성해 실제 UI 표시 없이 호출 여부 검증 가능
  3. 플랫폼 교체 유연성: WpfDialogServiceImpl 코드만 수정하면 ViewModel 변경 필요 없음

고급: 새 창 열기 시나리오

논리 구조는 동일합니다. IDialogServiceInterface를 확장해 ShowDetailWindow(object dataContext) 메서드 추가. Service 구현체에서는 새로운 창 생성 및 DataContext 설정 처리

태그: MVVM WPF DependencyInjection CommunityToolkit MessengerPattern

6월 28일 18:08에 게시됨